feat(restore): S3 backup restoration (restore-backup action)#79
feat(restore): S3 backup restoration (restore-backup action)#79delgod wants to merge 22 commits into
Conversation
Register _on_restore_workflow on peer_relation_changed + update_status; dispatch via (instruction, prior-step) tuple match through DOWNLOAD → RESTORE → RESYNC → COMPLETED; primary suppresses/resumes failover; leader advances instruction once all participants reach each barrier (_advance_if_leader + can_restore_workflow_proceed); _restore_teardown resumes suppression and marks RESTORE_FAILED on any abort path. Add restore_id property to ValkeyCluster (needed by _run_restore_step).
…tent move-aside FIX 1 (critical): Broaden _on_restore_workflow except clause from a narrow tuple to except Exception as e: so ValkeyServicesCouldNotBeStoppedError / ValkeyServicesFailedToStartError (standalone Exception subclasses outside the restore-error hierarchy) no longer escape _restore_teardown -> resume_failover(). Also broadens _do_primary_restore to except Exception: so any service-control error triggers roll_back() before propagating. FIX 2: Guard the move-aside in restore_on_primary() with path_exists(pre_restore). On a redelivered hook the .pre-restore file already holds ORIGINAL data; an unconditional second move-aside would clobber it. FIX 3: _restore_teardown(exc) sets RESTORE_UNHEALTHY on ValkeyRestoreUnhealthyError, RESTORE_FAILED for all other failures. FIX 4: Correct download_backup() docstring - buffers via BytesIO (MVP tradeoff). FIX 5: Add event.set_results() before event.fail() in empty-backup-id branch. Tests: 5 new tests in test_restore.py covering service-error teardown path, RESTORE_UNHEALTHY status selection, idempotent move-aside, _wait_until_loaded timeout, and unknown-backup-id rejection.
skourta
left a comment
There was a problem hiding this comment.
I left several comments/questions I will do manual testing next.
|
|
||
| Fail-closed: iterate the snapshotted participant *names* and look each | ||
| up in the live servers. A participant absent from the live set (it | ||
| departed mid-step) counts as NOT reached, so the gate stalls rather |
There was a problem hiding this comment.
This can be better explained. I only understood the comment when I read the code.
| except ValkeyCannotGetPrimaryIPError: | ||
| return "No primary available; cannot restore." | ||
| if "failover_in_progress" in ( | ||
| self.charm.sentinel_manager._get_sentinel_client() |
There was a problem hiding this comment.
If we have to use this then we should make it "public". Do we not have a function that checks this?
| try: | ||
| self._run_restore_step(instruction, step, role) | ||
| except Exception as e: | ||
| # Broad catch is deliberate: _restore_teardown -> resume_failover() is |
| def _do_primary_restore(self) -> None: | ||
| """Re-download the RDB if missing, then restore in-place; roll back on unhealthy state.""" | ||
| bm = self.charm.backup_manager | ||
| if not self.charm.workload.path_exists(bm._download_path): |
There was a problem hiding this comment.
Same if the path is used from outside make it "public"
| """Re-download the RDB if missing, then restore in-place; roll back on unhealthy state.""" | ||
| bm = self.charm.backup_manager | ||
| if not self.charm.workload.path_exists(bm._download_path): | ||
| bm.download_backup(self.charm.state.cluster.restore_id) |
There was a problem hiding this comment.
I thought the download step and restore are separate
There was a problem hiding this comment.
This way we download twice the backup file
|
|
||
| # boto3 writes the whole object into this BytesIO buffer so we can | ||
| # inspect the magic header before committing the file to disk. | ||
| buffer = io.BytesIO() |
There was a problem hiding this comment.
This is very memory consuming. To ensure a proper restore we would need to have at least the size of the rdb file free in memory.
| ) | ||
|
|
||
| buffer.seek(0) | ||
| self.workload.push_data_file( |
There was a problem hiding this comment.
I don't understand if we're downloading into memory why do we write the part file then rename it?
There was a problem hiding this comment.
Is it to protect against possible io writing issues?
| def wait_until_resynced(self) -> None: | ||
| """Bounded poll until this replica reports a connected, in-sync link. | ||
|
|
||
| Purpose-built: the stock ``wait_for_replica_fully_synced`` has no ceiling |
There was a problem hiding this comment.
Comment also hard to understand
| substrate: Substrate, | ||
| ) -> None: | ||
| """Remove the app entirely, redeploy a fresh cluster, restore from S3 -- data comes back.""" | ||
| _write_key(juju, "dr_key", "dr-value") |
There was a problem hiding this comment.
I would very much like our tests to be independently executable. If the cluster is not deployed we can deploy one.
|
|
||
| # Old data must still be present (restore rolled back or never committed). | ||
| got = _read_key(juju, _leader_unit_name(juju), "safe_key") | ||
| assert got == "safe-value", f"Old data lost after corrupt restore; got {got!r}" |
There was a problem hiding this comment.
Why aren't we checking on status?
Collapse the DOWNLOAD step into RESTORE: the primary now suppresses failover, downloads, and swaps in the RDB in a single sweep, while replicas only record the step. suppress_failover already configures every sentinel in one call, so a separate DOWNLOAD barrier only added a hook round-trip with no coordination benefit (PR #79 review). Drop RestoreStep.DOWNLOAD, rework the (instruction, prior-step) tuple dispatch, and remove the re-download fallback in _do_primary_restore (download now always precedes restore in the same step), which also stops the events layer reaching into BackupManager._download_path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mory download_backup buffered the entire object in an io.BytesIO in the charm process, needing RDB-sized free memory for a restore (PR #79 review). Stream the S3 object into a tempfile.NamedTemporaryFile instead -- O(1) memory -- validate the magic header from the temp file, then push to the workload and atomically rename onto the final name as before. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ore guard _restore_blocking_reason reached into sentinel_manager._get_sentinel_client() and re-implemented the "failover_in_progress" flag test inline (PR #79 review). Add SentinelManager.is_failover_in_progress() -- a deliberately non-retrying snapshot, unlike the client's @retry-decorated version which would block the action guard for minutes -- and call it instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…uards Rewrite the comments flagged as hard to follow in PR #79 review: can_restore_workflow_proceed (fail-closed rationale), the broad-except in _on_restore_workflow, and wait_until_resynced. Also document why the peer-relation guards in base_events / external_clients / tls return during a restore -- the skipped reconcile self-heals on the post-restore relation-changed, and deferring a departed event is unsafe. Comment-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on status Per PR #79 review: call the idempotent _deploy_cluster_and_s3 at the top of the disaster-recovery test so it runs standalone, and wait on the RESTORE_FAILED app status (a real convergence signal) instead of a bare sleep in the corrupt-restore test. Also drop the stale DOWNLOAD step from a comment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Condense the multi-paragraph comments and docstrings added across the restore feature to concise, effective one/two-liners. Comment-only: no code logic changes. Also fix two stale references to the removed DOWNLOAD step (restore_role docstring and the role= inline comment). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve conflict in src/events/base_events._on_storage_detaching: keep the storage feature's is_being_removed early-return and _scale_down_unit split (#78) and broaden the pre-scale-down guard to also refuse scale-down while a restore is in progress. Update the restore unit test for the new guard order. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…x both partitions) Both the data and archive partitions now need only ~1x the dataset. The download and the dump.rdb.pre-restore rollback copy previously shared the data partition (2x data). Since a restore discards the currently served data anyway, downtime is not a concern, so optimise for space and I/O instead: keep only the pre-restore rollback copy on the archive partition (1x), and download the restore RDB directly onto the data partition as dump.rdb (after moving the old dump aside). The new dump is written once -- no staging copy -- and the install is a same-partition dump.rdb.part -> dump.rdb rename, not a cross-device copy. The one remaining cross-partition move (dump -> archive/pre-restore, and the reverse on rollback) uses shutil.move on VM (os.replace raises EXDEV across partitions); K8s mv already handles it. Crash-safety is unchanged: pre-restore copy + idempotent move-aside + rollback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Magic-byte validation previously happened only during the download, which now runs after valkey is stopped -- so a missing or non-RDB backup-id would bounce the primary (stop -> download fails -> rollback -> restart). Add verify_backup_is_rdb: a cheap ranged GET of the first bytes, run in _do_primary_restore before the stop and outside the rollback wrapper, so a bad object fails while the primary is still serving, with nothing to roll back. The full-stream magic check during download stays as defence in depth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What
Adds a
restore-backupaction that restores the cluster's dataset from an S3backup produced by
create-backup. Serves both in-place rollback anddisaster recovery onto a freshly-deployed cluster.
juju run valkey/leader restore-backup backup-id=2026-06-29T14:32:45Z
Leader-only and asynchronous: the action validates and initiates, then the
operator watches
juju status(aRESTORE_IN_PROGRESSmaintenance status)until the cluster returns to
active.Approach
valkey is primary/replica via Sentinel with automatic full-resync, so — unlike
etcd's restore-every-node model — restore lands the RDB on only the current
primary and lets replicas resync from it. Coordination is an etcd-style
databag state machine (extends the existing
BackupManager/BackupEvents):DOWNLOAD → RESTORE → RESYNC → COMPLETED, with an app-level target instructionvs. a per-unit completed step; the leader advances only when every participant
reaches the current step. The genuinely hard part is coordinating Sentinel so it
doesn't fail over while the primary briefly restarts.
Restore replaces only the dataset — ACLs,
CharmUserspasswords, and TLS arecharm-managed config and are untouched.
Design highlights
Failover suppression: raises
down-after-millisecondson every sentinelfor the restore window (
DOWNLOAD → RESYNC); symmetric teardown resumes iton every abort path, so a failed restore can't leave Sentinel failover
disabled cluster-wide.
Tuple-match dispatch
(instruction, prior_step): a unit acts only from itsexact prior step — a unit that missed DOWNLOAD can never run the destructive
RESTORE.
Fail-closed barrier: iterates a snapshotted participant set; a departed
participant stalls (never silently dropped), so a mid-restore scale change
can't wedge or bypass the gate.
Safe RDB handling: magic-byte validation in-stream during download, written
to a temp name and atomically renamed onto the final name only on full success
(a partial download never carries the final name);
dump.rdbpreserved asdump.rdb.pre-restorefor rollback.Ordering/idempotency: stop only valkey-server (not Sentinel) to bracket the
swap;
stop_service-first rollback to defeat supervisor auto-restart;idempotent move-aside so a redelivered hook can't clobber the good pre-restore
copy; generous bounded dataset-aware waits (never hang, never false-pass).
Restore-awareness: peer-relation-changed handlers in
base_events,external_clients, andtls, plusrestart_workload, skip work during arestore;
storage-detachingrefuses a scale-down mid-restore (it wouldotherwise issue a manual Sentinel failover and stop a participant).
Testing
Unit: 202/202; lint + static clean. Covers each state transition, the
fail-closed barrier, the tuple-match guard, rollback + suppression-resume on
any step failure, bounded-wait timeouts, and the guard matrix.
Integration (
tests/integration/backup/, MicroCeph + s3-integrator):rollback, disaster recovery, and a corrupt-restore that asserts the cluster
keeps its old data and Sentinel failover still works afterward.
Backward compatibility
New peer-databag fields (
restore_id,restore_instruction,restore_participants,restore_step,restore_role) default falsy andtolerate an old-revision databag (9/edge rolls unit-by-unit); the guards are
pure no-ops until a restore is initiated — no impact on normal operation or the
existing backup feature.