Skip to content

kv(composed1): M2 — versioned-snapshot ring + kvFSM RouteHistory wiring#894

Merged
bootjp merged 2 commits into
mainfrom
feat/composed1-m2-route-history
May 31, 2026
Merged

kv(composed1): M2 — versioned-snapshot ring + kvFSM RouteHistory wiring#894
bootjp merged 2 commits into
mainfrom
feat/composed1-m2-route-history

Conversation

@bootjp
Copy link
Copy Markdown
Owner

@bootjp bootjp commented May 30, 2026

Summary

Second milestone (M2) of the Composed-1 cross-group commit-time guard per docs/design/2026_05_29_proposed_composed1_cross_group_commit_guard.md §M2.

Lands the catalog version retention ring and the kvFSM struct extension end-to-end so M3 (the apply-time gate) has every piece it needs without further plumbing churn. Behaviour-neutral: the FSM stores routes + shardGroupID but does not consult them at apply time yet.

What changes

Distribution layer

  • distribution.Engine gains a FIFO ring of RouteHistorySnapshot entries keyed by catalogVersion. Populated on every successful ApplySnapshot AND on NewEngineWithDefaultRoute (the v=0 seed, so a txn observing v=0 can resolve OwnerOf without falling through to the M3 not-found path on first commit).
  • New public constant DefaultRouteHistoryDepth = 32 per design doc §9 Q2.
  • New RouteHistorySnapshot struct + OwnerOf(key) (groupID, bool) + Version() accessor. OwnerOf mirrors Engine.GetRoute's right-half-open interval semantics against the historical snapshot.
  • New Engine.SnapshotAt(v) reads from the ring; returns (zero, false) for evicted / never-seen versions (M3 will surface this as ErrComposed1VersionGCd → coordinator retry).

kv layer

  • kvFSM gains routes RouteHistory + shardGroupID uint64 fields with doc comments pointing at §M2 / §4.2.
  • New RouteHistory + RouteSnapshot interfaces defined in kv so the FSM does not import distribution concretely.
  • New WithRouteHistory(routes, shardGroupID) FSM option.
  • New kv/route_history.go::WrapDistributionEngine adapter boxes a *distribution.Engine so it satisfies kv.RouteHistory. Go's structural-interface satisfaction is byte-equivalent on return types — Engine.SnapshotAt returns the concrete struct, kv.RouteHistory's SnapshotAt returns the interface; moving the interface to distribution would create an import cycle.

main.go wiring

  • buildShardGroups and its caller buildShardGroupsWithEncryptionWiring gain a routeEngine *distribution.Engine parameter.
  • Each shard FSM's NewKvFSMWithHLC now installs WithRouteHistory(WrapDistributionEngine(routeEngine), g.id).
  • Existing test callers (main_bootstrap_e2e_test.go, multiraft_runtime_test.go) updated for the new signature.

Tests (10 new)

distribution/engine_test.go (6 new):

  • TestEngineSnapshotAt_RecordsApplySnapshot — round-trip witness
  • TestEngineSnapshotAt_PreservesHistoryAcrossVersions — the M3-critical "old version is still resolvable after a new ApplySnapshot lands"
  • TestEngineSnapshotAt_FIFOEviction — depth=3 explicit, pins eviction order so a future depth change does not silently break the M3 contract
  • TestEngineSnapshotAt_UnknownVersionReturnsNotFound
  • TestEngineSnapshotAt_SeedsVersionZeroForDefaultRoute — the v=0 seed for the common pre-bootstrap case
  • TestEngineSnapshotAt_BareEngineHasNoHistory — bare-struct Engine is nil-safe; SnapshotAt always returns false (M3 will treat as "not in ring")

kv/fsm_route_history_test.go (4 new):

  • TestKvFSMWithRouteHistory_StoresFields — the M2 "Done when" criterion: struct extension wired through the adapter + v=0 seed end-to-end
  • TestKvFSM_NilRouteHistoryByDefault — legacy-default: no WithRouteHistoryroutes nil, shardGroupID 0
  • TestWrapDistributionEngine_NilEngineReturnsNil — defensive
  • TestKvFSM_WithRouteHistory_NilProviderTreatedAsUnwired — defensive: nil RouteHistory via the option ≡ no option

Verification

  • go build ./... — clean
  • go vet ./... — clean
  • go test -race -count=1 ./distribution ./kv — 1.0 s + 7.9 s, pass
  • make lint — 0 issues

Self-review (5 lenses)

  1. Data loss — no new write paths; the ring is in-memory only and rebuilt from ApplySnapshot on restart (catalog state is re-derived from the default Raft group's reserved keys via the existing watcher).
  2. Concurrency — ring lives under Engine.mu (the existing write-lock used by ApplySnapshot). recordHistorySnapshot is called with the lock held. RouteHistorySnapshot is immutable (routes are cloned on record). SnapshotAt takes the read lock. No new lock ordering.
  3. Performance — one extra map insert + slice append per ApplySnapshot (operator-frequency, not data-plane). Routes clone is O(|routes|). No additional per-request hot-path work at M2.
  4. Data consistency — at M2 the ring is plumbing only; no consistency surface change. The ring contract matches what M3's verifyComposed1 needs.
  5. Test coverage — 10 new unit tests cover round-trip, eviction order, v=0 seed, nil-safe bare engine, FSM struct extension, the adapter, legacy default. Existing engine and FSM tests pass unchanged.

Test plan

  • go test -race ./distribution ./kv
  • make lint
  • (Follow-up PR) M3 — FSM verifyComposed1 (observed-version §4.2(a) + current-version §4.4) + ErrComposed1Violation / ErrComposed1VersionGCd sentinels + coordinator retry path.

Resolves

The M2 row in the Composed-1 design doc.

Summary by CodeRabbit

  • New Features

    • Route history tracking: records versioned snapshots of route configurations and exposes snapshot lookup and history depth.
    • Route-history integration: route snapshots can be consulted by per-group state machines for cross-group verification.
  • Tests

    • Added comprehensive tests for snapshot recording, lookup semantics, FIFO eviction, default seeding, and constructor wiring.

Second milestone of the Composed-1 cross-group commit-time guard
per docs/design/2026_05_29_proposed_composed1_cross_group_commit_guard.md
§M2.  Lands the catalog version retention ring and the kvFSM struct
extension end-to-end so M3 (the apply-time gate) has every piece it
needs without further plumbing churn.

Behaviour-neutral.  The FSM stores routes + shardGroupID but does
NOT consult them at apply time yet; nodes wired without
WithRouteHistory remain "unpinned" — the M3 gate will short-circuit
exactly like the pre-feature FSM.

Changes:

  * distribution/engine.go — Engine gains a FIFO ring of
    `RouteHistorySnapshot` entries keyed by catalogVersion.
    Populated on every successful ApplySnapshot AND on
    NewEngineWithDefaultRoute (the v=0 seed, so a txn observing
    v=0 can still resolve OwnerOf without falling through to the
    M3 not-found path on first commit).  Default depth is the new
    public constant `DefaultRouteHistoryDepth = 32` per design doc
    §9 Q2.

  * distribution/engine.go — `RouteHistorySnapshot` struct +
    `OwnerOf(key) (groupID, bool)` mirroring Engine.GetRoute's
    right-half-open interval semantics but against the historical
    snapshot.  `Version()` accessor.  `Engine.SnapshotAt(v)` reads
    from the ring; returns (zero, false) for evicted / never-seen
    versions (M3 will surface this as ErrComposed1VersionGCd →
    coordinator retry, so retention depth is a liveness knob, not
    a safety knob).

  * kv/fsm.go — kvFSM gains `routes RouteHistory` +
    `shardGroupID uint64` fields with doc comments pointing at
    §M2 / §4.2.  New `RouteHistory` + `RouteSnapshot` interfaces
    defined in kv so the FSM does not import distribution
    concretely.  New `WithRouteHistory(routes, shardGroupID)` FSM
    option.

  * kv/route_history.go — `WrapDistributionEngine` adapter boxes a
    `*distribution.Engine` so it satisfies `kv.RouteHistory` (Go's
    structural-interface satisfaction is byte-equivalent on return
    types — `Engine.SnapshotAt` returns the concrete
    `distribution.RouteHistorySnapshot`, kv.RouteHistory's
    SnapshotAt returns the kv.RouteSnapshot interface; moving the
    interface to distribution would create an import cycle).

  * main.go + main_encryption_write_wiring.go — buildShardGroups
    and its caller buildShardGroupsWithEncryptionWiring gain a
    `routeEngine *distribution.Engine` parameter.  Each shard
    FSM's NewKvFSMWithHLC call now installs
    `WithRouteHistory(WrapDistributionEngine(routeEngine), g.id)`.

  * main_bootstrap_e2e_test.go + multiraft_runtime_test.go —
    existing test callers updated for the new buildShardGroups
    signature.  The e2e test passes cfg.engine; the multiraft
    runtime test passes nil (FSM stays unwired, M3 will short-
    circuit — matches the pre-feature posture).

Tests:

  * distribution/engine_test.go — 6 new tests pinning the M2 ring
    contract:
      - TestEngineSnapshotAt_RecordsApplySnapshot (round-trip witness)
      - TestEngineSnapshotAt_PreservesHistoryAcrossVersions (the M3-
        critical "old version is still resolvable after a new
        ApplySnapshot lands")
      - TestEngineSnapshotAt_FIFOEviction (depth=3 explicit, pins
        eviction order so a future depth change does not silently
        break the M3 contract)
      - TestEngineSnapshotAt_UnknownVersionReturnsNotFound
      - TestEngineSnapshotAt_SeedsVersionZeroForDefaultRoute (the
        v=0 seed for the common pre-bootstrap case)
      - TestEngineSnapshotAt_BareEngineHasNoHistory (bare-struct
        Engine for internal test seams is nil-safe; SnapshotAt
        always returns false, M3 will treat as "not in ring")

  * kv/fsm_route_history_test.go — 4 new tests:
      - TestKvFSMWithRouteHistory_StoresFields (the M2 "Done when"
        criterion: struct extension wired through the adapter +
        the v=0 seed end-to-end)
      - TestKvFSM_NilRouteHistoryByDefault (legacy-default: no
        WithRouteHistory → routes nil, shardGroupID 0)
      - TestWrapDistributionEngine_NilEngineReturnsNil (defensive)
      - TestKvFSM_WithRouteHistory_NilProviderTreatedAsUnwired
        (defensive: nil RouteHistory via the option is equivalent
        to no option)

Verification:
  * go build ./...                                — clean
  * go vet ./...                                  — clean
  * go test -race -count=1 ./distribution ./kv    — 1.0 s + 7.9 s, pass

Self-review (5 lenses):

  1. Data loss — no new write paths; the ring is in-memory only
     and rebuilt from ApplySnapshot on restart (catalog state is
     re-derived from the default Raft group's reserved keys via
     the existing watcher).  Cannot lose a write because no new
     branch refuses one.
  2. Concurrency — ring lives under Engine.mu (the existing
     write-lock used by ApplySnapshot).  recordHistorySnapshot is
     called with the lock held.  RouteHistorySnapshot is
     immutable (routes are cloned on record).  SnapshotAt takes
     the read lock.  No new lock ordering.
  3. Performance — one extra map insert + slice append per
     ApplySnapshot (operator-frequency, not data-plane).  Routes
     clone on record is O(|routes|); negligible vs the existing
     routesFromCatalog cost.  No additional per-request hot-path
     work at M2.
  4. Data consistency — at M2 the ring is plumbing only; no
     consistency surface change.  M3 will gate apply on it; the
     ring contract (RouteHistorySnapshot is immutable; OwnerOf
     uses the same right-half-open semantics as live GetRoute)
     matches what M3's verifyComposed1 needs.
  5. Test coverage — 10 new unit tests across distribution + kv
     cover the round-trip, eviction order, v=0 seed, nil-safe
     bare engine, the FSM struct extension, the adapter, and the
     legacy default.  Existing engine and FSM tests pass
     unchanged.

Next milestone (separate PR per design doc §6): M3 — FSM
verifyComposed1 (observed-version + current-version checks) +
ErrComposed1Violation / ErrComposed1VersionGCd sentinels +
coordinator retry path.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 30, 2026

@claude review

@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 30, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8ef05ef5-d508-4551-b3b9-d1afd3b33468

📥 Commits

Reviewing files that changed from the base of the PR and between 85056fa and e2dbb16.

📒 Files selected for processing (3)
  • distribution/engine.go
  • distribution/engine_test.go
  • kv/fsm_route_history_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • kv/fsm_route_history_test.go
  • distribution/engine.go

📝 Walkthrough

Walkthrough

The PR adds a depth-bounded, FIFO route snapshot history ring to distribution.Engine, exposes snapshot lookup via exported types and Engine accessors, introduces kv RouteHistory/RouteSnapshot interfaces and a nil-safe adapter, and wires route-history into per-group KV FSM construction and tests.

Changes

Route History Ring and FSM Integration

Layer / File(s) Summary
Distribution engine history ring
distribution/engine.go
Engine gains a history map, historyOrder FIFO list, historyDepth and DefaultRouteHistoryDepth. NewEngineWithDefaultRoute seeds version-0; ApplySnapshot records post-apply snapshots. Adds exported RouteHistorySnapshot (Version(), OwnerOf) and Engine.SnapshotAt(v), Engine.HistoryDepth(). Internal recordHistorySnapshotLocked clones live routes, is idempotent per version, and evicts when depth exceeded.
Distribution engine history tests
distribution/engine_test.go
Six tests: recording on apply, preservation across versions, FIFO eviction with small depth, unknown-version returns, version-0 seeding by default route, and bare engine no-history behavior.
KV FSM route history contracts
kv/fsm.go
Adds kvFSM fields routes RouteHistory and shardGroupID uint64. Introduces RouteHistory (SnapshotAt) and RouteSnapshot (Version(), OwnerOf) interfaces plus WithRouteHistory(routes, shardGroupID) FSMOption.
Route history adapter
kv/route_history.go
New WrapDistributionEngine(e *distribution.Engine) RouteHistory nil-safe adapter and wrapper distributionRouteSnapshot forwarding SnapshotAt, Version, and OwnerOf to distribution engine snapshots.
KV FSM route history tests
kv/fsm_route_history_test.go
Four tests verifying WithRouteHistory stores fields, default nil behavior when omitted, WrapDistributionEngine(nil) returns nil, and nil provider treated as unwired while recording shardGroupID.
Application integration wiring
main.go, main_encryption_write_wiring.go, main_bootstrap_e2e_test.go, multiraft_runtime_test.go
buildShardGroupsWithEncryptionWiring signature extended to accept routeEngine *distribution.Engine and forwards it to buildShardGroups. run() and tests pass cfg.engine/nil accordingly. Each group's kv.NewKvFSMWithHLC now composes kv.WithRouteHistory(kv.WrapDistributionEngine(routeEngine), g.id).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • bootjp/elastickv#765: Overlaps at FSM initialization call sites where additional FSM options (encryption/route-history) are composed.
  • bootjp/elastickv#748: Also modifies kv FSM option wiring and related constructor patterns used here.

Poem

🐰 I seeded a ring at version zero,

Routes remembered, never a hero,
FIFO hops keep history light,
FSM listens to who owned the byte,
Hopping through versions, soft and bright.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.83% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: adding versioned-snapshot ring history and kvFSM RouteHistory wiring for the Composed-1 milestone M2.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/composed1-m2-route-history

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

TLA+ spec divergence review (auto-triggered)

This PR touches files that the TLA+ safety spec has an anchor on (per
docs/design/2026_05_28_partial_tla_safety_spec.md §3),
so an AI review is requested below to verify the implementation has not drifted
from the model.

Anchored files changed in this PR head (85056fa):

  • distribution/engine.go
  • distribution/engine_test.go
  • kv/fsm.go

What to check, by subsystem:

  • kv/hlc*.goNext() must respect the HLC-4 preconditions (i)/(ii)/(iii) from the design doc: bounded skew, logical-counter handoff on leader change (strategy (c) Observe(MaxAppliedHLC)), and the commit-time ceiling fence (fail-closed when wall_now >= physicalCeiling). Any change to the bit layout (48/16), the CAS loop, or the ceiling getter/setter is in scope.
  • kv/coordinator.go, kv/sharded_coordinator.goRunHLCLeaseRenewal, hlcRenewalInterval, hlcPhysicalWindowMs constants, and the new-term detection that calls Observe(fsm.MaxAppliedHLC()) (strategy (c)). Any change to renewal cadence, group selection, or fail-closed behaviour is in scope.
  • kv/transaction.go, kv/lock_resolver.go — OCC commit-ts assignment, lock-map encoding (key, lock_ts) -> start_ts, and the LockResolver action OCC-3 depends on. (M2 spec will land OCC-1..OCC-5; until then the spec doc §5.2 is the contract.)
  • kv/fsm.go — FSM apply of HLC lease entries (SetPhysicalCeiling), and any future MaxAppliedHLC() accessor that strategy (c) needs.
  • store/mvcc_store.go — version visibility, snapshot install, and the MVCC-1..MVCC-4 invariants (M3 scope).
  • distribution/** — route catalog versioning, SplitRange atomicity, and CatalogWatcher async fan-out (M4 scope).

If the change is correct but requires a spec update, edit tla/hlc/HLC.tla (or the corresponding M2..M5 module once landed) and the design doc in the same PR. The tla-check workflow runs the TLC model check on the same paths.


@claude review please verify TLA+ spec divergence per the checklist above.

@codex review please verify TLA+ spec divergence per the checklist above.

@claude
Copy link
Copy Markdown

claude Bot commented May 30, 2026

Claude finished @bootjp's task in 2m 55s —— View job


Code Review — M2: versioned-snapshot ring + kvFSM RouteHistory wiring

Tasks

  • Understand the request
  • Read changed files
  • Analyze distribution/engine.go changes
  • Analyze kv/fsm.go + kv/route_history.go changes
  • Analyze main.go wiring changes
  • Analyze test files
  • Post review findings

Summary

M2 is well-scoped, behaviour-neutral plumbing with good test coverage. No data-loss or correctness bugs found. Three items below warrant attention before M3 ships, one of which is a latent performance issue that will become relevant when M3 puts OwnerOf on the apply-time hot path.


Issues

1. OwnerOf uses continue where break is correct for a sorted route list

distribution/engine.go:160-171RouteHistorySnapshot.OwnerOf does a linear scan over routes that are stored in sorted Start order. Once key < r.Start, all subsequent routes also have Start > key, so the loop should break rather than continue. Currently it iterates the entire slice unnecessarily.

This is a correctness-neutral optimisation today (result is the same), but M3 will call OwnerOf on every txn commit via verifyComposed1. With many routes the linear worst-case is O(N) per commit instead of the O(log N) you'd get with an early break or a binary-search front-end (mirroring routeIndex).

// current — iterates through all remaining routes even after a gap
if bytes.Compare(key, r.Start) < 0 {
    continue  // should be: break
}

Fix this →


2. historyOrder backing-array growth is unbounded

distribution/engine.go:213 — The FIFO eviction step is:

e.historyOrder = e.historyOrder[1:]

This advances the slice header's pointer but never releases the head of the backing array. After N evictions the backing array holds N+historyDepth elements; only the tail historyDepth slots are live. For the current DefaultRouteHistoryDepth = 32 at operator-level catalog frequency this is negligible, but worth a compacting step or a proper circular buffer if catalog churn is expected to increase. A cheap fix: copy the retained slice to fresh storage every time the depth is reached:

retained := make([]uint64, len(e.historyOrder)-1, e.historyDepth)
copy(retained, e.historyOrder[1:])
e.historyOrder = retained

3. recordHistorySnapshot lock contract is fragile

distribution/engine.go:199 — The function comment says "Caller MUST hold e.mu (write lock)" but NewEngineWithDefaultRoute calls it without the lock, relying on the documentation caveat "before the Engine is shared". This is documented but fragile:

  • A future refactor that moves the call past an e.mu.Unlock() or into a goroutine would introduce a silent data race.
  • A common pattern in this style is to rename the function recordHistorySnapshotLocked() to make the contract machine-checkable by naming convention, or to unconditionally take the lock inside NewEngineWithDefaultRoute even though it is not needed for correctness today.

Minor / Nits

4. TestEngineSnapshotAt_FIFOEviction — missing t.Parallel() and direct field mutation

distribution/engine_test.go:561 — This is the only new engine test that does not call t.Parallel(). Since it operates on a freshly constructed local Engine and all ApplySnapshot calls are sequential, adding t.Parallel() is safe and consistent with the other snapshot tests.

The direct write e.historyDepth = 3 bypasses the write lock. Fine for a single-threaded test in the same package, but once the test is parallelised the Go race detector will correctly flag this if another goroutine reads historyDepth under the RLock (e.g., via HistoryDepth()). Safest fix: expose a constructor option for the depth (only needed in tests), or accept the limitation in a comment.

5. WithRouteHistory(nil, nonZero) stores a non-zero shardGroupID alongside nil routes

kv/fsm.go:168-173TestKvFSM_WithRouteHistory_NilProviderTreatedAsUnwired documents this and notes the M3 gate will short-circuit on f.routes == nil. The comment also says "No invariant to assert on the ID here." Consider adding an explicit assertion that the ID is not reset to zero in that test, since the comment claims the non-zero ID survives but does not verify it — or add a short explanatory note to the M3 gate PR so the short-circuit branch is clearly tied back to this case.

6. shardGroupID is set to zero in both multiraft_runtime_test.go call sites

multiraft_runtime_test.go:57,111 — Both test callers pass nil for routeEngine, which goes through WrapDistributionEngine(nil) → nil, then WithRouteHistory(nil, g.id). g.id in the test is non-zero (groups 1, 2, …), so the FSM actually receives the correct shardGroupID. No bug, but the nil routeEngine means the M3 gate will short-circuit for all shard groups in these tests — which is the documented pre-feature behaviour, so this is fine.


Verdict

The M2 plumbing is correct and the 10 new tests cover the key contracts well. Item 1 (break vs continue in OwnerOf) is the only thing that should be addressed before M3 lands, since M3 puts OwnerOf on the apply-time hot path. Items 2 and 3 are worth a follow-up but are not blockers.

@chatgpt-codex-connector
Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements the M2 plumbing for the Composed-1 cross-group commit guard by introducing a versioned-snapshot ring to the route catalog Engine and wiring it into the KV FSM. The review feedback identifies two critical issues: a potential loss of version 0 history when the engine is initialized with static routes, and a potential runtime panic in recordHistorySnapshot if historyDepth is left as 0. Both issues have actionable suggestions to improve the robustness of the implementation.

Comment thread distribution/engine.go Outdated
Comment on lines +136 to +138
e.routes = routes
e.catalogVersion = snapshot.Version
e.recordHistorySnapshot()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

When the engine is initialized with static routes (e.g., via buildEngine in main.go using --shardRanges), the initial version 0 routes are never recorded in the history ring because recordHistorySnapshot is not called during startup. If a catalog update then occurs (moving to version 1), the version 0 history is lost. Any active transaction that observed version 0 will fail to resolve its owner via SnapshotAt(0), causing spurious retries or failures.

To fix this, we can record the pre-snapshot state (the current catalogVersion and routes) inside ApplySnapshot before overwriting them. Since recordHistorySnapshot is idempotent, this is safe and ensures version 0 (or any prior unrecorded version) is preserved.

Suggested change
e.routes = routes
e.catalogVersion = snapshot.Version
e.recordHistorySnapshot()
e.recordHistorySnapshot()
e.routes = routes
e.catalogVersion = snapshot.Version
e.recordHistorySnapshot()

Comment thread distribution/engine.go
Comment on lines +211 to +215
if len(e.historyOrder) >= e.historyDepth {
evict := e.historyOrder[0]
e.historyOrder = e.historyOrder[1:]
delete(e.history, evict)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If the Engine is constructed with history initialized but historyDepth left as 0 (the default value for int in Go), len(e.historyOrder) >= e.historyDepth will evaluate to 0 >= 0 (true) on the first snapshot record. Since e.historyOrder is empty, accessing e.historyOrder[0] will cause a runtime panic (index out of range).

To prevent this, defensively handle e.historyDepth <= 0 by falling back to DefaultRouteHistoryDepth, and ensure len(e.historyOrder) > 0 before accessing index 0.

	depth := e.historyDepth
	if depth <= 0 {
		depth = DefaultRouteHistoryDepth
	}
	if len(e.historyOrder) >= depth && len(e.historyOrder) > 0 {
		evict := e.historyOrder[0]
		e.historyOrder = e.historyOrder[1:]
		delete(e.history, evict)
	}

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
distribution/engine.go (1)

160-170: ⚡ Quick win

Use binary search for historical lookups.

OwnerOf linearly scans a route slice that's already sorted by Start. Once M3 calls this on commit-time verification, each owner lookup becomes O(n) instead of matching GetRoute's O(log n)` behavior.

♻️ Proposed refactor
 func (s RouteHistorySnapshot) OwnerOf(key []byte) (uint64, bool) {
-	for _, r := range s.routes {
-		if bytes.Compare(key, r.Start) < 0 {
-			continue
-		}
-		if r.End != nil && bytes.Compare(key, r.End) >= 0 {
-			continue
-		}
-		return r.GroupID, true
-	}
-	return 0, false
+	i := sort.Search(len(s.routes), func(i int) bool {
+		return bytes.Compare(s.routes[i].Start, key) > 0
+	})
+	if i == 0 {
+		return 0, false
+	}
+	i--
+	if end := s.routes[i].End; end != nil && bytes.Compare(key, end) >= 0 {
+		return 0, false
+	}
+	return s.routes[i].GroupID, true
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@distribution/engine.go` around lines 160 - 170, The OwnerOf method currently
does a linear scan over RouteHistorySnapshot.routes which are already sorted by
Start; replace that scan with a binary search (similar to GetRoute) so lookups
are O(log n). Implement a binary search over s.routes comparing key to
route.Start using bytes.Compare to find the highest route with Start <= key,
then verify the found route's End (if non-nil) with bytes.Compare(key,
route.End) < 0 before returning route.GroupID, true; otherwise return 0, false.
Ensure you reference RouteHistorySnapshot.OwnerOf and the s.routes slice and
preserve existing semantics for nil End bounds.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@distribution/engine.go`:
- Around line 199-214: In recordHistorySnapshot, guard against non-positive
history depths by treating history as disabled when e.historyDepth <= 0: add an
early return if e.history == nil || e.historyDepth <= 0 to avoid indexing
e.historyOrder[0]; use the existing symbols e.history, e.historyDepth,
e.historyOrder and e.catalogVersion to perform the check and leave the rest of
the eviction/insert logic unchanged so tests/bare-engine paths don’t panic.

---

Nitpick comments:
In `@distribution/engine.go`:
- Around line 160-170: The OwnerOf method currently does a linear scan over
RouteHistorySnapshot.routes which are already sorted by Start; replace that scan
with a binary search (similar to GetRoute) so lookups are O(log n). Implement a
binary search over s.routes comparing key to route.Start using bytes.Compare to
find the highest route with Start <= key, then verify the found route's End (if
non-nil) with bytes.Compare(key, route.End) < 0 before returning route.GroupID,
true; otherwise return 0, false. Ensure you reference
RouteHistorySnapshot.OwnerOf and the s.routes slice and preserve existing
semantics for nil End bounds.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0a00b8a5-c419-4508-b73a-e20baa9bd710

📥 Commits

Reviewing files that changed from the base of the PR and between 2bcb6e7 and 85056fa.

📒 Files selected for processing (9)
  • distribution/engine.go
  • distribution/engine_test.go
  • kv/fsm.go
  • kv/fsm_route_history_test.go
  • kv/route_history.go
  • main.go
  • main_bootstrap_e2e_test.go
  • main_encryption_write_wiring.go
  • multiraft_runtime_test.go

Comment thread distribution/engine.go Outdated
Comment on lines +199 to +214
func (e *Engine) recordHistorySnapshot() {
if e.history == nil {
// Engines constructed via the bare struct literal (e.g.
// internal test seams) — no history ring configured. Skip
// the record so the M2 plumbing stays optional for those
// paths; the M3 gate will observe SnapshotAt → (zero,
// false) and trigger the soft-fail-as-retry path.
return
}
if _, exists := e.history[e.catalogVersion]; exists {
return
}
if len(e.historyOrder) >= e.historyDepth {
evict := e.historyOrder[0]
e.historyOrder = e.historyOrder[1:]
delete(e.history, evict)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle non-positive history depths defensively.

If an internal seam leaves history initialized but sets historyDepth to 0, the first snapshot record will hit historyOrder[0] and panic. Treating historyDepth <= 0 as “history disabled” makes this helper safe for test/bare-engine paths too.

🛡️ Proposed fix
 func (e *Engine) recordHistorySnapshot() {
 	if e.history == nil {
 		// Engines constructed via the bare struct literal (e.g.
 		// internal test seams) — no history ring configured.  Skip
 		// the record so the M2 plumbing stays optional for those
 		// paths; the M3 gate will observe SnapshotAt → (zero,
 		// false) and trigger the soft-fail-as-retry path.
 		return
 	}
+	if e.historyDepth <= 0 {
+		return
+	}
 	if _, exists := e.history[e.catalogVersion]; exists {
 		return
 	}
 	if len(e.historyOrder) >= e.historyDepth {
 		evict := e.historyOrder[0]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (e *Engine) recordHistorySnapshot() {
if e.history == nil {
// Engines constructed via the bare struct literal (e.g.
// internal test seams) — no history ring configured. Skip
// the record so the M2 plumbing stays optional for those
// paths; the M3 gate will observe SnapshotAt → (zero,
// false) and trigger the soft-fail-as-retry path.
return
}
if _, exists := e.history[e.catalogVersion]; exists {
return
}
if len(e.historyOrder) >= e.historyDepth {
evict := e.historyOrder[0]
e.historyOrder = e.historyOrder[1:]
delete(e.history, evict)
func (e *Engine) recordHistorySnapshot() {
if e.history == nil {
// Engines constructed via the bare struct literal (e.g.
// internal test seams) — no history ring configured. Skip
// the record so the M2 plumbing stays optional for those
// paths; the M3 gate will observe SnapshotAt → (zero,
// false) and trigger the soft-fail-as-retry path.
return
}
if e.historyDepth <= 0 {
return
}
if _, exists := e.history[e.catalogVersion]; exists {
return
}
if len(e.historyOrder) >= e.historyDepth {
evict := e.historyOrder[0]
e.historyOrder = e.historyOrder[1:]
delete(e.history, evict)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@distribution/engine.go` around lines 199 - 214, In recordHistorySnapshot,
guard against non-positive history depths by treating history as disabled when
e.historyDepth <= 0: add an early return if e.history == nil || e.historyDepth
<= 0 to avoid indexing e.historyOrder[0]; use the existing symbols e.history,
e.historyDepth, e.historyOrder and e.catalogVersion to perform the check and
leave the rest of the eviction/insert logic unchanged so tests/bare-engine paths
don’t panic.

…894

Three findings from claude's review of PR #894, all valid:

  * OwnerOf O(N)-vs-O(early-break) on sorted route list.  Routes
    are sorted by Start (UpdateRoute / routesFromCatalog keep them
    sorted; recordHistorySnapshotLocked clones from that order).
    Once key < r.Start, no later route can cover key either.
    `continue` worked but iterated the whole tail; `break` short-
    circuits.  Matters because M3 puts OwnerOf on every txn
    commit's apply path.

  * historyOrder backing-array growth on FIFO eviction.  The
    previous `e.historyOrder = e.historyOrder[1:]` only moved the
    slice header — the head of the original backing array stayed
    alive across evictions, growing unboundedly.  Replace with an
    explicit `make([]uint64, len-1, historyDepth) + copy` so the
    array stays bounded at historyDepth.

  * recordHistorySnapshot lock contract fragility.  The function
    requires the caller to hold e.mu (write lock); the doc said
    so but nothing in the name signalled it.  Renamed to
    recordHistorySnapshotLocked per the Go convention so a future
    refactor that moves the call past an Unlock surfaces the
    contract by name.

Test nits also addressed:

  * TestEngineSnapshotAt_FIFOEviction now calls t.Parallel() and
    the direct `e.historyDepth = 3` write is documented as
    test-local (Engine not yet shared with any concurrent
    reader).
  * TestKvFSM_WithRouteHistory_NilProviderTreatedAsUnwired asserts
    shardGroupID=7 explicitly so a future refactor that
    accidentally zeroes it would be caught.

Verification: go test -race -count=1 ./distribution ./kv → pass
(1.0 s + 8.1 s).  No spec change; no contract change visible to
callers outside the distribution package.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 31, 2026

All three claude review items addressed in the latest commit:

1. OwnerOf O(N) → early break. Routes are sorted by Start so once key < r.Start no later route can cover key either; continuebreak short-circuits without changing resolution semantics. Matters because M3 puts OwnerOf on every txn commit's apply path.

2. historyOrder backing-array leak. historyOrder[1:] only advanced the slice header — the head of the backing array stayed alive across evictions. Replaced with make([]uint64, len-1, historyDepth) + copy so the array stays bounded at historyDepth.

3. recordHistorySnapshot lock-contract fragility. Renamed to recordHistorySnapshotLocked per the Go convention. Doc comment also explicitly cites the lock requirement.

Test nits also addressed:

  • TestEngineSnapshotAt_FIFOEviction now calls t.Parallel(); direct e.historyDepth = 3 write is documented as test-local (Engine is local to the test goroutine before any ApplySnapshot fires).
  • TestKvFSM_WithRouteHistory_NilProviderTreatedAsUnwired now asserts shardGroupID=7 explicitly so a future refactor that accidentally zeroes it would be caught.

go test -race ./distribution ./kv — pass (1.0s + 8.1s). No spec or contract change visible outside the distribution package.

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 31, 2026

Claude finished @bootjp's task in 3m 38s —— View job


Code Review — M2 follow-up (post-fixup commit e2dbb16)

Tasks

  • Understand the request
  • Read changed files (distribution/engine.go, kv/fsm.go, tests)
  • Verify previous review fixes were applied correctly
  • Check for any new issues
  • Post final review

All three items from the original review and all noted nits are cleanly addressed. Details below.

Original issues — verified fixed

1. OwnerOf continuebreak
distribution/engine.go:172 now uses break. Correct: routes are sorted by Start, so once key < r.Start no subsequent route can cover key. The inline comment at lines 160–168 explains the invariant and the M3 motivation — well done.

2. historyOrder backing-array growth
distribution/engine.go:233-235 now does make([]uint64, len-1, historyDepth) + copy instead of reslicing. The backing array is kept bounded at historyDepth slots from the first eviction onwards. Compaction is cheap (single allocation, ≤historyDepth entries) and the fix is correct.

3. recordHistorySnapshotrecordHistorySnapshotLocked
Rename follows the Go convention. The method-level comment acknowledges the NewEngineWithDefaultRoute exception (pre-sharing call without the lock). This is an acceptable resolution.

Nits — verified fixed

4. t.Parallel() in FIFOEviction
Added at line 562. The e.historyDepth = 3 direct write has a comment explaining it is test-local and the engine is not yet shared.

5. shardGroupID=7 assertion in NilProviderTreatedAsUnwired
kv/fsm_route_history_test.go:98 now asserts require.Equal(t, uint64(7), fsm.shardGroupID, ...).


Two items for M3 tracking

A. buildEngine does not seed v=0 history (deferred M3 concern)

main.go:592-598buildEngine (the static --shardRanges path) calls distribution.NewEngine() then engine.UpdateRoute(...). catalogVersion stays 0, but recordHistorySnapshotLocked is never called, so no v=0 entry lands in the ring.

A transaction that ran against this cluster at v=0 would hit SnapshotAt(0) = (zero, false) at M3 gate time — treated as "not in ring" → coordinator retry. For operator-provisioned static-range clusters this could mean spurious retries on every first commit until a catalog update bumps the version.

NewEngineWithDefaultRoute correctly seeds v=0 (and the test TestEngineSnapshotAt_SeedsVersionZeroForDefaultRoute covers it), but buildEngine takes a different path. Before M3 ships, either:

  • buildEngine should call recordHistorySnapshotLocked after populating routes, or
  • the M3 gate comment should document that buildEngine clusters start with a ring gap at v=0 and a single coordinator retry is expected on first commit.

Fix this →

B. Defensive historyDepth <= 0 guard (minor)

distribution/engine.go:223recordHistorySnapshotLocked guards e.history == nil but not e.historyDepth == 0. If an engine is constructed with history initialized but historyDepth left at the zero value (possible via struct literal or future test seam), the condition len(e.historyOrder) >= e.historyDepth evaluates to 0 >= 0 = true and e.historyOrder[0] panics on the empty slice.

This path does not exist in current code — all constructors that initialize history also set historyDepth = DefaultRouteHistoryDepth. But a two-line guard makes the function safe against future construction patterns:

if e.historyDepth <= 0 {
    return
}

Not a blocker; just worth adding before M3 introduces more callers.


Verdict

The fixup commit cleanly resolves every item from the original review. The code is correct and the 10 tests provide solid coverage of the M2 contracts. Items A and B above are not M2 blockers — they are guard-rails to lay before M3 ships. The PR is good to merge as-is.

@github-actions
Copy link
Copy Markdown
Contributor

TLA+ spec divergence review (auto-triggered)

This PR touches files that the TLA+ safety spec has an anchor on (per
docs/design/2026_05_28_partial_tla_safety_spec.md §3),
so an AI review is requested below to verify the implementation has not drifted
from the model.

Anchored files changed in this PR head (e2dbb16):

  • distribution/engine.go
  • distribution/engine_test.go
  • kv/fsm.go

What to check, by subsystem:

  • kv/hlc*.goNext() must respect the HLC-4 preconditions (i)/(ii)/(iii) from the design doc: bounded skew, logical-counter handoff on leader change (strategy (c) Observe(MaxAppliedHLC)), and the commit-time ceiling fence (fail-closed when wall_now >= physicalCeiling). Any change to the bit layout (48/16), the CAS loop, or the ceiling getter/setter is in scope.
  • kv/coordinator.go, kv/sharded_coordinator.goRunHLCLeaseRenewal, hlcRenewalInterval, hlcPhysicalWindowMs constants, and the new-term detection that calls Observe(fsm.MaxAppliedHLC()) (strategy (c)). Any change to renewal cadence, group selection, or fail-closed behaviour is in scope.
  • kv/transaction.go, kv/lock_resolver.go — OCC commit-ts assignment, lock-map encoding (key, lock_ts) -> start_ts, and the LockResolver action OCC-3 depends on. (M2 spec will land OCC-1..OCC-5; until then the spec doc §5.2 is the contract.)
  • kv/fsm.go — FSM apply of HLC lease entries (SetPhysicalCeiling), and any future MaxAppliedHLC() accessor that strategy (c) needs.
  • store/mvcc_store.go — version visibility, snapshot install, and the MVCC-1..MVCC-4 invariants (M3 scope).
  • distribution/** — route catalog versioning, SplitRange atomicity, and CatalogWatcher async fan-out (M4 scope).

If the change is correct but requires a spec update, edit tla/hlc/HLC.tla (or the corresponding M2..M5 module once landed) and the design doc in the same PR. The tla-check workflow runs the TLC model check on the same paths.


@claude review please verify TLA+ spec divergence per the checklist above.

@codex review please verify TLA+ spec divergence per the checklist above.

@chatgpt-codex-connector
Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@bootjp bootjp merged commit 2b118a4 into main May 31, 2026
11 checks passed
@bootjp bootjp deleted the feat/composed1-m2-route-history branch May 31, 2026 06:59
bootjp added a commit that referenced this pull request May 31, 2026
Third milestone of the Composed-1 cross-group commit-time guard per
docs/design/2026_05_29_proposed_composed1_cross_group_commit_guard.md
§M3.  Wires the safety property at apply time using the M2
versioned-snapshot ring and the M1 ObservedRouteVersion field.

Stacked on PR #894 (M2 plumbing).

What's enforced:

  (a) Observed-version owner — the spec-level Composed-1 check from
      tla/composed/Composed.tla.  Every write key of a Composed-1-
      pinned txn must be owned by THIS Raft group at the txn's
      observed catalog version (the version it read its read-set
      at, set in M1 via OperationGroup.ObservedRouteVersion).
      Refusal returns ErrComposed1Violation.

  (b) Current-version cross-version-read fence — the §4.4 / §3
      codex P1 trace.  Even when (a) passes, a route shift between
      BeginTxn and Commit can leave the write landing on the OLD
      owner while readers at the new version route to the NEW
      owner and miss the write.  The current-version fence refuses
      that case so M4 retry can re-route.  Refusal returns
      ErrComposed1Violation with a different diagnostic prefix
      ("current-version" vs "observed-version") so the retry path
      can distinguish.

  (c) Retention-miss fail-closed — when the txn's observed version
      has been evicted from the M2 ring (long-running txn or high
      catalog churn), return ErrComposed1VersionGCd.  Per design
      doc §4.3, the not-found semantics is a hard retryable error,
      NOT a soft pass, because soft-pass would let the gate be
      bypassed exactly in the cases where the cross-version-read
      hazard is most likely.

Short-circuits cleanly in three legacy / not-applicable cases:

  * FSM constructed without WithRouteHistory (legacy / test seam):
    routes == nil, gate returns nil.
  * Request carries ObservedRouteVersion == 0 (unpinned — pre-M1
    caller, or 2PC ABORT request that doesn't carry the version):
    gate returns nil.  This is what keeps M3 from regressing every
    caller that has not migrated to set OperationGroup.
    ObservedRouteVersion.
  * Engine.Current returns (zero, false) — engine has no history
    (bare-struct test seam): the (b) fence falls through.

Changes:

  * distribution/engine.go — adds Engine.Current() returning the
    snapshot at the current catalogVersion (used by the M3 fence).
    Adds SetHistoryDepthForTest as the cross-package test seam
    that lets kv-side tests trigger eviction without bypassing
    the package boundary (claude review on PR #894 — fragile-but-
    test-local lock contract documented inline).

  * kv/fsm.go — defines ErrComposed1Violation and
    ErrComposed1VersionGCd sentinels with the wrapped-error
    diagnostic shape M4 retry will use.  Adds verifyComposed1 and
    verifyOwnerFromSnapshot.  Wires verifyComposed1 into
    handleTxnRequest at the top so PREPARE / COMMIT / ABORT /
    NONE all pass through the gate (ABORT carries
    ObservedRouteVersion=0 and falls through naturally).

  * kv/route_history.go — RouteHistory interface gains Current().
    Adapter forwards to distribution.Engine.Current().

Tests (kv/fsm_composed1_test.go):

  * TestVerifyComposed1_StaleObservedVersionWithMovedKeyFails —
    design doc M3 "done when" criterion (i): observed-version
    snapshot resolves the key to a different group than this FSM.
  * TestVerifyComposed1_ObservedVersionOlderThanRingFails —
    criterion (ii): observed version is outside the ring →
    ErrComposed1VersionGCd.  Uses depth=2 via the cross-package
    SetHistoryDepthForTest seam.
  * TestVerifyComposed1_ObservedPassesButCurrentDiffersFails —
    criterion (iii) / the §3 codex P1 trace: observed-version
    check passes (routes[1][k1]=g1) but the current snapshot at
    v=2 has moved k1 to g2 → ErrComposed1Violation with the
    "current-version" diagnostic prefix.
  * TestVerifyComposed1_ObservedVersionZeroSkipsGate — legacy
    caller (ObservedRouteVersion=0) bypasses the gate.
  * TestVerifyComposed1_NilRouteHistorySkipsGate — unwired FSM
    (no WithRouteHistory option) bypasses the gate; matches the
    pre-feature posture.

Verification:
  * go build ./...                            — clean
  * go vet ./...                              — clean
  * go test -race -count=1 ./kv ./distribution — 10.2 s + 1.0 s, pass

Self-review (5 lenses):

  1. Data loss — the gate refuses commits but cannot lose them.
     Every rejection emits a sentinel the M4 coordinator path will
     convert into a successful commit on the correct owner.
  2. Concurrency — the gate runs under the FSM's apply loop (Raft
     applies are serial), so no new lock ordering.  RouteHistory
     reads take the engine's read lock; SnapshotAt and Current
     each acquire and release in a single call.
  3. Performance — two map lookups + a per-mutation linear scan
     of the snapshot's sorted routes per Composed-1-pinned commit.
     OwnerOf short-circuits via the M2 round-1 break optimisation,
     so the per-mutation cost is bounded by the first non-covering
     gap rather than the full routes slice.  Legacy callers
     (ObservedRouteVersion=0) pay one branch per commit and exit.
  4. Data consistency — closes the Composed-1 (observed) and
     Composed-1a (current) gaps end-to-end.  Spec correspondence:
     tla/composed/Composed.tla's Commit precondition is now
     enforced at apply time; the §4.4 fence matches
     Composed1a_CommitToCurrentOwner from PR #878.
  5. Test coverage — 5 new unit tests cover the three design doc
     criteria + the two legacy short-circuit paths.  Existing
     handleTxnRequest tests pass unchanged (their requests carry
     ObservedRouteVersion=0 from M1's behaviour-neutral default).

Next milestone (separate PR per design doc §6): M4 — coordinator
retry path.  When either Composed-1 sentinel returns, the
coordinator re-reads the route cache, re-routes the txn against
the new owning group, and re-issues it once.
bootjp added a commit that referenced this pull request May 31, 2026
Third milestone of the Composed-1 cross-group commit-time guard per
docs/design/2026_05_29_proposed_composed1_cross_group_commit_guard.md
§M3.  Wires the safety property at apply time using the M2
versioned-snapshot ring and the M1 ObservedRouteVersion field.

Stacked on PR #894 (M2 plumbing).

What's enforced:

  (a) Observed-version owner — the spec-level Composed-1 check from
      tla/composed/Composed.tla.  Every write key of a Composed-1-
      pinned txn must be owned by THIS Raft group at the txn's
      observed catalog version (the version it read its read-set
      at, set in M1 via OperationGroup.ObservedRouteVersion).
      Refusal returns ErrComposed1Violation.

  (b) Current-version cross-version-read fence — the §4.4 / §3
      codex P1 trace.  Even when (a) passes, a route shift between
      BeginTxn and Commit can leave the write landing on the OLD
      owner while readers at the new version route to the NEW
      owner and miss the write.  The current-version fence refuses
      that case so M4 retry can re-route.  Refusal returns
      ErrComposed1Violation with a different diagnostic prefix
      ("current-version" vs "observed-version") so the retry path
      can distinguish.

  (c) Retention-miss fail-closed — when the txn's observed version
      has been evicted from the M2 ring (long-running txn or high
      catalog churn), return ErrComposed1VersionGCd.  Per design
      doc §4.3, the not-found semantics is a hard retryable error,
      NOT a soft pass, because soft-pass would let the gate be
      bypassed exactly in the cases where the cross-version-read
      hazard is most likely.

Short-circuits cleanly in three legacy / not-applicable cases:

  * FSM constructed without WithRouteHistory (legacy / test seam):
    routes == nil, gate returns nil.
  * Request carries ObservedRouteVersion == 0 (unpinned — pre-M1
    caller, or 2PC ABORT request that doesn't carry the version):
    gate returns nil.  This is what keeps M3 from regressing every
    caller that has not migrated to set OperationGroup.
    ObservedRouteVersion.
  * Engine.Current returns (zero, false) — engine has no history
    (bare-struct test seam): the (b) fence falls through.

Changes:

  * distribution/engine.go — adds Engine.Current() returning the
    snapshot at the current catalogVersion (used by the M3 fence).
    Adds SetHistoryDepthForTest as the cross-package test seam
    that lets kv-side tests trigger eviction without bypassing
    the package boundary (claude review on PR #894 — fragile-but-
    test-local lock contract documented inline).

  * kv/fsm.go — defines ErrComposed1Violation and
    ErrComposed1VersionGCd sentinels with the wrapped-error
    diagnostic shape M4 retry will use.  Adds verifyComposed1 and
    verifyOwnerFromSnapshot.  Wires verifyComposed1 into
    handleTxnRequest at the top so PREPARE / COMMIT / ABORT /
    NONE all pass through the gate (ABORT carries
    ObservedRouteVersion=0 and falls through naturally).

  * kv/route_history.go — RouteHistory interface gains Current().
    Adapter forwards to distribution.Engine.Current().

Tests (kv/fsm_composed1_test.go):

  * TestVerifyComposed1_StaleObservedVersionWithMovedKeyFails —
    design doc M3 "done when" criterion (i): observed-version
    snapshot resolves the key to a different group than this FSM.
  * TestVerifyComposed1_ObservedVersionOlderThanRingFails —
    criterion (ii): observed version is outside the ring →
    ErrComposed1VersionGCd.  Uses depth=2 via the cross-package
    SetHistoryDepthForTest seam.
  * TestVerifyComposed1_ObservedPassesButCurrentDiffersFails —
    criterion (iii) / the §3 codex P1 trace: observed-version
    check passes (routes[1][k1]=g1) but the current snapshot at
    v=2 has moved k1 to g2 → ErrComposed1Violation with the
    "current-version" diagnostic prefix.
  * TestVerifyComposed1_ObservedVersionZeroSkipsGate — legacy
    caller (ObservedRouteVersion=0) bypasses the gate.
  * TestVerifyComposed1_NilRouteHistorySkipsGate — unwired FSM
    (no WithRouteHistory option) bypasses the gate; matches the
    pre-feature posture.

Verification:
  * go build ./...                            — clean
  * go vet ./...                              — clean
  * go test -race -count=1 ./kv ./distribution — 10.2 s + 1.0 s, pass

Self-review (5 lenses):

  1. Data loss — the gate refuses commits but cannot lose them.
     Every rejection emits a sentinel the M4 coordinator path will
     convert into a successful commit on the correct owner.
  2. Concurrency — the gate runs under the FSM's apply loop (Raft
     applies are serial), so no new lock ordering.  RouteHistory
     reads take the engine's read lock; SnapshotAt and Current
     each acquire and release in a single call.
  3. Performance — two map lookups + a per-mutation linear scan
     of the snapshot's sorted routes per Composed-1-pinned commit.
     OwnerOf short-circuits via the M2 round-1 break optimisation,
     so the per-mutation cost is bounded by the first non-covering
     gap rather than the full routes slice.  Legacy callers
     (ObservedRouteVersion=0) pay one branch per commit and exit.
  4. Data consistency — closes the Composed-1 (observed) and
     Composed-1a (current) gaps end-to-end.  Spec correspondence:
     tla/composed/Composed.tla's Commit precondition is now
     enforced at apply time; the §4.4 fence matches
     Composed1a_CommitToCurrentOwner from PR #878.
  5. Test coverage — 5 new unit tests cover the three design doc
     criteria + the two legacy short-circuit paths.  Existing
     handleTxnRequest tests pass unchanged (their requests carry
     ObservedRouteVersion=0 from M1's behaviour-neutral default).

Next milestone (separate PR per design doc §6): M4 — coordinator
retry path.  When either Composed-1 sentinel returns, the
coordinator re-reads the route cache, re-routes the txn against
the new owning group, and re-issues it once.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant