From 2a5c717755742dc2870a4fd92e10fced7847f251 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 26 Jun 2026 18:47:20 -0400 Subject: [PATCH] feat(historian-gateway): wire ContinuousHistorizationRecorder into DI + hosted lifecycle + meters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind ContinuousHistorizationOptions (Enabled/OutboxPath/CommitMode/ CommitIntervalMs/DrainBatchSize/DrainIntervalSeconds/Capacity/backoff) with a warn-only Validate(); gated on Enabled AND the ServerHistorian gateway being configured, the Host registers the durable FasterLogHistorizationOutbox (container -disposed) + a gateway-backed GatewayHistorianValueWriter, and binds outbox depth/dropped observable gauges on the central scraped meter. WithOtOpcUaRuntimeActors spawns the recorder (over the same dependency-mux ref) when the options + writer + outbox resolve, registering ContinuousHistorizationRecorderKey. Spawned with an EMPTY historized-ref set: the deployed address space builds later, so ref population is a documented follow-on (a later SetHistorizedRefs feed) — T18 wires the actor + outbox + writer + meters; the ref feed is the known remaining gap. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 45 ++++++++ .../ContinuousHistorizationMetrics.cs | 59 ++++++++++ .../ContinuousHistorizationOptions.cs | 83 ++++++++++++++ .../ServiceCollectionExtensions.cs | 46 ++++++++ .../ContinuousHistorizationOptionsTests.cs | 49 +++++++++ .../ServiceCollectionExtensionsTests.cs | 102 ++++++++++++++++++ 6 files changed, 384 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationMetrics.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationOptions.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationOptionsTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 09efb455..ba04ba4e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -23,6 +23,8 @@ using ZB.MOM.WW.OtOpcUa.Host.Logging; using ZB.MOM.WW.OtOpcUa.Host.Observability; using ZB.MOM.WW.OtOpcUa.Host.OpcUa; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions.Historian; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; using ZB.MOM.WW.OtOpcUa.Runtime.Scripting; @@ -124,6 +126,49 @@ if (hasDriver) builder.Configuration, (opts, sp) => GatewayHistorian.CreateDataSource(opts, sp)); + // Continuous historization of driver (non-Galaxy) tag values. Gated on ContinuousHistorization:Enabled + // AND the ServerHistorian gateway being configured: the recorder drains driver-tag live values to the + // SAME single gateway's WriteLiveValues SQL path, sourcing endpoint/key/TLS from the ServerHistorian + // section (this section carries only the recorder + outbox knobs). When both are on, register the durable + // crash-safe outbox + the gateway-backed live-value writer here; WithOtOpcUaRuntimeActors (below) spawns + // the recorder actor itself, gated on the same options. + var continuousHistorizationOptions = builder.Configuration + .GetSection(ContinuousHistorizationOptions.SectionName).Get() + ?? new ContinuousHistorizationOptions(); + foreach (var warning in continuousHistorizationOptions.Validate()) + Log.Warning("ContinuousHistorization misconfiguration detected at startup: {Warning}", warning); + if (serverHistorianOptions.Enabled && continuousHistorizationOptions.Enabled) + { + // Register the bound options so WithOtOpcUaRuntimeActors can gate the recorder spawn on Enabled. + builder.Services.AddSingleton(continuousHistorizationOptions); + + // Durable, crash-safe FasterLog outbox (the historization crash boundary). Built via the factory so + // the container OWNS disposal (FasterLogHistorizationOutbox is IDisposable). Binding the observable + // outbox depth/dropped gauges here (once, on first resolution) keeps the live instance behind them. + builder.Services.AddSingleton(_ => + { + var commitMode = Enum.TryParse( + continuousHistorizationOptions.CommitMode, ignoreCase: true, out var parsedMode) + ? parsedMode + : HistorizationCommitMode.PerEntry; + var outbox = new FasterLogHistorizationOutbox( + continuousHistorizationOptions.OutboxPath, + commitMode, + continuousHistorizationOptions.CommitIntervalMs, + continuousHistorizationOptions.Capacity); + ContinuousHistorizationMetrics.BindOutbox(outbox); + return outbox; + }); + + // Gateway-backed live-value writer over its OWN gRPC channel to the same single gateway (a second + // channel to a co-located sidecar is cheap — the gateway pools the historian sessions server-side). + builder.Services.AddSingleton(sp => + new GatewayHistorianValueWriter( + HistorianGatewayClientAdapter.Create( + serverHistorianOptions, sp.GetRequiredService()), + sp.GetRequiredService>())); + } + // Bind every cross-platform driver factory before AddAkka resolves IDriverFactory — replaces // the F7-default NullDriverFactory with a real DriverFactoryRegistryAdapter so DriverHostActor // can materialise real IDriver instances on deploy. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationMetrics.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationMetrics.cs new file mode 100644 index 00000000..110265c9 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationMetrics.cs @@ -0,0 +1,59 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Observability; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions.Historian; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +/// +/// Observable-gauge instruments for the continuous-historization durable outbox, hung off the +/// central (the same meter the Host's OpenTelemetry / +/// Prometheus binding already scrapes), so no extra meter allowlist entry is needed. +/// +/// The gauges read the bound outbox directly rather than Ask-ing the recorder actor: an +/// Ask inside a synchronous observable-gauge callback would block the metrics-collection +/// thread on the actor mailbox. The outbox exposes both gauge sources cheaply and +/// non-blockingly — completes synchronously (it +/// just reads an in-memory FIFO count) and is a +/// plain property. The recorder's other counters (TotalRecorded / DroppedNonNumeric / +/// OutboxAppendFailures) remain available via its GetStatus Ask for a health hook, but are +/// not surfaced as gauges here (Ask-in-gauge is the awkward path the plan calls out). +/// +/// +public static class ContinuousHistorizationMetrics +{ + private static volatile IHistorizationOutbox? _outbox; + + static ContinuousHistorizationMetrics() + { + // Registered once on first touch (i.e. when BindOutbox is first called at host start). Instruments + // are no-op until a listener attaches, so an unbound process pays nothing; the callbacks are + // null-safe and return 0 until BindOutbox supplies the live outbox. + OtOpcUaTelemetry.Meter.CreateObservableGauge( + "otopcua.historization.outbox.depth", + ObserveDepth, + unit: "{entry}", + description: "Un-acked entries currently held in the continuous-historization durable outbox."); + OtOpcUaTelemetry.Meter.CreateObservableGauge( + "otopcua.historization.outbox.dropped", + ObserveDropped, + unit: "{entry}", + description: "Lifetime entries the continuous-historization outbox dropped on capacity overflow."); + } + + /// Binds the process outbox the gauges observe. Called once by the Host when continuous + /// historization is enabled; subsequent calls re-point the gauges (idempotent in practice). + /// The durable outbox the recorder drains. + public static void BindOutbox(IHistorizationOutbox outbox) + => _outbox = outbox ?? throw new ArgumentNullException(nameof(outbox)); + + private static long ObserveDepth() + { + IHistorizationOutbox? outbox = _outbox; + if (outbox is null) return 0L; + // CountAsync over the FasterLog outbox completes synchronously (in-memory FIFO count); read the + // already-completed result without blocking. A (theoretical) pending result reports 0 this scrape. + ValueTask pending = outbox.CountAsync(CancellationToken.None); + return pending.IsCompletedSuccessfully ? pending.Result : 0L; + } + + private static long ObserveDropped() => _outbox?.DroppedCount ?? 0L; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationOptions.cs new file mode 100644 index 00000000..1c729eef --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationOptions.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +/// +/// Binds the ContinuousHistorization configuration section that gates the continuous +/// historization of driver (non-Galaxy) tag values. When is true +/// and the ServerHistorian gateway is configured, the Host builds a durable, +/// crash-safe FasterLogHistorizationOutbox + a gateway-backed IHistorianValueWriter +/// and WithOtOpcUaRuntimeActors spawns the ; +/// otherwise no recorder is spawned and driver tag values are not historized. +/// +/// The recorder taps the per-node dependency-mux value fan-out, appends each numeric value to +/// the outbox (the crash boundary), and drains the outbox to the historian's SQL live-value +/// write path (WriteLiveValues) through the single ServerHistorian gateway. The +/// gateway connection (endpoint / key / TLS) is sourced from ServerHistorianOptions — this +/// section carries only the recorder + outbox knobs, never a gateway address or credential. +/// +/// +public sealed class ContinuousHistorizationOptions +{ + /// The configuration section name this options class binds. + public const string SectionName = "ContinuousHistorization"; + + /// + /// When true (and the ServerHistorian gateway is configured), the + /// continuous-historization recorder + its durable outbox are wired and spawned; when + /// false (the default) no recorder is spawned and driver tag values are not historized. + /// + public bool Enabled { get; init; } + + /// + /// Directory holding the FasterLog outbox segment + commit files. Required when + /// is true. In production set an absolute path on durable + /// storage — a relative path resolves against the host's working directory, which may change + /// across deployments. + /// + public string OutboxPath { get; init; } = ""; + + /// + /// Outbox commit cadence: PerEntry (the default) fsyncs the log before each append + /// returns (safest, no loss window); Periodic batches commits onto a background timer + /// every ms (higher throughput, a bounded worst-case loss window). + /// Parsed case-insensitively against HistorizationCommitMode; an unrecognized value falls + /// back to PerEntry. + /// + public string CommitMode { get; init; } = "PerEntry"; + + /// Periodic-mode commit cadence in milliseconds; must be positive when + /// is Periodic. Ignored under PerEntry. + public int CommitIntervalMs { get; init; } = 100; + + /// Maximum outbox entries peeked + written per drain pass; clamped to a positive value by + /// the recorder (a non-positive value falls back to 64). + public int DrainBatchSize { get; init; } = 64; + + /// Steady drain cadence in seconds (also the post-success reschedule). Defaults to 2. + public double DrainIntervalSeconds { get; init; } = 2; + + /// Maximum un-acked outbox entries before the drop-oldest capacity policy kicks in; + /// 0 (the default) means unbounded. + public int Capacity { get; init; } + + /// Initial retry backoff (seconds) after a failed drain pass. Defaults to 1. + public double MinBackoffSeconds { get; init; } = 1; + + /// Cap (seconds) on the exponential retry backoff after repeated drain failures. Defaults to 30. + public double MaxBackoffSeconds { get; init; } = 30; + + /// Returns operator-facing misconfiguration warnings for an Enabled recorder + /// (empty when disabled or correctly configured). Pure — the registration logs each entry. + /// Zero or more human-readable warning messages (never carrying secret values). + public IReadOnlyList Validate() + { + var warnings = new List(); + if (!Enabled) return warnings; + if (string.IsNullOrWhiteSpace(OutboxPath)) + warnings.Add("ContinuousHistorization:OutboxPath is empty while historization is enabled — the durable outbox has no directory to persist to; the recorder cannot be wired."); + if (string.Equals(CommitMode, "Periodic", StringComparison.OrdinalIgnoreCase) && CommitIntervalMs <= 0) + warnings.Add($"ContinuousHistorization:CommitIntervalMs is {CommitIntervalMs} — must be > 0 in Periodic commit mode; the periodic-commit loop cannot run."); + return warnings; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs index 1be91b43..a5a74fa7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -11,6 +11,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions.Historian; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; using ZB.MOM.WW.OtOpcUa.Core.Scripting; using ZB.MOM.WW.OtOpcUa.Core.VirtualTags; @@ -33,6 +34,7 @@ public static class ServiceCollectionExtensions public const string DependencyMuxActorName = "dependency-mux"; public const string OpcUaPublishActorName = "opcua-publish"; public const string PeerProbeSupervisorName = "peer-probe-supervisor"; + public const string ContinuousHistorizationRecorderActorName = "continuous-historization-recorder"; /// /// Registers shared runtime services. Currently binds @@ -245,6 +247,46 @@ public static class ServiceCollectionExtensions HistorianAdapterActor.Props(historianSink, roleInfo.LocalNode), HistorianAdapterActorName); registry.Register(historian); + + // Continuous-historization recorder — gated on ContinuousHistorization:Enabled AND the + // gateway-backed IHistorianValueWriter + the durable IHistorizationOutbox being registered + // (the Host registers both ONLY when historization is enabled and the ServerHistorian gateway + // is configured). The recorder taps the dependency mux's value fan-out, so it is spawned after + // (and fed) the same `mux` ref the DriverHostActor uses. + // + // HISTORIZED-REF SET — DOCUMENTED GAP (T18 minimal wiring). The deployed address space (and + // thus the set of historized tag refs) is built later at deploy time, not here at actor-spawn + // time, so there is no clean ref set to resolve in WithOtOpcUaRuntimeActors. Per the plan, T18 + // spawns the recorder with an EMPTY initial ref set and registers its key; populating the refs + // (a later SetHistorizedRefs feed driven off the deployed composition) is the remaining wiring + // and a tracked follow-on. With an empty set the recorder registers interest in nothing and + // historizes nothing until that feed lands — the actor + outbox + writer + meters are wired. + var continuousOptions = resolver.GetService(); + if (continuousOptions is { Enabled: true }) + { + var valueWriter = resolver.GetService(); + var outbox = resolver.GetService(); + if (valueWriter is not null && outbox is not null) + { + var recorder = system.ActorOf( + ContinuousHistorizationRecorder.Props( + dependencyMux: mux, + writer: valueWriter, + outbox: outbox, + historizedRefs: Array.Empty(), + drainBatchSize: continuousOptions.DrainBatchSize, + drainInterval: TimeSpan.FromSeconds(continuousOptions.DrainIntervalSeconds), + minBackoff: TimeSpan.FromSeconds(continuousOptions.MinBackoffSeconds), + maxBackoff: TimeSpan.FromSeconds(continuousOptions.MaxBackoffSeconds)), + ContinuousHistorizationRecorderActorName); + registry.Register(recorder); + } + else + { + loggerFactory.CreateLogger("ZB.MOM.WW.OtOpcUa.Runtime.ServiceCollectionExtensions") + .LogWarning("ContinuousHistorization is enabled but IHistorianValueWriter and/or IHistorizationOutbox are not registered; the recorder will not be spawned. Expected only in misconfigured deployments or test harnesses."); + } + } }); return builder; @@ -258,5 +300,9 @@ public sealed class HistorianAdapterActorKey { } public sealed class DependencyMuxActorKey { } public sealed class OpcUaPublishActorKey { } +/// Marker key for the per-node ContinuousHistorizationRecorder (spawned only when +/// ContinuousHistorization:Enabled=true and the gateway value-writer + outbox are registered). +public sealed class ContinuousHistorizationRecorderKey { } + /// Marker key for the per-node PeerProbeSupervisor. public sealed class PeerProbeSupervisorKey { } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationOptionsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationOptionsTests.cs new file mode 100644 index 00000000..58c64674 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationOptionsTests.cs @@ -0,0 +1,49 @@ +using Xunit; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian; + +/// +/// Verifies self-gates on Enabled and +/// warns (warn-only, never blocks startup) on the two gated misconfigurations: a blank +/// OutboxPath while enabled, and a non-positive CommitIntervalMs under +/// Periodic commit mode. No warning text carries a secret value. +/// +public sealed class ContinuousHistorizationOptionsTests +{ + /// A disabled recorder yields no warnings regardless of the other knobs. + [Fact] + public void Disabled_no_warnings() + => Assert.Empty(new ContinuousHistorizationOptions { Enabled = false }.Validate()); + + /// An enabled recorder with a blank outbox directory warns about OutboxPath. + [Fact] + public void Enabled_requires_outbox_path() + => Assert.Contains( + new ContinuousHistorizationOptions { Enabled = true, OutboxPath = "" }.Validate(), + m => m.Contains("OutboxPath")); + + /// Periodic commit mode with a non-positive interval warns about CommitIntervalMs. + [Fact] + public void Periodic_requires_positive_interval() + => Assert.Contains( + new ContinuousHistorizationOptions + { + Enabled = true, + OutboxPath = "x", + CommitMode = "Periodic", + CommitIntervalMs = 0, + }.Validate(), + m => m.Contains("CommitIntervalMs")); + + /// A fully-configured enabled recorder produces no warnings. + [Fact] + public void Valid_config_is_clean() + => Assert.Empty( + new ContinuousHistorizationOptions + { + Enabled = true, + OutboxPath = "/var/lib/otopcua/historization", + CommitMode = "PerEntry", + }.Validate()); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs index 716be899..e9c2e3ba 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs @@ -7,6 +7,8 @@ using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions.Historian; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests; @@ -71,6 +73,106 @@ public sealed class ServiceCollectionExtensionsTests } } + /// + /// When ContinuousHistorization is not enabled (no options registered), the recorder + /// actor is NOT spawned — its key does not resolve in the registry. + /// + [Fact] + public async Task Recorder_not_spawned_when_continuous_historization_disabled() + { + using var host = BuildRuntimeActorHost(extra: null); + + await host.StartAsync(); + try + { + var registry = host.Services.GetRequiredService(); + registry.TryGet(out _).ShouldBeFalse(); + } + finally + { + await host.StopAsync(); + } + } + + /// + /// When ContinuousHistorization is enabled and a value-writer + outbox are registered, + /// the recorder actor IS spawned and its key resolves under the expected actor name. + /// + [Fact] + public async Task Recorder_spawned_when_enabled_with_writer_and_outbox() + { + using var host = BuildRuntimeActorHost(extra: services => + { + services.AddSingleton(new ContinuousHistorizationOptions { Enabled = true, OutboxPath = "x" }); + services.AddSingleton(new FakeValueWriter()); + services.AddSingleton(new FakeOutbox()); + }); + + await host.StartAsync(); + try + { + var recorder = host.Services.GetRequiredService>(); + recorder.ActorRef.ShouldNotBeNull(); + recorder.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.ContinuousHistorizationRecorderActorName); + } + finally + { + await host.StopAsync(); + } + } + + /// Builds a driver-role host that runs WithOtOpcUaRuntimeActors, with optional + /// extra DI registrations applied before AddAkka. + private static IHost BuildRuntimeActorHost(Action? extra) + => Host.CreateDefaultBuilder() + .ConfigureServices((_, services) => + { + services.AddSingleton>( + new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N"))); + services.AddSingleton(new FakeClusterRoleInfo()); + extra?.Invoke(services); + + services.AddAkka("otopcua-test", (ab, _) => + { + ab.AddHocon(@" + akka.actor.provider = ""Akka.Cluster.ClusterActorRefProvider, Akka.Cluster"" + akka.remote.dot-netty.tcp.hostname = ""127.0.0.1"" + akka.remote.dot-netty.tcp.port = 0 + akka.cluster.seed-nodes = [] + akka.cluster.roles = [""driver""] + ", HoconAddMode.Prepend); + ab.WithOtOpcUaRuntimeActors(); + }); + }) + .Build(); + + /// Non-throwing fake value writer that acks every batch. + private sealed class FakeValueWriter : IHistorianValueWriter + { + /// Acks the write unconditionally. + public Task WriteLiveValuesAsync( + string tag, IReadOnlyList values, CancellationToken ct) + => Task.FromResult(true); + } + + /// Empty in-memory outbox fake — the spawn test only needs construction, not draining. + private sealed class FakeOutbox : IHistorizationOutbox + { + /// Never drops (unbounded). + public long DroppedCount => 0; + /// No-op append. + public ValueTask AppendAsync(HistorizationOutboxEntry entry, CancellationToken ct) => ValueTask.CompletedTask; + /// Always returns an empty batch. + public ValueTask> PeekBatchAsync(int max, CancellationToken ct) + => ValueTask.FromResult>(Array.Empty()); + /// No-op remove. + public ValueTask RemoveAsync(Guid id, CancellationToken ct) => ValueTask.CompletedTask; + /// Always empty. + public ValueTask CountAsync(CancellationToken ct) => ValueTask.FromResult(0); + /// No-op dispose. + public void Dispose() { } + } + /// In-memory database factory for testing. private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory {