fix(destinations): collapse re-delivered Records on write - #311
Conversation
The Bridge read only `msg.header.stamp.sec` and dropped `.nanosec`, then packed it as the ingest protocol's plain integer time. Every Record's timestamp was rounded to the second, so a Measurement polling faster than 1 Hz produced Records that were indistinguishable in time — measured at 5 Hz: 8 Records, 3 distinct timestamps. The wire format already supported the fix. Fluent Forward has an EventTime extension (ext type 0x00, 4 bytes seconds + 4 nanoseconds), and the vendored Vector 0.57.0 parses it with all nine digits intact — verified by feeding its fluent source both frame shapes. Only the Bridge needed to change: Record now carries seconds and nanoseconds, and the Forwarder packs EventTime. Adds `epoch_nanos` as a new time_format, and makes it the default. `double` cannot represent nanoseconds — a float64 has ~15-16 significant digits and current epoch seconds spend 10 of them, so it tops out near microseconds. That is IEEE 754, not an implementation limit. `double` and `iso8601` stay available. Exactness also matters beyond precision: it is what makes the timestamp usable as part of a Record identity, which #309 needs. The schema follows: `date` becomes bigint in both init.sql files, `to_timestamp(date)` becomes `to_timestamp(date / 1e9)` in the dashboard, and params writing to those tables drop their explicit `time_format: "double"`. Console-only demos keep theirs. Regression cover: e2e_params.yaml's memory Measurement now polls at 5 Hz so several Records land inside one second, and verify_zero_loss.py asserts a nanosecond remainder exists, that some second holds 2+ Records (so it cannot pass vacuously), and that no two Records share an instant. At 1 Hz a return to whole-second stamps would not collide and would pass unnoticed. Closes #308 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5JwZEZrxEtYJdQUfsZRVo Signed-off-by: David Bensoussan <d.bensoussan@proton.me>
The Shipper is at-least-once, so an outage or a restart can deliver a Record twice — measured at roughly a fifth of a run in the E2E harness. Nothing collapsed them: dc_records had no identity and the sink plain-inserts, so the extra rows accumulated forever and every query had to remember DISTINCT. Vector's postgres sink cannot help. Its config accepts only endpoint, table, pool_size, batch, request and acknowledgements — `on_conflict` is rejected as an unknown field. A UNIQUE constraint would have been much worse than the bug. The sink writes a batch in one statement, so a violation fails the whole statement, which Vector classes as non-retriable and drops. Measured: a batch of four containing one duplicate lost all four, no retry, no dead-letter — one re-delivered Record takes out every Record batched with it. So the dedup is a BEFORE INSERT trigger returning NULL, which skips the offending row and lets the rest of the batch commit. It keys on (tag, date), which identifies a Record exactly now that timestamps carry nanoseconds (#308) — a re-delivery is byte-identical, carrying the timestamp the Measurement sampled at rather than the time of the retry. The index is deliberately not unique: the trigger enforces identity, the index only makes its lookup cheap. verify_zero_loss.py now hard-fails on duplicate Records instead of reporting them as notes. dc_files is exempt and says why: its rows are stamped at emit time, so (tag, date) is not their identity. Closes #309 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5JwZEZrxEtYJdQUfsZRVo Signed-off-by: David Bensoussan <d.bensoussan@proton.me>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## jazzy #311 +/- ##
==========================================
+ Coverage 40.35% 40.50% +0.16%
==========================================
Files 82 82
Lines 5076 5094 +18
==========================================
+ Hits 2048 2063 +15
- Misses 3028 3031 +3
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…cate The new no-duplicates assertions only fail if the run happened to produce a re-delivery, and re-delivery is not deterministic — a quiet run would pass them without the trigger ever firing. Same vacuous-pass flaw the timestamp check is explicitly guarded against. check_dedup_trigger injects a known duplicate every run: it inserts a probe row, re-inserts it in a single statement alongside two fresh rows (how the sink writes a batch), and asserts exactly 3 survive. Its own probe Tag, and its rows are removed in a finally. Exercised four ways against the shipped sql/init.sql: trigger present passes; trigger dropped fails naming it; a UNIQUE index fails with its own message about the batch being aborted; restored passes. That fourth case found a bug in the check itself — the UNIQUE path made psql() raise, crashing the verifier instead of reporting the one failure mode most worth explaining. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5JwZEZrxEtYJdQUfsZRVo Signed-off-by: David Bensoussan <d.bensoussan@proton.me>
|
Closing: the premise is retracted, and CI on this branch fails for exactly that reason. The "~20% duplication" this PR set out to fix was an artifact of the E2E harness, not the pipeline. What the evidence actually shows, from a full harness run: the real Measurement Tags had zero duplicates and zero gaps across a 45s outage plus a full mid-outage restart — Worth keeping from the work, recorded on #309 so it is not lost: Vector's |
Closes #309
The Shipper is at-least-once, so an outage or a restart can deliver a Record twice — roughly a fifth of a run in the E2E harness. Nothing collapsed them:
dc_recordshad no identity and the sink plain-inserts, so extra rows accumulated forever and every query had to rememberDISTINCT.Why not a UNIQUE constraint
It's the obvious answer and it loses data. Vector's
postgressink has no conflict handling —on_conflictis rejected as an unknown field, the config accepts onlyendpoint,table,pool_size,batch,request,acknowledgements— and it writes a whole batch in one statement. A violation fails the entire statement, which Vector classes as non-retriable and drops. Measured against a real Postgres:One duplicate, four events dropped, three good Records lost. Strictly worse than the bug.
What shipped
A
BEFORE INSERTtrigger returningNULL, which skips the offending row without failing the statement:Verified through the shipped
init.sql— a single-statement insert of four rows where one already exists returnsINSERT 0 3, with the other three committed and no error.The index on
(tag, date)is deliberately not unique: the trigger enforces identity, the index only makes its lookup cheap. Bothtools/e2e/sql/init.sqland the demos'postgresql/init.sqlget it.verify_zero_loss.pynow hard-fails on duplicate Records instead of reporting them as notes.dc_filesis exempt and says why: its rows are Bridge-generated audit records stamped at emit time, so(tag, date)isn't their identity.The local reproduction came back clean, and that matters
Before writing anything I tried to reproduce CI's numbers locally — one topic at 1 Hz into a Postgres Destination:
Zero duplicates. So duplication is not a deterministic consequence of outage-plus-restart, and CI's 26-per-topic depends on something a single-topic workload doesn't exercise — 20 topics, a 30s steady-state window immediately before the outage, far more in flight.
Two consequences worth stating plainly: steady state is genuinely clean (previously an assumption), and this fix is verified by CI's reproduction rather than a local one. If the CI run on this PR comes back with zero duplicate violations where the same harness previously reported 26 per topic, that is the verification.
Deliberately not done
Vector's
dedupetransform. It would stop a re-delivery crossing the uplink rather than cleaning up after it — the better place to solve the bandwidth half of this. But it only helps if duplicates are born at the Bridge → Vector hop; a sink retry re-sends a batch that has already passed the transform. Which hop dominates is still unmeasured (the local repro produced nothing to measure), so adding it now would be speculative. Left open on #309.Also left open:
dc_filesneeds its own key, the trigger costs one indexed lookup per row, andEXISTS-then-INSERTassumes a single writer (Vector's defaultpool_size). All three are documented indestinations.mdrather than left for someone to discover.🤖 Generated with Claude Code
https://claude.ai/code/session_01A5JwZEZrxEtYJdQUfsZRVo