chainsource: escalate unrecoverable block epoch loss to the daemon - #762
chainsource: escalate unrecoverable block epoch loss to the daemon#762Roasbeef wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a fatal timeout mechanism for block epoch subscriptions to prevent the daemon from silently spinning when a backend connection is stuck. If a subscription remains down past the fatal timeout, the actor escalates the failure via an OnFatal callback, which triggers a non-zero exit of the daemon so it can be restarted. A potential issue was identified where a normal shutdown or unsubscribe could trigger a spurious fatal escalation if the subscription had already exceeded the timeout; checking for context cancellation before escalating resolves this.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if registration == nil { | ||
| // If the subscription has stayed down past the fatal | ||
| // budget, stop retrying forever and escalate so the | ||
| // daemon can restart with a fresh backend connection. | ||
| if a.reconnectExhausted(downSince) { |
There was a problem hiding this comment.
If the actor is stopped normally (e.g., during a graceful shutdown or an unsubscribe request), a.ctx is cancelled. However, if the subscription was currently in a "down" state for longer than the fatal timeout, the loop will still evaluate reconnectExhausted(downSince) as true and trigger a spurious fatal escalation to the daemon before checking the context cancellation in waitForReconnect. Checking a.ctx.Err() != nil before escalating ensures we exit cleanly during normal shutdowns or unsubscribes.
if registration == nil {
if a.ctx.Err() != nil {
return
}
// If the subscription has stayed down past the fatal
// budget, stop retrying forever and escalate so the
// daemon can restart with a fresh backend connection.
if a.reconnectExhausted(downSince) {There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b19d79ce9b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if a.reconnectExhausted(downSince) { | ||
| a.escalateFatal(log, downSince, lastErr) | ||
|
|
||
| return |
There was a problem hiding this comment.
Do not kill subscriptions when no fatal hook is installed
In configurations that don't install OnFatal (for example systest.NewChainSourceActor, and external users because the hook is optional), this branch still exits the monitor after the timeout; since escalateFatal is a no-op without a hook, a backend outage lasting longer than 5 minutes permanently closes the block subscription instead of either restarting the daemon or continuing to retry. Please only take the fatal return when a hook is installed, or make the hook required for bounded retries.
Useful? React with 👍 / 👎.
176ace8 to
d6fbb56
Compare
|
@claude review this |
|
Claude finished @Roasbeef's task in 2m 44s —— View job Review: chainsource: escalate unrecoverable block epoch loss to the daemonI read the full diff across Two correctness issues stand out, one of which I'd treat as blocking. 🔴 1. Without an
|
The block epoch reconnect work treats a closed backend stream as transient and re-registers with bounded backoff, which heals a notifier that briefly churns. But it retries forever. When the backend connection is genuinely stuck — the production symptom was dozens of block subscribers all re-registering against a dead notifier every 30s, indefinitely — every subscriber spins in lockstep, no subscription ever heals, and the daemon keeps serving RPCs as a zombie while its readiness probe stays green. On that node a Lightning -> Ark in-swap never completed: the ark client could not hand the swap server a server pubkey, so intercepted HTLCs were canceled back. In this commit, we bound the reconnect. The BlockEpochActor now tracks how long a subscription has been continuously down — stamped when the stream is first lost and cleared only once a replacement actually delivers a block, so a backend that hands back streams which immediately close still counts as down. Once the down streak outlives FatalReconnectTimeout, the actor stops retrying and escalates through a new OnFatal hook instead of spinning. The fatal timeout carries a small one-shot jitter so a fleet that loses a shared backend at the same instant does not all escalate and restart in lockstep. Because every block subscriber escalates through this one hook, the escalation lives in a single place and covers them all. The hook is threaded from the daemon through ChainSourceConfig: the server wires it to a context.CancelCause on the run context, so run() returns the cause, the process exits non-zero, and the orchestrator restarts darepod with a fresh backend connection. Normal signal-driven shutdown still returns nil. A clock is injected so the timeout is exercised in tests without real waits.
The block epoch fail-fast makes the daemon exit on the wedge we know about, and k8s restarts an exited container. But the chart only probes the RPC port with tcpSocket, which stays green for a process that is listening yet making no progress. This adds the progress-aware backstop, mirroring tapd and nautilus. In this commit, we mount unauthenticated /v1/health and /v1/ready routes directly on the existing grpc-gateway mux via HandlePath. Both answer from in-process state only and never touch the chain backend, so they keep responding even when it is stuck, and a probe cannot be used to amplify load onto it. Liveness (/v1/health) fails when a subsystem has escalated a fatal failure, or when the daemon stalls in startup past a generous deadline; a failure restarts the pod. The wedged-backend class that motivated this is already surfaced by the block epoch fatal escalation, which latches that flag, so liveness needs no separate backend probe. Readiness (/v1/ready) fails until the wallet subsystem finishes starting, draining the pod from the Service endpoints without restarting it. The chart change that points the probes at these routes lives in lightning-infra.
d6fbb56 to
54f7075
Compare
|
Thanks — addressed the two correctness issues, folded into the existing commits (force-pushed #1 (🔴 silent subscription death without #2 (🟠 spurious escalation on shutdown) — fixed. Added an #3 (🟡 open-but-silent stream) — documented, not implemented. Agreed it's narrower than the closing-stream symptom this PR targets, and Nits confirmed (no change needed): the |
|
@Roasbeef, remember to re-request review from reviewers when ready |
Motivation
A production signet incident left the
darepodark client wedged after itsbacking LND bounced. The block epoch reconnect work (#698, "chainsource:
reconnect block epochs") correctly treats a closed stream as transient and
re-registers with bounded backoff — but it retries forever. When the
backend connection is genuinely stuck, dozens of block subscribers all
re-register against the dead notifier every 30s indefinitely (the log filled
with bursts of "Registering for block epoch notifications"), no subscription
ever heals, and the daemon serves RPCs as a zombie while its readiness probe
stays green.
The downstream effect: a Lightning → Ark in-swap never completed. The swap
server kept intercepting the HTLC but failed with
get server pubkey: identity pubkey is empty, because the wedged client could not surface theoperator identity. HTLCs were canceled back; from the payer's side the swap
looked stuck.
What this does
Bounds the reconnect instead of retrying forever:
BlockEpochActortracks how long a subscription has been continuouslydown — stamped on first loss, cleared only once a replacement stream
actually delivers a block. A backend that hands back streams which
immediately close therefore still counts as down (no false "healthy").
FatalReconnectTimeout(default 5m), theactor stops retrying and escalates via a new
OnFatalhook.vtxo,unroll,wallet,txconfirm)is served by this one actor, the escalation lives in a single shared place.
ChainSourceConfig. Theserver wires it to a
context.CancelCauseon the run context, sorun()returns the cause → non-zero exit → the orchestrator restarts
darepodwith a fresh backend connection. Normal signal-driven shutdown still
returns nil.
No
os.Exitin subsystem code; the failure bubbles up the normal error path.A clock is injected so the timeout is exercised without real waits.
Testing
go test ./chainsource/...green,go vet ./chainsource/ ./darepod/clean.OnFatal(clock-driven). Existing reconnect/iterator tests still pass.