Skip to content

kv: bound pre-Phase-D intent creation and classify resolution evidence - #1251

Open
bootjp wants to merge 1 commit into
mainfrom
design/prephase-d-resolution-evidence
Open

bootjp wants to merge 1 commit into
mainfrom
design/prephase-d-resolution-evidence

Conversation

@bootjp

@bootjp bootjp commented Sep 15, 2026

Copy link
Copy Markdown
Owner

The finding

The pre-Phase-D carve-out in ValidateForwardedTxnCommitTimestamp let a caller supply its own proof. A caller could PREPARE at an arbitrary pre-Phase-D start timestamp — creating an intent there — and then COMMIT at another pre-D timestamp, which the resolution path accepted because it was pre-D. Neither half consulted anything durable. The exemption exists for a real case (a transaction that legitimately began before the Phase-D marker and is only now being resolved), but as written it could not distinguish that case from a fabricated one.

docs/design/2026_09_02_proposed_prephase_d_resolution_evidence.md (now _partial_) laid out three options. This PR implements the decision recorded in its new §4a: §4.3 — the primary's durable record where readable, the time bound everywhere else.

What ships

§4.2 is wired. ValidateForwardedTxnStartTimestamp admits a pre-Phase-D start only inside an admission window — maxTxnLockTTLms plus an hour of grace — after which no transaction can still legitimately be preparing at such a start. This removes step 1 of the attack: the ability to create a fresh pre-D intent after the marker has passed.

The Phase-D floor arrives through a new optional TSOPhaseDFloorSource interface, which RaftTSOAllocator (kv/tso_raft.go:468) already satisfies. An allocator that does not implement it leaves the window open — the bound exists to narrow a carve-out, and a missing signal must not turn that narrowing into a refusal that strands legitimate legacy resolution.

§4.1's decision is implemented and tested but unwired. ClassifyPrePhaseDCommit prefers the primary's durable commit record over the time bound wherever it is readable. It is not yet called because the forwarded-commit path lacks the signal it needs: whether this node can definitively read the primary, as distinct from simply not finding a record. Wiring it without that distinction would make a remote primary indistinguishable from a missing record and would refuse exactly the cross-shard resolutions the design is careful to preserve. ShardStore.primaryTxnRecordedStatus already draws the distinction with its done return; exposing it to this path is the remaining work, recorded in the doc.

ABORT stays a window decision even with a local primary — abortTSFrom synthesises its timestamp, so no durable record can ever support it, and requiring one would refuse every legitimate abort resolution.

On the wall-clock read

The window reads time.Now(), which CLAUDE.md forbids for ordering. It is not an ordering decision. It governs whether a request may create state, never where that state sorts. No visibility, OCC, or MVCC comparison consults it. A wrong clock only widens or narrows who may create a pre-D intent — it can never place a write at the wrong point in the timestamp order. Skew costs availability for legacy resolution, never correctness. The header comment on kv/tso_prephase_d.go states this so the next reader does not have to re-derive it.

Behavior change / risk

Behavior change: a forwarded transaction with a pre-Phase-D start timestamp is refused (ErrPrePhaseDWindowClosed) once the admission window has elapsed. Before this PR it was always admitted.

Risk: a cluster whose Phase-D activation is more than maxTxnLockTTLms + 1h in the past, still holding an unresolved pre-D intent, will now be refused on new pre-D prepares. That is the intent — such a prepare cannot be a legitimately in-flight transaction. Resolution of already-created intents is unaffected; only creation is bounded. The grace hour errs long precisely because too small strands long-TTL transactions while too large only extends a window that is already bounded.

Test evidence

kv/tso_prephase_d_test.go — 9 tests. Each property was revert-checked: the corresponding test FAILS with the fix removed, and the file restores byte-exact (diff -q) afterward.

Reverted Test that failed
pre-D start admitted unconditionally (the original carve-out) TestForwardedStartTimestampClosesAfterTheAdmissionWindow
activation read from the logical instead of the physical half TestPhaseDActivationMillisReadsThePhysicalHalf, +2
ABORT required to produce a record TestClassifyPrePhaseDCommitTreatsAbortAsAWindowDecision, +1
record allowed to disagree with the claim TestClassifyPrePhaseDCommitUsesTheRecordWhenItCanReadIt/a_disagreeing_record_refuses_even_inside_the_window
  • go test ./kv/ ./adapter/ -race -count=1 -timeout 40mok kv 17.573s, ok adapter 641.807s
  • golangci-lint --config=.golangci.yaml run ./kv/... → 0 issues (no //nolint added)

Self-review

  1. Data loss — No write path touched. The change is admission-only: it can refuse a prepare, never lose or overwrite a committed write. No FSM, snapshot, or Pebble semantics involved.
  2. Concurrency / distributed failuresprePhaseDNowMillis is a package var swapped only under t.Cleanup in tests; production reads it without mutation. PhaseDFloor() is read through the existing allocator, so a leadership flip mid-request yields either the old or new floor — both are valid bounds, and neither can admit a post-window prepare. Raced clean.
  3. Performance — One interface type-assertion and one time.Now() on the forwarded-write path, both only on the error branch after ValidateDurablePersistenceTimestamp has already returned ErrTSOTimestampPrePhaseD. The common path is unchanged; no Raft round-trip, no allocation.
  4. Data consistency — The wall-clock read is an admission decision, never an ordering one (see above). No MVCC visibility, OCC validation, or HLC ceiling comparison consults it. PhaseDActivationMillis extracts the physical half with >> HLCLogicalBits, matching the documented (UnixMilli << 16) | logical layout, and is directly tested.
  5. Test coverage — Every new branch is covered, including the three the classifier can take and the open-window fallback for an allocator without the interface. No Jepsen workload applies — no replication, MVCC, or Redis-adapter behavior changed.

https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE

The pre-Phase-D carve-out let a caller supply its own proof. It could
PREPARE at an arbitrary pre-D start timestamp, creating an intent there,
then COMMIT at another pre-D timestamp the resolution path accepted
because it was pre-D. Neither half consulted anything durable.

This closes the first step. ValidateForwardedTxnStartTimestamp now admits
a pre-Phase-D start only inside an admission window -- the longest legal
lock TTL plus an hour of grace for skew and rolling activation -- after
which no transaction can still legitimately be preparing at such a start.
An allocator that cannot report the Phase-D floor leaves the window open;
a missing signal must not narrow into a refusal that would strand
legitimate legacy resolution.

The window reads a wall clock, which the HLC rules forbid for ordering.
It is not an ordering decision: it governs whether a request may create
state, never where that state sorts. No visibility, OCC or MVCC
comparison consults it, so skew costs availability for legacy
resolution, never correctness.

ClassifyPrePhaseDCommit is the second half -- prefer the primary's
durable record over the time bound wherever it is readable -- and ships
tested but unwired. It needs a signal the forwarded-commit path does not
have yet: whether this node can definitively read the primary, as
distinct from not finding a record. Wiring it without that would make a
remote primary indistinguishable from a missing record and refuse the
cross-shard resolutions the design is careful to preserve.
ShardStore.primaryTxnRecordedStatus already draws that distinction;
exposing it is the remaining work, tracked in the doc.

ABORT stays a window decision even with a local primary: abortTSFrom
synthesises its timestamp, so no record can ever support it.

Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
@bootjp

bootjp commented Sep 15, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@bootjp

bootjp commented Sep 15, 2026

Copy link
Copy Markdown
Owner Author

@claude review

@coderabbitai

coderabbitai Bot commented Sep 15, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 39 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 03a83631-443c-42b3-8e89-3f3fc76c059d

📥 Commits

Reviewing files that changed from the base of the PR and between 4ca7e90 and 3e27a89.

📒 Files selected for processing (4)
  • docs/design/2026_09_02_partial_prephase_d_resolution_evidence.md
  • kv/tso.go
  • kv/tso_prephase_d.go
  • kv/tso_prephase_d_test.go

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.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-15T13:48:02.428446Z 3e27a89 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown

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


Review

  • Gather context (PR description, diff, design doc)
  • Read changed files (kv/tso.go, kv/tso_prephase_d.go, kv/tso_prephase_d_test.go, design doc)
  • Trace the production allocator wiring for TSOPhaseDFloorSource
  • Post review feedback

Summary

The design (§4.2/§4.3 in docs/design/2026_09_02_partial_prephase_d_resolution_evidence.md) and the pure functions in kv/tso_prephase_d.go are sound and well-tested in isolation. But the one thing the PR title promises — "bound pre-Phase-D intent creation" in production — doesn't actually happen, because the interface the bound depends on never reaches the concrete allocator that's wired up at runtime.

Finding 1 (blocking): the admission window is a no-op in every real deployment

prePhaseDStartWithinWindow (kv/tso.go:427-434) only narrows the carve-out if alloc.(TSOPhaseDFloorSource) succeeds; otherwise it returns true (window open) by design. ValidateForwardedTxnStartTimestamp is called from adapter/internal.go:984 with i.tsAllocator, which is populated in main.go:3658-3662 via kv.ConfiguredTimestampAllocatorThrough(coordinate) — a helper whose own doc comment says it deliberately returns the allocator without resolving runtime-mode decorators. In the only path where Phase D is ever actually active (configureDedicatedCoordinatorTSO, main.go:2461-2524), that value is *DynamicTimestampAllocator (controller.Allocator()).

Walking the chain that *DynamicTimestampAllocator wraps:

  • DynamicTimestampAllocator (kv/tso_runtime.go:84-192) forwards PhaseDActive/PhaseDRequired/ValidateDurableTimestamp/Invalidate through currentTimestampAllocator() — but has no PhaseDFloor() method.
  • Its durableAllocator in Phase D is a *BatchAllocator (kv/tso_runtime.go:251-256), which likewise forwards PhaseDActive/PhaseDRequired/ValidateDurableTimestamp to b.tso (kv/tso.go:862-878) but has no PhaseDFloor() method.
  • b.tso is a *LeaderRoutedTSOAllocator (kv/tso_raft.go:502), which forwards PhaseDActive/PhaseDRequired/ValidateDurableTimestamp (kv/tso_raft.go:751-824) but again has no PhaseDFloor() method — even though the *RaftTSOAllocator underneath it (kv/tso_raft.go:468-473) is the one type that actually implements PhaseDFloor().

So the type assertion at kv/tso.go:428 fails at every layer of the real wrapper chain, prePhaseDStartWithinWindow always returns true, and ValidateForwardedTxnStartTimestamp never returns ErrPrePhaseDWindowClosed in production — regardless of how much time has passed since Phase D activated. This directly contradicts the PR body's "§4.2 is wired" claim and the design doc's "What has shipped" section, and it's exactly the attack surface this PR set out to close (an attacker can still PREPARE at an arbitrary pre-Phase-D start indefinitely).

This gap is invisible in the test suite because TestForwardedStartTimestampClosesAfterTheAdmissionWindow (kv/tso_prephase_d_test.go:180-200) drives ValidateForwardedTxnStartTimestamp with stubPrePhaseDAllocator, a fake that implements TSOPhaseDFloorSource directly — it never exercises the real DynamicTimestampAllocator → BatchAllocator → LeaderRoutedTSOAllocator → RaftTSOAllocator chain that production actually wires up.

Fix: add PhaseDFloor() uint64 forwarding methods to DynamicTimestampAllocator, BatchAllocator, and LeaderRoutedTSOAllocator, mirroring the existing PhaseDActive/PhaseDRequired forwarding pattern each already has, plus a test that goes through the real wrapper chain (or at minimum a compile-time assertion that the production-wired type satisfies TSOPhaseDFloorSource) so this can't silently regress again.

Fix this →

Finding 2 (minor): likely data race in the new tests on the package-level clock var

prePhaseDNowMillis (kv/tso_prephase_d.go:155) is a plain package-level var, reassigned directly (no atomic/mutex) by two different top-level tests — TestForwardedStartTimestampClosesAfterTheAdmissionWindow and TestForwardedStartTimestampAfterPhaseDIsUnaffectedByTheWindow (kv/tso_prephase_d_test.go:180-217) — both marked t.Parallel(). Go's test scheduler can run both concurrently in the same package, giving two goroutines unsynchronized read/write access to the same global. This is a classic -race-flaggable pattern; whether it fires depends on scheduling, so it may be passing today by luck rather than by design. Since the whole point of t.Cleanup(func() { prePhaseDNowMillis = restore }) is correctness under -race, at minimum these two tests should drop t.Parallel() (or the var should be swapped under a mutex/t.Setenv-style serialization) so the suite doesn't become a source of intermittent CI flakes.

Everything else

  • PrePhaseDStartAdmissible, PhaseDActivationMillis, and ClassifyPrePhaseDCommit are pure, well-documented, and their boundary tests (kv/tso_prephase_d_test.go) look correctly targeted (off-by-one at the window edge, physical-vs-logical half, record-disagrees-refuses, abort-is-always-a-window-decision).
  • Leaving §4.1 (ClassifyPrePhaseDCommit) unwired pending the primaryTxnRecordedStatus readability signal is a reasonable, clearly-documented scope cut, not a defect.
  • The wall-clock-read justification in the header comment is convincing as written (admission-only, never an ordering decision) — no objection there.
  • Design-doc-first workflow was followed correctly (doc renamed to _partial_, decision recorded in §4a before the implementation commit).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3e27a89ff3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread kv/tso.go
Comment on lines +428 to +430
source, ok := alloc.(TSOPhaseDFloorSource)
if !ok {
return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Resolve the Phase-D floor through allocator wrappers

In production Phase-D deployments, internalTimestampOptions passes the configured DynamicTimestampAllocator to Internal, but neither that wrapper nor its BatchAllocator/LeaderRoutedTSOAllocator delegates PhaseDFloor. Consequently this assertion always fails and returns true, so forwarded requests can continue creating arbitrary pre-Phase-D intents indefinitely and the attack this commit is intended to bound remains open. The floor must be exposed through the production allocator chain or obtained after resolving the wrappers.

Useful? React with 👍 / 👎.

Comment thread kv/tso.go
Comment on lines +415 to +416
if !prePhaseDStartWithinWindow(alloc) {
return errors.Wrap(ErrPrePhaseDWindowClosed, label)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Apply the closed-window rejection only to PREPARE

When an allocator does expose the floor and the window has closed, this rejects every forwarded transaction carrying a pre-D start timestamp, not just intent creation. adapter.Internal.stampTxnTimestamps calls this before inspecting the phase, while applyTxnResolution sends COMMIT and ABORT requests with the original startTS; therefore a legitimate old intent resolved after the window is rejected before ValidateForwardedTxnCommitTimestamp can apply its resolution carve-out, leaving the key locked. The window check needs to be phase-aware so it gates PREPARE rather than resolution traffic.

Useful? React with 👍 / 👎.

Comment thread kv/tso_prephase_d_test.go

// Inside the window: a transaction that began before the marker is still
// entitled to prepare.
prePhaseDNowMillis = func() int64 { return testPhaseDActivationMs }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid replacing the shared clock hook in parallel tests

This test and TestForwardedStartTimestampAfterPhaseDIsUnaffectedByTheWindow both call t.Parallel() and concurrently assign and restore the package-global prePhaseDNowMillis. A race-enabled run reports concurrent accesses, and an ordinary run can observe the other test's future-valued hook during the inside-window assertion and fail nondeterministically. Inject the clock per invocation, synchronize access, or stop these tests from running in parallel.

Useful? React with 👍 / 👎.

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