From 45b747e0bd178d04469327bfc85cbf2daf886cf6 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sun, 5 Jul 2026 11:13:53 -0500 Subject: [PATCH] test(ravendb): TransportCompliance + leadership compliance for the RavenDb control transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two compliance suites exercising the native RavenDB control queue added in #3285, and promotes it to a first-class "ravendb://" transport so it can pass the standard TransportCompliance battery. Framework changes (Wolverine.RavenDb): - Rename the control-transport scheme "ravencontrol" -> "ravendb". - Register the RavenDbControlTransport eagerly in UseRavenDbPersistence so the "ravendb://" scheme resolves for publishing rules configured at bootstrap (previously it registered lazily in RavenDbMessageStore.Initialize, so a config-time PublishAllMessages().To("ravendb://...") threw "Unknown Transport scheme"). The IDocumentStore is resolved lazily in the transport's InitializeAsync. The control endpoint only becomes a live listener when the message store promotes it to the NodeControlEndpoint under Balanced durability, so Solo hosts never poll — no behavior change for Solo. Compliance harness (Wolverine.ComplianceTests): - TransportComplianceFixture gains an overridable Mode (default Solo) so a control-plane transport that only wires its NodeControlEndpoint under Balanced can run the standard suite. Purely additive; every existing fixture stays Solo. Tests: - control_transport_compliance: full TransportCompliance over a Balanced two-node fixture sharing one embedded store — 22/22, no skips. Cluster cadence tightened in-fixture (CheckAssignmentPeriod/ScheduledJobPollingTime) so the leader-assigned scheduled-job agent is assigned promptly (the 30s default made schedule_send flap). - control_queue_leadership_election_compliance: full LeadershipElectionCompliance over ravendb:// instead of UseTcpForControlEndpoint() — 13/13, covering fan-out, send-to-node, and leader failover on the native control queue. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ol_queue_leadership_election_compliance.cs | 45 +++++++++++ .../RavenDbTests/control_queue_tests.cs | 4 +- .../control_transport_compliance.cs | 79 +++++++++++++++++++ .../Internals/RavenDbMessageStore.cs | 7 +- .../Transport/RavenDbControlTransport.cs | 18 ++++- .../WolverineRavenDbExtensions.cs | 12 +++ .../Compliance/TransportCompliance.cs | 51 +++++++----- 7 files changed, 189 insertions(+), 27 deletions(-) create mode 100644 src/Persistence/LeaderElection/RavenDbTests.LeaderElection/control_queue_leadership_election_compliance.cs create mode 100644 src/Persistence/RavenDbTests/control_transport_compliance.cs diff --git a/src/Persistence/LeaderElection/RavenDbTests.LeaderElection/control_queue_leadership_election_compliance.cs b/src/Persistence/LeaderElection/RavenDbTests.LeaderElection/control_queue_leadership_election_compliance.cs new file mode 100644 index 000000000..cef7c1526 --- /dev/null +++ b/src/Persistence/LeaderElection/RavenDbTests.LeaderElection/control_queue_leadership_election_compliance.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.DependencyInjection; +using Raven.Client.Documents; +using Wolverine; +using Wolverine.ComplianceTests; +using Wolverine.RavenDb; +using Xunit.Abstractions; +using RavenDbTests; + +namespace RavenDbTests.LeaderElection; + +/// +/// Runs the full leadership-election compliance battery while relying on the +/// native RavenDB control queue (ravendb://) for inter-node control +/// messaging, rather than UseTcpForControlEndpoint(). This exercises the +/// RavenDbControlTransport added in #3285 under real multi-node fan-out, +/// send-to-node, and leader-failover scenarios. +/// +[Collection("raven")] +public class control_queue_leadership_election_compliance : LeadershipElectionCompliance +{ + private readonly DatabaseFixture _fixture; + private IDocumentStore _store = null!; + + public control_queue_leadership_election_compliance(ITestOutputHelper output, DatabaseFixture fixture) : base(output) + { + _fixture = fixture; + } + + protected override Task beforeBuildingHost() + { + _store = _fixture.StartRavenStore(); + return Task.CompletedTask; + } + + protected override void configureNode(WolverineOptions options) + { + // Deliberately NOT calling UseTcpForControlEndpoint() — the RavenDb + // persistence registers its native control queue transport as the + // NodeControlEndpoint when none is otherwise supplied. + options.ServiceName = "raven"; + + options.Services.AddSingleton(_store); + options.UseRavenDbPersistence(); + } +} diff --git a/src/Persistence/RavenDbTests/control_queue_tests.cs b/src/Persistence/RavenDbTests/control_queue_tests.cs index def5c30f2..a70b11f0b 100644 --- a/src/Persistence/RavenDbTests/control_queue_tests.cs +++ b/src/Persistence/RavenDbTests/control_queue_tests.cs @@ -50,7 +50,7 @@ public async Task InitializeAsync() }).StartAsync(); var nodeId = _receiver.GetRuntime().Options.UniqueNodeId; - _receiverUri = new Uri($"ravencontrol://{nodeId}"); + _receiverUri = new Uri($"ravendb://{nodeId}"); } public async Task DisposeAsync() @@ -68,7 +68,7 @@ public void control_endpoint_is_wired_up_in_balanced_mode() // WolverineNode.For to throw "ControlEndpoint cannot be null for this usage". var endpoint = _sender.GetRuntime().Options.Transports.NodeControlEndpoint; endpoint.ShouldNotBeNull(); - endpoint.Uri.Scheme.ShouldBe("ravencontrol"); + endpoint.Uri.Scheme.ShouldBe("ravendb"); } [Fact] diff --git a/src/Persistence/RavenDbTests/control_transport_compliance.cs b/src/Persistence/RavenDbTests/control_transport_compliance.cs new file mode 100644 index 000000000..690de61ba --- /dev/null +++ b/src/Persistence/RavenDbTests/control_transport_compliance.cs @@ -0,0 +1,79 @@ +using JasperFx.Core; +using Microsoft.Extensions.DependencyInjection; +using Raven.Client.Documents; +using Wolverine; +using Wolverine.ComplianceTests.Compliance; +using Wolverine.RavenDb; +using Wolverine.Runtime; +using Xunit; + +namespace RavenDbTests; + +public class RavenDbControlTransportFixture : TransportComplianceFixture, IAsyncLifetime +{ + private DatabaseFixture _databases = null!; + private IDocumentStore _store = null!; + + public RavenDbControlTransportFixture() : base(new Uri("ravendb://placeholder"), 30) + { + // The RavenDb control queue only registers its NodeControlEndpoint under + // Balanced durability, so the compliance hosts must run Balanced. + Mode = DurabilityMode.Balanced; + + // Control messages are transient and each test tracks its own envelopes; + // resetting the shared store between every test would wipe the running + // nodes' identity/leadership records out from under them. + MustReset = false; + } + + public async Task InitializeAsync() + { + _databases = new DatabaseFixture(); + _store = _databases.StartRavenStore(); + + await ReceiverIs(opts => + { + opts.Services.AddSingleton(_store); + opts.UseRavenDbPersistence(); + tightenClusterCadence(opts); + }); + + var receiverNodeId = Receiver.Services.GetRequiredService().Options.UniqueNodeId; + OutboundAddress = new Uri($"ravendb://{receiverNodeId}"); + + await SenderIs(opts => + { + opts.Services.AddSingleton(_store); + opts.UseRavenDbPersistence(); + tightenClusterCadence(opts); + }); + } + + // The compliance battery runs against a freshly-started two-node Balanced + // cluster. With the production defaults (CheckAssignmentPeriod 30s, + // ScheduledJobPollingTime 5s) the leader-assigned scheduled-job agent may not + // even be assigned inside a single test's timeout — which is what makes + // schedule_send flap. Tighten the coordination cadence so agent assignment and + // scheduled-message release happen promptly, mirroring the leadership suite. + private static void tightenClusterCadence(WolverineOptions opts) + { + opts.Durability.CheckAssignmentPeriod = 1.Seconds(); + opts.Durability.HealthCheckPollingTime = 1.Seconds(); + opts.Durability.ScheduledJobPollingTime = 1.Seconds(); + opts.Durability.ScheduledJobFirstExecution = 0.Seconds(); + } + + protected override Task AfterDisposeAsync() + { + _store?.Dispose(); + _databases?.Dispose(); + return Task.CompletedTask; + } + + // Satisfy IAsyncLifetime; real teardown (stopping hosts + store cleanup) runs + // through the base IAsyncDisposable.DisposeAsync/AfterDisposeAsync path. + public new Task DisposeAsync() => Task.CompletedTask; +} + +[Collection("raven")] +public class control_transport_compliance : TransportCompliance; diff --git a/src/Persistence/Wolverine.RavenDb/Internals/RavenDbMessageStore.cs b/src/Persistence/Wolverine.RavenDb/Internals/RavenDbMessageStore.cs index 273e6168a..05bb1f995 100644 --- a/src/Persistence/Wolverine.RavenDb/Internals/RavenDbMessageStore.cs +++ b/src/Persistence/Wolverine.RavenDb/Internals/RavenDbMessageStore.cs @@ -80,7 +80,12 @@ public void Initialize(IWolverineRuntime runtime) && runtime.Options.Transports.NodeControlEndpoint == null && runtime.Options.Durability.Mode == DurabilityMode.Balanced) { - var transport = new Transport.RavenDbControlTransport(_store, runtime.Options); + // The transport itself is registered eagerly in UseRavenDbPersistence so + // its "ravendb://" scheme resolves for publishing rules. Here we promote + // its control endpoint to the NodeControlEndpoint, which marks it as a + // live listener — only under Balanced, so Solo hosts never poll. + var transport = runtime.Options.Transports.OfType().FirstOrDefault() + ?? new Transport.RavenDbControlTransport(_store, runtime.Options); runtime.Options.Transports.Add(transport); runtime.Options.Transports.NodeControlEndpoint = transport.ControlEndpoint; } diff --git a/src/Persistence/Wolverine.RavenDb/Internals/Transport/RavenDbControlTransport.cs b/src/Persistence/Wolverine.RavenDb/Internals/Transport/RavenDbControlTransport.cs index 69e636ae8..42bb74e8a 100644 --- a/src/Persistence/Wolverine.RavenDb/Internals/Transport/RavenDbControlTransport.cs +++ b/src/Persistence/Wolverine.RavenDb/Internals/Transport/RavenDbControlTransport.cs @@ -12,12 +12,21 @@ namespace Wolverine.RavenDb.Internals.Transport; internal class RavenDbControlTransport : ITransport, IAsyncDisposable { - public const string ProtocolName = "ravencontrol"; + public const string ProtocolName = "ravendb"; private readonly Cache _endpoints; private readonly WolverineOptions _options; private RetryBlock>? _deleteBlock; + // Registered eagerly at configuration time (UseRavenDbPersistence) so the + // "ravendb://" scheme resolves for publishing rules. The IDocumentStore isn't + // resolvable until the container is built, so it's supplied lazily in + // InitializeAsync. The ControlEndpoint is only promoted to a live listener when + // the message store wires it as the NodeControlEndpoint (Balanced mode). + public RavenDbControlTransport(WolverineOptions options) : this(null!, options) + { + } + public RavenDbControlTransport(IDocumentStore store, WolverineOptions options) { Store = store; @@ -29,7 +38,6 @@ public RavenDbControlTransport(IDocumentStore store, WolverineOptions options) }); ControlEndpoint = _endpoints[_options.UniqueNodeId]; - ControlEndpoint.IsListener = true; } public bool TryBuildBrokerUsage(out BrokerDescription description) @@ -40,7 +48,7 @@ public bool TryBuildBrokerUsage(out BrokerDescription description) public RavenDbControlEndpoint ControlEndpoint { get; } - public IDocumentStore Store { get; } + public IDocumentStore Store { get; private set; } public WolverineOptions Options => _options; @@ -87,6 +95,10 @@ public IEnumerable Endpoints() public ValueTask InitializeAsync(IWolverineRuntime runtime) { + // The store isn't available when the transport is registered at config time; + // resolve it now from the built container. + Store ??= (IDocumentStore)runtime.Services.GetService(typeof(IDocumentStore))!; + foreach (var endpoint in Endpoints()) endpoint.Compile(runtime); _deleteBlock = new RetryBlock>(deleteEnvelopesAsync, diff --git a/src/Persistence/Wolverine.RavenDb/WolverineRavenDbExtensions.cs b/src/Persistence/Wolverine.RavenDb/WolverineRavenDbExtensions.cs index 91edb3e5a..0009709fa 100644 --- a/src/Persistence/Wolverine.RavenDb/WolverineRavenDbExtensions.cs +++ b/src/Persistence/Wolverine.RavenDb/WolverineRavenDbExtensions.cs @@ -20,6 +20,18 @@ public static class WolverineRavenDbExtensions public static WolverineOptions UseRavenDbPersistence(this WolverineOptions options) { options.Services.AddSingleton(); + + // Register the native RavenDB control-queue transport eagerly so the + // "ravendb://" scheme resolves for publishing rules configured at bootstrap. + // The endpoint only becomes a live listener when the message store promotes + // it to the NodeControlEndpoint under Balanced durability (see + // RavenDbMessageStore.Initialize). The store is resolved later, in the + // transport's InitializeAsync. + if (!options.Transports.OfType().Any()) + { + options.Transports.Add(new Internals.Transport.RavenDbControlTransport(options)); + } + options.CodeGeneration.InsertFirstPersistenceStrategy(); options.CodeGeneration.Sources.Add(new AsyncDocumentSessionSource()); options.Services.AddHostedService(); diff --git a/src/Testing/Wolverine.ComplianceTests/Compliance/TransportCompliance.cs b/src/Testing/Wolverine.ComplianceTests/Compliance/TransportCompliance.cs index 6bd9bb4c3..96a371730 100644 --- a/src/Testing/Wolverine.ComplianceTests/Compliance/TransportCompliance.cs +++ b/src/Testing/Wolverine.ComplianceTests/Compliance/TransportCompliance.cs @@ -34,9 +34,18 @@ protected TransportComplianceFixture(Uri destination, int defaultTimeInSeconds = public bool AllLocally { get; set; } public bool MustReset { get; set; } = true; - + public bool IsSenderOnlyTransport { get; set; } + /// + /// Durability mode applied to the sender and receiver hosts. Defaults to Solo, + /// which is correct for the vast majority of broker transports. Control-plane + /// transports that only register in a clustered mode (e.g. the RavenDB control + /// queue, which wires its NodeControlEndpoint only under Balanced) can override + /// this to DurabilityMode.Balanced. + /// + public DurabilityMode Mode { get; set; } = DurabilityMode.Solo; + public async ValueTask DisposeAsync() { if (Sender == null) @@ -86,7 +95,7 @@ protected async Task TheOnlyAppIs(Action configure) .UseWolverine(options => { configure(options); - options.Durability.Mode = DurabilityMode.Solo; + options.Durability.Mode = Mode; configureReceiver(options); configureSender(options); }).StartAsync(); @@ -99,7 +108,7 @@ protected async Task SenderIs(Action configure) { configure(opts); configureSender(opts); - opts.Durability.Mode = DurabilityMode.Solo; + opts.Durability.Mode = Mode; }).StartAsync(); } @@ -117,7 +126,7 @@ private void configureSender(WolverineOptions options) options.Services.AddSingleton(); //options.Services.AddResourceSetupOnStartup(StartupAction.ResetState); - options.Durability.Mode = DurabilityMode.Solo; + options.Durability.Mode = Mode; options.UseNewtonsoftForSerialization(); } @@ -127,7 +136,7 @@ public async Task ReceiverIs(Action configure) Receiver = await Host.CreateDefaultBuilder() .UseWolverine(opts => { - opts.Durability.Mode = DurabilityMode.Solo; + opts.Durability.Mode = Mode; configure(opts); configureReceiver(opts); }).StartAsync(); @@ -179,11 +188,11 @@ public virtual void BeforeEach() private readonly bool _ownFixture; protected TransportCompliance() - { + { Fixture = new T(); _ownFixture = true; - } - + } + protected TransportCompliance(T fixture) { Fixture = fixture; @@ -203,13 +212,13 @@ public async Task InitializeAsync() theReceiver = Fixture.Receiver; theOutboundAddress = Fixture.OutboundAddress; - if (Fixture.MustReset) - { - await Fixture.Sender.ResetResourceState(); - - if (Fixture.Receiver != null && !ReferenceEquals(Fixture.Sender, Fixture.Receiver)) - { - await Fixture.Receiver.ResetResourceState(); + if (Fixture.MustReset) + { + await Fixture.Sender.ResetResourceState(); + + if (Fixture.Receiver != null && !ReferenceEquals(Fixture.Sender, Fixture.Receiver)) + { + await Fixture.Receiver.ResetResourceState(); } } @@ -218,12 +227,12 @@ public async Task InitializeAsync() public async Task DisposeAsync() { - if (!_ownFixture) - return; - if (Fixture is IAsyncDisposable) - await Fixture.DisposeAsync(); - else - Fixture?.SafeDispose(); + if (!_ownFixture) + return; + if (Fixture is IAsyncDisposable) + await Fixture.DisposeAsync(); + else + Fixture?.SafeDispose(); } [Fact]