Skip to content

Commit 46ec142

Browse files
committed
feat(WP-2.7): blue/green read-model generations and EF schema per generation
- ReadModelGeneration (name@v, name@v.n) and the ICollectionAliases port: one alias row per projection (target, previous, building), every change a compare-and-set; in-memory and PostgreSQL (migration V004). - Subscriptions per generation: each node runs the serving generation of its version and one being built (version bump, requested rebuild, or the first build); older versions retire once a newer one serves reads. The alias switches when every partition is within SwitchLagThreshold, and a retired generation's data is dropped after RetainPreviousFor. - Reads follow the alias: SFQL (interpreter and PostgreSQL compiler), domain queries, at_least waits, IReadModelResolver and the test host. - EF Core opt-in schema per generation (IGenerationSchemaContext, tables created from the model, model cache per schema, schema dropped on retirement, CreateForGeneration for reads). - One version of a projection per service; Projections(p => p.Remove<T>()). - CLI: blue/green projection rebuild (--in-place keeps the old behaviour), projection generations, projection switch, and a guarded drop. - ADR-032; doc 05/06 and ADR-031 updated; handlers and process managers are never part of a rebuild (IncludeInReplay dropped). - Tests: UT-SUB-25..28, UT-GEN-01..07, UT-EF-11..13, UT-CLI-31..34, SCK-73..75 (in-memory, PostgreSQL, PostgreSQL with RLS), E2E-08 in memory and on PostgreSQL through the CLI.
1 parent f420cbe commit 46ec142

41 files changed

Lines changed: 2219 additions & 92 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/v3.0.0/05-Event-Store-and-Persistence.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -376,8 +376,10 @@ CREATE TABLE sourceflow.documents (
376376
CREATE TABLE sourceflow.nodes ( -- liveness for lease fair-share (doc 06 §4)
377377
node_id text PRIMARY KEY, roles int NOT NULL, heartbeat_at timestamptz NOT NULL);
378378

379-
CREATE TABLE sourceflow.collection_aliases ( -- blue/green projection switch (doc 06 §6)
380-
alias text PRIMARY KEY, target text NOT NULL, switched_at timestamptz NOT NULL DEFAULT now());
379+
CREATE TABLE sourceflow.collection_aliases ( -- blue/green projection switch (doc 06 §6, ADR-032)
380+
alias text PRIMARY KEY, target text NOT NULL, switched_at timestamptz NOT NULL DEFAULT now(),
381+
previous text NULL, -- retired by the last switch, dropped after RetainPreviousFor
382+
building text NULL); -- requested by `projection rebuild`, cleared by its switch
381383

382384
CREATE TABLE sourceflow.subject_streams ( -- which streams hold a subject's PII (doc 10 §4)
383385
tenant_id text NOT NULL, subject_id text NOT NULL, stream_id text NOT NULL,

‎docs/v3.0.0/06-Projections-and-Subscriptions.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,14 @@ while lease held:
8080
4. Once `@2` lag is below `SwitchLagThreshold` (default 100 events), the engine updates `sourceflow.collection_aliases` (`account-summary → @2`) in one transaction. `IDocumentReader<AccountSummary>` resolves the alias on every query (cached, invalidated by notification).
8181
5. `@1` is stopped. Its data is dropped after `RetainPreviousFor` (default 7 days), or by `dotnet sourceflow projection drop account-summary@1`.
8282

83-
The CLI command `projection rebuild <name>` forces steps 2–5 without a code change (same version, new generation suffix).
83+
The CLI command `projection rebuild <name@version>` forces steps 2–5 without a code change (same version, new generation suffix: `account-summary@1.2`). `projection generations` lists the aliases, `projection switch` rolls back to a retained generation, and `--in-place` drops and replays the same generation instead.
84+
85+
**Details ([ADR-032](ADR-032-blue-green-read-model-generations-behind-forward-only-aliases.md)).**
86+
- The alias only moves forward, by compare-and-set: one node switches it.
87+
- Old-version nodes stop their generation once a newer version serves reads.
88+
- EF Core projections take part when they opt in to a schema per generation (`IGenerationSchemaContext`). Their reads resolve the generation with `IReadModelResolver`.
89+
- A service runs one version of each projection; `Projections(p => p.Remove<T>())` leaves out a module's older one.
90+
- Inline projections always write their first generation.
8491

8592
**Rebuild speed.** `IDocumentProjection` is *batch-aware*:
8693
1. Collect the document keys touched by the batch.
@@ -90,7 +97,7 @@ The CLI command `projection rebuild <name>` forces steps 2–5 without a code ch
9097

9198
For a typical rebuild this cuts database round trips from O(events) to O(batches) ([doc 09](09-Performance-and-Scalability.md)).
9299

93-
Replay is side-effect free by construction: projections are pure functions of events, and event handlers and process managers are **excluded** from rebuilds unless `IncludeInReplay` is set explicitly. This closes v2's replay side-effect problem (GAP-06).
100+
Replay is side-effect free by construction: projections are pure functions of events, and event handlers and process managers are **never** part of a rebuild. They keep their own subscriptions and checkpoints (E2E-08, UT-SUB-28). This closes v2's replay side-effect problem (GAP-06).
94101

95102
## 7. Errors and poison events
96103

‎docs/v3.0.0/ADRs/ADR-031-cli-runs-store-commands-globally-and-model-commands-in-the-service.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ A global tool can't see a service's model without loading the service's assembly
2323
3. **Inside a service**, `Program.cs` calls `if (await SourceFlowCli.TryRunAsync(args, app.Services) is { } code) return code;` before `app.Run()`. Every command then works with the service's own storage, model, routing and query service: `dotnet run --project <service> -- sourceflow schema check --baseline schemas.lock.json`. The Bank sample does this.
2424
4. **Without a transport, the global tool replays dead letters through the outbox.** It enqueues the original envelope, with the same message id, so the inbox still protects. The running service's relay then sends it.
2525
5. **Projection admin needs the leases.** `projection skip`, `repartition` and `rebuild` take the partitions' leases as `sourceflow-cli`. They refuse while a running node holds them, so subscriptions must be stopped first; `skip` runs on the node that holds the lease.
26-
6. **`projection rebuild <name@version>` rebuilds in place.** It drops the generation's documents in every tenant (`IDocumentAdmin`) and resets its checkpoints, and the service replays it from the start on its next run. Blue/green generation switching with collection aliases (doc 06 §6, steps 3 and 4) is still to come. It is tracked with E2E-08 under WP-2.7.
26+
6. **`projection rebuild <name@version>` is blue/green** ([ADR-032](ADR-032-blue-green-read-model-generations-behind-forward-only-aliases.md)). It requests the next generation, and the running service builds it and switches reads once it has caught up. With `--in-place`, it instead drops the generation's documents in every tenant (`IDocumentAdmin`) and resets its checkpoints; the service then replays it from the start on its next run. `projection generations` lists the aliases, `projection switch` rolls reads back to a retained generation, and `projection drop` refuses a generation that is serving or being built.
2727

2828
## Consequences
2929

3030
- CI can check schemas with the service's own build, and nothing is loaded by reflection.
3131
- Operators can run store commands without deploying anything.
32-
- An in-place rebuild serves an empty, growing read model while it catches up. Use a version bump (a new generation) when that matters, until alias switching lands.
32+
- An in-place rebuild serves an empty, growing read model while it catches up; the default blue/green rebuild does not.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# ADR-032: Blue/green read-model generations behind forward-only aliases
2+
3+
| | |
4+
|---|---|
5+
| **Status** | Accepted |
6+
| **Date** | 2026-11-11 |
7+
| **Deciders** | v3 architecture review; WP-2.7 implementation |
8+
| **Amends** | [doc 06](../06-Projections-and-Subscriptions.md) §6; [ADR-031](ADR-031-cli-runs-store-commands-globally-and-model-commands-in-the-service.md) decision 6 |
9+
10+
## Context
11+
12+
Doc 06 §6 describes blue/green rebuilds:
13+
14+
- A projection version bump, or `dotnet sourceflow projection rebuild`, builds a new **generation** from position zero.
15+
- The old generation keeps serving reads until the new one has caught up.
16+
- Reads then switch through an alias, and the old generation is dropped after a retention period.
17+
18+
Several details were left open:
19+
20+
- the generation naming;
21+
- how a running service learns about a requested rebuild;
22+
- what happens to nodes still running the old code during a rolling deploy;
23+
- how relational (EF Core) read models take part;
24+
- what "replay has no side effects" means for handlers.
25+
26+
## Decision
27+
28+
1. **Generation ids.**
29+
- The first build of version *v* is `{name}@{v}`, as before, so existing collections and checkpoints keep their names.
30+
- A rebuild of the same version is `{name}@{v}.{n}`, with *n* starting at 2.
31+
- Generations are ordered by version, then by number (`ReadModelGeneration`).
32+
- Each generation has its own subscription, `projection:{generation}`, with its own checkpoints and leases.
33+
2. **One alias row per projection name** (`collection_aliases`, extended by migration V004). The row holds:
34+
- `target`: the generation serving reads;
35+
- `previous`: the generation the last switch retired;
36+
- `building`: a rebuild that `projection rebuild` requested.
37+
38+
Every change is a compare-and-set through the `ICollectionAliases` storage port (SCK-73..75), so concurrent nodes switch an alias exactly once.
39+
3. **What each node runs.** Each node runs at most two generations of each async projection: the one serving reads, if it is this code's version, and one being built. The build target is one of these:
40+
- the requested rebuild;
41+
- this code's first generation, when the alias points at an older version;
42+
- this code's first generation, before any alias exists.
43+
44+
A node whose version is older than the alias target runs nothing for that projection. In a rolling deploy, old nodes keep the old generation current until the switch, then retire it within one poll interval.
45+
4. **Switching forward only.** A node points the alias at the generation it is building once every partition is within `SwitchLagThreshold` events of the log (default 100). The check runs on idle passes and on each alias refresh.
46+
- Reads resolve the alias on every query. The aliases are cached for one poll interval, and a switch made by this node takes effect at once.
47+
- This covers SFQL, domain queries, `at_least` consistency waits, `IReadModelResolver` and the test host.
48+
- `projection switch` can point the alias back at a retained generation (a rollback). Nodes on a newer version then build again.
49+
5. **Retention.** Once `RetainPreviousFor` has passed (default 7 days, checked once per `RetentionInterval`), the node drops the retired generation's data, then clears `previous`:
50+
- documents through `IDocumentAdmin`;
51+
- an EF Core generation's schema through `IProjectionGenerationStore`.
52+
53+
`projection drop` does the same on demand. It refuses the serving generation and the one being built.
54+
6. **EF Core opts in to a schema per generation.**
55+
- Opt in with `UseEntityFrameworkProjections<T>(configure, (options, schema) => …, generations)`. The context implements `IGenerationSchemaContext` and maps `HasDefaultSchema(Schema)`. Models are cached per schema.
56+
- A generation's schema and tables are created from the EF model on first use.
57+
- Reads resolve the generation with `IReadModelResolver` and open a context with `CreateForGeneration`.
58+
- Without the opt-in, an EF projection keeps one shared schema and does not take part in blue/green switching.
59+
7. **One version of a projection per service.** Two registered projections with the same name fail at startup. A host that carries a new version of a library's projection leaves the old one out with `Projections(p => p.Remove<T>())`.
60+
8. **Rebuilds never re-run side effects.** Process managers and event handlers have their own subscriptions, which no rebuild touches. The `IncludeInReplay` switch in doc 06 §6 is dropped: re-running a handler is a different operation (reset its subscription), not part of a rebuild.
61+
62+
## Consequences
63+
64+
- A version bump and a rebuild without a code change behave the same way. Readers see the old generation until the new one is close to the head. There is never an empty or half-built read model, except during the lag window allowed by `SwitchLagThreshold`.
65+
- Inline projections are not rebuilt blue/green; they always write their first generation. To rebuild one, make it async for the rebuild.
66+
- During the overlap, an old-version node reads the new generation's documents with its old document type. Additive document changes are safe; incompatible ones need the old nodes gone before the switch (set `SwitchLagThreshold` to 0 and finish the rollout first).
67+
- Each alias refresh is one small query per poll interval per node. Lag checks read at most `SwitchLagThreshold + 1` log rows per partition, and only while a generation is being built.

‎docs/v3.0.0/ADRs/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,4 @@ Every ADR is **Proposed** until the v3 architecture review. "Traces" lists the [
4444
| [ADR-029](ADR-029-schema-catalog-derived-from-serializer-metadata.md) | The schema catalog is derived at startup from the serializer's source-generated metadata | Accepted |
4545
| [ADR-030](ADR-030-domain-queries-bind-at-startup.md) | Domain queries are checked in two stages: shape at build time, binding at startup | Accepted |
4646
| [ADR-031](ADR-031-cli-runs-store-commands-globally-and-model-commands-in-the-service.md) | The CLI runs store commands as a global tool and model commands inside the service | Accepted |
47+
| [ADR-032](ADR-032-blue-green-read-model-generations-behind-forward-only-aliases.md) | Blue/green read-model generations behind forward-only aliases | Accepted |

‎docs/v3.0.0/implementation/05-Unit-Test-Plan.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,8 @@ These are additional to the SCK, which the in-memory store also runs.
238238
| UT-SUB-13..16 | Stop policy: poison event retried with backoff; partition stalled (metric = 1); other partitions continue; `projection skip` advances past it (audited) | doc 06 §7 |
239239
| UT-SUB-17..20 | Park policy: after `SubscriptionMaxAttempts`, the event is parked with consumer = subscription and the checkpoint moves past it; batch failure falls back to per-event isolation | doc 06 §7, GAP-07 |
240240
| UT-SUB-21..24 | Repartition: new partitions start from the minimum old checkpoint; refused for non-idempotent projections | doc 06 §3 |
241-
| UT-SUB-25..28 | Rebuild: new generation from position 0; alias switch when lag < threshold; old generation dropped after retention; handlers and PMs excluded unless `IncludeInReplay` | doc 06 §6, GAP-06 |
241+
| UT-SUB-25..28 | Rebuild: new generation from position 0; alias switch when lag < threshold; old generation dropped after retention; handlers and PMs never part of a rebuild | doc 06 §6, GAP-06, ADR-032 |
242+
| UT-GEN-01..07 | Generation ids (parse, format, order), alias compare-and-set, one version per service, requested rebuild of the same version, `SwitchLagThreshold` | doc 06 §6, ADR-032 |
242243
| UT-PRJ-01..06 | Applier: creates on first event; updates; skips when stored version ≥ event version (**DC-2**); null return leaves the document; inline and async share the code path; collection = `name@version` | DC-2, ADR-016 |
243244
| UT-PRJ-07..12 | Batch-aware document projection: one `GetMany` per batch; one upsert per changed document; rebuild throughput sanity | doc 06 §6 |
244245
| UT-PM-01..05 | Correlation: `CorrelateBy` → id; fallback to envelope correlation; correlation GUID ↔ id round trip; non-GUID ids → UUIDv5 | ADR-020 |
@@ -271,11 +272,12 @@ These are additional to the SCK, which the in-memory store also runs.
271272
|----------|---------|-----------|------------|
272273
| UT-SNAP-01..10 | Core + PostgreSQL | Snapshot every N; hash invalidation; snapshot loss harmless; cache `ReadTail` vs `Optimistic` correctness | new |
273274
| UT-EF-01..10 | EntityFrameworkCore | `DbContext` bound to the session transaction; `IdempotentUpsert`; rollback on checkpoint fencing failure | → `EfViewModelStoreIntegrationTests` (concepts) |
275+
| UT-EF-11..13 | EntityFrameworkCore | Schema per generation: schema naming, schema and tables created on first use, rebuild into a new schema read through `IReadModelResolver`, retired schema dropped | new |
274276
| UT-AWS-01..30 | AmazonSqs | Options → `AWSOptions` and client config (**UT-AWS-12: Region honoured**, GAP-03); attributes ≤ 10 with `sf_ext` overflow; FIFO group and dedup ids; batch splitting at 10; visibility heartbeat; `ChangeMessageVisibility` delay ≤ 12 h then fallback; provisioner plan (FIFO/standard compatibility, **UT-AWS-05: raw delivery + one endpoint receiver**, GAP-02); health check | → `AwsSqsCommandDispatcherTests`, `AwsSnsEventDispatcherTests`, `AwsBusBootstrapperTests`, `AwsHealthCheckTests`, `IocExtensionsTests` |
275277
| UT-GCP-01..25 | GooglePubSub | Options and ADC; emulator detection; ordering keys + publisher ordering; subscription naming `{svc}.{topic}` (GAP-13); ack-deadline extension; nack-based delay; dead-letter policy; health | → `GcpIocExtensionsTests`, `GcpOptionsTests`, `PubSub*DispatcherTests` |
276278
| UT-ASB-01..25 | AzureServiceBus | `TokenCredential` vs connection string (Development only); sessions for ordered endpoints; duplicate detection; `ForwardTo` subscription; correlation filters; scheduled re-send delay; lock renewal | → `azure-cloud` `AzureServiceBus*DispatcherTests`, `AzureBusBootstrapperTests`, `AzureIocExtensionsTests` |
277279
| UT-TOP-01..15 | Core | Topology from routing; naming convention (env prefix, `.fifo`, length and charset limits); `Validate` diff report; JSON export schema | → `BusConfigurationTests` (resolution concepts) |
278-
| UT-CLI-01..30 | Tool | Command parsing; each command's handler against in-memory adapters; exit codes; `--explain`; output formats | new |
280+
| UT-CLI-01..34 | Tool | Command parsing; each command's handler against in-memory adapters; exit codes; `--explain`; output formats; blue/green `projection rebuild`, `generations`, `switch`, guarded `drop` | new |
279281
| UT-ASP-01..20 | AspNetCore | `MapCommand`: Idempotency-Key, If-Match → 412, rejection → 422 ProblemDetails, conflict → 409, ETag and consistency header; `MapDomainQueries` exposes named queries | new |
280282
| UT-ASP-21..40 | AspNetCore query API | Fail-closed mapping without policy (SFQ0504); `/query`, `/query/validate` (never executes), `/query/explain` (permission, placeholders only), `/query/schema` (caller-visible fields); status mapping (400/403/409/413/415/429/503/504); problem+json shape **without input echo** (S-3); security headers (S-2); NDJSON trailer; CSV formula neutralisation (S-4); rate and concurrency limits with `Retry-After`; JSON depth and body limits; tenant from principal | new (ADR-026) |
281283
| UT-HC-01..14 | Core + adapters | Every health check state in doc 08 §5 | → `AwsHealthCheckTests` |

‎docs/v3.0.0/implementation/06-Integration-and-E2E-Test-Plan.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Isolation: every test uses a unique prefix (`t{testId}{random}`) for schemas, qu
5858
| SCK-56..60 | Tenancy: tenant-scoped streams, documents, inbox; RLS on → a cross-tenant read returns nothing even with a forged query | ADR-022 | new |
5959
| SCK-61..64 | EF Core projection: `SaveChanges` and checkpoint in one transaction; fencing failure rolls back the EF changes | doc 05 §10 | → `EfViewModelStoreIntegrationTests` |
6060
| SCK-L01 | Linearisability (CsCheck `SampleParallel`). Parallel `Any` appends, `NoStream` appends and appends with outbox rows must equal the model under some sequential ordering, which checks the compare-and-set and append/outbox atomicity under concurrency. Runs on in-memory, PostgreSQL, and PostgreSQL with RLS. | ADR-006, ADR-008 | new |
61+
| SCK-73..75 | Read-model aliases: first claim once, compare-and-set switch recording the previous target, requested generation kept until switched, concurrent switches with one winner | doc 06 §6, ADR-032 | new |
6162
| SCK-65..72 | Partitioned events variant: append across a partition boundary; horizon read across partitions; detach an old partition; archive exclusion rules | doc 09 §5 | new |
6263
| SCK-M01..M06 | Schema: fresh install; upgrade from every prior version; concurrent migrators serialise; `AutoMigrate` off outside Development; runtime role has DML only; `db script` output equals applied DDL | doc 05 §11 | → `DbContextMigrationHelper` problems (EF-06) |
6364

0 commit comments

Comments
 (0)