Guard registry lookups so draining nodes fail loudly, not wrongly - #168
Merged
Conversation
call!/3 is private and every callsite passes either a bare atom or a tagged tuple, so the is_atom/is_tuple clauses are exhaustive. Dialyzer reported the catch-all as pattern_match_cov. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
During a rolling deploy a node receives SIGTERM, OTP shuts
Sagents.Supervisordown, and the BEAM keeps running for the platform's whole grace period (commonly 30 to 60 seconds). The load balancer may still route requests to that node for all of it.Every agent lookup in that window raised:
Horde.Registry.lookup/2derives its ETS table name arithmetically (:"keys_#{registry}") and reads it with no liveness check. Named ETS tables die with their owning process, so the precondition for any read is "the localHorde.RegistryImplis alive" — and nothing verifies it. Elixir'sRegistryhas the same hole viaRegistry.key_info!/1. Neither backend can report the condition, so it escaped to callers as an:etserror.This is not a narrow race. It is a stable failure mode for the entire drain period, and no client-side or in-process retry can recover on that node.
Investigating it surfaced a second, independent defect. A registry crash left running
AgentSupervisors andAgentServers alive but unregistered — their:vianames are established once at start and nothing re-registers them. Lookups then answered "not running", and the next request started a second AgentServer for a conversation that already had one. Both held and persisted state for it, with nothing reporting the conflict. In a multi-node cluster Horde repairs this from a peer's CRDT within ~200ms; on a single node nothing does.Solution
Model "registry unavailable" as a value, and never let it collapse into "not registered."
Sagents.ProcessRegistrygainsavailable?/0, which checks the ETS table each backend actually reads via:ets.whereis/1— that answers:undefinedinstead of raising, and it tests exactly the precondition the read requires. On top of it:fetch/1returns three distinct outcomes:{:ok, pid},{:error, :not_registered},{:error, :registry_unavailable}.lookup/1,select/1,count/0andkeys/1cannot express the condition in their return values, so they raise the newSagents.RegistryUnavailableErrorrather than answering[]or0.The split is deliberate and is the whole point of the change. A caller reading "nothing is registered" responds by starting an agent; on a draining node that manufactures the duplicate described above.
fetch/1andguarded/2re-checkavailable?/0inside theirrescueso a genuineArgumentErrorstill surfaces as itself, closing the microsecond TOCTOU window without swallowing unrelated bugs.AgentServer.fetch_pid/1is the new request-path lookup.AgentServer's internalsafe_call/3now resolves through it instead of handingGenServer.calla:viatuple, which puts the whole lifecycle API (execute/1,cancel/1,resume/2,add_message/3,reset/1,dismiss_interrupt/1) behind the guard in one place.Session.start/3refuses to start an agent it cannot rule out. (GenServer.caston a:viatuple was already safe — Elixir wraps name resolution incatch _, _— so only thecallandwhereispaths needed changing.)Stop receiving traffic before you stop being able to serve it.
Sagents.ready?/0exposes the signal for a readiness check, anddocs/deployment.mddocuments the ordering that has to hold on SIGTERM: mark unready → LB stops routing → in-flight drains → tree stops. Previously only the last step happened.Close the orphan/duplicate defect.
Sagents.Supervisoris now:rest_for_one— the registry genuinely is a dependency of the dynamic supervisors after it, and:one_for_oneasserted an independence that does not hold.That change alone was not sufficient, which the tests caught.
Horde.Registry.start_link/3starts a supervisor, and the process registered asSagents.Registryis its child:When the registered process crashes, Horde restarts it internally with fresh empty ETS tables and
Sagents.Supervisornever observes a failed child, so the restart chain never fires. Elixir'sRegistryis shaped identically.Sagents.RegistryWatchercloses that gap: listed immediately after the registry, it monitors the registered process directly and stops when it dies, which is a child failure:rest_for_oneacts on. Verified directly — after a kill, the agents supervisor moves andcount_agents/0is0rather than2.The trade is explicit: a registry crash now costs running agents instead of leaving invisible duplicates. Agent state is durable, so a restart is recoverable; a silent duplicate is not.
Changes
Core
lib/sagents/process_registry.ex— Addedavailable?/0,fetch/1,ensure_available!/1; routedlookup/1,select/1,count/0,keys/1through aguarded/2helper that raises rather than returning a plausible default.lib/sagents/registry_unavailable_error.ex— New. Named exception carrying:operationand:registry, with a message that explains the lifecycle cause and points at the readiness fix.lib/sagents/registry_watcher.ex— New. Monitors the registered registry process and stops when it dies, so a backend-internal restart reaches:rest_for_one. Polls (100ms) when the name is unclaimed so a mid-restart backend cannot become a restart storm. Stops with{:shutdown, {:registry_down, reason}}so it restarts without being reported as a crash.lib/sagents/supervisor.ex— Strategy changed:one_for_one→:rest_for_one;RegistryWatcherinserted between the registry and its dependents.lib/sagents.ex— AddedSagents.ready?/0.Call sites threaded through the new API
lib/sagents/agent_server.ex— Addedfetch_pid/1;get_pid/1now raises instead of answeringnil;safe_call/3resolves viafetch_pid/1; addedcall!/3for calls with no room for an error tuple;queue_messagefolds the condition into its existing:no_serverfallback, since raising out of a tool body is what that guard exists to prevent.lib/sagents/session.ex—start/3,stop/2andsession_info/2usefetch_pid/1and propagate{:error, :registry_unavailable};running?/2raises, since a boolean has no room for "cannot tell".lib/sagents/agent_supervisor.ex—get_pid/1distinguishes:not_foundfrom:registry_unavailable;do_wait_for_agent_ready/4fails fast rather than burning its timeout on a node that cannot recover.lib/sagents/agents_dynamic_supervisor.ex—stop_agent/2error union widened; start-wait fails fast on:registry_unavailable.Documentation
docs/deployment.md— New guide (261 lines). The failure explained concretely, the required shutdown sequence, readiness vs liveness (a draining node is not unhealthy — restarting it is the wrong response), supervision tree ordering, Fly.io and Kubernetes examples, mapping the error to a retryable 503, and how to verify it.docs/clustering.md— Added a "Taking a node out of the cluster" section andSagents.ready?/0to the inspection snippets, cross-linking the new guide. It previously had no shutdown or drain guidance at all.lib/sagents/supervisor.ex(@moduledoc) — Now states explicitly thatSagents.Supervisormust precede the Endpoint and why (reverse shutdown order), plus a section documenting the:rest_for_onerationale.lib/sagents/process_registry.ex(@moduledoc) — New "Availability" section covering why neither backend reports the condition and why:registry_unavailablemust never be collapsed into "not registered".mix.exs—docs/deployment.mdadded to the docs extras.Testing
test/sagents/horde/rolling_deploy_test.exs— New, 7 tests, tagged:clusterand:slow, usingLocalClusterwith real Erlang nodes andmembers: :participation. Runs in ~24s and passed 4 of 4 repeat runs at varying seeds. Covers:Sagents.Supervisor(what OTP does on SIGTERM) while leaving the node up, and assert the raise carries the reported message and the reported frames in order.ready?/0reports false and the API returns:registry_unavailableinstead of raising, and an agent that is running is never reported as merely not running.:rest_for_one+ watcher fix.test/sagents/process_registry_availability_test.exs— New, 15 tests. Asserts the ETS tableavailable?/0checks is the one the backend actually reads, thatfetch/1keeps the two error cases distinct, and case-by-case that each raising function raises rather than answeringnil,[]or0.test/sagents/registry_watcher_test.exs— New, 5 tests. Stays up while the watched process lives, stops when it dies, waits rather than failing when the name is unclaimed, ignores unrelated DOWN messages, and assertsSagents.Supervisoractually wires the chain:rest_for_one.test/support/sagents/cluster_test_helper.ex— Reusable pieces:stop_supervisor/1,kill_registry/0,probe_once/1(captures a raise instead of propagating it),start_probe/2(out-of-tree caller that keeps polling while the tree comes down),registry_table_present?/0,ready?/0,classify/1,member_nodes/1,start_agent/1.test/sagents/session_test.exs— Updated Mimic stubs fromget_pid/1tofetch_pid/1.mix precommitalready runstest --include cluster --include slow, so the deploy path stays covered.Migration
No config, schema, or data changes. Existing code compiles unchanged.
AgentSupervisor.get_pid/1,AgentsDynamicSupervisor.stop_agent/2) — acasethat matched them exhaustively needs a catch-all clause.AgentServer.get_pid/1andSession.running?/2now raise a Sagents exception where they previously raised an:etsone, so nothing that handled the old behaviour correctly changes meaning.The real upgrade work is not in the code — it is wiring
Sagents.ready?/0into your readiness check perdocs/deployment.md. Without that, the guarded API turns 500s into 503s but the requests still land on a node that cannot serve them.Known gaps, deliberately out of scope
FileSystemServer.whereis/1,FileSystemSupervisor.get_filesystem/1,SubAgentServer.whereis/1,SubAgentsDynamicSupervisor.whereis/1) were not converted tofetch/1. They now raiseSagents.RegistryUnavailableErrorinstead of an:etsArgumentError— already an improvement, and never a silent lie — but their error unions were left alone. Widening them touches more call sites than this bug justifies. Worth a follow-up for consistency.list_agents/0derives ids fromProcessRegistry.keys/1and silently drops unregistered children whilecount_agents/0counts them. With:rest_for_one+ the watcher in place the orphan cannot arise through this path, so surfacing them was not implemented, but the inconsistency is still there and worth a separate cleanup.test/sagents/horde/node_transfer_test.exs:166("agent process is redistributed on graceful shutdown") fails 100% of the time on this machine, in isolation and in a full run. This is pre-existing — the only shared code touched here is additive functions onClusterTestHelper. An independent probe of the same scenario showed redistribution working correctly (both keys resolve on the surviving node within 1s,count_agents/0goes to 1), so the discrepancy looks like the test's own wait/teardown logic. It needs its own investigation, since graceful shutdown is the rolling-deploy path and this test is the only coverage of it.:localbackend: killingSagents.Registryunderconfig :sagents, :distribution, :localbrought the whole tree down and left it down (no peer, no parent supervisor in the test). The precise mechanism was not isolated and is recorded as an observation only; the follow-up should be done against the:rest_for_onechange rather than before it.