Graceful stop of a site node leaves the Akka cluster ~30 s late: Kestrel drains the peer's open LocalDb sync stream first (lmxopcua#906, scadaproj#57) #140
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Same exposure as dohertj2/lmxopcua#906, recorded in dohertj2/scadaproj#57.
Problem. On a graceful stop (Windows service stop / SIGTERM)
WebApplicationstops Kestrel beforeAkkaHostedService.StopAsync, which is whereCoordinatedShutdown(cluster leave + singleton hand-over) runs. Kestrel's graceful drain waits up toHostOptions.ShutdownTimeout(30 s default) for open requests. On a site pair the peer's LocalDb sync stream (/localdb_sync.v1.LocalDbSync/Sync, served byMapZbLocalDbSync()) never ends on its own:ZB.MOM.WW.LocalDb.Replication0.3.7 observes only the call token, which Kestrel cancels at the END of the drain.Program.cscancels onlySiteStreamGrpcServer's own streams onApplicationStopping(CancelAllStreams). So the stopping site node leaves the cluster about 30 s late and failover to its peer is delayed by that much. Central has no streaming RPCs today but has the same ordering.Fix plan.
ClrExitReason, unchanged exit-code behaviour; the exit-75 self-down watchdog stays quiet) from anIHostedLifecycleService.StoppingAsync, which runs before any hosted service'sStopAsync.ApplicationStoppinginto every streaming call's token and refuses new streamsUnavailablewhile stopping. Covers the LocalDb sync stream until LocalDb 0.3.8 (scadaproj 3ddb083) is published and pinned.No deploy.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Fixed on
main1a6108c4. No deploy.Root cause.
WebApplicationstops its web server beforeAkkaHostedService, whoseStopAsyncranCoordinatedShutdown. Kestrel's graceful drain waits up toShutdownTimeout(30 s) for open requests, and the peer's/localdb_sync.v1.LocalDbSync/Syncstream never ends on its own (LocalDb 0.3.7 observes only the call token, which Kestrel cancels at the end of the drain).CancelAllStreamscovered onlySiteStreamGrpcServer's streams.Fix.
AkkaHostedServiceis now anIHostedLifecycleService.StoppingAsyncruns before any hosted service'sStopAsync, Kestrel's included. It marks the stop intentional, raisesApplicationStopping(soCancelAllStreamsstill runs before the actors are torn down), then runs and awaitsCoordinatedShutdown(ClrExitReason), bounded by the host shutdown token. Exit codes are unchanged: a planned stop exits 0, and only a termination outside host shutdown exits 75. ScadaBridge does not use Akka.Hosting, so theClusterLeavingReason→ -1 path OtOpcUa had to avoid does not apply. The sameClrExitReasonis kept anyway.ShutdownStreamCancellationInterceptorsits innermost, after the auth gates, on both the central and the siteAddGrpcpipelines. It linksApplicationStoppinginto every streaming call's token and refuses new streams withUnavailablewhile stopping. Central has no streaming RPCs today, so there it is only a backstop.Evidence (
GracefulStopWithOpenSyncStreamTests: real WebApplication, Kestrel h2c,MapZbLocalDbSyncbehindLocalDbSyncAuthInterceptor, a real LocalDb sync peer and the productionAkkaHostedServicein a single-member cluster; 8 s drain budget; the drain start is timestamped by a wrappedIServer):Mutations, all killed:
StoppingAsyncskipsCoordinatedShutdown→ order assertion fails (drain at 1 ms, removed at 937 ms).Unavailablerefusal while stopping.StoppingAsyncdoes not raiseApplicationStopping.The last four are caught by unit tests or the wiring test.
Tests. Release build of the solution: 0 warnings. Host.Tests 733/733, IntegrationTests 133/133, Communication.Tests 843/843.
Pinning LocalDb 0.3.8 stays in scadaproj#57. The interceptor can stay after that.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
Now on QA vq03/vq04 too (2026-11-13,
8e32f353, record8ba5d5d7). The window's own stops ran the old0e540291binaries, so they still showed the bug: vq04's Site stop waited the full 30 s on the peer's LocalDb sync stream, then logged the old[FTL](#76). Stops of the new build were not exercised in this window: there were no drills or restarts after it.