Skip to content

tso: add dedicated ceiling fsm - #1095

Open
bootjp wants to merge 51 commits into
mainfrom
design/dedicated-tso-fsm
Open

tso: add dedicated ceiling fsm#1095
bootjp wants to merge 51 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
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Claude finished @bootjp's task in 1m 47s —— View job


Review of HEAD 5d3dce2 (CI race-condition fix)

  • Gather context (trigger comment, current HEAD, prior review history)
  • Verify the race actually existed and the mechanism described
  • Verify the fix (making the test sequential) resolves it
  • Check setTSOModeFlags documentation addition
  • Confirm no other parallel callers of setTSOModeFlags remain
  • Post final review (could not execute go test -race in this sandbox — see note below)

Root cause confirmed. setTSOModeFlags (main_tso_routing_test.go:326-348) mutates process-global flag vars (*tsoEnabled, *tsoShadowEnabled, *tsoModeFile, etc.) and restores them via t.Cleanup. That's safe under Go's test model only if at most one t.Parallel() caller exists concurrently — Go drains all sequential tests to completion before parallel ones run together, so a single parallel caller never races with anything. 4870f95's predecessor commit added t.Parallel() to TestCoordinatorTSOWiringAuthorizeActivation (also a setTSOModeFlags caller), giving two concurrent mutators of the same global vars — exactly what -race flags.

Fix verified correct and complete. I enumerated all 16 setTSOModeFlags( call sites in the file and mapped each to its enclosing func Test..., then cross-referenced against every t.Parallel() occurrence:

  • TestInternalForwardUsesRuntimeAllocatorAfterModeReload (line 224) — the sole pre-existing parallel caller, unchanged.
  • TestCoordinatorTSOWiringAuthorizeActivation (line 594) — t.Parallel() removed at line 597 in this commit; confirmed the two-line removal (call + blank line) is clean and the function body is otherwise untouched.
  • The remaining 14 callers are all sequential, matching the commit message's count exactly (16 total − 1 legitimate parallel − 1 fixed = 14 sequential).

No other file in the repo calls setTSOModeFlags (grepped repo-wide), so there's no missed call site elsewhere.

Documentation addition is accurate and well-placed. The new doc comment on setTSOModeFlags (lines 317-325) correctly states the non-parallel constraint and explains why (single parallel caller is safe, a second one isn't), directly citing the incident. This is exactly the kind of guard-rail comment that prevents the next contributor from reintroducing the same bug — good call adding it rather than just fixing the one test.

Scope is minimal and correctly targeted. The diff touches only main_tso_routing_test.go (a doc comment + removing t.Parallel() from one test), consistent with the stated root cause. No production code changed.

Not independently re-run: go test -race/gofmt require Bash approval not available in this sandbox (consistent with every prior review pass on this PR). The verification above is from a full static trace of every setTSOModeFlags call site and every t.Parallel() occurrence in the file, which is sufficient to confirm the fix eliminates the described race by construction (only one parallel mutator of the globals remains). The author's report of go test -race -count=1 ./ passing is consistent with this analysis.

Summary

The fix is correct, minimal, and precisely targets the described race: TestCoordinatorTSOWiringAuthorizeActivation no longer runs in parallel with TestInternalForwardUsesRuntimeAllocatorAfterModeReload, both of which mutate the same process-global TSO flags via setTSOModeFlags. The added doc comment on the helper is a good preventive measure. No new issues found in this commit. Also good catch on the process gap — verifying with -race (as make test actually runs) rather than a plain go test avoids this class of false-green report going forward.
· 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: 5d3dce2eb6

ℹ️ 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
Comment on lines +2234 to +2238
if phaseD && mode < kv.TSOModePhaseD {
return errors.Wrapf(ErrTSOActivationNotPermitted, "phase-d activation requires local mode phase-d, have %s", mode)
}
if cutover && mode < kv.TSOModeCutover {
return errors.Wrapf(ErrTSOActivationNotPermitted, "cutover activation requires local mode cutover or later, have %s", mode)

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 Permit reservations after markers are already durable

When cutover or Phase D has already been committed by another leader, a node whose mode file still names the preceding stage immediately follows the marker through DynamicTimestampAllocator.durableTimestampAllocator, but CurrentMode() remains stale until the next successful reload. If that node becomes the group-0 leader during the rolling transition, remote allocators continue sending the already-active ActivateCutover/ActivatePhaseD flag and this branch rejects every reservation; the resulting PermissionDenied is non-transient in isTransientTSORouteError, so timestamp allocation and writes on all other nodes fail until the leader's local configuration catches up. Treat an already-durable requested marker as a no-op before enforcing the local activation-stage check.

Useful? React with 👍 / 👎.

The gate added in a062200 checked the request's activation flags against
this node's runtime mode without asking whether the marker was already
committed. When another leader has activated cutover or Phase D, a node
whose mode file still names the preceding stage follows the marker
immediately through DynamicTimestampAllocator, but CurrentMode() stays
stale until its next successful reload. If that node holds group-0
leadership during the rolling transition, remote allocators keep sending
the now-redundant activation flag, the gate refuses, and PermissionDenied
is not transient to isTransientTSORouteError -- so allocation and writes
stall on every node until the local configuration catches up.

Narrow the flags to the markers that are not durable yet before consulting
the gate. Following a marker somebody else committed is mandatory, not an
activation request; only a marker this node would be the first to commit
is one.

Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
@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 (b91ae6e):

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

@bootjp

bootjp commented Aug 28, 2026

Copy link
Copy Markdown
Owner Author

HEAD (5d3dce2e) 一致の未対応指摘 1 件(P1)を修正しました(b91ae6e0)。前 tick で私が入れた activation gate の可用性欠陥です。

検証

指摘は正しいです。 前 tick の gate は、要求された marker が既に durable かどうかを問わずに local mode と突き合わせていました。

ロールアウト中の窓:

  1. 他のリーダーが cutover / Phase D を commit
  2. あるノードは DynamicTimestampAllocator.durableTimestampAllocator 経由でその marker に即座に追随する
  3. しかしそのノードの CurrentMode() は、次に mode file の reload が成功するまで古いまま
  4. そのノードが group-0 リーダーになると、リモート allocator はもう有効な ActivateCutover / ActivatePhaseD を送り続ける
  5. 私の gate がそれを拒否 → PermissionDenied

そして isTransientTSORouteErrorPermissionDenied再試行しません。結果、そのノードのローカル設定が追いつくまで全ノードで採番と書き込みが止まります。ご指摘のとおり、可用性を落とす欠陥でした。

修正

gate に渡す前に、まだ durable でない marker だけにフラグを絞ります

他人が commit 済みの marker に追随するのは義務であって活性化要求ではありません。 活性化要求と呼べるのは、このノードが最初に commit することになる marker だけです。その区別を入れました。

  • 両方とも durable 済み → gate を呼びません(通常の採番として処理)
  • cutover は durable / Phase D は未 durable → gate には (false, true) だけを渡す
  • allocator が状態アクセサを持たない場合は従来どおり全フラグを渡す(保守的)

Caller audit

関数 呼び出し元 結果
authorizeTSOActivation GetTimestamp のみ durable 済み分を除外
pendingTSOActivation(新規) 上記 1 箇所
WithDistributionTSOActivationGate main.go:716 の 1 箇所 未変更

main.go 側の authorizeActivation(mode 判定)は変更していません。「何を活性化とみなすか」を絞ったのはサーバ側です。

Test evidence

  • TestDistributionServerGetTimestamp_AlreadyDurableMarkerSkipsTheGate — 両方 durable なら gate が一度も呼ばれないこと
  • TestDistributionServerGetTimestamp_GatesOnlyThePendingMarker — cutover 済み + Phase D 未済で gate に (false, true) が渡ること
  • 既存の ..._AuthorizesActivationLocally / ..._PermittedActivationProceeds は無改変で緑

2 件とも「修正を戻すと FAIL する」ことを確認済みです。

ok  github.com/bootjp/elastickv          110.848s   (-race)
ok  github.com/bootjp/elastickv/kv        21.940s   (-race)
ok  github.com/bootjp/elastickv/adapter  634.015s   (-race)

前 tick の反省どおり -race 付きで全パッケージを流しましたgolangci-lint run ./ ./adapter/... ./kv/... → 0 issues。

@codex review
@claude review

@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Claude encountered an error after 15s —— View job


Review of HEAD b91ae6e0 in progress

  • Gather context (diff for commit b91ae6e0, prior tick's gate logic)
  • Verify the fix: authorizeTSOActivation only gates on not-yet-durable markers
  • Verify the new pendingTSOActivation helper logic
  • Check caller audit claims (single call site, no new wiring)
  • Check new regression tests
  • Post final review

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

ℹ️ 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/distribution_server.go Outdated
Comment on lines +260 to +263
state, ok := s.timestampAllocator.(interface {
CutoverActive() bool
PhaseDActive() bool
})

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 Expose cutover state from the production allocator

In the production wiring, main.go assigns a *kv.RaftTSOAllocator to serverAllocator, but that type implements PhaseDActive() without implementing CutoverActive(). Consequently this two-method assertion always fails in production, all requested flags are returned unchanged, and a node with an already-durable marker still invokes the stale-mode gate and returns PermissionDenied, so the reported cluster-wide allocation outage remains unfixed. The new tests miss this because their stub adds the otherwise-missing method; exercise this path with the concrete allocator or a production state accessor.

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

Useful? React with 👍 / 👎.

The activation gate narrows a requested cutover/phase-D activation away
when the allocator already carries the durable marker, using one type
assertion for both CutoverActive and PhaseDActive. *kv.RaftTSOAllocator --
the allocator main.go actually wires -- implements only PhaseDActive, so
the combined assertion never matched in production and the narrowing was
inert there. It passed in tests because the test double supplied both
methods.

Add CutoverActive to RaftTSOAllocator and probe the two markers
independently, so an allocator exposing one of them still gets that half
of the narrowing. The new test drives configureCoordinatorTSO's real
wiring and asserts the server allocator satisfies both interfaces, which
is what the previous test double hid.

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

bootjp commented Aug 28, 2026

Copy link
Copy Markdown
Owner Author

adapter/distribution_server.go:263 (P1, "Expose cutover state from the production allocator") — fixed in 1132828.

The finding is right, and it caught the exact trap this loop warns about. The activation gate narrowed a requested cutover/phase-D activation away when the allocator already carries the durable marker, using one type assertion for both CutoverActive() bool and PhaseDActive() bool. *kv.RaftTSOAllocator — the allocator main.go:2391 actually assigns to wiring.serverAllocator — implements PhaseDActive() at kv/tso_raft.go:451 but had no CutoverActive(). So the combined assertion never matched in production: the narrowing was inert exactly where it mattered, and it went green only because my test double distributionTSOAllocator supplied both methods.

Fix:

  • kv/tso_raft.go: add RaftTSOAllocator.CutoverActive() reading the same durable state as PhaseDActive().
  • adapter/distribution_server.go: probe the two markers independently, so an allocator exposing only one still gets that half of the narrowing.
  • main_tso_routing_test.go: TestServerAllocatorExposesDurableMarkerState drives the real configureCoordinatorTSO wiring and asserts the server allocator satisfies both interfaces — the assertion the test double was hiding.

Verification: the new test FAILs with CutoverActive() removed. go test -race green on . (110s), ./kv/... (23s), ./adapter/ (798s — the package needs -timeout above the 10m default with -race on this machine, which is what an earlier 600s abort was, not a hang). golangci-lint run ./ ./adapter/... ./kv/... → 0 issues.

@codex review
@claude review

@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Claude finished @bootjp's task in 1m 50s —— View job


Review of HEAD 1132828 (fix: expose cutover state on the production allocator)

  • Gather context (diff for commit 1132828a, prior finding in trigger comment)
  • Verify RaftTSOAllocator.CutoverActive() was added correctly in kv/tso_raft.go
  • Verify the independent-probe fix in adapter/distribution_server.go
  • Verify the new regression test exercises the real production wiring
  • Post final review (could not execute go test/golangci-lint in this sandbox — see note below)

Root cause confirmed by tracing the actual production wiring, not just re-reading the commit message:

  • main.go:2391 (configureDedicatedCoordinatorTSO) does local, err := kv.NewRaftTSOAllocator(...) then wiring.serverAllocator = local — no wrapping, so wiring.serverAllocator is always a *kv.RaftTSOAllocator in every configuration where group 0 exists (this assignment happens unconditionally in configureDedicatedCoordinatorTSO, before the dedicatedTSORuntimeRequired gate).
  • main.go:715 is the sole production call site of adapter.WithDistributionTimestampAllocator(in.tsoWiring.serverAllocator) (grepped across adapter/, main.go, cmd/ — the only other hits are test-only), which sets DistributionServer.timestampAllocator.
  • Before this fix, *kv.RaftTSOAllocator (kv/tso_raft.go) implemented PhaseDActive() (pre-existing, delegating to TSOStateMachine.PhaseDActive()) but had no CutoverActive() method. pendingTSOActivation's old code used a single combined type assertion interface{ CutoverActive() bool; PhaseDActive() bool }, which therefore always failed on the real production allocator — ok was false and the function fell through to return activateCutover, activatePhaseD unchanged, meaning the narrowing described in the previous commit (b91ae6e0, "let an already-durable marker through the activation gate") was a complete no-op in production. It only passed tests because distributionTSOAllocator (the test double) implemented both methods together.

Fix verified correct:

  • kv/tso_raft.go:456-458 adds RaftTSOAllocator.CutoverActive(), mirroring the existing PhaseDActive() delegation to a.state.CutoverActive() (TSOStateMachine.CutoverActive() already existed at kv/tso_fsm.go:257, so this is purely exposing already-tracked state, not new logic).
  • adapter/distribution_server.go:259-267 now probes each marker with its own single-method interface assertion and narrows only the flag it can actually check — an allocator missing one method still gets the other half of the narrowing instead of neither. If an allocator implements neither interface, both flags stay unmodified (fails toward treating the request as a genuine activation, which is the safe/conservative direction — it still goes through authorizeTSOActivation's local-mode gate rather than silently bypassing it).
  • Checked for the same latent bug pattern elsewhere: ValidateTimestamp (adapter/distribution_server.go:306-308) also uses a combined two-method assertion (PhaseDFloor() uint64; AllocationFloor() uint64), but RaftTSOAllocator implements both of those (kv/tso_raft.go:468,475), so that one isn't subject to the same failure mode — no fix needed there.
  • Confirmed LeaderRoutedTSOAllocator and ShadowTimestampAllocator (which only implement PhaseDActive(), no CutoverActive()) are never assigned to wiring.serverAllocator — they're wired into coordinate.WithTSOAllocator(...) instead, a separate write-path allocator slot unrelated to DistributionServer.timestampAllocator. So the fix's scope (only RaftTSOAllocator) is correctly targeted; no other allocator type needs the same treatment for this code path.

New regression test verified sound. TestServerAllocatorExposesDurableMarkerState (main_tso_routing_test.go) calls configureCoordinatorTSO — the actual function main.go's startup path calls — with a real *kv.ShardedCoordinator, TSOStateMachine, and shard-group map containing the dedicated TSO group ID, then asserts wiring.serverAllocator satisfies both single-method interfaces independently. This is a meaningfully different (and stronger) test than the pre-existing distributionTSOAllocator test double, which is exactly what let the original bug slip through: a hand-rolled double implementing both methods together can't catch a production type implementing only one. Traced configureCoordinatorTSOconfigureCoordinatorTSOWithObserverconfigureDedicatedCoordinatorTSO and confirmed wiring.serverAllocator = local runs unconditionally before the dedicatedTSORuntimeRequired mode check, so require.NotNil(t, wiring.serverAllocator) holds even under the test's legacy-mode flag setup (setTSOModeFlags(t, false, false)) — consistent with the test's own comment.

Not independently re-run: go test/golangci-lint require Bash approval not available in this sandbox (same limitation noted throughout this PR's review history — commands with env-var prefixes or multi-part shell syntax need interactive approval that wasn't granted here). The verification above is from a full manual trace of the diff, the call graph from main.go:715 back through configureDedicatedCoordinatorTSO, and cross-checking every other combined-interface assertion in the file for the same failure pattern. The author's reported go test -race passing on . (110s), ./kv/... (23s), ./adapter/ (798s), and golangci-lint at 0 issues are consistent with this analysis.

Summary

The fix is correct, minimal, and precisely closes the gap: the activation-narrowing logic added in the prior commit was silently inert in production because the combined type assertion never matched *kv.RaftTSOAllocator. Splitting into two independent single-method probes and adding the missing CutoverActive() method restores the intended behavior. Swept for the same combined-assertion pattern elsewhere in the file and confirmed no other instance is affected. The new test exercises the real production wiring path rather than a hand-rolled double, which is exactly what was needed to catch this class of bug. No new issues found in this commit.
· Branch: design/dedicated-tso-fsm

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 1132828a8a

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

Resolutions worth review:

- proto/service.proto: main took tags 3 and 4 on RawLatestCommitTSResponse
  for the version probe while this branch was open, and this branch used the
  same two for group_id / leader_fenced. Main's fields keep their tags and
  the watermark fields move to 5 and 6. On the request side both sides named
  the same group_id field, so it collapses onto main's tag 3. Generated code
  is regenerated with the pinned buf toolchain.
- adapter/grpc.go: both sides added a group_id branch to RawLatestCommitTS.
  They are different requests sharing one field -- keyless is this branch's
  leader-fenced group watermark, with a key it selects the group for main's
  per-key read -- so the keyless case is now guarded on an empty key and
  extracted into rawGroupWatermark. Without the guard this branch's early
  return shadowed main's per-key group read entirely.
- adapter/internal.go stampRawTimestamps: this branch validated an already
  stamped timestamp and returned; main added a route-floor check that its
  own comment says must also run for already-stamped requests. Composed, so
  a forwarded write is both validated and floor-checked.
- adapter/redis_txn.go: main's target-based fencing is the substrate, this
  branch's ReadTimestamp and label ride on it. The key-based entry point
  stays as a thin wrapper.
- adapter/redis_zset_cmds.go: main's bzpopmin candidate helpers are taken
  whole; persistBZPopMinResult keeps this branch's ReadTimestamp so the
  dispatch voucher still travels with the write.
- kv/sharded_coordinator.go: commit-ts allocation, the elem patch check, and
  the write-floor check now travel together in settleTxnCommitTimestamp.

Two tests changed because production behaviour legitimately moved:
FilesystemGroupIDs skips store-less groups, so main's sorting test needs
real stores; and ReadFenceGroupKeysForRange's enumeration order is no longer
the raw range order under user-key routing, so that assertion compares the
set. Option-count assertions in main_tso_routing_test.go move by one because
the startup gate now also forwards the write gate.

Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
@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 (721ca14):

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

@bootjp

bootjp commented Aug 28, 2026

Copy link
Copy Markdown
Owner Author

Merged origin/main721ca145. Two of the resolutions changed behaviour that was not obvious from the conflict markers, so they are worth a look:

A proto tag collision. Main took tags 3 and 4 on RawLatestCommitTSResponse for the version probe while this branch was open, and this branch had used the same two for group_id / leader_fenced. Main's fields keep their tags; the watermark fields move to 5 and 6. On the request side both sides independently named a group_id field, so those collapse onto main's tag 3 rather than being duplicated. Generated code is regenerated with the pinned toolchain (buf 1.55.1, protoc-gen-go v1.36.11, protoc-gen-go-grpc 1.6.1).

A shadowed branch in RawLatestCommitTS. Both sides added a group_id != 0 branch, and git stacked them. They are different requests sharing one field: keyless is this branch's leader-fenced group watermark, while with a key it selects the group for main's per-key read. As auto-merged, this branch's early return fired for any non-zero group_id and main's per-key group read became unreachable. The keyless case is now guarded on an empty key and extracted into rawGroupWatermark.

Other resolutions:

  • stampRawTimestamps: this branch validated an already-stamped timestamp and continued; main added a route-floor check whose own comment says it must run for already-stamped requests too. Composed with if/else so a forwarded write is both validated and floor-checked.
  • redis_txn.go: main's target-based fencing is the substrate; this branch's kv.ReadTimestamp and label ride on it. The key-based entry point stays as a thin wrapper that builds bare targets.
  • redis_zset_cmds.go: main's BZPOPMIN candidate helpers are taken whole; persistBZPopMinResult keeps this branch's ReadTimestamp so the dispatch voucher still travels with the write.
  • kv/sharded_coordinator.go: commit-ts allocation, the elem patch check, and the write-floor check now travel together in settleTxnCommitTimestamp (also what keeps dispatchTxn under the cyclop budget).

Three test expectations moved, each because production behaviour legitimately changed — flagging them rather than burying them:

  1. FilesystemGroupIDs skips store-less groups on this branch (the dedicated TSO group registers without a store), so main's sorting test needed real stores instead of zero-value placeholders.
  2. ReadFenceGroupKeysForRange's enumeration order is no longer the raw range order under main's user-key routing. The contract is one representative key per intersecting group and every consumer leases all of them, so the assertion now compares the set. I checked the callers before relaxing it.
  3. internalTimestampOptions counts move by one because the startup gate now also forwards the write gate. The legacy case is 2, not 3 — the count still discriminates whether a TSO allocator is underneath.

go test -race ./kv/... ./adapter/... . green (kv 26s, adapter 786s, root 124s); golangci-lint run ./adapter/... ./kv/... ./ → 0 issues.

@codex review
@claude review

@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 commented Aug 28, 2026

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-29T12:17:09.208695Z aabf40a Manual request
ℹ️ About Codex in GitHub

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

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

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

@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Claude finished @bootjp's task in 4m 37s —— View job


Review of merge commit 721ca14 (origin/main → design/dedicated-tso-fsm)

  • Gather context: diff the merge commit against both parents
  • Verify proto tag collision resolution (RawLatestCommitTSResponse tags 3-6, request group_id tag 3)
  • Verify the RawLatestCommitTS shadowed-branch fix (rawGroupWatermark, keyless vs. keyed group_id)
  • Verify stampRawTimestamps if/else composition (forwarded-write validation + route-floor check)
  • Verify redis_txn.go fencing merge (target-based fencing + ReadTimestamp/label)
  • Verify redis_zset_cmds.go BZPOPMIN merge (persistBZPopMinResult voucher preserved)
  • Verify kv/sharded_coordinator.go settleTxnCommitTimestamp extraction
  • Check the 3 moved test expectations (FilesystemGroupIDs, ReadFenceGroupKeysForRange, internalTimestampOptions counts)
  • Cross-check against CI (Proto/build/lint workflows on this exact commit)
  • Post final review

All six described resolutions check out as correct on the current HEAD (721ca14):

1. Proto tag collisionproto/service.proto confirmed: RawLatestCommitTSRequest.group_id is tag 3 (shared by both original branches' independent additions), RawLatestCommitTSResponse keeps version_visible/version_visible_supported on 3/4 (main's) and moves group_id/leader_fenced to 5/6. Generated proto/service.pb.go struct tags match exactly, and the "Proto" CI workflow (which regenerates + diffs against the pinned buf/protoc-gen-go toolchain) passed on this commit — good independent confirmation the .pb.go wasn't hand-edited out of sync with the .proto.

2. RawLatestCommitTS shadowed branchadapter/grpc.go:195-201: the keyless case is now correctly guarded on groupID != 0 && len(req.GetKey()) == 0 before extracting into rawGroupWatermark, letting main's per-key groupID != 0 branch (line 218) become reachable again. Traced both call sites: kv/shard_store.go:3817 builds {GroupId: groupID} with no key → hits the new leader-fenced watermark path; kv/shard_store.go:2680 builds {Key: key, ..., GroupId: groupID} → correctly falls through to the per-key group read. Both production paths are exercised correctly post-merge.

3. stampRawTimestampsadapter/internal.go:257-291 now does exactly what the commit message says: validate-then-continue for already-stamped requests, allocate-if-unstamped otherwise, and ensureRawWriteAllowed (the route-floor check) runs unconditionally afterward for both cases. Matches main's own comment that already-stamped requests still need the floor check.

4/5. redis_txn.go / redis_zset_cmds.go — confirmed redisReadFencedTimestamp (key-based) is a thin wrapper over redisReadFencedReadTimestampredisReadFencedTimestampForTargets (main's target-based substrate), and tryBZPopMinWithMode threads the kv.ReadTimestamp from beginTxnReadTimestamp straight into persistBZPopMinResult, so the dispatch voucher rides along as described.

6. Three moved test expectations — all verified as legitimate, non-lossy relaxations:

  • TestShardStoreFilesystemGroupIDsReturnsPhysicalGroupsSorted now uses real store.NewMVCCStore()-backed groups because FilesystemGroupIDs() (kv/shard_store.go:106-124) filters group.Store == nil — correct, matches the dedicated TSO group's no-store design.
  • TestShardStoreReadFenceGroupKeysForRangeIncludesIntersectingRoutes now uses require.ElementsMatch instead of order-sensitive require.Equal. Checked the sole production caller (adapter/redis_txn.go:227-244redisReadFenceRangeTargets): every returned key becomes an independent ReadFenceTarget and all targets get leased via leaseRedisReadFenceTargets — order genuinely doesn't matter to any consumer, so the relaxation is safe.
  • internalTimestampOptions counts (3 for gated-with-allocator, 2 for gated-without): confirmed startupGatedCoordinator (main.go) implements EnsureMutationsWriteAllowed (2809) and ObserveForwardedRequests (2820) unconditionally, both delegating to c.inner, so the wrapper always contributes those two options regardless of the allocator; the count difference is purely about whether ConfiguredTimestampAllocatorThrough finds an allocator. Matches the explanation.

One minor artifact from the resolution, not caught by tests (harmless but worth a cleanup): kv/sharded_coordinator.go's settleTxnCommitTimestamp (1342-1360) calls c.prepareTxnCommitTimestamp and then immediately calls ValidateElemCommitTSPatches(elems, settled) again — but prepareTxnCommitTimestamp (1372-1393) already calls ValidateElemCommitTSPatches(elems, resolved) internally right before returning, and resolved == settled. I traced this back: pre-merge, this branch's dispatchTxn called prepareTxnCommitTimestamp directly (one patch-check, inside it); main's dispatchTxn called resolveTxnCommitTS + one inline ValidateElemCommitTSPatches + ensureGroupedMutationsWriteAllowed. The resolution kept this branch's prepareTxnCommitTimestamp (with its internal check) as a black box and additionally re-added main's inline check on top, rather than recognizing they're the same step. prepareTxnCommitTimestamp has no other caller, so this is pure duplicate work — same elems, same value, validated twice — not a correctness bug, just wasted cycles on every transactional dispatch. Worth a one-line cleanup (drop the outer ValidateElemCommitTSPatches call in settleTxnCommitTimestamp, since prepareTxnCommitTimestamp already covers it) but not blocking.

Fix this →

Sanity checks: no leftover <<<<<<</=======/>>>>>>> conflict markers anywhere in the tree. CI on this exact commit (721ca14) shows Proto, Reviewdog (lint), Docker build, and both TLA+ workflows green; the Test and Jepsen Test workflows were still in progress at review time (not polled further per guidance — check back if needed). I could not run go build/go test/gofmt directly in this sandbox (non-git Bash commands require interactive approval unavailable here), so the above is from full manual tracing of the diff and call graphs, cross-checked against the passing CI runs.

Summary

All six described merge-conflict resolutions are correct and preserve both parents' intended behavior. One small, non-blocking artifact: a redundant ValidateElemCommitTSPatches call introduced by combining both sides' commit-timestamp validation into settleTxnCommitTimestamp — harmless (idempotent, same value) but worth trimming. No data-loss, concurrency, or consistency issues found in the resolved code.

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 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 👍 / 👎.

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