Skip to content

db-apply and db-assert walk large database fleets in parallel (--parallel N) - #442

Merged
jeremydmiller merged 1 commit into
masterfrom
gh-431-parallel-db-apply
Aug 10, 2026
Merged

db-apply and db-assert walk large database fleets in parallel (--parallel N)#442
jeremydmiller merged 1 commit into
masterfrom
gh-431-parallel-db-apply

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Fixes #431db-apply walked 1,037 databases strictly sequentially: 8m41s of dead deploy time for a no-op. Implements the direction from the issue thread.

Design (per the thread)

  • --parallel / -p lives on WeaselInput, default 1 — nothing changes for anyone who doesn't set it, and db-assert gets the same knob (identical loop, identical pain).
  • Grouped by descriptor.DatabaseUri(): N physical databases in flight, strict sequencing within a group.
  • Failure semantics identical at every parallelism: failures are caught inside the Parallel.ForEachAsync body (a body throw would otherwise cancel remaining iterations), printed as they happen, and the command terminates with an AggregateException of new DatabaseApplyException wrappers (database identity in message, original as inner). Non-zero exit if any failed.
  • Progress is a completion counter (positional (i+1)/total is meaningless in parallel), console writes under a lock, and per-database migration DDL is buffered and flushed as a unit above its completion line when --parallel > 1 — at 1 the logger streams live, preserving liveness for the >90-minute single-database case. Rides the redirectable-logger seam from 98aecb1 (refactor(core): make the migration logger redirectable per database (weasel#431) #437); a host's deliberately redirected logger is left alone.
  • Summary block: N unchanged / M migrated / K failed, failures listed.

Behavior changes (called out per the thread)

  • An apply failure no longer aborts the walk at the first bad database — every database is attempted, at any parallelism.
  • db-assert now reports unexpected (non-validation) exceptions as failed assertions instead of crashing the command.

Tests

Weasel.CommandLine.Tests 56/56 on net9.0 and net10.0. New coverage: runner unit tests (every DB exactly once; bounded concurrency with no overlap within a physical DB, using the field's two-targets-per-physical-DB topology in miniature; failures collected, not fatal; cancellation propagates; <= 1 runs sequentially) and integration tests against real Postgres (cross-physical-DB parallel apply; aggregate-and-continue at --parallel 4 and at 1; DDL attribution/counter/summary via captured console; no-op reapply; db-assert parallel pass and keep-going-on-failure). Docs updated for both commands.

Merging this also flushes the 11 commits pending on master since 9.23.2 (discovery progress, SQLite generated-columns fix, per-fingerprint stamp keying, SQL Server CREATE DATABASE postcondition, redirectable migration logger) into the next release.

🤖 Generated with Claude Code

…tabases (weasel#431)

db-apply walked its databases strictly sequentially, which at fleet scale
is the deployment cost itself: 1,037 target databases at ~0.5s apiece is
8m41s of dead wall time for a deploy where every single one reports "No
changes detected". db-assert has the identical loop and the identical pain.

Both commands now run through a shared batch runner with a --parallel flag
on WeaselInput (default 1 -- strictly sequential, exactly the old behavior,
so nothing changes without opting in). The shape follows the direction
agreed on the issue:

- The unit of parallelism is the *physical* database: targets are grouped
  by descriptor.DatabaseUri(), the parallelism applies across groups, and a
  group always runs sequentially within itself. Parallel DDL against one
  physical database only contends on its locks, so "--parallel 8" means 8
  physical databases in flight -- also the right unit to reason about
  against a server's max_connections ceiling.

- Failure semantics are keep-going-and-aggregate at every parallelism, not
  just above 1 -- "fail fast at 1, aggregate at 8" reads as a bug later.
  Every database is attempted, each failure prints as it happens, and
  db-apply terminates with an AggregateException of per-database
  DatabaseApplyExceptions (identity in the message, original stack trace in
  the inner exception), which is what turns the exit code non-zero. Note
  Parallel.ForEachAsync cancels its remaining iterations when a body
  throws, so the aggregation is built by catching inside the body -- once,
  in the runner, instead of in every caller.

- AnsiConsole is not safe for concurrent writers, so all output from inside
  the batch goes through a lock. The migration DDL is the other half of
  that problem: under parallelism each database's DDL is routed to its own
  buffer via the redirectable logger seam (#437) and flushed as one unit
  directly above that database's completion line. At --parallel 1 the
  logger is left alone so a genuinely long migration still streams its SQL
  live -- on a >90 minute restore pass that stream is how the operator
  knows the run is alive, and nothing is interleaving with it anyway.

- The positional (i+1)/total progress stops meaning anything once
  completions happen out of order, so it is now a completion counter, and
  the run closes with a summary block -- N unchanged, M migrated, K failed,
  failures listed -- which is the cheapest possible answer to "so how did
  the deploy actually go?".

Cancellation propagates through both the scheduler and the per-database
operation; connection pools are still released per finished database
(deliberately not with the batch token -- a cancelled run is exactly when
abandoning idle pools hurts most).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit 0840f15 into master Aug 10, 2026
16 checks passed
@jeremydmiller
jeremydmiller deleted the gh-431-parallel-db-apply branch August 10, 2026 18:36
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.

db-apply applies databases strictly sequentially: 8m41s of dead deploy time for a 1037-database no-op

1 participant