Skip to content

Commit fd96d5e

Browse files
authored
UI-driven resharding: metadata-store migrations with live operation log (#914)
* Backfill pinned cnpg shard into Duckling spec (spec.metadataStore.cnpgShard) Which cnpg shard a tenant's metadata lives on is currently only a composition-pinned status stamp (status.metadataStore.assignedShard) — not explicit anywhere in spec or the config store. charts PR #12918 adds an optional, schema-validated spec.metadataStore.cnpgShard field whose precedence is: spec override > pinned status > cnpg.activeShard. The provisioner now backfills that field on ready cnpg-shard ducklings with the CR's own pinned assignedShard — the same derived-output → durable-input move as the existing bucket-name backfill, and a pure no-op for the composition since the value equals the pin. Once every duckling carries it, the tenant's shard is explicit spec: the precondition for shard-migration cutovers, which patch this field to a DIFFERENT shard (an explicitly operator-driven step, never done by the backfill; an existing spec value is never overwritten). On a cluster whose Duckling XRD predates the field, the API server silently prunes the patch; the post-patch read-back detects that and latches the backfill off (one WARN, no per-tick patch churn) until the control plane restarts. e2e: new duckling_shard_backfill assertion (cnpg lane) waits for the spec field to match the pinned shard; on an old XRD it probes field support with its own patch and SKIPs loudly instead of failing, so it goes live the moment charts #12918 deploys to mw-dev. * Add UI-driven resharding: metadata-store migrations with live op log Operator-driven migration of an org's DuckLake catalog between metadata stores, in three directions: cnpg shard -> cnpg shard, external(RDS) -> cnpg, and cnpg -> external as the emergency escape hatch. Only the Postgres catalog moves; S3 parquet never does. Full design: docs/design/resharding.md. Config store: reshard operation rows + a verbose operator-facing log table (goose 000018), claim/heartbeat/epoch-fenced writes, cancel flag, and the LOAD-BEARING connection barrier: the lease-grant transaction refuses orgs whose warehouse is resharding, checked under the same per-org advisory lock the ready->resharding CAS takes. The connect-time 57P03 gates (PG wire + Flight) are UX only - a lease could otherwise be granted up to a queue-timeout after the gate ran. Runner (provisioner, every CP replica; single winner per op via claim CAS): block -> drain (leases+queue zero, then zero live workers - each runs a catalog-writing DuckLakeCheckpointer; lingering hot-idle workers retire via the standard CAS path, never raw pod deletes; parked Flight sessions are destroyed locally per CP or they hold leases for the token TTL) -> pause compaction (explicit spec patch, key-presence recorded and restored exactly) -> flip -> copy -> verify -> cleanup -> finalize with an end-of-op report (maintenance-mode duration, per-op tables/rows/bytes). cnpg->ext inverts copy and flip: the type flip deletes the cnpg source role/DB, so it IS the cleanup and only runs after verify; its target password is ephemeral (request -> in-process stash -> runner memory, never persisted). Rollback always patches the source shard VALUE back (never removes the key); ext->cnpg rollback nulls cnpgShard (XRD CEL). Cancel is honored between steps and inside every wait loop. External stores are never modified. Catalog copier: one REPEATABLE READ read-only snapshot, faithful DDL from pg_catalog introspection, raw binary COPY passthrough, constraints then non-constraint indexes (PK-backed excluded), per-table verify plus an outside-tx source-stability recheck that catches concurrent writers (e.g. an in-flight compaction job), fenced by a target-DB advisory lock. Admin API (RoleGate/audited): POST /orgs/:id/reshard, GET /orgs/:id/reshards, GET /reshards/:id{,/log?after_id=}, POST /reshards/:id/cancel. Console: Reshard button on org detail -> target form (shard select or external + once-only password) -> operation page with header (org, from->to, status/step, started/finished, maintenance duration, copy counters) + live incremental log with auto-scroll and a Cancel button. Drift checker treats resharding as expected. Tests: configstore postgres suite (claim/fencing/cancel/log pagination + the grant-gate), runner unit tests (all three directions, rollbacks, cancel, ephemeral-password takeover loss), admin handler tests (validation, secrets never persisted), migration asserts to v18, and four e2e assertions incl. the REAL ext->cnpg positive path off the harness RDS and a real bogus-shard rollback (DUCKGRES_RESHARD_FLIP_TIMEOUT=90s in the CI manifests). * e2e: treat post-reshard connect gate as transient; one-shot negative check The reshard assertions reconnect immediately after the resharding->ready flip, but the connect-time gate reads the polled config snapshot — up to one poll interval of lag during which connects still get the 57P03 'reshard in progress, please retry shortly' rejection. Add that message to _pg_exec's transient-retry list (it says retry, so the harness should), and switch the mid-drain negative connect check to a one-shot psql so it observes the rejection instead of retrying through it. * catalog copy: cast pg_constraint.contype to text contype is the internal "char" type (OID 18), which pgx cannot scan in binary format into a string — the ext->cnpg e2e run streamed the whole catalog and then failed exactly here (and rolled back cleanly). * Reshard: per-op cutover_timeout_seconds instead of a CI-wide short flip timeout The e2e's CI-wide DUCKGRES_RESHARD_FLIP_TIMEOUT=90s (added for the bogus-shard rollback assertion) also bounded the LEGITIMATE ext->cnpg cutover, which needs minutes: the endpoint converged in ~20s but Ready=True (provider-sql role/DB creation + cnpg SASL credential propagation) takes longer, so the real cutover rolled back at 90s. Make the cutover wait per-operation: new cutover_timeout_seconds on the op row/API/UI form (0 = 15m default; DUCKGRES_RESHARD_FLIP_TIMEOUT now only overrides that default). The e2e rollback assertion passes 90s on its own op, the ext->cnpg positive path passes 600s, and the CI env knob is gone from the manifests.
1 parent 6c00303 commit fd96d5e

31 files changed

Lines changed: 5157 additions & 15 deletions

CLAUDE.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,47 @@ touching this path:
528528
`compute_usage_pull_api` assertion (compute + storage) in
529529
`tests/e2e-mw-dev/harness.sh`.
530530

531+
## Resharding (metadata-store migrations) — LOAD-BEARING CONTRACT
532+
533+
Operator-driven moves of an org's DuckLake catalog between metadata stores
534+
(cnpg↔cnpg, ext→cnpg, cnpg→ext escape hatch), admin-console-driven with a
535+
verbose op log. Full design: `docs/design/resharding.md`. Pieces:
536+
`configstore/reshard.go` (+ migration `000018`),
537+
`provisioner/reshard_runner.go` + `catalog_copy.go`, `admin/reshard.go`, UI
538+
`ReshardForm.tsx`/`ReshardOperation.tsx`. Invariants:
539+
540+
- **The sound connection barrier is the lease-GRANT check**, not the
541+
connect-time 57P03 gates: the grant transaction refuses `resharding` orgs
542+
under the same per-org advisory lock `SetWarehouseResharding` takes for the
543+
`ready→resharding` CAS. Never rely on the snapshot-polled connect gate
544+
alone — a lease can be granted up to a queue-timeout after it ran.
545+
- **Drain, never kill**: live queries always finish. Drain = leases==0 AND
546+
queue==0 (one tx) AND zero live org workers (each runs a catalog-writing
547+
`DuckLakeCheckpointer`). Lingering hot-idle workers are retired via the
548+
standard CAS retire path only — never raw pod deletes. Parked reconnectable
549+
Flight sessions are destroyed locally per CP (they'd hold leases ~1h).
550+
- **Flip semantics differ by direction**: a `cnpgShard` change re-points
551+
role/DB in place (source ORPHANED — explicit cleanup after verify); a TYPE
552+
flip to external makes Crossplane DELETE the cnpg role/DB → cnpg→ext runs
553+
**copy-before-flip** (the flip IS the cleanup, only after verify).
554+
**External stores are never modified/deleted.**
555+
- **Rollback patches the source shard VALUE back — never removes the key**
556+
(precedence would fall through to the freshly-stamped bogus status pin);
557+
ext→cnpg rollback must null `cnpgShard` (XRD CEL forbids it on external).
558+
- **The ext target password is ephemeral**: request → in-process stash →
559+
runner memory; never in the op row, log, or audit. Takeover mid-copy fails
560+
with a clear re-run message instead of proceeding without it.
561+
- **Runner fencing**: claim bumps `runner_epoch`; every runner write is
562+
CAS-fenced on (runner, epoch); stale-heartbeat (>5m) ops are takeover-able;
563+
the copy holds a target-DB advisory lock.
564+
- Touching any of this → update `tests/configstore/reshard_postgres_test.go`,
565+
`provisioner/reshard_runner_test.go`, `admin/reshard_test.go`, the
566+
migration asserts in `tests/configstore/migrations_postgres_test.go`, AND
567+
the `reshard_*` assertions in `tests/e2e-mw-dev/harness.sh` (validation,
568+
cancel-during-drain, bogus-shard-rollback, ext→cnpg positive path).
569+
cnpg→ext positive path is unit-only (harness lacks the RDS password);
570+
cnpg→cnpg positive path needs a second mw-dev shard (follow-up).
571+
531572
## TODO Reference
532573

533574
`TODO.md` is a lightweight backlog for ideas that do not yet have a better

controlplane/admin/audit.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,19 @@ func auditActionFor(method, path string) string {
201201
case hasSeg(segs, "impersonate"):
202202
// Impersonation records its own richer row; this is a fallback.
203203
return "impersonate." + verb
204+
case last == "reshard":
205+
// POST /orgs/:id/reshard.
206+
return "reshard.create"
204207
case hasSeg(segs, "users"):
205208
return "user." + verb
206209
}
207210
return "org." + verb
211+
case "reshards":
212+
// POST /reshards/:opid/cancel (reads aren't audited).
213+
if last == "cancel" {
214+
return "reshard.cancel"
215+
}
216+
return "reshard." + verb
208217
}
209218
return "config." + verb
210219
}

controlplane/admin/ducklings_drift.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ func (h *driftHandler) findDrift(c *gin.Context) {
108108
CRReady: ready,
109109
}
110110
switch {
111+
case wh.State == configstore.ManagedWarehouseStateResharding:
112+
// A reshard in flight legitimately holds the warehouse out of
113+
// ready (and the CR may flap Ready during the cutover) — that is
114+
// the operation working, not drift.
115+
continue
111116
case !present:
112117
entry.Issue = "missing"
113118
entry.Message = "warehouse exists but Duckling CR not found"

0 commit comments

Comments
 (0)