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
This commit is contained in:
Loretta 2026-07-12 14:31:35 +02:00
parent fe02550f79
commit b210517826
6 changed files with 130 additions and 20 deletions

View File

@ -152,7 +152,17 @@
"PowerShell(dotnet build AyCode.Services\\\\AyCode.Services.csproj --nologo -v q 2>$null)", "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)", "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 --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)"
] ]
} }
} }

View File

@ -20,8 +20,10 @@ namespace AyCode.Core.Loggers;
/// </summary> /// </summary>
public sealed class AcLoggerOptions public sealed class AcLoggerOptions
{ {
/// <summary>Application type stamped on every log entry. Default: <see cref="AppType.Server"/>.</summary> /// <summary>Application type stamped on every log entry. <c>null</c> = a config nem adja meg — ilyenkor az
public AppType AppType { get; set; } = AppType.Server; /// <c>AddAcLoggerFactory(defaultAppType)</c> kód-defaultja érvényesül (host-onként a helyes érték: Web/Mobile/Server).
/// Ha az appsettings kimondja, MINDIG a config nyer.</summary>
public AppType? AppType { get; set; }
/// <summary>Global minimum log level. Entries below this level are discarded. Default: <see cref="LogLevel.Info"/>.</summary> /// <summary>Global minimum log level. Entries below this level are discarded. Default: <see cref="LogLevel.Info"/>.</summary>
public LogLevel LogLevel { get; set; } = LogLevel.Info; public LogLevel LogLevel { get; set; } = LogLevel.Info;

View File

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using AyCode.Core.Enums;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -23,31 +24,39 @@ public static class AcLoggerServiceExtensions
/// Concrete logger type. Must expose a public constructor with signature /// Concrete logger type. Must expose a public constructor with signature
/// <c>(AppType, LogLevel, string?, params IAcLogWriterBase[])</c>. /// <c>(AppType, LogLevel, string?, params IAcLogWriterBase[])</c>.
/// </typeparam> /// </typeparam>
/// <param name="services">The service collection.</param>
/// <param name="defaultAppType">A host kód-defaultja (pl. WASM/MAUI kliensen <see cref="AppType.Web"/>) — CSAK akkor
/// érvényesül, ha az appsettings (<see cref="AcLoggerOptions.AppType"/>) nem adja meg; ha a config kimondja, az nyer.</param>
public static IServiceCollection AddAcLoggerFactory<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TLogger>( public static IServiceCollection AddAcLoggerFactory<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TLogger>(
this IServiceCollection services) this IServiceCollection services, AppType defaultAppType = AppType.Server)
where TLogger : AcLoggerBase where TLogger : AcLoggerBase
{ {
return services.AddAcLoggerFactory<TLogger, IAcLogWriterBase>(); return services.AddAcLoggerFactory<TLogger, IAcLogWriterBase>(defaultAppType);
} }
/// <summary> /// <summary>
/// Registers a logger factory with a custom writer marker interface — e.g. a client-only marker /// 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. /// to keep server-only and client-only writers separated at the DI level.
/// </summary> /// </summary>
/// <typeparam name="TLogger">Concrete logger type. See <see cref="AddAcLoggerFactory{TLogger}"/> for ctor requirements.</typeparam> /// <typeparam name="TLogger">Concrete logger type. See <see cref="AddAcLoggerFactory{TLogger}(IServiceCollection, AppType)"/> for ctor requirements.</typeparam>
/// <typeparam name="TWriterBase">Writer marker interface; must extend <see cref="IAcLogWriterBase"/>.</typeparam> /// <typeparam name="TWriterBase">Writer marker interface; must extend <see cref="IAcLogWriterBase"/>.</typeparam>
/// <param name="services">The service collection.</param>
/// <param name="defaultAppType">A host kód-defaultja — csak config-érték hiányában érvényesül (a config nyer).</param>
public static IServiceCollection AddAcLoggerFactory<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TLogger, TWriterBase>( public static IServiceCollection AddAcLoggerFactory<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TLogger, TWriterBase>(
this IServiceCollection services) this IServiceCollection services, AppType defaultAppType = AppType.Server)
where TLogger : AcLoggerBase where TLogger : AcLoggerBase
where TWriterBase : class, IAcLogWriterBase where TWriterBase : class, IAcLogWriterBase
{ {
services.AddSingleton<Func<string, TLogger>>(sp => services.AddSingleton<Func<string, TLogger>>(sp =>
{ {
var opts = sp.GetRequiredService<IOptions<AcLoggerOptions>>().Value; var opts = sp.GetRequiredService<IOptions<AcLoggerOptions>>().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 => return categoryName =>
{ {
var writers = sp.GetServices<TWriterBase>().Cast<IAcLogWriterBase>().ToArray(); var writers = sp.GetServices<TWriterBase>().Cast<IAcLogWriterBase>().ToArray();
return (TLogger)Activator.CreateInstance(typeof(TLogger), opts.AppType, opts.LogLevel, categoryName, writers)!; return (TLogger)Activator.CreateInstance(typeof(TLogger), appType, opts.LogLevel, categoryName, writers)!;
}; };
}); });

File diff suppressed because one or more lines are too long

View File

@ -10,16 +10,26 @@ namespace AyCode.Services.Loggers
public class AcSignaRClientLogItemWriter : AcLogItemWriterBase<AcLogItemClient>, IAcLogWriterClientBase public class AcSignaRClientLogItemWriter : AcLogItemWriterBase<AcLogItemClient>, IAcLogWriterClientBase
{ {
private readonly HubConnection _hubConnection; 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) public AcSignaRClientLogItemWriter(string fullHubName, AppType appType = AppType.Web, LogLevel logLevel = LogLevel.Detail, string? categoryName = null) : base(appType, logLevel, categoryName)
{ {
_fullHubName = fullHubName;
_hubConnection = new HubConnectionBuilder() _hubConnection = new HubConnectionBuilder()
.WithUrl(fullHubName) .WithUrl(fullHubName)
//.WithAutomaticReconnect() //.WithAutomaticReconnect()
//.AddMessagePackProtocol(options => options.SerializerOptions = MessagePackSerializerOptions.Standard.WithSecurity(MessagePackSecurity.UntrustedData)) //.AddMessagePackProtocol(options => options.SerializerOptions = MessagePackSerializerOptions.Standard.WithSecurity(MessagePackSecurity.UntrustedData))
.Build(); .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() public async Task StartConnection()
@ -42,16 +52,56 @@ namespace AyCode.Services.Loggers
protected override async void WriteLogItemCallback(AcLogItemClient logItem) 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. // Teljes try/catch: async void — egy StartAsync/SendAsync-kivétel különben kezeletlenül szökne el.
logItem.TimeStampUtc = logItem.TimeStampUtc.ToUniversalTime(); 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) if (_hubConnection.State != HubConnectionState.Connected)
return; {
_droppedCount++;
ReportFailureOnce($"connection not available ({_hubConnection.State}), log item DROPPED", null);
return;
}
//var refreshToken = Setting.UserBasicDetails?.RefreshToken ?? Guid.NewGuid().ToString("N"); //var refreshToken = Setting.UserBasicDetails?.RefreshToken ?? Guid.NewGuid().ToString("N");
_hubConnection.SendAsync("AddLogItem", /*refreshToken,*/ logItem).Forget(); 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); }
}
/// <summary>Hiba-állapotonként EGYSZER ír a Console.Error-ra (nem spamel) — a következő sikeres küldés élesíti újra.</summary>
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}")}");
}
/// <summary>Sikeres küldésnél: ha hiba-állapotból jöttünk vissza, jelentjük a kiesés alatt elveszett logok számát.</summary>
private void ReportRecoveryIfNeeded()
{
if (!_failureReported) return;
_failureReported = false;
Console.Error.WriteLine($"[{nameof(AcSignaRClientLogItemWriter)}] connection recovered; url: {_fullHubName}; DROPPED items while down: {_droppedCount}");
_droppedCount = 0;
} }
} }
} }

View File

@ -34,6 +34,43 @@ private Task SaveTrackingItemUnsafeAsync(TrackingItem<TDataItem, TId> 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<Action, Task>` 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 ## ACCORE-SIGDS-I-W2H6: BinarySearch is public but throws NotImplementedException
**Status:** Open **Status:** Open