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 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)"
]
}
}

View File

@ -20,8 +20,10 @@ namespace AyCode.Core.Loggers;
/// </summary>
public sealed class AcLoggerOptions
{
/// <summary>Application type stamped on every log entry. Default: <see cref="AppType.Server"/>.</summary>
public AppType AppType { get; set; } = AppType.Server;
/// <summary>Application type stamped on every log entry. <c>null</c> = a config nem adja meg — ilyenkor az
/// <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>
public LogLevel LogLevel { get; set; } = LogLevel.Info;

View File

@ -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
/// <c>(AppType, LogLevel, string?, params IAcLogWriterBase[])</c>.
/// </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>(
this IServiceCollection services)
this IServiceCollection services, AppType defaultAppType = AppType.Server)
where TLogger : AcLoggerBase
{
return services.AddAcLoggerFactory<TLogger, IAcLogWriterBase>();
return services.AddAcLoggerFactory<TLogger, IAcLogWriterBase>(defaultAppType);
}
/// <summary>
/// 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.
/// </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>
/// <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>(
this IServiceCollection services)
this IServiceCollection services, AppType defaultAppType = AppType.Server)
where TLogger : AcLoggerBase
where TWriterBase : class, IAcLogWriterBase
{
services.AddSingleton<Func<string, TLogger>>(sp =>
{
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 =>
{
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
{
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()
@ -41,6 +51,9 @@ namespace AyCode.Services.Loggers
=> WriteLogItem(CreateLogItem(DateTime.UtcNow, appType, Environment.CurrentManagedThreadId, logLevel, logText, callerMemberName, categoryName, errorType, exMessage));
protected override async void WriteLogItemCallback(AcLogItemClient logItem)
{
// 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();
@ -48,10 +61,47 @@ namespace AyCode.Services.Loggers
await StartConnection();
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();
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
**Status:** Open