ingestion: TF-strict commit, per-partition offsets, row-level bisection#402
Conversation
The 2026-06-01 TimescaleDB outage exposed several ways the consumer advances Kafka offsets past failed batches, losing hours of customer data. Commit semantics, the dual-write asymmetry, and the fire-and-forget DLQ all combined to step over any failure that wasn't a hard exception. - kafkaService now commits per (topic, partition) via commitPartitionsOffsets. Only groups whose batch succeeded -- or whose poison content was successfully DLQ'd -- contribute offsets. Failed groups leave their partitions uncommitted; lag growth becomes the outage signal. DLQ is no longer subscribed by the live loop. - pubsubService mirrors: poison -> DLQ-then-ack; transient -> no ack. - publishToDeadLetterQueue returns Either Text () so the caller can gate commit on DLQ success. - bulkInsertOtelLogsAndSpans bisects on non-transient Hasql failures, isolating a single poison row in O(log N) attempts so a bad row no longer wedges the partition. - bulkInsertOtelLogsAndSpansTF requires TF success to return normally; PG-only failure is logged via PG_WRITE_FAILED_TF_OK and tolerated (PG is being phased out). - New Pkg/IngestError classifier replaces the brittle string-match isUnrecoverableError. Unknown errors default to PoisonBatch (DLQ-actionable) rather than TransientInfra (silent stall).
Code Review — ingestion: TF-strict commit, per-partition offsets, row-level bisectionThe durability semantics are sound and the design rationale is well-documented. A few code-quality and succinctness notes below.
|
PR-review polish on top of the durability changes: - Inline the Hasql-transient check at the two failure-handler call sites in Pkg/Queue.hs and delete Pkg/IngestError.hs. Two 1-line predicates with two call sites in one file wasn't worth its own module. - Drop the now-trivial allTopics = kafkaTopics alias; inline into consumerSub and drop the redundant all_topics startup-log field. - publishToDeadLetterQueue: collapse the case lefts results to maybe (Right ()) Left . listToMaybe . lefts. - Lower the bisection depth cap from 12 to 10 (still 1024 rows, well above chunkSize=700). Log BISECT_DEPTH_EXHAUSTED before re-raising so the theoretical stall is visible if it ever happens in practice. - Split bulkInsertOtelLogsAndSpansTF's (_, Left eTf) wildcard into explicit (Right _, Left eTf) and (Left ePg, Left eTf) arms so a both-failed event logs both errors instead of swallowing the PG side. - Replace the CLAUDE.md cross-reference in the dual-write doc with an inline note; CLAUDE.md is not in the repo and would rot.
Code ReviewThe core logic is sound and the durability semantics are well-reasoned. A few concrete improvements below. Bugs / CorrectnessNone found — the per-partition commit, bisect, and TF-strict semantics all look correct. Repeated pattern — extract a helper (
|
Second round of review polish on the durability changes: - Data/Effectful/Hasql: export `isTransientException :: SomeException -> Bool`. Collapses the four `maybe False isTransientHasqlError (fromException e)` repetitions across Queue.hs and Telemetry.hs to `Hasql.isTransientException e`. - Pkg/Queue.hs:kafkaService: drop the `commitTPs` IORef + `recordSuccess` writer; restructure the topic-group loop as `fmap concat $ forM ...` that returns the per-batch TopicPartition list directly. `tpsFor` becomes a small pure helper. Removes `newIORef`/`modifyIORef'`/`readIORef`/`liftIO` ceremony. - Pkg/Queue.hs:publishToDeadLetterQueue: replace `listToMaybe . lefts` with `partitionEithers + listToMaybe`, matching the existing idiom used at the `pollMessageBatch` site in the same file. - Pkg/Queue.hs: add an inline comment by the (now-absent) poll threadDelay site noting why no backoff lives there, so future readers don't reintroduce one as a "fix".
Code ReviewSolid durability work — the three-bug root cause is well diagnosed and the fix is coherent. A few conciseness/idiom notes given the project's priority on keeping the codebase small:
|
Idiomatic touch-ups on top of the durability changes: - Pkg/Queue.hs:kafkaService: replace `fmap concat $ forM` with `concatMapM` from Control.Monad.Extra. Lift the per-group action to a `let processGroup = ...` binding so the layout closes cleanly. - Pkg/Queue.hs:publishToDeadLetterQueue: drop the partitionEithers pair-with-ignored-second; use `lefts` directly. - Models/Telemetry/Telemetry.hs:bulkInsertOtelLogsAndSpans: switch the POISON_ROW_DROPPED branch from `0 <$ action` to `action $> 0` so it reads left-to-right. - Data/Effectful/Hasql.hs: trim the isTransientException doc comment to a single WHY line.
Code ReviewGood structural fix — per-partition commits and TF-strict semantics are the right direction after that incident. A few things need attention before merging. Bug: bisection + non-idempotent INSERT = duplicate PG rows (non-TF path)
Bisection is only entered on a non-transient failure. But once inside, the (+) <$> go (d - 1) l <*> go (d - 1) ris sequential. If In TF-strict mode this doesn't cause duplicates (TF success commits the offset), but the non-TF code path still exists and has this invariant broken. The docstring says "Transient Hasql errors propagate so the surrounding retry logic can act" — but that surrounding retry re-sends the whole batch, not just the right half. Bug: partial DLQ failure stalls already-DLQ'd partitions and causes duplicate DLQ entries
pure $ maybe (Right ()) Left $ listToMaybe $ lefts resultsIf a batch of N messages DLQ-publishes N-1 successfully and the last one fails, this returns
|
Address three correctness/observability issues and two conciseness nits surfaced in review: - Models/Telemetry/Telemetry.hs:bulkInsertOtelLogsAndSpans: hoist otelRowSnippet above the bisection recursion so each row's snippet is built (and any OTEL_UNPARSEABLE_IS_REMOTE log fires) exactly once even when bisection recurses O(log N) levels. Bisection now operates on a vector of (record, snippet) pairs. - Models/Telemetry/Telemetry.hs:bulkInsertOtelLogsAndSpans: docstring notes the non-TF-mode duplicate-row edge case when a mid-bisection transient propagates and Kafka redelivery re-mints UUIDs. The hypertable has no UUID-only unique constraint we can ON CONFLICT against; under TF-strict semantics PG failures never trigger retry, so the bug only surfaces in tests / legacy. - Pkg/Queue.hs:publishToDeadLetterQueue: collapse the explicit results-binding + lefts/listToMaybe chain to `fmap sequence_ $ forM ...`; same first-Left semantics, less syntax. Docstring notes the duplicate-in-DLQ-on-partial-failure tradeoff. - Pkg/Queue.hs:kafkaService:tpsFor: filter out negative offsets in the partition-commit map so a librdkafka sentinel (OffsetEnd = -1, OffsetInvalid) can't rewind the consumer to the start. Shouldn't appear in pollMessageBatch output but making the assumption explicit.
Code ReviewSolid fix for a real data-loss incident. The core logic is correct. A few observations below, focused on succinctness and correctness.
|
Review polish, fourth pass: - Pkg/Queue.hs:publishToDeadLetterQueue: switch back to explicit `results <- forM ...; pure (sequence_ results)` instead of `fmap sequence_ $ forM ...`. Same semantics, reads left-to-right and matches the explicit-bind idiom used elsewhere in this file. - Models/Telemetry/Telemetry.hs:bulkInsertOtelLogsAndSpans: drop the unreachable `go _ pairs | V.null pairs` guard. The outer guards V.null records; the single-row branch handles 1; recursive calls always split a >=2 vector — so 'go' never sees an empty vector. A comment notes the reasoning. - Models/Telemetry/Telemetry.hs:bulkInsertOtelLogsAndSpans: comment the sequential `<*>` semantics so a future refactor doesn't quietly introduce parallelism that would let the right half attempt rows after the left half throws (changing the transient + depth-exhaust semantics). Note: HM.fromListWith was considered for tpsFor to match the rest of Queue.hs, but Kafka.Types.PartitionId has Ord but not Hashable, so Data.Map.Strict stays. Tracking deferred tests in #403.
Code ReviewSummaryFixes three intertwined durability bugs: offset commit regardless of batch outcome, DLQ self-subscription, and the brittle string-match error classifier. Core correctness logic is sound. Notes below are mostly succinctness / minor nits.
|
Three review nits: - bulkInsertOtelLogsAndSpans: trim the ~25-line docstring to a 5-line summary. The non-TF-mode edge case and the snippet-hoisting rationale are PR-discussion material, not per-call doc. - bulkInsertOtelLogsAndSpans:rawInsert: move chunkSize / chunks / chunkStmt from `let ... in case` to a `where` clause so the body reads case-first. - kafkaService:tpsFor: deduplicate the per-partition K.TopicName topic by mapping `uncurry (K.TopicPartition (K.TopicName topic)) . second K.PartitionOffset` over Map.toList. Same result, no constructor repetition. Skipped: HM.fromListWith for tpsFor — Kafka.Types.PartitionId has Ord but not Hashable (confirmed via :info), so Data.Map.Strict stays.
Code ReviewOverview: Fixes the 2026-06-01 data-loss incident with three interlocking changes — per-partition Kafka commits, TF-strict dual-write semantics, and row-level bisection for poison isolation. The design rationale is well-documented and the logic is sound. A few improvements worth considering: Succinctness / idiomatic style
-- current (5 lines)
case res of
Left e -> do
...
Right _ -> pass
pure res
-- simpler (3 lines)
whenLeft_ res \e -> do
...
pure res
-- sequence_ short-circuits at the first Left; subsequent failures are logged above.
pure (sequence_ results)Or rename to Bisection depth — implicit contract with
|
- bulkInsertOtelLogsAndSpansTF tfThread: switch the Left/Right case to whenLeft_ \e -> do ... (already 8+ call sites in the codebase). Drops two lines and matches local idiom. - publishToDeadLetterQueue: name the aggregated result 'firstFailure' with an inline comment so the sequence_ short-circuit semantics (first Left wins, subsequent failures only in the dlq log above) is explicit at the call site. - bulkInsertOtelLogsAndSpans: relax docstring to tie the bisect cap (10 levels → 1024-row ceiling) to messagesPerPubsubPullBatch / kafkaBatchSize rather than chunkSize. Add a runtime BISECT_CAP_MAY_NOT_ISOLATE log on inputs above 1024 so on-call sees the bound being approached before BISECT_DEPTH_EXHAUSTED fires on a real poison row.
Code ReviewThis is a solid durability fix for a real production incident. The architecture decisions (TF-strict semantics, per-partition commits, bisection isolation) are well-reasoned and the PR description is exemplary. A few focused notes below. Correctness / Bugs
let firstFailure = sequence_ results
pure firstFailure
Simplify to one line and make the intent clearer: pure $ sequence_ results
-- or, more explicit:
pure $ maybe (Right ()) Left $ find isLeft results >>= either Just (const Nothing)Or even simpler — since pure (sequence_ results)Code Succinctness
-- current
tpsFor topic neRecords =
map (uncurry (K.TopicPartition (K.TopicName topic)) . second K.PartitionOffset)
$ Map.toList
$ Map.fromListWith max [...]A list comprehension is shorter and clearer: tpsFor topic neRecords =
[ K.TopicPartition (K.TopicName topic) p (K.PartitionOffset o)
| (p, o) <- Map.toList $ Map.fromListWith max
[(r.crPartition, K.unOffset r.crOffset + 1) | r <- toList neRecords, K.unOffset r.crOffset >= 0]
]
Two adjacent Behavioral Change Worth Calling Out
These are now classified as Minor Nits
SummaryThe core logic is correct and the durability improvements are significant. Main asks:
|
- publishToDeadLetterQueue: drop the intermediate `firstFailure` binding; inline as `pure $ sequence_ results`. The short-circuit comment now sits above the pure. - kafkaService: drop `concatMapM` import (used exactly once); use `concat <$> mapM processGroup ...`. Merge the two adjacent let blocks inside the do (byTopic + processGroup) and lift `tpsFor` to the outer `where` clause — it doesn't close over anything inner. - kafkaService:tpsFor: rewrite as a list comprehension. Easier to read than the point-free uncurry+second form. - bulkInsertOtelLogsAndSpans: drop the unnecessary `Relude.when` qualifier — Relude is imported unqualified.
Code ReviewThe durability model is sound and the design rationale is well-documented. Three succinctness notes and one operational concern. Pkg/Queue.hs — concat <$> mapM → concatMapM extra is already a build-depend. Use it: Telemetry.hs — BISECT_CAP_MAY_NOT_ISOLATE fires at logAttention logAttention is alert-level. This fires on every call when V.length records > 1024 — a capacity advisory, not an error. Use logInfo or logDebug so on-call is not paged for a sizing note during normal high-volume ingestion. Telemetry.hs — rawInsert: case chunks of → pattern guard ViewPatterns and LambdaCase are already enabled. A pattern guard on the single-chunk arm is cleaner and shorter: What looks good
The three items above (concatMapM, log level, pattern guard) are the main wins worth applying before merge. |
- kafkaService: switch `concat <$> mapM` back to `concatMapM` from Control.Monad.Extra (extra is already a build-dep). - bulkInsertOtelLogsAndSpans: demote BISECT_CAP_MAY_NOT_ISOLATE from logAttention to logInfo. It is a capacity advisory, not an error; on-call shouldn't get paged during normal high-volume ingestion. - bulkInsertOtelLogsAndSpans:rawInsert: switch the single-chunk vs multi-chunk branch from `case chunks of` to a pattern guard (`| [single] <- chunks = ... | otherwise = ...`). Shorter and uses the LambdaCase/ViewPatterns extensions already enabled.
- upsertSummary: `tryAny ... >>= either (const pass) (const pass)` collapses to `void $ tryAny ...` (hlint Use whenLeftM_). - Drop now-unused `Data.Text qualified as T` import.
Code Review — ingestion: TF-strict commit, per-partition offsets, row-level bisectionThe durability semantics are sound, the tradeoffs are well-documented, and the overall design is a significant correctness improvement. A few issues worth addressing: Bug:
|
Code ReviewOverall this is a solid durability fix. The per-partition commit strategy, Bug: type error in
|
New hs-opentelemetry-api added arguments to two signatures: - shutdownTracerProvider :: TracerProvider -> Maybe Int -> ... (timeout) - TracerOptions :: Maybe Text -> [ExceptionHandler] -> TracerOptions Pass Nothing / [] at the existing call sites.
Code ReviewThe durability fix is well-motivated and the overall approach is sound. A few findings on correctness, succinctness, and missed package opportunities: Potential Bug
-index-state: hackage.haskell.org 2026-05-31T00:00:00Z
+index-state: hackage.haskell.org 2026-05-30T16:55:48ZThe timestamp moves backwards by ~7 hours, making packages published in that window invisible to Cabal resolution. Was this intentional (pinning to a known-good snapshot), or an accidental revert? Succinctness / Package Improvements1. The PR description says "Result assembly uses -- actual code
pure $ sequence_ results
pure . maybe (Right ()) Left . listToMaybe . lefts $ resultsAlternatively 2. The PR correctly introduces
A companion export alongside isHasqlException :: SomeException -> Bool
isHasqlException = isJust . fromException @HasqlExceptionNot a blocker — different semantics from Minor
rawInsert pairs
| [single] <- chunks = ...
| otherwise = ... -- handles both multi-chunk AND emptyThe Positive Highlights
|
Pkg/Queue.hs:kafkaService— per-partition commits viaK.commitPartitionsOffsets. Only topic-groups whose batch succeeded (or whose poison content was successfully DLQ'd) contribute offsets. Failed groups leave their partitions uncommitted; librdkafka redelivers from the last committed point. Healthy partitions advance even when a sibling topic is sick. Poll-threadthreadDelayremoved (lag growth is the signal — inline comment guards against re-adding one). DLQ topic no longer subscribed by the live loop. Successful-offsets accumulator switched fromIOReftofmap concat . forMreturning per-batch TopicPartition lists.Pkg/Queue.hs:pubsubService— mirror semantics. Poison → DLQ-then-ack; transient → no ack and PubSub redelivers. No moremap fst validMsgsswallow.Pkg/Queue.hs:publishToDeadLetterQueue— returnsEither Text (); caller gates commit on DLQ success. Result assembly usespartitionEithers + listToMaybe, matching the existing idiom in this file.Data/Effectful/Hasql.hs— exportsisTransientException :: SomeException -> Boolso the four call sites previously open-codingmaybe False isTransientHasqlError . fromExceptioncollapse toHasql.isTransientException e. Replaces the brittle string-matchisUnrecoverableError; unknown errors classify as poison (DLQ-actionable) rather than as silent partition stalls.Models/Telemetry/Telemetry.hs:bulkInsertOtelLogsAndSpans— bisects on non-transient Hasql failures, isolating a single poison row in O(log N) attempts so a bad row no longer wedges the partition. Recursion capped at 10 levels (1024 rows), comfortably abovechunkSize = 700.BISECT_DEPTH_EXHAUSTEDlogged before re-raising at the cap.Models/Telemetry/Telemetry.hs:bulkInsertOtelLogsAndSpansTF— TF-strict contract. PG failure with TF success logsPG_WRITE_FAILED_TF_OKand returns normally; TF failure aborts. Both-failed case is split off and logsBOTH_WRITES_FAILEDwith both errors before re-raising.