Heal the auth mirror in batches the caller drives, not one blocking sweep - #2061
Heal the auth mirror in batches the caller drives, not one blocking sweep#2061rbuergi wants to merge 2 commits into
Conversation
…weep
The self-heal swept every partition schema inside a single DO block that the
client waited on while holding pg_advisory_xact_lock for the WHOLE sweep. Three
things were wrong with that, and only the first is a timeout:
1. the client blocked on one statement whose duration scales with the size of
the mesh, so a big instance hit Npgsql's default and the migration
CrashLoopBackOff'd — the same image migrating a smaller instance fine
(memex.meshweaver.cloud, 2026-08-22);
2. a timeout ABANDONS the client while the server keeps going: the work is
neither finished nor cancelled, and nothing knows which;
3. the global lock was held for the entire sweep, so every concurrently
booting silo queued behind it — the contention grows with the very thing
that made it slow.
Raising the timeout answers only (1), and answers it with a number that has to
keep growing as the mesh does. So the work is TRIGGERED in bounded batches and
driven to completion instead:
public.mw_auth_mirror_heal_batch(p_after text, p_limit int) RETURNS text
heals at most p_limit schemas after the cursor and returns the last one it
touched; the caller loops until it returns NULL. Each call takes the lock,
commits, and releases it. No statement is long, another silo can interleave, and
a dropped connection costs one batch instead of the sweep — the next call
resumes at the cursor, because the work was always idempotent per schema.
🚨 GetAuthMirrorSelfHealScript() is no longer "the heal" — it only DEFINES the
function. RunAuthMirrorSelfHealAsync is the operation, so no caller can half-run
it by executing the script alone. The three tests that executed the script
directly now call it; that is exactly how this was caught.
Verified against a real Postgres (Testcontainers): 15 heal tests green across
AuthMirrorTriggerTests, AccessTriggerSchemaResolutionTests and
GroupMembershipRecomputeTests; 739/741 for the whole assembly, the one failure
being UserDirectoryCompletenessTests, which is #2031 and fails on main.
Two mistakes the tests caught, worth naming: a bare `RETURN;` is valid in a DO
block and a syntax error in a function returning text (42601, surfacing as the
fixture failing to initialize); and defining the function is not running it.
There was a problem hiding this comment.
Pull request overview
This PR replaces the unbounded auth-mirror sweep with caller-driven batches and updates integration tests to invoke the complete healing operation.
Changes:
- Adds bounded PostgreSQL heal batches and cursor-driven execution.
- Applies per-batch command timeouts.
- Updates auth, access-trigger, and group-membership tests.
Outstanding review items include lock-safe global DDL setup, cursor recovery after failures, 26-schema batch-boundary coverage, and stale XML documentation.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
test/MeshWeaver.Hosting.PostgreSql.Test/GroupMembershipRecomputeTests.cs |
Updates group-membership healing coverage. |
test/MeshWeaver.Hosting.PostgreSql.Test/AuthMirrorTriggerTests.cs |
Tests auth-mirror reconciliation through the new operation. |
test/MeshWeaver.Hosting.PostgreSql.Test/AccessTriggerSchemaResolutionTests.cs |
Updates access projection healing coverage. |
src/MeshWeaver.Hosting.PostgreSql/PostgreSqlSchemaInitializer.cs |
Implements batched self-healing and cursor iteration. |
Suppressed comments (3)
src/MeshWeaver.Hosting.PostgreSql/PostgreSqlSchemaInitializer.cs:359
- This installs the new batch function before any auth-heal advisory lock is acquired.
InitializeAsyncis invoked by each booting silo, so concurrentCREATE OR REPLACE FUNCTIONstatements can race on the samepg_procrow and raise thetuple concurrently updatedfailure described byAcquireSchemaInitLockAsyncbelow. The per-batch lock cannot protect this command; serialize the install under the same cross-silo lock before driving the batches.
await using (var install = dataSource.CreateCommand(GetAuthMirrorSelfHealScript()))
{
// Installing the function is plain DDL — short by construction.
install.CommandTimeout = BatchCommandTimeoutSeconds;
await install.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
src/MeshWeaver.Hosting.PostgreSql/PostgreSqlSchemaInitializer.cs:666
- The 25-schema limit does not bound this batch when it repairs a
GrouporGroupMembershiprow: the upsert intoauth.mesh_nodeslater in the same function fireszzz_group_recompute_*, whoseGroupChangedTriggerFunctionBodyloops every partition with a group grant and performs a full rebuild. A batch containing stale/missing group rows can therefore still fan out across the whole mesh (and repeat once per row), defeating the timeout/scaling fix. Suppress or defer that trigger during mirror backfill, then process the affected projection work in bounded units.
AND t.table_schema NOT IN
('information_schema','pg_catalog','pg_toast','public','admin','auth')
AND t.table_schema NOT LIKE '%\_versions'
AND (p_after IS NULL OR t.table_schema > p_after)
ORDER BY t.table_schema
LIMIT p_limit
src/MeshWeaver.Hosting.PostgreSql/PostgreSqlSchemaInitializer.cs:312
- This is a user-noticeable production fix: it prevents large deployments from failing migration and entering CrashLoopBackOff, so it is not an internal-only change. Add the required per-PR
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-22-<slug>.mdentry withCategory: Fix, a description, icon, and negative date order.
await RunAuthMirrorSelfHealAsync(dataSource, ct).ConfigureAwait(false);
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| -- BOUNDED, ORDERED slice after the caller's cursor. The caller keeps calling with the | ||
| -- last name returned until this returns NULL, so no single statement is long and the | ||
| -- advisory lock above is held for ONE BATCH rather than the whole mesh. |
| string? after = null; | ||
| while (true) | ||
| { | ||
| await using var batch = dataSource.CreateCommand( | ||
| "SELECT public.mw_auth_mirror_heal_batch($1, $2)"); |
| private const int AuthMirrorHealBatchSize = 25; | ||
|
|
||
| /// <summary>Per-batch ceiling. Bounded work deserves a bounded, modest timeout — the point of | ||
| /// batching is that no single statement scales with the size of the mesh.</summary> | ||
| private const int BatchCommandTimeoutSeconds = 120; |
| CREATE OR REPLACE FUNCTION public.mw_auth_mirror_heal_batch(p_after text, p_limit int) | ||
| RETURNS text AS $auth_mirror_heal$ |
Test Results (shard 4)2 109 tests ±0 1 810 ✅ ±0 9m 28s ⏱️ -1s For more details on these failures, see this check. Results for commit 764e5ed. ± Comparison against base commit 1b0b834. ♻️ This comment has been updated with latest results. |
Test Results 55 files ±0 55 suites ±0 47m 12s ⏱️ -44s For more details on these failures, see this check. Results for commit 764e5ed. ± Comparison against base commit 1b0b834. ♻️ This comment has been updated with latest results. |
…e call The #2058 maintenance-timeout helper stays and gains an optional per-call bound — the batched heal's statements are bounded by construction, so they carry the modest per-batch ceiling rather than borrowing the whole-sweep one. The guard test's pin follows the new helper shape, keeping all three protections (a timeout is always applied, exactly one legitimate raw CreateCommand, no self-recursion). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
You were right that raising the timeout was the wrong shape. This is the fix; #2058 is the floor.
What was wrong
The self-heal swept every partition schema inside a single
DOblock that the client waited on while holdingpg_advisory_xact_lockfor the whole sweep. Three problems, and only the first is a timeout:Raising the timeout answers only (1), with a number that has to keep growing as the mesh does.
Trigger, then drive to completion
Heals at most
p_limitschemas after the cursor and returns the last one it touched; the caller loops until it returnsNULL. Each call takes the lock, commits, and releases it.🚨
GetAuthMirrorSelfHealScript()is no longer "the heal" — it only defines the function.RunAuthMirrorSelfHealAsyncis the operation, so no caller can half-run it by executing the script alone.Verified against a real Postgres
Testcontainers, not shape-tests: 15 heal tests green across
AuthMirrorTriggerTests,AccessTriggerSchemaResolutionTestsandGroupMembershipRecomputeTests. Whole assembly 739/741 — the single failure isUserDirectoryCompletenessTests, which is #2031 and fails onmaintoo.Two mistakes the tests caught, worth naming because both are invisible to a compiler:
RETURN;is valid in aDOblock and a syntax error in a function returningtext(42601 — surfaced as the whole fixture failing to initialize)Relationship to #2058
#2058 raises the blanket init timeout to 600s. Keep both: this removes the need for a large number, #2058 remains a sane floor for the other boot-time DDL. If #2058 lands first this needs a trivial rebase.