Skip to content

migration: add range version RPC handlers - #1088

Open
bootjp wants to merge 8 commits into
design/hotspot-split-m2-store-exportfrom
design/hotspot-split-m2-cross-group
Open

migration: add range version RPC handlers#1088
bootjp wants to merge 8 commits into
design/hotspot-split-m2-store-exportfrom
design/hotspot-split-m2-cross-group

Conversation

@bootjp

Copy link
Copy Markdown
Owner

Summary

  • add Internal ExportRangeVersions and ImportRangeVersions handlers backed by the local group store
  • wire per-group stores into Internal server registration
  • preserve staged migration route metadata in the serving route engine snapshot

Tests

  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./adapter ./distribution ./kv ./store -run 'TestInternal|TestMigration|TestExportVersions|TestImportVersions|TestEngineApplySnapshot|TestShardStore|TestLeaderRoutedStore' -count=1 -timeout=180s\n- GOCACHE=$(pwd)/.cache GOLANGCI_LINT_CACHE=$(pwd)/.golangci-cache golangci-lint run ./adapter ./distribution ./kv ./store --timeout=5m\n\nAuthor: bootjp

@coderabbitai

coderabbitaiBot 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: a4d66985-5e16-4b7f-bd86-12c1a11b3275

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 (64dc6f4):

  • distribution/engine.go
  • distribution/engine_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.

@chatgpt-codex-connector

Copy link
Copy Markdown

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

@gemini-code-assistgemini-code-assistBot 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 adds support for range migrations by implementing ExportRangeVersions and ImportRangeVersions on the internal gRPC server, alongside updating the distribution Route metadata to track migration states. Feedback focuses on adding a defensive check in ExportRangeVersions to prevent infinite loops when the cursor does not progress, and removing an unreachable nil guard on i.leader in verifyInternalLeader to comply with guidelines against dead defensive code, which also requires updating the corresponding unit tests to use a mock leader.

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 threadadapter/internal.go
Comment threadadapter/internal.go
Comment threadadapter/internal_migration_test.go
Comment threadadapter/internal_migration_test.go Outdated
Comment threadadapter/internal_migration_test.go Outdated

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:64dc6f4239

ℹ️ 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 threadadapter/internal.go Outdated
Comment threadadapter/internal.go
Comment threadadapter/internal.go
Comment threadadapter/internal.go
Comment threadadapter/internal.go Outdated
@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 (59a1672):

  • distribution/engine.go
  • distribution/engine_test.go
  • 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.

@bootjp

Copy link
Copy Markdown
OwnerAuthor

@codex review

Updated in 59a1672:

  • Route ImportRangeVersions through the target-group Raft/FSM path before ACK.
  • Observe the imported HLC floor before acknowledging non-empty imports.
  • Carry export key_family and bracket exclusions through the internal RPC.
  • Reject unbounded max_commit_ts exports and non-progressing export cursors.

Validation:

  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./adapter ./kv ./store -run 'TestInternal|TestMigration|TestExportVersions|TestImportVersions|TestApply|TestRouteKeyFilter|TestShardStore|TestLeaderRoutedStore' -count=1 -timeout=180s
  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./distribution -count=1 -timeout=120s
  • GOCACHE=$(pwd)/.cache GOLANGCI_LINT_CACHE=$(pwd)/.golangci-cache golangci-lint run ./adapter ./distribution ./kv ./store --timeout=5m
  • git diff --check

Note: a broader go test ./adapter ./distribution ./kv ./store -count=1 -timeout=240s timed out in the adapter integration suite; the targeted touched-path tests above passed.

@bootjp
bootjpforce-pushed the design/hotspot-split-m2-cross-group branch from 59a1672 to fbd7f56CompareJuly 13, 2026 14:32
@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 (fbd7f56):

  • distribution/engine.go
  • distribution/engine_test.go
  • 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.

@bootjp

Copy link
Copy Markdown
OwnerAuthor

@codex review

Rebased onto the latest design/hotspot-split-m2-fence-drain head in fbd7f56 after #1087 advanced. The migration import/export fixes remain intact and now include the parent broad DEL_PREFIX fence fix.

Validation:

  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./adapter ./kv ./store -run 'TestInternal|TestMigration|TestExportVersions|TestImportVersions|TestApply|TestRouteKeyFilter|TestShardStore|TestLeaderRoutedStore|TestRoutePrefixRange|TestFSMRejects.*WriteFenced|TestShardedCoordinatorRejects.*WriteFenced' -count=1 -timeout=180s
  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./distribution -count=1 -timeout=120s
  • GOCACHE=$(pwd)/.cache GOLANGCI_LINT_CACHE=$(pwd)/.golangci-cache golangci-lint run ./adapter ./distribution ./kv ./store --timeout=5m
  • git diff --check

@chatgpt-codex-connector

Copy link
Copy Markdown

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

@bootjp

Copy link
Copy Markdown
OwnerAuthor

@codex review

Updated in 0fe341d:

  • Serve GetRouteOwnership from the exact RouteHistorySnapshot requested by catalog_version.
  • Serve GetIntersectingRoutes from the same versioned snapshot, including open-ended ranges.
  • Return NotFound for catalog versions outside the retained route-history window and FailedPrecondition when the distribution engine is absent.

Validation:

  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./adapter -run 'TestDistributionServer(GetRouteOwnership|GetIntersectingRoutes|OwnershipRPCs)' -count=1 -timeout=120s
  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./adapter ./distribution -run 'TestDistributionServer|TestEngineSnapshotAt|TestEngineGetIntersectingRoutes|TestRouteHistorySnapshot' -count=1 -timeout=180s
  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./adapter ./kv ./store -run 'TestInternal|TestMigration|TestExportVersions|TestImportVersions|TestApply|TestRouteKeyFilter|TestShardStore|TestLeaderRoutedStore|TestDistributionServer(GetRouteOwnership|GetIntersectingRoutes|OwnershipRPCs)' -count=1 -timeout=180s
  • GOCACHE=$(pwd)/.cache GOLANGCI_LINT_CACHE=$(pwd)/.golangci-cache golangci-lint run ./adapter ./distribution --timeout=5m
  • git diff --check

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

  • distribution/engine.go
  • distribution/engine_test.go
  • 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.

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

  • distribution/engine.go
  • distribution/engine_test.go
  • distribution/migrator.go
  • distribution/migrator_export_plan_test.go
  • 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.

@bootjp

Copy link
Copy Markdown
OwnerAuthor

@codex review

Updated in 7d3b01c:

  • ImportRangeVersions now stages imported MVCC versions under the target-local !dist|migstage|<job_id>|<raw_key> shadow keyspace instead of writing raw user keys directly.
  • Added staged-data key helpers for job-scoped scans and future promotion/read-merge code to recover the original raw key.
  • Updated RPC/FSM tests to assert staged writes, raw-key absence before promotion, HLC floor preservation, and key/value clone safety.

Validation:

  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./distribution -run 'TestMigrationStagedDataKeyRoundTrip|TestPlanMigrationBrackets|TestValidateMigrationRouteRange' -count=1 -timeout=120s
  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./adapter ./kv ./store -run 'TestInternal|TestMigration|TestImportVersions|TestExportVersions|TestApplyMigration|TestDistributionServer(GetRouteOwnership|GetIntersectingRoutes|OwnershipRPCs)' -count=1 -timeout=180s
  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./adapter ./distribution ./kv ./store -run 'TestInternal|TestMigration|TestExportVersions|TestImportVersions|TestApply|TestRouteKeyFilter|TestShardStore|TestLeaderRoutedStore|TestDistributionServer(GetRouteOwnership|GetIntersectingRoutes|OwnershipRPCs)' -count=1 -timeout=240s
  • GOCACHE=$(pwd)/.cache GOLANGCI_LINT_CACHE=$(pwd)/.golangci-cache golangci-lint run ./adapter ./distribution ./kv --timeout=5m
  • git diff --check

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

  • distribution/engine.go
  • distribution/engine_test.go
  • distribution/migrator.go
  • distribution/migrator_export_plan_test.go
  • 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.

@bootjp

Copy link
Copy Markdown
OwnerAuthor

@codex review

Updated in e7f69ef:

  • Route reads with staged_visibility_active now merge live raw keys with target-local staged keys for GetAt, ScanAt, ReverseScanAt, and LatestCommitTS.
  • The merge chooses the newest MVCC version across live/staged candidates, preserves tombstone and TTL winner semantics, and maps staged keys back to their original raw keys.
  • Physical-limit scans on staged-visible routes now fail closed because the staged/live export merge cannot preserve the physical scan budget exactly.

Validation:

  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./kv -run 'TestShardStore(GetAt_MergesStagedVisibility|ScanAndLatestCommitTS_MergeStagedVisibility)' -count=1 -timeout=120s
  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./kv -run 'TestShardStore|TestMergeAndTrim|TestScanLockBounds' -count=1 -timeout=180s
  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./kv -count=1 -timeout=240s
  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./adapter ./store -run 'TestInternal|TestMigration|TestImportVersions|TestExportVersions|TestApply|TestRouteKeyFilter|TestShardStore|TestLeaderRoutedStore|TestGRPCServer_Raw' -count=1 -timeout=180s
  • GOCACHE=$(pwd)/.cache GOLANGCI_LINT_CACHE=$(pwd)/.golangci-cache golangci-lint run ./kv --timeout=5m
  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./adapter ./distribution ./kv ./store -run 'TestInternal|TestMigration|TestExportVersions|TestImportVersions|TestApply|TestRouteKeyFilter|TestShardStore|TestLeaderRoutedStore|TestDistributionServer(GetRouteOwnership|GetIntersectingRoutes|OwnershipRPCs)' -count=1 -timeout=240s
  • git diff --check

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:e7f69efd9e

ℹ️ 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 threadkv/shard_store.go Outdated
func latestMVCCVersionAt(ctx context.Context, st store.MVCCStore, key []byte, ts uint64) (store.MVCCVersion, bool, error) {
result, err := st.ExportVersions(ctx, store.ExportVersionsOptions{
StartKey: key,
EndKey: nextScanCursor(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.

P1 Badge Use exact-key export bounds for staged point reads

When the backing store is Pebble, ExportVersions converts EndKey to encodeKey(EndKey, MaxUint64) (store/lsm_migration.go), and nextScanCursor(key) (key + 0x00) sorts before normal encoded versions of key because the timestamp suffix begins with ^commitTS. On a staged-visible route backed by Pebble, both the live and staged point exports therefore return no candidates for ordinary keys, so GetAt can incorrectly return ErrKeyNotFound even though the key exists. Use a true single-key upper bound/exact-key filter instead of the scan cursor here.

Useful? React with 👍 / 👎.

Comment threadkv/shard_store.go Outdated
ts uint64,
reverse bool,
) ([]*store.KVPair, error) {
live, err := collectLatestLogicalVersions(ctx, g.Store, start, end, start, end, ts, liveLogicalVersionKey)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve scan limits while merging staged visibility

For staged-visible routes, this call starts an export loop that runs until result.Done, and the caller only applies limit after collecting both the full live range and the full staged prefix into maps. A user scan such as ScanAt(..., limit=100) over a migrated production range can therefore read and retain every version in the route before returning one page, which is a large latency/memory regression versus the previous bounded store scan. Please stop the merge once enough ordered candidates are available or otherwise page the merge by the requested limit.

Useful? React with 👍 / 👎.

Comment threadkv/shard_store.go
Comment threadkv/shard_store.go Outdated
Comment threadadapter/internal.go Outdated
Comment threaddistribution/engine.go
@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 (ca1a050):

  • distribution/engine.go
  • distribution/engine_test.go
  • distribution/migrator.go
  • distribution/migrator_export_plan_test.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.

@bootjp

Copy link
Copy Markdown
OwnerAuthor

@codex review

Updated in ca1a050:

  • Bound staged visibility point reads to the exact key and reject compacted staged reads before merging.
  • Preserve staged scan limits with bounded candidate windows, and fail closed for physical-limit scans when staged visibility is active.
  • Route S3 bucket meta/generation exports by decoded bucket name so route filters match logical ownership.
  • Enforce migration write timestamp floors at coordinator, FSM, and ShardStore write paths.

Validation:

  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./kv -run 'TestFSMRejects.*MigrationTimestampFloor|TestFSMRejectsCommitButNotPrepareAtMigrationTimestampFloor|TestShardStore(GetAt_MergesStagedVisibilityPebbleExactKey|StagedVisibilityReadTSCompacted|PhysicalLimitFailsClosedBeforeStagedVisibilityFallback|RejectsWritesAtMigrationTimestampFloor)|TestShardStoreScanAndLatestCommitTS_MergeStagedVisibility|TestShardedCoordinatorRejects(PointWrite|DelPrefix)AtMigrationTimestampFloor|TestShardedCoordinatorDispatchTxn_RejectsMigrationTimestampFloor' -count=1 -timeout=180s
  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./adapter -run 'TestInternalExportRangeVersions(UsesDecodedS3BucketRouteFilter|UsesStoreAndRouteFilter|RejectsUnboundedExport)|TestInternalImportRangeVersionsAppliesStoreBatch' -count=1 -timeout=120s
  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./internal/s3keys -run 'TestBucketGenerationKey|TestParseBucketGenerationKey|TestBucketMetaKey' -count=1 -timeout=120s
  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./kv -count=1 -timeout=240s
  • GOCACHE=$(pwd)/.cache GOTMPDIR=$(pwd)/.cache/tmp go test ./adapter ./distribution ./store ./internal/s3keys -run 'TestInternal|TestMigration|TestExportVersions|TestImportVersions|TestApply|TestRouteKeyFilter|TestLeaderRoutedStore|TestDistributionServer|TestRouteDescriptor|TestEngine|TestBucket' -count=1 -timeout=240s
  • GOCACHE=$(pwd)/.cache GOLANGCI_LINT_CACHE=$(pwd)/.golangci-cache golangci-lint run ./adapter ./distribution ./kv ./store ./internal/s3keys --timeout=5m
  • git diff --check

@chatgpt-codex-connector

Copy link
Copy Markdown

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

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:ca1a05070c

ℹ️ 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 threadkv/shard_store.go
Comment threadkv/shard_store.go Outdated
Comment on lines +717 to +719
nextWindow := nextStagedVisibilityCandidateWindow(window)
if nextWindow == window {
return out, 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 Continue staged scans after capped windows

For staged-visibility routes with more than 8192 visible logical keys, any caller asking for a limit above stagedVisibilityMaxCandidateWindow can get a truncated result. Once window reaches 8192, a full underlying scan with len(out) < limit falls into nextWindow == window and returns immediately, even though there may be more keys after the current candidate window. This makes large ScanAt/ReverseScanAt calls silently miss keys during staged cutover.

Useful? React with 👍 / 👎.

Comment threadadapter/internal.go Outdated
if routeStart != nil && bytes.Compare(routeKey, routeStart) < 0 {
return false
}
return routeEnd == nil || bytes.Compare(routeKey, routeEnd) < 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 Treat empty S3 route ends as unbounded

This decoded S3 predicate only treats nil as an unbounded route_end, unlike RouteKeyFilter and the route descriptor convention where nil or empty means +infinity. If an unbounded last route is passed as a non-nil empty byte slice, every decoded bucket route key compares greater than []byte{}, so bucket meta/generation exports for that route return no versions. Check len(routeEnd) == 0 here instead of only routeEnd == nil.

Useful? React with 👍 / 👎.

Comment threadkv/fsm_migration_import.go Outdated
if err := proto.Unmarshal(data, req); err != nil {
return errors.WithStack(err)
}
result, err := f.store.ImportVersions(ctx, store.ImportVersionsOptions{

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep migration imports off the direct write gate

In encrypted clusters where storage-envelope cutover is active but a replica's writer registration is not yet committed, this FSM-apply path calls store.ImportVersions, whose Pebble implementation uses the direct write/encryption gate (encryptForKey(..., true)). That can return ErrWriterNotRegistered while replaying the Raft entry and fail the import on that replica; migration imports need a raft-apply variant that uses the non-gated FSM path instead of the direct ImportVersions path.

Useful? React with 👍 / 👎.

Comment threadkv/sharded_coordinator.go
Comment threadkv/shard_store.go Outdated

if engineForGroup(g) == nil {
return s.localGetAt(ctx, g, key, ts)
return s.localGetAt(ctx, g, distribution.Route{}, key, ts)

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 staged visibility on explicit group reads

When callers use the explicit-group path (for example RawGet/RawScanAt with group_id for SQS partitioned ownership), this passes a zero route into the local read path, so routeHasStagedVisibility is always false and imported staged versions are ignored after cutover. Those reads can return not found or stale live data even though normal GetAt/ScanAt for the same logical route would merge staged data; the explicit group path needs the staged route metadata or must fail closed while staged visibility is active.

Useful? React with 👍 / 👎.

Comment threadadapter/internal.go
@bootjp

Copy link
Copy Markdown
OwnerAuthor

Addressed the staged-visibility migration review items.

Changes:

  • Merge staged commit timestamps into OCC validation/read-key checks for both ShardStore and ShardedCoordinator paths.
  • Keep migration imports on the raft-apply store path so encryption writer-registration gates do not block replay.
  • Preserve staged visibility metadata for explicit-group RawGet/RawScan paths, failing closed if the route cannot be resolved while staged visibility is active.
  • Continue staged scans past capped candidate windows.
  • Enforce migration write timestamp floors after TSO raw timestamp stamping.
  • Harden S3 decoded route export filters and reject fully unbounded migration export requests.

Validation:

  • go test -count=1 ./kv -run 'TestShardStore(ExplicitGroupReads_MergeStagedVisibility|ScanAt_ContinuesStagedVisibilityAfterCandidateWindow|ApplyMutations_ValidatesStagedReadKeys|PhysicalLimitFailsClosedBeforeStagedVisibilityFallback)|TestShardedCoordinator(ValidateReadKeysOnShard_UsesStagedVisibility|DispatchTxn_AddsStagedReadKeyAlias|RejectsTSORawPointWriteAfterStamping|DispatchTxn_RejectsMigrationTimestampFloor)'\n- go test -count=1 ./adapter -run 'TestGRPCServer_Raw(Get|ScanAt)UsesExplicitGroup|TestInternalExportRangeVersions(RejectsUnboundedExport|UsesDecodedS3BucketRouteFilter|DecodedS3EmptyRouteEndIsUnbounded|AppliesStoreBatch)'\n- go test -count=1 ./store -run 'TestRegistrationGate(DirectPathFailsClosedBeforeRegistration|FSMApplyPathNeverGated)'\n- golangci-lint --config=.golangci.yaml run ./kv ./store ./adapter --timeout=5m\n- git diff --check\n\nBroader go test -count=1 ./kv ./store ./adapter passed ./kv and ./store, then ./adapter hit the package-level 10m timeout; the focused adapter coverage above passed.\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 (d33bf35):

  • distribution/engine.go
  • distribution/engine_test.go
  • distribution/migrator.go
  • distribution/migrator_export_plan_test.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-connectorchatgpt-codex-connectorBot 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:d33bf3589a

ℹ️ 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 threadkv/shard_store.go Outdated
if route, ok := s.engine.GetRoute(routeKey(key)); ok && route.GroupID == groupID {
return route, nil
}
if s.groupHasStagedVisibility(groupID) {

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 Fail closed when the resolved staged route moved groups

When an explicit-group RawGet arrives with a stale group_id after cutover (for example, the caller still targets the old source group while engine.GetRoute(key) now returns the target route with StagedVisibilityActive), this checks staged visibility on the requested group instead of the resolved route. If the old group has no staged route, the function falls through to fallback and reads the old group's live MVCC without the staged merge, serving stale pre-migration data instead of failing closed or rerouting.

Useful? React with 👍 / 👎.

Comment threadkv/shard_store.go Outdated
if len(matched) > 0 {
return matched, true, nil
}
if s.groupHasStagedVisibility(groupID) {

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 stale explicit scans over staged target routes

For explicit-group RawScan, a stale proxy can still send the old source group_id after the current intersecting route has moved to the target with staged visibility. In that case matched is empty, this check only inspects the requested group, and the fallback scans the old group's local store, so range reads can miss staged target data instead of failing closed; fail when any intersecting current route is staged but not matched to the requested group.

Useful? React with 👍 / 👎.

Comment threadkv/shard_store.go
@bootjp

Copy link
Copy Markdown
OwnerAuthor

未解決スレッドのうち P1 / high バッジ 12 件を現 HEAD 0a1b9c89 のコードに対して 1 件ずつ検証しました。結論として、9 件はすでに解消済み、1 件は誤検出、1 件は意図的な設計(要確認)、実際に残っているのは 1 件だけです。

これらのスレッドは GitHub 上では outdated になっていませんが、original_commit_id を見るといずれも db7a3253 / 650c4507 / cb7b3de8 / ca1a0507 / 295cdf6b / f6457dea / acc9305a など古いコミット時点の指摘で、その後のコミットで修正されたものが多数含まれていました。

すでに解消済み(9 件)

指摘HEAD での状態
adapter/internal.go:649Reject pre-stamped forwarded writes below the floordb7a3253 では if r.Ts != 0 { continue } で floor チェックを飛ばしていましたが、HEAD では continue が消え、rejectWriteTimestampFloorMutations全リクエストに対して 走ります
adapter/internal.go:656Check write floors for forwarded txnsstampTxnTimestampsrejectWriteTimestampFloorTxnRequests(reqs) を呼ぶようになっています (adapter/internal.go:735)
adapter/internal.go:837Ignore txn metadata when checking routed floorsrejectWriteTimestampFloorMutationsforwardedTxnControlMutation(mut)!txn| 接頭辞のキーをスキップします。TxnMetaPrefix = "!txn|meta|" なので対象外になります
adapter/internal.go:225Gate import proposals during mixed-version rolloutsImportRangeVersionsverifyMigrationImportEnabled(ctx) を呼びます。ご指摘どおり promote 経路の verifyMigrationPromoteEnabled と同型のゲートです
adapter/internal.go:443Use value-aware filtering for legacy list-delta exportsAcceptKey に加えて AcceptVersion: i.migrationExportVersionFilter(req) が配線され (:444)、bracket.ContainsRoutedVersion(rawKey, value, ...) を使っています (:469)
kv/fsm_migration_import.go:44Bundle applied index with migration importsImportVersionsRaftAppliedIndex: f.pendingApplyIdx が渡されています
kv/sharded_coordinator.go:2366Add staged aliases for S3 bucket auxiliary txn keysstagedVisibilityReadKeyAliasstagedVisibilityRouteForS3BucketAuxiliaryKey(key) を先に引きます。ご指摘の「point-read 経路と同じ auxiliary-route lookup」そのものです
kv/shard_store.go:315Delete staged rows for prefix deleteskv/shard_store.go:3979-3989stagedPrefixstagedExclude を組み立て、DeletePrefixAtRaftAt (:4065) が staged 側にも削除を適用します
kv/shard_store.go:2581O(N) Database Queries during Scan (gemini high)latestCandidateVersionsAt はキーごとの point lookup ではなく、AcceptKey 述語付きの ExportVersions レンジスキャン 1 回にまとまっています

誤検出(1 件)

store/migration_promote.go:217 — Lock-Order Inversion (gemini high): 前提が誤っています。詳細は該当スレッドに記載しましたが、要点として store/ 内の applyMu.Lock()5 箇所すべてdbMu を先に取得しており、逆順は 1 件もありません。提案どおりの変更は PromoteVersions だけを逆順にし、警告されているデッドロックを新たに作り出します。現状維持とします。

意図的な設計(1 件・要確認)

kv/fsm.go:560 — Do not bypass timestamp floors with fence bypass keys: 実装して検証したところ、TestFSMWriteFenceBypassAllowsRawWriteBelowBypassedRouteFloorTestFSMWriteFenceBypassAllowsPinnedTxnOnNonOwningGroup が失敗しました。bypass キーは定義上「バイト範囲ルートが所有者ではない」キーなので、routeKey(key) 由来の floor を当てると無関係なルートの floor で正当な書き込みを恒久的に拒否します。詳細と確認事項は該当スレッドに記載しました。

現 HEAD でも有効(1 件)

kv/shard_store.go:2836 — Honor staged tombstones in auxiliary scans: これは現存します。appendReplacingKVsByKey (kv/shard_store.go:2886-2907) は staged 側が 可視 KV を出したときだけout[idx] を置換します。staged/new ルートが新しいトゥームストーンを持ち、旧 raw ルートに古い live 行が残っている場合、visibleLogicalKVs は何も出さないため置換が起こらず、SCAN / reverse-scan の結果に stale な live KV が残ります(point GetAt なら隠れる)。ご指摘のとおりマージが「可視の置換」だけでなく「削除の判断」を運ぶ必要があります。

次の tick でこの 1 件に着手します。

@claude review

@claude

claudeBot commented Aug 22, 2026

Copy link
Copy Markdown

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


Verification of bootjp's P1/high self-review audit (HEAD 0a1b9c89)

I independently re-traced each of the 12 claims against the code at HEAD (0a1b9c890cd13e0d582b8243055f4b64184de7e0, confirmed to match the commit named in the summary). All checks were done by reading the actual source, not by re-reading the review comments.

  • Verify current HEAD commit matches claimed 0a1b9c89
  • Verify the 9 "already resolved" claims against current code
  • Verify the "false positive" claim (lock-order inversion in store/migration_promote.go)
  • Verify the "intentional design / needs confirmation" claim (kv/fsm.go:560)
  • Verify the "still remains" claim (kv/shard_store.go:2836)
  • Post final review

"Already resolved" (9/9 confirmed)

All nine hold up against current code:

  1. Pre-stamped forwarded writesadapter/internal.go:642-651: the r.Ts != 0 { continue } early-exit is gone; rejectWriteTimestampFloorMutations now runs unconditionally after the if r.Ts == 0 stamping branch.
  2. Forwarded txn floor checkstampTxnTimestamps (:711-739) calls rejectWriteTimestampFloorTxnRequests(reqs) (:831).
  3. Txn metadata excluded from floor checkrejectWriteTimestampFloorMutations skips forwardedTxnControlMutation(mut) (:681-683, matches kv.TxnKeyPrefix).
  4. Import opcode gateImportRangeVersions calls i.verifyMigrationImportEnabled(ctx) (:222), same pattern as the promote gate.
  5. Value-aware legacy list-delta export filtermigrationExportFilter/migrationExportVersionFilter (:449-471) wire AcceptVersion using bracket.ContainsRoutedVersion for MigrationFamilyLegacyListMetaDelta.
  6. Applied index bundled with importskv/fsm_migration_import.go:39: AppliedIndex: f.pendingApplyIdx passed into ImportVersionsRaft.
  7. S3 auxiliary staged alias for txn keysstagedVisibilityReadKeyAlias (kv/sharded_coordinator.go:2360-2365) checks stagedVisibilityRouteForS3BucketAuxiliaryKey before falling back to plain routeKey.
  8. Staged rows deleted on prefix deletekv/shard_store.go:3979-3989 builds stagedPrefix/stagedExclude via MigrationStagedDataKey and applies them alongside the live prefix delete.
  9. O(N) point lookups → range scan (gemini) — latestStagedVisibilityCandidateslatestCandidateVersionsAt (:2547-2608) issues one bounded ExportVersions range call with an AcceptKey predicate over all candidate keys, not per-key point lookups.

"False positive" — confirmed correct

store/migration_promote.go:217 lock-order inversion (gemini): verified by grepping every dbMu.RLock()applyMu.Lock() pair in store/. All 4 other call sites (lsm_store.go:776/778SetDurableAppliedIndex, lsm_store.go:2322/2327applyMutationsWithOpts, lsm_store.go:2421/2424deletePrefixAtWithOpts, lsm_migration.go:325/328importVersionsWithOpts) use the same order: dbMu first, applyMu second — matching migration_promote.go:213/216. The suggested fix (swap to applyMudbMu) would make this one function the only outlier and create the exact deadlock it warns about. bootjp's rebuttal is correct; no change needed.

"Intentional design, needs confirmation" — reasoning holds up

kv/fsm.go:560: the bypass condition (:556-563) still gates both verifyRouteNotFencedForKey and verifyRouteWriteTimestampFloorForKey together, unchanged from what the reviewer flagged. I checked the two regression tests bootjp cited as evidence:

  • TestFSMWriteFenceBypassAllowsRawWriteBelowBypassedRouteFloor (kv/fsm_migration_fence_test.go:111) — writes key !sqs|msg|data|p|partitioned-key, a resolver-owned key that routeKey() cannot map to its true owner.
  • TestFSMWriteFenceBypassAllowsPinnedTxnOnNonOwningGroup (:123) — key z byte-range-routes to group 2 (fenced, MinWriteTSExclusive: 100) but the txn is pinned to group 1.

Both are real scenarios where routeKey(key)-based floor lookup would hit an unrelated route's floor. This is a legitimate open design question (does a floor concept even apply to resolver-owned/pinned keys?), not a bug to silently fix — bootjp is right to leave it pending author input rather than applying the reviewer's literal suggestion.

Still open — confirmed, and additional evidence found

kv/shard_store.go:2836 (appendReplacingKVsByKey): confirmed still present with the same behavior — it only overwrites out[idx] when the incoming kvs (from the later-ordered staged route) contains a visible entry for that key; mergeAndTrimScanResultsWithOptions short-circuits with if len(kvs) == 0 { return out } (:2832-2834), so a staged tombstone (filtered out of kvs upstream since deleted rows aren't emitted as visible KVs) never displaces a stale live row already in out.

I traced this further and found there's already a test for exactly this scenario — TestShardStoreS3BucketAuxiliaryScanHonorsStagedTombstone (kv/shard_store_test.go:495-531) — which sets up a stale live BucketMetaKey in the non-staged group and a staged tombstone (later commit-ts) for the same key in the staged group, then asserts the tombstone wins. Statically tracing the scan path (ScanAtscanAtWithReadFenceroutesForS3BucketAuxiliaryScanscanRoutesAtWithReadFencemergeAndTrimScanResultsWithOptions) for this exact fixture: the non-staged route's raw scan of group 1 returns the stale deletedKey → "stale" row first (ordered before the staged route by orderRawScanRoutesForStagedVisibility); the staged route's merged scan of group 2 correctly determines the tombstone is the winner and returns kvs = [] (tombstones aren't emitted as visible); the empty-kvs short-circuit then leaves the stale row in out untouched. This means the test as currently written should fail on this HEAD (I could not execute go test directly — the sandbox required approval I didn't have — so this is a static trace, not an execution result; worth double-checking with a real test run before relying on it).

If this trace is right, the fix needs mergeAndTrimScanResultsWithOptions/appendReplacingKVsByKey (or the staged-route scan) to propagate deletion decisions for keys the raw side already produced, not just visible replacements — e.g. by having the staged pass return delete markers for any key present in out that the merge resolves as tombstoned, or by resolving live-vs-staged per key before appending rather than merging two already-filtered result sets.
· branch design/hotspot-split-m2-cross-group

TestShardStoreS3BucketAuxiliaryScanHonorsStagedTombstone only exercised
the forward direction. The review that raised this named SCAN and
reverse-scan together, so lock the reverse path down as well: a staged
tombstone must hide the stale live row that the old raw route still
holds, in both directions.
Both already pass. filterS3AuxiliaryKVsOwnedByStagedRoutes drops the
non-staged route's auxiliary rows before the dedupe merge runs, so the
merge never has a stale row to replace.
Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
@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.

@bootjp

Copy link
Copy Markdown
OwnerAuthor

訂正: 前のコメントで「現 HEAD でも有効」とした kv/shard_store.go:2836 (Honor staged tombstones in auxiliary scans) は、すでに解消済みでした。私の確認が不十分でした。

誤った理由: appendReplacingKVsByKey (kv/shard_store.go:2886-2907) だけを単体で読み、「staged 側が可視 KV を出さなければ置換が起きない」→「stale な live 行が残る」と結論しました。呼び出し元をたどっていませんでした。

実際の挙動: 非 staged ルートの行は、そもそもマージに到達する前に落とされています。scanRouteAtDirectionWithS3StagedOwnerFilter (kv/shard_store.go:1728-1739) は「dedupeByKey かつ 当該ルートが staged でない かつ 他に staged ルートが存在する」場合に filterS3AuxiliaryKVsOwnedByStagedRoutes (:1760-1779) を通し、staged ルートのレンジに属する S3 auxiliary キーを除去します。したがってマージ側に「置換すべき stale 行」は最初から存在せず、staged ルートの見え方(トゥームストーンを含む)だけが結果に反映されます。

検証: 既存テスト TestShardStoreS3BucketAuxiliaryScanHonorsStagedTombstone (kv/shard_store_test.go:495-531) が、まさにご指摘のシナリオを再現しています — group 1(非 staged raw ルート)に bucket-a の live 行 (ts=10)、group 2(staged ルート)に staged エイリアスのトゥームストーン (ts=20)、ts=30 でのスキャン。現 HEAD で PASS します。

とはいえ 1 点だけ穴がありました。ご指摘は「SCAN/reverse-scan」と両方向に言及していましたが、既存テストは前方スキャンのみをカバーしていました。逆方向を追加検証したところこちらも PASS しましたが、回帰防止として TestShardStoreS3BucketAuxiliaryReverseScanHonorsStagedTombstone を追加しました(e7815afc)。

  • go test -race ./kv/ → ok (15.3s)
  • golangci-lint run kv/... → 0 issues

更新後の P1 / high 集計(HEAD 0a1b9c89 時点)

分類件数
すでに解消済み10
誤検出(store/migration_promote.go:217 lock-order)1
意図的な設計・要確認(kv/fsm.go:560 fence bypass)1
現 HEAD で有効0

つまり本 PR に残っている P1 / high の作業は、kv/fsm.go:560 についてスレッドでお尋ねした 2 点の回答待ちのみです。回答次第で「実所有者ルートの floor を引く」形の絞った修正を実装します。

なお本 PR は main とコンフリクト中です。

@claude review

@claude

claudeBot commented Aug 22, 2026

Copy link
Copy Markdown

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


Claude finished reviewing

Todo list

  • Read trigger comment and gather context
  • Check current PR state (mergeability, diff vs base)
  • Verify the two claims in the trigger comment (10 resolved, 1 false-positive lock-order, 1 open question re: fence bypass)
  • Do a fresh review pass on current HEAD for any new/remaining P1 issues
  • Post final review summary

Note on tooling

I could not run go build/go test/golangci-lint in this session — every Bash invocation (including read-only ones like go version, gh pr view) required approval that wasn't available in this non-interactive run. Everything below is from static reading of the diff at HEAD e7815af. If you want CI-equivalent verification from me, --allowedTools needs to permit Bash(go *) / Bash(gh *) for this session type.

Verifying the two open claims

1. store/migration_promote.go:217 lock order — confirmed false positive.
I independently audited every applyMu.Lock() call site in store/:

File:lineOrder
lsm_store.go:776/778dbMu → applyMu
lsm_store.go:2322/2327dbMu → applyMu
lsm_store.go:2421/2424dbMu → applyMu
lsm_migration.go:325/328dbMu → applyMu
migration_promote.go:213/216dbMu → applyMu

All five are consistent. Your rebuttal is correct — the review's proposed swap would have been the one to introduce a real lock-order inversion, not fix one.

2. kv/fsm.go:560 fence-bypass floor check — still open, unchanged.
Confirmed validateRawMutationForApply (fsm.go:548-568) still skips both verifyRouteNotFencedForKey and verifyRouteWriteTimestampFloorForKey together under the same !bypass guard — no code has landed for this since your last reply asking for clarification on resolver-owned/pinned-group floor semantics. This is the one real open item; it needs your answer to the two questions in that thread before a fix can be scoped correctly (a blanket "always check the floor" change would break the two existing regression tests for resolver/pinned-group bypass, as your prior experiment already showed).

3. kv/shard_store.go S3 auxiliary reverse-scan tombstone — independently confirmed fixed.
Traced the mechanism: scanRouteAtDirectionWithS3StagedOwnerFilterfilterS3AuxiliaryKVsOwnedByStagedRoutes (shard_store.go:1760) strips the non-staged route's auxiliary rows before the cross-route merge runs, for both forward and reverse callers (the helper doesn't branch on direction). So the merge never sees a stale row to replace, in either direction. TestShardStoreS3BucketAuxiliaryReverseScanHonorsStagedTombstone (added in e7815af) correctly exercises both the whole-family reverse scan and a scan scoped to just the tombstoned bucket. Logic checks out.

Fresh review of the previously-unreviewed commit 0a1b9c8 ("Preserve staged key scan routing")

This commit landed after the last visible reviewer pass and hadn't been reviewed by anyone yet, so I read it in full:

  • proxyRawScanKeysAt now sends RouteStart/RouteEnd/RouteBoundsPresent to the leader, and the receiving rawScanKeysAtWithReadFence in adapter/grpc.go:245 already branches on RouteBoundsPresent into the ScanAtWithReadFence path that supports staged-visibility merge. This correctly closes the "Send staged route metadata for key proxies" gap flagged on 2026-07-18.
  • filterTxnInternalKeysfilterScanInternalKeys, now using isScanInternalKey (isTxnInternalKey(key) || isMigrationStagedDataKey(key), shard_store.go:3578-3580). Closes "Filter staged control keys from key scans" — staged shadow rows (!dist|migstage|...) no longer leak through key-only scans/backups.
  • stampTxnTimestamps now propagates the resolved commitTS instead of discarding it. Traced the only production caller (Internal.Forwardpb.ForwardResponse.CommitTs) — before this change, a forwarded transaction's response always reported CommitTs: 0, since Forward never had that value available. This looks like a real, previously-unflagged bug fix (not obviously tied to the earlier PREPARE-floor-timing finding, but a legitimate fix regardless).

No new correctness issues found in this commit.

One hygiene finding (not a HEAD bug)

Commit ac75ab1 ("migration: add range version RPC handlers") does not compile in isolation: stampTxnTimestamps is declared to return (uint64, error), but its body still has return err and return i.rejectWriteTimestampFloorTxnRequests(reqs) (the latter returns a bare error) — both are single-value returns from a two-value-return function. This is a real go build failure at that commit; it's fixed by the very next commit (0a1b9c8). Doesn't affect current HEAD (which compiles correctly per my read), but it does mean git bisect across this range would land on a non-buildable commit, and any per-commit CI would fail on ac75ab1 alone. Worth squashing these two commits together, or a follow-up git rebase -i if commit-by-commit bisectability matters to you here — up to you, not blocking.

Bottom line

Your P1/high tally holds: 0 currently-valid P1/high issues at HEAD, 1 confirmed false positive, 1 genuinely open question awaiting your call on resolver/pinned-group floor semantics. The two newest commits (0a1b9c8, e7815af) are correct, targeted fixes with no regressions spotted. The only new item from this pass is the non-blocking ac75ab1 build-break hygiene note above.

CI (Jepsen/Test/PR checks) was still in progress at the time of this review — worth confirming green before merge, since I couldn't run the suite myself here. You mentioned the PR conflicts with main; that'll need resolving separately.
· branch design/hotspot-split-m2-cross-group

@bootjp

Copy link
Copy Markdown
OwnerAuthor

残る P2 バッジ 18 件の現 HEAD e7815afc に対する検証を開始しました。P1 と同じく、これらのスレッドは GitHub 上では outdated になっていませんが、original_commit_idHEAD から 98〜211 コミット前のものです。

今 tick で個別に確認できた分を先に共有します(未確認分は次 tick で続けます)。

すでに解消済み(8 件・コードを確認済み)

指摘HEAD での状態
kv/shard_store.go:2678Handle Empty-but-Non-Nil Slices in Scan Bounds (gemini)stagedVisibilityScanBounds はすでに len(start) > 0 / len(end) > 0 を使用(提案そのもの)
kv/shard_store.go:3896Redundant Slice Copying and Unused Helper (gemini)appendStagedVisibilityAlias存在しません(削除済み)
kv/shard_store.go:2581O(N) Database Queries during Scan (gemini high)latestCandidateVersionsAtAcceptKey 述語付きの ExportVersionsレンジスキャン 1 回に集約済み(前回コメント済み)
adapter/internal.go:216Reject zero migration import identifiersvalidateImportRangeVersionsRequestjob_id == 0bracket_id == 0 の両方を InvalidArgument で拒否
adapter/distribution_server.go:184Gate route-history reads during startupGetRouteOwnership が先頭で s.requireReadReady() を呼びます
kv/shard_store.go:2363Filter staged control keys from live scansisMigrationStagedDataKey によるフィルタが :2429 / :2640 / :2822 に入っています
kv/shard_store.go:2643Skip staged shadow rows in staged scans同上(:2640 がまさにこの位置)
kv/shard_store.go:335Continue latest-version lookups across export pageslatestMVCCVersionAtresult.NextCursorDone までページングするループになっています

判断をお願いしたい 1 件

kv/fsm.go:607 — Check S3 auxiliary fences before raw routes

現 HEAD の verifyRouteNotFencedForKey は S3 auxiliary レンジのチェックを持っていますが、raw routeKey チェックのです(:606 で raw、:609 で auxiliary)。ご指摘は「raw が fence されていると auxiliary を見る前に弾かれる」点で、これは事実です。

ただし提案どおり auxiliary を優先させると、fence された raw ルートを S3 aux キーが素通りできることになり、現状より fail-open 方向になります。これは PR #1084logicalRouteFloorKey を入れたときの結論(raw と論理の両方の floor を適用する = fail-closed)と逆向きです。

現状の「raw → auxiliary の順に両方チェックする」は fail-closed で、意図的な設計に見えます。両方適用のままで良いか、それとも S3 aux キーでは raw fence を無視すべきか、ご意見をいただけますか。

未確認(次 tick で継続)

adapter/internal.go:517kv/fsm.go:1207kv/fsm.go:1368kv/shard_store.go:999 / :2289 / :2704 / :2720 / :4299kv/sharded_coordinator.go:1310 / :2250

このうち kv/sharded_coordinator.go:1310Skip resolver-owned keys in the floor precheck)は、該当関数に resolver 所有キーのスキップが見当たらないため現存する可能性が高いと見ています。次 tick で確定させます。

なお本 PR は引き続き main とコンフリクトしています。

@claude review

@claude

claudeBot commented Aug 22, 2026

Copy link
Copy Markdown

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


P2 verification pass — results against current HEAD (e7815af)

  • Gather context / read trigger comment
  • Answer the kv/fsm.go:607 fence-ordering question
  • Verify adapter/internal.go:517 (legacy list-delta route filter)
  • Verify kv/fsm.go:1207 (PREPARE floor check uses startTS vs commitTS)
  • Verify kv/fsm.go:1368 (dedup probe staged S3 aux keys)
  • Verify kv/shard_store.go:999 (route group marking on exact legacy delta scan)
  • Verify kv/shard_store.go:2289 (staged route metadata on proxied scans)
  • Verify kv/shard_store.go:2704 / :2720 (route-filtered leader scan staged visibility + S3 decode)
  • Verify kv/shard_store.go:4299 (staged S3 bucket metadata in scans)
  • Verify kv/sharded_coordinator.go:1310 (resolver-owned keys skipped in floor precheck)
  • Verify kv/sharded_coordinator.go:2250 (S3 auxiliary txn routing vs Composed-1 gate)
  • Post consolidated findings

Answer to the kv/fsm.go:607 design question

Current verifyRouteNotFencedForKey (kv/fsm.go:597-613) checks both the raw routeKey fence and the decoded S3-auxiliary range fence, unconditionally — it doesn't short-circuit on the raw check:

rkey:=routeKey(key)
ifsnap.WriteFencedForKey(rkey) {
return errors.Wrapf(ErrRouteWriteFenced, ...)
}
ifstart, end, ok:=s3BucketAuxiliaryRouteRange(key); ok&&snap.WriteFencedIntersects(start, end) {
return errors.Wrapf(ErrRouteWriteFenced, ...)
}

So this is deliberately fail-closed both ways, consistent with the logicalRouteFloorKey/#1084 precedent you cited. I'd keep it as-is: making the auxiliary check "win" over an unrelated fenced raw route would make S3 bucket metadata writes fail-open through a fenced raw range, which is a strictly worse failure mode than today's (rare) false-positive rejection. No change needed here.

Interesting related note: verifyRouteWriteTimestampFloorForKey (the floor, not fence, check — kv/fsm.go:630-653) does not mirror this "check both" pattern — for S3 aux keys it checks only the decoded aux range and returns early, never falling through to the raw routeKey floor. That's an asymmetry between the two functions, but I don't think it's wrong: it matches the coordinator's rejectWriteTimestampFloorPointKey (see below), which does the same "aux range decided → return" shape. Flagging for awareness only.

Already fixed (5 items)

FindingStatus
adapter/internal.go:517 — legacy list-delta route filterFixed.migrationExportFilter now special-cases MigrationFamilyLegacyListMetaDelta to use bracket.ContainsRawKey only, and wires a separate migrationExportVersionFilterAcceptVersion: bracket.ContainsRoutedVersion(rawKey, value, ...) (adapter/internal.go:449-471). Value-aware routing for legacy deltas is in place.
kv/fsm.go:1368 — dedup probe ignores staged S3 aux keysFixed.currentStagedVisibilityRouteForKey (kv/fsm.go:1354-1373) now calls currentStagedVisibilityRouteForS3BucketAuxiliaryKey first, before falling back to raw routeKey.
kv/sharded_coordinator.go:2250 — S3 aux txn routing vs Composed-1 gateFixed.composed1OwnerKey (kv/fsm.go:1139-1144) now decodes S3 bucket auxiliary keys via s3BucketAuxiliaryRouteRange and uses the decoded bucket-route start as the owner key, matching the coordinator's resolveGroupAndRouteForKey/stagedVisibilityRouteForS3BucketAuxiliaryKey resolution instead of the raw routeKey.
kv/shard_store.go:2704/:2720 — route-filtered leader scan + S3 decode orderingFixed, both halves.scanRouteAtLeaderRouteFilter now branches on routeHasStagedVisibility(route) into scanRouteWithStagedVisibility, matching the non-filtered leader path. And routeKeyInScanBounds (kv/shard_store.go:2014-2026) checks s3BucketAuxiliaryRouteInRange (decoded bucket range) before falling back to raw-key comparison, so staged S3 aux rows survive route filtering.
kv/shard_store.go:4299 — staged S3 bucket metadata in scansFixed.routesForScanWithVersion/routesForFencedScanWithVersion explicitly call routesForS3BucketAuxiliaryScan for raw scans over BucketMetaPrefix/BucketGenerationPrefix, appending staged-visibility routes that intersect the decoded bucket route range.

Still present / partially fixed (4 items)

kv/shard_store.go:999 (now ~989-999) — route group marking on exact legacy-delta scan — still present.

funcisBroadLegacyListDeltaScan(start []byte) bool {
...returnlogicalUserKey==nil||!bytes.Equal(start, store.LegacyListMetaDeltaScanPrefix(logicalUserKey))
}
funcshouldMarkRouteGroupOnScan(start []byte, explicitGroupbool, routeStart []byte, routeEnd []byte) bool {
return!explicitGroup&&!routeScanBoundsPresent(routeStart, routeEnd) &&isBroadLegacyListDeltaScan(start)
}

When start is exactly LegacyListMetaDeltaScanPrefix(userKey), isBroadLegacyListDeltaScan returns false, so RouteGroupID marking is skipped for that scan, unchanged from the original finding.

kv/shard_store.go:2289 — staged route metadata on proxied scans — partially fixed.
Forward value scans are fixed: ScanGroupAtscanExplicitGroupAtWithReadFenceroutesForExplicitGroupScanWithRouteBounds resolves the real route (including staged visibility) via engine.GetIntersectingRoutes, falling back to a bare distribution.Route{GroupID} only when no route matches, and failing closed (ErrExplicitGroupStagedVisibilityUnresolved) on a staged mismatch.

But ReverseScanGroupAt (kv/shard_store.go:617-625) and ScanGroupKeysAt (kv/shard_store.go:629-634) still construct a bare distribution.Route{GroupID: groupID} directly and skip route resolution entirely:

func (s*ShardStore) ReverseScanGroupAt(...) (...) {
returns.scanRouteAtDirectionWithReadFence(ctx, distribution.Route{GroupID: groupID}, ...)
}
func (s*ShardStore) ScanGroupKeysAt(...) (...) {
returns.scanKeyRouteAt(ctx, distribution.Route{GroupID: groupID}, ...)
}

These are real, reachable entry points — adapter/grpc.go:293/303/390 calls them directly for explicit-group RawScanAt requests with reverse=true or keys_only=true. So a follower-proxied reverse or keys-only scan on a staged-visibility route can still silently miss staged rows, while the forward value-scan path no longer has this gap. Recommend applying the same routesForExplicitGroupScanWithRouteBounds resolution to these two entry points.

kv/fsm.go:1207 — PREPARE floor check validates against startTS, not the eventual commit timestamp — present, likely intentional, worth a decision.
handlePrepareRequest still calls f.uniqueMutationsAboveFloor(muts, ..., startTS) (kv/fsm.go:1206-1207), i.e. PREPARE's write-floor check runs against r.Ts (the txn start timestamp), while handleOnePhaseTxnRequest (kv/fsm.go:1277) correctly checks against commitTS. TestFSMRejectsPrepareAtMigrationTimestampFloorDuringApply only covers the case where startTS itself is below the floor — it doesn't cover a txn that starts before a floor is installed but commits after.

This may well be intentional: 2PC PREWRITE actually persists a lock/provisional MVCC version at startTS, so if that version must never land below the floor (to avoid provisional state polluting a migrated range), rejecting at PREPARE time is the safer, fail-closed choice — the client simply retries with a fresh startTS. But if that's not the intended contract, a cross-shard transaction that merely straddles a cutover would abort at PREPARE even though its COMMIT would have been valid. @bootjp — is prewrite-below-floor supposed to be disallowed outright (current behavior, keep as-is), or should PREPARE defer the floor check to the resolved commit timestamp the way COMMIT/one-phase do? I'd lean toward leaving it as a fail-closed abort-and-retry unless you have evidence it's actually breaking real cross-shard migration traffic.

kv/sharded_coordinator.go:1310 (now ~1298-1313) — resolver-owned keys not skipped in the floor precheck — confirmed still present.

func (c*ShardedCoordinator) rejectWriteFencedPointKey(key []byte) error {
ifc.partitionResolverRecognisesPointKey(key) {
returnnil// <-- fence check skips resolver-owned keys
}
rkey:=routeKey(key)
ifroute, ok:=c.engine.GetRoute(rkey); ok&&route.State== distribution.RouteStateWriteFenced {
...
}
...
}
func (c*ShardedCoordinator) rejectWriteTimestampFloorPointKey(key []byte, commitTSuint64) error {
start, end, ok:=s3BucketAuxiliaryRouteRange(key)
ifok { ... }
rkey:=routeKey(key) // <-- no partitionResolverRecognisesPointKey guardifroute, ok :=c.engine.GetRoute(rkey); ok&& route.MinWriteTSExclusive!=0&&commitTS<= route.MinWriteTSExclusive {
return errors.Wrapf(ErrRouteWriteTimestampTooLow, ...)
}
returnnil
}

The write-fence precheck (rejectWriteFencedPointKey) explicitly bails out for resolver-recognized keys (e.g. SQS HT-FIFO partition keys), but the parallel timestamp-floor precheck does not. rejectWriteTimestampFloorPointKey is reached both from dispatchTxn (line ~1405, via rejectWriteTimestampFloorPointElems — called for all elems regardless of GroupID) and from the forwarded-mutation path (rejectWriteTimestampFloorMutations, line ~2524-2541). In both cases, a resolver-owned partition key collapses via routeKey() to the shared byte-range route (e.g. !sqs|route|global); if that route has MinWriteTSExclusive set from an unrelated migration, a legitimate write to a resolver-owned partition on an unaffected group gets rejected with ErrRouteWriteTimestampTooLow. This matches the codex finding exactly and should be fixed by adding the same partitionResolverRecognisesPointKey guard to rejectWriteTimestampFloorPointKey.

Summary

Of the 11 P2 items covered this pass (10 explicitly listed + the fsm.go:607 design question): 5 fixed, 1 design decision to confirm with you (PREPARE floor uses startTS), 1 partially fixed (forward scans fixed, reverse/keys-only explicit-group scans still bypass route resolution), and 2 confirmed still present (exact legacy-delta scan route marking; resolver-owned-key skip missing from the floor precheck).

The two "still present" items (shard_store.go legacy-delta marking, sharded_coordinator.go resolver-owned floor skip) and the reverse/keys-only scan gap look like real, actionable gaps rather than judgment calls — happy to push fixes+regression tests for those if you'd like, per the repo's "test-first for review-identified defects" convention.

PR still conflicts with main.

rejectWriteFencedPointKey skips keys the partition resolver recognises,
because in a partition-resolved keyspace such as HT-FIFO SQS routeKey
collapses a concrete partition key onto the global SQS route, so that
route's state does not describe the key. rejectWriteTimestampFloorPointKey
had no such exemption, so the same collapsed route's MinWriteTSExclusive
was applied to those keys and rejected writes the fence precheck
deliberately lets through.
Apply the same exemption, using the existing
partitionResolverRecognisesPointKey helper so both prechecks agree on
what the resolver owns.
Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
@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.

@bootjp

Copy link
Copy Markdown
OwnerAuthor

P2 検証の続きです。1 件を修正し(3fa73ed1)、2 件を「設計判断が必要」として切り出しました。

修正: kv/sharded_coordinator.go:1310 — Skip resolver-owned keys in the floor precheck

ご指摘のとおり現存していました。同一ファイル内に明確な先例があります:

  • rejectWriteFencedPointKey (:1194-1196) は先頭で partitionResolverRecognisesPointKey(key) を確認し、resolver が認識するキーを除外します。
  • rejectWriteTimestampFloorPointKey (:1298) にはその除外がありませんでした

HT-FIFO SQS のようなパーティション解決キー空間では routeKey が具体的なパーティションキーをグローバル SQS ルートへ畳み込むため、そのルートの MinWriteTSExclusive はこのキーの floor ではありません。結果、write-fence precheck が意図的に通している書き込みを floor precheck が弾いていました。

既存の partitionResolverRecognisesPointKey をそのまま使って同じ除外を適用し、両 precheck の「resolver の所有範囲」の判断を一致させました。

Caller audit: rejectWriteTimestampFloorPointKey の呼び出し元は rejectWriteTimestampFloorPointElems(→ dispatchTxn:1405)と :2541(forwarded mutations 経路)の 2 箇所。どちらも coordinator 側の precheck で、Raft 側のゲートは別途 resolver のルーティングを通ります。

Test evidence: TestShardedCoordinatorFloorPrecheckSkipsResolverOwnedKeys を追加。resolver 未配線なら floor が効く(=テストが空虚でないこと)→ resolver 配線後は除外される → resolver が所有しないキーには引き続き floor が効く、の 3 点を検証。除外を外すと失敗することを確認済みです。
go test -race ./kv/ → ok (13.9s)、golangci-lint run kv/... → 0 issues。

追加で解消済みを確認(前回コメントの 8 件に加えて)

なし(今回の確認対象は下記 2 件と上記 1 件)。

設計判断をお願いしたい 2 件

1. kv/fsm.go:1207 — Check the floor against the resolved commit timestamp

現存します。handlePrepareRequeststartTS := r.TsuniqueMutationsAboveFloor に渡しており、PREPARE 時点の r.Ts は startTS です。ただし修正には FSM が PREPARE 時に解決済み commitTS を知る必要があり、現在のリクエストはそれを運んでいません(dispatchTxn は PREPARE 送信前に commitTS を解決していますが送信しません)。ワイヤ変更を伴う設計判断になるため、こちらの独断では進めません。

2. kv/sharded_coordinator.go:2250 — Align S3 auxiliary txn routing with owner checks

現存します。coordinator 側は stagedVisibilityRouteForS3BucketAuxiliaryKey で staged ルートのグループへ送りますが、FSM の Composed-1 ゲート (verifyComposed1snap.OwnerOf(routeKey(mut.Key))) は raw キーで所有者を判定します。整合させるには FSM 側にも S3 auxiliary の復号ルート判定を入れる必要があり、これは先にお尋ねしている kv/fsm.go:560 の質問(bypass キーの所有者をどう定義するか)と同じ論点です。そちらの回答が出てから合わせて対応するのが安全と考えます。

未確認(次 tick で継続)

adapter/internal.go:517kv/fsm.go:1368kv/shard_store.go:999 / :2289 / :2704 / :2720 / :4299

@claude review

@claude

claudeBot commented Aug 22, 2026

Copy link
Copy Markdown

Claude encountered an error after 9s —— View job


I'll analyze this and get back to you.

Route-group marking was gated on isBroadLegacyListDeltaScan, which is
false when the scan start is exactly LegacyListMetaDeltaScanPrefix(userKey).
Redis cleanup and compaction build their deletes as {Del, pair.Key,
GroupID: pair.RouteGroupID}, so a zero GroupID there routes the delete by
the raw "!lst|meta|d|..." key rather than the logical list key -- after a
split the delete goes to the wrong shard and the stale delta survives.
Gate marking on isLegacyListDeltaScan, which covers both the broad and
the per-user-key shape, at all three marking sites. Route *selection*
still distinguishes them: routesForInternalScanWithVersion deliberately
narrows an exact scan to the logical key's routes, so that call keeps
isBroadLegacyListDeltaScan.
Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
@bootjp

Copy link
Copy Markdown
OwnerAuthor

P2 検証を完了しました。1 件を追加修正し(86c48e62)、残りの状況を確定させます。

修正: kv/shard_store.go:999 — Preserve route group on exact legacy list-delta scans

現存していました。 消費側まで追って確認しています:

  • shouldMarkRouteGroupOnScanisBroadLegacyListDeltaScan(start) を条件にしており、start がちょうど LegacyListMetaDeltaScanPrefix(userKey) のとき false になります(:989-996!bytes.Equal(...) 節)。
  • その結果 RouteGroupID が 0 のままになり、adapter/redis_delta_compactor.go:908 / :1081adapter/redis_compat_helpers.go:1000 / :1050 が組み立てる {Op: Del, Key: pair.Key, GroupID: pair.RouteGroupID} が、論理リストキーではなく raw !lst|meta|d|... キーで routing されます。split 後は別シャードへ delete が飛び、stale な delta が残ります。

isLegacyListDeltaScan(broad / exact の両方を含む)を追加し、マーキングを行う 3 箇所shouldMarkRouteGroupOnScan:1101:1452)をこちらに切り替えました。

ルート選択は従来どおりです。routesForInternalScanWithVersion (:750) は exact scan を論理キーのルートへ意図的に絞り込むため、isBroadLegacyListDeltaScan のまま残しています(マーキングと選択で必要な区別が違う)。

Test evidence: TestShardStoreScanAt_ExactLegacyListDeltaScanMarksRouteGroup を追加。isBroadLegacyListDeltaScan(scanStart) が false であること(= exact 形状のテストであること)を先にアサートしたうえで、RouteGroupID が所有グループになることを検証しています。修正前は expected: 0x2, actual: 0x0 で失敗します。
go test -race ./kv/ → ok (15.4s)、golangci-lint run kv/... → 0 issues。

追加で解消済みを確認(3 件)

指摘HEAD での状態
adapter/internal.go:517Preserve raw-route matches for S3 bucket exportsdecodedS3BucketRouteFilterallowRawRouteMatch := !s3BucketRouteBounds(...) があり、route bound が raw `!s3
kv/fsm.go:1368Probe staged S3 auxiliary keys during dedupcurrentStagedVisibilityRouteForS3BucketAuxiliaryKey(snap, key, f.shardGroupID)snap.RouteOf(routeKey(key))に試すようになっています
kv/shard_store.go:2581O(N) Database Queries (gemini high)既報のとおり ExportVersions レンジスキャン 1 回に集約済み

最終集計(P2 バッジ 18 件 / HEAD 86c48e62

分類件数
すでに解消済み11
今回修正2:1310 resolver 除外、:999 route group マーキング)
設計判断待ち3kv/fsm.go:607kv/fsm.go:1207kv/sharded_coordinator.go:2250
未確認2kv/shard_store.go:2289:2704 / :2720 / :4299 のうち staged scan 系)

未確認として残っているのは staged visibility のスキャン経路(:2289Preserve staged route metadata on proxied scans:2704Use staged visibility for route-filtered leader scans:2720Decode S3 auxiliary keys before route filtering:4299Include staged S3 bucket metadata in scans)で、いずれも相互に絡むため次 tick でまとめて確認します。

設計判断待ちの 3 件はいずれも「バイト範囲ルートが所有者でないキーの所有者をどう定義するか」という同じ論点に帰着します。kv/fsm.go:560 のスレッドでお尋ねしている 2 点にご回答いただければ、3 件まとめて対応できます。

@claude review

@claude

claudeBot commented Aug 22, 2026

Copy link
Copy Markdown

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


Review of P2 status update (HEAD 86c48e6)

Todo list

  • Read trigger comment and understand scope
  • Verify the new fix at kv/shard_store.go (legacy list-delta route-group marking, commit 86c48e6)
  • Spot-check the 3 "already resolved" claims (adapter/internal.go:517, kv/fsm.go:1368, kv/shard_store.go O(N) export)
  • Investigate the 4 unconfirmed staged-visibility scan-routing items (:2289, :2704, :2720, :4299)
  • Weigh in on the design question at kv/fsm.go:560 (resolver-owned key floor checks)
  • Post final review summary

I re-verified every claim in your status update against the live HEAD (86c48e6), not the diff snippets, using independent read-only passes. Summary: everything you reported checks out, plus a concrete recommendation on the open design question.

1. New fix: mark route group on exact legacy list-delta scans (kv/shard_store.go)

Confirmed correct and complete.

  • isBroadLegacyListDeltaScan (:989) is unchanged — still false for exact-prefix scans, as intended.
  • New isLegacyListDeltaScan (:1008) covers both broad and exact shapes and is now used by shouldMarkRouteGroupOnScan (:1012) and the two direct call sites (~:1101, ~:1452).
  • routesForInternalScanWithVersion (:750) intentionally still uses the narrow isBroadLegacyListDeltaScan for route selection — verified this asymmetry is safe: for exact scans it already falls through to routesForLegacyListDeltaScan, which routes by the logical user key correctly. Marking always stamps whatever route was actually selected, so selection and marking can't diverge.
  • TestShardStoreScanAt_ExactLegacyListDeltaScanMarksRouteGroup (kv/shard_store_test.go:3441) is a faithful regression test — asserts isBroadLegacyListDeltaScan(scanStart) == false (proving it exercises the exact-scan shape) then asserts RouteGroupID == 2; would fail pre-fix. I was not able to execute go test myself in this sandbox (command execution requires approval that isn't grantable here), so I'm relying on static verification plus your reported go test -race ./kv/ → ok (15.4s).
  • All 4 shouldMarkRouteGroupOnScan call sites (:1870, :1918, :2092, :2174) cover leader-local, proxied/follower, and physical-limit scan paths — no gap found.

2. The 3 "already resolved" claims — all CONFIRMED

  • adapter/internal.go:517 (S3 raw-route export matches)decodedS3BucketRouteFilter's allowRawRouteMatch := !s3BucketRouteBounds(...) correctly restores raw-route matching only when the route bound isn't itself !s3route|-prefixed, without regressing the decoded-intersection path. Backed by TestInternalExportRangeVersionsPreservesS3BucketRawRouteMatches.
  • kv/fsm.go:1368 (one-phase dedup for S3 auxiliary keys)currentStagedVisibilityRouteForKey now tries currentStagedVisibilityRouteForS3BucketAuxiliaryKey before falling back to snap.RouteOf(routeKey(key)), called from dedupProbeOnePhase. Backed by TestOnePhaseDedup_NoOpsWhenS3AuxiliaryPriorAttemptLandedAsStagedVersion.
  • kv/shard_store.go O(N) staged-visibility lookupslatestStagedVisibilityCandidates now does exactly 2 bounded ExportVersions range scans (live + staged) with an AcceptKey filter and in-memory merge, not per-key point lookups. No remaining per-key ExportVersions calls in this path.

3. The 4 unconfirmed staged-visibility scan-routing items — all FIXED

  • Proxied scans (:2289) — fixed by a different (equally sound) mechanism than the original finding assumed: rather than carrying StagedVisibilityActive/MigrationJobID over the wire, the leader independently re-derives the full route from its own catalog via routesForExplicitGroupScan(groupID, start, end) when handling a proxied RawScanAt/ScanGroupAt. If a staged route intersects but doesn't match groupID, it fails closed with ErrExplicitGroupStagedVisibilityUnresolved rather than silently dropping staged rows.
  • Route-filtered leader scans (:2704)scanRouteAtLeaderRouteFilter now has the identical routeHasStagedVisibility(route) switch as scanRouteAtLeader, calling scanRouteWithStagedVisibility before the route filter is applied.
  • Decoded S3 keys before route filtering (:2720)filterRouteScanKVsrouteKeyInScanBounds now checks s3BucketAuxiliaryRouteInRange first (decoding bucket-meta/generation keys into their bucket-route range) before falling back to the generic prefix comparison. Consistently used at 3 call sites.
  • Staged S3 bucket metadata in prefix scans (:4299)routesForS3BucketAuxiliaryScan exists and is wired into routesForScanWithVersion, which backs both routesForScan and the read-fenced scan path, with dedupeRepeatedRawScanRoutes/filterS3AuxiliaryKVsOwnedByStagedRoutes preventing double-counting against the legacy-owner route.

4. Design question (kv/fsm.go:560 thread) — resolver-owned key floor checks

Recommendation: go with your option (a) — keep the full bypass exemption — but make it explicit and file a follow-up, rather than trying option (b).

Investigation found option (b) isn't actually implementable today:

  • SQS partitioned keys are not an in-scope migration target currently. kv/shard_key.go's sqsRouteKey collapses every concrete SQS key (partitioned or not) onto one fixed !sqs|route|global route key, and distribution/ has zero references to PartitionResolver anywhere — the migration bracket planner has no concept of per-partition ownership. So a split of the group owning the global SQS route would incidentally sweep in partitioned rows regardless of resolver assignment; this is an existing latent gap independent of the floor-check bypass, not something the bypass is meant to close.
  • The one existing "true owner" helper, ShardedCoordinator.resolveGroupAndRouteForKey, only special-cases S3 bucket auxiliary keys (which still have a real distribution.Route, just decoded differently). For resolver-owned SQS keys it falls through to the same naive routeKey() lookup already in use — reusing it would change nothing.
  • Pinned-txn keys (Elem.GroupID) have no catalog representation at all — there is no Route to look up a correct floor from, correct or otherwise.

Given that, applying routeKey(key)'s floor unconditionally (removing the bypass) doesn't fix a real bug today — it actively breaks two working tests by applying the wrong route's floor. Suggest: keep the exemption as-is, add a short comment at kv/fsm.go:556 cross-referencing this thread/gap, and open a follow-up issue to either (i) teach the migrator about the SQS partition resolver before treating those brackets as split-eligible, or (ii) give pinned/resolver-owned groups an explicit per-group floor primitive if per-partition migration is ever built. This closes out the :560, :1207, and :2250 threads with the same rationale, since they all reduce to the same "no route exists for this key" problem.


No blocking issues found. All P2 items in your status update are resolved as described, and the design-judgment threads have a clear path forward (keep bypass, document, follow-up issue) rather than a code change in this PR.
(branch: design/hotspot-split-m2-cross-group)

@bootjp

Copy link
Copy Markdown
OwnerAuthor

残していた staged visibility スキャン 4 件の確認が終わり、P2 バッジ 18 件の検証が完了しました。

追加で解消済みを確認(3 件)

指摘HEAD 86c48e62 での状態
kv/shard_store.go:2704Use staged visibility for route-filtered leader scansscanRouteAtLeaderRouteFilter の先頭が case routeHasStagedVisibility(route): kvs, err = s.scanRouteWithStagedVisibility(...) になっており、route-filtered 経路も staged/live マージを通ります
kv/shard_store.go:2720Decode S3 auxiliary keys before route filteringrouteKeyInScanBoundss3BucketAuxiliaryRouteInRange(key, routeStart, routeEnd)最初に判定します
kv/shard_store.go:4299Include staged S3 bucket metadata in scansroutesForS3BucketAuxiliaryScan が raw レンジ交差ルートに加えて、デコード済みレンジと交差する staged visibility ルートも収集します

現 HEAD でも有効(1 件)

kv/shard_store.go:2289 — Preserve staged route metadata on proxied scans

ご指摘のとおり現存します。ScanGroupAt / ReverseScanGroupAt / ScanGroupKeysAt:622 / :633 付近)はいずれも distribution.Route{GroupID: groupID} というstaged フィールドを持たない裸のルートを渡すため、受信側で routeHasStagedVisibility(route) が false になり live-only のストアスキャンに落ちます。proxied な explicit-group スキャンは staged 行を取りこぼします。

点読み側には既に routeForExplicitGroupKey (:284) があり、staged visibility を要求グループへ解決できない場合は ErrExplicitGroupStagedVisibilityUnresolvedfail-closed します。スキャン側にも同等の解決が必要で、ただしスキャンは単一キーではなく範囲なので、routeForExplicitGroupKey をそのまま流用できません(範囲が同一グループ内の複数ルートに跨り得るため)。

実装方針としては 2 案あります:

  1. 受信側で範囲解決[start, end) と交差し GroupID が一致するルートを引き、staged visibility を持つものがあればそのルートでスキャンする。点読みと同じく、解決できない staged visibility は fail-closed にする。
  2. RPC で migration フィールドを送るRawScanAtRequestmigration_job_id / staged_visibility_active を追加し、送信側の解決結果をそのまま使う。

1 は受信側で完結しますが「範囲が複数ルートに跨る場合にどのルートを採るか」を決める必要があり、2 はワイヤ変更を伴います。どちらが設計意図に沿うかご意見をいただけますか。点読み側の fail-closed と挙動を揃えたいので、独断では進めません。

最終集計(P2 バッジ 18 件 / HEAD 86c48e62

分類件数
すでに解消済み14
今回の tick で修正2:1310 resolver 除外、:999 route group マーキング)
設計判断待ち3kv/fsm.go:607kv/fsm.go:1207kv/sharded_coordinator.go:2250
現存・方針確認待ち1kv/shard_store.go:2289

P1/high 12 件とあわせると、本 PR の指摘 30 件のうち 24 件が解消済み、2 件が誤検出/意図的設計、4 件が方針確認待ちという状況です。

@claude review

@claude

claudeBot commented Aug 22, 2026

Copy link
Copy Markdown

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


Review complete

  • Read trigger comment and gather PR context
  • Verify the "still open" claim at kv/shard_store.go:2289 and answer the design question
  • Re-check the 3 "design judgment pending" items
  • Spot-check a sample of the "already resolved" claims for accuracy
  • Post final review

Verified against HEAD 86c48e6.

Correction to the "still open" scope

ScanGroupAt (kv/shard_store.go:566) is not actually part of the gap anymore — it already goes through routesForExplicitGroupScanWithRouteBoundsroutesForExplicitGroupScan, which pulls the real distribution.Route from s.engine.GetIntersectingRoutes/GetRoute (populated StagedVisibilityActive/MigrationJobID, distribution/engine.go:435-436,480-481) and fails closed with ErrExplicitGroupStagedVisibilityUnresolved when it can't safely place a staged route on the requested group. That's the same resolve-on-receiver pattern as the point-read path (routeForExplicitGroupKey, line 284).

The real remaining gap is narrower than "all three":

  • ReverseScanGroupAt (kv/shard_store.go:617-625) still builds a bare distribution.Route{GroupID: groupID} and calls scanRouteAtDirectionWithReadFence directly — no engine lookup at all.
  • ScanGroupKeysAt (kv/shard_store.go:629-634) does the same into scanKeyRouteAt.

Both are gRPC-reachable (adapter/grpc.go:293,303,390 — RawKV explicit-group ReverseScan/keys-only Scan), so during staged visibility a follower-proxied reverse scan or key-only scan on an explicit group can silently omit imported-but-not-yet-promoted rows. This is real and matches what the codex finding + your note describe, just scoped to 2 of the 3 named entry points.

Recommendation: option 1 (resolve on receiver), not option 2 (wire fields).

  • It's already the established, tested pattern in this file (routeForExplicitGroupKey for points, routesForExplicitGroupScan for ScanGroupAt) — extending it to the other two callers is mechanical, not a new design.
  • It fails closed by construction (ErrExplicitGroupStagedVisibilityUnresolved) instead of requiring you to invent new fail-closed semantics for a new wire field.
  • No proto/wire change means no rolling-upgrade compatibility window to reason about (a bare group_id request from an old binary would otherwise need a defined meaning against a new field).

Concrete shape:

  • ReverseScanGroupAt: swap the direct scanRouteAtDirectionWithReadFence(ctx, distribution.Route{GroupID: groupID}, ...) call for s.scanExplicitGroupAtWithReadFence(ctx, groupID, start, end, limit, ts, true /* reverse */, 0, nil, nil) — the reverse-ordering branch (routes[len(routes)-1-i]) already exists in scanExplicitGroupRoutesAtWithReadFence (line 590-592), so this is just wiring reverse=true through instead of hand-rolling the single-route call.
  • ScanGroupKeysAt: needs the multi-route treatment — resolve via routesForExplicitGroupScan, then run the existing per-route scanKeyRouteAt over each and merge with mergeAndTrimScanKeys, mirroring how scanExplicitGroupRoutesAtWithReadFence does it for values. A little more work than the reverse-scan fix since scanKeyRouteAt currently only takes one route.

The 3 "design judgment pending" items

kv/sharded_coordinator.go:2250 — already resolved, drop from the pending list. I checked both sides: resolveGroupAndRouteForKey (coordinator, line 2257-2270) already special-cases S3 bucket auxiliary keys via stagedVisibilityRouteForS3BucketAuxiliaryKeybefore falling back to router.ResolveGroup, and composed1OwnerKey (fsm.go:1139-1144) already returns the decoded aux-route start key instead of raw routeKey for the Composed-1 ownership gate — for both the "observed" and "current" snapshot passes in verifyComposed1 (line 962-1012), since both go through the same verifyOwnerFromSnapshotcomposed1OwnerKey path. Coordinator dispatch and FSM apply agree. This looks fixed since the c9d7ddc56c finding; no action needed.

kv/fsm.go:607 (verifyRouteNotFencedForKey) — still open, and it's a real (if minor) bug. The function checks snap.WriteFencedForKey(rkey) (raw routeKey) unconditionally, then also checks WriteFencedIntersects on the decoded S3-aux range if the key is one — it's an OR, not a fallback. So an unrelated raw !s3|... route fence still blocks a bucket-metadata write even when the bucket's own !s3route|<bucket> route isn't fenced. Contrast with verifyRouteWriteTimestampFloorForKey right below it (line 630-651), which does this correctly: if the key decodes as an S3 aux key, it checks only the aux range and returns, no raw-route fallback. Recommend mirroring that shape in verifyRouteNotFencedForKey — check s3BucketAuxiliaryRouteRange first and return on that branch, skipping the raw check for aux keys. Severity is availability (false rejection), not a data-safety hole, so this is a straightforward, low-risk fix.

kv/fsm.go:1207 (handlePrepareRequest floor check uses startTS) — still open, and I think the fix direction is "drop the check from PREPARE." I traced what PREPARE actually writes: buildPrepareStoreMutationsprepareTxnMutation only writes txnLockKey/txnIntentKey (internal !txn|lock|/!txn|intent| keys), never the real user key at a floor-relevant MVCC version. The user's actual versioned value is only written by handleCommitRequest, which already calls uniqueTxnMutationsAboveFloor(muts, ..., commitTS) with the realcommitTS (fsm.go:1408) — so the floor invariant on the real data is independently and correctly enforced at COMMIT regardless of what PREPARE does. Using r.Ts (== startTS) at PREPARE therefore isn't protecting the floor invariant (nothing floor-relevant is written yet); it just rejects any cross-shard txn whose startTS predates the migration cutover, even when it will legitimately commit later with commitTS > floor. That's an availability regression for in-flight cross-shard txns during migration windows, not a safety gap. Recommend reverting handlePrepareRequest to call plain uniqueTxnMutations(muts) (no floor check) and rely on COMMIT's check as the sole enforcement point — handleOnePhaseTxnRequest is unaffected since it already checks against its own commitTS, not startTS.

Spot-check of "already resolved" claims

Checked the two commits from this tick plus one from the "already resolved" batch — all hold up:

  • 3fa73ed (resolver-owned keys exempted from floor precheck): rejectWriteTimestampFloorPointKey (sharded_coordinator.go:1298) now calls partitionResolverRecognisesPointKey before applying the collapsed-route floor, matching the existing fence-precheck exemption at line 1195. Test-covered (kv/sharded_coordinator_txn_test.go).
  • 86c48e6 (route group marking on exact legacy list-delta scans): shouldMarkRouteGroupOnScan now uses isLegacyListDeltaScan (covers both broad and exact-prefix shapes) at all three marking call sites (shard_store.go:1870,1918,2092), not just isBroadLegacyListDeltaScan.
  • e7815af (staged tombstones in reverse auxiliary scans): confirmed the reverse-direction test was added alongside the existing forward one; the underlying filterS3AuxiliaryKVsOwnedByStagedRoutes fix was already in place, this closed a coverage gap rather than a behavior gap.

Summary

Of your tracked items: 1 correction (scope of the proxied-scan gap is ReverseScanGroupAt+ScanGroupKeysAt, not ScanGroupAt), 1 item reclassified as already-resolved (sharded_coordinator.go:2250), and clear fix recommendations for the remaining 2 real gaps (fsm.go:607 ordering, fsm.go:1207 drop-from-PREPARE) plus the proxied-scan fix (resolve-on-receiver, extend the existing pattern). All are availability/correctness-of-scope issues, not data-safety violations — no P0/P1 surfaced in this pass.
— branch design/hotspot-split-m2-cross-group

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:86c48e6283

ℹ️ 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 threadkv/fsm_migration_import.go Outdated
Comment on lines +45 to +46
if err != nil {
return errors.WithStack(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 Halt Raft apply when migration imports fail

When ImportVersionsRaft fails on only one target voter—for example because that replica encounters a Pebble I/O or encryption error—returning an ordinary error here lets internal/raftengine/etcd.applyNormalCommitted advance that replica's applied index because only responses implementing HaltApply stop setApplied. The leader can apply successfully and acknowledge the RPC while the failed voter permanently skips the imported versions, causing replica divergence and missing data after failover or promotion. Fatal import-store errors should use the same halt-apply path as migration promotion, while only explicitly classified request/idempotency errors remain ordinary.

Useful? React with 👍 / 👎.

applyMigrationImport returned every failure as an ordinary error. The
engine only stops advancing setApplied for responses implementing
HaltApply, so a store-side failure on a single target voter -- a Pebble
I/O error, the encryption gate rejecting the write -- let that replica
skip the imported versions while the leader applied and acked the RPC.
The divergence surfaces later as missing data after failover or
promotion.
Classify the way applyMigrationPromote already does. Errors that are a
verdict on the request bytes stay ordinary, because every replica
applying the same entry reaches them identically and the group advances
in step: batch gap, malformed version, oversized value. Everything else
halts, including a failed HLC-floor read. Halting on the request-shaped
errors instead would turn one malformed batch into a cluster-wide
outage.
Replay after a halt is safe: the import batch either did not commit, or
committed and is recognised as a duplicate by validateNextImportBatch.
validateImportVersion's anonymous errors become ErrInvalidImportVersion
so the classifier can name them. Promotion also calls that validator but
classifies only ErrInvalidExportCursor as ordinary, so its behaviour is
unchanged.
Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
@bootjp

Copy link
Copy Markdown
OwnerAuthor

codex の新しい P1 (kv/fsm_migration_import.go:46) を修正しました(da3b9671)。指摘は正しく、データ分岐を起こし得る実バグでした。

検証: 指摘のメカニズムを確認

internal/raftengine/etcd/engine.go:2955-2973applyNormalCommitted:

  • applyNormalEntryfsm.Apply(payload) の戻り値を response (any) として返し、err は常に nil(FSM のエラーは err 経路に乗らない)。
  • halt するのは responseHaltApply() error を実装している場合のみ
  • applyMigrationImport は全失敗を errors.WithStack(err) = 素の error で返していた。*withStackHaltApply を実装しないので、setApplied が前進する

つまり 1 台の target voter だけが Pebble I/O / 暗号化ゲート起因で ImportVersionsRaft に失敗すると、その replica だけが import 済みバージョンを恒久的に飛ばしたまま applied index を進め、leader は成功して RPC を ack します。failover / promote 後に欠損として顕在化します。

修正: applyMigrationPromote と同じ分類に揃えた

applyMigrationPromote は既に「ordinary(素の error)」と「halt」を分けています(isMigrationPromoteOrdinaryApplyError)。import 側にも同じ形を入れました。

ordinary のまま(halt しない) — リクエストのバイト列に対する判定であり、同じ entry を適用する全 replica が同一の結論に達するので歩調が崩れません:

  • store.ErrImportBatchGap
  • store.ErrInvalidImportVersion(新設。後述)
  • store.ErrValueTooLarge

halt する — この replica 固有の失敗:

  • ImportVersionsRaft のその他のエラー(Pebble I/O、暗号化ゲート)
  • MigrationHLCFloor の読み取り失敗
  • payload の decode 失敗(promote と同じ)

ここは意図的な線引きです。リクエスト起因のエラーまで halt にすると、1 個の不正バッチがクラスタ全体の停止になります。決定的で全 replica 同一なのだから、ordinary にして applied index を揃って進めるほうが正しい。

replay 安全性: halt 後の再適用は、import バッチが未コミットか、コミット済みなら validateNextImportBatch が duplicate と認識するかのいずれかなので冪等です。

validateImportVersion の匿名 errors.New は分類できないため store.ErrInvalidImportVersion を新設して marker にしました。store/migration_promote.go:45 も同じ validator を使いますが、promote 側の分類器は ErrInvalidExportCursor のみを見るので promote の挙動は不変です。

Caller audit(意味論変更のため)

  • applyMigrationImport の呼び出し元は kv/fsm.go:381 の 1 箇所のみ。
  • 応答の消費側は adapter/internal.go:374-386case error: の arm は ordinary 経路(batch gap / invalid version / value too large)で従来どおり機能します。halt 経路では engine が resolveProposal を呼ばずにプロセスを止める(§6.3 契約)ので、この switch には到達しません。proposeMigrationPromote (:398-410) と完全に同型です。
  • validateImportVersion / validateNextImportBatch の他の呼び出し元(store/migration_promote.go:45, store/migration_versions.go:608,617)は marker 追加のみで分類は変えていません。

Test evidence

追加(kv/fsm_migration_import_test.go):

  • TestApplyMigrationImportHaltsOnStoreFailure
  • TestApplyMigrationImportHaltsOnHLCFloorFailure
  • TestApplyMigrationImportHaltsOnUndecodablePayload
  • TestApplyMigrationImportOrdinaryErrorsDoNotHalt(table: 4 sentinel × wrapped)

分類ロジックだけを元に戻すと halt 系 3 本が FAILすることを確認済み(sentinel は残したまま戻して検証したので、ビルドエラーによる偽の失敗ではありません)。

  • go test -race ./kv/ → ok (12.9s)
  • go test -race ./store/ → ok (25.6s)
  • golangci-lint run kv/... store/... → 0 issues

なお同ファイルに対する 7 月の P1 3 件(:37 write gate / :76 staged primary probe / :44 applied index)は、いずれも現 HEAD では対応済みです(ImportVersionsRaft は非ゲート経路、AppliedIndex: f.pendingApplyIdx を同一バッチに同梱)。

@claude review

@claude

claudeBot commented Aug 22, 2026

Copy link
Copy Markdown

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


Review complete ✅

  • Read trigger comment and gather context
  • Inspect kv/fsm_migration_import.go (latest commit da3b967)
  • Verify applyMigrationImport halt/ordinary classification logic
  • Verify store.ErrInvalidImportVersion marker addition and existing callers unaffected
  • Verify adapter/internal.go response handling still correct for ordinary errors
  • Check new tests in kv/fsm_migration_import_test.go
  • Run targeted tests / lint — could not run, Bash tool calls in this session require approval that isn't available in this automated context (even go env was blocked). Verification below is static-analysis only; please rely on the CI run for this PR/commit for actual pass/fail confirmation.
  • Post final review

Verdict: fix is correct

The claim in the trigger comment holds up. I traced the mechanism independently:

internal/raftengine/etcd/engine.go:2955-2973 (applyNormalCommitted) only halts setApplied advancement when the FSM's Apply response implements interface{ HaltApply() error }. Before this commit, kv/fsm_migration_import.go:34-61 (applyMigrationImport) wrapped every failure — decode errors, ImportVersionsRaft errors, HLC-floor read errors — as a plain errors.WithStack(err), which never satisfies that interface. A per-replica failure (Pebble I/O, the encryption gate rejecting a write via s.encryptForKey in store/lsm_migration.go:453) would let that one voter silently skip the imported versions while the leader applied successfully and ack'd the RPC — a real, latent replica-divergence bug surfacing later as missing data after failover/promotion.

The fix (kv/fsm_migration_import.go:34-78) mirrors the existing applyMigrationPromote pattern in kv/fsm_migration_promote.go:37-57 exactly:

  • Classifies exactly three sentinels as "ordinary" (ok to advance setApplied because every replica reaches the same verdict from the same request bytes): store.ErrImportBatchGap, store.ErrInvalidImportVersion (new), store.ErrValueTooLarge.
  • Everything else — decode failures, ImportVersionsRaft store errors, MigrationHLCFloor read failures — goes through haltErr(errors.Wrap(errors.Mark(err, ErrMigrationImportApply), ...)), which does implement HaltApply() (kv/fsm_encryption.go:47-59).

I confirmed:

  • store.ErrInvalidImportVersion (store/store.go:35-40) is now wrapped by all three validateImportVersion failure branches (store/migration_versions.go:369-379), and both callers of validateImportVersionstore/lsm_migration.go:381 (Pebble import path used by ImportVersionsRaft) and store/migration_promote.go:45 (promotion) — pick it up transparently via errors.Is.
  • store/migration_promote.go's classifier (isMigrationPromoteOrdinaryApplyError, kv/fsm_migration_promote.go:86-88) only recognizes ErrInvalidExportCursor, so a validateImportVersion failure inside promotion still halts exactly as before this change — promotion's behavior is genuinely unchanged, as claimed.
  • The RPC-side consumer (adapter/internal.go:365-387, proposeMigrationImport) already had a case error: arm that correctly handles the ordinary-error path returned as a plain error (not *haltApplyResponse) — no change needed there, and none was made.
  • No other code depends on the old anonymous error message text (grep found no matches outside the definition site).
  • Applied-index-gated gateRegistration for the raft path is false (store/lsm_migration.go:320-321), so the encryption writer-registration gate can't fire here — consistent with the July P1 fix that kept migration imports off the direct-write gate; the s.encryptForKey errors that can occur on this path (key rotation/encryption failures) are genuinely per-replica and correctly halt.

New tests (kv/fsm_migration_import_test.go:115-231) are well-targeted: TestApplyMigrationImportHaltsOnStoreFailure, TestApplyMigrationImportHaltsOnHLCFloorFailure, TestApplyMigrationImportHaltsOnUndecodablePayload each assert haltApplyOf(applied) returns a non-nil error wrapping ErrMigrationImportApply and the underlying cause; TestApplyMigrationImportOrdinaryErrorsDoNotHalt is table-driven over all three sentinels (plain and wrapped) and asserts the inverse. The haltApplyOf helper (kv/fsm_encryption_test.go:65-71) correctly mirrors the engine's own type assertion, so these tests are a faithful proxy for the real halt/no-halt decision.

No further issues found in this change. This is a solid, narrowly-scoped fix for a genuine correctness bug (data-loss category per the repo's self-review conventions), following the established applyMigrationPromote pattern.

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:da3b967170

ℹ️ 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 threadkv/fsm.go
if err := f.verifyRouteWriteTimestampFloorForPrefix(prefix, commitTS); err != nil {
return err
}
if err := f.store.DeletePrefixAtRaftAt(ctx, prefix, txnCommonPrefix, commitTS, f.pendingApplyIdx); 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 Tombstone staged rows when applying prefix deletes

When a target route has staged visibility and a key exists only under !dist|migstage|<job>|..., a post-cutover DEL_PREFIX reaches this production FSM with the per-group Pebble store and tombstones only the raw prefix. The staged-only row therefore remains the winning visible version, so operations such as prefix cleanup or flush report success while the deleted key is still readable. The staged-prefix propagation implemented on ShardStore.DeletePrefixAtRaftAt does not help here because production constructs each FSM directly with st in main.go; this apply path must tombstone the corresponding staged logical range as part of the same Raft apply.

Useful? React with 👍 / 👎.

Comment threadadapter/internal.go
MaxCommitTSInclusive: req.GetMaxCommitTs(),
Cursor: req.GetCursor(),
MaxVersions: defaultMigrationExportMaxVersions,
MaxBytes: chunkBytes,

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 Cap export chunks below transport and Raft limits

When the caller supplies a large chunk_bytes, or a single exported version exceeds the requested chunk size, this passes the value through without enforcing the repository's 64 MiB gRPC/Raft command ceiling. ExportVersions appends a version before testing MaxBytes, while the store permits values up to 256 MiB, so an export can produce a response that gRPC cannot send or a batch that MarshalMigrationImportCommand can never propose. Such a row makes the migration permanently unable to advance unless export sizing rejects it or leaves enough envelope headroom for both wire formats.

Useful? React with 👍 / 👎.

Comment threadkv/fsm.go
}

uniq, err := uniqueMutations(muts)
uniq, err := f.uniqueMutationsAboveFloor(muts, r.GetWriteFenceBypassKeys(), commitTS)

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 staged versions in transaction conflict checks

When a one-phase transaction carries a caller-supplied startTS or read set, it can legitimately have ObservedRouteVersion == 0; after cutover it is routed to the target and allowed when its newly allocated commitTS is above the migration floor. This path then checks conflicts only through the target's raw MVCC keys, so if the logical key exists only under the staged prefix with a commit timestamp newer than startTS, the transaction misses that conflict and can commit a decision based on a stale pre-cutover read. The migration-aware transaction path must include the matching staged key in write/read-set OCC validation while staged visibility is active.

Useful? React with 👍 / 👎.

Comment threadadapter/internal.go
Comment on lines +172 to +173
if exportRangeVersionsRequestFullyUnbounded(req) {
return errors.WithStack(status.Error(codes.InvalidArgument, "migration export requires a raw or route bound"))

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 Require logical route bounds on every migration export

When an export supplies raw family bounds but leaves route_start and route_end empty, this validator accepts it because the request is not fully unbounded. Internal-family brackets deliberately scan a family-wide raw prefix, and the resulting empty RouteKeyFilter accepts every routed row in that prefix, so a migration can acknowledge and promote data belonging to unrelated routes that happen to share the source group. Reject exports without the moving route bounds instead of treating raw bounds alone as sufficient.

Useful? React with 👍 / 👎.

Comment threadkv/fsm.go
if landed {
return true, nil
}
route, ok := f.currentStagedVisibilityRouteForKey(meta.PrimaryKey)

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 Check staged commit and rollback records on 2PC retries

This adds a staged probe only for one-phase deduplication, while commitApplyStartTS, appendRollbackRecord, and the 2PC lock/intent helpers still inspect live keys only. During staged visibility, a response-lost COMMIT whose imported commit record exists only under the job prefix is treated as uncommitted, and an imported rollback marker is also missed; a later COMMIT can therefore create a live commit record for an already-aborted transaction, while an ABORT can create a rollback marker for an already-committed one. Reuse the staged route lookup for the 2PC commit/rollback identity checks before producing a new outcome.

Useful? React with 👍 / 👎.

While a route has staged visibility a logical key can exist only under
!dist|migstage|<job>|..., and handleDelPrefix tombstoned the raw prefix
alone. The staged row stayed the winning visible version, so prefix
cleanup and flush reported success with the deleted key still readable.
ShardStore.DeletePrefixAtRaftAt already propagates to staged prefixes,
but that is not the production path: main.go builds each FSM directly
over the per-group store via NewKvFSMWithHLC(st, ...), never over
ShardStore, so the apply path has to propagate itself.
Ordering is load-bearing. The raw delete bundles pendingApplyIdx into
its Pebble batch, so it has to be the last write and the staged deletes
pass appliedIndex 0. A crash between them then replays the whole entry,
and the staged tombstones are idempotent at the same commitTS. Doing the
raw delete first would advance the applied index and let cold-start
replay skip the staged half for good -- the same class of bug this is
fixing.
Staged deletes are scoped to this FSM's own group and its route's
migration job, so another job's staged rows under the same logical
prefix are untouched; a test pins that.
Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
@bootjp

Copy link
Copy Markdown
OwnerAuthor

HEAD (da3b9671) 一致の未対応指摘 5 件(P1 3 / P2 2) を全件洗い出し、まず P1-a を修正しました(8668bdce。残り 4 件は継続対応します。

P1-a kv/fsm.go:590 — Tombstone staged rows when applying prefix deletes ✅ 指摘どおり

検証しました。指摘は正確です。

  • handleDelPrefixf.store.DeletePrefixAtRaftAt(prefix, ...)raw prefix しか tombstone していません
  • ShardStore.DeletePrefixAtRaftAtstagedVisibilityPrefixDeletes で staged prefix へ伝播します(kv/shard_store.go:4078)。
  • しかし本番はその経路を通りませんmain.go:1457kv.NewKvFSMWithHLC(st, clock, ...)per-group の Pebble store を直接渡して FSM を構築しているためです。ご指摘のとおりです。

結果、staged visibility 中に staged prefix のみに存在するキーは、prefix delete 後も winning visible version として残り、cleanup / flush は成功を返すのに読めてしまいます。

修正: FSM 自身が staged prefix を tombstone するようにしました。既存の currentStagedVisibilityRouteForKey と同じ規約(route.GroupID == f.shardGroupID かつ routeHasStagedVisibility)で、自グループ分だけを対象にします。

順序が load-bearing です

staged 削除を に、しかも appliedIndex = 0 で実行しています。raw 削除が pendingApplyIdx を Pebble batch に同梱するため(store/lsm_store.go:2463)、raw を先にすると applied index が進み、その間にクラッシュすると replay が staged 側を恒久的にスキップします。これは今直しているのと同じクラスのバグなので、raw を最後に置きました。staged tombstone は同一 commitTS で冪等なので、途中クラッシュ後の再適用も安全です。

Test evidence

  • TestDelPrefix_TombstonesStagedRowsUnderStagedVisibility — staged のみに存在する行が prefix delete で消えること
  • TestDelPrefix_LeavesOtherJobsStagedRowsIntact別の migration job の staged 行は消えないこと(job/group スコープの検証)

staged 削除ループを外すと前者が FAIL することを確認済みです。

Caller audit

handleDelPrefix の呼び出し元は kv/fsm.go:518 の 1 箇所のみ。f.storeShardStore である一部テスト経路では staged 削除が二重に走り得ますが、同一 commitTS の同一 tombstone なので冪等です(currentStagedVisibilityRouteForKey も staged キーに対しては false を返す設計で、staged-of-staged は発生しません)。

  • go test -race ./kv/ ./store/ → ok
  • golangci-lint run kv/... → 0 issues

残り 4 件(未着手・検証済みで実在)

  • P1kv/fsm.go:1277 — one-phase txn の OCC 検証が staged キーを見ていない
  • P1kv/fsm.go:1342 — 2PC の commit/rollback 同一性判定が live キーのみ(commitApplyStartTS / appendRollbackRecord
  • P2adapter/internal.go:440 — export chunk が transport / Raft 上限を超え得る
  • P2adapter/internal.go:173 — migration export に logical route bounds を必須化

いずれも「staged visibility 中に live キーしか見ていない」という同じ根に属します。次の tick で P1 2 件から続けます。

@codex review
@claude review

@claude

claudeBot commented Aug 23, 2026

Copy link
Copy Markdown

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


Review of the 4 remaining findings (HEAD 8668bdc)

I re-read the current code (not just the diff context in the earlier review threads) for each of the 4 items @bootjp listed as still open. Results below — 3 confirmed real, 1 appears already mitigated by existing plumbing.

  • Verify P1: kv/fsm.go one-phase txn OCC validation misses staged keys
  • Verify P1: kv/fsm.go 2PC commit/rollback identity check misses staged keys
  • Verify P2: adapter/internal.go export chunk size can exceed transport/Raft limits
  • Verify P2: adapter/internal.go migration export should require logical route bounds
  • Post findings

1. One-phase txn OCC vs. staged keys — already covered, not an open bug

handleOnePhaseTxnRequest (kv/fsm.go:1291) doesn't call validateConflicts itself, but it doesn't need to: ShardedCoordinator.dispatchSingleShardTxn (kv/sharded_coordinator.go:1533) already runs readKeysWithStagedVisibilityAliasesForGroupandreadKeysWithStagedVisibilityMutationAliasesForGroup (:1559, :1580) before building the request, so r.ReadKeys already contains distribution.MigrationStagedDataKey(jobID, key) for both declared reads and write keys. That gets passed straight into f.store.ApplyMutationsRaftAt(storeMuts, r.ReadKeys, ...) (kv/fsm.go:1343), and the store's checkApplyConflictscheckReadConflicts(ctx, readKeys, startTS) (store/lsm_store.go:2311-2318) evaluates every one of those keys — including the staged alias — against startTS. Same mechanism covers 2PC PREPARE via dispatchMultiShardTxn's groupedReadKeysWithStagedVisibilityMutationAliases (kv/sharded_coordinator.go:1460), so validateConflicts's own staged-blindness (kv/fsm.go:1203) is redundant-but-harmless — it's an eager pre-check, not the actual enforcement point.

Caveat: TestShardedCoordinatorDispatchTxn_AddsStagedReadKeyAlias / ...AddsStagedWriteKeyAlias (kv/sharded_coordinator_txn_test.go:83, :123) only assert the alias lands in the outgoing ReadKeys, against a mock recordingTransactional — there's no end-to-end test that actually imports a staged version and confirms the one-phase apply rejects a conflicting write. Worth adding one for confidence, but I don't see a code path where this is currently broken.

2. 2PC commit/rollback identity check vs. staged keys — confirmed real, unaddressed

MigrationFamilyTxnCommit / MigrationFamilyTxnRollback are real, non-drain-only brackets in migrationFamilyBrackets() (distribution/migrator.go:440-441), so !txn|cmt|... / !txn|rb|... records genuinely get staged during a migration, same as user data.

But unlike dedupProbeOnePhase (kv/fsm.go:1388-1409, which was fixed to fall back to currentStagedVisibilityRouteForKey + the staged alias), the 2PC identity checks never look at the staged copy:

  • txnCommitTS (kv/fsm.go:1723-1736) — only f.store.GetAt(ctx, txnCommitKey(...), ...).
  • commitApplyStartTS's rollback-marker check (kv/fsm.go:1501) — only f.store.ExistsAt(ctx, txnRollbackKey(...), ...).
  • appendRollbackRecord's marker/commit-record checks (kv/fsm.go:1693, :1703) — same, raw-only.

If a transaction is prepared before cutover and its commit/rollback record only exists under the staged prefix on the target, a post-cutover COMMIT or ABORT replay can miss it and violate the "at most one of {commit, rollback} per (primaryKey, startTS)" invariant the code explicitly documents at kv/fsm.go:1679-1685. This is the same class of bug as the just-fixed prefix-delete issue, just in the 2PC identity path instead of DEL_PREFIX.

Fix this →

3. Export chunk size vs. transport/Raft limits — confirmed real, unaddressed

exportRangeVersionsOptions (adapter/internal.go:424-447) takes req.GetChunkBytes() straight from the caller with only a "default if zero" fallback (:425-428) — no upper clamp. maxMarshaledCommandSize (kv/transaction.go:42) and GRPCMaxMessageBytes (internal/grpc.go:8) are both exactly 64 << 20, so a chunk sized close to or above that ceiling — or one that overshoots because ExportVersions appends a version before testing MaxBytes — can produce a batch that MarshalMigrationImportCommand (kv/fsm_migration_import.go) permanently rejects as "too large" on the import side, stalling that migration batch with no way to shrink it after the fact (the export cursor already advanced past it). Needs a clamp with headroom for the Raft command envelope (job_id/bracket_id/cursor overhead), not just a floor.

Fix this →

4. Migration export should require logical route bounds — confirmed real, unaddressed

validateExportRangeVersionsRequest (adapter/internal.go:159-176) only rejects when the request is fully unbounded (exportRangeVersionsRequestFullyUnbounded, :178-183: raw start/end and route start/end all empty). A request with raw family bounds set but route_start/route_end left empty passes validation. migrationExportFilter (:449 onward) then ANDs the bracket's raw-key filter with the route filter, and an empty route range makes RouteKeyFilter/decodedS3BucketRouteFilter match everything — so such a request exports every routed row in that family's raw prefix, not just the rows in the route actually being split off. This matches the original codex finding; it hasn't been tightened to require the route bound specifically (only "a raw or route bound").

Fix this →


Spot-check of the P1-a fix (8668bdc)

Read the diff for the staged-prefix-delete fix — the ordering rationale (staged deletes first at appliedIndex=0, raw delete last with the real pendingApplyIdx so a crash mid-way replays the whole entry and staged tombstones are idempotent) is sound, and the group/job scoping in stagedVisibilityPrefixDeletes matches the existing currentStagedVisibilityRouteForKey convention. No issues found.

Summary

#FindingVerdict
1One-phase OCC misses staged keysAlready covered via ReadKeys aliasing + checkReadConflicts; add an end-to-end test for confidence but not a live bug
22PC commit/rollback identity check misses staged keysReal, unaddressed
3Export chunk size can exceed transport/Raft limitsReal, unaddressed
4Migration export should require route boundsReal, unaddressed

I did not implement fixes since this was a review request — @bootjp said they'll continue with the P1 items next. Happy to implement any of these on request.
(branch design/hotspot-split-m2-cross-group)

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:8668bdcec2

ℹ️ 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 threadkv/fsm.go Outdated
if f == nil || f.routes == nil {
return nil
}
snap, ok := f.routes.Current()

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 Carry staged-delete routing in the Raft entry

When a target follower applies this prefix-delete entry before its asynchronous catalog watcher has installed the CUTOVER descriptor, Current() can lack staged visibility even though the leader's snapshot has it. That follower therefore skips the staged tombstone but still persists the raw delete and applied index; after its watcher catches up or it becomes leader, staged-only rows remain readable on that replica. Encode the migration job/ranges in the proposed command or otherwise use durable, apply-consistent routing metadata rather than replica-local catalog state.

Useful? React with 👍 / 👎.

Comment threadkv/fsm.go
if _, _, ok := distribution.MigrationStagedDataKeyParts(key); ok {
return distribution.Route{}, false
}
snap, ok := f.routes.Current()

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 Make the staged retry probe deterministic across voters

During a response-lost one-phase retry, a leader whose watcher has applied staged visibility can find PrevCommitTS under the staged prefix and no-op, while a follower whose watcher still has the pre-CUTOVER snapshot falls through and applies the retry as a new live version. With a fresh startTS above the prior commit, the staged read alias does not conflict, so the replicas permanently diverge despite applying the same Raft entry. The staged job identity used by this decision must come from deterministic command or durable FSM state, not each replica's Current() catalog snapshot.

Useful? React with 👍 / 👎.

Comment threadkv/shard_store.go
if start, end, auxiliary := s3BucketAuxiliaryRouteRange(key); auxiliary {
routes, version := s.engine.GetIntersectingRoutesWithVersion(nil, nil)
for _, route := range routes {
if routeHasStagedVisibility(route) && migrationRouteRangesIntersect(route.Start, route.End, start, end) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep S3 auxiliary routing on the migrated owner after promotion

Once promotion clears StagedVisibilityActive, this branch stops associating bucket metadata and generation keys with the migrated !s3route|... owner and falls back to routing their raw !s3|bucket|... keys. The promoted versions remain physically on the target group, so subsequent S3 reads, scans, and writes can be sent back to the unrelated raw-key owner and observe stale or missing bucket state; the coordinator and ShardRouter repeat the same staged-only predicate. Preserve a permanent decoded-bucket ownership rule after the staged overlay is cleared.

Useful? React with 👍 / 👎.

Comment threadadapter/internal.go
Comment on lines +434 to +435
StartKey: req.GetRangeStart(),
EndKey: req.GetRangeEnd(),

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 Add filesystem chunk brackets before exporting migrated routes

For a route in the filesystem chunk domain, the logical catalog interval is !fs|route|chk|..., while the actual payload rows are stored under !fs|chk|... and only normalize to that route through fskeys.ExtractRouteKey. The migration plan has no filesystem chunk family bracket, so the user bracket supplies the virtual route interval here as the raw StartKey/EndKey; the export reaches none of the chunk payloads, yet the bracket can complete and be promoted. A cross-group split between file boundaries therefore loses every chunk in the moved files unless the plan scans the raw chunk prefix and applies the logical route filter.

Useful? React with 👍 / 👎.

This reverts 8668bdc. The fix decided whether to write a staged
tombstone from f.routes.Current(), which is WrapDistributionEngine over
the route engine that CatalogWatcher.Run refreshes on a polling timer --
replica-local state that is not synchronized with Raft apply.
A follower applying the prefix-delete entry before its watcher installs
the CUTOVER descriptor therefore skips the staged tombstone while still
persisting the raw delete and the applied index. Its replay can never
revisit the entry, so staged-only rows stay readable on that replica
alone. That is replica divergence, which is worse than the uniform
stale-row bug it was meant to fix: before, every replica agreed.
The underlying bug is real and stays open. Fixing it needs routing that
is durable and apply-consistent -- the migration job and ranges carried
in the proposed command, rather than read from replica-local catalog
state at apply time. That changes the Raft command format, so per
CLAUDE.md it wants a design doc before implementation.
Reverting the test too: TestDelPrefix_LeavesOtherJobsStagedRowsIntact
asserted that another job's staged copy of a deleted logical key
survives, which was my scoping assumption rather than a verified
requirement, and a promotion could resurrect deleted data under it.
Claude-Session: https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE
@bootjp

Copy link
Copy Markdown
OwnerAuthor

kv/fsm.go:630 の P1(Carry staged-delete routing in the Raft entry)は完全に正しく、私が入れた 8668bdce は replica divergence を作り込んでいましたb7aa8826revert しました。

検証

指摘のとおりです。裏を取りました:

  • FSM の routeskv.WrapDistributionEngine(routeEngine)main.go:1505)。
  • その routeEngine を更新するのは CatalogWatcher.Rundistribution/watcher.go:76-101)で、timer によるポーリングです。Raft apply とは同期していません。

つまり f.routes.Current()replica ごとにバラバラな非同期ローカル状態であり、私はそこから 永続化する書き込みの有無を分岐させていました。Raft apply は全 replica で決定的でなければならないという前提を破っています。

結果として起きること(ご指摘のとおり): watcher が CUTOVER descriptor を入れる前にこの entry を適用した follower は、staged tombstone をスキップしたまま raw delete と applied index を永続化します。applied index が進むので replay で戻ることもできず、その replica だけ staged-only 行が読めたままになります。

これは元のバグより悪い状態です。 修正前は「全 replica が一様に stale な行を見せる」でしたが、修正後は「replica 間で状態が食い違う」になります。したがって部分修正やガード追加ではなく、revert が正しい対応と判断しました。

元のバグは未解決のまま残ります

kv/fsm.go:590 の P1(staged 行が prefix delete で消えない)は依然として実在します。修正には、ご指摘のとおり migration job / ranges を提案コマンド側に載せるなど、durable かつ apply-consistent な routing メタデータが必要です。これは Raft コマンド形式の変更にあたるため、CLAUDE.md の design-doc-first 規約に従い、先に設計文書を出すべきと考えています。方針の指示をいただければそこから着手します。

テストも revert しました(重要)

TestDelPrefix_LeavesOtherJobsStagedRowsIntact は「削除された論理キーについて、別 job の staged コピーは残る」と assert していましたが、これは私のスコープ決定を検証せずにテストへ固定してしまったものでした。論理キーが削除されたなら、その staged コピーも消えるべきで、残すと promotion で削除済みデータが復活しかねません。誤った前提をテストで固めるのは有害なので、こちらも戻しています。

現 HEAD(b7aa8826)時点の残指摘

前 HEAD (da3b9671) で確認済みだった以下 4 件は、HEAD 一致判定では stale 扱いになりますが、コードを読んで実在を確認済みなので私の側で追跡を続けます:

  • P1kv/fsm.go:1277 — one-phase txn の OCC 検証が staged キーを見ていない
  • P1kv/fsm.go:1342 — 2PC の commit/rollback 同一性判定が live キーのみ
  • P2adapter/internal.go:440 — export chunk が transport / Raft 上限を超え得る
  • P2adapter/internal.go:173 — migration export に logical route bounds を必須化

8668bdce に対して新たに付いた残り 3 件(kv/fsm.go:1418 staged retry probe の決定性 / kv/shard_store.go:4283 S3 auxiliary routing / adapter/internal.go:435 filesystem chunk brackets)も未対応です。kv/fsm.go:1418 は今回と同じ「apply 時に replica-local 状態へ依存している」系の指摘に見えるため、次はそこから確認します。

@codex review
@claude review

@claude

claudeBot commented Aug 23, 2026

Copy link
Copy Markdown

Claude encountered an error after 9s —— View job


I'll analyze this and get back to you.

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:b7aa882673

ℹ️ 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 threadkv/fsm.go
if f.routes == nil || commitTS == 0 {
return nil
}
snap, ok := f.routes.Current()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep timestamp-floor apply decisions replica-deterministic

When a write is proposed before a route floor is published but applied concurrently with the catalog update, this Current() lookup can see the new floor on some voters and the old snapshot on others because each watcher refreshes its local cache independently. Voters that see the floor return ErrRouteWriteTimestampTooLow and advance their applied index without writing, while lagging voters persist the mutation, producing permanent replica divergence. Carry the relevant floor/version in the Raft command or consult durable Raft-applied target state instead of the local route cache. CLAUDE.mdL67-L67

Useful? React with 👍 / 👎.

Comment threadkv/shard_store.go
return nil, err
}
stagedKey := distribution.MigrationStagedDataKey(route.MigrationJobID, key)
staged, stagedOK, err := latestMVCCVersionAt(ctx, g.Store, stagedKey, ts)

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 Read live and staged aliases from one store snapshot

When a promotion Raft apply commits between the live lookup above and this staged lookup, the promotion batch can atomically copy the staged version to the live key and delete its alias after the first lookup has already observed the pre-promotion state. The second lookup then observes no staged version, so GetAt returns not-found—or an older live value—even though the winning version existed both before and after promotion. Serialize these lookups with promotion or read both aliases from one Pebble snapshot; the scan and latest-version merge paths need the same single-view guarantee. CLAUDE.mdL91-L91

Useful? React with 👍 / 👎.

Comment threadkv/shard_store.go
Comment on lines +621 to +623
return s.scanRouteAtDirectionWithReadFence(
ctx, distribution.Route{GroupID: groupID}, start, end, limit, ts, true, true,
0, nil, 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 Resolve staged routes for explicit-group reverse scans

When RawScanAt supplies a group_id with reverse=true during staged visibility, this passes a synthetic route containing only the group ID, so routeHasStagedVisibility is false and the leader performs a live-only reverse scan. Staged-only keys are omitted and older raw values can be returned, whereas the forward ScanGroupAt path resolves the group's real staged routes first. Route reverse explicit-group scans through the same staged-aware route selection.

Useful? React with 👍 / 👎.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@bootjp