fix(ingest): deterministic content-derived row id so DLQ replay is idempotent#445
Conversation
…empotent mintOtelLogIds assigned a fresh random UUID v4 per row, so reprocessing a dead-letter message wrote a brand-new row every time — the source of the 06-19 duplicate pile-up (and the reseek self-heal, which redelivers chunks, makes reprocessing more frequent). TF already dedups on (id, timestamp) (flush-time dedup_batches + today-partition sweep + read-side row_number rewrite), but random ids defeated it. Make `id` content-derived (UUID v5): spans key on the OTel-unique (project_id, trace_id, span_id) — matching TF's "same span id collapses retries" intent regardless of attribute drift; logs (no span_id) key on stable content (project_id, body, name, severity, attributes, resource). Excludes timestamp (the other dedup key, so co-content/different-time events stay distinct) and the parse-time fallback fields (observed_timestamp/ start_time → currentTime) which aren't stable across reprocessing. So re-parsing the same payload re-derives the same id and TF collapses the dup. mintOtelLogIds becomes pure; UUIDEff drops out of the ingest path (Telemetry.insertSystemLog + OtlpServer) — those used it solely for minting. Unit test asserts determinism, span-retry collapse, span/content distinctness, timestamp-exclusion, and no over-collapse across differing resource.
Code ReviewThis PR replaces random UUID v4 row IDs with deterministic UUID v5 IDs derived from OTel content (span path: Findings1. -- current
otelIdNamespace = fromMaybe UUID.nil (UUID.fromString "6f1a7c30-9b2d-5e84-8a3f-0c1d2e3f4a5b")
import Data.UUID.Quasi (uuid)
otelIdNamespace :: UUID.UUID
otelIdNamespace = [uuid|6f1a7c30-9b2d-5e84-8a3f-0c1d2e3f4a5b|]This removes 2. Redundant Text/ByteString roundtrip in The current pipeline is:
deterministicOtelId r =
UUID.toText $ UUIDv5.generateNamed otelIdNamespace $ BS.unpack $
BS.intercalate "\US" keyParts
where
enc :: AE.ToJSON a => Maybe a -> BS.ByteString
enc = maybe "" (BSL.toStrict . AE.encode)
keyParts = case r.context >>= (.span_id) of
Just sid | not (T.null sid) ->
[encodeUtf8 r.project_id, encodeUtf8 $ fromMaybe "" (r.context >>= (.trace_id)), encodeUtf8 sid]
_ ->
[ encodeUtf8 r.project_id, enc (unAesonTextMaybe r.body)
, encodeUtf8 $ fromMaybe "" r.name, enc r.severity
, enc (unAesonTextMaybe r.attributes), enc (unAesonTextMaybe r.resource) ]Requires adding 3. Scope note —
Overall the fix is correct and well-reasoned. The three points above are independent improvements — #1 and #2 can be applied inline, #3 is a follow-up ticket. |
- otelIdNamespace via [uuid|...|] quasi-quoter: malformed namespace is now a compile error instead of a silent nil-namespace fallback. - deterministicOtelId builds the v5 hash key in ByteString throughout, dropping the Text decode/re-encode roundtrip on every row.
Code ReviewThis PR replaces random UUIDv4 generation with deterministic UUIDv5 content-derived IDs, so DLQ replays of the same payload re-derive the same row ID and TimeFusion's `(id, timestamp)` dedup collapses retries instead of appending fresh rows. Removing `UUIDEff` from the ingest path and making `mintOtelLogIds` pure is the right structural simplification. Bug: separator injection in key derivation
`project_id` and `name` are raw `encodeUtf8` without escaping the `\x1F` (Unit Separator) byte used as the delimiter. Two distinct records can hash to the same id: Same bytes → same UUID → cross-project log dedup — the exact class of bug this PR is meant to eliminate. The `enc` helper already solves this for `body`/`severity`/`attributes`/`resource` (Aeson escapes `\x1F` as `\u001f` inside JSON strings). Apply the same treatment to `project_id` and `name`: -- log path:
[ enc (Just r.project_id)
, enc (unAesonTextMaybe r.body)
, enc r.name
, enc r.severity
, enc (unAesonTextMaybe r.attributes)
, enc (unAesonTextMaybe r.resource)
]
-- span path:
[ enc (Just r.project_id)
, enc (r.context >>= (.trace_id))
, enc (Just sid)
](OTel trace/span IDs are hex strings in practice, so they're safe today, but encoding them too makes the invariant unconditional.) Simplification: avoid intermediate
|
|
Found 3 test failures on Blacksmith runners: Failures
|
…njection A field containing the 0x1f delimiter byte could shift the join boundary so two distinct records (e.g. project_id="x\x1f"/name="y" vs project_id="x"/ name="\x1fy") hashed to the same id — cross-record dedup, the exact bug this path guards against. JSON-encode every part (incl. project_id/name/span ids) so the delimiter can never appear inside a part. Also intercalate over [Word8] directly instead of materialising then unpacking a joined ByteString. Regression test in DeterministicIdSpec.
Code ReviewThis PR fixes the 06-19 duplicate pile-up by switching from random UUID v4 to content-derived UUID v5 for OTel row IDs, making the DLQ replay path idempotent against TF's 1.
|
Problem
mintOtelLogIdsassigned a fresh random UUID v4 to every row'sid. So reprocessing a dead-letter message wrote a brand-new row each time — the root of the 2026-06-19 duplicate pile-up. The just-shipped reseek self-heal (#443) redelivers chunks, making reprocessing more frequent, so this matters more now.TimeFusion already dedups on
(id, timestamp)— flush-timededup_batches, the today-partition sweep, and a read-siderow_number()rewrite for cross-bucket dupes — but random ids defeated all of it (every replay looked like a new row).Fix
Make
idcontent-derived (deterministic UUID v5), so re-parsing the same payload re-derives the same id and TF collapses the duplicate:(project_id, trace_id, span_id)— matches TF's "same span id at the same timestamp collapses retries" intent, regardless of attribute drift.(project_id, body, name, severity, attributes, resource)— so distinct logs that merely share a timestamp stay separate, but a reprocessed identical log collapses.timestamp(it's the other dedup key, so same-content/different-time events stay distinct) and the parse-time fallback fields (observed_timestamp/start_time→currentTime) which aren't stable across reprocessing.mintOtelLogIdsbecomes pure;UUIDEffdrops out of the ingest path (Telemetry.insertSystemLog+ OtlpServer — they used it solely for minting).Test
Models.Telemetry.DeterministicIdSpec(unit, 7 cases, all green locally): determinism, span-retry collapse despite attribute drift, span/content distinctness, timestamp-exclusion, and no over-collapse when onlyresourcediffers (multi-pod logs stay separate).Scope note
This stops new duplicates from reprocessing. Pre-existing random-id duplicates (original 06-19 ingest + drain so far) won't retroactively collapse — they'd need a separate dedup/cleanup pass if desired.