|
| 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. |
0 commit comments