How ClickHouse's ReplacingMergeTree Let a Webhook Retry Storm Double-Count $118,440 in Revenue
← Back
November 5, 2026Database8 min read

How ClickHouse's ReplacingMergeTree Let a Webhook Retry Storm Double-Count $118,440 in Revenue

Published November 5, 20268 min read

08:14 UTC. #finance-ops: "today's GMV dashboard says $930,740 before 9am, that can't be right, yesterday's full day was $780K." The dashboard pulls straight from ClickHouse, refreshes every 60 seconds, and nothing about its query had changed in months. Orders were flowing normally. Checkout wasn't slow. Nothing was on fire anywhere an on-call engineer would normally look.


the scramble

First theory: duplicate events from Kafka. The order pipeline publishes a payment_captured event per status change, a materialized view consumes it into a ClickHouse table called orders_rt, and if the consumer somehow processed the same Kafka offset twice, every re-read would look exactly like this, revenue inflated with no corresponding spike in real orders.

That theory died fast. The consumer group's committed offsets matched the topic's log end offsets exactly, no rebalance in the last 24 hours, no lag, no gap. The producer had idempotent writes enabled and the broker confirmed zero duplicate sequence numbers for the window in question. Whatever was inflating the dashboard wasn't Kafka re-delivering anything. The events arriving were each legitimately new.


the hunt

If the events were legitimate, the next place to look was how many rows actually existed per order in ClickHouse itself:

checking for duplicate order_id rows in orders_rt
SELECT order_id, count() AS versions
FROM orders_rt
WHERE created_at >= today() - 1
GROUP BY order_id
HAVING versions > 1
ORDER BY versions DESC
LIMIT 5;

│ order_id   │ versions │
│ ord_9f21c8 │        6 │
│ ord_7a40e1 │        5 │
│ ord_c88b02 │        5 │
│ ord_1de934 │        4 │
│ ord_5c7a90 │        4 │

14,900 distinct order_id values had more than one row in the last 24 hours, 31,460 extra rows in total. orders_rt is a ReplacingMergeTree keyed on order_id and versioned by updated_at, which is supposed to mean the latest row wins and old ones eventually disappear. They hadn't disappeared. That pointed at the merge process itself, not the data arriving:

active parts for orders_rt, compared against yesterday's baseline
SELECT table, count() AS active_parts
FROM system.parts
WHERE table = 'orders_rt' AND active
GROUP BY table;

│ table       │ active_parts │
│ orders_rt   │         2180 │   -- baseline is 40-60

SELECT count() FROM system.merges WHERE table = 'orders_rt';
│ 212 │   -- merges actively queued, not yet applied

ReplacingMergeTree only collapses duplicate keys when a background merge actually runs. A plain SELECT sum(total_cents) against the table reads every active part as-is, duplicates and all, unless the query uses FINAL or the rows have already merged. The dashboard query did neither. It had been running the same unguarded sum() for months and it had always been fine, because merges normally kept the duplicate count low enough that nobody noticed a few extra cents here and there. 2,180 active parts was not normal merge backlog. Something had just fed the table far more row versions per order than usual, faster than the merge scheduler could work through.

Tracing ord_9f21c8's six rows back through the ingestion logs showed all six originated from the same payment intent, each one a separate webhook delivery from the payment provider, 40 to 90 seconds apart. Our webhook handler had no idea they were retries, it just saw six "payment captured" calls and faithfully published six Kafka events, each correctly stamped with a newer updated_at. At the business layer, idempotency was fine, the order's final status was correct either way. At the row layer, it meant six physical rows instead of one, and ClickHouse doesn't dedupe anything until it gets around to merging.


the find

The payment provider's own status page confirmed a 23-minute API degradation starting at 07:48 UTC. During that window their webhook sender retried on every slow or ambiguous acknowledgment, pushing retry volume to roughly 4.1x normal for the accounts affected. Our webhook endpoint kept responding fast, so from our side nothing looked wrong, we had no retry behavior to notice. We just received more webhooks than usual and emitted more events than usual, each one individually correct. The revenue dashboard's query had been implicitly depending on an assumption nobody had written down: that duplicate versions of the same order would always merge away before anyone summed the table. That assumption held for months. A spike in upstream retries was all it took to break it.


the fix

Waiting for merges to catch up, or forcing them with OPTIMIZE TABLE ... FINAL, fixes the symptom for one table at one moment and says nothing about the next retry storm. The dashboard query needed to stop trusting merge timing at all. argMax picks the value associated with the highest version per key, inside the query, regardless of how many unmerged rows currently exist:

dashboard query, before and after
-- before: sums every active row, duplicates included
SELECT
  toDate(created_at) AS day,
  sum(total_cents) / 100.0 AS revenue
FROM orders_rt
WHERE created_at >= today() - 1
GROUP BY day;

-- after: collapse to the latest version per order_id first, then sum
SELECT
  day,
  sum(total_cents) / 100.0 AS revenue
FROM (
  SELECT
    order_id,
    toDate(argMax(created_at, updated_at)) AS day,
    argMax(total_cents, updated_at) AS total_cents
  FROM orders_rt
  WHERE created_at >= today() - 1
  GROUP BY order_id
)
GROUP BY day;

The rewritten query added roughly 140ms of latency on a normal day, a reasonable trade for a number finance actually trusts regardless of what the merge backlog looks like at query time. We also added a check on system.parts active part count per table, alerting when it crosses 5x its 7-day median, since that's the earliest signal that something upstream is producing far more row versions than usual, well before any dashboard goes visibly wrong.


the aftermath

$118,440 revenue overcounted on the GMV dashboard over 3h 48m
2,180 → 48 active parts on orders_rt, before and after merges caught up

Actual revenue for the window was $812,300. Nobody got double-charged and no downstream billing system touched the bad numbers, the corruption lived entirely in one read path over one table. Finance paused that morning's reporting for under four hours while the query fix shipped and the backlog of unmerged parts cleared on its own by early afternoon.

  • ReplacingMergeTree doesn't dedupe on write and doesn't dedupe on a plain read either. It dedupes when a background merge happens to run, which is a scheduling detail, not a guarantee a query should ever depend on.
  • A dashboard can be correct for months by accident, relying on merge timing that happened to keep duplicate counts low, right up until an upstream system's retry behavior changes the shape of the input it was never tested against.
  • Business-layer idempotency and row-layer idempotency are different guarantees. Our webhook handler was idempotent about order status. It was never idempotent about how many rows that status change produced downstream.

Nobody touched the orders pipeline that morning. The only thing that changed was how fast someone else's retries arrived, and that was enough to turn an assumption nobody had written down into a number finance stopped trusting for half a day.

Share this
← All Posts8 min read