6c11362d9f
P4-2: SqliteAuditWriter.FlushBatch completed each pending row's Task (TrySetResult) inside the per-row insert loop, before transaction.Commit() ran. A later failure in the same batch (another row's insert throwing, or the commit itself failing) rolled the whole transaction back, but the earlier rows' Tasks had already completed successfully -- the caller and FallbackAuditWriter (whose ring-buffer fallback exists precisely for this outage) both saw false success while the rows were actually gone from the database; the outer catch's TrySetException became a silent no-op against an already-completed TaskCompletionSource. Every row's Completion is now deferred and only resolved (TrySetResult for the whole batch, or TrySetException for the whole batch) after transaction.Commit() succeeds or the transaction rolls back, mirroring SiteEventLogger.FlushBatch. P4-4: EventLogPurgeService gated the daily site_events purge on the active-node check, early-exiting on the standby, on the stale premise that "the standby receives no writes". DataConnectionActor runs on both site nodes and writes site_events regardless of active/standby status (register rows 89/90/97), so an unreplicated standby (the rig's site-b/site-c posture) grew its site_events file forever with no retention and no cap. The gate is removed; every node now purges its own file every tick. Safe on a replicated pair because the retention/cap deletes are CDC-captured and converge under LWW regardless of which node issues them; required on an unreplicated pair because each node's file is independent. The active-node check is retained only to annotate the purge tick's log line. Tests: new SqliteAuditWriterCommitFailureTests forces a genuine non-swallowed mid-batch INSERT failure via a real SQLite trigger + custom scalar function on the writer's own connection (reflection seam), proving (a) a row whose own insert succeeded still faults when a later row in the same batch fails, and (b) FallbackAuditWriter rings every faulted row and increments the failure counter for each -- both assertions fail against the pre-fix code (proof-of-failure verified by temporarily reverting the fix). EventLogPurgeServiceTests' standby case is inverted to assert the purge now runs (not skips) on a standby node.
55 lines
3.1 KiB
C#
55 lines
3.1 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
|
|
|
public static class ServiceCollectionExtensions
|
|
{
|
|
/// <summary>
|
|
/// Register site event logging services (recording, purge, query).
|
|
/// </summary>
|
|
/// <param name="services">The DI service collection to register into.</param>
|
|
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
|
|
public static IServiceCollection AddSiteEventLogging(this IServiceCollection services)
|
|
{
|
|
// The recorder is registered as a concrete singleton and the interface is
|
|
// forwarded to the same instance. The purge and query services depend on the
|
|
// concrete SiteEventLogger directly (they need its lock-guarded WithConnection)
|
|
// rather than downcasting an ISiteEventLogger, which would throw
|
|
// InvalidCastException for any other ISiteEventLogger implementation.
|
|
services.AddSingleton<SiteEventLogger>();
|
|
services.AddSingleton<ISiteEventLogger>(sp => sp.GetRequiredService<SiteEventLogger>());
|
|
services.AddSingleton<IEventLogQueryService, EventLogQueryService>();
|
|
|
|
// The purge service registers on every host node and purges its own
|
|
// site_events file on every tick, on every node (arch-review 2026-08-19 P4-4 —
|
|
// the standby writes site_events too, so it can no longer skip its own purge).
|
|
// It still consults an optional SiteEventLogActiveNodeCheck, but only to annotate
|
|
// the tick's log line, not to gate it. The Host registers the real active-node
|
|
// check on site nodes; tests and non-clustered hosts leave it unregistered, and
|
|
// the log line just reports every tick as active. Building the service via a
|
|
// factory so the optional delegate flows from DI rather than the constructor default.
|
|
//
|
|
// SiteEventLogReplicationCheck flows the same way and is likewise Host-supplied:
|
|
// it only selects the wording of the purge log line (whether to add the
|
|
// "expect a transient LocalDb oplog backlog" note), and this component has no
|
|
// view of LocalDb:Replication. Unregistered ⇒ no note, matching the default
|
|
// that replication is opt-in and off.
|
|
services.AddHostedService(sp => new EventLogPurgeService(
|
|
sp.GetRequiredService<SiteEventLogger>(),
|
|
sp.GetRequiredService<IOptions<SiteEventLogOptions>>(),
|
|
sp.GetRequiredService<ILogger<EventLogPurgeService>>(),
|
|
sp.GetService<SiteEventLogActiveNodeCheck>(),
|
|
sp.GetService<SiteEventLogReplicationCheck>()));
|
|
return services;
|
|
}
|
|
|
|
// NOTE: EventLogHandlerActor is wired up directly in
|
|
// ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs as a cluster singleton, because the
|
|
// actor must be created inside the ActorSystem with the resolved
|
|
// IEventLogQueryService. There is intentionally no DI helper for that here — a
|
|
// former AddSiteEventLoggingActors placeholder was dead code and has been removed.
|
|
}
|