Skip to content

Add fanout coordinator integration coverage - #98

Merged
SamuelMcAravey merged 3 commits into
mainfrom
codex/2025-12-19-add-integration-tests-for-fanoutcoordinator
Dec 19, 2025
Merged

Add fanout coordinator integration coverage#98
SamuelMcAravey merged 3 commits into
mainfrom
codex/2025-12-19-add-integration-tests-for-fanoutcoordinator

Conversation

@SamuelMcAravey

Copy link
Copy Markdown
Member

Summary

  • add integration tests covering fanout coordinator leasing, slice redispatch, cursor advancement, and fan-in joins
  • introduce in-memory lease test double and helper planners for fanout test scenarios

Testing

  • dotnet test tests/Bravellian.Platform.Tests/Bravellian.Platform.Tests.csproj -c Release --filter FanoutCoordinatorIntegrationTests (fails: Docker endpoint unavailable in test environment)

Codex Task

Copilot AI review requested due to automatic review settings December 19, 2025 06:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds comprehensive integration tests for the FanoutCoordinator component, covering lease acquisition, slice redispatch, cursor advancement, and fan-in join operations. The tests use in-memory test doubles to simulate distributed coordination scenarios without requiring external infrastructure.

  • Introduces four integration test scenarios validating fanout coordinator behavior
  • Implements reusable test helper classes (StaticPlanner, ShardedPlanner, InMemoryLeaseFactory) for testing fanout scenarios
  • Adds helper methods for querying outbox message state during tests

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/Bravellian.Platform.Tests/FanoutCoordinatorIntegrationTests.cs Outdated
Comment thread tests/Bravellian.Platform.Tests/FanoutCoordinatorIntegrationTests.cs Outdated
Comment thread tests/Bravellian.Platform.Tests/FanoutCoordinatorIntegrationTests.cs Outdated
Comment thread tests/Bravellian.Platform.Tests/FanoutCoordinatorIntegrationTests.cs Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +60 to +70
await DatabaseSchemaManager.EnsureFanoutSchemaAsync(
ConnectionString,
fanoutOptions.SchemaName,
fanoutOptions.PolicyTableName,
fanoutOptions.CursorTableName).ConfigureAwait(false);

joinStore = new SqlOutboxJoinStore(
Options.Create(outboxOptions),
NullLogger<SqlOutboxJoinStore>.Instance);

outboxService = new SqlOutboxService(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Ensure outbox join schema before using join store

InitializeAsync provisions the fanout schema but never creates the OutboxJoin/OutboxJoinMember tables before instantiating SqlOutboxJoinStore and SqlOutboxService. SqlServerTestBase only installs the Outbox tables, so when the FanoutSlices_CanJoinDownstreamMessagesIdempotently test later calls joinStore.CreateJoinAsync the queries will fail with Invalid object name 'dbo.OutboxJoin' because those tables were never created. Call DatabaseSchemaManager.EnsureOutboxJoinSchemaAsync here before constructing the join store to make the integration test runnable.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot

P1 Badge Ensure outbox join schema before using join store

InitializeAsync provisions the fanout schema but never creates the OutboxJoin/OutboxJoinMember tables before instantiating SqlOutboxJoinStore and SqlOutboxService. SqlServerTestBase only installs the Outbox tables, so when the FanoutSlices_CanJoinDownstreamMessagesIdempotently test later calls joinStore.CreateJoinAsync the queries will fail with Invalid object name 'dbo.OutboxJoin' because those tables were never created. Call DatabaseSchemaManager.EnsureOutboxJoinSchemaAsync here before constructing the join store to make the integration test runnable.

Useful? React with 👍 / 👎.

@SamuelMcAravey

Copy link
Copy Markdown
Member Author

@copilot I'm getting the following error in the tests. Please fix:

Test run for /home/runner/work/platform/platform/tests/Bravellian.Platform.Tests/bin/Release/net10.0/Bravellian.Platform.Tests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (x64)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
[xUnit.net 00:00:48.75] Bravellian.Platform.Tests.ManualSchemaExportTests.DeploySchemaAndExportToSqlProject [SKIP]
Skipped Bravellian.Platform.Tests.ManualSchemaExportTests.DeploySchemaAndExportToSqlProject [1 ms]
[xUnit.net 00:01:04.30] Bravellian.Platform.Tests.MetricsSchemaTests.SpUpsertMetricPointMinute_Should_Insert_And_Update [SKIP]
Skipped Bravellian.Platform.Tests.MetricsSchemaTests.SpUpsertMetricPointMinute_Should_Insert_And_Update [1 ms]
[xUnit.net 00:01:32.09] Bravellian.Platform.Tests.FanoutCoordinatorIntegrationTests.FanoutSlices_CanJoinDownstreamMessagesIdempotently [FAIL]
[xUnit.net 00:01:32.55] Bravellian.Platform.Tests.FanoutCoordinatorIntegrationTests.RunAsync_SkipsCompletedSlicesAfterCursorAdvances [FAIL]
Failed Bravellian.Platform.Tests.FanoutCoordinatorIntegrationTests.FanoutSlices_CanJoinDownstreamMessagesIdempotently [370 ms]
Error Message:
Microsoft.Data.SqlClient.SqlException : Invalid object name 'dbo.OutboxJoin'.
Stack Trace:
at Microsoft.Data.SqlClient.SqlCommand.<>c.b__195_0(Task1 result) at System.Threading.Tasks.ContinuationResultTaskFromResultTask2.InnerInvoke()
at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
--- End of stack trace from previous location ---
at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread)
--- End of stack trace from previous location ---
at Dapper.SqlMapper.QueryRowAsync[T](IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in /_/Dapper/SqlMapper.Async.cs:line 489
at Bravellian.Platform.SqlOutboxJoinStore.CreateJoinAsync(Int64 tenantId, Int32 expectedSteps, String metadata, CancellationToken cancellationToken) in /home/runner/work/platform/platform/src/Bravellian.Platform/Outbox/SqlOutboxJoinStore.cs:line 75
at Bravellian.Platform.Tests.FanoutCoordinatorIntegrationTests.FanoutSlices_CanJoinDownstreamMessagesIdempotently() in /home/runner/work/platform/platform/tests/Bravellian.Platform.Tests/FanoutCoordinatorIntegrationTests.cs:line 168
--- End of stack trace from previous location ---
Standard Output Messages:
Database schema created successfully on 127.0.0.1,32775
Failed Bravellian.Platform.Tests.FanoutCoordinatorIntegrationTests.RunAsync_SkipsCompletedSlicesAfterCursorAdvances [463 ms]
Error Message:
Shouldly.ShouldAssertException : await coordinator.RunAsync("reports", null, TestContext.Current.CancellationToken);
afterCompletion
should be
0
but was
2
Stack Trace:
at Bravellian.Platform.Tests.FanoutCoordinatorIntegrationTests.RunAsync_SkipsCompletedSlicesAfterCursorAdvances() in /home/runner/work/platform/platform/tests/Bravellian.Platform.Tests/FanoutCoordinatorIntegrationTests.cs:line 144
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
--- End of stack trace from previous location ---
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
Standard Output Messages:
Database schema created successfully on 127.0.0.1,32775
Failed! - Failed: 2, Passed: 400, Skipped: 2, Total: 404, Duration: 2 m 43 s - Bravellian.Platform.Tests.dll (net10.0)

Copilot AI commented Dec 19, 2025

Copy link
Copy Markdown
Contributor

@SamuelMcAravey I've opened a new pull request, #99, to work on those changes. Once the pull request is ready, I'll request review from you.

…sues in fanout tests (#99)

Co-authored-by: SamuelMcAravey <11021165+SamuelMcAravey@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>

Copilot AI commented Dec 19, 2025

Copy link
Copy Markdown
Contributor

@SamuelMcAravey I've opened a new pull request, #100, to work on those changes. Once the pull request is ready, I'll request review from you.

@SamuelMcAravey

Copy link
Copy Markdown
Member Author

@copilot

InitializeAsync provisions the fanout schema but never creates the OutboxJoin/OutboxJoinMember tables before instantiating SqlOutboxJoinStore and SqlOutboxService. SqlServerTestBase only installs the Outbox tables, so when the FanoutSlices_CanJoinDownstreamMessagesIdempotently test later calls joinStore.CreateJoinAsync the queries will fail with Invalid object name 'dbo.OutboxJoin' because those tables were never created. Call DatabaseSchemaManager.EnsureOutboxJoinSchemaAsync here before constructing the join store to make the integration test runnable.

Copilot AI commented Dec 19, 2025

Copy link
Copy Markdown
Contributor

@SamuelMcAravey I've opened a new pull request, #101, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI review requested due to automatic review settings December 19, 2025 06:32
@SamuelMcAravey
SamuelMcAravey merged commit d46581c into main Dec 19, 2025
3 checks passed
@SamuelMcAravey
SamuelMcAravey deleted the codex/2025-12-19-add-integration-tests-for-fanoutcoordinator branch December 19, 2025 06:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

foreach (var shard in shards)
{
yield return (shard, wk);
await Task.Yield();

Copilot AI Dec 19, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Task.Yield() here appears unnecessary. In test code, yielding control after each iteration adds overhead without providing value. The async enumerable pattern already provides sufficient asynchrony without explicit yields in simple enumeration scenarios.

Suggested change
await Task.Yield();

Copilot uses AI. Check for mistakes.
Comment on lines +389 to +403
public async ValueTask DisposeAsync()
{
cts.Cancel();
try
{
await expirationTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected when disposing before expiration
}

cts.Dispose();
onDispose(ResourceName);
}

Copilot AI Dec 19, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a race condition between disposing the lease and the expiration callback. When DisposeAsync is called, it cancels the token and waits for expirationTask. However, onDispose is called both when the lease expires naturally (line 351) and when it's disposed manually (line 402). If disposal happens right as the lease is expiring, onDispose could be called twice concurrently, potentially causing issues with the ConcurrentDictionary removal in the parent class.

Copilot uses AI. Check for mistakes.
Comment on lines +268 to +279

private sealed class InMemoryLeaseFactory : ISystemLeaseFactory
{
private readonly ConcurrentDictionary<string, InMemoryLease> leases = new(StringComparer.Ordinal);
private readonly TimeSpan? overrideDuration;
private long fencingToken;

public InMemoryLeaseFactory(TimeSpan? overrideDuration = null)
{
this.overrideDuration = overrideDuration;
}

Copilot AI Dec 19, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test helper class lacks documentation. Consider adding XML documentation comments to explain its purpose, which is to serve as an in-memory test double for ISystemLeaseFactory with configurable lease duration for testing lease expiration scenarios.

Suggested change
private sealed class InMemoryLeaseFactory : ISystemLeaseFactory
{
private readonly ConcurrentDictionary<string, InMemoryLease> leases = new(StringComparer.Ordinal);
private readonly TimeSpan? overrideDuration;
private long fencingToken;
public InMemoryLeaseFactory(TimeSpan? overrideDuration = null)
{
this.overrideDuration = overrideDuration;
}
/// <summary>
/// In-memory implementation of <see cref="ISystemLeaseFactory"/> used by tests.
/// </summary>
/// <remarks>
/// This test double stores leases in memory and optionally forces a fixed lease
/// duration via <paramref name="overrideDuration"/>. This allows tests to
/// reliably simulate and verify lease expiration behavior without relying on
/// external infrastructure or timing guarantees from a real lease store.
/// </remarks>
private sealed class InMemoryLeaseFactory : ISystemLeaseFactory
{
private readonly ConcurrentDictionary<string, InMemoryLease> leases = new(StringComparer.Ordinal);
private readonly TimeSpan? overrideDuration;
private long fencingToken;
/// <summary>
/// Initializes a new instance of the <see cref="InMemoryLeaseFactory"/> class.
/// </summary>
/// <param name="overrideDuration">
/// Optional lease duration to use for all acquired leases. When specified,
/// this value overrides any duration passed to <see cref="AcquireAsync"/>.
/// </param>
public InMemoryLeaseFactory(TimeSpan? overrideDuration = null)
{
this.overrideDuration = overrideDuration;
}
/// <summary>
/// Attempts to acquire an in-memory lease for the specified resource.
/// </summary>
/// <param name="resourceName">The name of the resource to acquire a lease for.</param>
/// <param name="duration">
/// The requested lease duration. This is ignored if an <c>overrideDuration</c>
/// was provided when constructing the factory.
/// </param>
/// <param name="contextJson">Optional JSON-encoded context associated with the lease.</param>
/// <param name="ownerToken">
/// Optional owner token for the lease. When not provided, a new token is generated.
/// </param>
/// <param name="cancellationToken">A token to observe while waiting for the operation to complete.</param>
/// <returns>
/// A task that resolves to an <see cref="ISystemLease"/> instance if the lease
/// was acquired, or <c>null</c> if another non-expired lease already exists
/// for the same resource.
/// </returns>

Copilot uses AI. Check for mistakes.
Comment on lines +225 to +234

private sealed class StaticPlanner : IFanoutPlanner
{
private readonly IReadOnlyList<FanoutSlice> slices;

public StaticPlanner(IEnumerable<FanoutSlice> slices)
{
this.slices = slices.ToList();
}

Copilot AI Dec 19, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test helper class lacks documentation. Consider adding XML documentation comments to explain its purpose, which is to provide a static list of FanoutSlice objects for testing scenarios where slice planning logic is not being tested.

Suggested change
private sealed class StaticPlanner : IFanoutPlanner
{
private readonly IReadOnlyList<FanoutSlice> slices;
public StaticPlanner(IEnumerable<FanoutSlice> slices)
{
this.slices = slices.ToList();
}
/// <summary>
/// Test helper implementation of <see cref="IFanoutPlanner"/> that always returns
/// a preconfigured, static set of <see cref="FanoutSlice"/> instances.
/// </summary>
/// <remarks>
/// This planner is used in scenarios where the fanout slice planning logic itself
/// is not under test and a fixed set of slices is sufficient for exercising other
/// parts of the fanout processing pipeline.
/// </remarks>
private sealed class StaticPlanner : IFanoutPlanner
{
private readonly IReadOnlyList<FanoutSlice> slices;
/// <summary>
/// Initializes a new instance of the <see cref="StaticPlanner"/> class with the
/// provided collection of <see cref="FanoutSlice"/> objects.
/// </summary>
/// <param name="slices">
/// The slices that will be returned unchanged from <see cref="GetDueSlicesAsync"/>.
/// </param>
public StaticPlanner(IEnumerable<FanoutSlice> slices)
{
this.slices = slices.ToList();
}
/// <inheritdoc />

Copilot uses AI. Check for mistakes.
return Task.FromResult(slices);
}
}

Copilot AI Dec 19, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test helper class lacks documentation. Consider adding XML documentation comments to explain its purpose, which is to provide a sharded planner implementation for testing cursor-based slice filtering with a fixed set of shard keys.

Suggested change
/// <summary>
/// Test helper planner that enumerates a fixed set of shard keys for a given work key.
/// This provides a sharded <see cref="BaseFanoutPlanner"/> implementation used to
/// verify cursor-based slice filtering behavior with a stable, predetermined set of shards.
/// </summary>

Copilot uses AI. Check for mistakes.
Comment on lines +313 to +318
newLease.DisposeAsync().AsTask().Wait();
return Task.FromResult<ISystemLease?>(null);
}

// If the resulting lease is expired, dispose the unused lease and loop to try again.
newLease.DisposeAsync().AsTask().Wait();

Copilot AI Dec 19, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling Wait() on an async dispose operation can lead to deadlocks, especially in test scenarios with synchronization contexts. Consider using ConfigureAwait(false).GetAwaiter().GetResult() instead, or restructure the code to avoid synchronous waits on async operations.

Suggested change
newLease.DisposeAsync().AsTask().Wait();
return Task.FromResult<ISystemLease?>(null);
}
// If the resulting lease is expired, dispose the unused lease and loop to try again.
newLease.DisposeAsync().AsTask().Wait();
newLease.DisposeAsync().AsTask().ConfigureAwait(false).GetAwaiter().GetResult();
return Task.FromResult<ISystemLease?>(null);
}
// If the resulting lease is expired, dispose the unused lease and loop to try again.
newLease.DisposeAsync().AsTask().ConfigureAwait(false).GetAwaiter().GetResult();

Copilot uses AI. Check for mistakes.
}

// If the resulting lease is expired, dispose the unused lease and loop to try again.
newLease.DisposeAsync().AsTask().Wait();

Copilot AI Dec 19, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling Wait() on an async dispose operation can lead to deadlocks, especially in test scenarios with synchronization contexts. Consider using ConfigureAwait(false).GetAwaiter().GetResult() instead, or restructure the code to avoid synchronous waits on async operations.

Copilot uses AI. Check for mistakes.
Comment on lines +289 to +319
while (true)
{
var nextToken = Interlocked.Increment(ref fencingToken);
var newLease = new InMemoryLease(
resourceName,
nextToken,
effectiveDuration,
ownerToken ?? OwnerToken.GenerateNew(),
RemoveLease);

var resultingLease = leases.AddOrUpdate(
resourceName,
_ => newLease,
(_, existing) => existing.IsExpired ? newLease : existing);

if (ReferenceEquals(resultingLease, newLease))
{
// We successfully installed our lease.
return Task.FromResult<ISystemLease?>(newLease);
}

if (!resultingLease.IsExpired)
{
// Another thread holds a non-expired lease.
newLease.DisposeAsync().AsTask().Wait();
return Task.FromResult<ISystemLease?>(null);
}

// If the resulting lease is expired, dispose the unused lease and loop to try again.
newLease.DisposeAsync().AsTask().Wait();
}

Copilot AI Dec 19, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The infinite while loop with concurrent AddOrUpdate calls has a potential race condition. Between checking if the resulting lease is expired (line 310) and looping to try again, another thread could acquire a new lease. This could cause the loop to spin indefinitely in highly contended scenarios. Consider adding a maximum retry count or a small delay between retries to prevent infinite spinning.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants