feat(core): self-heal registration if node falls out of core_validators - #309
Conversation
The initial registration loop in startRegistryBridge exits after one successful RegisterSelf and never retries. So if a validator gets deregistered for any reason after that — e.g. another peer's warden acting on a transiently stale eth-registry cache — the node has no mechanism to notice or re-register. It sits in event-listener mode forever, absent from core_validators, until someone restarts the process. Add startRegistrationWatchdog as a managed routine that runs hourly, checks isSelfAlreadyRegistered, and re-runs RegisterSelf if not. This makes the node self-heal from spurious deregs within ~1h instead of requiring manual intervention. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
isSelfAlreadyRegistered returns false for both missing and jailed rows, and RegisterSelf treats a jailed row as a cue to re-attest and unjail. So as originally written, the watchdog would unjail any jailed node every hour — defeating jailing as a sticky operator signal and creating a flap loop with the warden, which re-jails underperforming nodes on the next SLA cycle. Scope the watchdog to the missing-row case via a direct pgx.ErrNoRows check. Jailed rows are left alone for the operator to inspect and manually unjail via restart. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Pushed a fix in Bug: as originally written, the watchdog used Fix: scope the watchdog to the missing-row case with a direct |
|
Two small observations, none super critical but still worth briefly considering I suspect: 1. Watchdog vs warden cadence. The "Why an hour" rationale ties the watchdog to the warden cycle, but the warden cycle is per-peer. With K stale rendezvous attestors, successful deregs arrive at ~60/K min (K=8 in your incident → ~7.5min), while recovery arrives once per W min. Modeling availability as Δ/(Δ + W/2):
A 15m interval is a one-line change with no normal-operation cost (the watchdog only contacts eth/peers when the row is actually missing) and 2–3× better availability during the exact scenario the PR addresses. 2. CI gap on the no-auto-unjail invariant. Empirically: swapping the func (s *Server) watchdogShouldReregister(ctx context.Context) bool {
_, err := s.db.GetNodeByEndpoint(ctx, s.config.NodeEndpoint)
return errors.Is(err, pgx.ErrNoRows)
}func TestWatchdogShouldReregister(t *testing.T) {
pool := setupValidatorTestDB(t)
ctx := context.Background()
makeServer := func() *Server {
return &Server{db: db.New(pool), config: &config.Config{NodeEndpoint: testNode.Endpoint}, logger: zap.NewNop()}
}
t.Run("absent row returns true", func(t *testing.T) {
truncateValidators(t, pool)
assert.True(t, makeServer().watchdogShouldReregister(ctx))
})
t.Run("active row returns false", func(t *testing.T) {
truncateValidators(t, pool)
require.NoError(t, db.New(pool).InsertRegisteredNode(ctx, testNode))
assert.False(t, makeServer().watchdogShouldReregister(ctx))
})
t.Run("jailed row returns false", func(t *testing.T) {
truncateValidators(t, pool)
q := db.New(pool)
q.InsertRegisteredNode(ctx, testNode)
q.JailRegisteredNode(ctx, testNode.CometAddress)
assert.False(t, makeServer().watchdogShouldReregister(ctx))
})
}Verified locally — the test fails on the regression refactor (the Neither is blocking; the PR fixes indefinite stranding either way. |
Drop the missing-row-vs-jailed branching from ada02df. RegisterSelf is already idempotent — it early-returns when the node is registered and unjailed — so the watchdog can just call it unconditionally on a schedule. To bound flap when the node genuinely can't recover (e.g. underlying breakage causes the warden to keep re-jailing), grow the interval linearly each tick (1h, 2h, 3h, …, 24h cap). Restart resets to 1h, so a deliberate operator restart speeds recovery — exactly the mental model we want. Add a unit test for the schedule function. The goroutine itself is now small enough that the schedule is the only piece worth asserting. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Thanks @RolfAris — pushed On cadence (your #1): sticking with 1h, but the backoff schedule changes — see below. Faster intervals would shorten downtime per cycle but multiply the number of register/dereg pairs in On the test (your #2): good call, kept the spirit. The watchdog logic shifted from a On the bigger change. Ray pointed out my
Mental model: "your node always tries to come back; restarting makes it try harder." Bounds the flap loop with the warden naturally — by ~12 days of sustained re-jailing the watchdog is only firing once per 24h. |
Summary
The initial registration loop in
startRegistryBridgeexits after one successfulRegisterSelfand never retries. So if a validator gets deregistered for any reason after that — most commonly another peer'sstartValidatorWardenacting on a transiently stale eth-registry cache — the node has no mechanism to notice or re-register. It sits in event-listener mode indefinitely, absent fromcore_validators, until someone restarts the process.Add
startRegistrationWatchdogas a managed routine that runs hourly, checksisSelfAlreadyRegistered, and re-runsRegisterSelfif not. Self-heal within ~1h instead of requiring manual operator intervention.Motivating incident
validator.stuffisup.comupdated its delegate wallet on Ethereum. The node successfully self-registered at 2026-05-20T23:20:15. Six minutes laterval019.open-audio-validator.com's warden saw the new wallet missing from val019's local eth-registry cache (val019's websocket subscription had dropped and not yet caught up), fired a dereg, got quorum from 8 similarly-stale peers, and the row was deleted. Stuffisup's bridge had already exited its registration loop and never noticed — so the node has been absent fromcore_validatorsfor >24h despite being healthy, synced, and registered on Ethereum with the correct wallet.This watchdog would have re-inserted the row within an hour.
Why an hour
Long enough to not spam the chain, short enough that the recovery window is bounded. The warden runs every 60 min on each peer, so the watchdog interval matches the cadence at which the hostile event could recur.
Why no test
The watchdog is a
time.Hourticker wrapping two already-tested functions (isSelfAlreadyRegisteredreads from the DB,RegisterSelfis exercised by the full bootstrap integration path). There's no novel logic here to assert in isolation — a unit test would just be ceremony.Test plan
go build ./...go test ./pkg/core/server/...RegisterSelfshould land the row; if any peer's stale warden dereg's it again, the watchdog should restore it within an hour. Watchvalidator_historyfor the pattern.🤖 Generated with Claude Code