Skip to content

fix(#1778): a floor between automatic rolls, since a roll is a pod restart - #1780

Merged
rbuergi merged 2 commits into
mainfrom
feat/1778-roll-floor
Aug 17, 2026
Merged

rbuergi merged 2 commits into
mainfrom
feat/1778-roll-floor

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Closes #1778. This is my regression from #1773 — I am fixing it, not reporting it.

What I broke

#1773 renamed SelfUpdateOptions.PollInterval and removed the recurring poll. That left SelfUpdate__PollInterval: "1.00:00:00" in deploy/aks/values.aks.yaml — a deliberate one-roll-per-day production throttle — binding to nothing, while still reading like a live throttle to anyone opening the file.

The cause is specific: I grepped .cs for the renamed option, and grepped the charts only for the option I deleted. A rename is exactly as breaking as a deletion for a __-bound key.

Without pacing, publication frequency is portal-restart frequency, one for one — and each restart drops the live circuits of everyone using the portal.

The floor

  • SelfUpdateOptions.MinRollInterval, bound from SelfUpdate:* like the rest, default 1 hour.
  • Checked at the roll decision: inside the floor, log and defer.
  • A deferred roll is re-decided by the next publication event, exactly like an availability hold — the floor introduces no timer.

🚨 Restart-surviving state, and why not uptime

A successful roll restarts the process, so an in-memory "last rolled at" is empty exactly when the floor needs it. IDeploymentUpdater grows LastRolledAtAsync, answered in MeshWeaver.SelfUpdate.Aks by an annotation the patch itself stamps on the Deployment, and "never" by the platform's DetectOnly fallback (an install with no patcher cannot roll anyway).

Not process uptime — the tempting third option, wrong for crash recovery: a pod that comes back on an OLD image has a young process but an old deployment, and uptime would make it wait out a floor it satisfied hours ago, precisely when it most needs the newer image. The annotation gives the right answer. AnOldStampRollsImmediately_TheCrashRecoveryCase pins exactly that.

🚨 The value is 1 h, deliberately not the 24 h prod had

Restoring the prior number is not the neutral default it looks like. With hourly publication, a daily floor means a merged fix reaches prod the next day — strictly worse than the 3 h window the maintainer rejected. "Restore prior behaviour" would silently answer their complaint with "no".

Half the 2026-08-10 rationale for 24 h is also obsolete: adopt-before-compile took a prod roll from 80 compiles / 64.8 s to 0 compiles / 32.1 s, leaving the restart cost as the only live argument — which is what this bounds.

floor restarts/day a merged fix is visible
24 h ≤1 next day (what prod had)
6 h ≤4 same working day
1 h ≤24 ~1.5 h after merge

values.aks.yaml carries that trade in prose, so the number can be changed without re-deriving it. And the floor paces only the automatic cadence — kubectl rollout restart (startup pass takes the newest image) and a main-cd.yml dispatch both still apply an urgent fix immediately.

Timing, verified rather than assumed

Prod runs 3.0.0-rc4.ci.4147 (commit 324e61a), an ancestor of #1773 — so the portals still carry the old poller and are still on the 24 h throttle. The unpaced behaviour is not live yet; it begins with the first post-#1773 image prod rolls onto, which is the deadline this wants to be inside.

Verification

  • Whole solution -c Release -p:CIRun=true -warnaserror → 0 Error(s).
  • 18/18 self-update tests, including four new ones: fresh roll defers, old stamp rolls immediately (crash recovery), never-rolled is not held, zero floor opts out.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VjujCq1BsJkNKuZ4wXodo1

…start

Closes #1778. This is my regression from #1773: that PR renamed SelfUpdateOptions.PollInterval and
removed the recurring poll, which left `SelfUpdate__PollInterval: "1.00:00:00"` in
deploy/aks/values.aks.yaml binding to nothing — a deliberate one-roll-per-day production throttle
that still READ like a live throttle while being inert. I grepped .cs for the renamed option and
grepped the charts only for the option I deleted; a rename is exactly as breaking as a deletion for
a `__`-bound key.

Without pacing, publication frequency is portal-RESTART frequency, one for one, and each restart
drops the live circuits of everyone on the portal.

The floor:

  * SelfUpdateOptions.MinRollInterval, bound from SelfUpdate:* like the rest, DEFAULT 1 HOUR;
  * checked at the roll decision — inside the floor, log and defer;
  * a deferred roll is re-decided by the next publication event, exactly like an availability hold,
    so the floor introduces NO timer.

🚨 Restart-surviving state, because a successful roll RESTARTS THE PROCESS: an in-memory "last
rolled at" is empty exactly when the floor needs it. IDeploymentUpdater grows LastRolledAtAsync,
answered in MeshWeaver.SelfUpdate.Aks by an annotation the patch itself stamps on the Deployment,
and "never" by the platform's DetectOnly fallback (an install with no patcher cannot roll anyway).

🚨 NOT process uptime, which is the tempting third option and is wrong for CRASH RECOVERY: a pod
that comes back on an OLD image has a young process but an old deployment, and uptime would make it
wait out a floor it satisfied hours ago — precisely when it most needs the newer image. The
annotation gives the right answer: old stamp, floor elapsed, roll at once.
AnOldStampRollsImmediately_TheCrashRecoveryCase pins it.

🚨 The value is 1h, NOT the 24h prod had. Restoring the prior number is not the neutral default it
looks like: with hourly publication, a daily floor means a merged fix reaches prod the NEXT DAY —
strictly worse than the 3 h window the maintainer rejected, so "restore prior behaviour" silently
answers their complaint with "no". Half the 2026-08-10 rationale for 24h is also obsolete:
adopt-before-compile took a prod roll from 80 compiles / 64.8 s to 0 compiles / 32.1 s, leaving the
restart cost as the only live argument, which is what this bounds. values.aks.yaml carries the
24h/6h/1h trade in prose so the number can be changed without re-deriving it, and the floor paces
only the AUTOMATIC cadence — `kubectl rollout restart` and a main-cd dispatch both still apply an
urgent fix immediately.

Timing, verified rather than assumed: prod runs 3.0.0-rc4.ci.4147 (commit 324e61a), an ANCESTOR of
#1773 — so the portals still carry the old poller and are still on the 24 h throttle. The
unpaced behaviour is not live yet; it begins with the first post-#1773 image prod rolls onto, which
is the deadline this wants to be inside.

Verified: whole solution Release -warnaserror 0 Error(s); 18/18 self-update tests, including four
new ones for the floor (fresh roll defers, old stamp rolls immediately, never-rolled is not held,
zero floor opts out).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjujCq1BsJkNKuZ4wXodo1
Copilot AI lite review requested due to automatic review settings August 17, 2026 17:36

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

Adds a restart-pacing “floor” to self-update so event-driven publication checks don’t translate into unbounded pod restarts (and circuit drops), using restart-surviving state (a Deployment annotation) to handle crash-recovery correctly.

Changes:

  • Introduces SelfUpdateOptions.MinRollInterval and enforces it in SelfUpdateHostedService before applying an update.
  • Extends IDeploymentUpdater with LastRolledAtAsync and implements it for AKS via a Deployment annotation stamped during patch.
  • Updates AKS Helm values to replace the inert SelfUpdate__PollInterval with SelfUpdate__MinRollInterval, and adds/adjusts monolith tests for the new floor behavior.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
test/MeshWeaver.Hosting.Monolith.Test/SelfUpdateRollFloorTest.cs New tests pin floor semantics (fresh roll defers, crash-recovery rolls immediately, never-rolled not held, zero floor opts out).
test/MeshWeaver.Hosting.Monolith.Test/SelfUpdatePollerResilienceTest.cs Updates test fake to implement new IDeploymentUpdater.LastRolledAtAsync (currently with a doc-comment formatting bug).
test/MeshWeaver.Hosting.Monolith.Test/SelfUpdateAvailabilityGateTest.cs Updates test fake to implement new IDeploymentUpdater.LastRolledAtAsync (currently with a doc-comment formatting bug).
src/MeshWeaver.SelfUpdate.Aks/KubernetesDeploymentUpdater.cs Reads last-roll stamp from Deployment annotation and stamps the annotation during patch.
src/MeshWeaver.Hosting/SelfUpdate/SelfUpdateOptions.cs Adds the MinRollInterval option with detailed rationale in docs.
src/MeshWeaver.Hosting/SelfUpdate/IDeploymentUpdater.cs Adds LastRolledAtAsync to support restart-surviving floor state.
memex/Memex.Portal.Shared/SelfUpdate/UnavailableUpdateMechanics.cs Detect-only updater reports “never rolled” for floor purposes.
memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateHostedService.cs Enforces the floor at apply time via LastRolledAtAsync before patching.
deploy/aks/values.aks.yaml Replaces inert poll interval key with SelfUpdate__MinRollInterval and documents the tradeoffs.

💡 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/SelfUpdatePollerResilienceTest.cs Outdated
Comment thread test/MeshWeaver.Hosting.Monolith.Test/SelfUpdateRollFloorTest.cs Outdated
Comment thread memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateHostedService.cs
@github-actions

github-actions Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

948 tests  +3   947 ✅ +3   11m 43s ⏱️ +35s
 10 suites ±0     1 💤 ±0 
 10 files   ±0     0 ❌ ±0 

Results for commit 608d438. ± Comparison against base commit 674ae64.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files  ±0     11 suites  ±0   5m 39s ⏱️ -20s
2 106 tests ±0  1 910 ✅ ±0  196 💤 ±0  0 ❌ ±0 
2 512 runs  +2  2 316 ✅ +2  196 💤 ±0  0 ❌ ±0 

Results for commit 608d438. ± Comparison against base commit 674ae64.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 371 tests  ±0   1 370 ✅ ±0   5m 26s ⏱️ -53s
   11 suites ±0       1 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 608d438. ± Comparison against base commit 674ae64.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 801 tests  +2   1 800 ✅ +2   8m 3s ⏱️ -35s
   11 suites ±0       1 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 608d438. ± Comparison against base commit 674ae64.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

2 247 tests  +84   2 144 ✅ +84   8m 39s ⏱️ +32s
   11 suites ± 0     103 💤 ± 0 
   11 files   ± 0       0 ❌ ± 0 

Results for commit 608d438. ± Comparison against base commit 674ae64.

This pull request removes 65 and adds 149 tests. Note that renamed tests count towards both.
MeshWeaver.Hosting.Monolith.Test.MonolithMeshTest ‑ PingPong
MeshWeaver.Hosting.Monolith.Test.MonolithMeshTest ‑ PingToNonExistentHub_ThrowsDeliveryFailure
MeshWeaver.Hosting.Monolith.Test.MonolithMeshTest ‑ StreamToNonExistentHub_ThrowsDeliveryFailure
MeshWeaver.Hosting.Monolith.Test.MonotonicWriteGuardWindowTest ‑ StaleWriteInsideTheHighWaterWindow_IsRefused_AndTheStoreNeverMovesBackward
MeshWeaver.Hosting.Monolith.Test.NodeAlcUnloadTeardownOrderingTest ‑ NodeAssemblyContexts_AreUnloaded_Immediately_WhenTheMeshKeepsRunning
MeshWeaver.Hosting.Monolith.Test.NodeAlcUnloadTeardownOrderingTest ‑ NodeAssemblyContexts_AreUnloaded_OnlyAfterTheIoPoolsAreDrained
MeshWeaver.Hosting.Monolith.Test.NodeOperationIssuingHubTest ‑ WorkIssuedWhileHoldingTheRouter_NeverMakesTheRouterAnEnd
MeshWeaver.Hosting.Monolith.Test.NodeOperationTargetTest ‑ ChildOfAnExecutionHub_WalksUpToThatHub_NotPastItToTheRouter
MeshWeaver.Hosting.Monolith.Test.NodeOperationTargetTest ‑ HubWithHandlersButNoExecutionOptIn_StillFallsBackToTheSharedExecutionHub
MeshWeaver.Hosting.Monolith.Test.NodeOperationTargetTest ‑ HubWithoutTheOptIn_FallsBackToTheDedicatedExecutionHub_NeverTheRouter
…
MeshWeaver.Hosting.Monolith.Test.ModuleStaticAssetDiscoveryTest ‑ DependencyTheHostAlreadyServes_IsNotMounted
MeshWeaver.Hosting.Monolith.Test.ModuleStaticAssetDiscoveryTest ‑ DependencyTheHostServesWithoutAPhysicalDirectory_IsNotMounted
MeshWeaver.Hosting.Monolith.Test.ModuleStaticAssetDiscoveryTest ‑ ModuleWithoutAnAssetFolder_ContributesNothing
MeshWeaver.Hosting.Monolith.Test.ModuleStaticAssetDiscoveryTest ‑ MountedAsset_IsServedOverHttp
MeshWeaver.Hosting.Monolith.Test.ModuleStaticAssetDiscoveryTest ‑ OwnAssets_AreRebasedOntoTheModuleContentNamespace
MeshWeaver.Hosting.Monolith.Test.ModuleStaticAssetDiscoveryTest ‑ PrecompressedSibling_IsServedWhenTheClientAcceptsIt
MeshWeaver.Hosting.Monolith.Test.ModuleStaticAssetDiscoveryTest ‑ PrivateDependency_IsServedAtItsOwnContentNamespace
MeshWeaver.Hosting.Monolith.Test.ModuleStaticAssetDiscoveryTest ‑ SameDependencyFromTwoModules_MountsOnce
MeshWeaver.Hosting.Monolith.Test.ModuleStaticAssetDiscoveryTest ‑ ScopedStyleBundle_IsSurfacedForTheHostToLink
MeshWeaver.Hosting.Monolith.Test.ModuleStaticAssetDiscoveryTest ‑ WithNoPrecompressedSibling_TheIdentityFileIsServed
…

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

2 451 tests   - 66   2 447 ✅  - 66   7m 11s ⏱️ -14s
   11 suites ± 0       4 💤 ± 0 
   11 files   ± 0       0 ❌ ± 0 

Results for commit 608d438. ± Comparison against base commit 674ae64.

This pull request removes 135 and adds 69 tests. Note that renamed tests count towards both.
MeshWeaver.Hosting.Monolith.Test.MonolithRouterAnswerOnceAfterPackagingTest ‑ PackagedFireAndForgetAndNacks_AreNotAnswered_WhileOrdinaryTrafficIs
MeshWeaver.Hosting.Monolith.Test.NextLevelNavigationTest ‑ NextLevel_AddingNearerNode_CollapsesFrontier
MeshWeaver.Hosting.Monolith.Test.NextLevelNavigationTest ‑ NextLevel_SkipsEmptyNamespaces_AndSuppressesDeeperNodes
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_FromMcpShapedSessionHubWithoutNodeOperationHandlers
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_OverwritesExistingWhenForced
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_PreservesContent
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_SkipsExistingWhenNotForced
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_ThrowsWhenSourceNotFound
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_ToSpaceNamespace
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_WithDescendants
…
MeshWeaver.Hosting.Monolith.Test.MonolithMeshTest ‑ PingPong
MeshWeaver.Hosting.Monolith.Test.MonolithMeshTest ‑ PingToNonExistentHub_ThrowsDeliveryFailure
MeshWeaver.Hosting.Monolith.Test.MonolithMeshTest ‑ StreamToNonExistentHub_ThrowsDeliveryFailure
MeshWeaver.Hosting.Monolith.Test.MonotonicWriteGuardWindowTest ‑ StaleWriteInsideTheHighWaterWindow_IsRefused_AndTheStoreNeverMovesBackward
MeshWeaver.Hosting.Monolith.Test.NodeAlcUnloadTeardownOrderingTest ‑ NodeAssemblyContexts_AreUnloaded_Immediately_WhenTheMeshKeepsRunning
MeshWeaver.Hosting.Monolith.Test.NodeAlcUnloadTeardownOrderingTest ‑ NodeAssemblyContexts_AreUnloaded_OnlyAfterTheIoPoolsAreDrained
MeshWeaver.Hosting.Monolith.Test.NodeOperationIssuingHubTest ‑ WorkIssuedWhileHoldingTheRouter_NeverMakesTheRouterAnEnd
MeshWeaver.Hosting.Monolith.Test.NodeOperationTargetTest ‑ ChildOfAnExecutionHub_WalksUpToThatHub_NotPastItToTheRouter
MeshWeaver.Hosting.Monolith.Test.NodeOperationTargetTest ‑ HubWithHandlersButNoExecutionOptIn_StillFallsBackToTheSharedExecutionHub
MeshWeaver.Hosting.Monolith.Test.NodeOperationTargetTest ‑ HubWithoutTheOptIn_FallsBackToTheDedicatedExecutionHub_NeverTheRouter
…

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    65 files  ± 0      65 suites  ±0   46m 42s ⏱️ -57s
10 924 tests +23  10 618 ✅ +23  306 💤 ±0  0 ❌ ±0 
11 330 runs  +25  11 024 ✅ +25  306 💤 ±0  0 ❌ ±0 

Results for commit 608d438. ± Comparison against base commit 674ae64.

♻️ This comment has been updated with latest results.

… when the floor is off

Four findings, all real:

  * The test helper waited a fixed 3 s even where a roll was EXPECTED — the exact "never Task.Delay
    to wait for propagation" rule this repo states, broken by me. Split in two: ExpectRollAsync
    waits on the Patched stream with a timeout, and only ExpectNoRollAsync keeps a bounded delay,
    which is the sanctioned "confirm nothing happened" shape because there is no positive signal to
    wait for. Suite time 15 s -> 7 s, and the roll cases are no longer timing-sensitive under load.
  * A zero MinRollInterval no longer calls LastRolledAtAsync at all: that is a Kubernetes GET in the
    AKS implementation whose answer cannot change the decision when the floor is disabled, so it was
    an API call and a failure point on the happy path for nothing.
  * The doc comments I added to the two IDeploymentUpdater fakes had stray blank lines inside them,
    detaching them from the member they document (CS1587 territory). Made contiguous.

Verified: whole solution Release -warnaserror 0 Error(s); 18/18 self-update tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjujCq1BsJkNKuZ4wXodo1
@rbuergi

rbuergi commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

All four fixed in 608d438 — every one was substantive, and the Task.Delay catch is the best of them.

The fixed delay was me breaking a rule I have been citing all day: this repo's guidance is to wait on the condition, never the clock. Split in two — ExpectRollAsync waits on the Patched stream with a timeout, and only ExpectNoRollAsync keeps a bounded delay, which is the sanctioned "confirm nothing happened" shape since a deferral has no positive signal to wait for. Suite time 15 s → 7 s, and the roll cases are no longer timing-sensitive under CI load.

The zero-floor stamp read is gone: LastRolledAtAsync is a Kubernetes GET in the AKS implementation, and its answer cannot change the decision when the floor is disabled — an API call and a failure point on the happy path for nothing.

The doc comments did have stray blank lines inside them, detaching them from the member. The build tolerated it, so thank you for catching what -warnaserror did not.

Whole solution -c Release -warnaserror → 0 Error(s); 18/18 self-update tests.

@rbuergi
rbuergi merged commit 0422399 into main Aug 17, 2026
22 checks passed
rbuergi added a commit that referenced this pull request Aug 17, 2026
#1780 shipped SelfUpdate__MinRollInterval while this branch was open, so the
paragraph telling the reader to wait for a floor before tightening the tick
described a world that no longer exists. Rewritten to state the invariant that
now holds — the tick and the floor are matched at 1h and must move together, in
that order — and to keep the renamed-key trap, which is the part that cost real
time.

The values.aks.yaml conflict resolved in main's favour: it replaces the dead key
with the real one plus the 24h/6h/1h trade, which subsumes this branch's prose
about deleting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi
rbuergi deleted the feat/1778-roll-floor branch August 19, 2026 10:30
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.

Self-update has no floor between rolls — publication frequency is now portal-restart frequency

2 participants