Skip to content

feat: add neon-drill, loading restored backups into Neon branches - #37

Open
leochong wants to merge 2 commits into
mainfrom
feat/neon-restore-target
Open

feat: add neon-drill, loading restored backups into Neon branches#37
leochong wants to merge 2 commits into
mainfrom
feat/neon-restore-target

Conversation

@leochong

@leochong leochong commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Why

restore-test proves a backup restores, then deletes the cluster it timed. Nobody can query the result, so "the bytes came back" is as far as the evidence goes.

neon-drill runs the same rehearsal and leaves the recovered data in a throwaway Neon branch — cheap, isolated, and reachable by an ordinary connection string, so a person or a test suite can go and look.

The constraint that shaped the design

A wal-g physical backup cannot be restored into Neon. Neon keeps pages in its own pageserver, so there is no data directory to write and no replication protocol to stream into. Neither backup-fetch nor backup-push can address it — local backup-push asserts wal-g runs on the same host and calls pg_backup_start; remote mode needs REPLICATION for BASE_BACKUP; and both first call GetPgServerInfo, which needs pg_control_system(). Neon grants none of it.

So the drill is two-stage by necessity:

S3/GCS ──backup-fetch──> scratch PGDATA ──pg_ctl──> cluster ──pg_dump|psql──> Neon branch

The physical restore remains the thing under test. The branch is what it leaves behind.

The Neon load is deliberately outside the RTO verdict. It is a transfer bounded by dump-and-load throughput; judging it against a recovery budget would fail the drill for reasons unrelated to backup health. rto covers fetch and replay; the load is reported as neon_load_seconds.

What's here

  • pkg/neon — control-plane client: branch create/list/delete, connection URI, async operation polling. Sits outside pkg/storages and is not registered in StorageAdapters — Neon cannot hold backups, and registering it as a backend would imply it can.
  • internal/databases/postgres/neon_drill.go — reuses the existing drill machinery (ValidateDrillTarget, spacePhase, runFetchPhase, writeRecoveryConfig, stopDrillCluster, the DrillPhase/DoctorStatus vocabulary) rather than duplicating it. NeonDrillOptions/NeonDrillReport embed their restore-drill counterparts, so the restore half reports identically to a plain drill.
  • cmd/pg/neon_drill.go, cmd/pg/neon_branches.go — the latter carries NoStorage, since it never reads backup storage.
  • WALG_NEON_* config keys; the API key joins secretSettings.
  • Docs in PostgreSQL.md, BACKUP-RECOVERY.md, README.md, COPYRIGHT.md.

Which database gets dumped

The drill does not guess. Once the restored cluster is running it queries pg_database and picks the single connectable, non-template database that is not postgres. The ambiguous cases are explicit:

  • one user database — chosen silently and named in the neon-load detail;
  • several — the drill stops and lists them. A Neon branch holds one database, so picking for you could load the wrong data under a green verdict;
  • none but postgres — proceeds, but warns rather than passing, since it has almost certainly moved an empty database.

An explicit --source-database / WALG_NEON_SOURCE_DATABASE is never second-guessed and skips the query, so the drill still works when the catalog cannot be read.

The catalog query builds an explicit DSN rather than going through Connect(), which falls back to localhost:5432 and would otherwise be able to inspect the live cluster instead of the restored one.

Safety

A branch is a billable compute endpoint, so:

  • neon-auth runs first — bad credentials fail in seconds, not after an hour of restoring a backup that has nowhere to go
  • the branch is deleted on every exit path, SIGINT/SIGTERM included
  • only branches carrying the walg-drill- prefix are ever deleted, so a bug in cleanup cannot destroy a hand-made branch
  • the branch password reaches psql through the environment, never argv
  • the API key is scrubbed from control-plane error text. A test pins this — it failed first time, because the error echoed the body verbatim, so an endpoint reflecting the Authorization header would have put the key straight into a CI log

Shared-code change

runReplayPhase gains a leaveRunning bool. A plain drill stops the restored cluster immediately; the Neon drill dumps from it next. One call site updated.

Verification

  • pkg/neon tests use httptest (operation polling, list filtering, delete refusals, 404-as-success, rate limiting, key-never-leaks).
  • Drill tests use a fake client: auth failure creates no branch, load failure still deletes the branch, --keep-branch retains, failed delete reports fail, plus the source-database selection cases.
  • Full sweep over ./cmd/... ./internal/... ./pkg/... ./utility/... is green except TestInterpretTypeSymlink and TestHandlePITRWindow_MinWindowGate, both confirmed pre-existing by running them at a7e17e9b in a scratch worktree.
  • Lint clean for these files. Two misspell hits are suppressed with //nolint: cancelling/cancelled are Neon's wire values, and Americanising them would stop them matching.
  • Ran the built binary against a stub control plane — config resolution, auth, branch filtering, JSON output and the 401 path all behave.

A test also caught a real parser bug: packaged builds print pg_dump (PostgreSQL) 15.6 (Debian 15.6-1.pgdg120+2), and taking the last field yielded (Debian).

Not covered by tests: the pg_database query and the dump/load pipe both need a live cluster, so they are exercised only by the integration path — same as the existing queryRecoveryPoint.

Scope / follow-ups

Per the agreed scope this is the create-and-load side plus listing. Deferred: data-correctness verification (row counts/checksums) and wiring into doctor / backup-verify / retention-validate / pitr-window.

🤖 Generated with Claude Code

Leo and others added 2 commits August 14, 2026 09:06
restore-test proves a backup restores, then deletes the cluster it
timed. Nobody can query the result, so "the bytes came back" is as far
as the evidence goes.

neon-drill runs the same rehearsal and leaves the recovered data in a
throwaway Neon branch, which is cheap, isolated, and reachable by an
ordinary connection string.

It is two-stage by necessity. A wal-g physical backup cannot be restored
into Neon: Neon keeps pages in its own pageserver, so there is no data
directory to write and no replication protocol to stream into. Neither
backup-fetch nor backup-push can address it. The drill restores
physically into a scratch directory, starts the cluster, and dumps it
logically into the branch. The physical restore remains the thing under
test; the branch is what it leaves behind.

The Neon load is deliberately outside the RTO verdict. It is a transfer
bounded by dump-and-load throughput, and judging it against a recovery
budget would fail the drill for reasons unrelated to backup health.

Safety, since a branch is a billable compute endpoint:

- neon-auth runs first, so bad credentials fail in seconds rather than
  after an hour of restoring a backup that has nowhere to go
- the branch is deleted on every exit path, interrupts included
- only branches carrying the walg-drill- prefix are ever deleted, so a
  bug in cleanup cannot destroy a branch somebody created by hand
- the branch password reaches psql through the environment, never argv
- the API key is scrubbed from control-plane error text, which a test
  pins: an endpoint echoing the Authorization header would otherwise
  put it straight into a CI log

pkg/neon sits outside pkg/storages and is not registered in
StorageAdapters. Neon cannot hold backups, and registering it as a
storage backend would imply it can.

runReplayPhase gains a leaveRunning parameter: a plain drill stops the
restored cluster immediately, while the Neon drill dumps from it next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--source-database defaulted to "postgres", which is empty on almost
every cluster. A drill using the default would dump nothing, load
nothing, and report a pass - the exact false assurance the command
exists to prevent.

The restored cluster is already running by the time the dump starts, so
its catalog is the authoritative answer. The drill now queries
pg_database and picks the single connectable, non-template database that
is not the maintenance one.

The ambiguous cases are explicit rather than guessed:

- several user databases: stop and list them. A Neon branch holds one
  database, so choosing here could load the wrong data under a green
  verdict.
- nothing but postgres: proceed, but warn instead of passing, because
  the drill has almost certainly moved an empty database.

An explicit --source-database or WALG_NEON_SOURCE_DATABASE is never
second-guessed and skips the query, so the drill still works when the
catalog cannot be read.

The catalog query builds an explicit DSN rather than going through
Connect(), which falls back to localhost:5432 and would otherwise be
able to inspect the live cluster instead of the restored one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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