From b2105178263496c05f12da19495006750db7d8a3 Mon Sep 17 00:00:00 2001 From: Loretta Date: Sun, 12 Jul 2026 14:31:35 +0200 Subject: [PATCH] Refactor logger config, improve SignalR log writer errors - Make AcLoggerOptions.AppType nullable; resolve AppType via config or DI default parameter - Update AddAcLoggerFactory to accept host default AppType - Enhance AcSignaRClientLogItemWriter: robust error handling, dropped log tracking, single error reporting per failure state - Update logger README for new AppType precedence - Document AcSignalRDataSource threading/event model in ISSUES - Add diagnostic commands to settings.local.json --- .claude/settings.local.json | 12 +++- AyCode.Core/Loggers/AcLoggerOptions.cs | 6 +- .../Loggers/AcLoggerServiceExtensions.cs | 19 ++++-- AyCode.Core/docs/LOGGING/README.md | 10 +-- .../Loggers/AcSignaRClientLogItemWriter.cs | 66 ++++++++++++++++--- .../SIGNALR_DATASOURCE_ISSUES.md | 37 +++++++++++ 6 files changed, 130 insertions(+), 20 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a25e802..0ce687e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -152,7 +152,17 @@ "PowerShell(dotnet build AyCode.Services\\\\AyCode.Services.csproj --nologo -v q 2>$null)", "PowerShell(dotnet test AyCode.Services.Server.Tests\\\\AyCode.Services.Server.Tests.csproj --nologo --no-restore --filter \"FullyQualifiedName~AcBinaryHubProtocolConcurrencyTests\" 2>$null)", "Bash(git -C \"H:/Applications/Aycode/Source/AyCode.Blazor\" diff --stat HEAD)", - "Bash(git -C \"H:/Applications/Aycode/Source/AyCode.Blazor\" diff HEAD -- AyCode.Blazor.Components/Components/Grids/MgGridBase.cs)" + "Bash(git -C \"H:/Applications/Aycode/Source/AyCode.Blazor\" diff HEAD -- AyCode.Blazor.Components/Components/Grids/MgGridBase.cs)", + "Bash(find \"C:/Users/Fullepi/.nuget/packages/devexpress.blazor/25.1.3\" -name \"*.xml\" 2>/dev/null | head -3)", + "Bash(ls \"C:/Users/Fullepi/.nuget/packages/\" 2>/dev/null | grep -i devexpress | head -5; find \"C:/Users/Fullepi/.nuget/packages\" -maxdepth 4 -iname \"DevExpress.Blazor*.xml\" 2>/dev/null | head -3)", + "Bash(git -C \"H:/Applications/Mango/Source/NopCommerce.Common/4.70/Plugins/Nop.Plugin.Misc.AIPlugin\" rev-parse --show-toplevel)", + "Bash(git -C \"H:/Applications/Mango/Source\" rev-parse --show-toplevel)", + "Bash(git -C \"H:/Applications/Mango/Source/NopCommerce.Common/4.70/Plugins\" log --oneline -5 -- \"Nop.Plugin.Misc.AIPlugin/docs/SIGNALR/README.md\")", + "Bash(git -C \"H:/Applications/Mango/Source/NopCommerce.Common/4.70/Plugins\" show HEAD:\"Nop.Plugin.Misc.AIPlugin/docs/SIGNALR/README.md\")", + "Bash(git -C \"H:/Applications/Mango/Source/NopCommerce.Common/4.70/Plugins\" show 13e96ea:\"Nop.Plugin.Misc.AIPlugin/docs/SIGNALR/README.md\")", + "Bash(git -C \"H:/Applications/Mango/Source/NopCommerce.Common/4.70/Plugins\" restore --source 13e96ea -- \"Nop.Plugin.Misc.AIPlugin/docs/SIGNALR/README.md\")", + "Bash(git -C \"H:/Applications/Mango/Source/NopCommerce.Common/4.70/Plugins\" status --short)", + "Bash(exit 1)" ] } } diff --git a/AyCode.Core/Loggers/AcLoggerOptions.cs b/AyCode.Core/Loggers/AcLoggerOptions.cs index 153c5f2..c66e0dc 100644 --- a/AyCode.Core/Loggers/AcLoggerOptions.cs +++ b/AyCode.Core/Loggers/AcLoggerOptions.cs @@ -20,8 +20,10 @@ namespace AyCode.Core.Loggers; /// public sealed class AcLoggerOptions { - /// Application type stamped on every log entry. Default: . - public AppType AppType { get; set; } = AppType.Server; + /// Application type stamped on every log entry. null = a config nem adja meg — ilyenkor az + /// AddAcLoggerFactory(defaultAppType) kód-defaultja érvényesül (host-onként a helyes érték: Web/Mobile/Server). + /// Ha az appsettings kimondja, MINDIG a config nyer. + public AppType? AppType { get; set; } /// Global minimum log level. Entries below this level are discarded. Default: . public LogLevel LogLevel { get; set; } = LogLevel.Info; diff --git a/AyCode.Core/Loggers/AcLoggerServiceExtensions.cs b/AyCode.Core/Loggers/AcLoggerServiceExtensions.cs index 5ccf048..8337a23 100644 --- a/AyCode.Core/Loggers/AcLoggerServiceExtensions.cs +++ b/AyCode.Core/Loggers/AcLoggerServiceExtensions.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using AyCode.Core.Enums; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -23,31 +24,39 @@ public static class AcLoggerServiceExtensions /// Concrete logger type. Must expose a public constructor with signature /// (AppType, LogLevel, string?, params IAcLogWriterBase[]). /// + /// The service collection. + /// A host kód-defaultja (pl. WASM/MAUI kliensen ) — CSAK akkor + /// érvényesül, ha az appsettings () nem adja meg; ha a config kimondja, az nyer. public static IServiceCollection AddAcLoggerFactory<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TLogger>( - this IServiceCollection services) + this IServiceCollection services, AppType defaultAppType = AppType.Server) where TLogger : AcLoggerBase { - return services.AddAcLoggerFactory(); + return services.AddAcLoggerFactory(defaultAppType); } /// /// Registers a logger factory with a custom writer marker interface — e.g. a client-only marker /// to keep server-only and client-only writers separated at the DI level. /// - /// Concrete logger type. See for ctor requirements. + /// Concrete logger type. See for ctor requirements. /// Writer marker interface; must extend . + /// The service collection. + /// A host kód-defaultja — csak config-érték hiányában érvényesül (a config nyer). public static IServiceCollection AddAcLoggerFactory<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TLogger, TWriterBase>( - this IServiceCollection services) + this IServiceCollection services, AppType defaultAppType = AppType.Server) where TLogger : AcLoggerBase where TWriterBase : class, IAcLogWriterBase { services.AddSingleton>(sp => { var opts = sp.GetRequiredService>().Value; + // Prioritás: appsettings (ha megadva) → a hívó defaultAppType-ja. Így a host kódja hordozza a helyes + // alapértéket (kliensen Web/Mobile), a config pedig runtime felülírást ad újrafordítás nélkül. + var appType = opts.AppType ?? defaultAppType; return categoryName => { var writers = sp.GetServices().Cast().ToArray(); - return (TLogger)Activator.CreateInstance(typeof(TLogger), opts.AppType, opts.LogLevel, categoryName, writers)!; + return (TLogger)Activator.CreateInstance(typeof(TLogger), appType, opts.LogLevel, categoryName, writers)!; }; }); diff --git a/AyCode.Core/docs/LOGGING/README.md b/AyCode.Core/docs/LOGGING/README.md index 254272d..d782ca6 100644 --- a/AyCode.Core/docs/LOGGING/README.md +++ b/AyCode.Core/docs/LOGGING/README.md @@ -125,10 +125,12 @@ services.AddSingleton(); // Client-scoped marker variant: // services.AddSingleton(); -// 3. Register the logger factory (Func singleton in DI) -services.AddAcLoggerFactory(); +// 3. Register the logger factory (Func singleton in DI). +// The parameter is the HOST's default AppType (e.g. Web on a WASM/MAUI client) — applied only +// when appsettings does NOT specify AyCode:Logger:AppType; a config value always wins. +services.AddAcLoggerFactory(AppType.Web); // Custom writer-marker overload — pulls only TWriterBase-registered writers: -// services.AddAcLoggerFactory(); +// services.AddAcLoggerFactory(AppType.Web); ``` Consumers inject `Func` and invoke it with a category name. @@ -146,7 +148,7 @@ Consumers inject `Func` and invoke it with a category } ``` -Only `AppType` and `LogLevel` bind to `AcLoggerOptions` (defaults: `Server` / `Info`). Extend the POCO as needed; the binding picks up new properties automatically. The legacy `LogWriters[]` array (config-reading ctor) is unused in this pattern. +Only `AppType` and `LogLevel` bind to `AcLoggerOptions`. **`AppType` resolution order (2026-07):** appsettings value (if present) → the `AddAcLoggerFactory(defaultAppType)` parameter → `Server` (the parameter's own default). `AcLoggerOptions.AppType` is nullable — `null` means "not configured", so the host's code default can apply; a config value always wins. `LogLevel` default: `Info`. Extend the POCO as needed; the binding picks up new properties automatically. The legacy `LogWriters[]` array (config-reading ctor) is unused in this pattern. ### TLogger ctor requirement diff --git a/AyCode.Services/Loggers/AcSignaRClientLogItemWriter.cs b/AyCode.Services/Loggers/AcSignaRClientLogItemWriter.cs index 95d5d26..8e87fa3 100644 --- a/AyCode.Services/Loggers/AcSignaRClientLogItemWriter.cs +++ b/AyCode.Services/Loggers/AcSignaRClientLogItemWriter.cs @@ -10,16 +10,26 @@ namespace AyCode.Services.Loggers public class AcSignaRClientLogItemWriter : AcLogItemWriterBase, IAcLogWriterClientBase { private readonly HubConnection _hubConnection; + private readonly string _fullHubName; + + // A log-csatorna a SAJÁT hibáit nem logolhatja önmagán át (rekurzió) → Console.Error. + // Spam-védelem: hiba-állapotonként EGY jelentés; sikeres küldés után a kapu újraélesedik, és + // helyreálláskor kiírjuk, hány log-item veszett el. (Egyszerű mezők — a torz dupla-jelentés ártalmatlan.) + private bool _failureReported; + private long _droppedCount; public AcSignaRClientLogItemWriter(string fullHubName, AppType appType = AppType.Web, LogLevel logLevel = LogLevel.Detail, string? categoryName = null) : base(appType, logLevel, categoryName) { + _fullHubName = fullHubName; + _hubConnection = new HubConnectionBuilder() .WithUrl(fullHubName) //.WithAutomaticReconnect() //.AddMessagePackProtocol(options => options.SerializerOptions = MessagePackSerializerOptions.Standard.WithSecurity(MessagePackSecurity.UntrustedData)) .Build(); - _hubConnection.StartAsync().Forget(); + // A .Forget() logger nélkül elnyelné az induló kapcsolat hibáját → jelentő wrapper. + StartConnectionReportedAsync().Forget(); } public async Task StartConnection() @@ -42,16 +52,56 @@ namespace AyCode.Services.Loggers protected override async void WriteLogItemCallback(AcLogItemClient logItem) { - //Ez fontos, mert a signalR nem küldi a DateTime.Kind-ot! A szeró oldalon kap egy utc DateTime-ot, ami a kliens lokális DateTime-ját tartalmazza! - J. - logItem.TimeStampUtc = logItem.TimeStampUtc.ToUniversalTime(); + // Teljes try/catch: async void — egy StartAsync/SendAsync-kivétel különben kezeletlenül szökne el. + try + { + //Ez fontos, mert a signalR nem küldi a DateTime.Kind-ot! A szeró oldalon kap egy utc DateTime-ot, ami a kliens lokális DateTime-ját tartalmazza! - J. + logItem.TimeStampUtc = logItem.TimeStampUtc.ToUniversalTime(); - await StartConnection(); + await StartConnection(); - if (_hubConnection.State != HubConnectionState.Connected) - return; + if (_hubConnection.State != HubConnectionState.Connected) + { + _droppedCount++; + ReportFailureOnce($"connection not available ({_hubConnection.State}), log item DROPPED", null); + return; + } - //var refreshToken = Setting.UserBasicDetails?.RefreshToken ?? Guid.NewGuid().ToString("N"); - _hubConnection.SendAsync("AddLogItem", /*refreshToken,*/ logItem).Forget(); + //var refreshToken = Setting.UserBasicDetails?.RefreshToken ?? Guid.NewGuid().ToString("N"); + await _hubConnection.SendAsync("AddLogItem", /*refreshToken,*/ logItem); + + ReportRecoveryIfNeeded(); + } + catch (Exception ex) + { + _droppedCount++; + ReportFailureOnce("send failed, log item DROPPED", ex); + } + } + + private async Task StartConnectionReportedAsync() + { + try { await StartConnection(); } + catch (Exception ex) { ReportFailureOnce("initial connect failed", ex); } + } + + /// Hiba-állapotonként EGYSZER ír a Console.Error-ra (nem spamel) — a következő sikeres küldés élesíti újra. + private void ReportFailureOnce(string message, Exception? ex) + { + if (_failureReported) return; + _failureReported = true; + + Console.Error.WriteLine($"[{nameof(AcSignaRClientLogItemWriter)}] {message}; url: {_fullHubName}; droppedCount: {_droppedCount}{(ex == null ? string.Empty : $"; ex: {ex.GetType().Name}: {ex.Message}")}"); + } + + /// Sikeres küldésnél: ha hiba-állapotból jöttünk vissza, jelentjük a kiesés alatt elveszett logok számát. + private void ReportRecoveryIfNeeded() + { + if (!_failureReported) return; + _failureReported = false; + + Console.Error.WriteLine($"[{nameof(AcSignaRClientLogItemWriter)}] connection recovered; url: {_fullHubName}; DROPPED items while down: {_droppedCount}"); + _droppedCount = 0; } } } diff --git a/AyCode.Services/docs/SIGNALR_DATASOURCE/SIGNALR_DATASOURCE_ISSUES.md b/AyCode.Services/docs/SIGNALR_DATASOURCE/SIGNALR_DATASOURCE_ISSUES.md index b0912e6..08833ca 100644 --- a/AyCode.Services/docs/SIGNALR_DATASOURCE/SIGNALR_DATASOURCE_ISSUES.md +++ b/AyCode.Services/docs/SIGNALR_DATASOURCE/SIGNALR_DATASOURCE_ISSUES.md @@ -34,6 +34,43 @@ private Task SaveTrackingItemUnsafeAsync(TrackingItem ti) --- +## ACCORE-SIGDS-I-H3M8: DataSource mutations and events run on background threads (WASM-safe; theoretical race under Blazor Server/MAUI) + +**Severity:** Minor (theoretical — no repro; current consumers are single-threaded WASM/Hybrid) · **Status:** Open · **Area:** `AcSignalRDataSource` threading model + +> **Note:** documents accepted current behaviour — not scheduled for change. + +### Description +The DataSource does its collection work on non-UI threads: +- **Bulk loads**: `LoadDataSourceFromResponseData` → `BeginUpdate()` → `PopulateMerge` → `EndUpdate()` runs on the + SignalR receive pipeline; `EndUpdate` raises the collection Reset notification on that thread. +- **Per-item save confirms**: `ProcessSavedResponseItem` (thread-pool continuation via + `ContinueWith(TaskScheduler.Default)`, or the SignalR response callback) mutates the tracked item in place + (`CopyTo` under `lock (_syncRoot)` — no Begin/EndUpdate) and then fires `OnDataSourceItemChanged` on the same + background thread. + +Consequences for subscribers: +- `OnDataSourceLoaded` / `OnDataSourceItemChanged` / `OnSyncingStateChanged` handlers execute on the event + thread — any UI work must be marshalled by the subscriber (e.g. `InvokeAsync`). +- On single-threaded WebAssembly (the current deployment model) there is no true parallelism — safe. +- Under Blazor Server / MAUI a theoretical race exists: a bulk-load mutation on the SignalR thread can + interleave with a UI-thread enumeration of the same collection (any render), risking + `InvalidOperationException` (collection modified) or an inconsistent view. + +### Root cause +By design: the transport delivers responses on its own pipeline and the DataSource processes them where they +arrive; marshalling is left to subscribers (the DataSource has no UI dependency). + +### Known workaround +UI-layer subscribers marshal their render/refresh work via `InvokeAsync` (already the practice). A Server/MAUI +deployment would additionally need the collection mutations themselves dispatched to the UI context — see fix direction. + +### Fix direction (if ever needed) +Optional marshal delegate on the DataSource (e.g. a consumer-settable `Func` dispatcher) so +loads/merges/`CopyTo` run on the consumer's UI context under Server/MAUI. Not planned while all consumers are WASM/Hybrid. + +--- + ## ACCORE-SIGDS-I-W2H6: BinarySearch is public but throws NotImplementedException **Status:** Open