Skip to content

fix: the build follower waits on facts it can read, not on a notification that cannot cross processes - #1450

Merged
rbuergi merged 4 commits into
mainfrom
fix/1440-followgo-reads-witness
Aug 13, 2026
Merged

rbuergi merged 4 commits into
mainfrom
fix/1440-followgo-reads-witness

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #1440.

What the wait was waiting on, and why it could not arrive

BuildProtocolDriver.FollowGo was a single mesh.ObserveBuildGo(fingerprint).Take(1). That is a
projection of this cluster's mirror of Admin/Build. A builder in another process writes the
same durable row, but no change feed crosses the process boundary — PostgreSqlChangeListener
is registered and never started in either partitioned-PG overload (AddPartitionedPostgreSqlPersistence
has the AddHostedService commented out; the Aspire overload never had one). A mirror activated
before that write is never told, so the follower was waiting for an event that could not be
delivered to it.

It had also stopped observing its own claim, which closed the second door. RequestBuildClaim's
registration outlives the grant wait and the arbiter grants it the moment the build falls free — so
the follower was handed builds it never ran. It then held the claim at Planning, never
heartbeating, and was never taken over, because its process is alive and HolderStillHoldsIt
defends a live holder by design (a stopped heartbeat on a live process means busy, not dead).

Both terminal states were therefore un-endable:

the builder… what the follower needed what it had
died mid-bake to take the build over nothing — the only process that could publish that GO had stopped
finished to stand down cleanly its stranded registration locked the next image out of the build entirely

The fix

The follower ends on either of two real events, both level-triggered on durable state — the same
property #1437 gave the arbiter, and for the same reason:

  1. The GO becomes VISIBLE. ReadBuildGo reads it off the durable build root — the one witness a
    peer cluster shares with the builder — merged with this cluster's mirror.
  2. The arbiter hands us the claim. On a grant the durable witness is re-read: a GO already
    there means the winner finished and this process stands down without re-baking; no GO means the
    builder went away mid-bake and this process bakes.

WithdrawBuildClaim gives back the candidacy (and a claim that raced it) when the follower reaches
its answer some other way. Run's Catch now bounds the grant wait only — it used to wrap the
bake, so a TimeoutException anywhere in the sweep demoted the winner to a follower still holding
its own claim.

No timer, and no bound bolted onto the wait. A bound would end it by guessing, and a follower
that guesses "the build finished" certifies a share it never probed — a silent wrong answer where
the hang at least announced itself. (#1366 removed a staleness clock for exactly this reason.)

Deliberately NOT in scope

Starting the PG change listener. It would make the GO propagate promptly across clusters rather
than at the arbiter's next pass, but it changes behaviour for every partitioned-PG deployment and
wants its own justification and measurement — the issue says so explicitly. The follower is correct
without it.

Verification — fail before, pass after

Both tests drive the public BuildProtocolDriver.Run, so they compile unchanged against the
previous implementation. Reverting only the two src/ files and re-running:

Follower_TakesOverTheBake_WhenTheBuildFallsFreeWithoutAGo   FAIL  System.TimeoutException  [45 s]
Follower_StandsDown_SoTheNextBuildCanStillBeClaimed         FAIL  System.TimeoutException  [32 s]

With the fix, the whole class and then the whole project:

BuildCoordinationTest          Passed: 16, Failed: 0            (37 s)
MeshWeaver.Hosting.Monolith.Test   Passed: 667, Failed: 0, Skipped: 3   (8 m 15 s)

dotnet build -c Release -warnaserror --no-incremental clean on MeshWeaver.Hosting,
MeshWeaver.Graph and MeshWeaver.Documentation; DocumentationLinkIntegrityTest green.

Noted in passing, not fixed here

BuildNodeType.ArbitrateDurably builds its decision input from the claim lock node
(Admin/Build/_Claim), and Arbitrate's GrantSettleWindow guard tests node.Path == RootPath —
which is never true on that path. So #1424's convergence window is inert whenever a storage adapter
exists. Out of scope here; worth its own issue.

🤖 Generated with Claude Code

rbuergi and others added 3 commits August 13, 2026 18:55
…tion that cannot cross processes

`BuildProtocolDriver.FollowGo` subscribed to `ObserveBuildGo(fingerprint).Take(1)` and nothing
else. That is a projection of THIS cluster's mirror of `Admin/Build`, so a follower in another
cluster was waiting for an event that could not be delivered to it: the builder writes the same
durable row, but no change feed crosses the process boundary — `PostgreSqlChangeListener` is
registered and never started in either partitioned-PG overload.

It had also stopped observing its own claim, which closed the second door. A registration
outlives the grant wait and the arbiter grants it the moment the build falls free, so the
follower was handed builds it never ran — holding the claim at `Planning`, never heartbeating,
and never taken over because its process is alive and the takeover rule defends a live holder by
design. Both a builder that DIED (nobody will ever publish that GO) and a builder that FINISHED
(the follower's stranded registration then locks the next image out of the build) ended in a
wait with no way out.

The follower now ends on either of two real events, both level-triggered on durable state:

- the GO becomes VISIBLE — `ReadBuildGo` reads it off the durable build root, the same witness
  the claim arbiter decides on (#1437), merged with this cluster's mirror; and
- the arbiter hands us the claim — on which the durable witness is re-read: a GO already there
  means the winner finished and this process stands down without re-baking; no GO means the
  builder went away mid-build and this process bakes.

`WithdrawBuildClaim` gives back the candidacy (and a claim that raced it) when the follower
reaches its answer some other way. `Run`'s `Catch` now bounds the grant wait only — it used to
wrap the bake, so a TimeoutException anywhere in the sweep demoted the winner to a follower
still holding its own claim.

No timer and no bound on the wait: a bound would end it by guessing, and a follower that guesses
"the build finished" certifies a share it never probed.

Starting the PG change listener is deliberately NOT part of this — it changes behaviour for every
partitioned-PG deployment and wants its own justification. The follower is correct without it.

Both tests drive the public `BuildProtocolDriver.Run`, compile unchanged against the previous
implementation, and time out there (45 s and 32 s).

Fixes #1440

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… guarantee

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 13, 2026 17:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a cross-process build-following hang by making BuildProtocolDriver stop waiting on an in-process GO notification and instead end on level-triggered, durable facts (durable GO visibility or claim grant), ensuring followers either stand down cleanly or take over the bake.

Changes:

  • Refactors BuildProtocolDriver.Run/FollowGo to distinguish grant-wait timeout from bake execution and to end follow on durable GO visibility or a claim grant.
  • Adds durable witness read (ReadBuildGo) and explicit stand-down (WithdrawBuildClaim) helpers to prevent stranded claim registrations from wedging later builds.
  • Adds regression tests covering follower takeover-without-GO and follower stand-down-after-GO, plus documentation/WhatsNew updates.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test/MeshWeaver.Hosting.Monolith.Test/BuildCoordinationTest.cs Adds regression tests for follower takeover and stand-down behavior.
src/MeshWeaver.Hosting/BuildProtocolDriver.cs Implements the new follower termination logic and narrows the timeout catch to the grant wait only.
src/MeshWeaver.Graph/BuildCoordinationExtensions.cs Adds durable GO read and claim withdrawal helpers used by the follower.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-13-a-follower-that-can-never-be-told.md Adds a Fix whats-new entry describing the operational impact.
src/MeshWeaver.Documentation/Data/Architecture/BuildCoordination.md Updates architecture docs to reflect the lack of cross-process change feed and the new follower “two doors” behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread test/MeshWeaver.Hosting.Monolith.Test/BuildCoordinationTest.cs Outdated
Comment thread src/MeshWeaver.Graph/BuildCoordinationExtensions.cs Outdated
@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 909 tests  ±0   1 717 ✅ ±0   6m 16s ⏱️ +8s
   11 suites ±0     192 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit ca6c682. ± Comparison against base commit 4fd5e45.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

723 tests  ±0   723 ✅ ±0   11m 11s ⏱️ -9s
 10 suites ±0     0 💤 ±0 
 10 files   ±0     0 ❌ ±0 

Results for commit ca6c682. ± Comparison against base commit 4fd5e45.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files  ±0     11 suites  ±0   5m 25s ⏱️ +20s
1 834 tests ±0  1 731 ✅ ±0  103 💤 ±0  0 ❌ ±0 
2 156 runs  +1  2 053 ✅ +1  103 💤 ±0  0 ❌ ±0 

Results for commit ca6c682. ± Comparison against base commit 4fd5e45.

This pull request removes 1 and adds 1 tests. Note that renamed tests count towards both.
MeshWeaver.AI.Test.CodeCellLayoutTest ‑ Toolbar_ContainsRunAndEdit_NoCancel_NoOutputEmbed_BeforeFirstRun
MeshWeaver.AI.Test.CodeCellLayoutTest ‑ Toolbar_ContainsRun_NoCancel_NoOutputEmbed_BeforeFirstRun

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 924 tests  ±0   1 914 ✅ ±0   6m 58s ⏱️ - 1m 17s
   11 suites ±0      10 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit ca6c682. ± Comparison against base commit 4fd5e45.

This pull request removes 1 and adds 1 tests. Note that renamed tests count towards both.
MeshWeaver.AI.Test.CodeCellEditRightsTest ‑ Editor_Gets_Direct_Edit_Navigation
MeshWeaver.AI.Test.CodeCellEditRightsTest ‑ Editor_Gets_Inline_Editor_And_No_Edit_Button

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

2 463 tests  +7   2 462 ✅ +7   6m 39s ⏱️ +20s
   12 suites ±0       1 💤 ±0 
   12 files   ±0       0 ❌ ±0 

Results for commit ca6c682. ± Comparison against base commit 4fd5e45.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 502 tests  ±0   1 502 ✅ ±0   7m 28s ⏱️ -21s
   11 suites ±0       0 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit ca6c682. ± Comparison against base commit 4fd5e45.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    66 files  ±0      66 suites  ±0   44m 0s ⏱️ -58s
10 355 tests +7  10 049 ✅ +7  306 💤 ±0  0 ❌ ±0 
10 677 runs  +8  10 371 ✅ +8  306 💤 ±0  0 ❌ ±0 

Results for commit ca6c682. ± Comparison against base commit 4fd5e45.

This pull request removes 2 and adds 9 tests. Note that renamed tests count towards both.
MeshWeaver.AI.Test.CodeCellEditRightsTest ‑ Editor_Gets_Direct_Edit_Navigation
MeshWeaver.AI.Test.CodeCellLayoutTest ‑ Toolbar_ContainsRunAndEdit_NoCancel_NoOutputEmbed_BeforeFirstRun
MeshWeaver.AI.Test.CodeCellEditRightsTest ‑ Editor_Gets_Inline_Editor_And_No_Edit_Button
MeshWeaver.AI.Test.CodeCellLayoutTest ‑ Toolbar_ContainsRun_NoCancel_NoOutputEmbed_BeforeFirstRun
MeshWeaver.Graph.Test.CodeCellEditorTest ‑ CSharp_GetsLanguageServices_ScopedToTheOwner
MeshWeaver.Graph.Test.CodeCellEditorTest ‑ EditorHeight_FollowsTheSeed_WithinTheClamp
MeshWeaver.Graph.Test.CodeCellEditorTest ‑ Editor_AutoSavesIntoTheNode_AndBindsTheCellBuffer
MeshWeaver.Graph.Test.CodeCellEditorTest ‑ OtherLanguages_GetNoLanguageServer
MeshWeaver.Graph.Test.CodeCellEditorTest ‑ OwnerPath_IsTheNodeAboveSource_ElseTheParent
MeshWeaver.Hosting.Monolith.Test.BuildCoordinationTest ‑ Follower_StandsDown_SoTheNextBuildCanStillBeClaimed
MeshWeaver.Hosting.Monolith.Test.BuildCoordinationTest ‑ Follower_TakesOverTheBake_WhenTheBuildFallsFreeWithoutAGo

♻️ This comment has been updated with latest results.

…s doc promising a distinction it does not make

Copilot on #1450:
- NoDynamicTypes was a mutable Dictionary behind an IReadOnlyDictionary — ImmutableDictionary.Empty,
  per the repo's collections policy.
- ReadBuildGo returns null for 'no GO', 'no store' AND 'read failed', so the doc's claim that a
  failed read is distinguishable was wrong. Reworded to say what is actually true and WHY collapsing
  them is safe: every caller's null branch is the conservative one (it bakes, into content-addressed
  stores), the same fail-open asymmetry ArbitrateDurably applies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

FollowGo waits unboundedly for a GO that cannot cross processes — the PG change listener is registered but never started

2 participants