Skip to content

adapter, monitoring: report when a TTL-inline migration pass completes - #1283

Open
bootjp wants to merge 9 commits into
mainfrom
feature/intelligent-galileo-5tnqa8
Open

bootjp wants to merge 9 commits into
mainfrom
feature/intelligent-galileo-5tnqa8

Conversation

@bootjp

@bootjp bootjp commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Design: docs/design/2026_04_17_implemented_ttl_inline_value.md (Phase 3)

Builds on #1278 (now merged): ScanGroupOwnedAt pages and plan rotation.

Summary

There was no way to tell when the Redis TTL-inline migration had drained its backlog. A decision to disable the legacy !redis|ttl| read fallback (Phase 3) could only be based on elapsed time. This PR makes the migrator report each completed pass, per anchor type and data group, and documents the condition to check before disabling the fallback.

What a pass is

  • Each scan plan covers one anchor type and one data group.
  • A pass runs from the first page of the plan's prefix (no cursor) to the first page that reaches the end of the prefix (the cursor resets).
  • It starts when the tick that reads its first page starts, before that tick acquires its read timestamp.
  • Counters restart with the next pass.
  • One tick at a time runs the migration (ttlInlineMu). A tick that finds another migrating skips the migration.

What each pass reports

At the end of each pass the migrator logs ttl inline migrator: pass complete with stable keys:

  • type, group_id;
  • clean, full_pass, pages;
  • keys_scanned, keys_migrated, keys_skipped, keys_failed;
  • duration.

clean requires all of the following:

  • full_pass: the pass started at the top of the prefix, and its group stayed in every tick's plans on this node since;
  • nothing migrated;
  • nothing skipped: no anchor whose route this node did not lead;
  • nothing failed: no rewrite that could not be built.

Log levels:

  • Info: a pass that is not clean, and the first clean pass after one that was not.
  • Debug: repeated clean passes.

Metrics

Each pass has an outcome:

  • clean;
  • partial: rewrote, skipped and failed nothing, but was not full;
  • not_clean.

Two metrics, labelled type, group and outcome, wired from the metrics registry in main.go:

  • elastickv_redis_ttl_inline_migration_passes_total;
  • elastickv_redis_ttl_inline_migration_last_pass_start_timestamp_seconds: when the latest pass with that outcome began.

Edge cases

  • Leadership change: a group that drops out of LocalLeaderGroupIDs loses its open pass on the next tick, but its cursor is kept. If the group comes back, the resumed pass is partial. Losing and regaining leadership between two ticks is not noticed; the doc explains why that is safe given gate condition 1 (no live legacy writer).
  • Failed batch: the page is read again next tick and counted once. Keys rewritten by batches committed before the failure still count as migrated.

Phase 3 gate (doc)

T0 is the latest of: the last upgrade, the last route move between groups, and the last restart of any node. S is the deployment's guaranteed maximum clock offset. Then:

  1. No live build writes the legacy format.
  2. The delta compactor is enabled on every node.
  3. For every type and group, the latest clean pass across nodes started after T0 + S.
  4. That clean pass also started more than S after every not_clean pass on any node. A PromQL query in the doc must return nothing.

The doc also lists what a clean pass does not cover:

Behavior change

  • New log lines from the delta compactor, and two new metric families.
  • Migration writes are unchanged: same pages, cursors, batches and dispatches. The one difference: a SyncOnce that overlaps another one's migration now skips the migration. Overlap only happens with concurrent SyncOnce calls, a test-only path; Run calls it serially.
  • Refactors:
    • scanTTLInlineMigrationRaw returns a page struct.
    • The per-page loop moved into migrateTTLInlinePage.
    • advanceCursor gained a locked variant that reports a reset.
    • migrateTTLInlineOnce takes the tick's snapshot time.
  • newRedisDeltaCompactorIfEnabled takes the metrics registry.

Risk

Low; the change is observability only.

  • Memory: bookkeeping per plan, pruned every tick.
  • Locking: a non-blocking TryLock per tick and one more cursorMu acquisition per page.

Review rounds (Codex)

Finding Status Regression test
Overlapping ticks double-count a page Fixed (4e2a406), then by root cause (33ab941) TestDeltaCompactor_TTLInlineTicksDoNotOverlap
Failed stale page; out-of-order reports; ABA on an empty cursor Fixed by root cause (33ab941): ticks no longer overlap TestDeltaCompactor_TTLInlineTicksDoNotOverlap (both subtests)
duration omits the first page Fixed (4e2a406) TestDeltaCompactor_TTLInlinePassDurationIncludesFirstPage
Last-clean gauge survives later not_clean passes and stale leaders Fixed (9eb1c1f) Outcome gauges, plus gate condition 4
Clean pass must start, not just end, after T0 Fixed (aaee3d9) Start-time gauge, plus gate condition 3
Pass start must not follow its read timestamp Fixed (91df14c) TestDeltaCompactor_TTLInlinePassStartsNoLaterThanItsReadTimestamp
Clock-skew margin in the gate Fixed in the doc (eedce55) —
Track leadership terms for full_pass Not fixed in code; the doc claim was corrected and the precondition made explicit. Reasoning is in the review thread, for the author to decide —

Test evidence

  • TestDeltaCompactor_TTLInlineReportsCompletedPasses (table-driven, adapter/redis_delta_compactor_test.go):
    • clean, migrated-then-clean, multi-tick (600 anchors);
    • leadership away and back (partial, then clean);
    • skipped and failed anchors;
    • a page retried after a failed batch.
  • Regression tests for the Codex findings, listed in the table above. Each failed before its fix.
  • monitoring/redis_ttl_inline_test.go: counters, start-time gauges and nil safety.
  • Mutation checks: each of these fails its targeted case:
    • dropping the prune;
    • dropping the failed-page note;
    • letting skipped keys count as clean.
  • Race suites: go test -race -count=1 ./adapter/... ./monitoring/... passes on 33ab941 (adapter 227s).
    • An earlier run on aaee3d9 hit TestRedis_BullMQDirectCommands failing with NOTLEADER leader not found, an election-timing failure in a test this PR doesn't touch. It passed 8/8 on its own.
  • Root package: go test -run 'Background|DeltaCompactor|TTLMigration|Capabilit' . passes.
  • Lint: golangci-lint v2.9.0 on ./...: 0 issues.
  • Pre-existing flake: TestDeltaCompactor_TTLInlineStopsAfterTimeout (1ms tick budget) failed 2 in 3000 race runs, both with and without this change.
  • Jepsen: not run. The migrator's writes are unchanged; this PR counts and reports what they do.

Self-review (five lenses)

  1. Data loss: no write path changed. Same batches and OCC dispatch at readTS; the cursor still holds on a dispatch error. An overlapping tick skips the migration instead of repeating work.
  2. Concurrency / distributed:
    • One tick at a time migrates (TryLock, so SyncOnce never blocks on it).
    • Pass state and cursor share cursorMu.
    • Reports go out in completion order within a node.
    • Leadership loss prunes the open pass, and a resumed pass is partial.
    • A lose-and-regain between ticks is undetected, which is safe given gate condition 1.
    • The race suites pass.
  3. Performance:
    • Per tick: one TryLock, one time.Now() and a map prune.
    • Per page: one lock acquisition.
    • Per pass: one log line and two metric updates.
    • Cardinality: 8 types × groups led × 3 outcomes.
  4. Data consistency:
    • No timestamps are issued and no reads are added.
    • A pass's start is taken before its first page's read timestamp. The gate compares starts against T0 and against not_clean passes, with a clock-skew margin.
  5. Test coverage: the table-driven pass test, regression tests for every code finding, the metrics tests, and mutation checks.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YZANJ6GDMbc1ZaTeoBVXii

Add a passes counter by anchor type, data group and outcome, and the
last clean pass time per type and group, for the delta compactor to
report into.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZANJ6GDMbc1ZaTeoBVXii
Each scan plan (anchor type, data group) now tallies a pass from the
first page of its prefix to the page that reaches the end: anchors
scanned, migrated, skipped as not led here, and failed to build. When the
pass ends the migrator logs "ttl inline migrator: pass complete" with
stable keys and reports it to the observer main wires from the metrics
registry. A pass is clean when it started at the top of the prefix in
this leadership term and migrated, skipped and failed nothing.

A plan that drops out of the tick's plans (its group's leadership moved)
loses its open pass but keeps its cursor, so the pass it resumes is not
full and cannot be clean. A page retried after a failed batch counts once,
plus the keys its committed batches rewrote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZANJ6GDMbc1ZaTeoBVXii
Document the pass report and the condition to check before disabling the
legacy !redis|ttl| read fallback, and what a clean pass does not cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZANJ6GDMbc1ZaTeoBVXii
@coderabbitai

coderabbitai Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 30 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c768f17e-46c2-453e-b04c-e1d178e72692
📥 Commits

Reviewing files that changed from the base of the PR and between e569513 and eedce55.

📒 Files selected for processing: 10
  • adapter/redis_delta_compactor.go
  • adapter/redis_delta_compactor_test.go
  • adapter/redis_ttl_inline_migration_pass.go
  • adapter/redis_ttl_inline_migrator.go
  • docs/design/2026_04_17_implemented_ttl_inline_value.md
  • main.go
  • main_background_env_test.go
  • monitoring/redis_ttl_inline.go
  • monitoring/redis_ttl_inline_test.go
  • monitoring/registry.go
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

bootjp commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner Author

CI note: test (ubuntu-latest) failed on 41548be, in TestKernelMountUnlinkWhileOpenKeepsDataReadable (internal/filesystem/fuseadapter/mount_e2e_test.go:203). On the real FUSE mount, the kernel returned resource temporarily unavailable for remove .../open.txt. Cleanup then failed with fusermount3: ... Device or resource busy.

This PR doesn't cause it: the diff touches only adapter/ (the Redis TTL-inline migrator), monitoring/, main.go and a design doc. Nothing it changes is imported by the FUSE adapter or its tests. In the same job, adapter (244s), monitoring and the root package all passed under -race. Lint, build, and both encryption test jobs are green on this commit. The base branch (#1278) is green on this job, so there is no fix to port; this looks like a kernel-mount timing flake on the runner. I'm re-running the failed job once.


Generated by Claude Code

@bootjp
bootjp marked this pull request as ready for review October 17, 2026 13:24
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-17T22:50:35.144600Z eedce55 New commits
🔒 Security Review ✅ Completed 2026-10-17T22:51:08.672550Z eedce55 New commits
ℹ️ About Codex in GitHub

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

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

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 41548be683

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread docs/design/2026_04_17_implemented_ttl_inline_value.md Outdated
Comment on lines +69 to +70
if state.pass == nil || (fromStart && state.pass.pages > 0) {
state.pass = &ttlInlineMigrationPass{full: fromStart, startedAt: time.Now()}

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 Track leadership terms before declaring a pass full

Inferring full solely from fromStart does not ensure that the pass stayed within one leadership term as claimed. If a group loses and regains leadership entirely between two compactor ticks, pruneTTLInlineMigrationPlanStates never observes its absence, so the existing open pass remains full; the node then resumes after its old cursor and may emit a clean pass without rescanning keys changed while another leader was active. This can incorrectly authorize removal of the legacy fallback, especially during a rolling deployment where the intervening leader can still write legacy anchors. Associate pass state with a leadership generation or restart the pass when continuity cannot be established.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Not fixed in code. I corrected the claim in the doc (9eb1c1f) and made the precondition explicit instead.

What I traced. A group can lose and regain leadership between two ticks without the migrator noticing. The pass then stays open and full. Every anchor behind its cursor was still read inline earlier in this pass. For one of them to be legacy now, the interim leader must have written the legacy format. That needs a live build older than Phase 1. If such a writer exists, no pass can gate Phase 3 anyway: it could write a legacy anchor behind the cursor of any pass, even after a perfect one. So the gate needs "no live legacy writer" either way.

What changed.

  • Gate condition 1 now says this explicitly.
  • full_pass no longer claims "one leadership term". The doc says it means the group stayed in every tick's plans, and that a lose-and-regain between ticks is not noticed. "What a clean pass does not prove" explains why that's safe given condition 1.

Why not the code fix. Tracking leadership terms would need a new coordinator capability, such as per-group Raft terms. That method would have to be threaded through ShardedCoordinator, CoordinatorForwarder and the wrapper guard tests. For a case condition 1 already rules out, that's more code than it buys. I've passed the call to the PR author, @bootjp.


Generated by Claude Code

Comment thread adapter/redis_ttl_inline_migration_pass.go Outdated
Comment thread adapter/redis_ttl_inline_migration_pass.go Outdated
…m their first scan

Codex review on #1283 found two defects in the pass tally:

- Two ticks that read the same cursor both added the same page to the
  pass. On a middle page the pass counted it twice; on the last page the
  second tick opened and reported a spurious non-full pass. A page is now
  dropped when the plan's cursor moved after its scan began.
- A pass started when its first page was finished, so its duration left
  that page out. It now starts when the first page's scan begins.

Both carry a regression test that failed before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZANJ6GDMbc1ZaTeoBVXii
…ot the last clean pass

Codex review on #1283: a last-clean-pass gauge keeps its value after a
later not_clean pass, and max across nodes can pick a former leader's
stale clean sample. Either let the Phase 3 gate pass while the current
leader was still finding legacy anchors.

Passes now report one of three outcomes: clean, partial (found nothing
but did not read the whole prefix) and not_clean. The gauge becomes
last_pass_timestamp_seconds{type,group,outcome}. The doc's gate requires
the latest clean pass for every type and group to be later than T0 (the
last upgrade, route move between groups, or restart) and later than every
not_clean pass on any node.

The doc also states that a leadership loss and regain between two ticks
is not noticed. It says why that cannot make a clean pass wrong while no
live build writes the legacy format, and lists that as an explicit gate
condition.

The tests were updated first to the new outcome API and failed to build
before the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZANJ6GDMbc1ZaTeoBVXii

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9eb1c1f791

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread docs/design/2026_04_17_implemented_ttl_inline_value.md Outdated
Base automatically changed from feature/focused-pascal-dbr58v to main October 17, 2026 22:25
Codex review on #1283: a pass that ends after T0 may have read its first
pages before T0. A route change at T0 can then put a legacy anchor behind
its cursor, and the remaining pages still finish clean.

The observer now receives the pass's start, which is when its first
page's scan began. The gauge becomes
last_pass_start_timestamp_seconds{type,group,outcome}. The doc's gate now
requires the latest clean pass to have started after T0 and after every
not_clean pass.

The updated tests (the gauge value and the start the observer receives)
failed to build before the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZANJ6GDMbc1ZaTeoBVXii

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aaee3d9998

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread adapter/redis_ttl_inline_migrator.go Outdated
Codex review on #1283: SyncOnce acquires readTS at the start of the tick,
but a pass's start was stamped only when its first page's scan began,
after the delta handlers had run. If a route moved in between, a pass
that read a snapshot from before the move reported a start after it, so
it could satisfy the Phase 3 gate's started-after-T0 condition.

SyncOnce now notes the time before it acquires the read timestamp and
hands it to every page of the tick. A pass opened by such a page starts
then. Regression test: the reported start precedes the coordinator's
first Clock() call, which is where the read timestamp is taken. Before
the fix it came about 10ms after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZANJ6GDMbc1ZaTeoBVXii

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 91df14c46f

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread adapter/redis_ttl_inline_migration_pass.go
Comment thread monitoring/redis_ttl_inline.go
Comment thread adapter/redis_ttl_inline_migration_pass.go Outdated
Comment thread docs/design/2026_04_17_implemented_ttl_inline_value.md
Codex review on #1283 found three more ways for overlapping ticks to
corrupt pass reports:
- a failed stale page still added its migrated keys;
- reports from overlapping ticks could reach the gauge out of order;
- on a one-page plan the cursor resets to empty, so a stale page passed
  the cursor check and was reported as a second pass (ABA).

All three come from ticks overlapping, which only happens when SyncOnce
is called concurrently (tests); Run calls it serially. The migration now
holds ttlInlineMu for the whole tick, and a tick that finds it held skips
the migration. That makes the per-page cursor check from 4e2a406
redundant, so it is removed.

Regression test: TestDeltaCompactor_TTLInlineTicksDoNotOverlap runs a
tick inside the last page's scan, for a one-page plan and a middle page.
Before the change the overlapping tick scanned again, and the one-page
plan reported two passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZANJ6GDMbc1ZaTeoBVXii
Codex review on #1283: pass starts come from each node's wall clock, so a
node whose clock runs ahead can record a pass that began before T0 as
starting after it. The gate's comparisons, start after T0 and clean after
not_clean, now require a margin of S, the deployment's guaranteed maximum
clock offset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZANJ6GDMbc1ZaTeoBVXii
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants