Skip to content

migration: complete target promotion catalog state - #1090

Open
bootjp wants to merge 214 commits into
design/hotspot-split-m2-cross-groupfrom
design/hotspot-split-m2-promotion-complete
Open

migration: complete target promotion catalog state#1090
bootjp wants to merge 214 commits into
design/hotspot-split-m2-cross-groupfrom
design/hotspot-split-m2-promotion-complete

Conversation

@bootjp

@bootjp bootjp commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Author: bootjp

Summary:

  • Add the default-group target promotion completion transition.
  • Clear only staged route fields while retaining min_write_ts_exclusive.
  • Persist the route descriptor update and SplitJob promotion witness in one catalog batch.
  • Cover idempotent cleared-descriptor retries and stale input rejection.

Tests:

  • go test ./distribution -run 'Test(CompleteTargetPromotion|CatalogStoreCompleteSplitJobTargetPromotion)'\n- go test ./distribution\n- go test -run '^$' ./...\n- GOCACHE=$(pwd)/.cache GOLANGCI_LINT_CACHE=$(pwd)/.golangci-cache golangci-lint run ./distribution --timeout=5m

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 281bd714-02f4-4a1d-b645-10010911cc77

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

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

  • distribution/migration_promotion_complete.go
  • distribution/migration_promotion_complete_test.go

What to check, by subsystem:

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

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


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

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

@bootjp

bootjp commented Jul 13, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@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 the target promotion completion logic for split job migrations, including the state transition function CompleteTargetPromotionState and the transactional catalog store method CompleteSplitJobTargetPromotion. Feedback suggests strengthening defensive checks by validating the route's parent ID and start key during route clearing, and removing a redundant history lookup that could impact performance.

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 on lines +83 to +85
if !route.StagedVisibilityActive || route.GroupID != job.TargetGroupID {
return nil, errors.WithStack(ErrMigrationInvalidRoute)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

To enforce defensive programming and ensure catalog consistency, we should also validate that the target route's ParentRouteID and Start key match the split job's SourceRouteID and SplitKey before clearing the staged visibility fields. This prevents accidentally promoting an inconsistent or incorrect route descriptor.

Suggested change
if !route.StagedVisibilityActive || route.GroupID != job.TargetGroupID {
return nil, errors.WithStack(ErrMigrationInvalidRoute)
}
if !route.StagedVisibilityActive || route.GroupID != job.TargetGroupID || route.ParentRouteID != job.SourceRouteID || !bytes.Equal(route.Start, job.SplitKey) {
return nil, errors.WithStack(ErrMigrationInvalidRoute)
}

Comment on lines +151 to +158
if err := s.expectLiveSplitJobAt(ctx, expected.JobID, expectedRaw, readTS); err != nil {
return 0, 0, nil, err
}
if _, found, err := s.historySplitJobAt(ctx, expected.JobID, readTS); err != nil {
return 0, 0, nil, err
} else if found {
return 0, 0, nil, errors.WithStack(ErrCatalogSplitJobConflict)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The check s.historySplitJobAt is redundant and inefficient here. Since s.expectLiveSplitJobAt has already succeeded, the split job is guaranteed to be in the live state. In a consistent MVCC catalog, a split job cannot exist in both the live state and the history state simultaneously. Scanning the entire history of split jobs (which can grow very large in production) is expensive and unnecessary. We can safely remove this check.

Suggested change
if err := s.expectLiveSplitJobAt(ctx, expected.JobID, expectedRaw, readTS); err != nil {
return 0, 0, nil, err
}
if _, found, err := s.historySplitJobAt(ctx, expected.JobID, readTS); err != nil {
return 0, 0, nil, err
} else if found {
return 0, 0, nil, errors.WithStack(ErrCatalogSplitJobConflict)
}
if err := s.expectLiveSplitJobAt(ctx, expected.JobID, expectedRaw, readTS); err != nil {
return 0, 0, nil, err
}
References
  1. Remove dead or unreachable code paths, such as asynchronous handlers for tasks that are always performed synchronously, to improve code clarity and maintainability.

@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: 351477d4e1

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

out.Changed = true
out.ClearedRouteIDs = cleared
out.Job.TargetPromotionDone = true
out.Job.PromotionCompletedTS = completionTS

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 Record the catalog CAS timestamp

In the promotion-complete path, this stores the caller-supplied completionTS in promotion_completed_ts, but the design contract for that field is the HLC commit timestamp of the default-group CAS that clears the descriptor (see docs/design/2026_06_11_proposed_hotspot_split_milestone2_migration.md, promotion_completed_ts). In normal calls the target-local promotion timestamp/max promoted timestamp can differ from the catalog MVCC commit timestamp chosen later in applyPromotionCompleteMutations, so target cleared-descriptor ACKs can prove or wait on the wrong timestamp; compute the catalog commit timestamp before encoding the job and persist that value instead.

Useful? React with 👍 / 👎.

@bootjp
bootjp force-pushed the design/hotspot-split-m2-promotion-complete branch from 351477d to c57e42a Compare July 13, 2026 16:27
@bootjp

bootjp commented Jul 13, 2026

Copy link
Copy Markdown
Owner Author

Addressed review feedback in c57e42a:

  • validate target parent route and split key before clearing staged route fields
  • remove the redundant live+history conflict scan from the promotion-complete read path
  • store the actual catalog CAS commit timestamp in promotion_completed_ts

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

  • distribution/migration_promotion_complete.go
  • distribution/migration_promotion_complete_test.go

What to check, by subsystem:

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

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


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

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

@bootjp
bootjp force-pushed the design/hotspot-split-m2-promotion-complete branch from c57e42a to 2edefb8 Compare July 13, 2026 16:31
@bootjp

bootjp commented Jul 13, 2026

Copy link
Copy Markdown
Owner Author

Follow-up in 2edefb8:

  • removed caller-supplied completion timestamp from the promotion-complete APIs
  • the catalog CAS commit timestamp is now assigned immediately before encoding the SplitJob witness

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

  • distribution/migration_promotion_complete.go
  • distribution/migration_promotion_complete_test.go

What to check, by subsystem:

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

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


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

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

@bootjp

bootjp commented Jul 24, 2026

Copy link
Copy Markdown
Owner Author

@codex review

Addressed the latest-head metadata cleanup and MVCC snapshot findings in dcb7744.

Changes:

  • Metadata cleanup barriers now return an explicit incomplete error when any voter still reports METADATA_CLEARED=false, so cleanup retry points are not advanced while migration metadata may remain on a voter.
  • In-memory MVCC snapshots now select the migration snapshot format when import ack or HLC-floor metadata exists, and persist that metadata in an optional v2 section.
  • Streaming MVCC restore into Pebble writes restored import ack / HLC-floor metadata back to Pebble meta keys before exposing the restored DB.

Semantic audit:

  • Checked cleanupSplitJobTargetProofs, cleanupAbandonedSplitJobTarget, cleanupSplitJobSourceMetadata, cleanupSplitJobSourceProofs, and all syncSplitMigrationVoterBarrier call sites. Only metadata cleanup barriers needed fail-closed retry semantics; cursor-bearing barriers still persist progress and retry normally.
  • Checked restoreStreamingMVCCSnapshotBody, readStreamingMVCCRestoreHeader, writeStreamingMVCCRestoreTempDB, and migration import metadata readers/writers across in-memory and Pebble stores.

Validation:

  • go test ./adapter -run 'TestDistributionServerCleanupSplitJobSourceProofsWaitsForMetadataBarrier|TestSplitMigrationVoterBarrierReopensForMembershipChanges' -count=1 -timeout=10m\n- go test ./adapter -run 'Test(DistributionServerRunSplitJobRunnerOnce|DistributionServerCleanupSplitJobSourceProofsWaitsForMetadataBarrier|InternalProbeMigration|InternalApplyTargetStagedReadiness|DistributionServerSplitRange)' -count=1 -timeout=10m\n- go test ./adapter -run '^$' -count=1 -timeout=10m\n- go test ./store -count=1 -timeout=10m\n- go test ./kv ./store -count=1 -timeout=10m\n- golangci-lint --config=.golangci.yaml run ./adapter ./store --timeout=5m --allow-parallel-runners\n- git diff --check HEAD~1..HEAD

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

  • distribution/migration_promotion_complete.go
  • distribution/migration_promotion_complete_test.go
  • distribution/split_job_catalog.go
  • distribution/split_job_catalog_test.go
  • distribution/split_job_lifecycle.go
  • kv/fsm.go
  • kv/sharded_coordinator.go
  • store/mvcc_store.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: dcb7744f35

ℹ️ 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/internal.go Outdated
Comment on lines +185 to +186
Ready: time.Now().UnixMilli() >= req.GetReadDrainNotBeforeMs() &&
(i.readTracker == nil || i.readTracker.Oldest() == 0),

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 Relax read drain to ignore unrelated active reads

When a split reaches source-read-drain cleanup, this probe only reports ready if the process-wide ActiveTimestampTracker is completely empty. In production the same tracker is wired into the Internal service and all frontend read paths, so any unrelated Redis/Dynamo/S3 read on this node keeps Oldest() nonzero even if it started after cutover or targets another range. On a busy node this can keep SOURCE_READ_DRAINED false indefinitely, preventing source cleanup/metadata cleanup and leaving the migration retention pin in place; the drain check needs to distinguish pre-cutover reads for the moving range (or at least reads older than the cutover/grace point) instead of requiring zero global reads.

Useful? React with 👍 / 👎.

The SOURCE_READ_DRAINED probe required a globally empty
ActiveTimestampTracker, but that tracker is process-wide and is wired into
every frontend read path. On a busy node any unrelated Redis/DynamoDB/S3
read keeps Oldest() non-zero, so source cleanup and the migration
retention pin could stay blocked indefinitely.

Carry the promotion-completed HLC as read_drain_min_ts and only count pins
at or below it: reads issued after the source read fence armed carry a
newer timestamp and are already rejected for the moving range. A zero
floor (pre-upgrade coordinator) keeps the old empty-tracker requirement,
so the relaxation is opt-in from the control plane.

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 (75d86c0):

  • distribution/migration_promotion_complete.go
  • distribution/migration_promotion_complete_test.go
  • distribution/split_job_catalog.go
  • distribution/split_job_catalog_test.go
  • distribution/split_job_lifecycle.go
  • kv/fsm.go
  • kv/sharded_coordinator.go
  • store/mvcc_store.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

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

@chatgpt-codex-connector

Copy link
Copy Markdown

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

@bootjp

bootjp commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

Codex P2 (adapter/internal.go:186Relax read drain to ignore unrelated active reads) に対応しました。

変更点

  • ProbeMigrationStateRequestread_drain_min_ts (field 14) を追加。
  • SOURCE_READ_DRAINED プローブは、プロセス全体の ActiveTimestampTracker が空であることを要求するのをやめ、この floor 以下に pin されている read だけを drain 未完了として扱うようにしました。source read fence が armed になった後に発行された read はより新しい HLC を持ち、移動中レンジに対しては既に fence で弾かれるため、drain をブロックする必要がありません。
  • コントロールプレーン (split_job_runner.go) が job.PromotionCompletedTS を floor として渡します。
  • floor が 0(アップグレード前のコーディネータ)の場合は従来どおり「トラッカーが完全に空」を要求する fail-closed フォールバックです。

Caller audit: MIGRATION_STATE_PROBE_KIND_SOURCE_READ_DRAINED を構築する本番コードは adapter/split_job_runner.go:390 の 1 箇所のみ(他はテスト)。Internal.readTracker の参照箇所も新ヘルパー sourceReadsDrained に集約済み。

Test evidence

  • 追加: TestInternalProbeSourceReadDrainIgnoresPostCutoverReads(table-driven: 空 / pre-cutover pin / 境界値 == floor / post-cutover のみ / 混在 / floor=0 フォールバック)、TestInternalProbeSourceReadDrainHonorsGracePeriod
  • go test -race -run 'Migration|Split|Drain' ./adapter/ ./distribution/ → ok
  • go build ./... / go vet → ok
  • golangci-lint run adapter/... → 0 issues

Self-review

  1. データ損失: なし。drain 判定の緩和のみで書き込み経路に触れていない。retention pin の解放は cutover 後の read に限定されるため、pre-cutover read から見える版は引き続き保持される。
  2. 並行性/分散障害: floor 未指定時は従来の strict 判定に fail-close。Oldest() は既存のロック内で読むだけで新しいロック順序は導入していない。
  3. パフォーマンス: ホットパス変更なし。むしろビジーノードで無限に回っていた drain ポーリングが終息する。
  4. データ整合性: 比較対象はどちらもリーダー発行 HLC (Pin(readTS)PromotionCompletedTS = commitTS)。ローカル壁時計は既存の grace 判定にしか使っていない。
  5. テスト網羅: 新規分岐(floor=0 / oldest==0 / oldest<=floor / oldest>floor)をすべて table-driven で網羅。

なお、レビューが併記していた「移動対象レンジ以外の read を除外する」ためのレンジ単位トラッキングは、全 read パスの Pin シグネチャ変更を伴うため本 PR のスコープ外としています(タイムスタンプ floor で「無期限にブロックされる」問題自体は解消します)。

@claude review

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

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


Review: migration: scope source read drain to pre-cutover reads (75d86c0)

  • Read trigger comment and gather PR context
  • Locate the read-drain floor change (adapter/internal.go, adapter/split_job_runner.go, proto field read_drain_min_ts)
  • Review correctness of the floor comparison logic
  • Review caller wiring (split_job_runner.go) and fallback for floor=0
  • Check new tests
  • Post final review

Summary

This addresses the Codex P2 finding at adapter/internal.go:186 cleanly and correctly. Verified by reading the actual commit (75d86c08), not just the description:

Correctness of the floor comparisonsourceReadsDrained(drainMinTS) in adapter/internal.go:205-217 treats any active pin <= drainMinTS as still blocking, and only reports drained once oldest > drainMinTS (or the tracker is empty). The safety argument holds:

  • job.PromotionCompletedTS is assigned when target promotion completes in the Cleanup phase (distribution/migration_promotion_complete.go:295-296), which is strictly after SourceReadFence: true is armed during Cutover (adapter/split_job_runner.go:1291).
  • Since HLC commit timestamps are leader-issued and monotonically increasing across phases, PromotionCompletedTS is guaranteed >= the commit timestamp of the fence-arming entry. That makes the floor conservative in the safe direction: it can make the drain wait slightly longer than the theoretical minimum, but it can never let a read that predates the fence closing (and could still observe the source range) slip through as "drained." I did not find a path where this ordering could be violated.
  • At ackSplitJobSourceReadDrain (adapter/split_job_runner.go:378-395), by the time this step runs in cleanupSplitJob, TargetPromotionDone is already true (checked earlier in the same function), so PromotionCompletedTS is always non-zero on the normal path — the drainMinTS == 0 fallback is genuinely only a rolling-upgrade safety net for jobs whose control plane hasn't been upgraded yet, as claimed.

Caller audit — confirmed accurate: MIGRATION_STATE_PROBE_KIND_SOURCE_READ_DRAINED is only constructed in production at adapter/split_job_runner.go:390, and i.readTracker.Oldest() now has exactly one call site (adapter/internal.go:209), consolidated as described.

Proto changeread_drain_min_ts is field 14, additive/optional with a zero default, so it's backward compatible for rolling upgrades: a coordinator that hasn't picked up this change simply never sets it, and target voters correctly fall back to the strict all-reads-must-drain behavior. This matches the repo's wire-format compatibility expectations.

TestsTestInternalProbeSourceReadDrainIgnoresPostCutoverReads covers the important boundary (pins == cutoverTS still blocks — correctly conservative on ties), mixed pre/post pins, the empty-tracker case, and the drainMinTS == 0 fallback. TestInternalProbeSourceReadDrainHonorsGracePeriod checks the pre-existing ReadDrainNotBeforeMs gate still short-circuits independently. No other test files reference this probe kind, so there's no risk of the semantics change breaking unrelated coverage.

No correctness issues found. One non-blocking observation: the floor uses PromotionCompletedTS (Cleanup-phase timestamp) rather than the Cutover-phase fence-arm commit timestamp, which is slightly looser than necessary — but this is already compensated for by the existing ReadDrainNotBeforeMs grace period, so it's not worth a follow-up.

Note: I was not able to execute go test/go vet/golangci-lint in this sandboxed session (the harness required approval for go tool invocations that wasn't available here); this review is based on careful static reading of the diff, the surrounding call graph, and the new test file's assertions. The described validation (go test -race -run 'Migration|Split|Drain' ./adapter/ ./distribution/, build, vet, lint) should be re-confirmed via CI.

bootjp added a commit that referenced this pull request Aug 27, 2026
The only remaining M3 work is M3-PR4 least-loaded target_group_id
selection, and its own entry said it waits on the M2 migration plane. An
entry that names an unmet prerequisite does not belong under "Ready now --
no unmet prerequisites", where a contributor reading the index would take
it as schedulable today. Move it to the gated list, where it names step 1
the way the other gated entries name what they wait for.

Also correct the audit date. The index tells readers each status is a
snapshot from that date, and 2026-08-22 cannot describe a baseline whose
M3 row cites 07c48af -- that commit merged on 2026-08-25.

Re-verified against origin/main and the open pull requests while dating
it: the M2 row's #1084/#1085/#1088/#1090, the dedicated TSO row's #1095,
and the live backup row's #1056/#1128 are all still open, and no other
row's evidence moved.

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

bootjp commented Aug 28, 2026

Copy link
Copy Markdown
Owner Author

Current HEAD has no review verdict from both bots yet (the last codex/claude pass on this PR predates it, or hit usage limits). No open at-HEAD findings on my side.

@codex review
@claude review

@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

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


Review: current HEAD 75d86c08

  • Read trigger comment and gather PR context
  • Check current HEAD commit and diff vs base branch
  • Review outstanding/unresolved findings from prior rounds against current HEAD
  • Review the latest commit (75d86c0 — scope source read drain to pre-cutover reads) in detail
  • Check for TLA+ spec divergence per the anchored-files checklist
  • Post final review

Summary

I re-verified the current HEAD independently (not just re-reading my prior pass from 2026-08-21, which covered this same commit). No blocking issues found.

Latest commit (75d86c08) — read-drain floor scoping

  • sourceReadsDrained(drainMinTS) in adapter/internal.go:197-217 is correct: kv/active_timestamp_tracker.go:27-29 special-cases Pin(0) and Pin(^uint64(0)) to a no-op token that is never added to active, so Oldest() == 0 genuinely means "no tracked reads" — confirming the empty-tracker fast path is safe and not a false positive for a legitimately-zero real timestamp.
  • Floor safety: job.PromotionCompletedTS (set in distribution/migration_promotion_complete.go:295-296 / adapter/distribution_server.go:1272-1273 from the catalog CAS commit timestamp) is assigned during Cleanup, strictly after SourceReadFence arms during Cutover. Since HLC timestamps are leader-issued and monotonic across phases, the floor is conservative in the safe direction — it can only make the drain wait longer, never let a pre-fence read slip through undetected. I checked the one unrelated PromotionCompletedTS assignment I found (distribution/migrator.go:395, wall-clock-derived) and confirmed it's for the same-group no-op path (AdvanceSameGroupNoop), which jumps straight to SplitJobPhaseDone and never reaches ackSplitJobSourceReadDrain — so it can't leak a wall-clock value into this floor.
  • Caller wiring: MIGRATION_STATE_PROBE_KIND_SOURCE_READ_DRAINED is only constructed in production at adapter/split_job_runner.go:390 (confirmed via grep), matching the PR's caller audit claim.
  • Proto change (read_drain_min_ts, field 14) is additive/optional with a zero default and the generated internal.pb.go diff is consistent with standard protoc-gen-go output — safe for rolling upgrades (an un-upgraded coordinator simply never sets it, and the target correctly falls back to strict all-reads-must-drain).
  • New tests (adapter/internal_migration_probe_test.go) cover the important boundaries: pin exactly at the floor (still blocks — correctly conservative on ties), mixed pre/post-cutover pins, empty tracker, and the drainMinTS == 0 legacy fallback. The defer tracker.Pin(ts).Release() pattern inside the table-driven loop is correct — Pin runs eagerly at defer-registration time (inside the per-case t.Run closure), only Release is deferred, so there's no test-only ordering bug.

Spot-checked previously-flagged findings against current HEAD (since several rounds of Codex/Gemini P1/P2 findings landed over this PR's history and I wanted to confirm they're still fixed at HEAD, not just at the commit where they were claimed fixed):

  • kv/fsm_migration_readiness.go:127-146 (migrationMutationsIntersect) — the P1 "empty-prefix DEL_PREFIX skipped" finding is fixed: DEL_PREFIX is special-cased via routePrefixRange(mut.Key) before the len(mut.Key) == 0 skip applies to point writes.
  • kv/shard_store.go:457-468 (readinessRouteRangeForScan) — the P1 "filesystem/S3 bucket scan bounds not normalized for readiness" findings are fixed: it now chains s3keys.ManifestScanRouteBoundss3BucketAuxiliaryScanRouteRangefskeys.ChunkScanRouteBounds before falling back to the raw range.
  • adapter/split_job_runner.go:658-660 (cleanupSplitJobSourceMetadata) — the P2 "incomplete metadata probe treated as success" finding is fixed: it now returns splitMigrationVoterBarrierIncompleteError(...) instead of nil when metadataComplete is false.

No new correctness, data-loss, or concurrency issues found in this commit. TLA+-anchored subsystems (kv/fsm.go, kv/sharded_coordinator.go, store/mvcc_store.go, distribution/**) are unchanged by 75d86c08 itself — only adapter/internal.go, adapter/split_job_runner.go, and the proto files changed, and none of those touch HLC issuance, OCC commit-ts assignment, or MVCC visibility.

Note: As in my prior pass on this PR, go test/go vet/golangci-lint are not invokable in this sandboxed session (tool-approval is required for go/lint commands and isn't available here), so this review is static: full reading of the diff, the surrounding call graph, and the new test assertions, cross-checked against ActiveTimestampTracker's actual pin semantics. The validation commands the author listed in their commit message/PR comments should still be treated as the source of truth for CI-level confirmation.

@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: 75d86c08c9

ℹ️ 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/shard_store.go
Comment on lines +799 to +800
readinessStart, readinessEnd := readinessRouteRangeForScan(start, end)
if err := s.verifyExplicitGroupRoutesForRange(ctx, groupID, routes, readinessStart, readinessEnd); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Apply migration read guards to key-only scans

When a RawScanAt request uses keys_only, especially against an explicit or stale source group during cutover, this new readiness check is bypassed: ScanKeysAtWithReadFence proceeds through scanKeyRouteAtWithReadFence, whose leader and local key-scan paths never call targetReadyRouteForRange. Consequently, an armed source read fence can still return old-source keys, while an armed target guard can return live-only or empty keys during catalog-watcher lag; apply the same readiness proof before key-only scans.

Useful? React with 👍 / 👎.

return gateErr
}
}
return s.runSplitJobPhase(ctx, job)

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 Persist non-transient runner failures before returning

When a phase encounters a persistent error such as a corrupt export/cleanup cursor or a changed source route, this return only passes the error to the outer logging loop. No runner path assigns SplitJobPhaseFailed, RetryPhase, or LastError, so the job remains runnable, is retried every tick, keeps occupying the single live-job slot and any post-cutover guards indefinitely, and can never be acted on through RetrySplitJob; classify and durably record failures requiring operator intervention before returning.

Useful? React with 👍 / 👎.

Comment on lines +1100 to +1101
if pendingLocks {
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Actively drain pending locks before waiting

When ELASTICKV_LOCK_RESOLVER_ENABLED=false and a client leaves an expired prepared lock on a cold key in the moving range, this branch returns successfully forever: ProbeMigrationLocks only counts locks and neither this runner nor the probe resolves or aborts them. Because no foreground read needs to touch that cold key, the job remains in FENCE with the route write-fenced indefinitely; actively run the route-faithful lock resolver here or keep migration capability closed when the background resolver is disabled.

Useful? React with 👍 / 👎.

current.FenceTS = fenceTS
current.FenceCatalogVersion = fenceCatalogVersion
current.FenceAckCursor = distribution.CloneBytes(ackCursor)
current.SourceRetentionPinTS = 1

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 Relax the retention pin after fixing the delta floor

After deltaFloor has been computed, retaining a pin of 1 keeps the group-wide FSM compactor from pruning essentially any historical MVCC versions throughout DELTA_COPY, CUTOVER, and CLEANUP, even though the remaining export window is only (deltaFloor, fenceTS]. A slow or stalled migration can therefore cause unbounded disk growth on the source group; persist and reapply deltaFloor as the source control record's retention pin once that floor is durable.

Useful? React with 👍 / 👎.

scanKeyRouteAtWithReadFence resolved the group and went straight to the
local, leader, or proxy branch without calling targetReadyRouteForRange, so
keys_only reads skipped the readiness proof every value scan performs. An
armed source read fence still handed back keys from the old source, and an
armed target guard handed back live-only or empty keys while the catalog
watcher lagged. RawScanAt with keys_only against an explicit source group
is exactly the shape those fences exist for.

Take the ready route the same way scanRouteAtDirection does, right after
the group lookup and before any branch.

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

bootjp commented Aug 28, 2026

Copy link
Copy Markdown
Owner Author

kv/shard_store.go:800 (P1, "Apply migration read guards to key-only scans") — fixed in 37b1d8f0.

Verified the whole path before changing anything. ScanKeysAtWithReadFence with a non-zero groupID calls scanKeyRouteAtWithReadFence with a synthetic distribution.Route{GroupID: groupID}, and that function resolves the group and goes straight to its local / leader / proxy branch. Neither scanKeysRouteLocal nor scanKeysRouteAtLeader calls targetReadyRouteForRange, and the explicit-group value path's verifyExplicitGroupRoutesForRange (:800) has no counterpart here. So keys_only reads carried no readiness proof at all: an armed source read fence still returned old-source keys, and an armed target guard returned live-only or empty keys while the catalog watcher lagged.

Fix: take the ready route right after the group lookup and before any branch — the same shape scanRouteAtDirectionPhysicalLimit (:2387) and scanReadyLeaderPhysicalLimit (:2425) already use, including rebinding route so the staged-visibility branches see the ready one. That covers both entry points, since the routed path funnels through the same function.

Test: TestShardStoreSourceReadFenceRejectsKeyOnlyScan asserts ErrRouteCutoverPending from ScanKeysAt and from the explicit-group ScanKeysAtWithReadFence, using the same readiness harness as the existing point-read/value-scan test. Revert-checked — without the guard it returns nil.

The three P2s on this PR (adapter/distribution_server.go:310 persistent runner failures, adapter/split_job_runner.go:1101 lock drain with the resolver disabled, :1182 retention pin after the delta floor) are next; I have not verified them yet and am not claiming anything about them.

go test -race ./kv/... ./adapter/... green (kv 14s, adapter 864s); golangci-lint run ./kv/... → 0 issues.

@codex review
@claude review

@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

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


Review: 37b1d8f0 — key-only scan readiness fix

  • Read trigger comment and gather PR context
  • Verify the 37b1d8f0 fix for kv/shard_store.go:800 (P1: key-only scans bypass migration read guards)
  • Check for regressions / correctness of the new test
  • Spot-check current HEAD for other outstanding issues
  • Post final review

Summary

I verified this independently by reading the actual diff and tracing every keys-only call path, not just the commit description. The fix is correct.

Fix correctness (kv/shard_store.go:1697-1704)scanKeyRouteAtWithReadFence now calls s.targetReadyRouteForRange(ctx, g, route, start, end) and rebinds route immediately after the group lookup, before branching into the local/leader/proxy paths. This is the exact same shape used by the established value-scan counterparts scanRouteAtDirectionPhysicalLimit (:2396) and scanReadyLeaderPhysicalLimit (:2434) — call right after groupForID, before any dispatch, rebind route so downstream routeHasStagedVisibility(route) checks see the post-readiness route. No deviation from the established idiom.

Coverage check — I traced every keys-only entry point to confirm none bypasses the fixed choke point:

  • ScanKeysAtScanKeysAtWithReadFence (:737)
  • ScanKeysAtWithReadFence with explicit groupID != 0scanKeyRouteAtWithReadFence directly (:748), the exact case named in the finding
  • ScanKeysAtWithReadFence routed path → scanKeyRoutesAtWithReadFence → ultimately scanKeyRouteAtWithReadFence (:1486, :1603)
  • ScanGroupKeysAt (:854) → scanKeyRouteAt (:1671) → scanKeyRouteAtWithReadFence (thin wrapper, :1679)

All four funnel through the single function that now performs the readiness proof, so the fix isn't just patching one caller — it closes the gap at the shared choke point. I did not find a keys-only path that still skips it.

scanKeysRouteAtLeader (:1750) doesn't re-check readiness itself, but that's correct, not a gap — it now only ever receives the already-validated route from its caller, matching how scanRouteAtLeader in the value-scan path relies on the same upstream check rather than re-verifying.

Test (kv/shard_store_test.go new TestShardStoreSourceReadFenceRejectsKeyOnlyScan) — arms a source read+write fence over [m, z), then asserts ErrRouteCutoverPending from both ScanKeysAt (routed) and ScanKeysAtWithReadFence(..., groupID=1, ...) (explicit-group, the case named in the original finding). Uses the same newReadinessShardStore/applyTargetReadinessState harness as the existing point-read test (TestShardStoreSourceReadFenceRejectsPointRead), so it's consistent with established test patterns rather than a one-off. Both assertions are meaningful — dropping the fix would make either fail.

No correctness issues found in this commit. The three P2s the author flagged as "next" (adapter/distribution_server.go:310 persistent runner failures, adapter/split_job_runner.go:1101 lock drain with resolver disabled, :1182 retention pin relaxation) are explicitly unaddressed by this commit and unaddressed by me — the author was clear those haven't been verified yet, so there's nothing to review there until a follow-up lands.

Note: go test/golangci-lint are not invokable in this sandboxed session (tool-approval for go/lint commands isn't available here), consistent with prior review passes on this PR. This review is based on full static reading of the diff, tracing every caller of the fixed function, and comparing against the established readiness-check idiom used elsewhere in the same file. The go test -race ./kv/... ./adapter/... and golangci-lint results the author reported in the trigger comment should be treated as the source of truth for CI-level confirmation.

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: 37b1d8f088

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

Copy link
Copy Markdown
Owner Author

I attempted the base-branch merge (design/hotspot-split-m2-cross-group, now carrying origin/main) and stopped rather than guess, because one conflict is a snapshot on-disk format decision that belongs to you, not to a merge resolution.

What is resolvable and what I already worked out:

  • 22 files conflict. The generated protos (distribution.pb.go 41 hunks, service.pb.go, distribution_grpc.pb.go) regenerate cleanly from the merged .proto files, which do not conflict.
  • The design-doc conflict is a lifecycle rename collision: this branch renamed the M2 migration doc *_proposed_* → *_implemented_*, the base renamed it → *_partial_*. This branch's text is the later state (it records PR Complete hotspot split M2 migration lifecycle #1096 completing the runner), so *_implemented_* wins and the partial path is dropped; the two scaling-roadmap docs then take this branch's links to the implemented filename.

The blocker is store/mvcc_store.go (11 hunks) with store/lsm_store.go (7) and their snapshot tests: both sides advanced the MVCC snapshot format independently and assigned the same version number to different payloads

  • this branch: mvccSnapshotVersion = 2, adding readiness states (maxSnapshotReadinessStateCount) and writeSnapshotMigrationMetadataLocked;
  • the base: mvccSnapshotVersion = 3 with mvccSnapshotVersionV1/V2, and a writeMVCCSnapshotMetadata carrying migration acks, HLC floors, and promotions.

Picking a layout here decides what a V3 snapshot means on disk, and getting the write and read sides subtly out of step corrupts snapshot round-trips rather than failing loudly. The mechanical-looking answer — base's V3 as the substrate, this branch's readiness section appended as V4 — is probably right, but it is a format decision with a data-loss failure mode, so I would rather you confirm the intended version sequence and section order than have me invent it.

Say which way you want it and I will do the merge and the round-trip verification in the next pass. The branch is untouched; nothing half-merged was left behind.

@codex review
@claude review

@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

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


Snapshot version decision

  • Gather context: current branch vs. base branch state
  • Examine this branch's store/mvcc_store.go snapshot version logic
  • Confirm what's actually shipped on main today
  • Decision + rationale

Environment note first: this session is a shallow, network-disabled sandbox — git fetch/git fetch --unshallow require approval I don't have here, so I could not check out origin/design/hotspot-split-m2-cross-group's actual store/mvcc_store.go/lsm_store.go bytes to verify the base branch's V2/V3 payload layout directly. The recommendation below is built from what you already characterized precisely in the trigger comment plus what I could verify locally on this branch and on origin/main.

Decision: don't stack V2 → V3 → V4. Collapse into one unified V2.

The deciding fact: git show origin/main:store/mvcc_store.go shows mvccSnapshotVersion = 1 (legacy) is all that has ever shipped to main. Neither this branch's V2 (readiness states, added in 54cc7b9f/dcb7744f) nor the base branch's V2/V3 (acks + HLC floors + promotions, from design/hotspot-split-m2-store-export) has ever been persisted by a released build or a real cluster — both are dev-only formats living on unmerged feature branches, regenerated fresh in every test run. There is no live on-disk data anywhere depending on "2" meaning one thing vs. "3" meaning another. So the apparent format collision is a naming coincidence between two branches evolving toward the same milestone in parallel, not a real backward-compat obligation between two shipped formats.

Given that, defining V3 as "base's format" and V4 as "base + this branch's readiness section" would permanently bake an intermediate, never-shipped dev format into the version history for no compatibility benefit — and it's the riskier path precisely because it invites exactly the kind of silent field-order mismatch you're worried about (two authors independently deciding what "V3" vs "V4" byte-for-byte contains, verified only by each branch's own tests, not by a merged round-trip test).

Concrete resolution:

  1. Keep mvccSnapshotLegacyVersion = 1 unchanged.
  2. Land a single merged mvccSnapshotVersion = 2 that is the union of both branches' sections: readiness states (this branch) + import acks + HLC floors + promotions (base), each as its own explicitly-framed, independently-optional section (count/flag prefix, as both branches already do per-section) rather than one monolithic blob — so a section being empty doesn't perturb the others, and a future section can be appended later without another full version bump.
  3. Fix section order once, in the merged writeSnapshotMigrationMetadataLocked: readiness states → import acks → HLC floors → promotions. Mirror the identical order on the Pebble/streaming path (readStreamingMVCCRestoreHeader / writeStreamingMVCCRestoreTempDB in lsm_store.go) so both snapshot mechanisms describe the same wire format.
  4. Keep this branch's existing gate — only emit v2 when any migration-related state is non-empty (readiness cache, import acks, HLC floors, or promotions), else emit legacy v1 — extending the current len(s.migrationReadinessCache) != 0 || ... check with an OR across all four.
  5. Round-trip test matrix after the merge: all-empty (v1), each section present alone, and all four present together — extend store/mvcc_store_snapshot_test.go and store/lsm_store_test.go rather than trusting either branch's existing per-feature tests to catch a merge-induced field-order bug.

One risk I can't rule out from here: if some other in-flight branch or a running dev/Jepsen environment already has real V2- or V3-tagged snapshot files on disk that need to survive this merge, the "nothing has shipped" argument doesn't hold and you'd need the versioned-tiers approach instead. From what's visible in this PR's history that looks unlikely (M2 is still *_partial_* per the design doc lifecycle), but you're better positioned than I am to know if such an artifact exists.

If you agree with the merged-V2 approach, I can do the merge and round-trip verification in the next pass — just confirm and I'll proceed (this branch is otherwise untouched, no changes pushed).
· branch design/hotspot-split-m2-promotion-complete

@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-29T11:15:41.658478Z f81057b 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.

@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: 37b1d8f088

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

MaxScannedBytes: maxScannedBytes,
KeyFamily: req.GetKeyFamily(),
AcceptVersion: func(key, value []byte) bool {
return bracket.ContainsRoutedVersion(key, value, req.GetRouteStart(), req.GetRouteEnd(), routeKey)

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 resolver-owned SQS rows during route cleanup

When the migrated interval contains !sqs|route|global and HT-FIFO partitions remain assigned to the source group, this predicate treats every partitioned SQS row as belonging to the catalog route because routeKey collapses those keys to the global SQS key. The export path instead uses RouteKeyFilterForGroup and the partition resolver, but neither the catalog cutover nor this cleanup updates that resolver; requests therefore continue routing those partitions to the source while CLEANUP deletes their rows, causing message loss. Pass the partition-aware ownership predicate through cleanup, or reject catalog migrations that intersect resolver-owned SQS data.

Useful? React with 👍 / 👎.

Comment thread adapter/split_job_runner.go Outdated
Comment on lines +1002 to +1003
if !bytes.Equal(importResp.GetAckedCursor(), nextCursor) {
return errors.New("split migration import acknowledged a different cursor")

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 Recover when the importer is ahead of catalog progress

When the target applies a batch but the runner dies before persistSplitJobCopyProgress, the target is one sequence ahead of the durable job. If a concurrent source write changes the replayed chunk boundary before restart, re-exporting from the old cursor produces a different nextCursor; the importer correctly treats the repeated sequence as a duplicate and returns its previously acknowledged cursor, but this comparison rejects that recovery state forever. The job then remains live with its migration guards and retention pin held, so reconcile progress from the target's durable acknowledgement instead of requiring the newly replayed chunk to have the same boundary.

Useful? React with 👍 / 👎.

Comment on lines +826 to +827
if route.ParentRouteID != job.SourceRouteID {
continue

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 Retain the source group across disjoint sibling splits

After FENCE replaces the original route with left and right children, SplitRange intentionally permits another split wholly within the disjoint left child. That operation replaces the left child with grandchildren whose ParentRouteID is the left child's ID, so this filter can no longer find the route ending at job.SplitKey; once the moving right child belongs to the target, neither the original parent nor a matching direct sibling remains from which to recover the source group. CLEANUP then returns ErrMigrationSourceRouteChanged permanently after cutover, retaining guards and the live job. Persist the source group in the job, follow route ancestry, or prevent sibling rewrites while it is needed.

Useful? React with 👍 / 👎.

bootjp added 2 commits August 29, 2026 15:44
A runner that dies after the target durably acknowledged an import batch but
before persisting its own progress replays that batch on restart. The source
keeps taking writes while the copy runs, so the replayed chunk can end on a
different boundary than the one the target accepted; the target then answers
as a duplicate with its own recorded cursor, and the strict boundary check
rejected that recovery state on every retry, leaving the job live with its
migration guards and retention pin held.

store.ImportVersionsResult already reported Duplicate on both backends and
every caller dropped it. Carry it on ImportRangeVersionsResponse and let the
runner adopt the target's durable acknowledgement: cursor and batch sequence
advance, the replayed rows are not counted again, and the stream is restarted
from the adopted cursor rather than continuing on a boundary the target never
accepted.

Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
FENCE replaces the source route with a left child that keeps the source group
and a right child that moves to the target. SplitRange then permits another
split wholly inside the left child, since that range is disjoint from the
moving one, and it replaces the left child with grandchildren that name the
left child as their parent. From then on no route ends at the split key under
the original parent, and the parent itself is already gone, so the group still
holding the source data cannot be recovered from the route shape at all:
cleanup failed with ErrMigrationSourceRouteChanged on every attempt after
cutover and kept the job live with its migration guards and retention pin held.

Record the source group on the job at plan time, where it is known and where it
cannot be rewritten by a later split, and consult it after the route shape and
the original parent have both been tried. Jobs written before the field decode
with group 0 and keep resolving exactly as they did. The three call sites that
each carried their own copy of the parent fallback now share one resolver.

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

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

ℹ️ 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/internal.go
Comment on lines +562 to +563
case req.GetMode() == pb.MigrationCleanupMode_MIGRATION_CLEANUP_MODE_VERSIONS && req.GetKeyFamily() == 0:
return errors.WithStack(status.Error(codes.InvalidArgument, "migration cleanup key_family is required"))

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 fully unbounded version cleanup requests

When the cleanup opcode gate is open, a VERSIONS request with a nonzero job_id and key_family but omitted range_start, range_end, route_start, and route_end passes this validation. migrationCleanupOptionsFromProto then creates a bracket with no route check, and the store treats the nil raw bounds as a full-keyspace scan; if max_commit_ts is also omitted, zero means no timestamp ceiling. Such a malformed internal RPC therefore Raft-replicates deletion of arbitrary user versions across the entire group (up to the per-call limit), so require a raw or route bound for version cleanup, analogous to export validation.

Useful? React with 👍 / 👎.

routeKey collapses every HT-FIFO partitioned SQS row to the single global SQS
route key, so a migrated interval covering that key looked to cleanup like it
owned every partition -- including the ones the resolver still routes to this
group, whose rows the export never claimed. Cleanup deleted them while requests
kept being served from here, losing messages.

Apply cannot ask the resolver which partitions moved: it is process-local
config (--sqsFifoPartitionMap), not replicated, so a per-node answer would make
FSM apply diverge across replicas. Recognise the partitioned shape structurally
instead -- a prefix test over compile-time constants, identical on every
replica -- and leave that keyspace alone: catalog route cleanup deletes only
catalog-routed data. Retiring a partition's rows belongs to the resolver's own
migration path.

kv.IsPartitionedSQSKey carries its own copy of the family prefixes, so it is
pinned against keys built by the adapter's real constructors.

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

bootjp commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

b5eea8c1 b2167678 f81057b5 — all three P1s at HEAD are addressed.


1. adapter/split_job_runner.go:1003 — "Recover when the importer is ahead of catalog progress"

Confirmed, and the signal already existed. I traced the loop: the runner sends BatchSeq = progress.LastAckedBatchSeq + 1; the target's validateNextImportBatch (store/migration_versions.go:416) returns duplicate for any seq at or below its durable ack and answers with its own recorded cursor. store.ImportVersionsResult has carried a Duplicate field on both backends since this stack landed (lsm_migration.go:342, migration_versions.go:644) — and it had zero consumers: adapter/internal.go built the response from AckedCursor alone, so the wire never carried it and the runner could only see a cursor that did not match.

Carried it through (ImportRangeVersionsResponse.duplicate, regenerated with buf) and reconciled: on a duplicate the runner adopts the target's cursor, advances the batch sequence, and returns so the export restarts from that cursor — the rest of the stream is cut relative to a boundary the target never accepted. AcceptedRows and MaxImportedTS deliberately stay put: this runner never observed which rows were accepted, and both are bookkeeping (I grepped — MaxImportedTS is written and persisted but read by nothing that makes a decision; the durable HLC floor lives on the target via MigrationHLCFloor).

The strict boundary check survives for the non-duplicate case, so a target acknowledging a cursor it was never sent is still an error.

Tests (adapter/split_job_import_replay_test.go): the target answers ImportRangeVersions out of a real store.MVCCStore, so the duplicate verdict is production validateNextImportBatch reading a real durable ack, not a stubbed answer. The lost attempt is modelled by importing batch 1 at cursor-a; the restart then replays batch 1 from an export that ended at cursor-b. Revert-checked: without the fix it fails at split_job_runner.go:1003 with "acknowledged a different cursor".

2. adapter/split_job_runner.go:827 — "Retain the source group across disjoint sibling splits"

Confirmed. liveSplitJobIntervals (adapter/distribution_server.go:1545) only blocks a split of a source child that intersects [splitKey, ∞), so splitting the left child is permitted; its grandchildren then carry ParentRouteID = left child, and splitJobSourceSibling finds no route ending at the split key. The parent fallback that all three call sites carried does not help either — the parent is already gone after fence.

Recorded source_group_id on the job at plan time (InitializeSplitJobPlan, where the source route is in hand and cannot be rewritten later) and consult it only after the route shape and the original parent have both been tried, so a job written before the field resolves exactly as it did. The three duplicated fallbacks are now one splitJobSourceRouteState. Jobs decode with group 0 and keep the old behaviour.

Tests (adapter/split_job_source_group_test.go): four cases — sibling split survived, intact route shape still authoritative, pre-fence parent fallback, and an unresolvable source still reporting failure rather than handing back group 0. Revert-checked.

3. kv/fsm_migration_cleanup.go:82 — "Preserve resolver-owned SQS rows during route cleanup"

Mechanism confirmed; the first suggested remedy is unsafe and I did not take it. The report offers "pass the partition-aware ownership predicate through cleanup, or reject catalog migrations that intersect resolver-owned SQS data". The first cannot be done: applyMigrationCleanup runs inside FSM apply, and the resolver is process-local config (--sqsFifoPartitionMap), not replicated — consulting it there would let two replicas reach different delete decisions for the same committed entry. That is the determinism rule the 8668bdce revert exists for.

I also did not take the second, because it is more restrictive than the defect requires. The asymmetry is that export (RouteKeyFilterForGroup, adapter/internal.go:903, running outside apply) consults the resolver while cleanup collapses every partitioned row to !sqs|route|global via routeKey. The deterministic half of the resolver's contract is available without the resolver: PartitionResolver.RecognisesPartitionedKey is documented (kv/shard_router.go:39-45) as answering on key shape only, never on the routing map. So cleanup now recognises the partitioned shape structurally — a prefix test over compile-time constants, identical on every replica — and leaves that keyspace alone entirely. Catalog route cleanup deletes only catalog-routed data; retiring a partition's rows belongs to the resolver's own migration path. Leaking a migrated partitioned row is recoverable; deleting one the resolver still routes here is not.

kv.IsPartitionedSQSKey carries its own copy of the family prefixes (the same duplication internal/backup/sqs.go:47 already accepts), so adapter/sqs_partitioned_cleanup_alignment_test.go pins it against keys built by the adapter's real constructors — all five partitioned families recognised, legacy and queue-meta layouts not.

Sweep. ContainsRoutedVersion/ContainsRawKey have exactly three call sites: this one, and two in the export path that already apply the resolver filter and are non-destructive anyway. No sibling.

Verification: go test -race ./kv/ ./distribution/ ./adapter/ all pass (kv 14s, distribution 1s, adapter 656s) — run again after the last two commits. golangci-lint ./adapter/... ./distribution/... ./kv/... 0 issues.

Self-review passes. Data loss: fix 3 is the data-loss fix; fixes 1 and 2 each release a job that was permanently stuck holding its migration guards and retention pin. Concurrency: fix 1 changes recovery ordering only — the duplicate branch returns before touching the rest of the stream; no new shared state. Performance: one prefix test per cleaned key, one map lookup per import batch. Consistency: source_group_id is additive and defaults to the previous resolution; the import ack semantics are the target's existing durable record, now merely visible. Test coverage: three revert-checked suites, one of which drives the real store.

Still open on this PR: the branch is CONFLICTING against main. The conflict is the mvccSnapshotVersion collision — this branch is at 2 with readiness states, main is at 3 with migration acks/HLC floors/promotions. Resolving it means choosing what the merged on-disk snapshot layout is, which I am not deciding unilaterally. #1088 now emits the oldest layout that fits the state, which may make the merge mechanical; I will re-check once that lands.

@codex review
@claude review

@claude

claude Bot commented Aug 29, 2026

Copy link
Copy Markdown

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


Review: HEAD f81057b5 — three P1 fixes verified

  • Read trigger comment and gather PR context
  • Verify fix 1: importer-ahead-of-catalog recovery (b5eea8c1)
  • Verify fix 2: source group persisted across sibling splits (b2167678)
  • Verify fix 3: resolver-owned SQS rows preserved during cleanup (f81057b5)
  • Spot-check new tests for each fix
  • Post final review

I re-verified each fix independently by reading the actual diffs and tracing the call graphs, not just the summary. All three hold up.

1. b5eea8c1 — importer-ahead-of-catalog recovery

Confirmed: store.ImportVersionsResult.Duplicate was already set correctly by both backends (store/migration_versions.go:644, store/lsm_migration.go:342) from validateNextImportBatch (batchSeq <= existing.batchSeq → duplicate, returns the durably-recorded cursor), but adapter/internal.go's ImportRangeVersions handler dropped the field when building the gRPC response — so the wire never carried it. Now it does (ImportRangeVersionsResponse.duplicate, additive field 2, safe for rolling upgrade — an old target simply never sets it and the runner keeps the old strict-boundary behavior).

adoptDuplicateImportAck advances only Cursor and LastAckedBatchSeq, explicitly leaving AcceptedRows/MaxImportedTS/Done untouched, with the reasoning spelled out in a comment. I checked whether adopting only +1 to LastAckedBatchSeq is safe even if the target's actual ack is more than one batch ahead (possible if multiple batches landed before a local persist failure): since existing.cursor is always the target's true latest state regardless of how far the sent batchSeq trails, the next resend from the adopted cursor will just repeat the duplicate/adopt cycle harmlessly until LastAckedBatchSeq catches up — no data corruption, just extra round-trips in an already-rare recovery path.

The strict boundary check correctly survives for genuine mismatches (target acking a cursor it wasn't sent, without claiming duplicate).

Test (adapter/split_job_import_replay_test.go): realImportTargetStub answers out of a real store.MVCCStore, so the duplicate verdict is production store.ImportVersions/validateNextImportBatch, not a stub. TestCopySplitJobStreamAdoptsDuplicateImportAck models exactly the described race (batch 1 accepted at cursor-a, replay after restart re-exports through cursor-b) and asserts the adopted cursor, advanced batch seq, and Done staying false. TestCopySplitJobStreamRejectsUnexplainedCursorMismatch pins the non-duplicate error path via a lying-target stub. Both are meaningful (revert-checked per the author).

2. b2167678 — source group survives disjoint sibling splits

Confirmed, and I traced the specific mechanism the fix depends on: splitJobSourceSibling scans all routes and independently tracks routeEnd (from the route whose Start == splitKey, i.e. the moved/target child) and sourceGroupID (from the route whose End == splitKey, i.e. the source child). When the source-side left child is split further via same-group SplitRange, only the second lookup breaks (grandchildren's ParentRouteID no longer equals job.SourceRouteID) — the untouched target child still satisfies the first lookup, so routeEnd is correctly preserved even while ok=false. splitJobSourceRouteState's final fallback (job.SourceGroupID != 0) correctly reuses that still-valid routeEnd rather than losing it, which the new test TestSplitJobSourceRouteStateSurvivesDisjointSiblingSplit explicitly pins (routeEnd == []byte("z")).

One residual scenario I did not see addressed (not a defect in this fix, just noting the boundary): if the target-side child is itself later split in-place (via ordinary same-group SplitRange, which per docs/architecture_overview.md/CLAUDE.md is unrelated to the cross-group split-job machinery and isn't blocked by the single-in-flight-job guard), then routeEnd resolution would also fail, and the job.SourceGroupID fallback would return a nil/stale routeEnd silently (ok=true but an unbounded range). This is a narrower, pre-existing gap in the same class of problem (no persisted "moved range end" analogous to SourceGroupID) — worth a follow-up if it's reachable in practice, but out of scope for what this commit claims to fix.

Field is additive (source_group_id = 35), decodes to 0 for old jobs, and the resolver tries live-route-shape paths first — so behavior for already-encoded jobs is unchanged, confirmed by TestSplitJobSourceRouteStateStillReadsTheRouteShape.

3. f81057b5 — resolver-owned SQS rows excluded from cleanup

Confirmed the structural-recognition approach is sound and necessary (an FSM-apply-time resolver lookup would be non-deterministic across replicas, as noted). I checked kv.sqsPartitionedFamilyPrefixBytes (5 entries: data/vis/dedup/group/byage, each prefix + "p|") against adapter/sqs_keys.go's SqsPartitioned*Prefix constants — the discriminator (sqsPartitionedDiscriminator = "p|") matches kv.sqsPartitionMarker exactly, and the 5 families are exhaustive (grepped for every SqsPartitioned*Prefix declaration in the adapter package — no 6th family exists that kv is missing). Queue metadata (sqsQueueMetaPrefix, etc.) is correctly excluded from the partitioned-shape list, matching "only message families are partitioned."

The guard is placed before the route-key check in the AcceptVersion closure, so partitioned rows are unconditionally exempt from catalog-driven version cleanup regardless of route range — correctly conservative per "retiring a partition's rows belongs to the resolver's own migration path."

Tests: adapter/sqs_partitioned_cleanup_alignment_test.go builds keys via the adapter's real constructors (sqsPartitionedMsgDataKey, sqsPartitionedMsgVisKey, etc.) and asserts kv.IsPartitionedSQSKey recognizes all 5 plus correctly excludes legacy/queue-meta layouts — this is exactly the alignment pin needed since the two packages maintain independent copies of the prefix list. kv/fsm_migration_cleanup_sqs_test.go exercises migrationCleanupOptionsFromProto's AcceptVersion end-to-end, confirming a partitioned row collapsing to sqsGlobalRouteKey is rejected while a legacy row in the same range is still accepted (so the guard doesn't disable cleanup wholesale).

Verification limits

As in prior review passes on this PR, go build/go test/golangci-lint are not invokable in this sandboxed session (tool approval for go/lint commands isn't available here). This review is based on full static reading of all three diffs, the modified call graphs, and the new/existing test assertions — cross-checked against the actual store.ImportVersions, splitJobSourceSibling, and adapter SQS key-construction implementations rather than trusting the commit messages at face value. The go test -race ./kv/ ./distribution/ ./adapter/ and golangci-lint results the author reported should be treated as the source of truth for CI-level confirmation.

No blocking issues found in any of the three fixes.

The branch is still noted as CONFLICTING against main (the mvccSnapshotVersion collision from the earlier merge-decision comment) — unrelated to these three commits, no action taken here per the author's note that it's pending re-check after #1088.
· branch design/hotspot-split-m2-promotion-complete

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: f81057b5fd

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

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