Skip to content

UI-driven resharding: metadata-store migrations with live operation log#914

Merged
benben merged 7 commits into
mainfrom
ben/reshard-machinery
Jul 8, 2026
Merged

UI-driven resharding: metadata-store migrations with live operation log#914
benben merged 7 commits into
mainfrom
ben/reshard-machinery

Conversation

@benben

@benben benben commented Jul 8, 2026

Copy link
Copy Markdown
Member

Moves an org's DuckLake metadata catalog between metadata stores from the admin console, with a verbose live operation log. 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. Design doc: docs/design/resharding.md; new LOAD-BEARING section in CLAUDE.md.

Operator flow

Org detail → Reshard metadata store… → form (target shard select / external endpoint + once-only password, drain timeout, typed confirm) → operation page: header (org, from→to, status + current step e.g. "Waiting for all connections to drain…", started/finished, maintenance-mode duration, tables/rows/bytes copied), a Cancel button, and a live incremental log pane. An end-of-op report (also on failure/cancel) lands in the log: maintenance duration, copy counters, cleanup outcome.

The parts that had to be right

  • Sound connection barrier: the lease-grant transaction refuses resharding orgs, checked under the same per-org advisory lock the ready→resharding CAS takes — the snapshot-polled connect-time 57P03 gates (PG wire + Flight) alone are unsound (a lease can be granted up to a queue-timeout after they ran).
  • Drain, never kill: leases + queued requests must hit zero (one tx), then zero live workers — each worker runs a DuckLakeCheckpointer that writes the catalog independent of sessions. Lingering hot-idle workers retire via the standard CAS retire path (never raw pod deletes). Parked reconnectable Flight sessions (which hold leases up to the 1h token TTL) are destroyed locally per CP once truly idle.
  • Flip semantics differ by direction: a cnpgShard change re-points role/DB in place (source orphaned; dropped explicitly only after verify); a TYPE flip to external makes Crossplane DELETE the cnpg role/DB — so cnpg→ext runs copy-before-flip (the flip IS the cleanup). External stores are never modified.
  • Copy: one REPEATABLE READ snapshot, faithful DDL from pg_catalog introspection, raw binary COPY passthrough, constraints then non-constraint indexes, per-table verify, plus an outside-tx source-stability recheck that catches concurrent writers (e.g. an in-flight compaction job that outlived the compaction pause). Target-DB advisory lock fences zombie ex-runners.
  • Crash-safety: claim CAS + runner-epoch fencing on every write, 30s heartbeat, >5m-stale takeover. Rollback patches the source shard VALUE back (never removes the key); ext→cnpg rollback nulls cnpgShard (XRD CEL). Cancel honored between steps and inside every wait loop.
  • Ephemeral ext password: sent once, stashed in-process to the local runner, never persisted to op row / log / audit. A takeover mid-copy fails with a clear re-run message.

Tests

  • tests/configstore/reshard_postgres_test.go — claim CAS, takeover fencing, cancel, log after_id pagination, and the grant-gate test (lease refused during resharding, other orgs unaffected, drain state sees leases+queue). Migration asserts bumped to v18.
  • controlplane/provisioner/reshard_runner_test.go — happy paths for all three directions (incl. copy-before-flip ordering for cnpg→ext), drain-timeout / flip-timeout / verify-mismatch rollbacks, cancel-during-drain, ephemeral-password loss.
  • controlplane/admin/reshard_test.go — validation matrix, 409s, cancel, password never persisted or echoed.
  • e2e (harness.sh): reshard_validation, reshard_cancel_during_drain (held session keeps drain waiting; new connections blocked; drain-not-kill), reshard_bogus_shard_rollback (real flip → Synced=False → rollback, spec.cnpgShard patched back, data intact; DUCKGRES_RESHARD_FLIP_TIMEOUT=90s in CI manifests), and the REAL ext→cnpg positive path: catalog moved off the harness RDS onto shard-001 with data verified intact.
  • UI: npm run build + existing vitest suite green.

Explicitly not covered (and why)

  • cnpg→cnpg positive-path e2e needs a second mw-dev shard (follow-up infra); the direction shares all machinery with ext→cnpg except the flip patch.
  • cnpg→ext positive-path e2e is impossible in-harness: the harness knows the RDS secret NAME but not the password, and the start API deliberately requires the password ephemerally. Covered by unit tests (ordering + flip-back/copy-back recovery).
  • The plan's tests/integration catalog-copy test was superseded by the real e2e copy (RDS → cnpg shard exercises the copier against two real Postgres flavors, strictly stronger).

🤖 Generated with Claude Code

benben added 3 commits July 7, 2026 10:01
…gShard)

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.
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).
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Test Impact Plan

Deterministic summary of how this PR changes tests, CI runners, and coverage-risk signals.

Summary

Area Added Changed Deleted
Test files 3 2 0
E2E/journey files 0 1 0
Workflow files 0 0 0

Signals

  • Test cases: +18 / -0
  • Assertions: +222 / -0
  • Skips or known failures added: 0
  • Workflow continue-on-error added: 0
  • Workflow path filters added: 0
  • Test commands removed from justfile: 0
  • E2E/journey retry lines added: 5

Coverage risk: needs review

Warnings

  • E2E or journey retry behavior changed (needs review)
    • 5 retry/attempt line(s) added

benben added 4 commits July 8, 2026 14:17
- Migration numbering: main's storage-usage migration deliberately took
  000019, leaving 000018 for this branch — union the migration-test
  asserts (17, 18, 19; latest 19) and add 18 + the reshard tables to the
  v8-baseline downgrade block (main's DELETE list couldn't include 18).
- Reshard grant-gate test: seed the org row before the warehouse row —
  fk_duckgres_orgs_warehouse rejects an orphan warehouse (caught by CI;
  this machine has no docker for the postgres-backed suite).
- PASS line: union of main's compute+storage wording and the reshard
  assertions.
…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.
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).
…ip 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.
@benben benben merged commit fd96d5e into main Jul 8, 2026
31 of 32 checks passed
@benben benben deleted the ben/reshard-machinery branch July 8, 2026 15:17
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