Reload no longer crashes on Chain.UNSPECIFIED, removes gRPC upstreams by prefixed id with lifecycle cleanup, applies methods.disabled changes synchronously, pushes status via existing SubscribeChainStatus streams, and optionally closes client RPCs so edges reconnect. NativeCall returns -32601 (CODE_METHOD_NOT_EXIST) for disabled/unknown methods. Co-authored-by: Cursor <cursoragent@cursor.com>
9.5 KiB
dshackle v0.79.10 — SIGHUP reload removal investigation
Base tag: v0.79.10 (f2c1bd06, "Update deps (#877)")
Branch: reload-removal
(a) Hot-reload codepath and the Key UNSPECIFIED missing failure
SIGHUP entry
| Step | Location |
|---|---|
| OS signal registration | ReloadConfigSetup.kt:21-22 — Signal.handle(signalHup, this) |
| Handler / lock | ReloadConfigSetup.kt:25-58 — catches exceptions, logs Config is not reloaded, cause - ${e.message} |
| Upstream processor | UpstreamConfigReloadConfigProcessor in ReloadConfigProcessor.kt:20-46 |
Diff logic
-
Full-config equality short-circuit —
ReloadConfigProcessor.kt:24-26compares deserialized YAML tomainConfig.initialConfig(ReloadConfigService.kt:27,MainConfig.kt:28-38). Any change (includingmethods.disabled) marks the upstream entry as modified. -
Per-upstream diff —
ReloadConfigProcessor.kt:53-79:- Missing in new config →
removed - Present but not equal (data class) →
reloaded(treated as remove and re-add) - New keys →
added
- Missing in new config →
-
Default-options diff —
ReloadConfigProcessor.kt:82-108: changed/removed chain names indefaultOptionspopulatechainsToReload, which triggers bulk removal of all upstreams on that chain before re-adding (ReloadConfigUpstreamService.kt:50-56pre-patch). -
Removal execution (v0.79.10 bugs) —
ReloadConfigUpstreamService.kt:44-68(original):- Calls
multistreamHolder.getUpstream(chain)for every chain inchainsToReloadand everyupstreamsToRemovepair. CurrentMultistreamHolder.getUpstream()useschainMapping.getValue(chain)(CurrentMultistreamHolder.kt:33-34).- Multistreams are created only for
Chain.entries.filterNot { UNSPECIFIED }(MultistreamsConfig.kt:30-31). Chain.UNSPECIFIEDis not in the map → Kotlin throwsNoSuchElementException: Key UNSPECIFIED missing.
When
UNSPECIFIEDenters the reload set:Global.chainById(unknownChainName)returnsUNSPECIFIED(Global.kt:56-62).analyzeDefaultOptions/analyzeUpstreamsusedchainByIdwithout filtering (ReloadConfigProcessor.kt:62-63, 100, 106pre-patch).buildDefaultOptionsinConfiguredUpstreams.kt:97-105(pre-patch) could store options underUNSPECIFIEDwhen a default-options chain name was unknown.
- Calls
-
Additional v0.79.10 removal gaps (fixed in patch):
- Async removal:
Multistream.processUpstreamsEvents()emits to a Reactor sink processed on a scheduler (Multistream.kt:153-157, 441-444). Reload calledaddUpstreams()immediately after firing REMOVED events, so add/remove could race. - gRPC upstream IDs: Config
id: fooregisters per-chain upstreams asfoo_ethereum,foo_polygon, etc. (GenericGrpcUpstream.kt:69). Removal looked up exact config id (ReloadConfigUpstreamService.kt:61-64pre-patch) → no match, upstream never removed. - gRPC client lifecycle:
ConfiguredUpstreamsstartedGrpcUpstreams.start()with no registry/stop on config removal (ConfiguredUpstreams.kt:77-88pre-patch) — outbound describe loop kept running after YAML entry deleted.
- Async removal:
methods.disabled on reload
- Parsed into
ManagedCallMethodsat upstream creation (UpstreamCreator.kt:97-112). - A
methods.disabledchange makes the upstream data class unequal →reloadedset → remove + re-add path (ReloadConfigProcessor.kt:69-70). - Does apply on reload (not startup-only), provided the YAML change is detected.
- Aggregated method list on the multistream is recalculated in
MultistreamState.updateMethods()(MultistreamState.kt:81-97) viaonUpstreamsUpdated()after REMOVED/ADDED events.
(b) How method availability is announced to gRPC clients
| Mechanism | RPC | Method list? | Live updates? |
|---|---|---|---|
| Describe | BlockchainRpc.describe → Describe.kt:33-79 |
Yes — DescribeChain.supportedMethods from multistream.getMethods().getSupportedMethods() (Describe.kt:42-46) |
No — unary snapshot at call time |
| SubscribeChainStatus | BlockchainRpc.subscribeChainStatus → SubscribeChainStatus.kt:27-47 |
Yes — SupportedMethodsEvent in ChainEvent (SubscribeChainStatus.kt:71, ChainEventMapper.kt:124-125) |
Yes — multistream.stateEvents() pushes MethodsEvent when methods change (MultistreamStateHandler.kt:11-12, MultistreamState.kt:74-76) |
| SubscribeStatus | SubscribeStatus.kt:34-48 |
No — availability + quorum only | Yes for status |
| NativeCall | — | Enforces at call time via upstream.getMethods().isAvailable(method) (NativeCall.kt:313-327) |
N/A |
Implication for dRPC edge gateways
- Edges on an active
SubscribeChainStatusstream receive updated method lists without reconnect (comment atSubscribeChainStatus.kt:28-29explicitly mentions hot reload). - Edges that only call
Describeat connect will not see removals until reconnect. Describeis not streamed.
(c) NativeCall typed error for disabled / unknown methods
Proto field: NativeCallReplyItem.item_error_code (int), set in NativeCall.buildResponse() (NativeCall.kt:193-196).
| Condition | Behaviour (v0.79.10) |
|---|---|
| Method not in aggregated list | InvalidCallContext with ChainCallError(RpcResponseError.CODE_METHOD_NOT_EXIST, …) (NativeCall.kt:314-327) |
| Method not callable on remote path | RpcException(CODE_METHOD_NOT_EXIST, …) (NativeCall.kt:445-446) |
| JSON-RPC standard code | RpcResponseError.CODE_METHOD_NOT_EXIST = -32601 (RpcResponseError.java:28) — "method does not exist / is not available" |
Bug (pre-patch): InvalidCallContext stored the request item id in CallError.id, and buildResponse emitted that as item_error_code instead of -32601. Upstream failures via ChainException already used the RPC code as CallError.id (NativeCall.kt:768).
Patch: CallError.itemErrorCode() returns upstreamError?.code ?: id (NativeCall.kt).
Distinct codes exist: -32601 (method missing) vs -32603 (internal) vs -32000.. (upstream) per RpcResponseError.java.
(d) Graceful client disconnect mechanism (v0.79.10)
| Mechanism | Present? | Location |
|---|---|---|
Server @PreDestroy shutdown() |
Yes — full server stop only | GrpcServer.kt:124-129 |
maxConnectionIdle(3600s) |
Yes — idle timeout, not reload-triggered | GrpcServer.kt:81, Defaults.kt:33 |
| Per-connection GOAWAY / drain API | No | — |
| HTTP/2 GOAWAY on demand | No public API in grpc-java server used here | — |
Conclusion: v0.79.10 had no reload-triggered client refresh. Edges holding stale Describe data need either live SubscribeChainStatus or a forced reconnect.
Patch approach (reload-removal branch)
GrpcClientRefreshService— server interceptor tracking activeServerCalls;disconnectClients()closes them withStatus.UNAVAILABLE(GrpcClientRefreshService.kt).- Config flag —
reload.disconnect-clients-on-removal(defaulttrue) inReloadConfig.kt/MainConfigReader.kt. - Triggered after upstream/method removal in
UpstreamConfigReloadConfigProcessor.reload()(ReloadConfigProcessor.kt).
This closes active RPCs (including long-lived SubscribeChainStatus), prompting client reconnect. It is not a raw Netty GOAWAY on the transport, but achieves the same operational outcome for grpc-java clients.
Recommendation
| Edge behaviour | Approach |
|---|---|
| Uses SubscribeChainStatus | Live MethodsEvent propagation is sufficient for method list; optional disconnect still helps for Describe-cached state |
| Uses Describe-at-connect only | Requires reconnect — use disconnectClientsOnRemoval (default on) or accept stale method lists until natural reconnect |
| Calls removed methods | NativeCall -32601 — immediate typed failure even without reconnect |
GOAWAY vs error-code: Use both: error-code for in-flight calls to removed methods; disconnect (or SubscribeChainStatus stream) for updating the edge's cached capability set. Error-code alone leaves the edge believing the method still exists.
Patch summary (reload-removal)
- Filter
Chain.UNSPECIFIEDfrom reload diffs and default-options processing. - Synchronous removal via
Multistream.processUpstreamsEventsSync(). - Match gRPC upstreams by
{configId}_prefix;GrpcUpstreamsRegistrystops outbound clients on removal. - Propagate removals through existing
SubscribeChainStatus/MultistreamStatepath. - Optional client disconnect on removal (configurable, default on).
- Fix
item_error_codefor disabled methods (-32601). - Tests extended in
ReloadConfigTest.kt.
Build / test verification
# Prerequisites (this workspace)
# - foundation published to mavenLocal (./foundation/gradlew publishToMavenLocal)
# - foundation/src/main/resources/public/chains.yaml (drpcorg/public submodule or raw fetch)
# - emerald-grpc/ proto submodule cloned
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
./gradlew test --tests "io.emeraldpay.dshackle.config.reload.*"
# BUILD SUCCESSFUL — 8 tests (ReloadConfigTest + HealthReloadConfigProcessorTest)
./gradlew build
# compiles + ktlint pass; full suite: 1635 tests, 2 failed (pre-existing on v0.79.10):
# - GenericWsHeadSpec (WS disconnect liveness)
# - IntegrationTest (Spring context / environment)
Reload-specific tests all pass. Full ./gradlew build reports two failures that also occur on unmodified v0.79.10 in this environment.