Chaos Tests
This page is part of the documentation for Orleans.Lattice 9.9.0 (release line 9.9), built 2026-10-04. It is also published as markdown, with every table and list, at chaos-tests.md, and llms.txt lists every page.Orleans.Lattice ships a suite of integration tests that bombard a running
cluster (single-cluster and multi-site) with concurrent reads, writes,
scans, atomic-write sagas, topology mutations, and inter-site network
partitions, then assert that the system's public correctness guarantees
hold. They act as the end-to-end contract for the properties described
in Consistency and Replication -
specifically that the public ILattice API is strongly consistent
across arbitrary concurrent shard splits, online resizes, and online
reshards; that point-mutation public-API calls (DeleteRangeAsync,
SetIfVersionAsync (the public compare-and-swap entry point), ScanKeysAsync / ScanEntriesAsync cancellation) hold their stated
invariants under concurrent contention; that SetManyAtomicAsync
remains atomically visible (zero-or-all keys per poll) on the
authoring site and on every receiver site; that
SetManyAtomicAsync keeps a multi-tree saga's keys
all-or-nothing across every participating tree under concurrent shard
splits; and that the per-merge-mode CRDT dispatch paths converge across
partitioned sites. The single-cluster suite also exercises the
recovery protocols (resumable splits, two-phase root promotion,
shadow-write atomicity, shadow-forwarding, and registry version stamping)
under random storage-write faults. The
replication suite extends those guarantees to the production shipper,
WAL trim, per-peer liveness, tombstone-reap filtering, and the gRPC
transport; the Azure Table WAL suite pins append-batch atomicity and
offset monotonicity against the local Azurite emulator. The
lattice.schema companion suite additionally pins that
SetManyAtomicAsync stays all-or-nothing while a tree's schema
enforcement policy or target schema version is changed concurrently, and
that every stored value stays self-describing and decodable across a
version advance and an eager background migration.
Every fixture is tagged [Category("Chaos")], so any test filter carrying TestCategory!=Chaos - the Tier 2 filter of the tiered test workflow among them - skips them. Most are also marked [NonParallelizable] so each has its cluster to itself; AdmissionControlChaosTests, BoundedCacheEvictionChaosTests, ClusterSplitConcurrencyChaosTests, ShardConsolidationChaosTests, LatticePredicatePushdownChaosTests and the replication suite's AntiEntropyRemediationGuardChaosTests are not.
Core chaos suite (test/lattice/BPlusTree/, plus one fixture in test/lattice/Predicates/)
| Test class | File | Purpose |
|---|---|---|
| Happy-path chaos | ChaosIntegrationTests.cs |
Strong invariants during heavy concurrent load with manually-triggered splits. |
| Chaos + storage faults | ChaosWithFaultsIntegrationTests.cs |
Parametrized theory that injects random storage faults; asserts eventual convergence after the fault window closes. |
| Chaos + online resize | ChaosResizeIntegrationTests.cs |
Full-workload chaos while ResizeAsync changes fan-out in the background under SnapshotMode.Online. Exercises the TreeResizeGrain phase machine (Snapshot → Swap → Reject → Cleanup), shadow-forwarding on every source shard, and the alias swap. |
| Chaos + online reshard | ChaosReshardIntegrationTests.cs |
Full-workload chaos while ReshardAsync grows the physical shard count (4 to 8) in the background. Exercises the reshard coordinator's migration loop and its dispatch-budget clamping (MaxConcurrentMigrations), and ShardMap convergence when reshard-dispatched splits race with workload writes. The same fixture also shrinks a tree (4 to 2) under the same workload, exercising fold planning, every fold's drain, freeze, swap and finalise racing live writes, and the release of each retired donor's storage while scans and counts may still hold a pre-fold shard map. |
| Atomic-write reader isolation | AtomicVisibilityChaosTests.cs |
Strict reader isolation: a continuous reader concurrent with SetManyAtomicAsync always observes either the full pre-saga snapshot, the full post-saga snapshot, or all keys hidden - never a partial view. Quiescent topology (no concurrent split/resize/reshard). |
| Atomic-write reader isolation across shard split | ShardSplitTopologyTests.cs |
Same zero-or-all visibility invariant as AtomicVisibilityChaosTests, but the topology mutator drives a manual SplitAsync on shard 0 concurrently with a chain of SetManyAtomicAsync sagas. Exercises shadow-forward of saga prepares onto the destination shard and the saga terminal-broadcast retry onto the new owner via StaleShardRoutingException. |
| Atomic-write reader isolation across online resize | ResizeTopologyTests.cs |
Same zero-or-all visibility invariant, but the topology mutator runs an online ResizeAsync (MaxLeafKeys / MaxInternalChildren to 8) concurrently with the saga chain. Exercises shadow-forwarding from the source physical tree to the destination, the alias swap, and the saga terminal-broadcast retry onto the new owner via StaleTreeRoutingException. |
| Atomic-write reader isolation across online reshard | ReshardTopologyTests.cs |
Same zero-or-all visibility invariant, but the topology mutator runs a 4-shard → 8-shard ReshardAsync concurrently with the saga chain. Exercises the retroactive prepared-mutation sweep at BeginShadowWrite, the registry's TxDecisionRetention tombstone window, and the saga terminal-fan-out shadow-forward fallback that mirrors TxCommit / TxAbort marks onto the destination shard via the post-Complete MovedAwaySlots lookup. |
| Digest determinism under load | ChaosDigestIntegrationTests.cs |
ILattice.GetLeafProjectionDigestAsync is byte-stable across repeated calls in a write-quiescent window after concurrent writer / scanner load, and per-shard EntryCount sums equal CountAsync. |
| Range delete under load | ChaosRangeDeleteIntegrationTests.cs |
A worker repeatedly issues DeleteRangeAsync over the middle band of a 600-key universe (rd-000200 to rd-000400) while point writers, a refill writer re-inserting delete-band keys, scanners and a split coordinator run. Post-window, every key in the two protected bands is present and envelope-valid - the delete never strays outside its range - and scanners never observe a malformed value. The live count inside the delete band is deliberately not pinned, because deletes and refills race. |
| Compare-and-swap under contention | CompareAndSwapChaosTests.cs |
Four writers increment an eight-key counter universe through a read-then-SetIfVersionAsync (CAS) loop while a split coordinator churns shards. A lost CAS returns false and the caller re-reads with GetWithVersionAsync and retries; post-window, every stored counter equals the number of successful CAS calls made against it, so no update was lost, and no caller saw an exception outside the documented transient class. |
| Scan cancellation under load | ScanCancellationChaosTests.cs |
Scanner workers repeatedly open ScanKeysAsync / ScanEntriesAsync, cancel 5-24 ms in, and re-open while writers churn the universe. Cancellation must surface as OperationCanceledException (an enumeration abort is tolerated), a partial scan must never yield an unknown key or a malformed value, and a fresh full scan after the window must return exactly the pinned universe - the check that no cancelled enumerator left state behind. |
| Multi-silo restart under load | MultiSiloRestartChaosTests.cs |
Two-silo TestCluster, sustained write/read load on an ILattice tree, secondary silo restarted every ~2.5 s via TestCluster.RestartSiloAsync. Post-window invariants: pinned CountAsync and an envelope-valid value on every key, read back with a bounded retry (up to 60 s) that absorbs only silo-reactivation faults; during the window every exception is tolerated and counted, and only a read that returns a malformed value fails the run. Uses ProcessScopeMemoryGrainStorage (a static-dictionary-backed IGrainStorage shared across every silo in the test process) so secondary-silo restart does not wipe the shard-root / registry topology - per-silo Orleans memory storage would otherwise let a re-placed shard-root activation read empty state and overwrite the live topology with a fresh leaf root (the underlying split-brain that previously surfaced as InvalidCastException). It also registers one process-shared InMemoryWalStorageProvider on every silo through AddWalStorage, because a reactivated leaf rebuilds its entries from the write-ahead log and the default per-silo provider would vanish with the restarted silo, silently emptying every WAL partition it hosted. |
| Cross-tree atomic write under shard churn | ChaosCrossTreeAtomicWriteIntegrationTests.cs |
A commit worker drives one all-or-nothing SetManyAtomicAsync saga per generation into the same logical slot of three distinct trees, while per-tree split coordinators churn shards and reader workers probe every settled committed generation. Asserts cross-tree all-or-nothing: a settled committed key is present in all three trees or none, and every committed generation is durably present in all trees post-window, even as splits move keys between shards mid-saga on every tree. |
| Admission cap under cross-shard pressure | AdmissionControlChaosTests.cs |
Concurrent cross-shard writers drive one tree with an enforcing LatticeOptions.MaxLiveKeys cap and a second tree with only an advisory LatticeOptions.AdmissionAdvisoryLiveKeys ceiling. The enforcing cap must reject some writes with LatticeQuotaExceededException and keep rejecting once its aggregate settles above the cap - overshoot past the configured value is allowed - while the advisory tree never rejects. |
| Bounded read-through cache eviction | BoundedCacheEvictionChaosTests.cs |
Pins LatticeOptions.MaxCacheValueBytes so small that almost every cached payload is evicted down to its metadata, so nearly every read takes the eviction path back to the primary leaf. Under concurrent overwrite churn, eviction must never turn a live key into a false miss or surface a stale or cross-key payload. |
| Guarded atomic write under split churn | ChaosPredicateAtomicSetManyIntegrationTests.cs |
Guarded SetManyAtomicAsync<T> batches (Score >= Guard, evaluated server-side against each key's pre-saga value) under split churn and concurrent point writes: a batch over an always-matching band always commits and stamps every key, and a batch holding a permanently failing key always returns PreconditionFailed and writes nothing. |
| Conditional batch write under split churn | ChaosPredicateConditionalSetManyIntegrationTests.cs |
The guarded SetManyAsync<T> overload stamps a marker onto a band of keys while splits move them and point writers rewrite them: a key whose current value fails the guard is never written, a matching key is, and only submitted keys are ever considered. After the window it also audits the quiesced tree for leaves that no descent from their shard root reaches, and reads every key back through the routing path, so a split that lost its separator fails the run. |
| Predicate-filtered cursors under split churn | ChaosPredicateCursorIntegrationTests.cs |
Predicate-filtered key, entry and snapshot-entry cursors paged across a mid-paging split: pages stay in ascending key order, every surfaced item satisfies the predicate, and a stable band returns exactly its matching keys. |
| Conditional range-delete cursor under split churn | ChaosPredicateDeleteRangeCursorIntegrationTests.cs |
A resumable conditional range-delete cursor stepped to completion in bounded pages under split churn and conflicting writes tombstones every in-range key that matches the predicate and nothing else. |
Predicate GetManyAsync under split churn |
ChaosPredicateGetManyIntegrationTests.cs |
The predicate GetManyAsync<T> overload, evaluated server-side on the owning leaf: every returned value satisfies the predicate, and a stable band returns exactly its matching keys however splits move them. |
| Conditional range delete under split churn | ChaosPredicateRangeDeleteIntegrationTests.cs |
The conditional DeleteRangeAsync<T> overload tombstones exactly the in-range keys whose value matches the predicate, and never touches a key outside the range, while splits and point writers race it. |
| Predicate scans under split churn | ChaosPredicateScanIntegrationTests.cs |
The predicate ScanEntriesAsync<T> / ScanKeysAsync<T> / ScanValuesAsync<T> overloads across a mid-scan split: output stays in ascending key order, every surfaced value satisfies the predicate, and a stable band is returned in full. |
| Cluster-wide split admission after a crash | ClusterSplitConcurrencyChaosTests.cs |
Models a silo crashing mid-split by reporting split footprints that saturate the LatticeOptions.MaxClusterConcurrentAutoSplits ceiling and are then never refreshed; once their time-to-live lapses a fresh grant must succeed, so a crash cannot wedge splitting cluster-wide. |
| Retry policy masks storage faults | RetryPolicyChaosTests.cs |
Parametrized theory (5%, 15% and 30% fault probability) that arms one-shot storage write faults and requires every caller-side SetAsync to succeed through BoundedExponentialRetryPolicy under an ambient LatticeIdempotencyContext; a companion test arms a fault on every 15 ms tick while incrementing a PnCounter and requires the counter to equal the number of increments that succeeded, so a retry never double-counts. |
| Shard consolidation under churn | ShardConsolidationChaosTests.cs |
Online shard-consolidation folds run while a split driver shatters the tree, writers ingest, and shard roots are force-deactivated: no key ever becomes unreachable, no acknowledged write is lost, and no virtual slot is left unrouted. |
| Predicate translator and evaluator agree | Predicates/LatticePredicatePushdownChaosTests.cs |
Eight workers evaluate a storm of structurally random predicates against a shared pool of 256 encoded documents, both through the server-side predicate evaluator and as the compiled lambda; any disagreement fails the run. |
Cross-cluster, gRPC, and Azure Table WAL suites
The replication packages and the Azure Table WAL provider ship their own chaos suites - cross-cluster convergence
and atomic visibility (test/lattice.replication/Chaos/), the gRPC transport
suite (test/lattice.replication.grpc/Chaos/), and the Azure Table WAL suite
(test/lattice.storage.azuretable/Chaos/). They drive the real replication
pipeline using in-process test clusters, a simulated delivery pump, an in-memory
test server, and the Azurite emulator, and are documented in
the replication chaos tests.
Schema enforcement and versioning atomicity suite (test/lattice.schema/Chaos/)
The lattice.schema companion package ships its own chaos fixtures that prove the
core atomic-write guarantee is preserved while the schema control plane mutates
underneath it - a policy is set or cleared, or a target schema version is advanced
and eagerly migrated - concurrently with a chain of atomic sagas. They run on a
single-silo TestCluster with core lattice, schema enforcement, and schema
versioning all registered (SchemaAtomicChaosClusterFixture), and are tagged
[Category("Chaos")] [NonParallelizable] like most other suites.
| Test class | File | Purpose |
|---|---|---|
| Atomic write under policy churn | AtomicWriteUnderPolicyChurnChaosTests.cs |
A committer drives cross-tree SetManyAtomicAsync sagas (alternating a compliant and a non-compliant leg) while a churner flips each participating tree's enforcement policy between "require JSON" and "no policy". Asserts every saga is decided as a unit against the policy current at admission - it either commits every leg or rejects the whole batch and mutates no tree - and a concurrent reader never observes a torn (cross-generation) snapshot. |
| Atomic write under version advance | AtomicWriteUnderVersionAdvanceChaosTests.cs |
A committer drives single-tree SetManyAtomicAsync batches into one versioned tree while a churner advances the target schema version v1 -> v2 -> v3 concurrently, then a quiesced eager background migration re-stamps the tree. Asserts every batch lands all-or-nothing, that every read is always envelope-stripped and upcast to the current target, and that the eager migration preserves the last committed snapshot. |
The workload
The four full-workload single-cluster fixtures (Tests 1-4 below) run a
parallel workload over a fixed key universe. The resize and reshard
fixtures pre-register their tree at 4 shards with MaxLeafKeys = 4; the
happy-path and faults fixtures seed a fresh tree id without pre-registering
it, so their trees take the library defaults (64 shards, 128 keys per leaf,
128 children per internal node) and their topology churn comes from the
split driver. Writers only rewrite existing keys with
monotonically-increasing values of the form v-{keyIndex}-{writerId}-{seq}.
Any value matching that envelope proves the byte array is internally
consistent.
The atomic-visibility fixtures (Tests 5-6, 8), the per-mode convergence fixtures (Test 9), the multi-site smoke (Test 10), the range-delete / CAS / scan-cancel public-API fixtures, the production-shipper fixtures (WAL trim, liveness + inbound stats, compaction + shipping), and the downstream-package fixtures (gRPC transport, Azure Table WAL) do not follow this exact shape - each defines its own universe and worker mix appropriate to the invariant it targets. See the per-test sections, the suite tables at the top of this document, or the replication chaos tests for the replication and storage fixtures.
Fixture and parameter differences:
| Test | Fixture | MaxLeafKeys |
MaxInternalChildren |
Key prefix | Universe |
|---|---|---|---|---|---|
| Happy-path | FourShardClusterFixture (tree not pre-registered: 64 shards) |
128 (library default) | 128 (library default) | chaos-{i:D5} |
500 |
| Chaos + faults | MultiShardFaultInjectionClusterFixture (tree not pre-registered: 64 shards) |
128 (library default) | 128 (library default) | fchaos-{i:D5} |
200 |
| Chaos + resize | FourShardClusterFixture (4 shards) |
4 -> 16 mid-run |
128 (library default) -> 16 mid-run |
resize-chaos-{i:D5} |
200 |
| Chaos + reshard | FourShardClusterFixture (4 shards -> 8, or -> 2 in the shrink run) |
4 | 128 (library default) | reshard-chaos-{i:D5} |
200 |
flowchart LR
Seed[Seed universe<br/>N keys] --> Chaos
subgraph Chaos[Chaos window]
direction TB
PW[Point writers] --> Tree
BW[Bulk writers] --> Tree
AW[Atomic writers] --> Tree
PR[Point readers] --> Tree
BR[Bulk readers] --> Tree
SC[Scanners] --> Tree
CT[Counters] --> Tree
TM[Topology mutator<br/>split / resize / reshard<br/>± fault injector] --> Tree
Tree[(ILattice)]
end
Chaos --> Assert[Assert invariants]
Worker categories (the exact mix and counts vary per test; each fixture declares them as constants at the top of its source):
- Point writers -
SetAsyncon random universe keys. - Bulk writers -
SetManyAsyncwith batches of 8 random keys (happy-path / faults only). - Atomic writers -
SetManyAtomicAsyncwith batches of 2 random keys (happy-path / faults only). - Point readers -
GetAsync; validates envelope if a value is returned. - Bulk readers -
GetManyAsyncfor 16 random keys (happy-path / faults only). - Scanners - happy-path: rotating
ScanKeysAsync,ScanEntriesAsync, reverse scan and range scan, each of which must yield exactly the universe (or the range) in order, with no duplicates and no unknown keys; resize and reshard: full-treeScanKeysAsyncwith no duplicates and no unknown keys; faults:ScanEntriesAsync, checking order, unknown keys and envelopes. - Counters -
CountAsyncmust always equal the pinned universe size (the faults fixture lets the count drift during its fault window). - Topology mutator - test-specific:
- happy-path: every ~200 ms drives a manual split, and then a split pass, on a randomly chosen non-empty shard.
- faults: the same split driver at a ~500 ms cadence, plus a fault
injector that arms random
WriteStateAsyncfaults. - resize: initiates
ResizeAsynconce at the window start and pumps the coordinator to completion. - reshard: initiates
ReshardAsync(8)once at the window start and pumps the reshard coordinator and the per-shard split passes to completion; the shrink run initiatesReshardAsync(2)instead and pumps the coordinator and every fold it starts.
Test 1 - Happy-path chaos (ChaosIntegrationTests)
This test establishes that ILattice's consistency guarantees hold
during the chaos window, not just after it closes. Every operation
observes a fully consistent view of the tree.
What it proves
| Invariant | Mechanism under test |
|---|---|
CountAsync returns the exact universe size, always |
Per-slot count routing against the authoritative ShardMap, each shard counted in work-bounded batches, plus version stability check |
ScanKeysAsync / ScanEntriesAsync yield exactly the universe, no duplicates, no unknowns, in strict sorted order |
In-line reconciliation-cursor injection into the k-way merge + HashSet dedup |
ScanKeysAsync(null, null, reverse: true) yields the full universe in reverse |
Reverse-scan path also reconciles |
ScanKeysAsync(start, end) yields exactly the in-range slice |
Range pruning is slot-aware |
GetAsync / GetManyAsync never return a corrupt value |
Writes are atomic per-shard; CRDT LWW resolves concurrent rewrites |
| No public-API call throws an unhandled exception | Stale routing retries and enumeration aborts are transparent |
| Splits during a scan never cause data loss, duplication, or out-of-order output | MovedAwaySlots + version stamping + in-line reconciliation |
Tolerated transients
The test counts these as transient rather than as failures:
EnumerationAbortedException- a stream cursor grain deactivated mid-iteration. The caller re-issues the scan.- The stale shard-routing signal - a routing activation used a cached shard map after a concurrent split committed its swap. The routing tier invalidates its map and retries within a bounded wall-clock budget, so the signal reaches the caller only once that budget is spent.
- An
InvalidOperationExceptionreporting that an atomic write failed and was rolled back (a saga aborted by a transient routing fault mid-split), or that a count, scan orGetManyAsyncexhaustedLatticeOptions.MaxScanRetrieswhile the topology or the saga rate kept changing. TimeoutException- an Orleans call timeout under saturated load.
Any other exception, or any observed envelope/duplicate/missing-key violation, fails the test.
Pass criteria
After the chaos window closes:
CountAsyncmatches the pinned universe size exactly.ScanKeysAsyncyields exactly the pinned universe (no gaps, no extras).- Every worker category performed at least one operation (proves the workload ran under real concurrency, not a degenerate single-thread schedule); splits and atomic writes count attempts.
- Once the atomic writer's committed and transiently failed sagas total at least three, at least half of them committed.
- Zero envelope violations were observed during the window.
Test 2 - Chaos + storage faults theory (ChaosWithFaultsIntegrationTests)
This parametrized theory layers random storage faults on top of the same
workload. Unlike the happy-path test, per-operation invariants are
weakened during the fault window - arbitrary exceptions are tolerated
because a failed WriteStateAsync legitimately cascades into split
aborts, stale routing, and count drift. Instead, the test asserts
eventual convergence: once faults stop and the cluster quiesces,
the tree must recover to the exact same pinned universe with every
value still matching its envelope.
faultProbability is the probability, per 20 ms tick, that the fault
injector arms a fresh one-shot WriteStateAsync fault on a randomly
chosen target grain (the initial leaf and the shard-root grain of shards
0-3, a subset of the tree's 64 shards).
Orleans' FaultInjectionGrainStorage consumes each armed fault on the
next write for that grain, so the injector re-arms continuously to
approximate a steady-state failure rate.
Note: Orleans' one-shot fault API caps concurrent armed faults at ≈
|targets|. HigherfaultProbabilityprimarily drives faster re-arm latency rather than a linear increase in fault count. The gradient is still meaningful for exercising recovery paths under progressively heavier disruption.
Test phases
sequenceDiagram
participant Test
participant Tree as ILattice (64 shards)
participant Injector
participant Workers
Test->>Tree: Seed universe (faults off)
Test->>Injector: Start at p=faultProbability
Test->>Workers: Start 13 role workers + split coordinator
loop Chaos window (8 s)
Injector-->>Tree: AddFaultOnWrite(random target)
Workers-->>Tree: mixed reads/writes/scans/splits
Note over Workers: exceptions tolerated<br/>envelope-check values if observed
end
Test->>Injector: Stop (cts fires)
Test->>Tree: DrainAndHealAsync (up to 15 s)
Note over Tree: retry writes over universe<br/>until 3 consecutive clean passes
Test->>Tree: Assert strong invariants
Tolerated during faults
Every exception type is tolerated and counted (tolerated-write-errors,
tolerated-read-errors, tolerated-scan-errors, etc.). A single
storage fault cascades into many observable shapes:
- Direct
InvalidOperationExceptionfrom the faulted write. OrleansExceptionwrappers when a faulted grain deactivates.EnumerationAbortedExceptionif a stream cursor was on the deactivated grain.StaleShardRoutingExceptionafter a shard map swap when the split coordinator crashed and resumed mid-phase.ArgumentExceptionfrom the injector itself when a target already has an armed fault pending (skipped).
Envelope violations (a value that doesn't start with v-{index}-) are
not tolerated - CRDT LWW is supposed to preserve atomicity of the
value payload even when the wrapping write fails.
Healing phase (DrainAndHealAsync)
After the fault injector stops, lingering armed faults remain on whichever targets weren't hit during the chaos window. The test drains them by replaying writes over the entire universe until 3 consecutive passes complete exception-free, bounded by a 15 s timeout. This loop:
- Consumes any remaining one-shot faults (each fires once on its next write, clearing itself).
- Gives resumable splits and pending root promotions time to reach
their
RunSplitPassAsynckeepalive tick and replay. - Rewrites every universe key with a fresh value on each pass
(
v-{i}-heal-{pass}), so after three clean passes every key holds a value a fault-free pass wrote, and that value matches its envelope.
Pass criteria (post-quiescence)
After healing:
CountAsync == UniverseSizeexactly.ScanKeysAsyncyields exactly the pinned universe.ScanEntriesAsyncyields exactly the pinned universe with every value matching its envelope.- Every universe key appears in the post-heal
ScanKeysAsync. - Zero envelope violations were observed during the whole run.
- Point writes, point reads, scans, counts and splits each completed at
least once and at least one atomic write was attempted; the injector
armed at least one fault (for
p > 0); in the no-fault baseline at least 70% of atomic writes committed (with fewer than ten attempts: at least one committed and at most one failed).
Test 3 - Chaos + online resize (ChaosResizeIntegrationTests)
This test targets the online resize path. A full concurrent workload
runs against a seeded tree while ResizeAsync changes the B+ fan-out
to MaxLeafKeys = 16 / MaxInternalChildren = 16 under
SnapshotMode.Online. The resize starts inside the chaos window and its
phases - snapshot drain, alias swap, per-shard reject phase, cleanup - are
pumped there under live traffic; any phase still running when the window
closes is finished in a post-window drain of up to 30 s before the
invariants are checked.
Recovery surfaces exercised
TreeResizeGrainphase machine (Snapshot → Swap → Reject → Cleanup) under sustained traffic.- Shadow-forwarding on every source shard - the workload's live point writes during the drain must be mirrored to the destination with their original HLCs (the forward carries last-writer-wins writes only; typed CRDT deltas and bulk appends are not mirrored).
- Alias swap - mid-flight
GetAsync/SetAsyncon a stateless-workerLatticeGrainactivation holding a stale alias must transparently re-resolve and retry. - Strongly-consistent
CountAsync/ScanKeysAsyncduring the online snapshot drain and Rejecting phase.
Tolerated transients
The same set as the happy-path test, plus StaleTreeRoutingException
raised during the alias swap window.
Pass criteria
After the chaos window closes:
CountAsyncmatches the pinned universe size exactly.ScanKeysAsyncyields exactly the pinned universe.- The resize coordinator reports itself idle, so the resize ran to completion.
- Every worker category performed at least one operation; the resize was driven to completion.
- Zero envelope violations observed during the window.
Test 4 - Chaos + online reshard (ChaosReshardIntegrationTests)
This test targets the online reshard path - growing the physical shard
count from 4 to 8 while the tree continues to serve traffic. The
reshard is kicked off synchronously before the chaos timer starts (so
cold-activation cost doesn't burn the window on slow Release CI
runners); the in-window driver only pumps the migration loop to
completion. A second test in the fixture runs the same workload while
ReshardAsync(2) shrinks a fresh 4-shard tree to 2 shards, its driver
pumping the coordinator and every fold it starts. Both runs finish any
residual work in a post-window drain of up to 30 s.
Recovery surfaces exercised
TreeReshardGrainmigration loop under sustained traffic - eligibility filtering, dispatch-budget clamping (MaxConcurrentMigrations), re-evaluation across ticks.ShardMapconvergence when reshard-dispatched splits race with workload writes (shadow-write, drain, swap, reject, permanentMovedAwaySlots).- No invariant drift across the full reshard window.
- Shrink run: fold planning and concurrency under sustained traffic, every fold's drain, freeze, swap and finalise racing live writes, and the release of each retired donor's storage while scans and counts may still hold a pre-fold shard map.
Neither run exercises the autonomic hot-shard monitor's rule that
suppresses its passes while a reshard is in flight: the fixture keeps
the default HotShardSampleInterval and AutoSplitMinTreeAge, so every
monitor pass returns inside the minimum-tree-age grace period, before it
reaches that rule, until well after a reshard normally completes, and no
assertion reads the monitor.
Tolerated transients
EnumerationAbortedException, the stale shard-routing signal,
TimeoutException, and the same saga-rollback and
MaxScanRetries-exhaustion InvalidOperationException messages as the
happy-path test.
Pass criteria
After the chaos window closes:
CountAsyncmatches the pinned universe size exactly.ScanKeysAsyncyields exactly the pinned universe.- The reshard coordinator reports itself idle, so the reshard ran to completion.
- The post-reshard
ShardMaphas at leastReshardTargetdistinct physical shards. - Every worker category performed at least one operation.
- Zero envelope violations observed during the window.
The shrink run checks the same count, scan, idle-coordinator, worker and
envelope criteria, and additionally that the final ShardMap holds exactly
two distinct physical shards and that each of the two shards that left the
map reports itself retired and holds no leaves.
Test 5 - Atomic-write reader isolation (AtomicVisibilityChaosTests)
This test asserts the universal reader-isolation invariant for
SetManyAtomicAsync: every poll of a continuous reader concurrent
with an in-flight saga must observe either the full pre-saga snapshot,
the full post-saga snapshot, or all keys hidden - never a partial view.
The invariant holds per poll, with no bounded-window caveat, across
50 sequential saga rounds at a 10 ms reader cadence.
What it proves
| Invariant | Mechanism under test |
|---|---|
| Continuous reader observes zero-or-all keys at every poll | The leaf's prepared-write commit path stages every saga write in a per-transaction pending bucket, and every key a read reaches resolves against one transaction-registry decision, the tree-wide visibility flip |
| Saga drives 16 keys spanning multiple leaves through the full prepare → terminal pipeline | AtomicWriteGrain per-shard terminal broadcast, idempotent under concurrent retry |
| Final post-round value is preserved across 50 iterations | LWW resolution under saga commit ordering |
Workload
- Seed phase -
SetAsyncfor each of 16 keys (atomic-00…atomic-15) at round 0, followed by a singleSetManyAtomicAsyncat round 0 to land all keys through the saga path before reader rounds begin. - Saga rounds - 50 sequential rounds. Each round starts a continuous reader task that polls all 16 keys via
GetManyAsyncevery 10 ms, and concurrently issuesSetManyAtomicAsyncwith the post-round value envelope. - Reader classification - every poll is bucketed:
fullPre(every key at the previous round's value),fullPost(every key at the new round's value),fullHidden(every key missing during the prepare → terminal window), or split (any mixed observation, which fails the test).
Pass criteria
- Zero split-view failures across all 50 rounds.
totalPolls > 0andfullPostPolls > 0(proves the reader and saga ran under real concurrency).- Final
GetManyAsyncof all 16 keys yields the round-50 envelope on every key.
Tolerated transients
The reader's GetManyAsync may observe OperationCanceledException at the round boundary; saga writes are not expected to surface any transient - the saga's own retry-on-stale-routing logic absorbs split / resize / reshard activity at the API layer.
Companion observability
See Metrics.
Test 6
Three sibling fixtures extend Test 5's reader-isolation invariant
across each of the three online topology mutations (shard split,
online resize, online reshard). Every fixture seeds the same 16-key
universe, then drives 15 sequential SetManyAtomicAsync rounds while
the topology mutator runs in parallel; a continuous reader polls all
16 keys every 10 ms and every poll must observe either the full
pre-round value, the full post-round value, or all 16 keys hidden -
never a partial subset.
What they prove
| Invariant | Mechanism under test |
|---|---|
| Saga prepares survive a mid-flight shard split | Source shard's shadow-forward pipeline mirrors prepared entries to the destination during the split's drain phase; saga terminal-broadcast retries onto the new owner via StaleShardRoutingException |
| Saga prepares survive a mid-flight online resize | Source physical tree shadow-forwards every last-writer-wins write (including saga prepares; typed CRDT deltas and bulk appends are not mirrored) to the destination physical tree during the snapshot drain; the alias swap is observed via the stale tree-routing signal and the saga terminal-broadcast retries onto the new owner |
| Saga prepares survive a mid-flight online reshard | TreeReshardGrain migration loop dispatches per-shard splits; the retroactive prepared-mutation sweep at BeginShadowWrite and the terminal-fan-out shadow-forward fallback together mirror prepares and the saga's TxCommit / TxAbort marks onto the destination shard via the post-Complete MovedAwaySlots lookup; the registry's TxDecisionRetention tombstone window absorbs duplicate terminals |
Workload (per fixture)
- Seed phase -
SetAsyncfor each of 16 keys at round 0, followed by a singleSetManyAtomicAsyncat round 0 so the universe is pinned through the saga path before the topology mutator starts. - Topology kick-off - exactly once before the saga loop:
SplitAsync(shard 0)forShardSplitTopologyTests,ResizeAsync(MaxLeafKeys=8, MaxInternalChildren=8)forResizeTopologyTests, orReshardAsync(8)forReshardTopologyTests. A background driver pumps the coordinator'sRunSplitPassAsync/RunResizePassAsync/RunReshardPassAsyncto completion while the saga loop runs. - Saga loop - 15 sequential rounds. Each round starts a continuous reader task that polls all 16 keys via
GetManyAsyncevery 10 ms and concurrently issuesSetManyAtomicAsyncwith the post-round value envelope. - Reader classification - identical to Test 5:
fullPre,fullPost,fullHidden, or split (mixed observation, fails the test). - Drain phase - after the saga loop, the test pumps the coordinator to idle and asserts the final post-round value is present on every key.
Pass criteria (per fixture)
- Zero split-view failures across all 15 rounds.
totalPolls > 0andfullPostPolls > 0.- Final
GetManyAsyncof all 16 keys yields the round-15 envelope on every key. - The split, resize or reshard coordinator reports itself idle before the test exits.
Tolerated transients
- The stale shard-routing and stale tree-routing signals on the reader's
GetManyAsync- every fixture's reader loop catches both and retries. OperationCanceledExceptionat the round boundary when the reader's CTS fires.
Companion observability
Same orleans.lattice.atomic_write.* histograms as Test 5, plus the topology-mutator-side counters that fire when the split / resize / reshard actually mutates the tree (orleans.lattice.leaf.splits, orleans.lattice.shard.splits_committed, and the per-split orleans.lattice.split.retroactive_forward.duration / .entries pair on shadow-forward). See Metrics.
Test 7 - Digest determinism under load (ChaosDigestIntegrationTests)
This test exercises ILattice.GetLeafProjectionDigestAsync under
sustained concurrent load and asserts two determinism invariants that
gate the digest's value as a cross-silo divergence detector:
byte-identical repeated calls in a write-quiescent window, and
per-shard EntryCount sums equal CountAsync.
What it proves
| Invariant | Mechanism under test |
|---|---|
| Digest hash is byte-stable across repeated calls when no writes occur in between | The digest is an order-independent XOR fold of per-entry XxHash128 contributions, maintained at each mutation and aggregated up the internal nodes, so once the last coalesced publish has landed, repeated reads in a write-quiescent window hash the same persisted aggregate |
Sum of per-shard EntryCount equals CountAsync |
Shard-level digest counts are accountable against the tree's own population view |
| Digest computation is safe to call concurrently with foreground writer / scanner traffic | No exception is observed on any worker - digest rendering does not block or interfere with the read / write path |
Workload
- Seed phase - 200 keys (
chaos-digest-{i:D5}) preloaded withSetAsync. - Chaos window (~8 s) - 4 writer tasks rewriting random keys, 2 scanner tasks calling
KeysAsync, 1 digest poller callingGetLeafProjectionDigestAsyncfor every shard in a tight loop. - Quiesce phase - after the chaos window closes, the digest is sampled twice in succession with no intervening writes.
Pass criteria
- No exception observed on any worker during the chaos window (
EnumerationAbortedExceptionis tolerated on the scanner; everything else is fatal). - For every shard,
secondPass[s].HashequalsfirstPass[s].HashandEntryCountis equal. - Sum of
firstPass[s].EntryCountequalstree.CountAsync(). tree.CountAsync()equals 200 (writers only update existing keys; no inserts or deletes).
Tolerated transients
EnumerationAbortedExceptionfrom the scanner'sKeysAsyncenumeration when a stream cursor grain deactivates mid-iteration.
Test 8 - Cross-cluster atomic-visibility chaos (CrossClusterAtomicVisibilityChaosTests)
This test asserts the cross-cluster receiver-side reader-isolation
invariant for SetManyAtomicAsync: a saga authored on one site and
shipped via the WAL replication transport to two peer sites must be
observed all-or-nothing on every receiver, even when the inter-site
delivery topology is partitioned and healed mid-workload. It is the
cross-cluster sibling of Test 5
and exercises the same reader-isolation mechanism - prepared writes
staged in per-transaction pending buckets and made visible by one
transaction-registry decision - through the receiver-side
prepared/terminal apply seam.
What it proves
| Invariant | Mechanism under test |
|---|---|
| Every saga's keys land all-or-nothing on every receiver site | Receiver-side prepared/terminal apply seam staging prepared writes in the leaf's per-tx pending bucket, then flipping them on terminal arrival |
| Source HLC rides through the wire verbatim | LatticeHlcOverrideContext wrapping the apply call so the receiver does not stamp a fresh local HLC |
| Repeated terminal delivery is idempotent | The per-tree transaction registry classifies an arriving terminal against the decision already recorded and returns without a write when the outcome matches, so a redelivery inside the retention window changes nothing; the leaf additionally absorbs a second delivery within one activation from its recently-terminal set |
| Mid-workload partition does not produce partial-saga visibility on any site | Prepares queued behind the partition and the matching terminal both ship after heal; the receiver-side staging buffer holds prepared entries off the visible projection until the terminal arrives |
| Producer-side per-key WAL filter does not strand terminals | ReplicationShipperGrain.ShouldShip bypasses KeyFilter / KeyPrefixes for TxCommit / TxAbort records |
Workload
- Topology - three independent
TestClusterinstances (site-0,site-1,site-2) wired throughMultiSiteClusterFixture, each with its ownMemoryGrainStorage-backed Lattice and a per-siteReplicationApplierdriven by the chaos delivery pump. - Author phase - every site concurrently runs 6 local
SetManyAtomicAsyncsagas (4 keys per saga, deterministic per-saga key prefix), so each saga emits 4 preparedSetrecords + 1TxCommitper touched shard onto its source site's WAL. - Partition cycle -
site-0's loop isolatessite-2after the first third of its workload and heals it after the second third. TheChaosDeliveryPumpcontinues polling but does not advance the cursor on the partitioned edges, so the prepares and terminals authored during the outage queue at the source and ship en bloc after heal. - Drain phase - after every author task completes,
pump.HealAllAndDrainAsyncheals every edge and waits for every per-edge cursor to catch up to its sender's WAL tail, with a 60 s timeout.
Pass criteria
- On every receiver site, for every authored saga: the count of visible keys is either
0(saga not yet shipped or aborted) orKeysPerSaga(saga fully visible). Any partial-visibility count is a saga-atomicity violation and fails the test. - On every receiver site, every saga's keys are present after the drain - every authored saga is a local commit, so universal visibility is the strong post-drain assertion.
pump.PumpErrorsis empty - a transient grain failure during the run surfaces here without aborting the loop, but the convergence assertion remains the source of truth.
Tolerated transients
The chaos pump's per-edge loop catches and queues transient grain exceptions onto PumpErrors; only sustained faults that prevent convergence within the drain timeout fail the test. The universe is small (3 sites x 6 sagas x 4 keys = 72 keys) so authoring completes in seconds and the drain typically settles in under a second after heal.
Companion observability
Saga writes emit orleans.lattice.atomic_write.duration / orleans.lattice.atomic_write.batch_size on the authoring site (see Metrics). On the receiver side every inbound entry's apply attempt records orleans.lattice.replication.apply.duration, tagged with the tree, the source peer's cluster id (peer), the apply outcome and the owning tenant.
Test 9
Four of the replication suite's per-merge-mode convergence fixtures -
last-writer-wins, OR-Set, PN-Counter and MV-Register; the suite covers
every other LatticeMergeMode value too, catalogued in
the replication chaos tests -
prove that the producer-side change-feed → shipper → receiver-side
applier pipeline converges every site to the same final state under
concurrent multi-site writes and mid-workload partitions. Every
fixture wires three sites through MultiSiteClusterFixture /
ChaosDeliveryPump, declares the test tree under the relevant merge
mode, lets every site author a disjoint workload while one site is
isolated and re-healed mid-flight, drains the pump, then asserts the
mode-specific convergence invariant pointwise across sites.
What they prove
| Fixture | Mode | Convergence invariant | Mechanism under test |
|---|---|---|---|
LwwRegisterConvergenceChaosTests |
LwwRegister |
Every site reads the same VersionedValue after drain - the lexicographic (HLC, originClusterId) winner across all authored writes |
The receiver applies each shipped write through its replication-apply seam with the source HLC, so LWW resolution picks the same winner on every site; per-edge change-feed cursors do not skip entries across partition heal |
OrSetConvergenceChaosTests |
OrSet |
Every site's OrSet(key).GetAsync() yields exactly the union of authored adds (test 1), or the union of authored adds minus the union of authored removes (test 2) |
The receiver folds each shipped typed delta into its local state under LatticeOriginContext (a bootstrap entry that carries full state instead of a delta is merged and written back by compare-and-swap); OR-Set's commutative-monoid merge absorbs out-of-order receive |
PnCounterConvergenceChaosTests |
PnCounter |
Every site's PnCounter(key).ValueAsync() returns the same algebraic sum of authored deltas |
The same receiver-side typed-delta fold as OR-Set; per-replica P/N maps merge by component-wise max |
MvRegisterConvergenceChaosTests |
MvRegister |
Every site's MvRegister<T>(key).ValuesAsync() yields exactly the dot-frontier expected from the authored history: concurrent writes survive as a multi-value set, and any write whose dot is causally dominated by a later writer's observed context is superseded on every replica |
The same receiver-side typed-delta fold under LatticeOriginContext; dot-context merge drops dominated entries and pointwise-maxes the per-replica context maps |
Workload (per fixture)
- Topology - 3
TestClusterinstances wired throughMultiSiteClusterFixturedeclared with the fixture's merge mode;ChaosDeliveryPumpdrives every inter-site edge. - Author phase - every site authors a disjoint family of writes against a single key (
k):- LWW: 40 sequential
SetAsynccalls per site. - OR-Set test 1: 25 sequential
AddAsynccalls per site. - OR-Set test 2: 15 adds + 2 observed-removes per site.
- PN-Counter: 30 increments + 10 decrements per site.
- MV-Register test 1: every site is isolated before any write, each writes one value concurrently, and all three values survive the heal as a multi-value set.
- MV-Register test 2: two-phase scenario - site 0 issues two sequential
SetAsynccalls and drains so every peer observes its dot context; then sites 1 and 2 write concurrently behind a partition that isolates site 2 from site 1, producing two surviving concurrent dots that both dominate the site-0 entry.
- LWW: 40 sequential
- Partition cycle - one site's loop isolates a target site for part of its workload, and both the window and the (driver, target) pair vary per fixture: site 2 isolates site 1 over the middle half of the LWW writes; site 0 isolates site 2 from half-way to its last add in OR-Set test 1, and over the middle third in test 2; site 1 isolates site 0 over the middle third for PN-Counter; in MV-Register every site is isolated for test 1's concurrent writes, and site 2 for test 2's concurrent phase.
- Drain phase - after every author task completes,
pump.HealAllAndDrainAsync(30 s)heals every edge and waits for every per-edge cursor to catch up.
Pass criteria
- LWW: pointwise equality of
(Value, Version)across all 3 sites after drain. - OR-Set: pointwise set-equivalence of
Elements()across all 3 sites against the closed-form expected union. - PN-Counter: pointwise equality of
ValueAsync()across all 3 sites againstSiteCount * (IncrementsPerSite - DecrementsPerSite). - MV-Register: pointwise set-equivalence of
ValuesAsync()across all 3 sites against the closed-form expected frontier (surviving concurrent dots only).
Tolerated transients
- PN-Counter and MV-Register wrap
IncrementAsync/DecrementAsyncandSetAsyncin a call-site retry (8 attempts with linear backoff, then one final unretried call) that catches anInvalidOperationExceptioncarryingCAS budget exhausted. The wrapper is defensive only: both accessors now apply one producer-side delta per call - a single read, then oneApplyCrdtDeltaAsync, with no compare-and-swap loop - so neither raises that exception, and a foreign-origin merge that lands mid-call cannot abort the local write. - OR-Set / LWW: no per-call retry needed - the pipeline is non-blocking from the producer side; transient pump errors queue onto
pump.PumpErrorsand surface in the post-drain assertion if convergence stalls.
Companion observability
Producer-side: the orleans.lattice.replication.ship.* family (see Metrics) - the ship.duration, ship.effective_batch_size and ship.ack_latency histograms plus its payload, manifest and dictionary counters. Receiver-side: orleans.lattice.replication.apply.duration, tagged with the tree, the source peer's cluster id (peer), the apply outcome and the owning tenant - it carries no merge-mode tag, so each fixture's streams are told apart by its tree - and a 3-site run records two peer values on each receiver site (2 inbound edges each), six in all.
Test 10 - Multi-site fixture smoke (MultiSiteClusterFixtureSmokeTests)
These are not chaos tests in the bombardment sense - they are
deterministic single-write smoke tests that pin the simpler
invariants the cross-cluster convergence suite relies on. They ship
under [Category("Chaos")] so they run in the chaos tier alongside the
suites they diagnose; a failure here
short-circuits failure analysis on the larger convergence /
atomic-visibility fixtures by isolating which seam broke.
What they prove
| Test | Invariant |
|---|---|
Site_change_feed_yields_locally_authored_lww_entry |
A locally-authored SetAsync lands on the per-site producer-side WAL, the local IChangeFeed yields the captured WalRecord, and the record carries the site's OriginClusterId and declared LatticeMergeMode |
Delivery_pump_ships_lww_entry_from_site_0_to_site_1 |
A locally-authored SetAsync on site 0 propagates end-to-end through the chaos delivery pump to site 1, where a subsequent GetAsync returns the authored value |
Tolerated transients
None - these tests run on a quiescent 2-site fixture with no partition / concurrency. Any exception is fatal.
Test 11 - Cross-tree atomic write under shard churn (ChaosCrossTreeAtomicWriteIntegrationTests)
This test asserts the cross-tree all-or-nothing visibility invariant
for SetManyAtomicAsync: a key committed by a cross-tree saga
is present in every participating tree or in none of them - a
reader must never observe a partial cross-tree commit (some trees
flipped, others not), even while shard splits move keys between shards
mid-saga on every tree.
A single commit worker writes a fresh per-generation key into the same logical slot of three distinct trees as one all-or-nothing cross-tree saga; three reader workers continuously probe committed generations across all three trees; and one split coordinator per tree churns shards continuously throughout the ~15 s window.
What it proves
| Invariant | Mechanism under test |
|---|---|
| A settled committed generation key is present in all three trees | Coordinator's single global decision flips visibility uniformly across every participating tree's ITxRegistryGrain via delegation |
| A settled committed key never vanishes under concurrent split churn | Per-tree finalize promotes prepared entries into the main store before settlement; splits are read-transparent for promoted committed keys |
| Every committed generation is durably present in all trees post-window | Cross-tree all-or-nothing completeness across the whole run |
Workload
- Seed phase - 200
SetAsynckeys per tree across three trees (xct-{run}-0..2), each on a four-shard fixture (FourShardClusterFixture) so splits are enabled. - Commit worker - one cross-tree saga per generation at a 50 ms cadence: a
LatticeTreeBatchper tree writingx-{gen}into all three trees under operation idxctop-{run}-{gen}. Asserts each saga returnsCommitted. - Reader workers - three readers repeatedly probe the six most recently settled generations (ending at the commit frontier minus a
SettleMarginof 5) in all three trees; a settled generation that reads absent in any tree is a failure. - Split coordinators - one per tree at a 250 ms cadence, picking a random non-empty shard and driving
SplitAsync+RunSplitPassAsync.
Pass criteria
- Zero invariant violations (no settled committed key absent from any tree).
- Post-window: every committed generation
0..lastCommittedpresent in all three trees. - The commit worker committed at least one saga, readers performed at least one probe, and split coordinators attempted at least one split (proves real concurrency).
Why the invariant is checked only on settled generations
The cross-tree feature guarantees all-or-nothing commit, not a
simultaneous cross-tree read snapshot. Each per-tree GetAsync is an
independent point-in-time read, so the global decision flip landing
between a reader's three samples yields a benign transient skew that is
not a partial-commit violation. The reader therefore checks monotonicity
only on generations the commit frontier has advanced a safety margin
past, by which point every participant's finalize has promoted the entry
into its main store. Post-window completeness then closes the gap for the
in-flight tail.
Tolerated transients
The stale shard-routing signal, EnumerationAbortedException, cursor
snapshot expiry or pin exhaustion, TimeoutException, and the saga's own
rollback / topology-churn / fan-out-saturation / "fewer than 2 virtual
slots" InvalidOperationException messages are bucketed as transient -
the commit worker moves on to its next generation, and the readers and
split coordinators keep looping; any other exception fails the test.
Test 12 - Cross-cluster cross-tree atomic visibility (CrossClusterCrossTreeAtomicVisibilityChaosTests)
This test (in the replication suite) asserts the receiver-side
cross-tree all-or-nothing invariant: when a cross-tree
SetManyAtomicAsync batch spanning two replicated trees ships to a remote
cluster, a reader on the receiver must never observe one tree committed
while a sibling tree is still pre-saga. Because each tree's terminals
replicate on their own WAL feed, the receiver routinely applies one tree's
terminal before the other's; the receiver coordinator grain
(ILatticeCrossTreeReceiverGrain) must hold every participating tree
invisible until all of the batch's replicated terminals have arrived, then
flip them together.
Workload
- Two sites, each authoring
SetManyAtomicAsyncbatches spanning two replicated trees (chaos-xt-a,chaos-xt-b) under deterministic per-batch key namespaces and a stableoperationId. - Two independent
ChaosDeliveryPumpinstances - one per tree - ship the two trees' change feeds with independent partition cycles, so the receiver routinely sees one tree's terminal before the other's. - A mid-workload partition isolates each tree's edges at staggered points and heals them later, forcing terminal skew across the two trees.
Pass criteria
- On every receiver site, for every authored batch, tree A's keys and tree B's keys have identical presence - both fully visible or both fully absent. Tree A visible while tree B is absent (or vice versa) is a cross-tree atomicity violation.
- No single-tree partial view (every batch's per-tree key set is wholly present or wholly absent).
- Post-drain: every batch's keys are present on both trees on every site.
Test 13 - Atomic write under enforcement-policy churn (AtomicWriteUnderPolicyChurnChaosTests)
This test (in the lattice.schema suite) asserts that cross-tree
SetManyAtomicAsync stays all-or-nothing while a tree's schema
enforcement policy is changed live. Schema enforcement validates the
whole batch once, up front at the coordinator, against whatever policy is
current at admission - so concurrent policy churn may change which
outcome a given saga gets, but must never split a batch.
A committer drives one cross-tree saga per generation across two trees - one leg always compliant, the other alternating compliant (odd generations) and deliberately non-compliant (even generations) - while a churner flips each tree's policy between "require JSON" and "no policy" at a 5 ms cadence, and a reader polls both trees on every generation.
What it proves
| Invariant | Mechanism under test |
|---|---|
| A committed saga installs every leg on every tree | Admission-time whole-batch validation decides the saga atomically before any prepare |
| A rejected saga mutates no tree | A single admission failure rejects the whole batch; no leg is applied |
| A reader never sees a torn per-tree snapshot | Atomic-write reader isolation holds under concurrent policy mutation |
Workload
- Committer - one cross-tree
SetManyAtomicAsyncsaga per generation (60 generations) overtreeA(always compliant) andtreeB(compliant on odd, non-compliant on even generations), each value embedding its generation. - Policy churner - flips each tree's policy between a
LatticeSchemaRule.Json()policy and no policy at a 5 ms cadence. - Reader - a concurrent per-generation read of both trees, asserting each tree's key set is at one coherent generation (never a torn cross-generation mix).
Pass criteria
- Zero invariant violations: every committed generation is present on both trees on every key; every rejected generation advanced neither tree.
- At least one generation committed and at least one was rejected by the churned policy (proves the race actually exercised both outcomes).
Tolerated transients
The committer treats a LatticeSchemaViolationException (or a non-committed outcome) as the reject branch; any other exception fails the test.
Test 14 - Atomic write under schema-version advance and eager migration (AtomicWriteUnderVersionAdvanceChaosTests)
This test (in the lattice.schema suite) asserts that single-tree
SetManyAtomicAsync stays all-or-nothing and every stored value stays
self-describing and decodable while a versioned tree's target schema
version is advanced concurrently, and that the one-call eager background
migration preserves that atomic snapshot when it re-stamps the tree. It
runs in two phases matching the two distinct version-change operations and
their concurrency contracts.
A target-version advance is a config-only change (it moves no data), so it is safe to run concurrently with live writes: new writes stamp at the new target and existing values are upcast on read. The eager data migration is a shadow-build with alias cutover whose v1 contract requires the tree be write-quiescent, so it is validated in a quiesced window - not hammered under live writes.
What it proves
| Invariant | Mechanism under test |
|---|---|
| Every atomic batch lands as a unit under a concurrent version advance | The write interceptor stamps each delta at admission; the advance never tears the saga |
| A read never returns raw enveloped bytes and never throws for a reachable version | The read decoder strips the envelope and upcasts to the current target through the registered hop chain |
| Eager migration preserves the last committed snapshot | The quiesced shadow-build re-stamps each value from its own version to the target and cuts the alias over atomically |
Workload
- Committer - single-tree
SetManyAtomicAsyncbatches (60 generations) of 12 keys into one versioned tree, each value embedding its generation. - Version churner - advances the target schema version v1 -> v2 -> v3 (once each) concurrently with the write loop.
- Reader - a concurrent per-generation read that fails on any raw enveloped byte leak or torn batch.
- Quiesced migration - after the write loop drains, one
MigrateToTargetVersionAsyncre-stamps every value (written across v1..v3) to the current target, followed by an idempotent second call.
Pass criteria
- Zero invariant violations across the concurrent phase (all-or-nothing, decodable, no raw-envelope leak).
- The target advanced exactly twice (v1->v2->v3).
- The eager migration succeeded, preserved the last committed generation on every key, and the idempotent re-migration also succeeded.
Tolerated transients
None beyond the churner's own cancellation; any unexpected exception during the advance, the write loop, or the migration fails the test.
Observed recovery surfaces
Between them, the chaos tests exercise every recovery path documented in shard-splitting.md, online-reshard.md, tree-sizing.md, tombstone-compaction.md, wal.md, wal-storage-providers.md, state-primitives.md, ../lattice.replication/README.md, ../lattice.replication/transport.md, ../lattice.replication.grpc/README.md, and the architecture notes:
The table below covers the four full-workload topology-mutation chaos fixtures (Tests 1-4). The atomic-write reader-isolation (Test 5), atomic-visibility-across-topology siblings (Test 6), digest-determinism (Test 7), cross-cluster atomic-visibility (Test 8), per-mode convergence chaos (Test 9), multi-site smoke (Test 10), and the per-test invariant fixtures (range delete, CAS, scan cancel, multi-silo restart, WAL trim, liveness + inbound stats, OR-Map convergence, compaction + shipping, gRPC transport, Azure Table WAL) target orthogonal invariants and are documented in their own grids below.
| Surface | Happy path | Faults | Resize | Reshard |
|---|---|---|---|---|
| Concurrent reads/writes during split shadow phase | ✅ | ✅ | - | ✅ |
ScanKeysAsync / ScanEntriesAsync in-line reconciliation |
✅ | ✅ | ✅ | ✅ |
CountAsync per-slot routing + version stability + bounded retry |
✅ | ✅ | ✅ | ✅ |
StaleShardRoutingException transparent retry |
✅ | ✅ | - | ✅ |
StaleTreeRoutingException transparent retry across alias swap |
- | - | ✅ | - |
Permanent MovedAwaySlots rejection after split completion |
✅ | ✅ | - | ✅ |
Resumable SplitInProgress intent replay across crashes |
- | ✅ | - | - |
Two-phase root promotion (PendingPromotion) replay |
- | ✅ | - | - |
Shadow MergeManyAsync atomicity under failed source write |
- | ✅ | - | - |
Registry ShardMap.Version stamping under retry |
- | ✅ | - | ✅ |
| Idempotent drain chunks | - | ✅ | ✅ | ✅ |
TreeResizeGrain phase machine under live traffic |
- | - | ✅ | - |
| Per-source-shard shadow-forwarding under live traffic | - | - | ✅ | - |
TreeReshardGrain migration loop + dispatch-budget clamping |
- | - | - | ✅ |
Saga-atomicity surfaces (Tests 5, 6, 8)
| Surface | Atomic vis. quiescent | Split + saga | Resize + saga | Reshard + saga | Cross-cluster |
|---|---|---|---|---|---|
| Continuous reader observes zero-or-all keys per poll | ✅ | ✅ | ✅ | ✅ | ✅ |
AtomicWriteGrain per-shard terminal broadcast idempotent under retry |
✅ | ✅ | ✅ | ✅ | ✅ |
| Prepared-write commit path holds keys hidden until terminal arrives | ✅ | ✅ | ✅ | ✅ | ✅ |
| Saga prepares shadow-forwarded onto destination shard mid-split | - | ✅ | - | ✅ | - |
| Saga prepares shadow-forwarded onto destination physical tree mid-resize | - | - | ✅ | - | - |
Retroactive prepared-mutation sweep at BeginShadowWrite |
- | - | - | ✅ | - |
Saga terminal-fan-out shadow-forward fallback via post-Complete MovedAwaySlots |
- | - | - | ✅ | - |
Registry TxDecisionRetention tombstone absorbs duplicate terminals |
- | - | - | ✅ | ✅ |
| Receiver-side prepared/terminal apply seam holds prepares off projection | - | - | - | - | ✅ |
LatticeHlcOverrideContext preserves source HLC on receiver |
- | - | - | - | ✅ |
ShouldShip bypass for TxCommit / TxAbort records |
- | - | - | - | ✅ |
Per-mode convergence surfaces (Test 9)
| Surface | LWW | OR-Set | PN-Counter | MV-Register |
|---|---|---|---|---|
| Producer-side change feed yields locally-authored mutations with origin id | ✅ | ✅ | ✅ | ✅ |
| Shipper drives delivery to every peer site under partition-cycled topology | ✅ | ✅ | ✅ | ✅ |
| Receiver-side mode-specific dispatch (source-HLC LWW apply / typed-delta fold) | ✅ | ✅ | ✅ | ✅ |
LatticeOriginContext flagging foreign-origin writes during apply |
✅ | ✅ | ✅ | ✅ |
| Commutative-monoid CRDT merge absorbs out-of-order receive | - | ✅ | ✅ | ✅ |
| Dot-context supersession of causally-dominated entries on merge | - | - | - | ✅ |
Per-test invariant surfaces (range delete, CAS, scan cancel, restart, replication, storage)
The chaos tests below target surfaces that the four full-workload topology grids above do not cover: per-call public-API invariants (range delete, CAS, scan cancellation), Orleans membership churn (multi-silo restart), the production replication pipeline (WAL trim, liveness + inbound stats, OR-Map convergence, compaction + shipping), and the two downstream-package suites (gRPC transport, Azure Table WAL). The range-delete, CAS, scan-cancel and multi-silo-restart columns map to rows of the core suite table at the top of this document; the WAL-trim, liveness, OR-Map, compaction, gRPC and Azure Table WAL columns map to fixtures catalogued in the replication chaos tests.
| Surface | Range delete | CAS | Scan cancel | Multi-silo restart | WAL trim | Liveness + inbound | OR-Map | Compaction + shipping | gRPC transport | Azure Table WAL |
|---|---|---|---|---|---|---|---|---|---|---|
DeleteRangeAsync range exclusivity under concurrent writers |
✅ | - | - | - | - | - | - | - | - | - |
DeleteRangeAsync cross-shard fan-out + tombstone scope |
✅ | - | - | - | - | - | - | - | - | - |
SetIfVersionAsync (CAS) linearisable winner under contention |
- | ✅ | - | - | - | - | - | - | - | - |
SetIfVersionAsync (CAS) lost update returns false, and the re-read-and-retry loop loses no increment |
- | ✅ | - | - | - | - | - | - | - | - |
ScanKeysAsync / ScanEntriesAsync cooperative cancellation surfaces OperationCanceledException |
- | - | ✅ | - | - | - | - | - | - | - |
| Cancelled scan leaks no grain-side enumerator state | - | - | ✅ | - | - | - | - | - | - | - |
TestCluster.RestartSiloAsync mid-workload preserves universe (count, envelope) |
- | - | - | ✅ | - | - | - | - | - | - |
Process-shared IGrainStorage isolates membership churn from storage disappearance |
- | - | - | ✅ | - | - | - | - | - | - |
| Producer-side WAL trim cannot prune un-acked entries | - | - | - | - | ✅ | - | - | - | - | - |
Real AddLatticeReplication + loopback transport under sustained writes |
- | - | - | - | ✅ | ✅ | - | ✅ | - | - |
Outbound liveness probe: peer.last_contact_seconds climbs while a peer edge is isolated and falls below half a probe interval within ten probe intervals of the heal |
- | - | - | - | - | ✅ | - | - | - | - |
| Budget-driven receiver-side apply faults drain in full, the receiver still converges, and the inbound peer-stats row records contact with no outbound-only backlog or in-flight counts | - | - | - | - | - | ✅ | - | - | - | - |
| OR-Map convergence under concurrent multi-site mutation + partition | - | - | - | - | - | - | ✅ | - | - | - |
| Per-tree typed-CRDT shape resolution end-to-end on producer + receiver dispatch | - | - | - | - | - | - | ✅ | - | - | - |
ReplicationShipperGrain.ShouldShip keeps MutationKind.Tombstone envelopes off the wire |
- | - | - | - | - | - | - | ✅ | - | - |
ITombstoneCompactionGrain.RunCompactionPassAsync mid-shipment preserves receiver convergence |
- | - | - | - | - | - | - | ✅ | - | - |
| gRPC sender retry loop under 15% and 30% per-call channel faults delivers every attempted entry | - | - | - | - | - | - | - | - | ✅ | - |
| Re-deliveries the channel faults induce are absorbed as no-ops by the receiver's exact-identity dedupe cache or the idempotent leaf apply | - | - | - | - | - | - | - | - | ✅ | - |
| Azurite-backed WAL keeps every shard's offsets dense, gap-free and duplicate-free under sustained concurrent appends across shards | - | - | - | - | - | - | - | - | - | ✅ |
Legend: ✅ = covered by a live test.
Runtime characteristics
Every chaos suite shares the same shape: a short chaos window (a few seconds of sustained workload with topology churn and / or fault injection), a bounded heal / drain phase, and a post-quiescence invariant assertion. Per-suite cost scales with the workload described in each suite's "What it proves" / "Purpose" column rather than being fixed, so this section stays accurate as suites are added without a per-suite runtime table to maintain.
- Single-cluster suites (
test/lattice/BPlusTree/) run key universes of 8 to 600 keys (16 keys for the saga-atomicity suites) across up to ~16 parallel workers - writers, scanners, and a topology or saga driver - with a chaos window of roughly 6-20 s (up to 90 s for the shard-consolidation suite) and a heal / drain budget up to ~60 s for the saga-atomicity suites. Most trees start at 4 shards (64 where a fixture does not pre-register its tree; the multi-silo restart and bounded-cache fixtures pin 2 and 1) and grow under split or reshard; the shrinking reshard run folds its tree from 4 shards to 2. - Cross-cluster suites (
test/lattice.replication/Chaos/) run two or three in-process clusters over a fault-injectable inter-site delivery layer, with a single-key or single-tree universe (the atomic-visibility suite uses ~72 keys), a chaos window of a few seconds with a site or edge partitioned mid-workload, and a drain budget up to ~60 s. See the replication chaos tests for the per-suite catalog.
The cross-cluster, gRPC, and Azure Table suites drive the real replication pipeline (and the real gRPC transport service and Azure Table provider where applicable) rather than test doubles of that logic, but they run in-process: in-process test clusters with a simulated delivery pump, an in-memory test server, and the Azurite emulator stand in for networked silos and a real cloud storage account.
Derived-state recovery across an identity swap
Derived state that tails a source tree's write-ahead log - a folded / materialised view, and a tag index maintained in a sibling index tree - must recover when the source's PHYSICAL identity is repointed under its logical registry alias (a restore-style cutover, possibly repeated). Two chaos suites, each hosted in the sibling package whose fixture already stands up the machinery, cover this and are tracked by issue #1167:
| Suite | Where | What it proves |
|---|---|---|
| Materialised-view recovery across an identity swap | test/lattice.replication/Chaos/DerivedStateRecoveryAcrossIdentitySwapChaosTests.cs |
A folded view tails a logical source tree whose physical identity is repointed under its registry alias repeatedly, under a sustained mutation workload that accretes backlog between drains. The maintainer rebinds to the source's current physical id event-driven when the tree registry pushes an alias change (with a coarse backstop re-resolve as a safety net), and on a change rebuilds against the new physical source and rebinds its tail. After the workload quiesces the view reflects exactly the final identity's contents: keys dropped by a cutover are retracted (a WAL tail alone can never retract a key the restored source never had), changed values win, and a large abandoned backlog never survives. Complements the deterministic single-swap regression that landed with the maintainer heal. |
| Tag-index reconcile under repeated restore | test/lattice.backup/Chaos/BackupRestoreReconcileChaosTests.cs |
A tag index over a live subject tree is driven through a real shadow-cutover restore under a large tagged working set with a concurrent reader hammering the tag query. The restore fires a prompt reconcile that drops every membership row absent from the restored point-in-time; the reader never observes an out-of-universe key or a torn membership. A second case injects one aborted catalogue enumeration into the restore's tag-index discovery and requires the restore to recover that scan rather than swallow the abort and leave every orphan row in place (issue #3233). Repeated restores to successively earlier point-in-times narrow membership monotonically to each restore's subject with no row stranded from a superseded restore. |
Routing and cross-tree recovery across a shadow-cutover restore
A shadow-cutover restore swaps a logical tree's alias to a freshly loaded shadow tree while retaining the previous physical tree so the restore can be reverted. Two consumers must recover across that swap: stateless-worker routing activations that cache the logical-to-physical alias, and an in-flight cross-tree atomic-write saga that drives a participant through that alias. Two chaos suites, hosted in the backup package whose fixture stands up both the restore machinery and the core lattice, cover this and are tracked by issue #1167:
| Suite | Where | What it proves |
|---|---|---|
| Routing self-heal across a shadow-cutover | test/lattice.backup/Chaos/ShadowCutoverRoutingSelfHealChaosTests.cs |
Many stateless-worker routing activations are warmed against a tree's pre-cutover identity under sustained concurrent reads, then the tree is cut over to a restored shadow while the readers keep hammering. The retained tree refuses logical-alias traffic with the internal staleness signal, which the routing tier catches and re-resolves onto the shadow. No reader ever surfaces that signal or observes an out-of-universe value, and after the cutover every activation converges onto the restored snapshot with no key left resolving to pre-cutover content. The repeated-cutover case restores to successively earlier point-in-times and proves the heal re-arms for each. |
| Cross-tree atomic write across a participant cutover | test/lattice.backup/Chaos/CrossTreeAtomicWriteAcrossCutoverChaosTests.cs |
A sustained stream of cross-tree atomic writes spans two trees while one participant is repeatedly cut over to a restored shadow underneath the saga. The saga's deadline-bounded retry loops must absorb the staleness signal the retired physical tree raises, re-resolve onto the shadow, and reach a clean terminal decision - no call leaks the signal to the caller. After the cutover storm a fresh all-or-nothing batch still commits and is atomically visible on both trees, proving the cut-over participant self-healed and is not wedged. Complements the mocked prepare-side and terminal-broadcast stale-routing retry regressions by exercising the whole coordinator over a real restore. |
See also
- Consistency - the per-operation guarantees these tests verify against the public API under topology mutation.
- Replication - the producer-side change-feed → shipper → receiver-side applier pipeline that the cross-cluster suite exercises.
- Adaptive Shard Splitting - the split protocol exercised by the happy-path, faults, reshard, and split + saga tests.
- Online Reshard - the reshard coordinator and its interaction with autonomic splits.
- Tree Sizing - the online
resize path exercised by
ChaosResizeIntegrationTestsandResizeTopologyTests. - Architecture - grain layers, root promotion, bounded retry, and the invariants the chaos tests verify.
- State Primitives - HLC and LWW, which guarantee the value envelope holds even under concurrent rewrites.