Skip to content

tso: add dedicated ceiling fsm - #1095

Merged
bootjp merged 54 commits into
mainfrom
design/dedicated-tso-fsm
Sep 2, 2026
Merged

tso: add dedicated ceiling fsm#1095
bootjp merged 54 commits into
mainfrom
design/dedicated-tso-fsm

Conversation

@bootjp

@bootjp bootjp commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add a minimal dedicated TSO state machine that accepts only HLC lease entries.
  • Snapshot and restore the physical ceiling as 8-byte big-endian state, and classify full lease entries as volatile-only.
  • Update the centralized TSO design doc status and remaining runtime wiring.

Validation

  • go test ./kv -run 'TestTSOStateMachine|TestLocalTSOAllocator|TestBatchAllocator|TestShardedCoordinator.*Timestamp|TestCoordinateUsesTSOAllocator' -count=1 -timeout=180s
  • go test ./kv -count=1 -timeout=300s
  • golangci-lint --config=.golangci.yaml run ./kv --timeout=5m
  • git diff --check
  • go test ./adapter -run '^TestMilestone1SplitRange_RestartReloadsCatalog$' -count=1 -timeout=180s
  • go test ./... -count=1 -timeout=600s (adapter package timed out at 600s; other packages completed)

Notes

  • This adds the dedicated FSM implementation and tests. Runtime bootstrap wiring for groupID = 0 remains a follow-up until the TSO leader redirect path exists.

Author: bootjp

Summary by CodeRabbit

  • 新機能

    • 専用タイムスタンプサービスに対応し、複数ノード・複数シャードで一貫した時刻を利用できるようになりました。
    • Shadow、Cutover、Phase Dへの段階的移行と、設定ファイルによる実行時モード切り替えに対応しました。
    • タイムスタンプの予約・検証APIを追加しました。
    • TSOの状態、遅延、移行状況を監視するメトリクスとアラートを追加しました。
  • 改善

    • Redis、DynamoDB、S3、SQSなどで、一貫した読み取り時点を用いて処理するようになりました。
    • 旧形式の保存データや構成からの復元互換性を維持しました。
  • バグ修正

    • 不正な時刻や移行状態を検出し、安全に処理を拒否するよう改善しました。

@bootjp

bootjp commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

TSOの永続状態、専用Raft group、Phase D、ランタイムモード切替を追加しました。各アダプターのトランザクションをkv.ReadTimestampとvoucherに統一し、起動ゲート、監視、互換復元、commit floor検証を追加しました。

Changes

TSO状態とランタイム制御

Layer / File(s) Summary
TSO状態、予約、Phase D
kv/tso_fsm.go, kv/tso_raft.go, kv/tso_runtime.go, kv/sharded_coordinator.go
allocation floor、cutover、Phase D marker、スナップショット復元、Raft予約、caller StartTS検証、ランタイムモード遷移を追加しました。
Commit floorとプロトコル契約
kv/shard_store.go, kv/coordinator.go, kv/tso.go, proto/*.proto, adapter/grpc.go
グループ別commit floor、TSO allocator解決、applied-read voucher、ValidateTimestamp、明示的なRaft group応答を追加しました。

ReadTimestamp配線

Layer / File(s) Summary
アダプターのトランザクション配線
adapter/distribution_server.go, adapter/dynamodb_*.go, adapter/redis_*.go, adapter/s3*.go, adapter/sqs_*.go, internal/filesystem/service.go
読み取りと書き込みに同じkv.ReadTimestampを使用し、DispatchWithReadTimestampへ変更しました。再試行経路でもvoucherを保持します。
検証と互換動作
adapter/*_test.go, multiraft_runtime_test.go, distribution/catalog_test.go, kv/*_test.go
Phase D、voucher再利用、legacy形式、エラー伝播、スナップショット時点読み取り、storeクリーンアップを検証しました。

起動と運用

Layer / File(s) Summary
専用TSO groupと起動配線
main.go, main_encryption_admin.go, main_*_test.go
専用TSO groupの構築、TSO runtime controller、モードファイル再読み込み、起動ゲート、LeaderView-only配線を追加しました。
監視と文書
monitoring/*.go, monitoring/prometheus/rules/tso-alerts.yml, docs/**/*.md, kv/tso_fanout_benchmark_test.go
TSOのリクエスト、shadow比較、モード、永続状態、再読み込みのメトリクスとアラートを追加しました。運用手順、設計書、ベンチマークを更新しました。

Estimated code review effort: 5 (Critical) | ~120 minutes

Merge Risk: 🔵 Low · up to a010d

The PR adds dedicated TSO state handling and related runtime, monitoring, and documentation changes. Current evidence indicates bounded integration and rollout risks: duplicate write observation, missing forwarded-write sampling, inconsistent rollout status, and alerting that may miss node-local readiness; the change is mergeable with explicit owner awareness or follow-up.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant DistributionServer
  participant TSORuntimeController
  participant LeaderRoutedTSOAllocator
  participant TSOStateMachine
  participant ShardedCoordinator

  Client->>DistributionServer: GetTimestamp or transaction request
  DistributionServer->>TSORuntimeController: resolve active allocator
  TSORuntimeController->>LeaderRoutedTSOAllocator: reserve or validate timestamp
  LeaderRoutedTSOAllocator->>TSOStateMachine: commit durable marker or allocation state
  TSOStateMachine-->>LeaderRoutedTSOAllocator: committed state
  LeaderRoutedTSOAllocator-->>DistributionServer: timestamp and durable state
  DistributionServer->>ShardedCoordinator: DispatchWithReadTimestamp
  ShardedCoordinator-->>Client: transaction result
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.29% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 140 functions across 24 files. (1 skipped:… 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 タイトルは専用TSO FSMとceiling状態の追加を正しく示しています。変更範囲全体を網羅しませんが、主要な変更の一部を具体的に表しています。
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.
Full details: Docstring Coverage

Explanation

Docstring coverage is 9.29% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 140 functions across 24 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

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.

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

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.

Code Review

This pull request implements the TSOStateMachine, a minimal state machine for the dedicated timestamp group that handles HLC lease-renewal entries, along with its snapshotting, restoration, and volatile entry classification logic. It also updates the design documentation to reflect this progress and adds comprehensive unit tests for the new state machine. There are no review comments to evaluate, and I have no additional feedback to provide.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

@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

| M6 — partial | Dedicated TSO Raft group (`groupID = 0`) is reserved/bootstrap-capable and warmed by the HLC renewal bridge; TSO-leader-only timestamp issuance and the minimal `TSOStateMachine` remain open | Low |

P3 Badge Update stale M6 status row

This milestone row still says the minimal TSOStateMachine remains open, but this same change marks it implemented in §1.0 and adds kv/tso_fsm.go. Readers using this design doc to plan M6 will incorrectly think the FSM work is still pending, so please update the row to match the new runtime-wiring-only remaining status.

ℹ️ 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_fsm.go Outdated
}
ceilingMs := int64(binary.BigEndian.Uint64(data[1:])) //nolint:gosec // value is a Unix ms timestamp encoded as uint64.
if f != nil && f.hlc != nil && ceilingMs > 0 {
f.hlc.SetPhysicalCeiling(ceilingMs)

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 Advance the TSO HLC floor on lease apply

When this FSM is used for the dedicated TSO group, applying a lease only updates physicalCeiling while leaving HLC.last at zero on followers/restarted nodes. If the current TSO leader has handed out timestamps under a future ceilingMs and then fails before the wall clock reaches it, the new leader has applied the same lease but has not observed any of those allocations, so its first NextBatchFenced can return (ceilingMs<<16)|0 again and duplicate timestamps. The TSO path needs to persist/observe an allocation floor or force a fresh higher lease before serving after leadership changes.

Useful? React with 👍 / 👎.

Comment thread kv/tso_fsm.go Outdated

func (f *TSOStateMachine) Apply(data []byte) any {
if len(data) != hlcLeaseEntryLen {
return errors.Wrapf(ErrTSOStateMachineInvalidEntry, "expected %d bytes, got %d", hlcLeaseEntryLen, len(data))

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 Return a halting response for invalid TSO entries

If a malformed payload is ever committed to the dedicated TSO group, returning a bare error here does not actually reject it at the raft apply layer: applyNormalCommitted only stops before setApplied for responses implementing HaltApply() error, while plain error responses are still marked applied and only delivered back to the proposer. In a misrouted group-0 proposal or mixed-version entry scenario, the invalid entry would be permanently advanced past instead of failing closed, violating the FSM's “accepts only HLC lease entries” contract.

Useful? React with 👍 / 👎.

@bootjp

bootjp commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

@codex review

Updated commit: 4e49dfbe7f31f084fc98bb5bec47a0e1458c4c24

Addressed current-head review findings:

  • TSOStateMachine now observes a derived allocation floor for each applied/restored HLC lease ceiling, preventing a restarted or newly elected TSO leader from reissuing the previous leader's logical slots.
  • Invalid TSO FSM entries now return a HaltApply response instead of a plain error, so malformed group-0 entries fail closed without advancing applied index.
  • The centralized TSO design doc M6/status text now matches the implemented FSM and remaining runtime-wiring work.

Caller/semantic audit:

  • NewTSOStateMachine remains only used by the new tests in this branch; runtime group-0 wiring is still a documented follow-up.
  • The HLC floor change is limited to the dedicated TSO FSM path and does not change kvFSM HLC lease apply behavior.

Validation:

  • go test ./kv -run 'TestTSOStateMachine|TestLocalTSOAllocator|TestBatchAllocator|TestShardedCoordinator.*Timestamp|TestCoordinateUsesTSOAllocator' -count=1 -timeout=180s
  • go test ./kv -count=1 -timeout=300s
  • golangci-lint --config=.golangci.yaml run ./kv --timeout=5m
  • git diff --check
  • git verify-commit HEAD

@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: 4e49dfbea8

ℹ️ 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_fsm.go Outdated
return
}
hlc.SetPhysicalCeiling(ceilingMs)
hlc.Observe(tsoLeaseAllocationFloor(ceilingMs))

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 Persist the post-lease allocation floor

When the dedicated TSO FSM is wired, applying/restoring a lease with this Observe only records ceilingMs|maxLogical in memory while snapshots/WAL entries still persist just ceilingMs. If a leader applies ceiling C, serves one timestamp, NextBatchFenced returns (C+1,0) because the floor is already C|maxLogical; after a restart or re-election with only the same lease entry/snapshot, applyTSOLeaseToHLC recreates the same floor and can serve (C+1,0) again. This means the new floor does not actually prevent reissuing timestamps unless the allocation floor/current HLC is made durable or allocation is blocked until a higher ceiling is committed.

Useful? React with 👍 / 👎.

Comment thread kv/tso_fsm.go Outdated
Comment on lines +72 to +73
if hlc == nil || ceilingMs <= 0 {
return

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 Reject non-positive TSO lease ceilings

When a full HLC-lease entry encodes 0 or a high-bit value that decodes negative, this branch returns without error, so Apply returns nil and the engine advances the applied index. Renewals only generate positive Unix-ms ceilings, so these are malformed group-0 lease entries; accepting them masks the bad proposal instead of using the new fail-closed HaltApply path. Please reject non-positive ceilings in Apply while keeping snapshot restore's empty-state handling separate if needed.

Useful? React with 👍 / 👎.

Comment thread kv/tso_fsm.go Outdated
if f != nil {
hlc = f.hlc
}
return &tsoFSMSnapshot{ceilingMs: hlcCeilingFromHLC(hlc)}, nil

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 Keep snapshots sourced from TSO-applied leases

When group 0 is wired during the documented bridge, this FSM is likely to share the process HLC that shard FSMs also advance; then Snapshot can serialize a ceiling raised by some data-group lease even if the TSO group never committed that lease. If a shard renewal succeeds while the group-0 proposal fails or lags, installing/restoring the group-0 snapshot advances dedicated TSO state beyond its own consensus log. Keep a TSO-FSM-owned ceiling field updated only from this FSM's Apply/Restore path and snapshot that value instead.

Useful? React with 👍 / 👎.

@bootjp

bootjp commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

@codex review

Updated commit: 44e6c18

Addressed current-head review findings:

  • TSOStateMachine now keeps TSO-owned ceiling and allocation-floor state, and snapshots those fields instead of reading the shared HLC mirror. This prevents data-group lease renewals from contaminating group-0 snapshots.
  • HLC lease entries now advance only the physical ceiling; allocation-floor advancement is an explicit TSO FSM entry and is persisted through the FSM snapshot format.
  • Non-positive HLC lease ceilings and malformed allocation-floor entries now halt apply through ErrTSOStateMachineInvalidEntry.
  • The design doc now reflects the ceiling/floor snapshot contract and the remaining runtime group-0 wiring work.

Caller/semantic audit:

  • NewTSOStateMachine is still referenced only by this branch's TSO FSM tests; runtime group-0 wiring remains documented as a follow-up.
  • kvFSM HLC lease behavior is unchanged; the new allocation-floor opcode is TSO-FSM-local and intentionally uses a byte that data-group kvFSM already routes through the fail-closed reserved range.

Validation:

  • go test ./kv -run 'TestTSOStateMachine|TestLocalTSOAllocator|TestBatchAllocator|TestShardedCoordinator.*Timestamp|TestCoordinateUsesTSOAllocator' -count=1 -timeout=180s
  • go test ./kv -count=1 -timeout=300s
  • golangci-lint --config=.golangci.yaml run ./kv --timeout=5m
  • git diff --check
  • git verify-commit HEAD

Additional check:

  • go test ./... -count=1 -timeout=600s was attempted; all completed packages reported ok, but adapter timed out at 600s in long-running gRPC/SQS adapter tests. This is outside the changed kv/doc surface.

@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: 44e6c1852b

ℹ️ 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_fsm.go Outdated
Comment on lines +104 to +105
case tsoSnapshotV1Len:
ceilingMs = int64(binary.BigEndian.Uint64(payload[:hlcLeasePayloadLen])) //nolint:gosec // legacy snapshot value.

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 Derive the floor when restoring legacy TSO snapshots

When this restores an 8-byte snapshot emitted by the previous TSO FSM format, allocationFloor remains 0. Those legacy snapshots were produced by code that also observed tsoLeaseAllocationFloor(ceilingMs) on apply/restore to prevent a restarted or newly elected TSO leader from handing out logical slots from the old ceiling millisecond. After upgrading from such a snapshot, the HLC mirror has only the ceiling, so the next NextBatchFenced can start at ceiling<<16|0 and overlap timestamps already covered by the prior floor; derive the legacy floor from the decoded ceiling before calling restoreSnapshotState.

Useful? React with 👍 / 👎.

Comment thread kv/tso_fsm.go Outdated
Comment on lines +150 to +151
f.ceilingMs.Store(ceilingMs)
f.allocationFloor.Store(allocationFloor)

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 Keep TSO-owned fields monotonic during restore

When Restore is called on an FSM that has already applied a higher ceiling/floor (the monotonic-restore test exercises this shape), these unconditional stores regress the FSM-owned state even though the HLC mirror remains high via its monotonic setters. Because Snapshot() now serializes f.ceilingMs and f.allocationFloor rather than the HLC, the next snapshot from that FSM can persist the lower values and lose the higher applied TSO floor/ceiling; restore should max these fields the same way apply does.

Useful? React with 👍 / 👎.

@bootjp

bootjp commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

@codex review

Updated commit: 5b6fd30

Addressed current-head P2 findings:

  • Restoring a legacy 8-byte TSO FSM snapshot now derives the allocation floor from the restored ceiling, preserving the previous format's floor behavior across upgrade/restart.
  • Restore now updates TSO-owned ceiling/floor fields monotonically instead of unconditionally storing lower snapshot values; the HLC mirror and subsequent snapshots stay at the highest applied TSO state.
  • The design doc Restore example now reflects the 8-byte legacy and 16-byte current snapshot handling.

Caller/semantic audit:

  • NewTSOStateMachine remains referenced only by this branch's TSO FSM tests; runtime group-0 wiring remains a documented follow-up.
  • The restore semantic change is limited to TSOStateMachine and matches the monotonic apply path.

Validation:

  • go test ./kv -run 'TestTSOStateMachine|TestLocalTSOAllocator|TestBatchAllocator|TestShardedCoordinator.*Timestamp|TestCoordinateUsesTSOAllocator' -count=1 -timeout=180s
  • go test ./kv -count=1 -timeout=300s
  • golangci-lint --config=.golangci.yaml run ./kv --timeout=5m
  • git diff --check
  • git verify-commit HEAD

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: 5b6fd30121

ℹ️ 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".

Base automatically changed from design/dedicated-tso-group to main July 19, 2026 06:07

@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: 46be7a832f

ℹ️ 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 main.go Outdated
Comment on lines +1195 to +1196
sg := &kv.ShardGroup{Engine: runtime.engine}
sg.Txn = kv.NewLeaderProxyForShardGroup(sg, kv.WithProposalObserver(proposalObserver))

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 Don't count the TSO group in raft-envelope cutover scope

When group 0 is configured alongside a single data group, this returned ShardGroup is added to encWiring.attachRaftEnvelopeGroup in buildShardGroups. I checked adapter/encryption_admin.go's EnableRaftEnvelope path and main_raft_envelope_wiring.go's ValidateCutoverScope: the cutover precheck rejects unless exactly one group is attached, so enabling the dedicated TSO group turns a previously valid single-data-group raft-envelope rollout into FailedPrecondition (got 2) even though group 0 holds no user data. Exclude group 0 from that scope check or handle its cutover separately.

Useful? React with 👍 / 👎.

Comment thread kv/tso_fsm.go Outdated
Comment on lines +181 to +183
legacy, err := hasLegacyKVFSMSnapshotHeader(br)
if err != nil || !legacy {
return legacy, err

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 Preserve headerless legacy group-0 snapshots

When upgrading a reserved group-0 log that previously ran kvFSM from a version that emitted the supported headerless snapshot format, this probe returns legacy=false, so Restore falls through to readTSOSnapshotState and accepts only exactly 8 or 16 bytes. ReadSnapshotHeader deliberately leaves non-magic/headerless snapshots unconsumed for the inner store restore path, and those raw store payloads are not constrained to the TSO lengths, so affected nodes can fail to restore or misparse a 16-byte store payload instead of draining the legacy MVCC snapshot. Detect and drain headerless kvFSM snapshots before trying the raw TSO format.

Useful? React with 👍 / 👎.

@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: e701cb221b

ℹ️ 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 main_encryption_admin.go Outdated
Comment thread kv/tso_fsm.go Outdated
@bootjp
bootjp force-pushed the design/dedicated-tso-fsm branch from e701cb2 to 97a42ba Compare July 19, 2026 12:17
@bootjp

bootjp commented Jul 19, 2026

Copy link
Copy Markdown
Owner Author

Evidence for current HEAD 97a42ba72c18205fcb84a43ad4ca0cedc48efa31:

  • Dedicated TSO encryption-admin wiring keeps mutators disabled but retains LeaderView; ResyncSidecar now rejects group-0 followers.
  • Allocation-floor proposals use a versioned TSO envelope. Bare/future encryption-reserved entries cannot be decoded as TSO state and halt fail-closed.
  • Semantic audit: the sole production encryptionAdminWiringForGroup caller was checked; data-group mutator behavior is unchanged. Allocation-floor encoding remains TSO-FSM-local in this PR and all apply/classifier/test consumers use the same envelope.
  • TLA audit: make tla-check matched all safe and expected-gap model outcomes.
  • The design remains Partial until the full dependency stack is clear.

History sanitation:

  • Rebuilt on current origin/main 915bc77795d940644e09ce3ee521626e64e2f442.
  • PR-visible history is one commit, authored and committed by bootjp <contact@bootjp.me>.
  • GitHub signature verification is verified: true.
  • Desired tree hash before and after rebuild: 5d4fd0dccfb27bf859311df68cdf9a119fcb33bc.

Validation:

  • go test ./kv . -count=1
  • go test -race ./kv . -run "TestTSOStateMachine|TestEncryptionAdmin_(DedicatedTSOGroup|DataGroup)|TestRegisterEncryptionAdminServer" -count=1
  • golangci-lint run ./... --timeout=5m --allow-parallel-runners (0 issues)
  • make tla-check
  • git diff --check origin/main..HEAD

@codex review

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
kv/tso_fsm.go (1)

94-94: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

//nolint:gosec の抑制は境界チェックへの置き換えを検討してください。

コーディングガイドラインでは //nolint の追加を避け、リファクタリングを優先することが求められています。タイムスタンプの uint64int64 変換については、変換前に math.MaxInt64 との境界チェックを行う小さなヘルパー(例: func unixMillisToInt64(v uint64) (int64, error))を導入することで、複数箇所の //nolint:gosec を排除できます。既存コードで許容される慣例であれば据え置きで構いませんが、新規追加分については抑制の集約を推奨します。

As per coding guidelines: "Avoid adding //nolint unless absolutely required; prefer refactoring."

Also applies to: 165-167, 197-197, 325-325

🤖 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 `@kv/tso_fsm.go` at line 94, Replace the new `//nolint:gosec` suppressions
around the `ceilingMs` conversion and the corresponding conversions at the other
referenced sites with a shared checked conversion helper, such as
`unixMillisToInt64`. Have the helper validate against `math.MaxInt64` before
converting and return an error for overflow, then propagate or handle that error
at each caller while preserving existing timestamp behavior.

Source: Coding guidelines

🤖 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.

Nitpick comments:
In `@kv/tso_fsm.go`:
- Line 94: Replace the new `//nolint:gosec` suppressions around the `ceilingMs`
conversion and the corresponding conversions at the other referenced sites with
a shared checked conversion helper, such as `unixMillisToInt64`. Have the helper
validate against `math.MaxInt64` before converting and return an error for
overflow, then propagate or handle that error at each caller while preserving
existing timestamp behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 378b480f-a7cd-42e8-a4ec-e03b8e9fa899

📥 Commits

Reviewing files that changed from the base of the PR and between 915bc77 and 97a42ba.

📒 Files selected for processing (7)
  • docs/design/2026_04_16_partial_centralized_tso.md
  • kv/tso_fsm.go
  • kv/tso_fsm_test.go
  • main.go
  • main_encryption_admin.go
  • main_encryption_admin_test.go
  • multiraft_runtime_test.go

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 97a42ba72c

ℹ️ 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".

bootjp added 2 commits July 19, 2026 22:58
## Summary
- route dedicated group-0 timestamp requests to the current TSO leader
- commit every returned window end and fence each leader term above
authoritative data-group commit floors
- add synchronous fail-closed shadow migration and a durable one-way
cutover marker
- preserve rolling compatibility by rejecting legacy timestamp responses
without durable reservation metadata
- update the centralized TSO design status through M6

## Migration safety
- shadow candidates are serialized through group 0 before the legacy
value is returned
- overlapping legacy candidates are discarded and retried
- cutover commits the marker before the first production window
- group-0, shadow, and cutover failures stop timestamp issuance instead
of falling back

## Validation
- `go test ./kv -count=1 -timeout=10m`
- `go test . -count=1 -timeout=10m`
- `go test ./adapter -run
'Test(DistributionServerGetTimestamp|GRPCServer_RawLatestCommitTS_EmptyKeyReturnsGlobalWatermark)'
-count=1 -timeout=5m`
- `go test ./... -run '^$' -count=1 -timeout=10m`
- `go test -race ./kv -run
'Test(RaftTSOAllocator|LeaderRoutedTSOAllocator|ShadowTimestampAllocator|ShardStoreGlobalCommittedTimestampFloor)'
-count=1 -timeout=10m`
- `golangci-lint --config=.golangci.yaml run ./kv ./adapter .
--timeout=5m`
- `make gen
BREAKING_AGAINST='../.git#subdir=proto,branch=design/dedicated-tso-runtime'`

## Stack
Base: #1103

Author: bootjp

@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: aa94f4eedb

ℹ️ 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_raft.go Outdated
Comment on lines +131 to +132
if min == ^uint64(0) {
return empty, errors.WithStack(ErrTxnCommitTSRequired)

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 Reject near-overflow TSO minimums

When ReserveBatchAfter is called with n > 1 and a minimum close to MaxUint64 (for example Distribution.GetTimestamp{count:2, min_timestamp:MaxUint64-1}), this guard lets the request through; a.clock.Observe(min) then causes NextBatchFenced to wrap the reserved base to 0 and the HLC current value to 1 before the later response validation can fail, and commitAllocationFloor can persist that regressed floor. Reject any min that cannot fit the requested window before observing it.

Useful? React with 👍 / 👎.

Comment thread main.go Outdated
cfg.engine,
distCatalog,
adapter.WithDistributionCoordinator(coordinate),
adapter.WithDistributionTimestampAllocator(tsoWiring.serverAllocator),

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 Gate mutating timestamp RPCs during startup rotation

When a client can reach the raft gRPC listener during startup rotation, this wiring makes Distribution.GetTimestamp call the dedicated allocator and commit group-0 allocation/cutover entries, but startupRotationGatedMethod only gates Distribution.SplitRange plus the RawKV/Transactional/Internal/Admin mutators. That lets timestamp proposals bypass the same startup mutator fence before waitRotateOnStartup completes and before public traffic is marked ready; include GetTimestamp in the gate or delay exposing the allocator until the gate opens.

Useful? React with 👍 / 👎.

Comment thread adapter/distribution_server.go Outdated
return &pb.GetTimestampResponse{Timestamp: s.engine.NextTimestamp()}, nil
}

reservation, err := s.allocateTimestampReservation(ctx, count, minTimestamp, activateCutover)

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 Gate TSO cutover activation

When group 0 is configured for Phase A/B but this node was not started with --tsoEnabled, any caller that can reach Distribution.GetTimestamp can set activate_cutover=true; this line forwards that untrusted request bit into the allocator, which commits the one-way cutover marker. Since main wires the server allocator even before production cutover, this bypasses the operator flag and the all-nodes-shadow rollout precondition, so keep activation tied to local/internal configuration rather than the public RPC field.

Useful? React with 👍 / 👎.

Comment thread kv/shard_store.go Outdated
return 0, errors.Wrapf(ErrTSOCommitFloorUnavailable,
"data group %d is not led by this node", groupID)
}
if _, err := linearizableReadEngineCtx(nonNilTSOContext(ctx), engine); err != nil {

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 Bound local floor ReadIndex calls

When the TSO leader also leads a data group and the incoming timestamp request has no deadline, this local LinearizableRead inherits an unbounded context, unlike verifyLeaderEngineCtx and the remote floor RPC timeout. If that ReadIndex stalls, ReserveBatchAfter is still holding the allocator mutex while initializing the term floor, so one stuck local floor probe can wedge all subsequent TSO allocations; wrap this path in a bounded context.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot mentioned this pull request Jul 23, 2026

@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: 721ca145c0

ℹ️ 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_raft.go
Comment on lines +440 to +447
if timestamp == 0 || timestamp > end {
return errors.Wrapf(ErrTSOTimestampInvalid,
"timestamp=%d phase_d_floor=%d allocation_floor=%d", timestamp, floor, end)
}
if timestamp <= floor {
return errors.Wrapf(stderrors.Join(ErrTSOTimestampInvalid, ErrTSOTimestampPrePhaseD),
"timestamp=%d phase_d_floor=%d allocation_floor=%d", timestamp, floor, end)
}

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 Do not accept every slot in a reserved batch

When tsoBatchSize > 1, BatchAllocator.refill commits the entire window end as AllocationFloor before its local callers claim all offsets, but this check accepts any number in that interval. An unauthenticated Internal.Forward caller can therefore persist using an as-yet-unclaimed slot; when the owning BatchAllocator later reaches that offset, it returns the same timestamp to an unrelated write, violating global timestamp uniqueness and potentially invalidating OCC ordering. Validation needs proof that the caller actually claimed the slot, rather than only checking the enclosing reserved range.

AGENTS.md reference: AGENTS.md:L35-L35

Useful? React with 👍 / 👎.

@bootjp

bootjp commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

kv/tso_raft.go:447 (P1, "Do not accept every slot in a reserved batch") — verified, not fixed, because every fix I can see either changes the TSO wire protocol or trades throughput, and both are yours to choose. Here is what I confirmed.

The mechanism is exactly as described. BatchAllocator.refill calls NextBatch(ctx, batchSize), which commits the window end as AllocationFloor (commitAllocationFloor), and only then does the local allocator hand out offsets one at a time. ValidateDurableTimestamp accepts any timestamp <= AllocationFloor, so every unclaimed slot in the current window is accepted. When the owning allocator later reaches that offset it issues the same value to an unrelated write — two writes at one timestamp, which is precisely the uniqueness Phase D exists to enforce.

Two things sharpen the severity beyond the report:

  • It is on by default. defaultTSOBatchSize = 256 (main.go:58), so the accepted-but-unclaimed window is 255 slots wide out of the box, not a corner case behind a non-default flag.
  • The validator cannot fix this by itself. The TSO state machine stores a single scalar AllocationFloor — the highest window end applied. There is no record of which slots inside that window any node has claimed, and the claiming happens on whichever node holds the batch, not on the group-0 leader doing the validation. So no change confined to ValidateDurableTimestamp can tell a claimed slot from an unclaimed one.

That leaves three directions, and they are not equivalent:

  1. Require batchSize == 1 while Phase D is active. Then the window end is the claimed watermark and the existing check becomes exact. Closes it today with no protocol change, at the cost of a Raft round trip per persistence timestamp — which is the throughput property batching was added for.
  2. Make claims durable. Record the claimed offset (per node, or as a monotone claimed-watermark entry) so validation has something to check against. This is a TSO wire/state change, so per CLAUDE.md it wants a *_proposed_* design doc first.
  3. Narrow acceptance to fully-claimed windows — reject anything above the previous window's end. This is wrong: it rejects legitimate writes using the current window.

I did not pick one. Option 1 is a one-line guard I can land immediately if you want the hole closed now and will accept the throughput cost; option 2 is the design-doc route and I can write the proposal. Tell me which and I will do it in the next pass.

One correction to the report's framing, which does not change the conclusion: Internal.Forward is not covered by adminTokenProtectedMethod (only ForwardAdminProposal and ForwardLeaseRead are), so "unauthenticated" is accurate for that method on the internal listener — but the internal listener is the peer port, so the attacker in the model is a peer or something that reached it, not an arbitrary client.

@codex review
@claude review

@claude

claude Bot commented Aug 29, 2026

Copy link
Copy Markdown

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


Review of kv/tso_raft.go:447 — batch-slot claim gap

  • Gather context (current HEAD 721ca14, kv/tso_raft.go, BatchAllocator, ValidateDurableTimestamp)
  • Verify the claimed mechanism (window-end commit vs. per-slot claim)
  • Verify severity claims (defaultTSOBatchSize=256, Internal.Forward auth exposure)
  • Assess the three proposed options
  • Post final review response

Mechanism independently verified — the report is correct.

  • RaftTSOAllocator.ValidateDurableTimestamp (kv/tso_raft.go:428-449) accepts any timestamp in (PhaseDFloor, AllocationFloor]. AllocationFloor is a single scalar — the highest committed window end (kv/tso_fsm.go) — with no record of which individual offsets inside that window have actually been handed to a caller.
  • BatchAllocator.refill (kv/tso.go:906-947) calls nextRefillBatchNextBatch(ctx, batchSize), which durably commits end via commitAllocationFloor (kv/tso_raft.go:389-398) before b.win.Store(...) runs and before any offset in [base, end] is claimed. tryWindowAfter (kv/tso.go:882-904) then hands out offsets one at a time via w.offset.Add(1), entirely locally, with no further communication back to group 0.
  • So the instant a window commits, every value in it — used or not — passes ValidateDurableTimestamp on the group-0 leader. Nothing distinguishes "claimed by a real write" from "reserved but idle."

Severity claims verified:

  • defaultTSOBatchSize = 256 (main.go:58), and TSORuntimeController.installMode (kv/tso_runtime.go:330-352) uses the same c.batch (built with cfg.BatchSize) for both TSOModeCutover and TSOModePhaseD — confirmed via kv/tso_runtime.go:251 (NewBatchAllocator(cfg.Routed, cfg.BatchSize)). So the 255-wide unclaimed gap is live in Phase D under default configuration, not an opt-in edge case.
  • adminTokenProtectedMethod (adapter/admin_grpc.go:513-517) matches only the /Admin/ prefix. Internal.Forward isn't in that gate — confirmed by grep, no other interceptor wraps it. The correction in the trigger comment is accurate: this is a peer-port exposure (whatever reaches the internal listener), not a public-client-only concern, but it's still unauthenticated at the gRPC layer.

Assessment of the three directions:

  1. batchSize == 1 during Phase D — I'd characterize this as a strong mitigation, not a full structural fix. It removes the multi-slot dangling-reservation problem (each Next() call commits exactly the one value it's about to hand out), but there's still a race window between commitAllocationFloor returning and the caller actually stamping a write with that value — just narrowed from "as long as the window survives" (unbounded under low traffic) to "one Raft round trip." Worth stating that distinction explicitly rather than calling it fully closed.
  2. Durable claim tracking — the only way to make ValidateDurableTimestamp actually assert "this value was issued to a specific write," since group 0 has no visibility into what any node's local BatchAllocator has claimed. Correctly flagged as a TSO-FSM/durable-state change requiring a *_proposed_* design doc first per CLAUDE.md.
  3. Reject above previous window's end — correctly identified as wrong; that would reject legitimate in-flight writes using the current (already-committed) window, since writes proceed on locally-claimed offsets immediately without a round trip back to group 0.

My recommendation: land (1) now as a scoped, testable stopgap — it's a BatchAllocator/runtime-wiring change (window sizing), not a TSO FSM or wire-format change, so it doesn't itself require design-doc-first, consistent with how the other defensive validation fixes earlier in this thread (e.g. "reject minimum beyond ceiling") were landed directly with a regression test per the CLAUDE.md review-defect convention. Follow with a *_proposed_*.md for (2), since (1) alone leaves the narrowed single-RPC race intact and the design doc should say so explicitly (e.g. as a new §10 Known Limitations entry, matching the pattern already used for the Phase-D wall-clock gap). I'd implement (1) as: in BatchAllocator.nextRefillBatch (kv/tso.go:949-956), request a window of size 1 instead of b.batchSize when b.PhaseDActive() is true, since PhaseDActive/ensurePhaseDTransition are already consulted per-call in this path.

This is a throughput/safety trade-off call, so I've verified the analysis and given a recommendation rather than pushing code — say the word and I'll implement (1) with a regression test (asserting AllocationFloor never outpaces the caller's own claimed offset while Phase D is active) in the next pass.
· Branch: design/dedicated-tso-fsm

@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: 721ca145c0

ℹ️ 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 adapter/redis_txn.go Outdated
return 0, nil, err
return kv.ReadTimestamp{}, nil, err
}
readTimestamp, err := kv.BeginReadTimestampThrough(ctx, r.coordinator, selectTS(), 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.

P2 Badge Normalize empty Redis snapshots before Phase-D validation

When Phase D is required on a Redis store with no committed records, r.readTS() returns the ^uint64(0) empty-store sentinel, which BeginReadTimestampThrough rejects as invalid. LRANGE reaches this helper through fenceRangeListReadGroups, so reading a nonexistent list on a fresh Phase-D cluster returns an error instead of an empty array and cannot activate Phase D. Normalize the empty watermark to a valid applied timestamp such as 1, as txnStartTS already does, before invoking the Phase-D boundary.

Useful? React with 👍 / 👎.

Comment thread kv/tso_fsm.go Outdated
phaseDActive = s.phaseDActive
phaseDFloor = s.phaseDFloor
}
snapshotLen := tsoSnapshotV3Len

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 Keep legacy group-0 followers able to restore snapshots

During the rolling binary rollout while every mode file is still legacy, an upgraded node constructs TSOStateMachine immediately and this unconditional V3 choice emits a 17-byte snapshot beginning with the encoded ceiling rather than a legacy kvFSM snapshot header. If that node leads group 0 and an older follower needs a snapshot after log compaction, the old kvFSM treats these bytes as a headerless store snapshot and pebbleStore.Restore rejects the unknown magic, so the follower cannot catch up and the rollout can lose quorum. Preserve an old-reader-compatible snapshot until the compatibility window closes, and cover a new-leader-to-old-follower snapshot install.

AGENTS.md reference: AGENTS.md:L24-L24

Useful? React with 👍 / 👎.

bootjp added 3 commits August 29, 2026 20:38
main's TSOStateMachine.Restore reads exactly 8 bytes and rejects anything
longer as trailing bytes, while this branch emitted 17 unconditionally. During
a rolling upgrade a not-yet-upgraded group-0 follower therefore rejects the
leader's snapshot and cannot catch up, which on a three-node group 0 risks
quorum. (The reported mechanism -- kvFSM treating the bytes as a store snapshot
and pebbleStore.Restore rejecting the magic -- is not this path: main already
runs TSOStateMachine on group 0, and the dedicated TSO group opens no MVCC
store.)

The reader already accepts all four lengths, so only the writer changes. V1
carries a floor implicitly: its reader reconstructs
tsoLeaseAllocationFloor(ceiling), so a floor already equal to that value
round-trips exactly -- which is the state every node holds after restoring a
pre-allocation-floor snapshot, and without that case a node that caught up from
an old leader would immediately become unreadable to its remaining old peers. A
zero floor also fits: the substitute is only ever higher, and it widens a bound
that Phase D never consults, since a real floor and the phase-D marker each
need their own committed envelope. Any other floor is real allocator state a
substitute could raise, so it takes V2.

Five existing tests pinned the old fixed length. Their intent is kept: the
zero-state payload is still all zeros, the monotonic-ceiling test trades a
byte-level floor assertion for a real round trip through Restore, and the
TSO-owned-ceiling test now asserts the restored floor is the one derived from
the TSO ceiling and below what the unrelated HLC value would give.

adapter: normalize the empty-store watermark before Phase-D validation

snapshotTS answers ^uint64(0) for a store with no committed record, and
BeginReadTimestampThrough rejects both 0 and that sentinel once Phase D is
required, so LRANGE on a nonexistent list on a fresh Phase-D cluster returned
an error instead of an empty array. Normalize at the single point every fenced
read passes through, to the same floor of 1 txnStartTS already uses.

docs: propose TSO batch slot claims

ValidateDurableTimestamp accepts any value in (PhaseDFloor, AllocationFloor],
and AllocationFloor is the committed window end -- recorded before any offset
is handed out -- so with the default batch size of 256 a 255-wide band of
timestamps validates while belonging to nobody. Both candidate fixes carry a
decision (a Raft round trip per issued timestamp, or a new durable claim
record), so this lands as a proposal rather than a patch.

Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
Resolutions:
- adapter/internal.go, kv/sharded_coordinator.go, kv/shard_store_test.go:
  both sides added independent members in the same region; unioned.
- main.go: main's live-backup flags, validation, and readTracker/backup-floor
  wiring are additive, so they merge alongside this branch's TSO wiring. The
  group-build body conflict is git mis-anchoring main's unchanged inline loop
  against this branch's shardGroupBuilder refactor -- main changed nothing
  there, so the refactor stands. internalOptionsForGroup folds into main's
  internalServerOptions, which now also takes the group id and keeps the
  group-0 KV-forward rejection.
- main added SnapshotEvery to raftengine.Engine and a readTracker parameter to
  buildShardGroups; the branch's test stubs and call sites are updated to match.

Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
main threads readTracker into every data-group FSM via
NewKvFSMWithHLCAndTracker; the merge kept this branch's shardGroupBuilder
refactor, which still called NewKvFSMWithHLC and silently dropped it. Lint
caught the now-unused parameter.

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

bootjp commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

Two of the three at-HEAD findings are fixed; the third has a design doc instead of a patch, deliberately.


kv/tso_fsm.go:668 — "Keep legacy group-0 followers able to restore snapshots" (P1)

Consequence confirmed. Mechanism as described is wrong, and the correct one matters for the fix.

The report says the upgraded node's bytes reach "the old kvFSM", which treats them "as a headerless store snapshot", and pebbleStore.Restore rejects the unknown magic. That is not this path. origin/main already runs TSOStateMachine on group 0 — kv/tso_fsm.go exists there with tsoSnapshotLen = 8 — and buildDedicatedTSOGroup explicitly opens no MVCC store, so no pebble store is involved on either side.

What actually happens is narrower and still a P1: main's TSOStateMachine.Restore reads exactly 8 bytes and returns "tso fsm: restore snapshot: trailing bytes" for anything longer. This branch's WriteTo emitted 17 (V3) unconditionally. So during the rolling window a not-yet-upgraded follower rejects the leader's snapshot and cannot catch up — on a three-node group 0 that is the quorum risk the report describes.

Fix. Same shape as the snapshot-version work on #1088: emit the shortest layout that can carry the state. The reader already accepts all four lengths, so only the writer changed.

The interesting part is which floors V1 can carry. A V1 payload has no floor field and its reader reconstructs tsoLeaseAllocationFloor(ceiling), so:

  • floor already equals that value — V1 round-trips it exactly. This case is not hypothetical: it is what every node holds after restoring a pre-allocation-floor snapshot, and without it a node that caught up from an old leader would immediately become unreadable to its remaining old peers. I only found it because five existing tests failed on my first attempt.
  • floor is zero — the reader substitutes a higher value. That widens the upper bound ValidateDurableTimestamp accepts, but a zero floor means no allocation-floor envelope has committed, and the phase-D marker that same window requires has not either, so validation refuses everything with ErrTSOPhaseDInactive until it does.
  • any other floor — real allocator state a substitute could raise, which under Phase D would accept timestamps never issued. Needs V2.

applyLeaseCeiling never touches the allocation floor, so the legacy window sits squarely in the first two cases.

Five existing tests changed, each pinning the old fixed 17-byte length. Their intent is preserved: SnapshotWithNilHLCWritesZeroState still asserts an all-zero payload; RestoreKeepsMonotonicCeiling loses its byte-level floor assertion and gains a real round-trip through Restore instead; SnapshotUsesTSOOwnedCeiling's require.Zero(targetHLC.Current()) becomes require.Equal(tsoLeaseAllocationFloor(tsoCeiling), ...) plus an assertion that it is below the floor the unrelated ceiling would give — which tests the "TSO-owned, not HLC-owned" property more precisely than zero did.

New tests (kv/tso_fsm_snapshot_compat_test.go): a table over all four layouts, each round-tripped through the real Restore; plus a reader modelled on main's exact 8-bytes-then-reject behaviour, asserting the legacy-window and lease-derived payloads restore on it and a real floor does not. Revert-checked.

adapter/redis_txn.go:641 — "Normalize empty Redis snapshots before Phase-D validation" (P2)

Confirmed exactly as described. snapshotTS (adapter/ts.go:22) answers ^uint64(0) when LastCommitTS() is 0, and kv/tso.go:468 rejects both 0 and ^uint64(0) once Phase D is required. fenceRangeListReadGroups passes r.readTS straight through, so LRANGE on a nonexistent list on a fresh Phase-D cluster errors.

Normalized at redisReadFencedTimestampForTargets, which is the single point every fenced read passes through — the report's suggested value of 1, matching what txnStartTS already does for the same reason. Below the Phase-D floor it takes the existing applied-read voucher path (ErrTSOTimestampPrePhaseD), which is the correct handling for reading an empty store, not an error.

Sweep. Two selectTS sources reach this helper: r.readTS (the broken one) and r.txnStartTS (already normalized, unaffected by the change since it never returns 0 or the sentinel).

Test (adapter/redis_fenced_select_ts_test.go): drives redisReadFencedTimestampForTargets against a Phase-D coordinator with an empty store, with the sentinel produced by the real snapshotTS. Revert-checked through that production path.

kv/tso_raft.go:447 — "Do not accept every slot in a reserved batch" (P1)

Verified, including the severity claims. ValidateDurableTimestamp accepts any value in (PhaseDFloor, AllocationFloor]; AllocationFloor is the committed window end, recorded before tryWindowAfter hands out any offset. defaultTSOBatchSize = 256 (main.go:58) and installMode uses the same batch for Phase D as for cutover, so the 255-wide unclaimed band is live by default. adminTokenProtectedMethod (adapter/admin_grpc.go:515) matches only /Admin/, so Internal.Forward is outside the admin token.

No patch here on purpose. The two candidate fixes are not equivalent:

  • Forcing batchSize == 1 under Phase D removes the multi-slot band but leaves the gap between commitAllocationFloor returning and the caller stamping its write, and it costs a group-0 Raft round trip per issued timestamp. That is precisely the per-Next() consensus CLAUDE.md says to avoid, so it is an operator-visible throughput tradeoff, not an implementation detail.
  • A durable per-slot claim closes it structurally but needs a wire/retention decision (what a claim is, per-timestamp vs. per-owner watermark, what happens across leadership change).

Per the design-doc-first workflow, docs/design/2026_08_29_proposed_tso_batch_slot_claims.md lays both out with the verification above and names the open questions. This one needs your call before either lands — I did not want to spend a throughput regression on your behalf.

Verification: go test -race ./kv/ passes (22s); ./adapter/ still running, will report. golangci-lint ./kv/... 0 issues.


Merge with main

The branch had gone CONFLICTING; merged in four files.

  • adapter/internal.go, kv/sharded_coordinator.go, kv/shard_store_test.go — both sides added independent members in the same region; unioned.
  • main.go — main's live-backup flags, validation, and readTracker/backup-floor wiring are additive and merge alongside this branch's TSO wiring. The largest conflict there is git mis-anchoring main's unchanged group-build loop against this branch's shardGroupBuilder refactor; verified against the merge base that main changed nothing in that body, so the refactor stands. internalOptionsForGroup folds into main's internalServerOptions, which now also takes the group id and keeps the group-0 KV-forward rejection.
  • main added SnapshotEvery to raftengine.Engine and a readTracker parameter to buildShardGroups; four test stubs and call sites updated to match.

One real defect came out of that merge, caught by lint rather than by me: keeping the shardGroupBuilder refactor meant data-group FSMs were still built with NewKvFSMWithHLC, silently dropping the readTracker main threads through NewKvFSMWithHLCAndTracker. Fixed in aabf40ab; without it every backup pin would have been invisible to the data-group FSMs.

Post-merge verification: go test -race ./kv/ ./adapter/ ./internal/backup/ . all pass (kv 27s, adapter 813s, backup 9s, root 120s), re-run for root+kv after the tracker fix. golangci-lint ./kv/... ./adapter/... . 0 issues.

@codex review
@claude review

@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_implemented_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 (aabf40a):

  • distribution/catalog.go
  • distribution/catalog_test.go
  • kv/coordinator.go
  • kv/sharded_coordinator.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.

@claude

claude Bot commented Aug 29, 2026

Copy link
Copy Markdown

Claude finished @bootjp's task in 6m 5s —— View job


Review of HEAD aabf40a

  • Gather context (trigger comment, current HEAD, prior review history)
  • Verify fix 1: kv/tso_fsm.go snapshot version compatibility (shortest-layout writer)
  • Verify fix 2: adapter/redis_txn.go empty-snapshot Phase-D normalization
  • Review design doc for kv/tso_raft.go batch-slot-claim gap (deliberately unpatched)
  • Verify merge-with-main resolution and the aabf40a readTracker fix
  • Post final review

kv/tso_fsm.go (c139b03) — mostly correct, one residual gap found

Verified the core mechanism claim against origin/main: main's TSOStateMachine.Restore (tsoSnapshotLen = 8) reads exactly 8 bytes and errors on any trailing byte, confirming the commit's correction of the earlier codex report (no pebble store / kvFSM magic involved — buildDedicatedTSOGroup opens no MVCC store on either branch). tsoSnapshotLenFor/tsoAllocationFloorIsLeaseDerived are bounds-safe: I traced WriteTo for every combination the switch can select and confirmed no out-of-range write is possible, and confirmed the phaseDActive ⇒ cutoverActive invariant (enforced in applyPhaseDEntry, line 226) guarantees the V4 case never needs a V3-only cutoverActive byte write with an insufficient buffer.

Residual gap: tsoAllocationFloorIsLeaseDerived re-derives eligibility from the current ceilingMs, but applyLeaseCeiling (kv/tso_fsm.go:535-543) never touches allocationFloor. So the V1-safe state only holds at the instant right after a legacy restore — it doesn't persist once more plain lease-renewal entries land afterward:

  1. Node restores a legacy 8-byte snapshot (ceiling=1000) → allocationFloor set to tsoLeaseAllocationFloor(1000) via the legacySnapshot branch in readTSOSnapshotState.
  2. A further ordinary lease renewal commits (ceiling=1001, no allocation-floor envelope involved — this is normal, unthrottled background renewal, not gated on Phase-D reservation activity).
  3. allocationFloor is still tsoLeaseAllocationFloor(1000), which now != tsoLeaseAllocationFloor(1001)tsoAllocationFloorIsLeaseDerived returns false → the next Snapshot() from this node picks V2 (16 bytes).
  4. A not-yet-upgraded peer reading that 16-byte snapshot hits the exact "trailing bytes" rejection this commit set out to fix — just reappearing one lease tick after the restore, rather than being eliminated for the whole rolling-upgrade window.

This is realistic in a live 3-node quorum: a node that crashed/restarted mid-rollout and caught up via InstallSnapshot (picking up a real derived floor), then later becomes the snapshot provider for a third straggling node after further lease renewals, would emit the incompatible V2 layout. It requires no contrived setup — just the ordinary sequence of restart-then-continue-running that Raft membership recovery already does.

The five updated tests (TestTSOStateMachineRestoreKeepsMonotonicCeiling etc.) don't catch this because they all snapshot-immediately-after-restore or use marshalTSOAllocationFloor with a value that happens to equal the lease-derived one, rather than restoring, then applying one more plain lease entry, then re-snapshotting.

Fix direction: track whether a real allocation-floor envelope has ever been applied (a boolean, distinct from allocationFloor's numeric value) and gate V1 eligibility on that flag rather than re-deriving from the current ceiling — or keep allocationFloor continuously synced to tsoLeaseAllocationFloor(ceiling) inside applyLeaseCeiling for as long as no real envelope has committed, mirroring what restore already does once.

Fix this →

adapter/redis_txn.go (c139b03) — verified correct

Confirmed the mechanism: snapshotTS (adapter/ts.go) returns ^uint64(0) for an empty store, and BeginReadTimestampThrough (kv/tso.go:468) rejects both 0 and ^uint64(0) once Phase D is required — exactly as described. redisFencedSelectTS is the single choke point every fenced read passes through (redisReadFencedTimestampForTargets), and normalizing to 1 matches the existing txnStartTS convention. Checked that this doesn't change behavior for non-empty stores (real watermarks pass through unchanged, confirmed by the new test's seeded case) and that applying the same normalization unconditionally (even outside Phase D) is a no-op for legacy mode, since an empty-store read at 1 vs. the ^uint64(0) sentinel both yield "no results." Test (adapter/redis_fenced_select_ts_test.go) exercises both the pure helper and the real redisReadFencedTimestampForTargets production path with a newPhaseDVoucherCoordinator. No issues found.

docs/design/2026_08_29_proposed_tso_batch_slot_claims.md — appropriately deferred

Independently verified the claims: defaultTSOBatchSize = 256 (main.go:58), RaftTSOAllocator.ValidateDurableTimestamp (kv/tso_raft.go:428-448) is confirmed to be a pure range check (PhaseDFloor, AllocationFloor] with no per-slot claim tracking, TSORuntimeController.installMode (kv/tso_runtime.go) confirmed to route both TSOModeCutover and TSOModePhaseD through the same c.batch allocator/size, and adminTokenProtectedMethod (adapter/admin_grpc.go:763-767) confirmed to protect only /Admin/, ForwardAdminProposal, and ForwardLeaseRead — not the plain Internal.Forward RPC the exploit would use. The doc's tradeoff framing (batch-size-1 costs a Raft round trip per issued timestamp, which is exactly what CLAUDE.md says to avoid; a durable per-slot claim needs a wire/retention design) is sound, and correctly triggers the design-doc-first rule for a durable-state/timestamp-issuance change rather than picking a tradeoff unilaterally. No issues with deferring this.

Merge with main + aabf40a — verified correct

internalServerOptions (main.go:3582-3599) confirmed to still gate adapter.WithKVForwardRejected() on groupID == dedicatedTSORaftGroupID, preserving the group-0 KV-forward rejection through the merge. The aabf40a fix is correct and complete: readTracker is threaded from its construction (main.go:483) through buildShardGroupsshardGroupBuilder.readTrackerbuildDataGroup's kv.NewKvFSMWithHLCAndTracker call (confirmed this signature exists in kv/fsm.go:285 alongside the still-present tracker-less NewKvFSMWithHLC used only for the no-tracker constructor path). buildDedicatedTSOGroup (group 0, using NewTSOStateMachine) is correctly untouched by this — it was never supposed to receive the tracker. Good catch that this was lint-driven (unused parameter) rather than caught by a passing test suite; that class of silent-drop regression is exactly what CLAUDE.md's review passes exist to catch, and this one only surfaced via the linter rather than a targeted test.

Not independently run: go build/go vet/go test all require Bash approval not available in this sandbox (consistent with every prior review pass on this PR). The verification above is from full static tracing of the diffs, the byte-offset arithmetic in WriteTo/readTSOSnapshotState, and cross-referencing origin/main's actual tso_fsm.go to confirm the compatibility claim. The author's reported go test -race runs across kv, adapter, internal/backup, and root, plus golangci-lint at 0 issues, are consistent with everything checked here except the one gap above (which is a logic/architecture issue no test currently exercises, not something lint or a passing -race run would surface).

Summary

  • The Redis empty-snapshot normalization fix is correct and complete.
  • The TSO batch-slot-claim design doc is well-reasoned and appropriately deferred per the design-doc-first rule.
  • The merge resolution and readTracker wiring fix are correct.
  • The snapshot-compatibility fix closes the reported scenario but has a residual gap: tsoAllocationFloorIsLeaseDerived's current-ceiling-based re-derivation goes stale after any lease renewal following a legacy restore, which can reintroduce the same "not-yet-upgraded follower can't restore the snapshot" quorum risk one tick later. Recommend tracking a dedicated "real floor committed" flag instead of re-deriving eligibility from the live ceiling before merging, since this directly affects the rolling-upgrade safety property the commit was written to guarantee.
    · Branch: design/dedicated-tso-fsm

@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: aabf40abf6

ℹ️ 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 +369 to +371
startErr := ValidateDurablePersistenceTimestamp(ctx, alloc, startTS, label)
if startErr != nil && errors.Is(startErr, ErrTSOTimestampPrePhaseD) {
return nil

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 Require proof before exempting legacy resolutions

When Phase D is active, a direct Internal.Forward caller can first submit a PREPARE with an arbitrary start timestamp below the Phase-D floor (which ValidateForwardedTxnStartTimestamp explicitly admits), then submit a COMMIT with another pre-D timestamp; this branch accepts the pair solely because the request is labeled as a resolution. The newly created intent makes the subsequent commit valid to the FSM, allowing a write to be inserted retroactively at an old, non-TSO-issued timestamp and breaking repeatable MVCC snapshots. Fresh evidence in the current code is that neither validation step verifies that the commit timestamp was recorded by a primary before Phase-D activation; require durable primary-record evidence rather than trusting the phase flag.

AGENTS.md reference: AGENTS.md:L33-L35

Useful? React with 👍 / 👎.

Seven files conflicted. Resolutions:

- adapter/sqs_{messages,reaper,redrive}.go (5 sites): both sides edited the
  same two lines independently. Kept the branch's
  kv.DispatchWithReadTimestamp and main's narrower
  isIgnorableTransactRaceError, which deliberately stops swallowing route
  fences as "another worker already did it".
- adapter/grpc.go: two guard clauses added at the same spot. main's
  requireReadReady runs first so the group-watermark branch is gated by
  it too.
- adapter/redis_list_dedup_test.go: independent test additions, unioned.
- kv/sharded_coordinator.go: the branch moved ValidateElemCommitTSPatches
  and the write-floor check into settleTxnCommitTimestamp while main
  renamed the latter to ensureGroupedMutationsWriteAllowedWithBypass and
  gave it a bypass set. Kept the branch's placement with main's
  bypass-aware callee, threading bypassKeysByGroup into the helper.
- adapter/distribution_server.go: struct fields unioned. main's
  planSplitRange / nextCatalogSplitIDs refactor wins for SplitRange
  because it carries the shipped OCC read-key feature; the branch's
  kv.ReadTimestamp parameter is kept on saveSplitResultViaCoordinator
  because the dispatch needs its voucher, and readTS is derived from it —
  snapshot.ReadTS is exactly readTimestamp.Timestamp(), so no behavior
  changes.
The merge put main's requireReadReady gate in front of the branch's
group-watermark branch, which pushed the handler to cyclomatic
complexity 11. Move the three-way store-capability dispatch into
rawKeyCommitTS; behavior is unchanged.
@github-actions

github-actions Bot commented Sep 2, 2026

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_implemented_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 (97630ac):

  • distribution/catalog.go
  • distribution/catalog_test.go
  • kv/coordinator.go
  • kv/sharded_coordinator.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.

Records the kv/tso.go:371 finding: the exemption for a forwarded
resolution carrying pre-Phase-D timestamps takes both halves of its
proof from the request being validated, so a direct Internal.Forward
caller can PREPARE a fresh pre-D intent and then commit against it.

Lays out the two candidate remedies -- verifying the primary's durable
commit record, and time-bounding the PREPARE side by the Phase-D
activation instant plus the maximum lock TTL -- with the cross-shard and
ABORT cases that neither covers alone, and leaves the choice open for
review rather than guessing at a semantics change on the OCC path.
@github-actions

github-actions Bot commented Sep 2, 2026

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_implemented_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 (4d59e5b):

  • distribution/catalog.go
  • distribution/catalog_test.go
  • kv/coordinator.go
  • kv/sharded_coordinator.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.

@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: 4d59e5b6a0

ℹ️ 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_raft.go
if err == nil {
return false
}
if errors.Is(err, ErrTSONotLeader) || errors.Is(err, ErrTSOProtocolUnsupported) || isTransientLeaderError(err) {

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 Retry transient commit-floor failures on the local leader

When the caller is colocated with the group-0 leader and first-term initialization temporarily cannot fence a data-group leader, GlobalCommittedTimestampFloor returns ErrTSOCommitFloorUnavailable, but this classifier treats it as non-transient, so nextReservation exits immediately instead of using its retry budget and the local write fails. The equivalent remote request is converted by timestampRPCError to Unavailable and retried, making writes on the TSO-leader node uniquely susceptible during data-group elections or brief RPC failures; classify this sentinel as transient as well.

Useful? React with 👍 / 👎.

Comment on lines +227 to +229
if s.timestampAllocator == nil {
return s.legacyTimestampResponse(count, minTimestamp, activateCutover, activatePhaseD)
}

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 Preserve legacy GetTimestamp behavior during group-0 warm-up

When a dedicated group 0 is present but the configured mode remains legacy, main.go still installs a non-nil server allocator, so this branch sends an existing empty GetTimestampRequest through RaftTSOAllocator instead of the legacy distribution clock. Clients that previously called this public RPC on any node now receive ErrTSONotLeader from every node that is not the group-0 leader—and depend on group-0 quorum even though no TSO mode has been enabled—breaking the no-path-change warm-up stage. Keep legacy-format requests on legacyTimestampResponse until dedicated issuance is active while still allowing explicitly marked shadow/cutover reservations.

Useful? React with 👍 / 👎.

@bootjp

bootjp commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

3b609951 97630ac6 4d59e5b6 — conflicts resolved against origin/main, and the at-HEAD P1 is verified with a design doc rather than a patch.

kv/tso.go:371 — "Require proof before exempting legacy resolutions"

The core observation is correct. Both halves of the carve-out's proof come from the request being validated: resolution is a field of the incoming batch, and startTS is forwardedTxnStartTS(reqs) — also from the request. A direct Internal.Forward caller can PREPARE at any pre-Phase-D startTS (ValidateForwardedTxnStartTimestamp admits it by design), then COMMIT at another pre-D timestamp against the intent it just created. Nothing consults durable state to confirm a primary ever recorded that commit timestamp. I traced stampTxnTimestampsfillForwardedTxnCommitTScollectForwardedTxnMetas and confirmed each step.

Two things the finding gets wrong, which do not change the conclusion but do change the severity:

  1. It reads as though this PR opens the hole. It does not. kv/tso.go does not exist on origin/main — I checked. On main, Internal.Forward accepts any forwarded start and commit timestamp with no validation whatsoever. This PR closes the general case and leaves one narrow window; it is a regression only against the PR's own stated invariant, not against shipped behaviour.

  2. The invariant ValidateDurablePersistenceTimestamp exists for — "never persist at a timestamp group 0 has not issued yet" — is not violated here. Pre-D timestamps sit below the floor and group 0 only issues above it, so no collision with a future issuance is possible. The real damage is the one the finding's last sentence names: retroactive insertion below timestamps that have already been read. That is genuine, and it is what makes this worth fixing.

Why there is no patch. The remedy the finding asks for — durable primary-record evidence — runs into two problems I could not resolve without a decision:

  • For a COMMIT, the evidence exists (txnCommitKey(primaryKey, startTS), which is exactly what resolveExpiredLock reads via primaryTxnRecordedStatustxnCommitTS), but the primary may live on another shard group. The background resolver already has a "primary shard is not locally ready" case where it declines to answer; a leader that does not host the primary has no safe verdict, and refusing would break legitimate cross-shard legacy resolution.
  • For an ABORT, there is no record to match: abortTSFrom(lock.StartTS, commitTS) synthesises the timestamp. A "must match a durable record" rule cannot cover that path unchanged.

There is a second remedy the finding does not mention that may be better: the attacker's leverage is step 1, not step 2. A transaction genuinely in flight across the marker can only stay preparable for its lock TTL (defaultTxnLockTTLms = 30s, maxTxnLockTTLms = 24h), so ValidateForwardedTxnStartTimestamp could admit a pre-D start only inside a bounded window after Phase-D activation — the physical half of PhaseDFloor() plus the maximum TTL. After that no new pre-D intent can be fabricated, and the commit carve-out is then only usable against intents that genuinely predate the marker, which is what it was written for.

Both options, the cases neither covers alone, and four open questions are in docs/design/2026_09_02_proposed_prephase_d_resolution_evidence.md (4d59e5b6). Picking between them changes when legacy transactions get rejected, which is not a call to make unilaterally inside a merge-conflict PR.

Conflict resolution (3b609951)

Seven files:

  • adapter/sqs_{messages,reaper,redrive}.go (5 sites) — both sides edited the same two lines. Kept this branch's kv.DispatchWithReadTimestamp and main's narrower isIgnorableTransactRaceError, which deliberately stops swallowing route fences as "another worker already did it". Verified the rename is a real narrowing, not a mechanical one: isRetryableTransactWriteError still exists on main for the DynamoDB paths.
  • adapter/grpc.go — two guard clauses added at the same spot. main's requireReadReady runs first so the group-watermark branch is gated by it too. That pushed RawLatestCommitTS to cyclomatic complexity 11, so 97630ac6 extracts the three-way store-capability dispatch into rawKeyCommitTS (behaviour unchanged) rather than adding a //nolint.
  • adapter/redis_list_dedup_test.go — independent test additions, unioned.
  • kv/sharded_coordinator.go — this branch moved ValidateElemCommitTSPatches and the write-floor check into settleTxnCommitTimestamp; main renamed the latter to ensureGroupedMutationsWriteAllowedWithBypass and gave it a bypass set. Kept this branch's placement with main's bypass-aware callee, threading bypassKeysByGroup into the helper — otherwise the merge would have silently dropped main's write-fence bypass on the whole 2PC path.
  • adapter/distribution_server.go — struct fields unioned. main's planSplitRange / nextCatalogSplitIDs refactor wins because it carries the shipped OCC read-key feature; this branch's kv.ReadTimestamp parameter is kept on saveSplitResultViaCoordinator because the dispatch needs its voucher. I verified snapshot.ReadTS is exactly readTimestamp.Timestamp() (CatalogStore.SnapshotAt sets ReadTS: ts from its argument) before treating the two as interchangeable.

Evidence

go test -race ./kv/ ./distribution/ .        ok
go test -race -timeout 40m ./adapter/        ok  780.637s   0 failures
golangci-lint run ./adapter/... ./kv/... ./distribution/... .   0 issues

The first ./adapter/ run aborted at the default 10m timeout inside Test_consistency_satisfy_write_after_read_sequence / Test_grpc_transaction (~7m40s each, unrelated to this branch); the 40m rerun above is clean.

Five-lens self-review

  1. Data loss — the sharded_coordinator.go resolution is the risk. Dropping main's side would have removed the write-floor bypass from the 2PC path; the resolution keeps it and go build would not have caught the loss, so I checked both sides' symbol tables (git grep ensureGroupedMutationsWriteAllowed on each ref) rather than trusting the compile.
  2. Concurrency / distributedisIgnorableTransactRaceError narrows what SQS swallows, so route fences now propagate instead of being mistaken for a completed race. That is main's intended behaviour and the direction that fails safe.
  3. PerformancerawKeyCommitTS is a straight extraction, same branches, no extra allocation.
  4. Data consistency — the carve-out is unchanged and no wider than before; the doc records why. requireReadReady now also gates the group watermark, which is strictly more conservative.
  5. Test coverage — no new behaviour to cover; the merge is validated by the full kv, distribution, adapter and root suites under -race.

@codex review
@claude review

@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown

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


Review of HEAD 4d59e5b (merge conflict resolution 3b60995, refactor 97630ac, design doc 4d59e5b)

  • Gather context (current HEAD, diffs for the three commits)
  • Verify the design-doc-instead-of-patch reasoning for kv/tso.go:371
  • Verify merge conflict resolution: adapter/sqs_{messages,reaper,redrive}.go
  • Verify merge conflict resolution: adapter/grpc.go (rawKeyCommitTS extraction)
  • Verify merge conflict resolution: kv/sharded_coordinator.go (write-floor bypass threading)
  • Verify merge conflict resolution: adapter/distribution_server.go (ReadTS/voucher equivalence)
  • Check the new design doc's content for internal consistency
  • Post final review (could not execute go build/go test/gofmt — see note)

Merge conflict resolutions (3b60995) — all four verified correct

  • adapter/grpc.go (97630ac): confirmed requireReadReady() gates RawLatestCommitTS (line 214) exactly as claimed, and the new rawKeyCommitTS helper is a faithful, behavior-preserving extraction of the three-way store-capability dispatch (group-aware / fence-aware / plain reader) — same branches, same error wrapping, just moved to bring the caller under the cyclop budget.
  • kv/sharded_coordinator.go: confirmed settleTxnCommitTimestamp (this branch's placement/naming) now calls main's ensureGroupedMutationsWriteAllowedWithBypass with bypassKeysByGroup threaded through from dispatchTxn, and ValidateElemCommitTSPatches is still enforced. Cross-checked the pre-merge branch tip (aabf40ab) — the double ValidateElemCommitTSPatches call (once inside prepareTxnCommitTimestamp, once inside settleTxnCommitTimestamp on the same resolved value) already existed before the merge, so it's pre-existing redundancy, not something the merge introduced.
  • adapter/distribution_server.go: verified the specific factual claim — CatalogStore.SnapshotAt(ctx, ts) (distribution/catalog.go:315-328) returns CatalogSnapshot{..., ReadTS: ts}, i.e. snapshot.ReadTS is literally the ts argument, which SplitRange passes as readTimestamp.Timestamp(). So the claim "snapshot.ReadTS is exactly readTimestamp.Timestamp()" holds by construction, not by coincidence. saveSplitResultViaCoordinator correctly kept the kv.ReadTimestamp parameter and uses readTimestamp.WithDispatchVoucher(ctx) + kv.DispatchWithReadTimestamp for the actual dispatch, while planSplitRange/nextCatalogSplitIDs are main's OCC read-key refactor as claimed.
  • adapter/sqs_{messages,reaper,redrive}.go: confirmed exactly 5 call sites for isIgnorableTransactRaceError (2 in sqs_messages.go, 2 in sqs_reaper.go, 1 in sqs_redrive.go), and spot-checked one site — both properties survived the merge together: kv.DispatchWithReadTimestamp (this branch's voucher-aware dispatch) feeding into isIgnorableTransactRaceError (main's narrower classifier, store.ErrWriteConflict / kv.ErrTxnLocked only — confirmed it does not swallow isRouteWriteFencedError, matching the stated intent of not treating route fences as "someone else already did it").
  • adapter/redis_list_dedup_test.go: diffed the function name lists between origin/main and HEAD — identical sets, so nothing was dropped; the "union" was a no-op here since both sides had the same tests (just shifted line numbers from unrelated earlier content).
  • No leftover <<<<<<</=======/>>>>>>> conflict markers anywhere in *.go.

Design doc (4d59e5b, docs/design/2026_09_02_proposed_prephase_d_resolution_evidence.md) — one factual inaccuracy, substance otherwise checks out

Verified the doc's central technical claims against current code:

  • The quoted ValidateForwardedTxnCommitTimestamp code block (§1) matches kv/tso.go:350-374 verbatim, including the !resolution || startTS == 0 gate.
  • ValidateForwardedTxnStartTimestamp (kv/tso.go:388-399) does admit a pre-Phase-D startTS unconditionally (errors.Is(err, ErrTSOTimestampPrePhaseD)nil), confirming step 1 of the attack scenario in §1.
  • abortTSFrom (kv/sharded_coordinator.go:2004) does synthesize commitTS+1 rather than reading a durable record, confirming §3's claim that a "must match a durable record" rule can't cover ABORT unchanged.
  • defaultTxnLockTTLms = 30_000 and maxTxnLockTTLms = 86_400_000 (kv/txn_consts.go:4,7) match the values cited in §4.2's bound.

One claim is factually wrong, though it doesn't undermine the argument: §2 states "kv/tso.go does not exist on origin/main". It does — git cat-file -e origin/main:kv/tso.go succeeds, and it's the original ~30-line TSOAllocator interface file this branch grew into ~700 lines. What's actually true, and what the doc needs, is narrower: I checked origin/main's adapter/internal.go directly, and its stampRawTimestamps/fillForwardedTxnCommitTS call no ValidateForwardedTxn*/ValidateDurablePersistenceTimestamp-equivalent function at all — so the substantive point ("Internal.Forward on main accepts any forwarded timestamp with no validation") is correct, just misattributed to the wrong file existing/not-existing. Worth a one-line correction so a future reader who greps for kv/tso.go on main isn't confused by a false claim sitting next to an accurate one.

Fix this →

The rest of the doc's reasoning is sound: the two proposed remedies (§4.1 durable-record verification, §4.2 wall-clock-bounded PREPARE admission) correctly identify their own tradeoffs — §4.1's cross-shard-primary gap and §4.2's CLAUDE.md wall-clock caveat are both real constraints, not hand-waved. Deferring implementation to a proposed design doc rather than quick-patching a durable-state/timestamp-admission change is the right call per CLAUDE.md's design-doc-first rule, consistent with how this PR has handled every other durable-state finding in its review history (Phase-D wall-clock drift, allocation-window gaps, caller-supplied commit-ts validation).

Not independently run: go build/go test/gofmt/golangci-lint require Bash approval not available in this sandbox (the same limitation noted throughout this PR's entire review history). The verification above is from full static reads of the diffs and the actual current file contents (including cross-referencing origin/main's versions of kv/tso.go, adapter/internal.go, distribution/catalog.go), not test execution. The author's reported go test -race (kv/distribution/root: clean; adapter: clean at 780s under a 40m timeout) and golangci-lint run at 0 issues are consistent with what's in the diff.

Summary

The merge conflict resolution is correct on all four contested files — each keeps both sides' intended behavior rather than silently dropping one. The rawKeyCommitTS extraction is a clean, behavior-preserving refactor. The decision to write a proposed design doc instead of patching kv/tso.go:371 directly is the right call: the finding is real but narrow (a regression only against this PR's own stricter invariant, not against main's much weaker baseline), and the fix requires a policy decision (which of two remedies, with real tradeoffs on each) that shouldn't be made unilaterally mid-merge. One correction needed in the doc: §2's claim that kv/tso.go doesn't exist on origin/main is false — the file exists in its pre-Phase-D form; the underlying point (no validation exists on main) is still accurate and just needs different phrasing.

Branch: design/dedicated-tso-fsm ·

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 4d59e5b6a0

ℹ️ 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".

@bootjp
bootjp merged commit 5f8392b into main Sep 2, 2026
15 checks passed
@bootjp
bootjp deleted the design/dedicated-tso-fsm branch September 2, 2026 15:03
bootjp added a commit that referenced this pull request Sep 3, 2026
…ge7-projection

One conflict: adapter/redis_lua_context.go, where both sides added a
field to the same struct literal. This branch's negativeTypeLimit and
main's readTimestamp (from the dedicated TSO FSM, #1095) are
independent, so both are kept.
bootjp added a commit that referenced this pull request Sep 3, 2026
…m2-cross-group

main now carries the dedicated TSO ceiling FSM (#1095). Nine files
conflicted.

proto/service.proto is the one that mattered: this branch put
version_visible_results on tag 5 of RawLatestCommitTSResponse, and #1095
shipped group_id = 5 / leader_fenced = 6 there while this branch was
open. main's tags are released wire format, so the batch results moved
to tag 7. Regenerated with buf; the generated field carries varint,7.

adapter/grpc.go: this branch split RawLatestCommitTS into a key_batch
fast path plus rawLatestCommitTSSingle, while main added the
group-watermark branch and extracted rawKeyCommitTS. Kept the split and
moved main's watermark check into rawLatestCommitTSSingle -- a watermark
request carries neither key nor key_batch, so it falls through the batch
check and lands there with the same behavior. main's rawKeyCommitTS
replaces this branch's inline reader dispatch; readRouteVersion is
already a parameter there, so main's redeclaration was dropped.

adapter/internal.go and adapter/redis_lua_context.go: both sides added
fields to the same struct, unioned (kvForwardRejected / readTimestamp
from main, the migration and rawTypeAtStart fields from this branch).

The four test files are purely additive on both sides, but git
interleaved two pairs of unrelated test functions that happened to share
a middle. Reconstructed both complete functions in each pair rather than
splicing halves; the import block in distribution_server_test.go is a
plain union.
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