Add RocksDB managed backup and restore operations - #1831
Conversation
New operations, also runnable from the CLI under their operation name (and offline against a stopped server): create_backup, list_backups, verify_backup, delete_backup, purge_backups, restore_backup — plus RocksDB support for get_backup, which streams a gzipped-by-default tar (gzip=false for a plain tar) and works against a remote target. Managed backups are an incremental, checksum-verified, server-side repository under storage.backupPath (default <rootPath>/backup), one subdirectory per database, and capture the transaction log alongside the data. Restore closes the database across all worker threads, verifies process-wide closure (registryStatus), then purges and rewrites the directory — guarded by a per-database flock plus an fsynced marker so an interrupted restore is detected and not loaded on restart. A database a loaded component holds open, and always the system database, cannot be restored online (409 pointing at running it offline with the server stopped); offline restore additionally probes RocksDB's own lock before purging. Job workers release their RocksDB handles on exit so the closure check can pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
…wrapping
- bin/backup.ts: load `.env` at the top of runBackupCommand so useOperationApi /
resolveRequestOptions see HARPER_CLI_TARGET/CLI_TARGET — otherwise a .env-configured
remote target was invisible and a destructive backup op could run against local.
- rocksdbBackup.ts: restore into an empty/tableless database no longer fails with
"no tables to back up" — guard requireRocksRootStore on the database actually having
tables (validate + online restore paths).
- restoreMarker.ts: directory fsync is now best-effort via a shared fsyncDir helper that
ignores EPERM/EISDIR/ENOTSUP, so beginRestore/completeRestore work on Windows.
- rocksdbBackup.ts: wrap restore-failure errors in a new Error({cause}) instead of
mutating error.message (a frozen/library error's message can be non-writable in strict mode).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Companion to HarperFast/harper#1831. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backup operation responses exposed rocksdb-js's camelCase BackupInfo fields
(backupId, numberFiles) directly, inconsistent with the snake_case Operations API
convention and these operations' own snake_case inputs. Map at the API boundary:
list_backups returns { backup_id, timestamp, size, file_count } (dropping the internal
appMetadata), and create/verify/restore return backup_id (offline restore: restored_to).
listBackupsInDir stays camelCase for internal use; a new listBackupsOffline maps the CLI
offline list to match. Tests updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rocksdb-backup-operations
…_count) Matches HarperFast/harper#1831, which now returns snake_case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ions # Conflicts: # DESIGN.md # bin/cliOperations.ts
…ions # Conflicts: # components/mcp/tools/operations.ts # server/serverHelpers/serverHandlers.js
The six managed-backup ops (create/list/verify/delete/purge/restore) were registered with `requires_su: true` plus `[READ_PERM]`. Per operation_ authorization gate-2, once a super_user places a requires_su op in a role's `operations` allowlist, verifyPerms returns null (authorized) without ever evaluating the declared table CRUD perms. A super_user could therefore delegate destructive whole-database operations (restore/delete/purge) to a non-SU role while believing only a read-adjacent capability was granted. These are whole-database administrative operations, not table-scoped, so they must never be delegable. Changing the registration shape can't achieve that (gate-2 delegation applies to every requires_su op); the established pattern is to self-enforce super_user in the handler, as get_deployment_ payload does. Add a requireSuperUser gate to each op's request-context entry point: the direct handlers (list/delete/purge) and the job-op validators (create/verify/restore), which run before any job record is created. Also refresh the gate-2 TODO to reflect that the backup ops now self-enforce (get_backup remains the lone op relying solely on the gate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Looks awesome! GPT mostly has concerns about the lock handling and timing with failures. But probably the biggest gap is blob file handling. We could independently handle blob files, but kinda makes the backup system a bit incomplete. We might want to consider some way of creating a snapshot directory that adds hard-links to the blob files so they can exist as snapshots alongside the rocksdb backups? (And include them in the tar'ed snapshot for get_backup). I'd also be fine with blob backup being a follow-up PR and saying backups don't support blobs for now. Good work!
🤖 Reviewed with GPT 5.6
…ions # Conflicts: # bin/cliOperations.ts
Blobs (kriszyp review): file-backed blobs (getBlobPathsForDatabaseName) are now captured with managed backups and get_backup, controlled by exclude_blobs (default false). New dataLayer/blobBackup.ts snapshots each blob root to <backupDir>/blobs/<backupId>/<rootIndex>/ (full per-id copy, hard-link-else-copy, never symlink; atomic temp+rename). create/restore/delete/purge (online + offline) snapshot/restore/remove blobs; restore purges each root then rewrites from the snapshot. get_backup appends blob entries to the tar by streaming the native plain tar minus its 1024-byte trailer, packing blobs via tar-stream, then gzipping the combined stream. Restore-safety fixes (kriszyp review): - Offline lock probe recognizes rocksdb-js 2.5.0's real LOCK error (no code, "While lock file: .../LOCK: Resource temporarily unavailable"), takes the restore lock+marker before probing, and fails closed on contention. - Restore metadata moved to an isolated .restore/ dir keyed by a hash of the db dir name, fixing the database-name-namespace collision and NAME_MAX overflow. - beginRestore reports a pre-existing marker so a failed recovery attempt no longer clears a marker over a possibly half-purged directory. - dropDatabase and restore now serialize on the same per-db lock instead of a check-then-act marker probe. Also fixes a merge regression: resolveRequestOptions' credential resolution moved back inside cliOperations' try so an incomplete auth pair maps to exit(1) again. Atomic get_backup download (temp sibling + rename). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single operation listed under 'Operations API' read oddly. Remove the get_backup entry and point the <api-operation> block at the operations reference (docs.harperdb.io/reference/v5/operations-api/operations) for the full list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The managed backup writes a restore README and a blobs-layout README to disk, but
the streamed get_backup archive carried neither. Generate both on the fly as tar
entries: a top-level README.md (archive contents + how to restore a downloaded
snapshot) and blobs/README.md (an archive-variant of the layout doc — blobs/<rootIndex>/…
with no backup-id level, plus the rootIndex -> storage.blobPaths mapping). The blobs
README content is now shared via blobsReadmeContent(roots, { archive }).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kriszyp
left a comment
There was a problem hiding this comment.
This looks like a solid mechanism, I like this, good work. I think the codex findings are reasonable requests. The snapshot consistency is certainly the most interesting/challenging, but I think the suggestion of having a mechanism to delay/pause blob deletions during the backup process makes sense to me.
🤖 Reviewed with GPT 5.6
- restore metadata dir: '.restore' is itself a legal database name, so a database named '.restore' collided with the metadata directory (markers written inside the live DB; purgeAllFiles deletes them; completeRestore ENOENTs). Rename the dir to contain a backtick, which schemaRegex forbids — guaranteeing it can never be a legal database path. - blob restore root-count mismatch: restoring a 2-root backup into a 1-root config silently collapsed the out-of-range index onto the last root, mis-addressing blobs (records persist storageIndex). Reject before any destructive step instead (assertBlobSnapshotRestorable), pre-checked in both restore paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kriszyp
left a comment
There was a problem hiding this comment.
I'll drop this in for the comments since it is still marked ready, but I assume this is still a bit of WIP with the blob/database coordination.
🤖 Reviewed with GPT 5.6
…ames, tar-stream dep, doc paths - restore metadata dir: the backtick name blocks API creation, but the startup scan opens any CURRENT+MANIFEST directory without applying schemaRegex, so an out-of-band directory at the reserved name could still be loaded (and other DBs' markers written into it). Explicitly skip the reserved dir in both scan loops. Regression added with a real-looking RocksDB planted at the reserved path. - get_backup tar: path.relative() yields '\' on Windows, which would become literal filename characters when extracted on POSIX. Normalize blob entry names to POSIX '/'. Test asserts no emitted tar entry name contains a backslash. - tar-stream: now used directly by production code but only resolved transitively via tar-fs. Declare it (and @types/tar-stream) as direct deps + document in dependencies.md. - docs: update stale '.restore/' references in DESIGN.md and restoreMarker.ts jsdoc to the actual reserved backtick directory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ions # Conflicts: # bin/cliOperations.ts # bin/harper.ts # resources/databases.ts
create_backup is two-phase (engine backup, then blob snapshot); the engine backup is visible to list/verify/restore before the snapshot finishes, so a snapshot failure or a crash between phases left a backup that verified healthy but silently missed its blobs, and a concurrent restore could pick a half-written one. New dataLayer/backupManifest.ts writes <backupDir>/manifests/<id>.json (atomically, after both phases are durable) recording the blob-inclusion policy; a graceful snapshot failure rolls back the engine backup + partial snapshot. Consumers treat a missing manifest as incomplete: list_backups hides it, verify/restore reject it (409), and restore uses the manifest's blobs flag (not snapshot-dir presence) to decide blob restore. verify_backup also flags a manifest that claims blobs but has no snapshot. list/verify responses gain a 'blobs' field. Addresses kriszyp's completion-marker review; the joint engine+blob snapshot and scan/open lock remain deferred (documented best-effort). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stub snapshotBlobs to reject and assert the engine backup, blob snapshot, and manifest are all removed — the rollback path the completion-manifest commit added. Suggested by the Claude PR reviewer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Runs the RocksDB backup / blob / restore-marker / CLI-download unit suites (rocksdbBackup, blobBackup, restoreMarker, bin/backup) in one command. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
useOperationApi forced the operation-API path whenever any last_target was saved, so a 'harper login' against the local server (which saves http://localhost:9925/) made offline backup commands (list_backups, etc.) try to reach the stopped local server (ECONNREFUSED) instead of reading local files. A *local* last_target no longer forces the API path — only an explicit target=/env target or a *remote* last_target does; a local/absent target falls through to the getHdbPid liveness check (API if running, offline files if stopped). Adds routing regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The top-level harper CLI handler printed the raw Error via console.error AND logger.error, dumping the stack twice (e.g. purge_backups with an invalid keep_count). Route through a formatCliError helper: expected client errors (a ClientError with a numeric statusCode — bad args, not found, locked repo) print just 'error: <message>', matching the output of a forwarded operation; genuinely unexpected errors keep their stack for debugging. Adds a unit test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
login() persists via saveCredentials() to $HOME/.harperdb/credentials.json, but the login test suite only mocked cwd/exit/cliOperations — not HOME — so running it wrote mock 'example.com' tokens into the developer's real ~/.harperdb/credentials.json and set last_target there, silently redirecting subsequent 'harper' CLI commands to example.com. Override HOME to a temp dir for the whole suite. Verified the full bin suite no longer mutates the real credentials file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kriszyp
left a comment
There was a problem hiding this comment.
Looks good, nice work. I'm including the comments; codex thinks they are blockers/major, but I think we've agreed it is reasonable follow-up work, so I'm good with merging this.
🤖 Reviewed with GPT 5.6
| } catch (error) { | ||
| throw mapLockedError(error, databaseName); | ||
| } | ||
| // snapshot blobs (unless excluded) then publish the completion manifest; rolls back on failure |
There was a problem hiding this comment.
The binding's backup-directory writer lock ends when rootStore.backup() resolves, before finalizeBackup() copies blobs and publishes the manifest. A concurrent purge_backups can therefore acquire that lock, see and purge this new engine backup, and return while this path continues copying blobs, writes a manifest, and reports a successful backup_id that no longer exists in the engine. Please hold one lifecycle lock across engine creation, blob capture, and manifest publication (and use that same lock for delete/purge), or add an equivalent final engine-presence check under serialization with rollback. A regression can pause snapshotBlobs, run purge, then assert creation cannot return success for an absent backup.
There was a problem hiding this comment.
Real race — the engine backup lock releases before finalizeBackup copies blobs + writes the manifest, so a concurrent purge can delete the engine backup while create finishes. Tracked as a follow-up to serialize backup management ops under a per-database lock: #2031.
| // the restore-metadata directory is reserved: never load it as a database, even if a | ||
| // (out-of-band) RocksDB directory happens to occupy that reserved name — the API can't | ||
| // create it (schemaRegex forbids the backtick), but the scan opens any CURRENT+MANIFEST dir | ||
| if (databaseEntry.name === RESTORE_META_DIR) continue; |
There was a problem hiding this comment.
This skip prevents startup from opening the reserved directory, but it still leaves the collision case unresolved. If the reserved `restore` path is already a RocksDB (possible because discovery historically accepted CURRENT + MANIFEST-* directories without applying schemaRegex), an upgrade now silently makes that database disappear, and the next restore writes its .lock/.restoring files into the same directory via acquireRestoreLock. Please detect an existing non-metadata directory before treating this path as lifecycle state and either refuse startup/restore with an actionable error or migrate the metadata outside the database root. The regression should use a real RocksDatabase containing a row and verify its data is not merely skipped.
There was a problem hiding this comment.
Agreed — the scan skip avoids loading it, but a pre-existing on-disk `restore` database (only reachable via manual creation) would silently disappear. Tracked as a follow-up to warn/refuse instead of silently skipping: #2033.
| request.target || | ||
| process.env.HARPER_CLI_TARGET || | ||
| process.env.CLI_TARGET || | ||
| (lastTarget && !isLocalTarget(lastTarget)) |
There was a problem hiding this comment.
A loopback last_target does not establish that the target is this Harper installation: it may be an SSH tunnel, a container-published port, or another local Harper root. If that endpoint is currently down and this installation's PID is absent, restore_backup/delete_backup/purge_backups now silently operate on this installation's files instead of the saved target. Please make offline mode explicit or persist an unambiguous local-installation identity rather than inferring it from the hostname. Add a regression with a saved loopback target for a different port/installation and assert that it never falls through to direct file access.
There was a problem hiding this comment.
Fair — a loopback last_target may be a tunnel/container/another root, so silently routing destructive offline ops to local files is a footgun. Tracked as a follow-up (restrict local fallback to read-only, or gate destructive ops behind an explicit offline flag): #2032.
Summary
Adds managed backup/restore for RocksDB databases as Operations API operations —
create_backup,list_backups,verify_backup,delete_backup,purge_backups,restore_backup— plus RocksDB support forget_backup(streams a gzipped-by-defaulttar;gzip=falsefor plaintar; works against a remotetarget=). Each is also runnable from the CLI under its operation name, and offline against a stopped server. Managed backups are an incremental, checksum-verified, server-side repository understorage.backupPath(default<rootPath>/backup), one subdirectory per database, capturing the transaction log alongside the data.Purpose
Give RocksDB first-class server-managed backup/restore (LMDB already had volume-snapshot workflows), without stopping the server for the common case.
Where to focus review
dataLayer/rocksdbBackup.ts(restoreBackup) +dataLayer/restoreMarker.ts. Online restore writes an fsynced marker + takes a per-db flock, closes the DB across all worker threads, verifies process-wide closure viaregistryStatus(), then purges + rewrites. A component-held DB (and alwayssystem) can't be closed → 409 pointing at offline restore. Worth checking the close→verify→purge ordering and the marker/lock crash-recovery states (in-progress/incomplete/clear).restoreBackupOffline. The offline path is chosen on a PID heuristic, but the PID file is briefly absent duringharper restart, so it now probes RocksDB's own lock (RocksDatabase.open) before purging — a lock error aborts, a corrupt/half-restored dir still proceeds (recovery).server/jobs/jobProcess.ts+resources/databases.ts(closeDatabase/closeLoadedDatabases, new). Job workers close their process-global RocksDB handles on exit or the closure check can never pass;systemis intentionally left open; tableless databases are handled explicitly.bin/cliOperations.ts. Extracted target + auth resolution intoresolveRequestOptions, now shared bycliOperationsand the streamingget_backupdownload.cliOperationsbehavior should be unchanged (bin suite green).Cross-model review (codex + Gemini)
Fixed from review: offline-restore lock probe, CLI
content-dispositionpath traversal,jobProcesscleanup whenupdateJobrejects, marker-removal fsync, tableless-DB handle leak. Deferred (not blocking):registryStatus()polling — the cross-process case is covered by the offline probe; a native primitive is a rocksdb-js follow-on.resetDatabasesshares the gap), filed as Table background work (expiration timer, delete/cleanup callbacks) not disposed when a database's stores are closed #1811.Docs
Companion docs PR: HarperFast/documentation#590.
Generated by an LLM (Claude Opus 4.8, 1M context).
🤖 Generated with Claude Code
Update — blob backups + restore-safety review (2026-07-29)
This revision addresses the review feedback (kriszyp / GPT 5.6). Merged latest
mainfirst (one conflict inbin/cliOperations.ts, resolved).Blobs (
exclude_blobs, default false → blobs included)File-backed blobs live in roots outside the RocksDB directory, so the engine backup misses them. New
dataLayer/blobBackup.tscaptures them alongside the engine data:<backupDir>/blobs/<backupId>/<rootIndex>/<relpath>— a full per-id copy (like the binding's transaction-log snapshots), hard-linked when possible, copied across filesystems, never symlinked, built in a temp dir and atomically renamed.restore_backuppurges each root and rewrites it from the snapshot;delete_backup/purge_backupsremove the matching snapshots. Wired for both the online ops and the offline CLI.get_backupappends blob files to the same tar underblobs/<rootIndex>/…. Since the binding finalizes its tar with a fixed 1024-byte end-of-archive marker,createBackupStreamstreams the native plain tar minus that trailer, appends blobs viatar-stream, and gzips the combined stream itself.get_backupCLI help updated.Restore-safety fixes
LOCK-file error (a plainError, nocode,"While lock file: …/LOCK: Resource temporarily unavailable"), takes the restore lock+marker before probing, and fails closed. Two-process regression test included.<db>.restoring/<db>.restore.locksiblings to an isolated.restore/dir keyed by a hash of the db-dir name — fixes the collision with a legal database namedorders.restoringand theNAME_MAXoverflow on 250-char names.beginRestorereports a pre-existing marker so a failed recovery attempt no longer clears a marker over a possibly half-purged directory.dropDatabasenow holds the per-db restore lock across the whole drop (serializing with restore on one primitive) instead of a check-then-act marker probe.Please look at
bin/cliOperations.tsauth: the merge resolution moved credential resolution intoresolveRequestOptions, which ran beforecliOperations' try/catch — so an incompleteauth_username=/auth_password=pair escaped theexit(1)handling. Moved back inside the try; the 4harper#1872credential tests pass again. Worth a sanity check that CLI auth precedence is unchanged.get_backuptar merge depends on the binding's fixed 1024-byte trailer; the trailer-strip verifies the withheld bytes are all-zero and throws loudly if the format ever changes.blobBackup.tsheader).dropDatabasenow refuses (409) a database with an incomplete-restore marker, same as it already did for an in-progress restore.Unit suites green (
dataLayer,resources,bin); relying on CI for the integration suite. New tests:blobBackup.test.js, extendedrocksdbBackup.test.js(blobs,exclude_blobs, get_backup tar contents, two-process lock probe), rewrittenrestoreMarker.test.js(collision/NAME_MAX/preexisting/scan),databases.test.js(drop-vs-restore),bin/backup.test.js(atomic download).Generated by an LLM (Claude Opus 4.8, 1M context).