External Control
The Wallaby.Client package is a standalone remote control plane for a Wallaby installation. It talks only to the source Postgres database so any process with a connection string can drive it. Useful for an ops console, an admin endpoint in another service, or a deployment script. It references no Wallaby host packages and needs no running node.
dotnet add package Wallaby.ClientThe package depends only on Npgsql (plus the logging/DI abstractions) and is AOT-compatible, so it suits small operational tools.
Creating a client
// Owns a pooled data source built from the connection string, disposed with the client:
await using var control = new WallabyControlClient(connectionString);
// Or over an existing NpgsqlDataSource (not disposed with the client):
await using var control = new WallabyControlClient(dataSource);A multi-host data source is automatically targeted at the primary. For token-based authentication (RDS IAM, Azure Entra ID, Cloud SQL IAM) build the data source yourself with Npgsql's UsePeriodicPasswordProvider and use the data-source constructor; the client opens only pooled connections, so nothing else is needed (see authentication). For dependency injection, three idempotent AddWallabyControlClient overloads register a singleton:
builder.Services.AddWallabyControlClient(connectionString); // client owns the data source
builder.Services.AddWallabyControlClient(sp => myDataSource); // caller owns the data source
builder.Services.AddWallabyControlClient(); // resolves NpgsqlDataSource from the containerEverything below shares one design: state changes are guarded updates on durable rows, so every operation is idempotent, safe to run concurrently from multiple actors, and interoperates with the host-side equivalents.
Inspecting state
var state = await control.GetStateAsync();
// state.State Running | SuspendRequested | Suspended
// state.Origin who initiated a suspension: Client | Configuration
// state.Reason / RequestedBy / RequestedAt / SuspendedAt / ResumedAt
// state.Slots every managed slot: name, publication, kind, exists-on-server, active,
// retained-WAL bytes (null when the slot is gone or read from a standby),
// invalidation reason (null unless the server invalidated the slot)A slot's RetainedWalBytes is how much WAL the server must keep for it. Watch it especially for external slots: they pin WAL from the moment they exist, and Wallaby's own heartbeat does not advance them, so a consumer that lags (or never connects) grows this number until max_slot_wal_keep_size invalidates the slot.
Suspend and resume
var suspended = await control.SuspendAsync(new WallabySuspendOptions
{
Reason = "PG18 major-version upgrade",
});
// ...later...
await control.ResumeAsync();SuspendAsync durably drops every replication slot Wallaby manages (primary and external) — and every Wallaby-managed publication with them, so the database is fully quiesced: the upgrade precheck passes, and schema migrations blocked by publication column lists or row filters (cannot alter type of a column used by a publication...) run freely during the window. A publication Wallaby doesn't own (ManagePublicationTables = false) is never touched. The suspension idles the installation across restarts and database outages until an explicit ResumeAsync. On resume, nodes recreate their slots and publications and re-backfill every mapped table. For more information on this feature, see the major-version upgrade runbook.
The re-backfill is upsert-only, so documents whose deletes were committed while suspended would linger in sinks. ResumeAsync(purge: true) additionally purges each mapped destination before its re-backfill, converging sinks to exactly the current table contents (sinks must implement ISinkPurger; destinations are temporarily incomplete while the re-backfill runs). The purge request is persisted with the resume (visible as PurgeOnResume in GetStateAsync), so it survives restarts and is honored by whichever node repairs the gap. It is scoped to that repair: if the next leader finds no gap to repair — the suspension was resumed before any slot was actually dropped — the request is discarded with a logged warning rather than left pending for a later, unrelated slot-loss repair. Use RequestBackfillAsync(table, purge: true) if destinations must still be purged in that case.
Options on WallabySuspendOptions:
| Option | Default | Meaning |
|---|---|---|
Reason | null | Free-text reason, surfaced in GetStateAsync and health-check data. |
RequestedBy | machine name | Recorded with the request. |
WaitForCompletion | true | Wait until every managed slot is verified dropped; false fires the request and returns immediately. |
HostGracePeriod | 15 s | How long to give a running host to drop the slots before the client drops them itself (safe: an actively streamed slot refuses the drop). |
Timeout | 2 min | Deadline for completion; expiry throws WallabyControlTimeoutException with the last observed state. The request stays persisted. |
Progress | null | IProgress<WallabyControlState> reporting each poll. |
WARNING
A client-requested suspension is never auto-resumed: not by restarts, not by fresh deployments. Only an explicit ResumeAsync ends it.
Widening publications for schema migrations
Postgres refuses ALTER TABLE ... ALTER COLUMN ... TYPE (and DROP COLUMN) on any column pinned by a publication column list or row filter. Suspension clears this, but at the cost of a capture outage and a full re-backfill, which is more than a migration needs. Widening avoids both:
// Temporarily reconcile every managed publication to whole-table membership (no column lists).
await control.WidenPublicationsAsync();
// ... run the blocked migration ...
// Clear the flag; the next leader term reapplies the narrow lists from the captured model.
await control.RestorePublicationsAsync();No slot is dropped and the checkpoint stays continuous, so there is no capture gap and no re-backfill.
RestorePublicationsAsync sends a request for the host to restore the publication list.
Semantics:
- While widened, deliberately excluded columns are published: Data minimization via
Consumes/ConsumesAllExceptis temporarily lifted at the server (client-side selection still filters what sinks receive). The leader logs a warning each term while the flag is set, and the health check stays Healthy withpublicationsWidened/publicationsWidenedAtin its data. - Unmanaged publications: (
ManagePublicationTables = false) are never touched. Clear their lists or filters manually before the migration. - Widen while suspended is refused: a suspension already dropped the managed publications.
- Suspending while widened ends the widening: the suspension drops the managed publications outright, and resume recreates them with their configured narrow lists.
- Choosing between the two: engine upgrade (slots must not exist) ⇒ suspend; schema migration only ⇒ widen.
For psql-only operators, the raw-SQL equivalent of the guarded transitions:
-- Widen (only while Running; a host bounce or the client fallback applies it):
UPDATE wallaby.control SET publications_widened = true, widened_at = now(), updated_at = now()
WHERE state = 'Running' AND NOT publications_widened;
SELECT pg_notify('wallaby_control', '');
-- Restore:
UPDATE wallaby.control SET publications_widened = false, widened_at = NULL, widened_by = NULL, updated_at = now()
WHERE publications_widened;
SELECT pg_notify('wallaby_control', '');Triggering backfills
await control.RequestBackfillAsync("public.products");
await control.RequestBackfillAsync("public.products", purge: true); // purge destinations first
await control.CancelBackfillAsync("public.products"); // withdraw a queued request (clears its purge mark)
var status = await control.GetBackfillStatusAsync(); // every tracked table's state
foreach (var table in status.Where(t => t.Status == WallabyBackfillStatus.InProgress))
{
Console.WriteLine($"{table.Table}: {table.Progress:P0} done, about {table.EstimatedRemaining} left");
}Progress and EstimatedRemaining derive from the progress facts the host records; both are null when the table has no row estimate.
Identical semantics to the in-host manager: the request is persisted (it survives restarts), the current leader is signalled instantly, and a request made while the table is already backfilling wins. The client has no entity model, so tables are addressed by schema-qualified name.
CancelBackfillAsync withdraws a queued request before the leader serves it and returns whether a request was withdrawn (see cancelling a queued request for the exact semantics). See Backfill for how snapshots run and what purge: true converges.
Requirements and privileges
- The client never creates schema objects. The
wallabyschema is created and migrated by the Wallaby host at startup, and the client checks its version ledger (wallaby.schema_version); an operation the found version cannot serve throwsWallabySchemaVersionException, which carries the found and required versions. The only DDL it ever runs targets Wallaby-managed publications, and only in the no-host fallbacks: dropping them when it finalizes a suspension, and rewriting their membership when it applies a widening, in both cases objects the host recreates or re-narrows from configuration. - Suspending needs the same rights Wallaby itself uses: drop its replication slots and update the
wallabyschema's tables. - Operations are visible to the installation's own diagnostics: a suspension turns the health check
Degradedwith the reason in its data, and backfill progress shows inGetBackfillStatusAsyncand the host's logs.