Exactly-once reads on a store with no primary key
Why this sink's at-most-one-row-per-key guarantee lives in the read path instead of the write path, why that is not a workaround, and exactly what stays eventual because of it.
Read this before trusting a number this sink produces. It is the one property that does not port over from the built-in Postgres sink for free, and understanding why is what tells you which query is safe to bill from.
What the sink has to guarantee, restated
The plugin's own sink contract asks for "at most one row per deduplication
key" - a retried write call, carrying the same events, must not add a second
row for any event that already made it into the log. The built-in Postgres
sink gets this the direct way: the key is the row's primary key, so
INSERT ... ON CONFLICT DO NOTHING makes a retry a no-op inside one atomic
statement, and the guarantee is true the instant that statement returns.
ClickHouse has nothing that plays the same role
Tinybird's data sources run on ClickHouse, and ClickHouse has no primary key
constraint at all. Its ORDER BY - the sorting key - decides how rows are
physically arranged on disk for fast range scans; it does not decide whether a
second row sharing that key is allowed to exist. Nothing in the engine refuses
a duplicate insert.
The nearest mechanism is ReplacingMergeTree, the engine usage_events.datasource
declares. It collapses rows that share a sorting key, but only during
background merges - a maintenance process that runs when ClickHouse decides
to, on parts it chooses to combine, at a time nobody calling write controls.
Two copies of one retried event can both sit on disk for minutes, for hours,
or - for a partition the engine never gets around to merging - indefinitely.
So the honest, complete statement about ReplacingMergeTree on its own is:
eventual. A sink built on nothing but that engine double counts every
retry until a merge happens to run, and for a number that becomes a line on an
invoice, "eventually correct" is not a rough edge to note in passing - it is
exactly the failure this whole plugin, and this sink, exist to prevent.
What this sink does instead: enforce the guarantee where the log is read
Deduplication here is enforced at query time, not at write time. Every endpoint this sink calls collapses to one row per key before it sums anything, in the endpoint's own SQL:
SELECT key, argMax(quantity, version) AS event_quantity, ...
FROM usage_events
WHERE ...
GROUP BY keyThat collapse is not eventual - it is computed fresh, over whatever rows
happen to exist in the table at the exact moment the query runs. A duplicate
that landed on disk a millisecond ago is already folded into one row by that
GROUP BY, because the query does not care how many physical copies exist; it
only ever emits one logical row per key. aggregate() called immediately
after a retried write() returns the identical number it returned before the
retry - and that is asserted against a live Tinybird instance, not argued from
first principles: see Testing.
version decides which copy wins, and it is chosen to agree with Postgres
argMax(quantity, version) needs version to break the tie between
duplicate rows, and the schema assigns it as the negated ingestion
timestamp:
version = -toUnixTimestamp64Milli(recorded_at)Negating it means the largest version - the row both argMax at read time
and ReplacingMergeTree at merge time keep - is the row with the smallest
recorded_at: the earliest-ingested copy. That is not an arbitrary choice.
It is exactly the row the built-in Postgres sink keeps, because
INSERT ... ON CONFLICT DO NOTHING always keeps whichever copy landed first
and silently drops every later attempt. Picking the same rule here means the
two sinks answer identically for the same sequence of retries, and it means
the answer this sink gives does not depend on when, or whether, a background
merge happened to run - the merge is choosing among rows that the read path
was already ignoring.
Both recorded_at and version are assigned by Tinybird itself when a row is
appended, never sent by the sink. One clock decides ingestion order, exactly
the way Postgres's own now() does for the built-in sink - which is what lets
a fleet of Medusa instances with clocks that disagree by a few seconds still
agree on which copy of an event arrived first.
Why this is not a workaround
Calling the read-time collapse a stopgap for a missing database feature would
be the wrong way to think about it, and the schema's own comments in
usage_events.datasource explain why: the two rules - the ReplacingMergeTree
engine and the GROUP BY key in every endpoint - are chosen to agree with
each other, and that agreement is the actual property the sink rests on,
not a consolation prize for lacking a primary key.
A merge can only ever remove a row the read path was already discarding.
Because both mechanisms keep the earliest-ingested copy under the same
version rule, a merge collapsing two physical rows into one never changes
which logical row an endpoint would have reported - it just deletes something
that was already invisible to every query. An answer computed today and the
same answer recomputed after however many merges have run since are
therefore identical, which is the entire property a usage log needs to be
audit-safe a year later. The merge is a storage optimization, full stop:
fewer bytes to scan, not a different answer.
Contrast that with the shape of an actual workaround - deduplicating in the
application code that calls aggregate(), or running a periodic cleanup query
that deletes obvious duplicates. Both of those would be racing the write path
and would still be wrong in the gap before they ran. Enforcing the property in
the read path removes the race instead of narrowing it: there is no window,
however small, in which two live queries against this sink can disagree about
one event's contribution to a total.
What is left eventual, stated as plainly as the guarantee above
Being precise about the boundary of a guarantee is what makes the guarantee trustworthy. Three things genuinely do not have the same instant-correctness property, and each one is either irrelevant to billing or bounded to a single optimistic count:
- The physical log itself. Between a duplicate write and whatever merge
eventually runs, two rows for the same key can both be sitting on disk, and
SELECT count()run directly against theusage_eventsdata source will say so - it is not wrong, it is answering a different question thanaggregate()does. Never bill from a direct query against the data source. The three endpoints are the collapse, and they are this sink's only supported read path; a dashboard built by querying the data source directly will disagree with an invoice, correctly, because it counted rows the invoice never counted. - The write-side counters.
duplicates, in whatwrite()returns, is produced by asking which keys are already present immediately before appending - a check, then an act. Two processes racing to write the exact same key at the exact same instant can both find it absent and both append, which makes the reported counters optimistic by exactly one in that narrow window. Nothing else is affected by this: the two rows that land are identical copies of the same event, and the read path still keeps one of them. This is a blemish on a diagnostic count, not on a billed total. - Which copy of a key wins, when it is observable at all. Both rules keep
the earliest-ingested copy, so this only becomes visible if a caller reuses
one explicit
idempotencyKeyacross two events that carry genuinely different facts - which is a caller bug under either sink, not something this package can detect or should paper over.
Nothing about aggregate() or listEvents() depends on a merge having run,
or on the pre-append duplicate check having guessed correctly. Both are
correct against whatever the table contains at the moment they are called,
which is the actual claim this whole page has been building toward.
Testing the part that matters
A fake Tinybird backend can prove what goes out on the wire and what comes back off it, and the unit test suite does exactly that. It cannot prove the one property this page is about, because that property belongs to the real engine on the other side of the HTTP call, not to this package's code.
So src/live.test.ts writes to an actual Tinybird and reads the answer back,
skipped automatically unless the environment names one:
tb local start
tb --local build
TINYBIRD_HOST=http://localhost:7181 TINYBIRD_TOKEN=... pnpm testWhat it asserts there, against a real engine rather than a description of one: that a write can be aggregated the instant it returns; that replaying an identical batch does not move the total; that a duplicate which did land as a second physical row on disk still does not move the total; that the window is half-open at millisecond resolution and consecutive periods tile without a gap or an overlap; that a dimension filter is a typed equality; and that paging never skips a row or repeats one.