Skip to content

Commit 1c4f032

Browse files
committed
test: pin the guarantees that were only being argued for
CI gained a live job, and measuring what the suite actually catches turned up more than expected. Live tests were gated behind one flag covering two suites with different needs: the self-contained Postgres one and the MorphDB one, which has no redis or seeded tenant and so waits out Testcontainers' one-hour default. Enabling either meant enabling both. Split the gate per suite, bound the MorphDB readiness wait so it fails instead of stalling, and add a postgres-live CI job now that the Postgres suite can run alone. Then, measuring detection power by removing each guarantee and checking for red: the append serialization and the deterministic-paging tie-break were both fully uncovered, as were schema-name validation and the projection snapshot bound. The concurrency test only asserts final state, which a Postgres sequence satisfies on its own; the paging and snapshot guards are invisible to the in-memory store because its LINQ sort is stable and its stream copies the log before yielding. Each is now pinned by a test that goes red when the guarantee goes away. One guarantee stays uncovered on purpose: that watermark assignment order equals commit order lives in a transient window the public API cannot hold open, so a test for it would be a poller that can fail while the code is correct. Said so in the code instead. No production code changed.
1 parent 4f2901e commit 1c4f032

15 files changed

Lines changed: 457 additions & 26 deletions

.github/workflows/ci.yml

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,47 @@ jobs:
4444
- name: Build
4545
run: dotnet build Formbase.slnx --no-restore --configuration Release
4646

47-
# Live/ tests (Testcontainers + real MorphDB) are excluded from compilation unless
48-
# IncludeLiveTests=true, so the default suite needs no Docker host or service containers.
47+
# Live tests are excluded from compilation unless their opt-in flag is set, so the default
48+
# suite needs no Docker host or service containers. The Postgres suite runs in its own job.
4949
- name: Test
5050
run: dotnet test Formbase.slnx --no-build --configuration Release --verbosity normal
5151

52+
postgres-live:
53+
name: Postgres Live Contract
54+
runs-on: ubuntu-latest
55+
# Gated on the cheap suite: a compile error or a plain-suite regression should not also burn
56+
# container-pulling minutes here. The live flag changes which files compile, so this job cannot
57+
# reuse the build job's output and rebuilds regardless.
58+
needs: build
59+
# The runner already provides a Docker daemon, and the fixture starts its own postgres through
60+
# Testcontainers — so this needs no `services:` block, only the opt-in flag.
61+
# Bounds the job independently of the fixture's own readiness timeout: a wedged daemon or a slow
62+
# image pull costs a few minutes of Actions time, not the six-hour default.
63+
timeout-minutes: 15
64+
65+
steps:
66+
- name: Checkout
67+
uses: actions/checkout@v4
68+
69+
- name: Setup .NET
70+
uses: actions/setup-dotnet@v4
71+
with:
72+
dotnet-version: ${{ env.DOTNET_VERSION }}
73+
74+
- name: Cache NuGet packages
75+
uses: actions/cache@v4
76+
with:
77+
path: ~/.nuget/packages
78+
key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }}
79+
restore-keys: |
80+
${{ runner.os }}-nuget-
81+
82+
# The flag changes which files compile, so restore/build/test all carry it.
83+
# MorphDB live stays off: its service needs redis and a seeded tenant that this harness
84+
# does not yet provide, so enabling it here would fail the job by design.
85+
- name: Test (Postgres live contract)
86+
run: dotnet test Formbase.slnx --configuration Release --verbosity normal -p:IncludePostgresLiveTests=true
87+
5288
quality:
5389
name: Format & Vulnerability Scan
5490
runs-on: ubuntu-latest

README.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,17 +116,15 @@ dotnet build Formbase.slnx
116116
dotnet test Formbase.slnx # default suite — no Docker required
117117
```
118118

119-
Live tests stand up real backing services via Testcontainers and are excluded from the default build. Run them explicitly on a machine with Docker:
119+
Live tests stand up real backing services via Testcontainers and are excluded from the default build. The two suites need different things, so each has its own switch:
120120

121121
```bash
122-
dotnet test Formbase.slnx -p:IncludeLiveTests=true
122+
dotnet test Formbase.slnx -p:IncludePostgresLiveTests=true # Docker only — self-contained
123+
dotnet test Formbase.slnx -p:IncludeMorphDbLiveTests=true # also needs Redis + a seeded tenant
124+
dotnet test Formbase.slnx -p:IncludeLiveTests=true # umbrella: both
123125
```
124126

125-
The PostgreSQL raw-store live tests run against a plain `postgres` container and pass out of the box. The MorphDB live tests additionally require a Redis service and a provisioned tenant, so they are best run in CI where those can be declared as service containers — filter to just the Postgres suite when running locally:
126-
127-
```bash
128-
dotnet test Formbase.slnx -p:IncludeLiveTests=true --filter "FullyQualifiedName~PostgresRawStoreLiveContractTests"
129-
```
127+
The PostgreSQL raw-store live tests run against a plain `postgres` container and pass out of the box, which is why they are the ones worth enabling locally. The MorphDB suite is not runnable yet: MorphDB reports `/health` as 503 while Redis is absent, so its fixture times out (after two minutes — bounded deliberately, so an unreachable service fails rather than stalls). Completing that harness is tracked as its own roadmap phase.
130128

131129
## Roadmap
132130

tests/Formbase.Core.Tests/Contracts/RawStoreContractTests.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,12 @@ public async Task Concurrent_appends_get_distinct_monotonic_watermarks()
142142
var store = CreateStore();
143143
const int count = 20;
144144

145-
// Launch all appends at once. For a store that assigns watermarks with a naive max()+1 or that
146-
// lets assignment order diverge from commit order, this collides or drops rows; a correctly
147-
// serialized store gives every append a distinct watermark and loses nothing.
145+
// Launch all appends at once: a store that assigns watermarks with a naive max()+1 collides or
146+
// drops rows, while a correct one gives every append its own watermark and loses nothing.
147+
// This asserts on final state only, so it says nothing about *how* a store gets there — a
148+
// Postgres sequence is concurrency-safe by itself, and this test stays green even with that
149+
// adapter's append serialization removed (measured, cycle 21). What serialization buys is
150+
// pinned per-adapter instead; see PostgresAppendSerializationTests.
148151
var appends = Enumerable.Range(0, count)
149152
.Select(i => store.AppendAsync(Qc, DocumentId.New(), Body($$"""{"n":{{i}}}""")))
150153
.ToArray();

tests/Formbase.Core.Tests/Formbase.Core.Tests.csproj

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,31 @@
99
</PropertyGroup>
1010

1111
<!--
12-
Live tests (Live/) stand up a real MorphDB container via Testcontainers and need a healthy Docker
13-
host. They are excluded from compilation by default so the suite never depends on Docker and cannot
14-
hang. Opt in with: dotnet test -p:IncludeLiveTests=true (requires Docker).
12+
Live tests stand up real services via Testcontainers and need a healthy Docker host, so they are
13+
excluded from compilation by default: the suite never depends on Docker and cannot hang.
14+
15+
The two live suites have different requirements and are therefore gated independently — folding
16+
them into one switch means the self-contained Postgres suite cannot run without also dragging in
17+
the MorphDB one, which is not yet runnable (see below).
18+
19+
dotnet test -p:IncludePostgresLiveTests=true # needs Docker only — self-contained
20+
dotnet test -p:IncludeMorphDbLiveTests=true # additionally needs redis + a seeded tenant
21+
dotnet test -p:IncludeLiveTests=true # umbrella: both
22+
23+
MorphDB live is expected to fail until its harness is completed (its /health stays 503 without
24+
redis); it fails on a bounded timeout rather than hanging.
1525
-->
16-
<ItemGroup Condition="'$(IncludeLiveTests)' != 'true'">
17-
<Compile Remove="Live/**/*.cs" />
26+
<PropertyGroup Condition="'$(IncludeLiveTests)' == 'true'">
27+
<IncludePostgresLiveTests>true</IncludePostgresLiveTests>
28+
<IncludeMorphDbLiveTests>true</IncludeMorphDbLiveTests>
29+
</PropertyGroup>
30+
31+
<ItemGroup Condition="'$(IncludePostgresLiveTests)' != 'true'">
32+
<Compile Remove="Live/Postgres/**/*.cs" />
33+
</ItemGroup>
34+
35+
<ItemGroup Condition="'$(IncludeMorphDbLiveTests)' != 'true'">
36+
<Compile Remove="Live/MorphDb/**/*.cs" />
1837
</ItemGroup>
1938

2039
<ItemGroup>

tests/Formbase.Core.Tests/Live/MorphDbEngineLiveTests.cs renamed to tests/Formbase.Core.Tests/Live/MorphDb/MorphDbEngineLiveTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@
77
using Formbase.Core.Schema;
88
using Formbase.MorphDb;
99

10-
namespace Formbase.Core.Tests.Live;
10+
namespace Formbase.Core.Tests.Live.MorphDb;
1111

1212
/// <summary>
1313
/// The flagship end-to-end scenario against a real MorphDB projection store: raw-first intake,
1414
/// then structure, then queryable records. Everything but the projection store is in-process;
1515
/// the projection lands in a real database. Requires Docker (category: Live).
1616
/// </summary>
1717
[Collection(MorphDbCollection.Name)]
18-
[Trait("Category", "Live")]
18+
[Trait("Category", "Live.MorphDb")]
1919
public sealed class MorphDbEngineLiveTests
2020
{
2121
private readonly MorphDbFixture _fixture;

tests/Formbase.Core.Tests/Live/MorphDbFixture.cs renamed to tests/Formbase.Core.Tests/Live/MorphDb/MorphDbFixture.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,25 @@
44
using MorphDB.Client;
55
using Testcontainers.PostgreSql;
66

7-
namespace Formbase.Core.Tests.Live;
7+
namespace Formbase.Core.Tests.Live.MorphDb;
88

99
/// <summary>
1010
/// Spins up a real MorphDB service backed by PostgreSQL, both on a shared Docker network, once for
1111
/// all live tests. MorphDB ensures its own schema/extensions at startup, so a plain postgres suffices.
12+
/// <para>
13+
/// Incomplete on purpose: MorphDB's <c>/health</c> reports 503 while its optional redis dependency is
14+
/// absent, so readiness is never reached with this harness alone (a redis service and a seeded tenant
15+
/// are still missing). The readiness wait is therefore bounded — Testcontainers otherwise retries for
16+
/// its one-hour default, which stalls a CI job rather than failing it.
17+
/// </para>
1218
/// </summary>
1319
public sealed class MorphDbFixture : IAsyncLifetime
1420
{
1521
private const string PostgresAlias = "postgres";
1622

23+
/// <summary>Bounds the readiness wait so an unreachable service fails the run instead of stalling it.</summary>
24+
private static readonly TimeSpan ReadinessTimeout = TimeSpan.FromMinutes(2);
25+
1726
private readonly INetwork _network = new NetworkBuilder().Build();
1827
private PostgreSqlContainer _postgres = null!;
1928
private IContainer _morphdb = null!;
@@ -42,7 +51,7 @@ public async Task InitializeAsync()
4251
.WithEnvironment("ASPNETCORE_ENVIRONMENT", "Production")
4352
.WithPortBinding(8080, assignRandomHostPort: true)
4453
.WithWaitStrategy(Wait.ForUnixContainer()
45-
.UntilHttpRequestIsSucceeded(r => r.ForPort(8080).ForPath("/health")))
54+
.UntilHttpRequestIsSucceeded(r => r.ForPort(8080).ForPath("/health"), o => o.WithTimeout(ReadinessTimeout)))
4655
.Build();
4756
await _morphdb.StartAsync();
4857

tests/Formbase.Core.Tests/Live/MorphDbProjectionStoreLiveContractTests.cs renamed to tests/Formbase.Core.Tests/Live/MorphDb/MorphDbProjectionStoreLiveContractTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
using Formbase.Core.Tests.Contracts;
33
using Formbase.MorphDb;
44

5-
namespace Formbase.Core.Tests.Live;
5+
namespace Formbase.Core.Tests.Live.MorphDb;
66

77
/// <summary>
88
/// Runs the projection-store contract against a real MorphDB service. Proves the adapter honors the
99
/// same guarantees as the in-memory store. Requires Docker (category: Live).
1010
/// </summary>
1111
[Collection(MorphDbCollection.Name)]
12-
[Trait("Category", "Live")]
12+
[Trait("Category", "Live.MorphDb")]
1313
public sealed class MorphDbProjectionStoreLiveContractTests : ProjectionStoreContractTests
1414
{
1515
private readonly MorphDbFixture _fixture;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using Formbase.Core.Primitives;
2+
using Npgsql;
3+
4+
namespace Formbase.Core.Tests.Live.Postgres;
5+
6+
/// <summary>
7+
/// Pins what the append-path advisory lock actually buys, which the shared contract suite cannot see.
8+
/// <para>
9+
/// The contract suite's concurrency test asserts on <i>final</i> state — distinct watermarks, nothing
10+
/// lost. Postgres sequences are concurrency-safe on their own, so that assertion holds with or without
11+
/// the lock: removing the lock leaves the whole suite green (measured, cycle 21). What the lock is
12+
/// really for is the <b>check-then-insert window</b>: two transactions both read "no such id", both
13+
/// insert, and one dies on the primary key instead of returning idempotently. Holding the lock through
14+
/// commit closes that window — and because the store reads at READ COMMITTED, the waiter's lookup runs
15+
/// against a snapshot taken after the winner committed, so it sees the row and returns it.
16+
/// </para>
17+
/// <para>
18+
/// <b>Not covered here.</b> The lock also makes watermark <i>assignment order equal commit order</i>,
19+
/// without which a projection reading the head mid-flight could record a head above a document that
20+
/// commits later and skip it forever. That failure exists only inside a transient window, and the
21+
/// public API offers no way to hold a commit open, so it has no deterministic test — asserting it
22+
/// would mean a probabilistic poller that can fail while the code is correct. It stays covered by
23+
/// argument (see the type's remarks), not by test.
24+
/// </para>
25+
/// Requires Docker (category: Live.Postgres).
26+
/// </summary>
27+
[Collection(PostgresCollection.Name)]
28+
[Trait("Category", "Live.Postgres")]
29+
public sealed class PostgresAppendSerializationTests
30+
{
31+
private const int Racers = 8;
32+
33+
private readonly PostgresFixture _fixture;
34+
35+
public PostgresAppendSerializationTests(PostgresFixture fixture) => _fixture = fixture;
36+
37+
[Fact]
38+
public async Task Concurrent_appends_of_one_id_all_return_the_same_single_row()
39+
{
40+
var schema = "fb_append_" + Guid.NewGuid().ToString("N");
41+
await using var racers = new RacingStores(_fixture, schema, Racers);
42+
var type = FormTypeRef.Create("invoice");
43+
var id = DocumentId.New();
44+
45+
// Every racer submits the same document id at once — the re-submission a retrying client or a
46+
// second instance makes. Idempotency must hold under a race, not just in sequence.
47+
var stored = await racers.RaceAsync(store => store.AppendAsync(type, id, DocumentBody.Parse("""{"n":1}""")));
48+
49+
stored.Select(document => document.Watermark.Value).Distinct()
50+
.Should().ContainSingle("every racer must observe the one document that won, not its own insert");
51+
(await CountRowsAsync(schema, id)).Should().Be(1, "a duplicate id must never create a second row");
52+
(await SequenceValueAsync(schema)).Should().Be(1, "the losing racers must not burn watermarks");
53+
}
54+
55+
private async Task<int> CountRowsAsync(string schema, DocumentId id)
56+
{
57+
await using var connection = await _fixture.DataSource.OpenConnectionAsync();
58+
await using var command = new NpgsqlCommand(
59+
$"""SELECT count(*) FROM "{schema}".raw_documents WHERE id = @id""", connection);
60+
command.Parameters.AddWithValue("id", id.Value);
61+
return (int)(long)(await command.ExecuteScalarAsync())!;
62+
}
63+
64+
private async Task<long> SequenceValueAsync(string schema)
65+
{
66+
await using var connection = await _fixture.DataSource.OpenConnectionAsync();
67+
await using var command = new NpgsqlCommand(
68+
$"""SELECT last_value FROM "{schema}".raw_watermark_seq""", connection);
69+
return (long)(await command.ExecuteScalarAsync())!;
70+
}
71+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using Formbase.Core.Primitives;
2+
using Formbase.Postgres;
3+
using Npgsql;
4+
5+
namespace Formbase.Core.Tests.Live.Postgres;
6+
7+
/// <summary>
8+
/// Pins the cold-start guarantee: many instances reaching a fresh schema at once must all succeed.
9+
/// <para>
10+
/// Each store gates its own initialization with a private <c>SemaphoreSlim</c>, which does nothing
11+
/// across instances (see <see cref="RacingStores"/>) — so the only thing standing between concurrent
12+
/// cold starts and a failure is the schema-scoped advisory lock around the DDL. Remove that lock and
13+
/// these tests fail: <c>CREATE ... IF NOT EXISTS</c> is not atomic against the catalog, so racing
14+
/// creators collide on a duplicate-object error (verified by mutation, cycle 20).
15+
/// </para>
16+
/// Requires Docker (category: Live.Postgres).
17+
/// </summary>
18+
[Collection(PostgresCollection.Name)]
19+
[Trait("Category", "Live.Postgres")]
20+
public sealed class PostgresColdStartRaceTests
21+
{
22+
private const int Racers = 8;
23+
24+
private readonly PostgresFixture _fixture;
25+
26+
public PostgresColdStartRaceTests(PostgresFixture fixture) => _fixture = fixture;
27+
28+
[Fact]
29+
public async Task Concurrent_cold_start_creates_the_schema_exactly_once()
30+
{
31+
var schema = NewSchema();
32+
await using var racers = new RacingStores(_fixture, schema, Racers);
33+
34+
// A read is enough to trigger initialization, so this isolates the DDL race from the append path.
35+
var heads = await racers.RaceAsync(store => store.HeadAsync(FormTypeRef.Create("invoice")));
36+
37+
heads.Should().AllSatisfy(head => head.Value.Should().Be(0), "an empty store has no watermark yet");
38+
(await CountTablesAsync(schema)).Should().Be(1, "the racing creators must converge on one table");
39+
}
40+
41+
[Fact]
42+
public async Task Concurrent_cold_start_with_appends_assigns_unique_watermarks()
43+
{
44+
var schema = NewSchema();
45+
await using var racers = new RacingStores(_fixture, schema, Racers);
46+
var type = FormTypeRef.Create("invoice");
47+
48+
// Initialization and the first append collide in the same window — the DDL lock and the append
49+
// lock are the same key, so this also proves the two paths do not deadlock against each other.
50+
var appended = await racers.RaceAsync(store =>
51+
store.AppendAsync(type, DocumentId.New(), DocumentBody.Parse("""{"n":1}""")));
52+
53+
appended.Select(document => document.Watermark.Value)
54+
.Should().BeEquivalentTo(Enumerable.Range(1, Racers).Select(n => (long)n),
55+
"each racer consumes exactly one watermark from the shared sequence");
56+
}
57+
58+
private async Task<int> CountTablesAsync(string schema)
59+
{
60+
await using var connection = await _fixture.DataSource.OpenConnectionAsync();
61+
await using var command = new NpgsqlCommand(
62+
"SELECT count(*) FROM information_schema.tables WHERE table_schema = @s AND table_name = 'raw_documents'",
63+
connection);
64+
command.Parameters.AddWithValue("s", schema);
65+
return (int)(long)(await command.ExecuteScalarAsync())!;
66+
}
67+
68+
private static string NewSchema() => "fb_race_" + Guid.NewGuid().ToString("N");
69+
}

tests/Formbase.Core.Tests/Live/PostgresFixture.cs renamed to tests/Formbase.Core.Tests/Live/Postgres/PostgresFixture.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
using Npgsql;
22
using Testcontainers.PostgreSql;
33

4-
namespace Formbase.Core.Tests.Live;
4+
namespace Formbase.Core.Tests.Live.Postgres;
55

66
/// <summary>
77
/// Spins up a plain PostgreSQL once for all raw-store live tests and exposes a shared data source. Each
@@ -14,6 +14,13 @@ public sealed class PostgresFixture : IAsyncLifetime
1414

1515
public NpgsqlDataSource DataSource { get; private set; } = null!;
1616

17+
/// <summary>
18+
/// Builds a data source with its own connection pool, independent of <see cref="DataSource"/>. Stores
19+
/// built over separate pools share no in-process state, which is how a multi-process cold start is
20+
/// approximated in a single test host. The caller disposes what it creates.
21+
/// </summary>
22+
public NpgsqlDataSource CreateIndependentDataSource() => NpgsqlDataSource.Create(_postgres.GetConnectionString());
23+
1724
public async Task InitializeAsync()
1825
{
1926
_postgres = new PostgreSqlBuilder("postgres:16-alpine").Build();

0 commit comments

Comments
 (0)