Skip to content

backup: keep a concurrently renewed session alive on a failed renewal - #1207

Open
bootjp wants to merge 2 commits into
mainfrom
fix/backup-renewal-generation-guard
Open

backup: keep a concurrently renewed session alive on a failed renewal#1207
bootjp wants to merge 2 commits into
mainfrom
fix/backup-renewal-generation-guard

Conversation

@bootjp

@bootjp bootjp commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Follow-up to #1056, which merged at afe538e4 with two Codex findings still open against that commit.

adapter/admin_backup.go:440 — P1, renewal race

A renewal that fails part way through its fan-out released every group pin and forgot the session unconditionally. If another renewal had already succeeded, that teardown left its caller holding a token whose pins were gone, with retention free to compact the versions underneath it — a backup silently invalid while the caller believed it renewed.

The report offered "serialize renewals per pin, or make cleanup conditional on the failing attempt still owning the session generation". Serializing alone does not fix it: even fully serialized, a later failed renewal still tears down an earlier successful one. So this takes the second option. backupSession carries a generation that every accepted renewal advances in extendBackupSession, RenewBackup captures it before the fan-out, and abandonFailedRenewal cleans up only while that generation is still current.

This is the same hazard class as the existing closeBackupSession comment ("leaving the session live across those proposals let an overlapping renewal commit a fresh Pin after the Release").

adapter/admin_backup.go:344 — P2, ambiguous reservation

BackupReserve commits, the response is lost or the context expires, proposeBackupAll errors, and BeginBackup returns without proposing BackupUnreserve. The unacknowledged reservation holds one of the few global active-backup slots until its TTL for a backup no caller received.

A capacity rejection stays uncompensated — that one is definitive, nothing was reserved. Every other error is ambiguous, and compensateBackupRelease(controlGroup, nil, pinID) already does exactly the right thing: with no data groups it proposes only the unreserve, which is idempotent and keyed by this pin.

Behaviour change

  • A failed renewal that overlapped a successful one no longer releases the pins. Without a concurrent success it tears down exactly as before (TestRenewBackupStillReleasesWhenItOwnsTheSession).
  • BeginBackup now proposes one extra unreserve on non-capacity reservation failures.

Risk

Confined to the renewal and reservation error paths. The generation is process-local session state, not replicated, so no wire or on-disk format changes.

Tests

adapter/admin_backup_renew_race_test.go:

  • TestRenewBackupKeepsAConcurrentlyRenewedSession — the concurrent success is injected from inside the failing renewal's own fan-out, after it captured the generation: onPropose fires on the reserve preceding the failing pin and calls the real extendBackupSession, which is what a successful renewal ends with. Asserts the session survives and that no release or unreserve was proposed.
  • TestRenewBackupStillReleasesWhenItOwnsTheSession — the ordinary failure path still tears down.
  • TestBeginBackupUnreservesAmbiguousReservationFailures — a non-capacity reserve failure proposes the unreserve.

Both fixes revert-checked: reverting the generation guard fails the first test ("the concurrently renewed session must still be live"), reverting the compensation fails the third ("an ambiguous reservation must be compensated").

Verification

go test -race ./adapter/ ./kv/ ./internal/backup/ — all pass (adapter 642s, kv 13s, backup 2.2s). golangci-lint ./adapter/... 0 issues.

Self-review

  1. Data loss — this is the data-loss fix: the P1 is a path where a live backup's pins vanish while the caller holds a token saying otherwise, letting retention compact versions the dump still needs.
  2. Concurrency / distributed failures — the generation is read and compared under the existing backupStateMu; forgetBackupSessionAtGeneration does the compare and the delete in one critical section, so two failing renewals cannot both claim the session. No new lock ordering.
  3. Performance — one extra map read per renewal, and one extra idempotent proposal on an error path that previously leaked a slot.
  4. Data consistency — a renewed token now always names pins that are still held, which is the invariant RenewBackup is supposed to provide.
  5. Test coverage — three tests above, all revert-checked, driven through RenewBackup/BeginBackup rather than the helpers.

https://claude.ai/code/session_013rNHooj7NF3giihWVba8QE

bootjp added 2 commits August 29, 2026 20:48
A renewal that fails part way through its fan-out released every group pin and
forgot the session unconditionally. If another renewal had already succeeded,
that teardown left its caller holding a token whose pins were gone, with
retention free to compact the versions underneath it -- a backup silently
invalid while the caller believed it renewed.

The session now carries a generation that every accepted renewal advances, and
a failing attempt only cleans up while the generation it started from is still
current. A failure with no concurrent success behaves exactly as before.

Also compensate an ambiguous capacity reservation. A capacity rejection is
definitive, but any other proposal error may be a reservation that committed
with only the response lost, and BeginBackup returned without unreserving it --
holding one of the few global active-backup slots until its TTL for a backup no
caller ever received. Unreserve is idempotent and keyed by the pin, so
compensating costs nothing when the reservation never landed.

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

bootjp commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

@codex review
@claude review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-29T11:57:36.896174Z 82e8b2b Manual request
ℹ️ About Codex in GitHub

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

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

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

@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 26 minutes.

View limit details

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

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

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 432c1f63-4b07-46ef-bb7f-ddcb35d06cb5

📥 Commits

Reviewing files that changed from the base of the PR and between ddbb0a5 and 82e8b2b.

📒 Files selected for processing (2)
  • adapter/admin_backup.go
  • adapter/admin_backup_renew_race_test.go

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.

@claude

claude Bot commented Aug 29, 2026

Copy link
Copy Markdown

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


Claude finished @bootjp's task

Todo:

  • Gather context (diff, changed files, CLAUDE.md conventions)
  • Understand request (review the PR per @claude review)
  • Review adapter/admin_backup.go changes (generation guard, ambiguous-reservation compensation)
  • Review new test file adapter/admin_backup_renew_race_test.go
  • Post review feedback

Review

I traced both fixes end-to-end against the FSM apply paths and the existing test helpers; both hold up.

Generation guard (RenewBackup / abandonFailedRenewal, admin_backup.go:440-461,1258-1283)

  • generation is captured via backupSessionGeneration before the fan-out starts, and the compare-and-delete happens atomically under backupStateMu in forgetBackupSessionAtGeneration. Two failing renewals racing the same session therefore cannot both win the delete, and a failing renewal that lost the race to a concurrently-succeeded one correctly skips both forgetBackupSession and compensateBackupRelease.
  • Worth double-checking: extendBackupSession (admin_backup.go:1205-1213) now writes the session back to the map unconditionally (previously only when the deadline advanced), which is required — without it, the generation++ on a struct read from the map would be silently discarded when a renewal doesn't extend the deadline. Good catch, correctly fixed as part of this same change.
  • I also checked the second failure branch (requireRenewableBackupToken failing after a successful fan-out, admin_backup.go:456-459): it reuses the same pre-fan-out generation, so it correctly no-ops if a concurrent renewal already succeeded and bumped it in the meantime, and correctly tears down otherwise (e.g. a genuine concurrent EndBackup/closing). No gap there.
  • Minor style nit (non-blocking): abandonFailedRenewal's branching—
    if live && !s.forgetBackupSessionAtGeneration(tok.pinID, generation) {
        return
    }
    if !live {
        s.forgetBackupSession(tok.pinID)
    }
    s.compensateBackupRelease(groups[0], groups, tok.pinID)
    reads a little indirect. An if live { ... } else { ... } shape expressing "own it or there was nothing to own" might read slightly clearer, but behavior is correct as written.

Ambiguous-reservation compensation (pinBackupGroups, admin_backup.go:344-358)

  • Confirmed compensateBackupRelease(control, nil, pinID) only proposes the Unreserve when groups is nil (len(groups) > 0 guard at admin_backup.go:973), matching the PR description.
  • Confirmed Unreserve/Release apply (kv/fsm_backup.go:61-63ActiveTimestampTracker.ReleaseBackupPin[ForGroup], kv/active_timestamp_tracker.go:304-311) is a plain idempotent map delete, so compensating a reservation that never actually landed is a safe no-op.
  • Capacity-exhaustion (backupCapacityReservationFull) is still excluded from compensation, correctly, since nothing was reserved in that case.

Tests (admin_backup_renew_race_test.go)

  • TestRenewBackupKeepsAConcurrentlyRenewedSession injects the concurrent success from inside the failing renewal's own fan-out via onPropose on the preceding reserve proposal, calling the real extendBackupSession — this is a deterministic, single-goroutine way to simulate the race without introducing test flakiness, and it exercises the exact ordering the fix targets.
  • TestRenewBackupStillReleasesWhenItOwnsTheSession and TestBeginBackupUnreservesAmbiguousReservationFailures correctly cover the non-regression and the P2 fix respectively.
  • All three reuse existing helpers (backupTestProposer, onPropose, subtypes(), backupSubtypeReserve/Pin/Release/Unreserve) already present in admin_backup_test.go — no new test scaffolding needed, consistent with the repo's existing patterns.

Overall: the fix matches the two Codex findings from #1056 precisely, the reasoning in the PR body checks out against the code, and the new tests are well-targeted (revert-checked, as claimed). I don't see any correctness gaps. Only a cosmetic branch-structure nit above, not worth a follow-up on its own.

Note: I was not able to run go test/golangci-lint in this sandbox (Bash execution requires interactive approval that isn't available here), so I verified correctness by manual trace through the code and cross-referencing the FSM apply paths instead of executing the suite. The PR body's own verification (go test -race ./adapter/ ./kv/ ./internal/backup/, golangci-lint) should be trusted for actual pass/fail confirmation.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 82e8b2bd92

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread adapter/admin_backup.go
Comment on lines +473 to +474
if live && !s.forgetBackupSessionAtGeneration(tok.pinID, generation) {
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Compensate when the guarded session has disappeared

If EndBackup removes the session while this renewal is in flight, forgetBackupSessionAtGeneration returns false because the entry is absent, and this early return skips all compensation. A reserve or partial pin fan-out from the renewal can commit after EndBackup's release/unreserve and then remain active until the new TTL, blocking compaction and consuming one of the limited backup slots. Only a still-live session with a different generation proves that another renewal owns the pins; an absent or closing session still needs release/unreserve compensation.

Useful? React with 👍 / 👎.

Comment thread adapter/admin_backup.go
// slots until its TTL for a backup no caller ever received. Unreserve is
// idempotent and keyed by this pin, so compensating costs nothing when
// the reservation never landed.
s.compensateBackupRelease(controlGroup, nil, pinID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Compensate ambiguous ResourceExhausted responses

This compensation is bypassed whenever backupCapacityReservationFull sees any gRPC ResourceExhausted status, even though the forwarding path preserves that status for transport or proxy failures as well as the definitive ErrTooManyActiveBackups apply response. If a local attempt committed the reservation but a subsequent forwarded retry fails with infrastructure-level ResourceExhausted, BeginBackup returns without unreserving the new pin and consumes a backup slot until its TTL. Restrict the definitive-capacity case to the actual capacity error, or issue the idempotent unreserve for ambiguous ResourceExhausted responses too.

Useful? React with 👍 / 👎.

Comment thread adapter/admin_backup.go
Comment on lines +473 to +474
if live && !s.forgetBackupSessionAtGeneration(tok.pinID, generation) {
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reconcile deadlines before preserving a newer generation

When concurrent renewals request different TTLs, a failed long-TTL attempt can first extend the control reservation and some group pins to nearly an hour, while a short-TTL attempt completes and advances the generation. The failed attempt then takes this early return, but the live session records only the successful attempt's shorter deadline; after that token/session expires, the failed attempt's reservation and partial pins remain orphaned until their longer deadline, consuming capacity and blocking compaction. Preserving the successful renewal therefore also needs to reconcile or later clean up deadlines installed by the failed attempt rather than treating every generation change as sufficient cleanup.

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant