Skip to content

Add live backup pin FSM substrate - #1056

Merged
bootjp merged 27 commits into
mainfrom
design/live-backup-pin-substrate
Aug 29, 2026
Merged

Add live backup pin FSM substrate#1056
bootjp merged 27 commits into
mainfrom
design/live-backup-pin-substrate

Conversation

@bootjp

@bootjp bootjp commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Summary

  • add a live-backup pin/extend/release FSM envelope for retaining read timestamps during future online logical backup scans
  • extend ActiveTimestampTracker with deadline-based backup pins, expiry sweeping, limits, and idempotent release/extend behavior
  • wire shard FSMs to the same ActiveTimestampTracker used by local compaction

Tests

  • go test ./kv -run 'Test(ActiveTimestampTracker|Backup|ApplyBackup)' -count=1 -timeout=240s
  • go test ./kv -count=1 -timeout=300s
  • go test . -run 'TestBuildShardGroupsWithEtcdEngineRoutesAcrossGroups|TestBuildShardGroupsWithEtcdEngineRestartsAcrossGroups' -count=1 -timeout=240s
  • go test . -run 'TestRaftBootstrapMembers_E2E|TestRaftBootstrapMembers_MultiGroup' -count=1 -timeout=300s
  • go test ./... -run TestNonexistent -count=0 -timeout=300s
  • go test . -count=1 -timeout=300s
  • golangci-lint run ./kv . --timeout=5m
  • git diff --check
  • git verify-commit HEAD

Author: bootjp

Summary by CodeRabbit

  • 新機能

    • ライブバックアップの開始・更新・終了、対象範囲の一覧表示、データストリーミングに対応しました。
    • バックアップ中のデータ整合性を保つ時刻フェンス、期限付き保持、容量制限を追加しました。
    • ノードおよびリーダーのバージョン情報を管理画面で確認できます。
    • Redisの従来形式のハッシュ・セットもバックアップ/復元対象になりました。
    • バックアップ走査のスナップショット固定とページングに対応しました。
    • 複数グループにまたがるバックアップの整合性確認とリーダー転送に対応しました。
  • 改善

    • スナップショット延期時に処理を継続できるようになりました。
    • 不正なバックアップデータや期限切れ情報を検出し、安全に処理を停止します。
    • バックアップ完了後も必要なデータ保持とコンパクション制御が正しく行われます。

@coderabbitai

coderabbitai Bot commented Jul 10, 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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b41a6898-4a1d-44cb-ac61-efefa527831c

📥 Commits

Reviewing files that changed from the base of the PR and between 87b1b4a and 09fc754.

📒 Files selected for processing (3)
  • internal/backup/live.go
  • internal/backup/live_test.go
  • kv/fsm.go
💤 Files with no reviewable changes (1)
  • kv/fsm.go

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

ライブバックアップ機能を追加しました。期限付きバックアップピン、FSMのtimestamp floor、管理RPC、固定ルート走査、リーダー転送、ライブデコーダー、RedisレガシーBlob対応、スナップショット延期処理を実装しています。

Changes

ライブバックアップ基盤

Layer / File(s) Summary
バックアップ追跡とワイヤ契約
kv/active_timestamp_tracker.go, kv/backup_codec.go, kv/coordinator.go
期限付きバックアップピン、期限延長、上限、スイーパー、固定長ワイヤ形式、全グループ時刻取得を追加しました。
FSM適用とスナップショット保護
kv/fsm.go, kv/fsm_backup.go, internal/raftengine/...
バックアップエントリの適用、timestamp floorの永続化、古い書き込みの拒否、アクティブピン中のスナップショット延期を追加しました。
ルートスナップショットと走査
kv/backup_scan.go, kv/shard_store.go, distribution/engine.go
捕捉済みルートを使う検証、ページング走査、パーティション所有判定、グループ単位の保持判定を追加しました。
リーダー転送と内部RPC
kv/leader_admin_proposer.go, kv/leader_proxy.go, kv/sharded_coordinator.go, adapter/internal.go, proto/internal.proto
管理提案とリース読み取りをリーダーへ転送し、Bearer認証、リトライ、適用時刻、エラー変換を追加しました。
管理バックアップ制御
adapter/admin_backup.go, adapter/admin_grpc.go, proto/admin.proto
Begin、Renew、End、スコープ列挙、バックアップストリーミング、ノードバージョン取得を追加しました。署名付きトークン、セッション状態、容量予約、ピア互換性も実装しました。
起動配線とデコーダー
main.go, main_admin.go, internal/backup/*
共有トラッカーをFSMとAdminへ接続しました。キー分類、RedisレガシーBlob、S3アップロード、SQSサイドレコードの処理を追加しました。

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

Merge Risk: 🟡 Moderate · up to 09fc7

This PR adds cluster-wide live-backup pinning and streaming; if ending a backup partially fails, the control reservation and session can be cleared while some shard pins remain, temporarily delaying compaction or snapshots until retry or expiry. The current head also retains a conditional legacy-record compatibility risk and tests that may not exercise intended routing and error paths, so merge should wait for cleanup reconciliation or explicit owner acceptance and targeted test fixes.

Sequence Diagram(s)

sequenceDiagram
  participant AdminClient
  participant AdminServer
  participant ShardedCoordinator
  participant kvFSM
  participant BackupScanner
  AdminClient->>AdminServer: BeginBackup
  AdminServer->>ShardedCoordinator: LeaseReadAllGroupsTimestamp
  AdminServer->>kvFSM: propose backup pin and timestamp floor
  kvFSM-->>AdminServer: applied backup state
  AdminServer->>BackupScanner: capture routes and validate snapshot
  AdminClient->>AdminServer: StreamBackup
  AdminServer->>BackupScanner: scan captured routes
  BackupScanner-->>AdminClient: BackupKV stream
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.69% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 325 functions across 50 files. 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 タイトルは、ライブバックアップ用のピンおよびFSM基盤の追加という変更の中心を簡潔に示しています。関連する補助変更も含みますが、主要な変更を適切に要約しています。
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.
✨ 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.

@bootjp

bootjp commented Jul 10, 2026

Copy link
Copy Markdown
Owner Author

@codex 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 (9a7491c):

  • kv/fsm.go

What to check, by subsystem:

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

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


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

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

@chatgpt-codex-connector

Copy link
Copy Markdown

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

@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 introduces a backup pinning mechanism to the ActiveTimestampTracker and kvFSM to retain MVCC versions at live-backup read timestamps during background compaction. It adds FSM commands for pinning, extending, and releasing backup pins, alongside a background sweeper to reap expired pins. The reviewer provided critical feedback to improve robustness: first, expired backup pins should be ignored in Oldest() to avoid blocking compaction before the sweeper runs; second, validation and limit errors must not halt the FSM to prevent DoS vulnerabilities; and third, a graceful shutdown mechanism (Close() and stopCh) should be added to the tracker to prevent goroutine leaks from the background sweeper.

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.

Comment thread kv/active_timestamp_tracker.go Outdated
Comment thread kv/fsm_backup.go
Comment thread kv/active_timestamp_tracker.go
Comment thread kv/active_timestamp_tracker.go
Comment thread kv/active_timestamp_tracker.go
@bootjp
bootjp force-pushed the design/live-backup-pin-substrate branch from 9a7491c to b9e3e10 Compare July 10, 2026 19:47
@bootjp

bootjp commented Jul 10, 2026

Copy link
Copy Markdown
Owner Author

Addressed latest-head review findings:

  • expired backup pins are ignored by Oldest() before the sweeper runs
  • invalid backup pins and active-backup limit failures now return non-fatal apply errors instead of halting the FSM
  • ActiveTimestampTracker now has an idempotent Close() path for the backup-pin sweeper

Validation:

  • go test ./kv -run 'Test(ActiveTimestampTracker|ApplyBackup|BackupPayload)' -count=1 -timeout=240s\n- go test ./kv -count=1 -timeout=300s\n- golangci-lint run ./kv --timeout=5m\n\n@codex 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 (b9e3e10):

  • kv/fsm.go

What to check, by subsystem:

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

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


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

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

@chatgpt-codex-connector

Copy link
Copy Markdown

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

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

ℹ️ 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/active_timestamp_tracker.go Outdated
Comment on lines +149 to +150
if _, exists := t.backupPins[pinID]; !exists && len(t.backupPins) >= t.maxBackupPins {
return errors.WithStack(ErrTooManyActiveBackups)

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 Reap expired pins before enforcing the active limit

When a replica still has maxBackupPins expired entries that Oldest() already ignores but the sweeper has not deleted yet, this raw len(t.backupPins) check returns ErrTooManyActiveBackups for a new valid backup pin. Since applyBackup treats that error as non-fatal, the Raft entry is advanced without recording the pin on that replica; if it later compacts or becomes the backup-serving leader, the backup read timestamp is not retained. Reap or exclude expired pins before applying the cap.

Useful? React with 👍 / 👎.

Comment thread kv/active_timestamp_tracker.go Outdated
Comment on lines +170 to +171
pin.deadline = deadline
t.backupPins[pinID] = pin

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 late renewals for expired backup pins

If a BackupExtend arrives after the previous deadline but before the sweeper has deleted the entry, Oldest() has already stopped honoring this pin, so compaction may have advanced past the backup's read timestamp during that gap. This assignment makes the expired pin active again and reports a successful renewal, allowing a backup to continue even though its retention fence was temporarily absent. Treat expired pins as missing/invalid before extending them.

Useful? React with 👍 / 👎.

Comment thread kv/backup_codec.go
Comment thread main.go
@bootjp
bootjp force-pushed the design/live-backup-pin-substrate branch from b9e3e10 to 91a7d04 Compare July 10, 2026 20:01
@bootjp

bootjp commented Jul 10, 2026

Copy link
Copy Markdown
Owner Author

Addressed the latest-head findings:

  • backup pin capacity checks now reap expired pins before enforcing the limit
  • expired or missing backup renewals now return ErrInvalidBackupPin instead of reactivating a stale retention fence
  • deadline_ms=0 decodes to time.Time{} so apply validation rejects it as invalid
  • backup pin tracker entries are scoped by Raft group, so one group's Release cannot remove another group's pin for the same pin_id

Validation:

  • go test ./kv -run 'Test(ActiveTimestampTracker|BackupCodec|ApplyBackup|BackupPayload)' -count=1 -timeout=240s\n- go test ./kv -count=1 -timeout=300s\n- golangci-lint run ./kv --timeout=5m\n\n@codex 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 (91a7d04):

  • kv/fsm.go

What to check, by subsystem:

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

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


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

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

@chatgpt-codex-connector

Copy link
Copy Markdown

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

@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: 91a7d04265

ℹ️ 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/fsm_backup.go Outdated
Comment on lines +27 to +28
if errors.Is(err, ErrTooManyActiveBackups) || errors.Is(err, ErrInvalidBackupPin) {
return 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.

P1 Badge Do not drop committed backup pins as proposal errors

When this branch is reached for the local-state-dependent errors (capacity, missing/expired renewals), applyBackup returns a plain error instead of haltErr, so the Raft engine treats the entry as applied and only the proposer observes the response. In a multi-replica group, one replica can hit this branch because its tracker still has an unexpired pin or its clock has already expired a renewal while the leader installed the new/extended fence; that replica then advances without retaining read_ts, and after a leader change or local compaction the backup can lose the version it was meant to protect.

Useful? React with 👍 / 👎.

Comment thread kv/active_timestamp_tracker.go Outdated
t.mu.Lock()
expired := t.reapExpiredBackupPinsLocked(time.Now())
key := newBackupPinKey(pinID, groupID)
if _, exists := t.backupPins[key]; !exists && len(t.backupPins) >= t.maxBackupPins {

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 Count backup capacity by pin ID, not group entry

Now that backupPins is keyed by (pin_id, groupID) and every shard FSM shares this tracker, len(t.backupPins) charges one slot per Raft group. A single logical backup fan-out with the same pin_id across more than 64 groups will fill the default limit and the next group’s BackupPin returns ErrTooManyActiveBackups, so large sharded deployments cannot start even one backup unless the limit is raised by group count.

Useful? React with 👍 / 👎.

@bootjp
bootjp force-pushed the design/live-backup-pin-substrate branch from 91a7d04 to b04b7fb Compare July 10, 2026 20:19
@bootjp

bootjp commented Jul 10, 2026

Copy link
Copy Markdown
Owner Author

Addressed latest-head review findings:

  • committed backup Pin entries now use an apply path that does not enforce the local active-backup cap, so a replica does not advance without installing the retention fence
  • committed backup Extend entries treat missing/expired local pins as no-op apply results instead of proposal errors, while direct Extend still rejects missing/expired renewals
  • the active-backup limit now counts logical pin IDs rather than per-group tracker entries, so one backup fan-out across groups consumes one capacity slot

Validation:

  • go test ./kv -run 'Test(ActiveTimestampTracker|BackupCodec|ApplyBackup|BackupPayload)' -count=1 -timeout=240s
  • go test ./kv -count=1 -timeout=300s
  • golangci-lint run ./kv --timeout=5m
  • git diff --check
  • git verify-commit HEAD

@codex 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 (b04b7fb):

  • kv/fsm.go

What to check, by subsystem:

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

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


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

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

@chatgpt-codex-connector

Copy link
Copy Markdown

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

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: b04b7fb235

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

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
main.go (1)

389-416: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

readTracker.Close() を shutdown cleanup に追加してください。 ActiveTimestampTracker はスイーパー goroutine を持つため、cleanup.Add(readTracker.Close) で終了時に止める必要があります。

🤖 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 `@main.go` around lines 389 - 416, Register readTracker.Close with the shutdown
cleanup after creating the ActiveTimestampTracker, using
cleanup.Add(readTracker.Close), so its sweeper goroutine is stopped during
termination.
🧹 Nitpick comments (1)
kv/fsm.go (1)

372-373: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

applyBackup(data)applyHLCLease(data[1:]) でオペコードバイトの扱いが一貫していません。

applyHLCLeasedata[1:](オペコード除外)を渡すのに対し、applyBackupdata(オペコード含む)をそのまま渡しています。decodeBackupEntry がフルデータを期待しているため機能上は問題ありませんが、将来的な保守で混乱を招く可能性があります。

🤖 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/fsm.go` around lines 372 - 373, applyBackup と applyHLCLease
で入力データのオペコード除外方法を統一してください。applyBackup 呼び出し側では applyHLCLease と同様にオペコードバイトを除いた
data[1:] を渡し、decodeBackupEntry の期待する入力形式も確認して関連処理を一貫させてください。
🤖 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.

Outside diff comments:
In `@main.go`:
- Around line 389-416: Register readTracker.Close with the shutdown cleanup
after creating the ActiveTimestampTracker, using cleanup.Add(readTracker.Close),
so its sweeper goroutine is stopped during termination.

---

Nitpick comments:
In `@kv/fsm.go`:
- Around line 372-373: applyBackup と applyHLCLease
で入力データのオペコード除外方法を統一してください。applyBackup 呼び出し側では applyHLCLease と同様にオペコードバイトを除いた
data[1:] を渡し、decodeBackupEntry の期待する入力形式も確認して関連処理を一貫させてください。

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b885a775-9adc-47fb-833c-e1990bc6fa44

📥 Commits

Reviewing files that changed from the base of the PR and between cd58354 and b04b7fb.

📒 Files selected for processing (11)
  • kv/active_timestamp_tracker.go
  • kv/active_timestamp_tracker_test.go
  • kv/backup_codec.go
  • kv/backup_codec_test.go
  • kv/fsm.go
  • kv/fsm_backup.go
  • kv/fsm_backup_test.go
  • main.go
  • main_bootstrap_e2e_test.go
  • main_encryption_write_wiring.go
  • multiraft_runtime_test.go

@bootjp

bootjp commented Jul 10, 2026

Copy link
Copy Markdown
Owner Author

Addressed latest-head review findings:

  • registered readTracker.Close with the shutdown cleanup stack so the backup-pin sweeper stops on termination
  • made backup FSM apply pass payload bytes consistently with the other reserved-opcode apply paths while preserving the full wire encoder/decoder contract

Validation:

  • go test ./kv -run 'Test(ActiveTimestampTracker|BackupCodec|ApplyBackup|BackupPayload)' -count=1 -timeout=240s\n- go test ./kv -count=1 -timeout=300s\n- go test . -count=1 -timeout=180s\n- golangci-lint run ./kv --timeout=5m\n- golangci-lint run . ./kv --timeout=5m\n- git diff --check\n- git verify-commit HEAD\n\n@codex 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 (a39218b):

  • kv/fsm.go

What to check, by subsystem:

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

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


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

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

@chatgpt-codex-connector

Copy link
Copy Markdown

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

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: a39218b5b9

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

@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 (4d56518):

  • kv/fsm.go

What to check, by subsystem:

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

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


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

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

@chatgpt-codex-connector

Copy link
Copy Markdown

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

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

ℹ️ 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/active_timestamp_tracker.go Outdated
}
return errors.WithStack(ErrInvalidBackupPin)
}
pin.deadline = deadline

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the later backup deadline on renewals

When two BackupExtend proposals for the same pin are in flight, or a retry of an older renewal commits after a newer one, this unconditional assignment can move the deadline backwards. If that stale deadline expires before the next renewal, Oldest() stops honoring the pin and local compaction can advance past the backup read timestamp while the backup is still running; apply should keep max(existing deadline, requested deadline) rather than shortening it.

Useful? React with 👍 / 👎.

@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 (2f77c32):

  • kv/fsm.go

What to check, by subsystem:

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

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


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

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

The revert-check loop reinserted the same comment block on each pass, so
the ClearBackupPinsForGroup call in Restore carried three verbatim copies.
Cosmetic only -- no behaviour change.

Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
@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-29T07:00:01.698795Z afe538e 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.

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

  • distribution/engine.go
  • kv/coordinator.go
  • kv/fsm.go
  • kv/sharded_coordinator.go

What to check, by subsystem:

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

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


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

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

@chatgpt-codex-connector

Copy link
Copy Markdown

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b21643474f

ℹ️ 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 internal/backup/live.go
Comment on lines +38 to +41
case hasAnyBackupPrefix(key,
S3BucketMetaPrefix, S3ObjectManifestPrefix,
S3UploadMetaPrefix, S3UploadPartPrefix, S3BlobPrefix, S3GCUploadPrefix, S3RoutePrefix,
):

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 Include S3 chunk backing records in live scans

When S3 blob offload is enabled, object manifests reference records under S3ChunkRefPrefix and payloads under S3ChunkBlobPrefix, but neither prefix is recognized by this S3 branch. backupKeySelected consequently rejects both as unscoped even when no scope filter is requested (adapter/admin_backup.go:638-643), while S3Encoder requires them to reconstruct offloaded objects. Such backups stream the manifest without its backing chunks and fail finalization with missing/incomplete blob data; route chunk refs to their bucket scope and ensure their referenced content-addressed blobs are included.

Useful? React with 👍 / 👎.

An offloaded object's manifest names its payload through !s3|chunkref| rows,
but ScopeForKey did not recognise that family, so backupKeySelected rejected
them as unscoped and the dump carried manifests with no chunk references.
The offline decoder already routes the family; only the live classifier was
missing it. The reference key carries the bucket, so it scopes like every
other object-level row.

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 (09fc754):

  • distribution/engine.go
  • kv/coordinator.go
  • kv/fsm.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 29, 2026

Copy link
Copy Markdown
Owner Author

internal/backup/live.go:41 (P1, "Include S3 chunk backing records in live scans") — half fixed in 09fc7541, half is a design decision I am not making unilaterally. Details, because the two halves are not the same problem:

Chunk references — fixed. Verified the claim: ScopeForKey's S3 branch listed bucket meta / manifest / upload meta / upload part / blob / gc / route but not !s3|chunkref|, so backupKeySelected rejected those rows as unscoped and the dump carried object manifests with no references to their payload. The offline decoder already routes the family (decode.gorouteS3ChunkRef), so only the live classifier was missing it. The reference key carries the bucket, so it scopes like every other object-level row. Test in TestScopeForKey; revert-checked.

Content blobs — not fixed, and I do not think the suggested shape works. Two things block it, both checkable:

  1. !s3|chunkblob|<sha256> is content-addressed and carries no bucket. s3keys.ChunkBlobKey builds the key from the digest alone, so there is nothing in it to derive a Scope{Adapter: "s3", Name: <bucket>} from. The current Scope model cannot express these rows; that is a structural gap, not a missing case in a switch.
  2. The blobs are peer-local. docs/design/2026_04_25_partial_s3_raft_blob_offload.md §3.1 states it directly — "chunkblob is not written through Raft. It is written directly to Pebble on the receiving node and pulled by peers via the new fetch protocol" — and store/lsm_store.go's localAuxiliaryMVCCPrefixes classifies them that way. So the node serving StreamBackup is not guaranteed to hold every blob its manifests reference; scanning its local store can produce a dump that looks complete and is not. (This is the same property that made me exclude the family from migration export in store: add migration version import export #1085.)

A live backup of offloaded objects therefore needs the producer to fetch blobs by digest over S3BlobFetch rather than expect them in a local range scan, plus a scope model that can hold non-bucket-scoped rows. That is a producer feature with a wire-format component, which this repo's CLAUDE.md wants a *_proposed_* design doc for before implementation. I would rather say that plainly than add the prefix to a switch and have the dump fail later at verifyChunkCompleteness on whichever node happened to be missing a blob.

go test -race ./internal/backup/... ./adapter/ green (backup 2s, adapter 800s); golangci-lint run ./internal/backup/... → 0 issues.

@codex review
@claude review

@claude

claude Bot commented Aug 29, 2026

Copy link
Copy Markdown

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


Review: 09fc754 (backup: scope S3 chunk references to their bucket)

  • Read trigger comment and gather context
  • Verify internal/backup/live.go chunk-ref scoping fix (S3ChunkRefPrefix added to ScopeForKey)
  • Verify the offline decoder already routes S3ChunkRefPrefix (as claimed)
  • Verify the chunk-blob deferral reasoning (content-addressed, peer-local, not in Raft)
  • Check new test coverage in internal/backup/live_test.go
  • Post review findings

Part 1 — chunk-ref fix is correct. S3ChunkRefPrefix (!s3|chunkref|) is now in the ScopeForKey S3 prefix list (internal/backup/live.go:40) and has its own case (:90-96) that calls s3keys.ParseChunkRefKey and scopes the row to the bucket the key encodes, same pattern as the other object-level S3 cases. This was genuinely missing before — !s3|chunkref| was absent from both the prefix list and the switch, so ScopeForKey fell through to (Scope{}, false, nil) and backupKeySelected (adapter/admin_backup.go:638-643) treated the row as unscoped and dropped it, matching the originally reported failure mode (manifest present, references missing).

Confirmed the decoder-side claim too: routeS3ChunkRef was already wired into the offline dispatch table (internal/backup/decode.go:329) before this commit — this fix only touches the live-scan classifier (ScopeForKey), which is a separate code path from the import-side decode.go dispatcher. So the "only the live classifier was missing it" framing is accurate, not just an assumption.

Test (TestScopeForKey, new case in internal/backup/live_test.go:29-37): builds a real key via s3keys.ChunkRefKey("photos", 1, "cat.jpg", "", 0, 3) and asserts Scope{Adapter: "s3", Name: "photos"}, scoped: true. This exercises the actual encode→decode round trip rather than a hand-built byte string, so it would have failed before this commit (key present, but not in the prefix switch → falls through to unscoped).

Part 2 — the chunk-blob deferral is justified, not just asserted. I checked both claims independently:

  • s3keys.ChunkBlobKey (internal/s3keys/chunkref.go:75) builds !s3|chunkblob|<sha256> from the digest alone — no bucket, object, or any other field encoded. There is genuinely nothing in that key for ScopeForKey to recover a Scope{Adapter: "s3", Name: <bucket>} from; the current Scope model has no non-bucket-scoped representation.
  • docs/design/2026_04_25_partial_s3_raft_blob_offload.md:133 states directly: "chunkblob is not written through Raft. It is written directly to Pebble on the receiving node...", and store/lsm_store.go:216-217 lists !s3|chunkblob| in localAuxiliaryMVCCPrefixes. So a StreamBackup scan of local Pebble state is not guaranteed to see every blob a manifest on that node references — the correct fix needs the backup producer to fetch by digest via the existing S3BlobFetch peer protocol (adapter/s3_blob_fetch.go), not a local range scan. That's new wire-protocol behavior in the backup producer, which is exactly the class of change this repo's CLAUDE.md requires a *_proposed_* design doc for before implementation ("new control-plane RPC... write a *_proposed_*.md design doc first"). Declining to bolt on a same-shape fix here is the right call rather than shipping something that fails later and more confusingly at verifyChunkCompleteness.

Limitation: go test/go build require interactive approval unavailable in this sandboxed session (same limitation noted throughout this PR's review thread), so I verified statically — tracing ParseChunkRefKey/ChunkRefKey round-trip, the decode.go dispatch table, localAuxiliaryMVCCPrefixes, and the design doc text directly rather than running the test suite. The evidence posted in the trigger comment (go test -race ./internal/backup/... ./adapter/ green, golangci-lint run ./internal/backup/... clean) is consistent with what this diff should produce.

No blocking issues found. The chunk-ref half is a correct, well-tested fix; the chunk-blob half is a defensible scope decision backed by the actual peer-local/content-addressed design, not a corner cut.
· branch design/live-backup-pin-substrate

@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: 09fc7541fe

ℹ️ 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/fsm.go
// capacity, until its deadline lapsed. The durable floor below is the part
// that does survive, and it is reloaded right after.
if f.readTracker != nil {
f.readTracker.ClearBackupPinsForGroup(f.shardGroupID)

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 Clear stale capacity reservations during restore

Fresh evidence in this fix shows that restore clears only entries keyed by the shard's actual group ID, while BackupReserve is stored under synthetic group ID 0 via PinWithDeadline. If the control-group follower applied a reservation but missed the later unreserve before installing a post-release snapshot, that reservation survives this call and continues consuming a global active-backup slot until its deadline; repeated restores can therefore cause ErrTooManyActiveBackups when no backup is active. Reconcile the reservation owned by the restoring control group as well.

Useful? React with 👍 / 👎.

@bootjp

bootjp commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

kv/fsm.go:627 (P2, "Clear stale capacity reservations during restore") — verified, and the finding is right about my fix being incomplete, but the obvious repair has a collision hazard that I would rather flag than guess through.

Confirmed by reading the apply path: applyDecodedBackup stores pins with ApplyPinWithDeadlineForGroup(..., f.shardGroupID, ...) but stores a reservation with PinWithDeadline(...), which is PinWithDeadlineForGroup(pinID, 0, ...) — the synthetic group 0. My Restore clear uses ClearBackupPinsForGroup(f.shardGroupID), so on a control group whose id is not 0 the reservation survives exactly as described, and keeps consuming a global capacity slot until its deadline.

The repair that looks obvious — key reservations by the applying FSM's shardGroupID so the existing clear covers them — does not work: BeginBackup uses the same pinID for the reserve entry and the pin entry (adapter/admin_backup.go:339 and :347). Keyed by the real group id they would collapse onto one (pinID, groupID) map entry on the control group, so the pin would overwrite the reservation and the later unreserve would find nothing. The synthetic group 0 is load-bearing: it is what keeps "capacity reservation" and "pin on group N" as separate entries, and reserve is the one that enforces the limit (PinWithDeadlineenforceLimit=true) while pins apply without it.

So the real options are:

  1. Give reservations their own map in ActiveTimestampTracker, keyed by (pinID, owningGroupID), so restore can clear the restoring group's reservations without touching another group's. Needs the capacity count redefined over the two maps — today ActiveBackupPinCount counts reservation and pins together, so a straight split changes what ErrTooManyActiveBackups measures.
  2. Carry the owning group in the reserve entry so the FSM can recognise "this group owns reservations" on restore. That is a field on an internal Raft entry — small, but it is a wire change to a replicated command.
  3. Clear group 0 from any restoring group. Simple and wrong: a data group's restore would wipe an in-flight backup's reservation while its pins remain, under-counting capacity for a live backup.

I did not implement any of them because each redefines what a capacity slot is, and that is the part you own. Say which shape you want and I will land it with the round-trip test.

@codex review
@claude review

@claude

claude Bot commented Aug 29, 2026

Copy link
Copy Markdown

Claude finished @bootjp's task in 8m 41s —— View job


Review: kv/fsm.go:627 capacity-reservation restore gap

  • Read trigger comment and gather context
  • Verify the reservation/pin group-0 collision analysis against current code
  • Decide which fix shape to implement
  • Implement fix with round-trip regression test
  • Static self-review (could not run go test/lint in this sandbox — see note)
  • Push commit — blocked, see below

Verified the analysis is correct by reading the code directly: applyDecodedBackup (kv/fsm_backup.go:59-63, pre-fix) called f.readTracker.PinWithDeadline/ReleaseBackupPin for backupSubtypeReserve/Unreserve, both of which hardcode group 0 (PinWithDeadlineForGroup(pinID, 0, ...)). Restore (kv/fsm.go:627) calls ClearBackupPinsForGroup(f.shardGroupID). On any control group whose real ID isn't 0, those never matched, so a reservation that outlived its BackupRelease (compacted away before this replica restores from a later snapshot) survived restore and kept consuming a capacity slot until its deadline — exactly as described.

Went with option 1 (separate tracker slot for reservations), refined to avoid a second map: added a reservation bool discriminant to the existing backupPinKey{id, groupID} struct (kv/active_timestamp_tracker.go). Confirmed your collision concern is real and why option "just use f.shardGroupID for the reserve entry" doesn't work on its own: pinBackupGroups proposes the reserve entry only to groups[0] (the control group), but that same control group also receives the fan-out BackupPin entry for the identical pin_id — without the discriminant those would collide into one map entry keyed (pinID, controlGroupID).

Changes:

  • backupPinKey gains reservation bool; newBackupReservationKey builds the reservation variant.
  • New ReserveWithDeadlineForGroup(pinID, groupID, readTS, deadline) / UnreserveForGroup(pinID, groupID), sharing the existing pinAtKey/releaseKey locked helpers (refactored pinWithDeadlineForGrouppinAtKey to take a key instead of recomputing one, so pin and reserve paths share the same capacity-check/merge/sweep logic).
  • kv/fsm_backup.go's backupSubtypeReserve/Unreserve cases now call ReserveWithDeadlineForGroup(..., f.shardGroupID, ...) / UnreserveForGroup(..., f.shardGroupID) instead of the group-0 methods.
  • ClearBackupPinsForGroup needed no logic change — it already matches on key.groupID regardless of the new discriminant, so it now correctly reconciles both a group's stale pin and its stale reservation in one sweep. Updated its doc comment and the BackupReserveEntry/BackupUnreserveEntry "group-zero" comments in kv/backup_codec.go, which were describing the design this fix removes.
  • PinWithDeadline/PinWithDeadlineForGroup/ReleaseBackupPin/ReleaseBackupPinForGroup (the group-0-by-default, non-reservation API) are unchanged in signature and behavior — they're exercised extensively by existing tests as generic pin mechanics, unrelated to this bug.

Tests added (would fail against the pre-fix tree):

  • kv/active_timestamp_tracker_test.go: TestActiveTimestampTrackerReservationAndPinAreDistinctSlots — reserve + pin for the same (pinID, groupID) occupy distinct slots (count=2, not 1); releasing the pin alone leaves the reservation still enforcing the limit; ClearBackupPinsForGroup clears both.
  • kv/fsm_backup_test.go: TestFSMRestoreClearsStaleBackupCapacityReservation — a control group applies its own BackupReserve + BackupPin for one pin ID, restores from a snapshot holding neither, and must end at ActiveBackupPinCount() == 0; then proves the freed slot by successfully reserving a new pin ID under a max=1 limit (checked on the actual Apply response, not just haltApplyOf, since ErrTooManyActiveBackups is a non-fatal response rather than a halt error).

Traced both by hand against the pre-fix code: pre-fix, the reservation lived at (pinID, group=0) while the pin lived at (pinID, group=3) — already non-colliding since production always hardcoded reserve to 0, so the count assertion before restore (==2) would already hold pre-fix; the fix is proven by the post-restore assertion (==0), which fails pre-fix because ClearBackupPinsForGroup(3) never touches the group-0 key.

Caller/compile audit: grepped every call site of the touched methods (PinWithDeadline, PinWithDeadlineForGroup, ApplyPinWithDeadlineForGroup, ReleaseBackupPin(ForGroup), ClearBackupPinsForGroup) across both production and test code, and confirmed the new backupPinKey{...} fields are only ever built via the two named-field constructors (no positional literals elsewhere that a struct-field addition could silently break).

I could not execute go build/go test/golangci-lint in this sandboxed sessiongo/gh invocations require interactive approval that wasn't available here, the same limitation earlier reviews on this PR hit. This is based on careful static tracing, not a live run.

Push blocked

Committed locally (backup: scope capacity reservations to their control group, not group 0), but pushing to origin design/live-backup-pin-substrate fails:

remote: Permission to bootjp/elastickv.git denied to github-actions[bot].
fatal: unable to access 'https://github.com/bootjp/elastickv.git/': The requested URL returned error: 403

Reproduced twice, not transient. The commit only exists in this session's ephemeral checkout and will be lost once it ends. Pasting the full diff here so nothing is lost — please apply it directly, or let me know if there's a permission I should ask you to grant and I'll retry the push.

Full diff (6 files, +163/-28)
diff --git a/kv/active_timestamp_tracker.go b/kv/active_timestamp_tracker.go
index 1aa9ef3..756cde7 100644
--- a/kv/active_timestamp_tracker.go
+++ b/kv/active_timestamp_tracker.go
@@ -34,15 +34,26 @@ type backupDeadlinePin struct {
 	deadline time.Time
 }
 
+// backupPinKey identifies one tracker slot. reservation distinguishes a
+// group's capacity reservation from an ordinary pin on that same group: the
+// control group that owns a backup's BackupReserve entry also applies that
+// backup's BackupPin entry under its own shardGroupID, and both entries carry
+// the same pin ID, so without this discriminant the reservation and the pin
+// would collide on one map entry.
 type backupPinKey struct {
-	id      BackupPinID
-	groupID uint64
+	id          BackupPinID
+	groupID     uint64
+	reservation bool
 }
 
 func newBackupPinKey(pinID BackupPinID, groupID uint64) backupPinKey {
 	return backupPinKey{id: pinID, groupID: groupID}
 }
 
+func newBackupReservationKey(pinID BackupPinID, groupID uint64) backupPinKey {
+	return backupPinKey{id: pinID, groupID: groupID, reservation: true}
+}
+
 type ActiveTimestampTrackerOption func(*ActiveTimestampTracker)
 
 func WithActiveTimestampTrackerMaxBackupPins(maxPins int) ActiveTimestampTrackerOption {
@@ -205,14 +216,22 @@ func (t *ActiveTimestampTracker) PinWithDeadline(pinID BackupPinID, readTS uint6
 }
 
 func (t *ActiveTimestampTracker) PinWithDeadlineForGroup(pinID BackupPinID, groupID uint64, readTS uint64, deadline time.Time) error {
-	return t.pinWithDeadlineForGroup(pinID, groupID, readTS, deadline, true)
+	return t.pinAtKey(newBackupPinKey(pinID, groupID), pinID, readTS, deadline, true)
 }
 
 func (t *ActiveTimestampTracker) ApplyPinWithDeadlineForGroup(pinID BackupPinID, groupID uint64, readTS uint64, deadline time.Time) error {
-	return t.pinWithDeadlineForGroup(pinID, groupID, readTS, deadline, false)
+	return t.pinAtKey(newBackupPinKey(pinID, groupID), pinID, readTS, deadline, false)
+}
+
+// ReserveWithDeadlineForGroup records groupID's capacity reservation for
+// pinID. It is a distinct tracker slot from any pin the same group later
+// applies for pinID (see backupPinKey), always enforces the active-backup
+// limit, and is called only by the FSM belonging to a backup's control group.
+func (t *ActiveTimestampTracker) ReserveWithDeadlineForGroup(pinID BackupPinID, groupID uint64, readTS uint64, deadline time.Time) error {
+	return t.pinAtKey(newBackupReservationKey(pinID, groupID), pinID, readTS, deadline, true)
 }
 
-func (t *ActiveTimestampTracker) pinWithDeadlineForGroup(pinID BackupPinID, groupID uint64, readTS uint64, deadline time.Time, enforceLimit bool) error {
+func (t *ActiveTimestampTracker) pinAtKey(key backupPinKey, pinID BackupPinID, readTS uint64, deadline time.Time, enforceLimit bool) error {
 	if t == nil {
 		return nil
 	}
@@ -221,7 +240,6 @@ func (t *ActiveTimestampTracker) pinWithDeadlineForGroup(pinID BackupPinID, grou
 	}
 	t.mu.Lock()
 	expired := t.reapExpiredBackupPinsLocked(time.Now())
-	key := newBackupPinKey(pinID, groupID)
 	if enforceLimit && !t.hasBackupPinIDLocked(pinID) && t.activeBackupPinIDCountLocked() >= t.maxBackupPins {
 		t.mu.Unlock()
 		t.logExpiredBackupPins(expired)
@@ -302,21 +320,35 @@ func (t *ActiveTimestampTracker) ReleaseBackupPin(pinID BackupPinID) {
 }
 
 func (t *ActiveTimestampTracker) ReleaseBackupPinForGroup(pinID BackupPinID, groupID uint64) {
-	if t == nil || pinID.IsZero() {
+	t.releaseKey(newBackupPinKey(pinID, groupID))
+}
+
+// UnreserveForGroup releases groupID's capacity reservation for pinID. It is
+// the counterpart to ReserveWithDeadlineForGroup and does not touch any pin
+// the same group holds for pinID.
+func (t *ActiveTimestampTracker) UnreserveForGroup(pinID BackupPinID, groupID uint64) {
+	t.releaseKey(newBackupReservationKey(pinID, groupID))
+}
+
+func (t *ActiveTimestampTracker) releaseKey(key backupPinKey) {
+	if t == nil || key.id.IsZero() {
 		return
 	}
 	t.mu.Lock()
 	defer t.mu.Unlock()
-	delete(t.backupPins, newBackupPinKey(pinID, groupID))
-}
-
-// ClearBackupPinsForGroup drops every volatile backup pin recorded for one
-// Raft group. Snapshot restore is the caller: a follower that applied a
-// BackupPin but not its BackupRelease can catch up from a snapshot the leader
-// took after the release, and that snapshot legitimately carries no pin state
-// while both log entries are already compacted away. Keeping the old pin would
-// block compaction and snapshots on that replica, and hold backup capacity,
-// until its deadline lapsed.
+	delete(t.backupPins, key)
+}
+
+// ClearBackupPinsForGroup drops every volatile backup pin AND capacity
+// reservation recorded for one Raft group -- both key kinds carry this
+// groupID, so one sweep reconciles whichever of the two (or both, on a
+// backup's control group) this replica was holding. Snapshot restore is the
+// caller: a follower that applied a BackupPin or BackupReserve but not its
+// matching BackupRelease/BackupUnreserve can catch up from a snapshot the
+// leader took after the release, and that snapshot legitimately carries no
+// pin state while both log entries are already compacted away. Keeping the
+// old entry would block compaction and snapshots on that replica, and hold
+// backup capacity, until its deadline lapsed.
 func (t *ActiveTimestampTracker) ClearBackupPinsForGroup(groupID uint64) {
 	if t == nil {
 		return
diff --git a/kv/active_timestamp_tracker_test.go b/kv/active_timestamp_tracker_test.go
index df0808f..2b4fc19 100644
--- a/kv/active_timestamp_tracker_test.go
+++ b/kv/active_timestamp_tracker_test.go
@@ -219,6 +219,53 @@ func TestActiveTimestampTrackerBackupPinsAreScopedByRaftGroup(t *testing.T) {
 	require.Equal(t, 1, tracker.ActiveBackupPinCount())
 }
 
+// A backup's control group applies both a capacity reservation (via
+// ReserveWithDeadlineForGroup) and its own data-group pin (via
+// ApplyPinWithDeadlineForGroup) for the same pin ID and the same group ID.
+// They must occupy distinct tracker slots: releasing one must not touch the
+// other, and ClearBackupPinsForGroup -- snapshot restore's cleanup path --
+// must clear both.
+func TestActiveTimestampTrackerReservationAndPinAreDistinctSlots(t *testing.T) {
+	tracker := NewActiveTimestampTracker(
+		WithActiveTimestampTrackerSweepInterval(0),
+		WithActiveTimestampTrackerMaxBackupPins(1),
+	)
+	const groupID = uint64(3)
+	pinID := backupTrackerTestPinID(1)
+	deadline := time.Now().Add(time.Hour)
+
+	require.NoError(t, tracker.ReserveWithDeadlineForGroup(pinID, groupID, 75, deadline))
+	require.NoError(t, tracker.ApplyPinWithDeadlineForGroup(pinID, groupID, 75, deadline))
+	require.Equal(t, 2, tracker.ActiveBackupPinCount(),
+		"the reservation and the pin must not collide into one map entry")
+
+	// The reservation alone enforces the active-backup limit: a second
+	// distinct pin ID must still be rejected while the reservation lives,
+	// proving UnreserveForGroup and ReleaseBackupPinForGroup are not aliases.
+	require.ErrorIs(t,
+		tracker.ReserveWithDeadlineForGroup(backupTrackerTestPinID(2), groupID, 80, deadline),
+		ErrTooManyActiveBackups)
+
+	tracker.ReleaseBackupPinForGroup(pinID, groupID)
+	require.Equal(t, 1, tracker.ActiveBackupPinCount(),
+		"releasing the pin must not release the reservation")
+	require.ErrorIs(t,
+		tracker.ReserveWithDeadlineForGroup(backupTrackerTestPinID(2), groupID, 80, deadline),
+		ErrTooManyActiveBackups,
+		"the reservation must still hold capacity after the pin alone was released")
+
+	tracker.UnreserveForGroup(pinID, groupID)
+	require.Equal(t, 0, tracker.ActiveBackupPinCount())
+
+	// Re-seed both slots to prove ClearBackupPinsForGroup reconciles the
+	// whole group in one sweep, as snapshot restore relies on.
+	require.NoError(t, tracker.ReserveWithDeadlineForGroup(pinID, groupID, 75, deadline))
+	require.NoError(t, tracker.ApplyPinWithDeadlineForGroup(pinID, groupID, 75, deadline))
+	require.Equal(t, 2, tracker.ActiveBackupPinCount())
+	tracker.ClearBackupPinsForGroup(groupID)
+	require.Equal(t, 0, tracker.ActiveBackupPinCount())
+}
+
 func TestActiveTimestampTrackerDuplicatePinApplyIsMonotonic(t *testing.T) {
 	tracker := NewActiveTimestampTracker(WithActiveTimestampTrackerSweepInterval(0))
 	pinID := backupTrackerTestPinID(1)
diff --git a/kv/backup_codec.go b/kv/backup_codec.go
index ad0edb0..2cf7ece 100644
--- a/kv/backup_codec.go
+++ b/kv/backup_codec.go
@@ -55,11 +55,13 @@ type BackupReleaseEntry struct {
 	PinID BackupPinID
 }
 
-// BackupReserveEntry is committed through one deterministic Raft group before
-// fan-out. Its group-zero tracker record serializes the cluster-wide pin cap.
+// BackupReserveEntry is committed through one deterministic Raft group (the
+// backup's control group) before fan-out. The applying FSM's tracker record
+// serializes the cluster-wide pin cap in a slot distinct from that same
+// group's BackupPin record for the same pin ID (see backupPinKey).
 type BackupReserveEntry = BackupPinEntry
 
-// BackupUnreserveEntry releases the group-zero capacity reservation.
+// BackupUnreserveEntry releases the control group's capacity reservation.
 type BackupUnreserveEntry = BackupReleaseEntry
 
 type backupEntry struct {
diff --git a/kv/fsm.go b/kv/fsm.go
index e31d682..dbebed0 100644
--- a/kv/fsm.go
+++ b/kv/fsm.go
@@ -617,12 +617,13 @@ func (f *kvFSM) Restore(r io.Reader) error {
 	if err := f.store.Restore(io.NopCloser(br)); err != nil {
 		return errors.WithStack(err)
 	}
-	// Volatile pins do not travel in a snapshot, and the entries that would
-	// have released them are compacted away by the time this replica installs
-	// one. A pin this node applied before falling behind would otherwise keep
-	// blocking compaction and snapshots here, and keep consuming backup
-	// capacity, until its deadline lapsed. The durable floor below is the part
-	// that does survive, and it is reloaded right after.
+	// Volatile pins and capacity reservations do not travel in a snapshot, and
+	// the entries that would have released them are compacted away by the time
+	// this replica installs one. A pin or reservation this node applied before
+	// falling behind would otherwise keep blocking compaction and snapshots
+	// here, and keep consuming backup capacity, until its deadline lapsed. The
+	// durable floor below is the part that does survive, and it is reloaded
+	// right after.
 	if f.readTracker != nil {
 		f.readTracker.ClearBackupPinsForGroup(f.shardGroupID)
 	}
diff --git a/kv/fsm_backup.go b/kv/fsm_backup.go
index 480350f..eab5e17 100644
--- a/kv/fsm_backup.go
+++ b/kv/fsm_backup.go
@@ -57,9 +57,9 @@ func (f *kvFSM) applyDecodedBackup(entry backupEntry) error {
 		f.readTracker.ReleaseBackupPinForGroup(entry.release.PinID, f.shardGroupID)
 		return nil
 	case backupSubtypeReserve:
-		return f.readTracker.PinWithDeadline(entry.pin.PinID, entry.pin.ReadTS, entry.pin.Deadline)
+		return f.readTracker.ReserveWithDeadlineForGroup(entry.pin.PinID, f.shardGroupID, entry.pin.ReadTS, entry.pin.Deadline)
 	case backupSubtypeUnreserve:
-		f.readTracker.ReleaseBackupPin(entry.release.PinID)
+		f.readTracker.UnreserveForGroup(entry.release.PinID, f.shardGroupID)
 		return nil
 	default:
 		return ErrBackupWireSubtype
diff --git a/kv/fsm_backup_test.go b/kv/fsm_backup_test.go
index 7ec5f18..9dfa368 100644
--- a/kv/fsm_backup_test.go
+++ b/kv/fsm_backup_test.go
@@ -555,3 +555,56 @@ func TestFSMRestoreKeepsOtherGroupsBackupPins(t *testing.T) {
 	require.Equal(t, uint64(60), tracker.OldestBackupForGroup(9),
 		"another group's pin must survive this group's restore")
 }
+
+// A backup's control group applies both the cluster-wide capacity
+// reservation (BackupReserve, previously tracked at a hardcoded group 0) and
+// its own data-group pin (BackupPin, tracked at f.shardGroupID) for the same
+// pin ID. Restore must clear both, not just the pin: leaving the reservation
+// behind would keep consuming a capacity slot -- and, before this fix, would
+// go on doing so forever, since ClearBackupPinsForGroup(f.shardGroupID) never
+// matched the reservation's hardcoded group-0 key on any group whose real ID
+// was not 0.
+func TestFSMRestoreClearsStaleBackupCapacityReservation(t *testing.T) {
+	const controlGroupID = uint64(3)
+	tracker := NewActiveTimestampTracker(
+		WithActiveTimestampTrackerSweepInterval(0),
+		WithActiveTimestampTrackerMaxBackupPins(1),
+	)
+	fsm := newBackupTestFSMWithGroup(t, tracker, controlGroupID)
+	pinID := backupTrackerTestPinID(1)
+	deadline := time.Now().Add(time.Hour)
+
+	require.NoError(t, haltApplyOf(fsm.Apply(EncodeBackupReserveEntry(BackupReserveEntry{
+		PinID: pinID, ReadTS: 75, Deadline: deadline,
+	}))))
+	require.NoError(t, haltApplyOf(fsm.Apply(EncodeBackupPinEntry(BackupPinEntry{
+		PinID: pinID, ReadTS: 75, Deadline: deadline,
+	}))))
+	require.Equal(t, 2, tracker.ActiveBackupPinCount(),
+		"the reservation and the pin must occupy distinct tracker slots")
+
+	// A snapshot taken on a replica holding neither, restored here.
+	source := newBackupTestFSMWithGroup(t, NewActiveTimestampTracker(
+		WithActiveTimestampTrackerSweepInterval(0)), controlGroupID)
+	snapshot, err := source.Snapshot()
+	require.NoError(t, err)
+	var raw bytes.Buffer
+	_, err = snapshot.WriteTo(&raw)
+	require.NoError(t, err)
+	require.NoError(t, snapshot.Close())
+
+	require.NoError(t, fsm.Restore(bytes.NewReader(raw.Bytes())))
+	require.Equal(t, 0, tracker.ActiveBackupPinCount(),
+		"a restored snapshot must not leave this group's stale reservation or pin behind")
+
+	// The freed capacity slot is provable: under the max-1 limit configured
+	// above, a stale reservation left behind would make this Reserve fail.
+	// ErrTooManyActiveBackups is a non-fatal apply response, not a HaltApply
+	// error, so it must be checked on resp itself -- haltApplyOf alone would
+	// not see it.
+	resp := fsm.Apply(EncodeBackupReserveEntry(BackupReserveEntry{
+		PinID: backupTrackerTestPinID(2), ReadTS: 90, Deadline: deadline,
+	}))
+	require.NoError(t, haltApplyOf(resp))
+	require.Nil(t, resp, "reserving a new pin must succeed once the stale reservation is cleared")
+}

@codex review
· branch design/live-backup-pin-substrate

@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: 09fc7541fe

ℹ️ 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/fsm.go
if f.readTracker != nil {
f.readTracker.ClearBackupPinsForGroup(f.shardGroupID)
}
f.backupFloorLoadErr = f.reloadBackupTimestampFloor(context.Background())

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 Invalidate cached TSO windows after restoring the floor

When a running replica installs a snapshot whose persisted backup floor is above a TSO batch it cached before falling behind, this reload updates only the FSM atomic; observeStoreLastCommitTS advances the HLC but does not invoke ShardedCoordinator.ObserveTimestampFloor or invalidateTimestampWindow. If that replica later handles writes, it can continue issuing cached timestamps at or below the restored floor, causing every such proposal to be rejected by verifyBackupTimestampFloor until the stale window is exhausted. Propagate the successfully restored floor through the coordinator's floor observer so cached timestamp windows are discarded.

Useful? React with 👍 / 👎.

Comment thread internal/backup/redis_set.go Outdated
Comment on lines +142 to +143
if len(rest) == 0 {
return nil, false

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 Accept empty Redis keys in legacy collection blobs

When an unmigrated legacy hash or set uses the empty Redis key, its storage key is exactly !redis|hash| or !redis|set|; the command paths pass cmd.Args[1] through without rejecting an empty key, and the equivalent legacy zset parser also accepts an empty remainder. This shared helper instead classifies that valid record as malformed, so HandleHashLegacyBlob or HandleSetLegacyBlob makes the logical backup decoder fail on data that the Redis API can create. Distinguish a missing prefix from a present prefix with an empty user key.

Useful? React with 👍 / 👎.

Comment thread kv/backup_scan.go
key []byte,
ts uint64,
) ([]byte, error) {
rkey := routeKey(key)

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 Resolve partitioned transaction status through its owner

Fresh evidence after the captured-route fix is that this path still calls routeKey directly and never consults partitionResolver. For a committed or rolled-back transaction whose primary is a partitioned SQS key, the commit/rollback row is stored in the resolver-selected group, while routeKey collapses the key into the catalog-routed SQS namespace; validation therefore reads the status from the wrong group, treats the residual lock as pending, and makes BeginBackup fail on a clean snapshot. Resolve the transaction's embedded user key through the partition resolver first and perform the fenced status read against that group.

Useful? React with 👍 / 👎.

The empty key is legal in Redis, so an unmigrated hash or set stored under
it has its blob at exactly !redis|hash| or !redis|set|. The shared key parser
I added treated that as malformed, which fails the decoder on data the Redis
API can create; parseZSetLegacyBlobKey has always accepted it. Reject only a
missing prefix, and let the value carry the validity check.

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 (a110654):

  • distribution/engine.go
  • kv/coordinator.go
  • kv/fsm.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 29, 2026

Copy link
Copy Markdown
Owner Author

internal/backup/redis_set.go:143 (P2, "Accept empty Redis keys in legacy collection blobs") — fixed in a1106548. Another one of mine: the shared key parser I introduced last round rejected an empty remainder, and parseZSetLegacyBlobKey — the function I was mirroring — has always accepted it. The empty key is legal in Redis, so an unmigrated hash or set stored under it has its blob at exactly !redis|hash| / !redis|set|, and rejecting that fails the decoder on data the API can create. Now only a missing prefix is malformed; the value still carries the validity check.

Tests: a round-trip through the empty key (the emitted filename is discovered from the directory rather than guessed at), plus the malformed case moved to a key with the wrong family prefix, which is what "malformed key" actually means. Revert-checked.

go test -race ./internal/backup/... ./kv/... green (backup 7s, kv 19s); golangci-lint run ./internal/backup/... → 0 issues.

The other three at-HEAD findings on this PR are not addressed:

  • kv/fsm.go:627 (capacity reservations on restore) — analysed in my previous comment; it needs you to pick between the three shapes there, because each redefines what a capacity slot counts.
  • kv/fsm.go:629 (invalidate cached TSO windows after restoring the floor) — not verified yet.
  • kv/backup_scan.go:282 (resolve partitioned transaction status through the owner) — not verified yet. Worth noting it is the same partition-resolver-vs-routeKey shape as the migration export finding on migration: add range version RPC handlers #1088, so it is likely real.

@codex review
@claude review

@claude

claude Bot commented Aug 29, 2026

Copy link
Copy Markdown

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


Review: a110654 (backup: accept the empty Redis key in legacy collection blobs)

  • Read trigger comment and gather context
  • Verify internal/backup/redis_set.go fix (empty-key acceptance in legacy blob parser)
  • Check new/updated tests for the fix
  • Check status of kv/fsm.go:627 (capacity reservation on restore)
  • Check status of kv/fsm.go:629 (TSO window invalidation after floor restore)
  • Check status of kv/backup_scan.go:282 (partitioned txn status resolution)
  • Post final review

The stated fix (a110654) is correct. parseRedisLegacyBlobKey (internal/backup/redis_set.go:137-146) now only checks bytes.HasPrefix(key, []byte(prefix)) and returns key[len(prefix):], true unconditionally — it no longer rejects an empty remainder. I confirmed the claimed precedent: parseZSetLegacyBlobKey (internal/backup/redis_zset.go:307-312) has always accepted an empty remainder too, distinguishing "no prefix" via len(rest) == len(key) rather than len(rest) == 0. The value-side validity check is untouched — decodeHashLegacyBlobValue/decodeSetLegacyBlobValue still reject a missing/corrupt magic prefix (internal/backup/redis_hash.go:113-115), so a genuinely malformed blob at the empty key still errors; only the key shape changed.

Tests (internal/backup/redis_legacy_blob_test.go): the round-trip test for the empty key now writes through HandleHashLegacyBlob/HandleSetLegacyBlob at exactly RedisHashLegacyBlobPrefix/RedisSetLegacyBlobPrefix and asserts the finalized dump contains the record; the "reject missing magic" test was correctly narrowed to use the empty key (so it now tests the value check, not the key check) plus a new case using a wrong-family prefix (!redis|other|k) to test the real "malformed key" path. This is a sound before/after split — good regression coverage for exactly the reported defect.

Status of the three remaining open findings

kv/fsm.go:627 (capacity reservations on restore) — confirmed still open. a110654 only touched redis_set.go/its test; Restore() still calls f.readTracker.ClearBackupPinsForGroup(f.shardGroupID) unchanged (kv/fsm.go:626-628), which still doesn't touch the group-0-keyed BackupReserve entries applied via PinWithDeadline/PinWithDeadlineForGroup(pinID, 0, ...) in kv/fsm_backup.go. Per your own prior analysis in this thread, this needs a design decision (separate reservation map keyed by owning group, a wire-format change to carry the owning group, or something else) rather than a quick patch — nothing to add here.

kv/fsm.go:629 (TSO window invalidation after floor restore) — verified, and it's real. Traced the full chain:

  • On a normal BackupPin/BackupExtend apply, pinWithDeadlineForGroup (kv/active_timestamp_tracker.go:214-238) fires t.backupFloorObserver(readTS) under the tracker's lock, and main.go:554 wires that observer to coordinate.ObserveTimestampFloor (kv/sharded_coordinator.go:506-523), which does c.clock.Observe(ts) and invalidateTimestampWindow(c.tsAllocator).
  • invalidateTimestampWindow (kv/tso.go:59-63) calls Invalidate() on the allocator when it implements timestampWindowInvalidator. BatchAllocator.Invalidate() (kv/tso.go:382-387) does b.epoch.Add(1); b.win.Store(nil) — it discards the currently published, pre-fetched timestamp window. This allocator is production-wired: main.go:2189 does coordinate.WithTSOAllocator(batch) where batch := kv.NewBatchAllocator(tso, *tsoBatchSize) when --tsoEnabled is set (main.go:2174-2189).
  • Restore() (kv/fsm.go:626-633) only calls f.reloadBackupTimestampFloor(ctx), which stores the persisted floor into the FSM-private f.backupTimestampFloor atomic (kv/fsm_backup.go:95-111) — used only by verifyBackupTimestampFloor for write fencing. It never calls anything that reaches ObserveTimestampFloor/invalidateTimestampWindow, and f.backupTimestampFloor has no exported accessor, so nothing outside the FSM can even read the restored value.
  • I checked whether observeStoreLastCommitTS(f.hlc, f.store) (called right after, kv/fsm.go:633) covers this: it only calls hlc.Observe(...), which is the same effect as c.clock.Observe(ts) in the legacy shared-HLC path but does not invalidate a BatchAllocator's cached window (BatchAllocator and HLC are separate objects; only ObserveTimestampFloor's explicit invalidateTimestampWindow(c.tsAllocator) call does that).
  • Consequence: BatchAllocator.nextAfter/tryWindowAfter (kv/tso.go:400-433) filters served timestamps only against the caller-supplied min, not against any floor-awareness — so a stale cached window fetched before this replica fell behind can keep dispensing values ≤ the just-restored floor until the window's monotonically increasing offsets happen to climb past it. Every such value is rejected downstream by verifyBackupTimestampFloor (kv/fsm_backup.go:114-124), which fails closed, so this is a liveness/availability gap (repeated failed proposals), not a correctness/data-safety bug — but it's real and reachable specifically on a node that re-acquires TSO-leader status after falling behind and catching up via a post-pin snapshot with --tsoEnabled.
  • This needs a similar API decision to the :627 finding: something like exposing a method on ActiveTimestampTracker (or the FSM) to re-fire the floor observer with the reloaded value during Restore, since backupFloorObserver is currently a private field with no way to invoke it except through the pin-application path.

kv/backup_scan.go:282 (partitioned transaction status resolution) — verified, and it's real, though not backup-specific. capturedBackupGetAt (kv/backup_scan.go:276-301) computes rkey := routeKey(key) and matches only against snapshot.routes (byte-range routes), falling back to plain s.GetAt(ctx, key, ts) — neither path ever consults s.partitionResolver. I confirmed s.partitionResolver (kv/shard_store.go:32) is referenced in exactly two places in this package, both in kv/backup_scan.go:593-601 (the scan-side preferredRoutedScanKey ownership check) — pointReadRoutesWithVersion (kv/shard_store.go:356-385), which backs every GetAt, never touches it. Critically, this isn't new: the pre-existing, non-backup txnCommitTS/hasTxnRollback (kv/shard_store.go:3234-3258) already resolve commit/rollback records via plain s.GetAt, with the identical gap. Meanwhile, ShardedCoordinator/ShardRouter.ResolveGroup (kv/shard_router.go:92-131, kv/sharded_coordinator.go:2115 etc.) does consult the partition resolver on the write/dispatch side. So if a transaction's primary key is ever a partitioned key (e.g. an SQS FIFO key), its mutations/lock/commit/rollback records get proposed to and stored in the partition-resolved group, but every status read (ShardStore.GetAt, backup or live) looks it up via byte-range routing only — which can name a different, wrong group, and misread an already-committed/rolled-back transaction as pending. This matches your note that it's the same shape as the #1088 migration-export finding. Since the gap predates this PR and lives in ShardStore.GetAt itself, fixing it fully is broader than this PR's scope, but it directly undermines the correctness of the new backup-validation path this PR adds, so it's worth tracking alongside :629 before this subsystem is considered done.

Note on tooling: go build/go test/golangci-lint require interactive approval not available in this sandboxed session, so all three findings above were verified by static tracing of the call graphs (as cited) rather than a live run — consistent with the limitation noted throughout this PR's review thread.
· branch design/live-backup-pin-substrate

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

ℹ️ 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 internal/backup/live.go
return scopeForDDBKey(key)
case hasAnyBackupPrefix(key,
S3BucketMetaPrefix, S3ObjectManifestPrefix,
S3UploadMetaPrefix, S3UploadPartPrefix, S3BlobPrefix, S3ChunkRefPrefix,

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 Include referenced S3 chunk blobs in the live stream

Fresh evidence at current HEAD is that 09fc7541 adds only S3ChunkRefPrefix here, while S3ChunkBlobPrefix remains unrecognized and therefore unscoped. With S3 blob offload enabled, backupKeySelected rejects those chunk-blob records (adapter/admin_backup.go:638-643), but S3Encoder can resolve each streamed reference only from payloads received by HandleChunkBlob (internal/backup/s3.go:492-537); consequently an offloaded object's manifest and references are streamed without its content and decoder finalization fails. The live path must also retrieve and emit every referenced content-addressed chunk blob.

Useful? React with 👍 / 👎.

A content-addressed chunk blob carries no bucket in its key, so ScopeForKey
has no scope to stream it under and returned "not scoped" -- silently dropping
it while the object's manifest and chunk references still went out. S3Encoder
resolves each streamed reference only from payloads delivered to
HandleChunkBlob, so such a dump cannot finalize: the live path would have
produced a backup that cannot be restored.

Reject the key instead. Emitting these blobs is part of enabling S3 blob
offload, which s3BlobOffloadDecision still forces back to the legacy path
until the M3 reference-counting and orphan-scanner work sets blobOffloadGCReady
-- nothing in the tree sets it outside a test today, so no cluster can produce
a chunk blob yet. When that changes, a backup fails loudly here rather than
quietly emitting an undecodable dump.

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 (afe538e):

  • distribution/engine.go
  • kv/coordinator.go
  • kv/fsm.go
  • kv/sharded_coordinator.go

What to check, by subsystem:

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

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


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

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

@chatgpt-codex-connector

Copy link
Copy Markdown

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: afe538e497

ℹ️ 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/admin_backup.go
Comment on lines +439 to +440
s.compensateBackupRelease(groups[0], groups, tok.pinID)
s.forgetBackupSession(tok.pinID)

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 Serialize renewals before destructive cleanup

When two RenewBackup calls for the same token overlap and one succeeds while the other encounters a partial fan-out failure, the failing call unconditionally releases every group pin and forgets the shared session. If the successful call passes its final liveness check and extends the session before this cleanup completes, it can still return a renewed token even though the other call then removes its session and retention pins, allowing compaction to invalidate the backup. Serialize renewals per pin or make cleanup conditional on the failing attempt still owning the session generation.

Useful? React with 👍 / 👎.

Comment thread adapter/admin_backup.go
Comment on lines +340 to +344
if _, _, err := proposeBackupAll(ctx, []backupGroup{controlGroup}, reserveEntry); err != nil {
if backupCapacityReservationFull(err) {
return nil, status.Errorf(codes.ResourceExhausted, "%s", kv.ErrTooManyActiveBackups)
}
return nil, status.Errorf(codes.Unavailable, "reserve backup capacity: %v", 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 Release ambiguous capacity reservations

If the control-group BackupReserve commits but its response is lost or the caller context expires, proposeBackupAll returns an error here and BeginBackup exits without proposing BackupUnreserve. The unacknowledged reservation then consumes one of the small global active-backup slots until its TTL despite no backup being returned to the caller; compensate the control group on non-capacity reservation errors because unreserve is idempotent.

Useful? React with 👍 / 👎.

@bootjp

bootjp commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

afe538e4 — addresses the P1 on internal/backup/live.go:40 ("Include referenced S3 chunk blobs in the live stream").

Mechanism confirmed; the severity claim does not hold at HEAD. I traced it rather than taking it as given:

  • ScopeForKey recognises S3ChunkRefPrefix but not S3ChunkBlobPrefix, so backupKeySelected (adapter/admin_backup.go:638) drops chunk-blob records while the object's manifest and chunk references still stream. S3Encoder resolves each streamed reference only from payloads delivered to HandleChunkBlob (internal/backup/s3.go:492), so the decoder cannot finalize such a dump. That part is exactly right.
  • Chunk blobs are in the scanned keyspace, so this is reachable in principle: ShardStore.LocalStoreForKey (kv/shard_store.go:3849) returns the group's own store.MVCCStore — "peer-local" means written without going through Raft, not written somewhere else.
  • But the mode cannot be turned on. s3BlobOffloadDecision (adapter/s3_blob_offload.go:181) forces every PUT back to the legacy path unless blobOffloadGCReady is set, and nothing in the tree sets it outside adapter/s3_blob_offload_test.go:138 — there is no option, flag, or lifecycle hook for it, pending the M3 reference-counting/grace-queue/orphan-scanner work. So no cluster can hold a !s3|chunkblob| record today, and no backup can currently be corrupted this way.

What I did instead of the suggested remedy. The report asks the live path to "retrieve and emit every referenced content-addressed chunk blob". I did not do that here: a chunk blob is content-addressed with no bucket in its key, so it has no backup scope to be streamed under, and deciding how it should be scoped (per referencing bucket? duplicated across buckets that share a blob? a scope-less side channel?) is a design question that belongs with the M3 enablement work, not a patch on this PR.

What this PR does is stop the silent failure: ScopeForKey now returns ErrScopeOffloadedChunkBlob for a chunk-blob key instead of "not scoped". Both callers (backupKeySelected, countBackupScopeKey) already propagate a classification error, so a backup fails loudly rather than emitting a dump that cannot be restored. Today that changes nothing observable; when offload is enabled it turns silent corruption into a FailedPrecondition that names the missing work.

Tests (internal/backup/live_chunkblob_test.go): a chunk-blob key built with the real s3keys.ChunkBlobKey must produce ErrScopeOffloadedChunkBlob; its sibling s3keys.ChunkRefKey must still scope to its bucket, so the guard does not swallow the reference records. Revert-checked: removing the case makes the first test fail with "expected error … but got nil".

Verification: go test ./internal/backup/ passes; golangci-lint ./internal/backup/... 0 issues.

Self-review passes. Data loss: strictly fewer silent omissions — the guard converts a dropped record into a failure, and drops nothing new. Concurrency: pure function, no state. Performance: one prefix compare per key, ahead of the existing S3 prefix set. Consistency: the decoder's chunk-blob route (decode.go:330) is untouched, so a dump that does carry blobs still restores. Test coverage: above, revert-checked.

If you'd rather the live path actually carry the blobs, that wants a *_proposed_* design doc under docs/design/ covering the scoping question — say the word and I'll write it rather than guess the shape.

@codex review
@claude review

@claude

claude Bot commented Aug 29, 2026

Copy link
Copy Markdown

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


Review: afe538e (backup: fail closed on offloaded S3 chunk blobs)

  • Read trigger comment and gather context
  • Inspect internal/backup/live.go diff (ScopeForKey / ErrScopeOffloadedChunkBlob)
  • Verify the "offload cannot currently be enabled" claim (adapter/s3_blob_offload.go)
  • Verify both callers (backupKeySelected, countBackupScopeKey) propagate the new error correctly
  • Verify decoder side is untouched (internal/backup/decode.go)
  • Review new test internal/backup/live_chunkblob_test.go
  • Post findings

Both claims in the trigger comment check out under static review.

The fail-closed fix is correct and well-placed. The new case bytes.HasPrefix(key, []byte(S3ChunkBlobPrefix)) (internal/backup/live.go:48-49) sits in ScopeForKey's switch before the general S3 case, and S3ChunkBlobPrefix (!s3|chunkblob|) is deliberately absent from that general case's hasAnyBackupPrefix list (line 50-54) — so the new branch isn't dead code and isn't shadowed. I checked the three S3 prefixes involved (S3BlobPrefix = "!s3|blob|", S3ChunkRefPrefix = "!s3|chunkref|", S3ChunkBlobPrefix = "!s3|chunkblob|", internal/s3keys/keys.go:14-16) and confirmed none is a prefix of another, so there's no ordering hazard with adjacent cases.

Both call sites correctly turn the classification error into a hard failure: backupKeySelected (adapter/admin_backup.go:638-644) maps it to codes.FailedPrecondition for the streaming path, and countBackupScopeKey (adapter/admin_backup.go:1109-1118) wraps and propagates it for the baseline-counting path (collectBackupScopeCountsbuildExpectedBackupBaseline). Both already handled ErrScopeKeyMalformed the same way, so this reuses an established fail-closed pattern rather than inventing a new one.

The "unreachable today" claim is verified, not just asserted: s.blobOffloadGCReady (adapter/s3.go:115) gates s3BlobOffloadDecision (adapter/s3_blob_offload.go:185-188) — if false, every PUT decision is forced to s3BlobOffloadModeLegacy regardless of the other flags. Grepping the whole tree, blobOffloadGCReady is only ever set to true in adapter/s3_blob_offload_test.go:138; there's no flag, config option, or lifecycle hook that sets it in production code. So no cluster can currently write a !s3|chunkblob| record, and this change has no observable effect today — it's purely a guard against the M3 offload-enablement work silently corrupting backups later.

Decoder side is untouched, confirmed via diff: git diff a110654 afe538e -- internal/backup/decode.go is empty, and routeS3ChunkBlob / HandleChunkBlob (internal/backup/decode.go:330,488-494) are unchanged — so an offline/full backup dump that does carry chunk blobs (e.g. from a direct Pebble snapshot read that bypasses the live ScopeForKey path) still decodes correctly. This change only affects the live streaming path's classification, which is the right scope for this PR.

Test coverage is appropriately narrow and targeted (internal/backup/live_chunkblob_test.go): TestScopeForKeyFailsClosedOnOffloadedChunkBlob builds a real key via s3keys.ChunkBlobKey and asserts ErrScopeOffloadedChunkBlob, scoped=false, zero Scope{}. TestScopeForKeyStillScopesChunkRef proves the sibling chunk-reference prefix (which does carry a bucket) is unaffected — important because it rules out the guard accidentally being too broad. Both function signatures used (s3keys.ChunkBlobKey([32]byte), s3keys.ChunkRefKey(bucket, generation, object, uploadID, partNo, chunkNo)) match the real implementations I checked in internal/s3keys/.

Minor, non-blocking observation: the error is wrapped with errors.WithStack(ErrScopeOffloadedChunkBlob) (no key context), while the sibling ErrScopeKeyMalformed cases all use errors.Wrapf(ErrScopeKeyMalformed, "... key %q", key) to include the offending key bytes in the message. Since this error path is meant to be operator-visible once M3 offload lands, including the key (or at least its length/prefix) would make a future FailedPrecondition easier to debug. Not a defect — just a small consistency gap with the neighboring code.

Limitation: go test/go vet require interactive approval that wasn't available in this sandboxed session (the same limitation noted throughout this PR's review thread), so this review is based on static tracing of the diff, the prefix constants, and the call graph, not a live run. The evidence posted in the trigger comment (go test ./internal/backup/ passing, golangci-lint 0 issues) is consistent with what this diff should produce.

No blocking issues found. The fix correctly converts a would-be silent data-loss bug (once S3 blob offload is enabled) into a loud FailedPrecondition, is inert today, and is well-tested for both the failing and non-failing sibling case.
· branch design/live-backup-pin-substrate

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: afe538e497

ℹ️ About Codex in GitHub

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

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

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

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

@bootjp
bootjp merged commit 231bfd2 into main Aug 29, 2026
15 checks passed
@bootjp
bootjp deleted the design/live-backup-pin-substrate branch August 29, 2026 07:56
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.

2 participants