fix(operator): propagate a backup's merge status to StorageBackup.status - #479
Open
boddumanohar wants to merge 1 commit into
Open
boddumanohar wants to merge 1 commit into
boddumanohar wants to merge 1 commit into
Conversation
A StorageBackup stopped being polled the moment the control plane reported it completed, because backupTerminal treated completion as the end of the backup's lifecycle. It is not. Retention merges a completed backup into its successor at an arbitrary later point, moving it through merging to merged, and the control plane pushes nothing: the operator learns of the transition only by asking again. So it never did, and neither merging nor merged ever reached StorageBackup.status. Consumers had no way to tell a merged backup from a restorable one. The e2e retention test had no signal at all and inferred the merge from a shrinking backup count, which cannot work either: a merged backup is retained in the control plane's database with status merged rather than removed, and the backup list applies no status filter, so the count never shrinks. backupPhaseFromAPIStatus also had no case for merged, so it fell through to the default and reported Pending. Fixing the polling alone would therefore have turned a completed backup into a Pending one as soon as it merged, which is worse than the silence: both parts have to land together. Completion is no longer terminal, and a completed backup is re-polled on a separate slow interval rather than the ten-second progress one — a merge is not urgent, nothing waits on it the way a caller waits for the backup itself, and every poll costs one cluster-wide backup list. Merged and failed are terminal. Making Merged reachable also made it reachable by BackupRestore, which waited for a Done phase that can never arrive and polled every ten seconds forever, emitting a warning event each time: the same unbounded-retry shape as the capacity-refusal hang. A merged backup no longer exists on its own, and the control plane refuses a chain containing anything but completed backups, so the restore now terminates with that reason. status.phase carries no Enum marker, so the CRDs are unchanged and nothing needed regenerating. Tests were written first and run red against the unfixed tree: the completed backup returned RequeueAfter=0, and backend status "merged" produced phase "Pending" while still polling. Also carries the prose fixes the house style gate reports for the files this change touches, which is why a few unrelated comment lines move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
noctarius
force-pushed
the
main
branch
2 times, most recently
from
September 9, 2026 10:21
60dceb7 to
fbaabe4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
A
StorageBackupstopped being polled the moment the control plane reported itcompleted, becausebackupTerminaltreated completion as the end of the lifecycle. It is not: retention merges a completed backup into its successor at an arbitrary later point, moving it throughmergingtomerged, and the control plane pushes nothing. The operator learns of the transition only by asking again — so it never did, and neitherMergingnorMergedever reachedStorageBackup.status.Consumers could not tell a merged backup from a restorable one. The e2e retention test had no signal at all and inferred the merge from a shrinking backup count.
Two findings worth flagging, because they change the diagnosis:
merged(tasks_runner_backup.py:360— its own comment says "remove the old backup", but it only rewrites the status), andlist_backupsapplies no status filter. The count never shrinks, regardless of how many new backups arrive. The CR phase is the only viable signal.backupPhaseFromAPIStatushad nomergedcase, so it fell through todefaultand reportedPending. Fixing the polling alone would have turned a completed backup into aPendingone the moment it merged — worse than the silence. Both parts have to land together.Nothing was needed on the control-plane side:
BackupDTOalready passes the status through raw (_dtos.py:544), andbackupAPIResponse.Statusalready parses it. The data was arriving; the operator had stopped looking.The change
completedis no longer terminal.mergedandfailedare.backupMergeWatchRequeue, 5m) rather than the ten-second progress one: a merge is not urgent, nothing waits on it the way a caller waits for the backup itself, and every poll costs one cluster-wide backup list.mergedmaps to a new terminalBackupPhaseMerged.Mergedreachable made it reachable byBackupRestore, which waited for aDonethat can never arrive and polled every ten seconds forever, emitting a warning event each time — the same unbounded-retry shape as the capacity-refusal hang fixed in 900d593. A merged backup no longer exists on its own, and the control plane refuses a chain containing anything but completed backups (backup_controller.py:421), so the restore fails with that reason.status.phasecarries noEnummarker, so the CRDs are unchanged and nothing needed regenerating (make -C operator testproduced no drift).Tests
Written first and run red against the unfixed tree:
The
mergingandfailedrows of the table test pass on the unfixed tree and act as guards.Green after the fix;
make -C operator test,-shuffle=on,make -C operator lint(0 issues), and all 9 house style gates pass. New matrix inoperator/docs/tests/test-plan-backup-lifecycle.md(BL-01, BL-03, BL-06 are the regression rows).What this does not do
status.phaseinstead of the count. Not touched here.StorageBackupSyncalready lists every backend backup once per cluster per cycle and skips tracked CRs outright (storagebackupsync_controller.go:118). Extending it to refresh status on tracked CRs would get this at one API call per cluster instead of one per completed backup, and would retire cleanly when SSE lands. Left as a follow-up rather than folded in, since it changes that controller's contract. @maxschettler — this is the cheaper alternative to lifetime pollers.A correction on the pool-deletion premise
While tracing this I checked the claim that "the operator deletes all backups in a pool when the pool is deleted". It does not, and neither does the control plane:
handleStoragePoolDeletiononly calls the pool DELETE, drops the StorageClass, and clears node labels; there are no owner references onStorageBackup; andpool_controller.delete_pool()never readsBackuprows. Pool deletion is also not blocked by a backup — it counts only lvols, and nothing in sbcli refuses an lvol delete because a snapshot or backup exists.The data loss in the failing test came from deleting the StorageBackup CR, whose finalizer calls the lvol-scoped endpoint and wipes the whole chain for that volume. Both CRDs are
Namespaced, so namespace teardown does the same. That fix needs a per-backup delete endpoint, which sbcli does not have today (/{backup_id}is GET-only) — tracked separately.🤖 Generated with Claude Code