reshard: run each operation in a dedicated pod + stream the catalog backup to S3#942
reshard: run each operation in a dedicated pod + stream the catalog backup to S3#942benben wants to merge 7 commits into
Conversation
…ize ceiling) pg_dump now writes the custom-format archive to stdout and the bytes flow straight into an S3 multipart upload (aws feature/s3/manager, 16MiB parts, concurrency 2). Peak memory is bounded by partsize x concurrency regardless of catalog size — the previous temp-file + single PutObject implementation buffered the whole dump and OOM-killed a 512Mi control-plane pod on a ~20k-table catalog. A nonzero pg_dump exit or an empty dump deletes the uploaded partial object so it can never masquerade as a valid backup.
The CP never executes reshard steps in-process anymore. A reshard start now: - creates the op PENDING and spawns a dedicated pod duckgres-reshard-op-<id> (app=duckgres-reshard, restartPolicy Never, TGPS 600, requests=limits from env-only DUCKGRES_RESHARD_POD_CPU/MEMORY, default 2/8Gi). The pod runs the CP's own image + ServiceAccount and inherits an allowlist of the CP's env spec verbatim (secretKeyRefs preserved — nothing secret lands in a pod spec). - new run mode --mode reshard-runner (both binaries): claim ONE op (DUCKGRES_RESHARD_OP_ID) via the standard CAS, execute the step machine to a terminal state, exit 0 (op row is the outcome's source of truth); only infrastructure errors (store unreachable, claim lost/fenced) exit nonzero. - ephemeral ext-target password handoff replaces claim-on-create: the start replica keeps the password in an in-memory stash and the pod pulls it once over GET /api/v1/reshards/:id/password (internal-secret identity ONLY; 404 when the stash/op is gone -> the machine fails the op with the existing clear re-run message). The pull URL (never the password) is persisted on the op row (migration 000022) so a reconciler respawn re-wires the handoff. - leader-only reconciler: respawns the pod of a pending-past-grace or stale-heartbeat op (bounded, 3 attempts, then force-fail with an operator message), reaps pods of terminal ops. - CreateReshardOperationClaimed/AdoptClaimedOperation/scan-loop are gone; ClaimReshardOperation stays (the pod + takeover respawns use it, which also routes through reconstructProgress for correct takeover rollback). - k8s/networkpolicy.yaml: egress-only policy for app=duckgres-reshard (DNS, 5432, 443, 8080).
CLAUDE.md (run modes + the resharding LOAD-BEARING CONTRACT), docs/design/resharding.md (execution-model section: pod-per-op, reconciler, one-shot password pull; streamed backup; crash-safety), README run modes, main.go usage text. e2e: reshard lanes updated for the pod model — ops start PENDING (the pod claims the row), waits carry pod-schedule/pull latency, and the ext->cnpg positive path now also asserts the duckgres-reshard-op-<id> pod appears with the right labels while the op runs and is reaped by the leader reconciler after it finishes. Runner-pod crash respawn stays unit-covered (documented in tests/mw-dev/README.md).
Test Impact PlanDeterministic summary of how this PR changes tests, CI runners, and coverage-risk signals. Summary
Signals
Coverage risk: neutral or increased No coverage-reduction warnings detected. |
The first e2e run failed 'pre-flip catalog backup did not complete' on the ext->cnpg lane: everything in the runner pod worked (config store, duckling patches, RDS + cnpg connections, the whole copy) EXCEPT the one step that needs AWS credentials/STS, and the actual error line was unrecoverable — the op-log dump was truncated by the log stream and 'run.sh diagnostics' never captured the reshard pod's stdout. - run.sh diagnostics: dump reshard runner pods (describe tail + logs) so the step machine's slog lines (incl. the exact backup error) survive the run. - harness: on a backup-assert failure, first print the op log's backup/STS/S3-related lines (bounded — immune to stream truncation) and the runner pod's own log tail, before the full dump. - reshard-runner mode: log the AWS credential context at boot (region + presence booleans for pod-identity/IRSA env, never values) so a missing-injection case is visible from one log line.
…ck s3:PutObjectTagging) Root cause of the e2e 'pre-flip catalog backup did not complete' failure — PRE-EXISTING on main, not introduced by the pod model: PutObject with an x-amz-tagging header requires s3:PutObjectTagging, which the per-org duckling IAM roles do not grant, so the tagged upload 403s (AccessDenied on s3:PutObjectTagging). PR #939's own merged-commit e2e run failed with the identical error from the old temp-file PutObject path; the assertion never passed against the real cluster. Fix: upload untagged. Lifecycle retention keys on the reserved _reshard_catalog_backups/ prefix (already the documented suggested rule), which needs no extra permission. Unit test now pins that the upload carries NO Tagging; docs updated (resharding.md retention, CLAUDE.md).
|
CI note: the e2e failure ( Fixed here ( Everything else in the pod model worked end-to-end in both runs: cancel + rollback lanes completed inside their runner pods and the reconciler reaped the pods afterwards. |
Run 29265449165 died rc=130 with no FAIL line mid COPY-lane: the harness pod was externally killed (gone from the namespace at diagnostics time) — a node reclaim, the same disruption class the CP and config-store are already pinned against. The harness Job has backoffLimit 0 and ~15 minutes of ordered assertions, so give its pod the same karpenter.sh/do-not-disrupt annotation.
Problem
During a recent reshard, the control-plane pod (512Mi limit) OOM-crashed while running
pg_dumpon a ~20k-table DuckLake catalog in thebackup_catalogstep, orphaning the operation. Two structural problems:PutObject), so memory scaled with catalog size.What this does
1. Each reshard executes in a dedicated per-operation pod
--mode reshard-runner(all-in-one binary +cmd/duckgres-controlplane,-tags kubernetes): connect the config store, claim the ONE op named byDUCKGRES_RESHARD_OP_IDvia the existing claim CAS, execute the existing step machine (provisioner.ReshardRunner, moved as-is —RunSingleOperationreplaces the scan loop; the takeover claim path still runs reshard: reconstruct rollback progress on takeover so cancel unblocks the warehouse #940'sreconstructProgress), exit. The op row is the source of truth for the outcome: failed/cancelled/rolled-back ops exit 0; only infrastructure errors (store unreachable, claim lost/fenced) exit nonzero. SIGTERM cancels the run ctx → rollback within the 600s termination grace.duckgres-reshard-op-<id>(labelsapp=duckgres-reshard,duckgres-op-id=<id>, restartPolicy Never) when a reshard is started. The pod runs the CP's own image + ServiceAccount (no new RBAC) and inherits an allowlist of the CP container's env spec verbatim — a secretKeyRef stays a ref, so the config-store DSN / internal secret are exactly as secret-referenced as on the CP deployment. Arch is pinned to the CP's own (kubernetes.io/arch), nodeSelector/tolerations inherited.DUCKGRES_RESHARD_POD_CPU/DUCKGRES_RESHARD_POD_MEMORY(requests=limits, default 2 CPU / 8Gi), wired throughconfigresolvelike the other pod-scheduling knobs.2. Ephemeral ext-target password handoff (replaces claim-on-create)
DUCKGRES_RESHARD_PASSWORD_URL=http://<cp-pod-ip>:8080/api/v1/reshards/<id>/password.GET /api/v1/reshards/:id/password: internal-secret identity only (an SSO admin is 403'd — operators never read tenant credentials), 404 for terminal/unknown ops or a stashless replica; the access (never the value) is logged to the op log. GETs are not body-audited.password_url, migration000022) so a reconciler respawn re-wires the same handoff while the stashing replica lives. A 404 pull → the runner proceeds stashless and the step machine fails the op with the existing clear "password is not available … cancel and re-run" message + rollback.CreateReshardOperationClaimed/AdoptClaimedOperation(claim-on-create, Reshard: real claim-on-create for the ext-target password case #936) removed;ClaimReshardOperationstays (the pod uses it).Invariant preserved: the password is never at rest — not in the op row, log, audit, any pod spec, or a k8s Secret; creating-replica memory → one authenticated pull → runner memory.
3. Streaming catalog backup
PGCatalogBackuper.Backupnow streamspg_dump --format=customstdout straight into an S3 multipart upload (feature/s3/manager, 16MiB parts × concurrency 2) — no temp file, no whole-dump buffer; memory is bounded regardless of catalog size. stderr is captured for diagnostics; a nonzero pg_dump exit or an empty dump deletes the partial uploaded object so it can never masquerade as a valid backup; an upload failure aborts the multipart and kills pg_dump. Key scheme, object tag,PGPASSWORD-not-argv, empty-dump refusal, andRedacted()errors unchanged.Manifests / RBAC
k8s/networkpolicy.yaml: new egress-only policyduckgres-reshard-runner-boundariesforapp=duckgres-reshard(DNS, 5432 config store/cnpg poolers/external RDS, 443 S3+STS, 8080 CP admin API for the password pull), asserted intests/manifests/.k8s/rbac.yamlneeds nothing new: the runner pod uses the CP's own ServiceAccount, which already holds the Duckling-CR patch + config-store access; pod create/delete for the spawner is already in the CP Role.Charts follow-up (separate repo, required before prod use)
duckgres-reshard-runner-boundaries.DUCKGRES_RESHARD_POD_CPU/MEMORYon the CP deployment (defaults 2/8Gi otherwise).POD_IPon the CP deployment is optional — the CP falls back to interface-address detection for the password URL.Notes
reconstructProgressruns insideexecute(), so the pod claim path — including reconciler respawn takeovers — inherits it unchanged.000022covered in bothTestConfigStoreRunsVersionedSQLMigrationsand the v8 downgrade round-trip (per the tests: cover goose migration 000021 in the v8 downgrade round-trip #941 lesson).Tests
go build ./... && go build -tags kubernetes ./...— greengo vet ./... && go vet -tags kubernetes ./...— greengo test -tags kubernetes ./controlplane/... ./tests/manifests/... ./tests/mw-dev/ ./configresolve/ .— green, except pre-existing postgres-container tests that need docker-compose (unavailable on the dev machine; they run in CI's configstore-integration-tests job)controlplane/reshard_pod_test.go(pod spec: labels/SA/image, env allowlist + secretKeyRef preservation, knobs, no password env for cnpg targets),reshard_reconciler_test.go(respawn with persisted password URL, healthy-op no-op, retry bound → force-fail, terminal-pod reap matrix),reshard_runner_mode_test.go(password pull: secret header, 404-is-terminal, transient retry),provisioner/catalog_backup_test.go(PATH-shim pg_dump + fake S3: streamed bytes/size, PGPASSWORD-not-argv, stderr in errors, partial-object delete, empty-dump refusal),RunSingleOperationoutcome contract inreshard_runner_test.go, password-endpoint auth + spawn-failure paths inadmin/reshard_test.go,SetReshardOperationPasswordURL/ListActiveReshardOperationsintests/configstore/.tests/mw-dev/e2e/harness.sh): reshard lanes updated for the pod model (ops startpending; waits widened for pod schedule/pull) and the ext→cnpg positive path now asserts theduckgres-reshard-op-<id>pod appears (with labels) while the op runs and is reaped by the reconciler after it finishes. Mid-op pod-kill respawn stays unit-covered (documented intests/mw-dev/README.md).🤖 Generated with Claude Code