Skip to content

fix: concurrent bounded shutdown so Ctrl-C/SIGTERM actually exits#440

Merged
tonyalaribe merged 3 commits into
masterfrom
fix/graceful-shutdown-concurrent-cancel
Jun 22, 2026
Merged

fix: concurrent bounded shutdown so Ctrl-C/SIGTERM actually exits#440
tonyalaribe merged 3 commits into
masterfrom
fix/graceful-shutdown-concurrent-cancel

Conversation

@tonyalaribe

Copy link
Copy Markdown
Contributor

Problem

Hitting Ctrl-C didn't shut the app down — the kafka workers kept retrying dead-DB writes (retryHasqlWrite transient loop) and kafka.consumer.metrics kept emitting, instead of the process exiting.

Root cause

runServer ended in async's waitAnyCancel asyncs:

waitAnyCancel xs = waitAny xs `finally` mapM_ cancel xs   -- cancel a = throwTo tid AsyncCancelled <* waitCatch a

mapM_ cancel cancels fibers sequentially, blocking on each one's death before signalling the next. So one slow-to-die fiber early in the list (Warp's 30s graceful drain, a wedged librdkafka poll, a long DB op) keeps every later fiber alive — including the kafka workers, whose cancel was queued behind it.

(Note: this is not retryHasqlWrite swallowing the async exception — it uses UnliftIO.tryAny, which rethrows async exceptions. The cancel simply never reached the retry loop.)

Fix

Replace waitAnyCancel with awaitShutdown:

awaitShutdown asyncs = do
  stop <- newEmptyMVar
  for_ [sigINT, sigTERM] \s -> installHandler s (Catch (void (tryPutMVar stop ()))) Nothing
  void $ race (takeMVar stop) (waitAny asyncs)
  cancelAllConcurrently shutdownDeadlineUs asyncs   -- = timeout 15s $ mapConcurrently_ cancel asyncs
  • Concurrent cancel — teardown is bounded by the slowest single fiber, not the sum; no fiber can hold the others hostage.
  • 15s hard deadline — a fiber that refuses to die (uninterruptible FFI) can't hang the process; we proceed to shutdownMonoscope (drain/flush/close pools) and exit.
  • SIGTERM handlerdocker stop / k8s now drain gracefully instead of SIGKILL-dropping in-flight buffers.

cancelAllConcurrently is exported solely for the regression test.

Test

test/unit/System/ServerSpec.hs proves a slow-to-die fiber listed first no longer blocks cancelling the others, and that the deadline is honored when a fiber never dies. Added async to the unit-tests deps.

⚠️ The unit test links the library, so it can only run once the (separate, in-progress) library work compiles. Locally verified that System.Server itself compiles cleanly (ghcid "All good").

Replay byte-batches the due prefix (grouped by ce-type + write target)
instead of one bulk write per record, so a backlog drains in ~64 MiB
writes like primary ingest.

Replay also rewrites only the originally-failed store: a WriteTarget
(WriteBoth/WritePgOnly/WriteTfOnly) derived from the monoscope-write-failure
header threads through processList -> insertAndHandOff ->
bulkInsertOtelLogsAndSpansTF (new TF-only branch). Avoids duplicating the
still-durable leg (PG inserts aren't idempotent, so a tf-failed replay would
otherwise double the PG row). Live ingest paths pass WriteBoth.
Integration (TimefusionWriteFailureSpec): tf-failed replay writes exactly
one row — pins the no-duplicate-PG-row fix; pg-failed replay skips a down
TimeFusion leg (Right where a normal msg DLQs).

Pure header->target (writeTargetFor) and DLQ-header (writeFailureDlqHeaders)
checks moved from integration `it`s to doctests.
runServer ended in async's waitAnyCancel, whose `mapM_ cancel` cancels
fibers sequentially and blocks on each one's death before signalling the
next. One slow-to-die fiber (Warp's graceful drain, a wedged librdkafka
poll) kept every later fiber alive — so Ctrl-C left the kafka workers
retrying dead-DB writes and emitting consumer metrics instead of exiting.

Replace with awaitShutdown: install SIGINT+SIGTERM handlers, race a signal
against any fiber exiting, then cancelAllConcurrently — mapConcurrently_
cancel under a 15s deadline. Teardown is now bounded by the slowest single
fiber, not the sum, and a wedged fiber can't hang the process. Catching
SIGTERM also lets docker stop / k8s drain gracefully instead of SIGKILL.

cancelAllConcurrently is exported for the regression test in ServerSpec.
@tonyalaribe
tonyalaribe merged commit 0b45863 into master Jun 22, 2026
8 of 9 checks passed
@tonyalaribe
tonyalaribe deleted the fix/graceful-shutdown-concurrent-cancel branch June 22, 2026 12:09
@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown

Code Review

Overall this is a clean, well-motivated PR. The core shutdown fix is correct and elegant, and the WriteTarget refactor is a clear improvement over the Bool API. A few notes below ranging from potential bugs to nits.


System.Server — shutdown fix

The awaitShutdown / cancelAllConcurrently split is the right design. A few things to check:

installHandler drops GHC's default SIGINT handler. GHC's RTS normally converts SIGINT into a UserInterrupt async exception thrown to the main thread. Replacing it means ^C no longer does that — it now only fills the MVar. That's the intent here, but it means any code that catches UserInterrupt specifically (e.g. ghci-style repl tooling) would stop seeing it. Fine for a production server process; worth a one-line note.

Handler not restored. installHandler returns the old handler; we discard it. Since we only call awaitShutdown once at process end this is harmless, but callers wrapping runServer in tests that reuse the process may see stale handlers. Not a bug for the current usage.

cancelAllConcurrently exported for testing. Totally acceptable here, but if the module export list grows this is a candidate for an Internal sub-module. Not blocking.


Telemetry.hsWriteTarget / singleLeg

writeTargetFor False (Just "tf-failed")WriteTfOnly. When enableTf = False, a tf-failed DLQ message will still attempt a TF write (because the header-match short-circuits before the flag check). If TF was turned off between write-time and replay-time, the replay will fail trying to reach a store that's intentionally disabled. The PR description implies this is deliberate ("presumes TF was enabled when it failed"), but it could surprise an operator who just set enableTimefusionWrites = false to disable TF. Consider at least a logAttention when this mismatch is detected.

singleLeg's losts lambda is slightly opaque.

WritePgOnly -> singleLeg "pg" This (\l -> (l, 0)) =<< ...
WriteTfOnly -> singleLeg "tf" That (\l -> (0, l)) =<< ...

The losts parameter maps a single count to (pgL, tfL) — the naming doesn't make the pair structure obvious at the call site. Two Int parameters (pgL tfL) defaulting to 0 and lost respectively would be slightly more readable; or just inline the two cases since there are only two callers. The existing form is concise, just a touch opaque.

Long line in singleLeg guard. The | lost <- ..., lost > 0 -> let (pgL, tfL) = losts lost in underPersist ... line is quite long and fourmolu may flag it — worth checking.


Pkg/Queue.hs — DLQ grouping

Map.fromListWith (<>) reverses within-group order. fromListWith f new old calls f new old, so each new element is prepended: due = [r0, r1, r2] → group accumulates as [r2, r1, r0]. The subsequent sortOn (.crOffset) grp before chunksByBytes corrects this, so it's not a bug — but it's an extra sort on an already-ordered list. Prefer Map.fromListWith (flip (<>)) to preserve insertion order and make the sortOn a no-op:

Map.fromListWith (flip (<>)) [(dlqGroupKey r, [r]) | r <- due]

Semantics change: DLQ records now batch within a group. Previously every due DLQ record was a single-element WorkItem; now records with the same (ce-type, write-target) key are batched and chunked together. A single corrupt record in a batch will cause the whole batch to DLQ again rather than just itself. This is consistent with the primary-path behavior and the doctest is updated, but it's worth a comment in the description or inline noting the semantic change for future readers (the current comment explains why grouping is needed but not that batching semantics changed).

dlqGroupKey is computed even for KafkaPrimary (passed as a function reference). It's only called in the KafkaDlqReplay branch so no actual work is done — but since it's built at the call site (not inside subChunksFor), the closure is always allocated. Tiny; not worth changing unless you see it in a profile.


Tests

System.ServerSpec — The regression test is clean and correctly exercises both properties (concurrent cancel, deadline honour). One nit: slow and stuck asyncs are never explicitly cancelled after the test exits — they'll be GC'd eventually but will linger for up to 2s / indefinitely during the test run. This can cause surprising noise if the test suite runs with --jobs. async provides withAsync which would scope their lifetimes to the test block:

withAsync (forever ...) \slow ->
  withAsync (forever ...) \fast -> do
    cancelAllConcurrently 10_000_000 [slow, fast]
    ...

Integration tests — Replacing the three pure header-check it blocks with two end-to-end DB assertions is a good upgrade; the old tests only validated the map construction, not the actual write effect.

writeTargetFor and writeFailureDlqHeaders are now doctest-only. The removed integration tests covered those functions; the doctests are weaker (pure, no DB state). Acceptable given the new end-to-end tests cover the round-trip, just noting the coverage gap on the pure header logic in case the doctests are skipped in CI.


Summary

Area Severity
writeTargetFor False "tf-failed" → attempts disabled store Worth a logAttention / doc note
Map.fromListWith (<>) reversal fixed downstream by sort Minor — use flip (<>)
slow/stuck asyncs leak in unit tests Minor — use withAsync
singleLeg losts lambda readability Nit
cancelAllConcurrently export Nit

The shutdown fix and WriteTarget refactor are both correct and improve the codebase. Happy to approve once the fromListWith flip and withAsync nits are considered.

tonyalaribe added a commit that referenced this pull request Jun 25, 2026
…break)

Every "Build and Test" run has failed — across all PRs and the merged ones —
compiling test/unit/System/ServerSpec.hs (added in #440) with:

  GHC-87110: Could not load module 'Control.Concurrent'. It is a member of the
  hidden package 'base-4.21.0.0'. Perhaps you need to add 'base' to build-depends.

ServerSpec imports Control.Concurrent / Control.Exception directly from base;
every other test/unit module goes through relude, so the unit-tests suite never
listed base. With NoImplicitPrelude the direct base imports can't resolve and
the suite fails to compile (the varying error line across runs was just whichever
import GHC reached first — not a flake). Add base to the unit-tests build-depends.
tonyalaribe added a commit that referenced this pull request Jun 25, 2026
…break)

Every "Build and Test" run has failed — across all PRs and the merged ones —
compiling test/unit/System/ServerSpec.hs (added in #440) with:

  GHC-87110: Could not load module 'Control.Concurrent'. It is a member of the
  hidden package 'base-4.21.0.0'. Perhaps you need to add 'base' to build-depends.

ServerSpec imports Control.Concurrent / Control.Exception directly from base;
every other test/unit module goes through relude, so the unit-tests suite never
listed base. With NoImplicitPrelude the direct base imports can't resolve and
the suite fails to compile (the varying error line across runs was just whichever
import GHC reached first — not a flake). Add base to the unit-tests build-depends.
tonyalaribe added a commit that referenced this pull request Jun 25, 2026
* fix(queue): self-heal DLQ commit-starvation via reseek on no-ack

A consumer chunk that returns no acks (transient write/DLQ-publish
failure → routeBatchOutcome []) does not advance the commit base, but
the poll position has already moved past it. librdkafka never redelivers
uncommitted offsets in-session, so the base pins forever while the
consumer forward-processes to log-end and goes idle — the 2026-06-25
apitoolkit_eu_dlq wedge (~7.6M lag, committed offsets frozen).

Fix: on no-ack, the worker records the chunk's lowest offset in reseekVar;
the poll thread (which owns all Kafka ops) seeks each such partition back
to that offset and resets its tracker so the un-acked chunk is redelivered
and retried once the failure clears. Seeks backward only (never past the
committed base) so it can never skip — worst case redelivers
already-processed offsets, which is idempotent downstream.

Regression test in KafkaConsumerSpec drives a chunk that no-acks on first
processing then acks: without the fix the base pins and the watermark
never recovers (drive times out); with it the reseek redelivers and the
commit advances. Adds SeekPartitions to the in-memory broker mock.

* fix(queue): address reseek review — prune reseekVar on rebalance + test rigor

- Prune reseekVar to the live partition set in the committer (alongside
  tracker/committed). Without it, a partition that stalled (reseek recorded)
  then got revoked would make the poll thread seek a partition this consumer
  no longer owns and re-seed a stale tracker entry the prune can never reach.
- Tracker reset on reseek uses the left-biased Map union
  (Map.map (`PartProgress` mempty) reseeks <> tk) instead of folding a toList.
- Test: assert the retry actually fired (attemptVar >= 2), so removing the
  no-ack guard can't make the test pass trivially.
- Test mock: key the broker cursor by (topic, partition) so same-topic
  multi-partition seeks no longer silently collide.

(head-instead-of-minimum was not applied: head trips -Werror=x-partial;
minimum is the lint-clean partial reducer and the offset list is small.)

* fix(ci): add base to unit-tests build-depends (fixes GHC-87110 build break)

Every "Build and Test" run has failed — across all PRs and the merged ones —
compiling test/unit/System/ServerSpec.hs (added in #440) with:

  GHC-87110: Could not load module 'Control.Concurrent'. It is a member of the
  hidden package 'base-4.21.0.0'. Perhaps you need to add 'base' to build-depends.

ServerSpec imports Control.Concurrent / Control.Exception directly from base;
every other test/unit module goes through relude, so the unit-tests suite never
listed base. With NoImplicitPrelude the direct base imports can't resolve and
the suite fails to compile (the varying error line across runs was just whichever
import GHC reached first — not a flake). Add base to the unit-tests build-depends.
tonyalaribe added a commit that referenced this pull request Jun 25, 2026
…break) (#444)

Every "Build and Test" run has failed — across all PRs and the merged ones —
compiling test/unit/System/ServerSpec.hs (added in #440) with:

  GHC-87110: Could not load module 'Control.Concurrent'. It is a member of the
  hidden package 'base-4.21.0.0'. Perhaps you need to add 'base' to build-depends.

ServerSpec imports Control.Concurrent / Control.Exception directly from base;
every other test/unit module goes through relude, so the unit-tests suite never
listed base. With NoImplicitPrelude the direct base imports can't resolve and
the suite fails to compile (the varying error line across runs was just whichever
import GHC reached first — not a flake). Add base to the unit-tests build-depends.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant