fix(daemon/shaper): wake the statusz poll loop on endpoint discovery - #1001
Conversation
The traffic-shaper poll loop's only wake-up source was its ticker, so a cold start converged a full poll interval (5 min by default) late even when the BN pod was already ready and statusz was serving. Run starts the pod watcher and the poll loop concurrently, so the entry reconcile normally runs before the watcher's initial list has recorded an endpoint. effectiveStatuszURL then returns "", the empty-URL branch returns nil rather than an error (so superviseResponsibility does not retry it), and the loop parks on ticker.C with nothing to tell it the endpoint arrived moments later. Add a capacity-1 urlChanged channel, signalled by the pod watcher when it records, changes, or clears the discovered endpoint, and select on it alongside the ticker. The buffer is load-bearing: it makes the signal order-independent, so a send landing before the loop reaches its select is retained rather than dropped — an unbuffered channel would reintroduce the same race. Extra wake-ups are absorbed by the existing digest gate. Measured on a local install before the fix: pod ContainersReady 23:08:36, daemon start 23:08:38, membership 23:13:39 — 5m01s, exactly one poll interval, with statusz returning 200 throughout. Fixes #1000 Signed-off-by: alex-au <alex.w.aus@gmail.com>
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
There was a problem hiding this comment.
Pull request overview
This PR fixes a startup convergence race in the block-node traffic-shaper daemon by waking the statusz poll loop immediately when the BN statusz endpoint is discovered/changed/lost via the pod watcher, rather than waiting for the next poll tick. This improves time-to-populate the daemon-owned nft sets on cold starts, restarts, and reschedules—especially after the default poll interval increase to 5 minutes.
Changes:
- Add a buffered, non-blocking
urlChangedsignal channel to wakerunStatuszPollas soon as the discovered statusz URL changes. - Emit wake-up signals from the pod watcher on endpoint discovery/change and on endpoint clear-by-delete.
- Add focused unit tests that pin the buffering/coalescing behavior and validate signal-driven convergence independent of the ticker cadence.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| internal/daemon/blocknode/traffic_shaper_monitor.go | Adds urlChanged + signalURLChanged() and wakes the poll loop on discovery signals. |
| internal/daemon/blocknode/pod_watcher.go | Signals the poll loop when the discovered statusz endpoint is recorded or cleared. |
| internal/daemon/blocknode/traffic_shaper_monitor_test.go | Adds regression + behavior tests for buffered signalling and signal-driven wake-ups. |
| internal/daemon/blocknode/pod_watcher_test.go | Adds tests ensuring signals fire only on endpoint changes and on endpoint clear. |
| docs/dev/traffic-shaper.md | Updates reboot/convergence documentation to reflect immediate wake-up on discovery. |
| docs/dev/daemon/traffic-shaper-statusz.md | Updates pod-discovery behavior docs to remove “bounded by poll interval” caveat. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Description
After
block node install --traffic-shaping-enabled, theinet weaver-workload-policysets stayedempty for a full poll interval (5 min default) — even though the BN pod was
ContainersReadyandstatusz was serving the correct roster the whole time. Same on
reconfigure, host reboot, andsystemctl restart solo-provisioner-daemon.Measured on a local ENV-2 install before this change:
ContainersReady=Trueblock node installworkflow completessetup_report_20260812_230837.yamlsetup_report_20260812_230838.yamlbn-publisher/bn-partner-out/bn-backfillpopulateΔ = 5 min 01 s — exactly one poll interval. Note the pod was ready two seconds before the daemon
started, so this was never "waiting for the pod": everything the daemon needed was in place at
T₀,and statusz answered
200with the right roster throughout.Root cause
Runstarts the pod watcher and the poll loop concurrently (traffic_shaper_monitor.go:126-136), soon a cold start the entry reconcile (
:343) normally runs before the watcher's initialpods.List()(pod_watcher.go:72) has recorded an endpoint. Then:effectiveStatuszURL()returns"".nil, not an error — sosuperviseResponsibilitydoes not retryit with the 5 s→5 min back-off.
select { <-ctx.Done(); <-ticker.C }.ticker.Cwas the only wake-up source,so nothing told it the endpoint appeared moments later.
A few milliseconds of startup race cost a full interval of convergence.
Why the buffer is load-bearing — the main thing to review
urlChangedismake(chan struct{}, 1)with a non-blocking send. That is not incidental: it iswhat makes the signal order-independent. The watcher frequently signals before the poll loop reaches
its
select. With capacity 1 the signal waits in the buffer and theselectreturns immediately; anunbuffered send would find no receiver, drop silently, and reintroduce the exact race this closes —
just moved a few lines.
TestSignalURLChanged_RetainsSignalForLateReceiverpins this propertydirectly, with no timing dependence.
Relationship to #964 — this is a composition regression
#964 §3 documented the caveat as accepted: "When using pod discovery only (no
base_url) … convergenceis then bounded by up to one poll interval after discovery." Two changes five days apart made that bite:
base_url(enabled by hiero-ledger/hiero-block-node#3316)base_urlpath onto the pod-discovery path. Cost then: ~5 sBefore #936, operators set
base_urlexplicitly in the install TUI, which returns immediately fromeffectiveStatuszURL()and never consults the watcher — which is why the guarantee held in practice andthis race went unnoticed. Deliberately not reverting to
base_url: discovery follows the pod acrossrestarts, reschedules and IP changes, which a fixed URL cannot. This PR gives the discovery path the
startup convergence
base_urlalready had.The caveat also understated the trigger — it describes waiting for the pod to become ready, but here
the pod was ready before the daemon started. The race is against the watcher's initial list, not pod
readiness.
Files changed
internal/daemon/blocknode/traffic_shaper_monitor.gourlChanged(cap 1) +signalURLChanged();selecton it alongsideticker.C; rewrite the entry-reconcile caveat commentinternal/daemon/blocknode/pod_watcher.gorecordDiscoveredStatusz) and on clear-by-delete (handlePodDelete)internal/daemon/blocknode/traffic_shaper_monitor_test.gointernal/daemon/blocknode/pod_watcher_test.godocs/dev/daemon/traffic-shaper-statusz.mdpoll_intervaldocs/dev/traffic-shaper.mdNo informer is involved —
runPodWatcheris a rawList+Watch, so theWaitForCacheSyncwording inissue #1000's proposed fix was not implementable as written. It also turned out unnecessary: with the
buffered channel the entry reconcile does not need to observe a URL at all.
Review guide
Checklist
traffic_shaper_monitor.go—urlChangedis created with capacity 1; a change to0silentlyrestores the bug (only the new unit test would catch it)
signalURLChanged()usesselect { case ch <- struct{}{}: default: }— must never block, since itis called from the watch-event path
TrafficShaperMonitordirectly, and anil channel in
selectblocks forever rather than panicking (so a missing guard would be silent)pod_watcher.go:279— signal is sent afterm.mu.Unlock(), never while holding the mutexhandlePodDelete— signal is sent after the unlock and before theif !ok { return }earlyreturn, so a pod with no recorded veth still reports the loss
handlePodDeleteonly signals whenendpointCleared— an unrelated pod's delete must stay silent"" → urltransition already resetslastDigest/lastApply(
:256-275), so the woken reconcile does a fresh apply rather than short-circuitingTest commands
TestRunStatuszPoll_DiscoverySignalWakesLoopBeforeNextTickuses a 1-hour poll interval, so the tickercannot mask a missing signal. Verified it fails without the fix — replacing the new
selectcase with anever-ready channel produces
timed out waiting for count >= 1 (last: 0).Manual UAT
Requires a BN host with the plane installed and converged, and a BN whose statusz serves
/statusz/inbound|outbound(verify withcurl -s -o /dev/null -w '%{http_code}\n' http://<podIP>:40983/statusz/inbound→
200). Run the observer in one shell throughout:Set
STS=$(kubectl -n block-node get statefulset -o name | head -1)for the cases below.UAT-1 — Fresh install (the reported bug). Run
block node install --traffic-shaping-enabled …withthe observer already running. Record pod-ready time, daemon start
(
systemctl show solo-provisioner-daemon -p ActiveEnterTimestamp) and the first non-emptypublisher={…}.Expected: membership within seconds of pod-ready. Before the fix: pod-ready + ~5 min.
UAT-2 — Daemon restart with no ready pod (fast loop, ~2 min).
Expected:
publisher={…}within seconds ofready=True, not atT0 + 5 min. This is the primarygate — it forces the daemon to start with no endpoint, which is the race.
UAT-3 — Control: convergence must stop tracking
poll_interval. Repeat UAT-2 withcomponents.block_node.statusz.poll_intervalset to30s, then5m, in/opt/solo/weaver/config/daemon.yaml(restart the daemon — there is no hot-reload).Expected: convergence latency is the same in both runs and tracks pod-ready. Before the fix it
tracked the interval — that correlation was the diagnostic that proved the bug, so it disappearing is the
proof it is fixed.
UAT-4 — Pod reschedule changes the endpoint. With everything converged,
kubectl -n block-node delete pod -l app.kubernetes.io/name=block-node-server. The replacement usuallygets a new pod IP.
Expected:
journalctl -u solo-provisioner-daemonshowsTrafficShaperStatuszDiscoveredwith the newstatusz_url, thenTrafficShaperStatuszEndpointResolvedand a reconcile within seconds — not oneinterval later. Membership re-populates. Also confirm the
$VETHHTB re-attaches (tc class show dev <veth>).UAT-5 —
base_urlpath unregressed. Setstatusz.base_urlto the pod IP endpoint, restart the daemon,repeat UAT-2.
Expected: unchanged prompt convergence. The override short-circuits
effectiveStatuszURL()before thediscovered URL is read, so signals are inert on this path.
UAT-6 — Endpoint loss is logged promptly.
kubectl -n block-node scale $STS --replicas=0.Expected:
TrafficShaperStatuszEndpointLostwithin seconds, not up to one interval later. No exec fires(the woken reconcile no-ops on an empty URL) — confirm no
reconcile-shaperinvocation in the journal.UAT-7 — No signal storm / no redundant applies. Leave the node idle and converged for ~15 min.
Expected: one
--checkprobe per tick, and no privileged apply while the roster is unchanged (thedigest gate holds). Only the hourly force-resync should apply. Confirms extra wake-ups are cheap.
Risks / rollback
capacity-1 buffer (coalescing) and the digest gate (no apply when unchanged), so the worst case is one
unprivileged
--checkprobe per distinct transition. UAT-7 covers this.is guarded for direct-construction in tests.
waiting for the tick.
Not covered here:
GET /block_node/traffic_shaper/statusreports{"monitor":{"state":"running"}}evenwhile a responsibility is in permanent back-off — observed during this investigation and filed as field
evidence on #750, which already owns per-responsibility health reporting.
Related Issues