kafka: unified Eff-based consumer + producer, tiered DLQ, flush-gated commits#437
Conversation
…roker tests Rework the Kafka ingest path into a single decoupled consumer loop (primary ingest + DLQ replay) running over kafka-effectful's KafkaConsumer effect, and move the producer/DLQ-publish path onto the KafkaProducer effect. Consumer (decoupledLoop): - byte-sized ~64 MiB sub-chunks (one chunk = one bulk INSERT/Delta write), replacing record-count chunking; strict, leak-free grouping - ki-scoped poll/worker/committer; poll never blocks on the DB and uses pausePartitions backpressure so processing latency can't stall heartbeats - cross-poll contiguous-prefix watermark commit (completeOffsets / committableCommits); committer also emits interim consumer metrics - removed the inline loop + KAFKA_DECOUPLED_CONSUMER flag (single path); deleted leadingDurable / tpsFor / committablePrefix Tiered DLQ retry: - failures escalate by attempt-count through retry topics to a terminal parking topic; per-message next-due-at enforces backoff; replay commits only the durable due-prefix Kafka as an Eff effect (kafka-effectful, git-pinned v0.3.0.0): - KafkaConsumer + KafkaProducer effects; producer keeps the process-wide singleton via a custom runSharedKafkaProducer interpreter - publish/DLQ functions now Eff over (KafkaProducer, Time, Log, Error); getCurrentTime -> Time effect, runLogT -> Log effect - factor ATBackgroundEffects out of System.Types so consumers can layer KafkaConsumer/KafkaProducer/Error on top Tests: - unit: pure helpers (chunksByBytes, offset watermark never-commit-past- unwritten invariant, retry-tier routing) - integration: a shared in-memory Broker (producer appends, consumer reads) drives the real loop end-to-end including the producer->DLQ->store round-trip; add base hasql dep to the integration-tests stanza
The committer now flushes the shared producer before committing source offsets, so any DLQ/replay publish whose ack advanced the watermark is broker-acked before its offset is committed. Closes the crash-loss window where a SIGKILL between async enqueue and delivery would lose a buffered poison copy while its source offset was already committed. No-op on the happy path (empty producer buffer), so steady-state ingest throughput is unaffected; under broker degradation it blocks <= delivery.timeout.ms, stalling commits (safe: redelivery) not processing. Done in the committer rather than per-batch in routeBatchOutcome so the flush is amortized to 1/sec and never serializes the worker pool during a poison storm. The in-memory broker test now models the async buffer (produce -> pending, FlushProducer -> durable log), turning the poison round-trip into a real regression guard: without the gate the DLQ records stay stuck in pending. PubSub path still acks to Google without a flush and retains the window (documented on publishToDeadLetterQueue); left for a follow-up.
Code Review — PR #437Overall this is a well-structured refactor. The effect-abstraction is clean, the flush-before-commit durability gate is the right fix, and the pure-function extraction with doctests is exactly the right approach. Notes below, prioritised by impact. Potential Bug — In Catch-all _ -> error "runSharedKafkaProducer: unsupported KafkaProducer operation"Same in Conciseness / package usage
-- current (publishRawToKafka)
map (\(k, v) -> (BC.pack $ toString k, BC.pack $ toString v)) (HM.toList attributes)
-- simpler
bimap encodeUtf8 encodeUtf8 <$> HM.toList attributes
The 5-tuple returned by [((Text, K.PartitionId), ConsumerRecord ..., [(Text, ByteString)], [Int64], Int)]A named record or type alias would make the worker pattern-match self-documenting. This is the biggest readability drag in the PR. The committer's tuple-section Minor observations
Summary Architecture is sound, the in-memory broker test that exercises the flush-before-commit contract is exactly the right design, and the watermark committer tests cover the data-loss-critical invariant well. Main asks: (a) document or harden the |
- runSharedKafkaProducer: use whenJustM for the produce-error throw (hlint). - Drop test/unit/Pkg/QueueSpec.hs: its cases are all pure-function checks that belong as doctests. Migrated the gaps (empty-input chunking, retry tier 2 / exhausted, already-committed drop, gap-pins-base, self-prune) into the existing chunksByBytes / retryDestination / completeOffsets doctests.
Code Review — kafka: unified Eff-based consumer + producer, tiered DLQ, flush-gated commitsOverall this is a solid refactor: the effectful consumer/producer abstraction tests beautifully, the watermark committer invariants are well-doctest'd, and the Ki-scoped three-thread decomposition is clean. A few issues worth addressing before merging. Bug / Behavioral Regression
$ fromRight []
<$> runBackground … (runErrorNoCallStack @K.KafkaError (runSharedKafkaProducer appCtx (routeBatchOutcome …)))A Correctness Concerns
ins s o = if o >= base0 then IntSet.insert (fromIntegral o) s else s
DLQ backoff only fires when the entire poll batch is not-yet-due ( when (role == KafkaDlqReplay && null subChunks && not (null rs)) (threadDelay 5_000_000)If a batch mixes due and not-yet-due records, Runtime panic for future _ -> error "runSharedKafkaProducer: unsupported KafkaProducer operation"Same pattern in test mocks. If Code Succinctness / Package UsageHeader conversion — use -- current
map (\(k, v) -> (BC.pack $ toString k, BC.pack $ toString v)) $ HM.toList attributes
-- shorter with bimap (Data.Bifunctor, already in scope via Relude)
map (bimap (BC.pack . toString) (BC.pack . toString)) $ HM.toList attributes
-- current
when (not (null commits) || inflight > 0) $ …
-- idiomatic
unless (null commits && inflight == 0) $ …
pure $ first ("Failed to publish replay event: " <>) ("" <$ result)
The function had a doctest when it was top-level. It still has the doctest block in the Minor
|
- publishRawToKafka: bimap for the header pack instead of a lambda. - committer metrics: unless (null commits && inflight == 0) over when (not ...). - runSharedKafkaProducer: document the intentionally non-exhaustive interpret (revisit on kafka-effectful upgrade). - subChunksFor: doctest the DLQ due-prefix filter (the non-obvious branch). - Replay.publishReplayEvent: return Either Text () and drop the always-empty messageId from the response, removing the "" <$ result smell.
|
Thanks for the thorough review. Disposition below. Implemented (1292040)
Declined, with reasoning
Follow-up (out of scope for this PR)
Noted
|
|
The prior two reviews were thorough and 1292040 addressed most actionable points. A few items not yet surfaced:
-- current
readMaybe . toString . decodeUtf8 @Text . snd =<< find …
-- simpler – BC is already in scope
readMaybe . BC.unpack . snd =<< find …
The second review incorrectly suggested
Both reviews flagged this; neither proposed one. A type alias costs zero runtime and fixes the positional-counting: type WorkItem =
( (Text, K.PartitionId)
, K.ConsumerRecord (Maybe ByteString) (Maybe ByteString)
, [(Text, ByteString)] -- payloads
, [Int64] -- offsets
, Int -- bytes
)The Migration file bundled without context
Still open from the first review
|
- subChunksFor 5-tuple → WorkItem type alias (also annotates workQ as TBQueue WorkItem so the worker/poll destructuring is legible) - Map.insertWith (\_ old -> old) → Map.insertWith (flip const) - isDue: readMaybe . toString . decodeUtf8 @text → readMaybe . BC.unpack
Code Review — PR #437: Unified Eff-based Kafka consumer + producer, tiered DLQ, flush-gated commitsOverall: This is a well-structured rewrite. The effect-based abstraction, pure decision logic with doctests, and flush-before-commit durability gate are all solid design choices. A few specific issues below, roughly in priority order. Bugs / correctness
The function moved from a top-level binding (where doctest finds it) into a
In modifyTVar' trackerVar (Map.insertWith (flip const) tpKey (PartProgress (minimum offs) mempty))
Performance
| n + size x > target = reverse acc : go [x] (size x) xsWhen the guard fires, go acc !n (x : xs) =
let !s = size x
in if | null acc -> go [x] s xs
| n + s > target -> reverse acc : go [x] s xs
| otherwise -> go (x : acc) (n + s) xs
(tpKey, recc, consumerRecordToTuple <$> chunk, K.unOffset . (.crOffset) <$> chunk, sum (recordBytes <$> chunk))Three separate let (tuples, offs, bytes) = foldl' (\(!ts,!os,!b) r -> (ts<>[consumerRecordToTuple r], os<>[K.unOffset r.crOffset], b+recordBytes r)) ([],[],0) chunkCode quality / succinctness
type WorkItem =
( (Text, K.PartitionId)
, K.ConsumerRecord (Maybe ByteString) (Maybe ByteString)
, [(Text, ByteString)]
, [Int64]
, Int
)A small record (
The explicit data PartProgress = PartProgress {base :: !Int64, ahead :: !(Set Int64)}
The current implementation walks let eligible = Map.mapMaybe (\(PartProgress b _) -> b <$ guard (b > 0)) tracker
newCommits = Map.differenceWith (\b old -> b <$ guard (b > old)) eligible committed
tps = [K.TopicPartition (K.TopicName t) p (K.PartitionOffset b) | ((t,p),b) <- Map.toList newCommits]
in (tps, Map.union newCommits committed)
let existing = Map.findWithDefault [] topic b.logs
...
in b {logs = Map.insert topic (existing <> [cr]) b.logs}List append is O(n). For the test sizes (≤5) it's harmless, but swapping to Runtime safetyNon-exhaustive interpreter panics at runtime on kafka-effectful upgrade _ -> error "runSharedKafkaProducer: unsupported KafkaProducer operation"Acknowledged in the comment, but worth flagging: if Minor
What's good
|
Code Review — kafka: unified Eff-based consumer + producer, tiered DLQ, flush-gated commitsThe architecture is solid: decoupled poll/worker/committer threads, pure watermark logic, effect-parameterized consumer, and flush-before-commit gate are all well-designed. The doctests on the pure functions are a good addition. A few concrete suggestions below, prioritised by impact. 🔴
|
Code Review: PR #437 — kafka: unified Eff-based consumer + producer, tiered DLQ, flush-gated commitsThe architectural direction is excellent: effects-based abstraction, pure logic separated from I/O, a correct flush-before-commit gate, and a well-structured tiered retry system. The PR description is thorough and the doctests on pure helpers are appreciated. Below are findings in rough priority order. Correctness / Bugs[High] data PartProgress = PartProgress {base :: !Int64, ahead :: !IntSet.IntSet}
[High] Non-exhaustive _ -> error "runSharedKafkaProducer: unsupported KafkaProducer operation"A new constructor in a future [Medium] TOCTOU race on The poll loop reads these two (inflight, paused) <- atomically $ (,) <$> readTVar inflightVar <*> readTVar pausedVar[Medium]
[Medium] modifyTVar' trackerVar (Map.insertWith (const id) tpKey (PartProgress (minimum offs) mempty))On re-assignment of a partition after rebalance, the old [Low]
Simplification / Code Quality[Medium] type WorkItem =
( (Text, K.PartitionId)
, K.ConsumerRecord (Maybe ByteString) (Maybe ByteString)
, [(Text, ByteString)]
, [Int64]
, Int
)A named record with [Medium] when (inflight >= highWaterBytes && not paused) do
ps <- assigned -- first call
...
when (inflight <= lowWaterBytes && paused) do
ps <- assigned -- second call
...Hoist [Low] Duplicated bimap (BC.pack . toString) (BC.pack . toString)This appears in at least two places ( textToBS :: Text -> BC.ByteString
textToBS = BC.pack . toStringand use [Low] Magic number workQ <- newTBQueueIO 64At ~64 MiB per chunk this is ~4 GiB of in-flight work. Add a named constant with a comment explaining the sizing. [Low]
[Low] The intent is "skip uninitialized partitions," but offset 0 is a valid commit target. The guard is correct for the Kafka semantics used here (base=0 means nothing completed yet), but a comment explaining why Test Coverage[Medium]
[Medium] Retry tier escalation not tested The unit doctests cover [Low] The PR description mentions [Low] Integration test hard-codes DLQ topic name let dlq = Map.findWithDefault [] "otlp_deadletter" b.logsDerive from config instead: let dlqTopic = tr.trATCtx.config.kafkaDeadLetterTopic[Low] 15-second
Security[Low] Internal exception text in Kafka headers / HTTP responses
Summary table
Overall this is a well-thought-out rewrite. The flush-before-commit gate is correct and the test that validates it is a nice piece of work. The high-priority items are type safety and the runtime-panic risk from the non-exhaustive match — both are small fixes. Recommend addressing those before merge. |
Code Review — PR #437 (follow-up, post-12920404)Several items from the prior rounds have already landed in the diff ( Unresolved from earlier rounds
The correctness of
(cs,,) . Map.size <$> readTVar trackerVar <*> readTVar inflightVarTupleSections + operator sectioning in an Remaining code quality
-- \| Headers stamped on every DLQ publish.The backslash is spurious. Should be
when (inflight >= highWaterBytes && not paused) do
ps <- assigned -- KE.assignment effect call
…
when (inflight <= lowWaterBytes && paused) do
ps <- assigned -- second call
…In the case where both conditions fire (unlikely but possible if watermarks cross), SummaryArchitecture and tests remain solid. The remaining asks are all small: add a one-line comment on the |
Code ReviewWell-designed refactor. The decoupled-loop architecture is a significant improvement: separating polling, processing, and committing into distinct threads eliminates the old single-threaded serialization bottleneck, the effectful abstraction makes the integration test credible, and the flush-before-commit invariant is correctly implemented and tested. The pure functions are clearly specified via doctests. Feedback below is focused on type safety, correctness nits, and dead weight.
|
Code ReviewWell-structured PR overall — the effectful abstraction makes testing genuinely tractable, the flush-before-commit gate is clearly motivated, and the pure-function / doctest approach for the core logic is exactly right. A few issues below, roughly ordered by severity. 1. Dead parameters in
|
Code Review — PR #437: Unified Eff-based Kafka consumer + producerOverall: this is a well-architected rewrite. The effect-layered design, pure decision functions, flush-gated commits, and structured-concurrency shell are all solid. The in-memory broker test is elegant. A few focused notes below. Correctness / Bugs
>>= \result ->
liftIO
$ fromRight []
<$> runBackground … (runErrorNoCallStack @K.KafkaError (runSharedKafkaProducer appCtx (routeBatchOutcome …)))
Performance
go acc !n (x : xs)
| n + size x > target, not (null acc) = reverse acc : go [x] (size x) xs
| otherwise = go (x : acc) (n + size x) xsBoth branches call SafetyWildcard The cabal.project NB comment and the inline comment both call this out. The risk is real: a Style / ConcisenessOpaque (cs,,) . Map.size <$> readTVar trackerVar <*> readTVar inflightVarTupleSections is available, but tracked <- Map.size <$> readTVar trackerVar
inflight <- readTVar inflightVar
pure (cs, tracked, inflight)
modifyTVar' trackerVar $ \m ->
case Map.lookup work.tpKey m of
Nothing -> Map.insert work.tpKey (PartProgress (minimum work.offsets) mempty) m
Just _ -> mor use
The Test Coverage
Minor
|
… test config Two pre-existing integration failures (red since #437 switched these paths to the Eff Kafka producer; CI sets no Kafka creds): - Replay tests crashed with uncaught `KafkaError "sasl.username and sasl.password must be set"`. getOrInitKafkaProducer throws that via throwIO on producer init, which runErrorNoCallStack does NOT catch. runSharedProducer (the web-handler one-shot wrapper, sole caller = replayPostH) now wraps the action in tryAny so an unconfigured/unreachable Kafka surfaces as Left → the handler returns the "warning" status the test already expects, instead of a 500. - KafkaConsumerSpec decoupledLoop "flushes DLQ before commit" got 0 instead of 5: withTestResources left kafkaDeadLetterTopic unset (def = ""), so attempt-1 poison routed to topic "" while the test asserts on "otlp_deadletter". Set it in the test config (respecting .env when present). Verified locally via live-test-dev: both replay specs + all 3 decoupledLoop specs green.
#446) * fix(log-explorer): resolve load-more, live-tail, column, and chart-fetch bugs Fixes a cluster of LogList explorer + dashboard widget defects, each with a regression test (new vitest harness with a `transport` seam + controllable IntersectionObserver, backed by vitest-canvas-mock): - dedupe inclusive-boundary row across paginated/live pages (no duplicate rows) - empty load-more page stops pagination even if server still reports hasMore - refresh with empty result clears stale rows; drops inline-expanded aggregates - hidden/reordered columns survive load-more & 5s live-stream refetches - fetchGeneration guard prevents a stale load-more from contaminating a new query - per-kind loading-guard reset so concurrent fetches don't clear each other - live-stream listener/interval + worker callbacks cleaned up on disconnect (no orphaned polling loops or reject-after-teardown across HTMX remounts) - buildRecentFetchUrl preserves the `to` upper bound on bounded ranges - cursorFromTimestamp tolerates ns/µs/ms epochs (no year-55000 stall) - per-chart fetch sequencer discards stale /chart_data responses so a late failure can't reapply the error overlay over freshly rendered bars - column resize seeds from the column's real current width - drop unused ansi_up (v6) dependency * style(telemetry): clear pre-existing hlint warnings in deterministicOtelId Unrelated to the log-explorer changes, but on the CI hlint gate for this branch: - L.intercalate → intercalate (Relude); drop intercalate from the L import - BSL.toStrict → toStrict (Relude); drop the now-unused BSL import - singleLeg lambdas → tuple sections (,0)/(0,) * fix(log-explorer): address review — ns timestamps in live-tail/expand, interval null, empty-tick expand From PR review: - buildRecentFetchUrl + expandTimeRangeUrl now use cursorFromTimestamp (was raw new Date()/String(epochNs)) so ns/µs epochs don't stall live-tail / "show earlier" - null liveStreamInterval after clearInterval on mode-switch so re-enable restarts polling - don't set expandTimeRange on an empty recent tick (was flashing "Show earlier events") - dedupeById: explicit filter body instead of comma-operator side effect Also surfaced by the new tests: timestamp column extraction used `idx || fallback`, which falls through when timestamp is at column 0 → no cursor. Fixed to `??`. Regression tests added for each in log-list-bugs.test.ts. * fix(web-components): regenerate cross-platform lockfile so npm ci passes on Linux My earlier macOS `npm install` pruned the Linux wasm-fallback's transitive @emnapi/core + @emnapi/runtime entries (and bumped them out of sync), so CI's `npm ci` on Linux failed with "Missing @emnapi/core from lock file". Regenerated from master's lockfile inside a node:22 Linux container so all platform optionals are retained; validated `npm ci` green on Linux. Only deltas vs master remain the intended ones: ansi_up removed, vitest-canvas-mock added. * fix(tests): make replay handler degrade gracefully + set DLQ topic in test config Two pre-existing integration failures (red since #437 switched these paths to the Eff Kafka producer; CI sets no Kafka creds): - Replay tests crashed with uncaught `KafkaError "sasl.username and sasl.password must be set"`. getOrInitKafkaProducer throws that via throwIO on producer init, which runErrorNoCallStack does NOT catch. runSharedProducer (the web-handler one-shot wrapper, sole caller = replayPostH) now wraps the action in tryAny so an unconfigured/unreachable Kafka surfaces as Left → the handler returns the "warning" status the test already expects, instead of a 500. - KafkaConsumerSpec decoupledLoop "flushes DLQ before commit" got 0 instead of 5: withTestResources left kafkaDeadLetterTopic unset (def = ""), so attempt-1 poison routed to topic "" while the test asserts on "otlp_deadletter". Set it in the test config (respecting .env when present). Verified locally via live-test-dev: both replay specs + all 3 decoupledLoop specs green. * fix(log-explorer): O(1) page dedup, live-tail stall at upper bound, cleanups - addWithFlipDirection re-deduped the whole merged tree on every append (O(pages²·rows) over a long scroll). Maintain a persistent seenIds set and filter only the incoming page via mergeIntoTree; rebuild on full fetch/refresh, extend on child-expand splices. - buildRecentFetchUrl: on a bounded range, once the newest row reaches `to`, from=newest+10ms ≥ to → every 5s tick fetched an empty window and the live banner hung forever. Detect from≥to and stop live-tail with a toast. - dedupeById: widen id constraint to string|null|undefined so the null guard is honest about untyped server data instead of dead code. - handleColumnsChanged: O(n) Array.includes per iteration → Set lookup. - Regression tests for all four. * perf(tests): lock the test template DB so CREATE-from-template is contention-free Integration tests build a `monoscope_test_template` once (99 migrations, ~3.6s) and clone it per spec via `CREATE DATABASE ... TEMPLATE`. That clone failed with "source database is being accessed by other users" whenever TimescaleDB's background-worker scheduler was attached to the template — which it does on its ~5s launcher poll, because the template carries the timescaledb extension. Each collision forced the terminate + threadDelay + retry path (up to ~5s/spec) and was a flaky-failure source. Fix: keep the template `ALLOW_CONNECTIONS false` (exactly like Postgres's template0). The scheduler can't attach, so the clone is a deterministic ~0.1s (measured) with no retries. Since the locked template can't be connected to, the migration-fingerprint is stamped as the template's DB comment and read from the master connection instead of a table inside it. Net -72 LOC. Verified: template rebuilds + locks (datistemplate=t, datallowconn=f, checksum comment), child DBs clone + are fully usable, integration specs pass. * Auto-format code with fourmolu --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Summary
Rewrites
Pkg.Queueto unify the Kafka consumer and producer behindkafka-effectful'sKafkaConsumer/KafkaProducereffects. The previous separate primary-ingest and DLQ-replay loops collapse into onedecoupledLoopparameterized byKafkaRole; decision logic is factored into pure, doctested functions (chunksByBytes,completeOffsets,committableCommits,subChunksFor,retryDestination) with a thin 3-thread IO shell (workers / committer / poller).Highlights
-retry-60s,-retry-600s) + terminal-parking, with per-message attempt-count / due-time headers; DLQ stays a manual forensic archive.readMVarinstead of serializing onmodifyMVar.Testing
QueueSpec): pure logic — byte-chunking, offset watermark (gap-hold, order-independence, never-past-failed, self-pruning), DLQ tier routing.KafkaConsumerSpec): an in-memory broker round-trips producer↔consumer through oneTVar, modelling librdkafka's async buffer (produce → pending,FlushProducer → durable log). Asserts the watermark commit and that DLQ publishes are flushed durable before the source offset commits — drop the gate and the test fails.Known follow-up
publishToDeadLetterQueue); left for a follow-up.Ops note
retryTiers), so first-failure DLQ messages wait ~5s before replay (was: no per-message delay, only a global tail-backoff). Deliberate, to prevent replay thrashing. The-retry-60s,-retry-600s, and-parkingtopics must be provisioned on the broker.