Designing a High-Throughput Metering Event Pipeline
You design this pipeline the day a single Postgres writer stops keeping up with your meter events — usually somewhere past a few thousand events per second, or when one noisy tenant can stall everyone else’s billing. At that point ingestion, aggregation, and rating can no longer share a thread, and “just insert the row” stops being a correctness strategy. This page builds the scaled version of the system described in Usage-Based Billing Implementation: how to ingest events idempotently at volume, buffer them in a log, count them exactly once under replay, and absorb bursts without dropping a single billable event.
Trade-offs
The core architectural decision is what sits between ingestion and aggregation. Each option trades operational weight against the guarantees and throughput you get.
| Buffer choice | Sustained throughput | Ordering | Replay / exactly-once | Ops weight | When to pick |
|---|---|---|---|---|---|
| Direct Postgres insert | ~1–3k events/s per writer | N/A | Unique constraint only | Lowest | < a few thousand events/day |
| Redis Streams | ~50–100k events/s | Per stream | Consumer groups + ack | Low | Single region, moderate scale |
| Kafka / partitioned log | 100k–1M+ events/s | Per partition (per customer) | Offsets + idempotent fold | Higher | Multi-consumer, high scale |
| SQS / managed queue | ~tens of k/s | None (best-effort) | At-least-once + dedup id | Low | Bursty, cloud-native, no ordering need |
Kafka (or any partitioned log) is the default for genuinely high throughput because partitioning by customer_id gives you per-customer ordering and lets each consumer own counters with zero cross-shard locking. The rest of this page assumes a partitioned log, but the patterns transfer to Redis Streams and SQS.
Step-by-Step Implementation
1. Deduplicate at ingestion
The ingestion endpoint must be cheap — a deduplication check and an append, nothing more. Heavy work here caps your throughput. Key on the event’s natural identity; this mirrors the idempotent webhook consumer pattern applied to the metering boundary.
import hashlib
def ingest(event: dict, redis, producer) -> dict:
idem_key = hashlib.sha256(
f"{event['customer_id']}:{event['meter']}:{event['event_id']}".encode()
).hexdigest()
# Claim the key; 7-day TTL covers any producer retry window
if not redis.set(f"idem:{idem_key}", "1", nx=True, ex=604800):
return {"status": "duplicate"} # ✅ collapse retry
producer.send(
topic="usage.raw",
key=event["customer_id"].encode(), # ✅ per-customer ordering
value=event,
)
return {"status": "accepted"} # 202, fast path
Redis is the fast first line; it is not the source of truth. A flushed cache must not let duplicates through, which is what the next stage’s durable guard handles.
2. Append to a partitioned durable log
Partitioning by customer_id is the load-bearing decision. All of one customer’s events land on one partition, so a single consumer sees them in order and can hold that customer’s counters locally. Add partitions to scale; rebalancing moves whole customers, never splitting a counter across consumers.
3. Fold into windows with an exactly-once guard
Each consumer reads its partitions and folds events into per-window counters. Because the log is at-least-once and crashes cause replay, the increment itself must be idempotent. Gate every fold on a durable per-window record of applied event ids — the increment runs only when the guard row is freshly inserted.
-- Atomic: claim the event for this window, and only then increment.
WITH claim AS (
INSERT INTO applied_usage_events (customer_id, meter, period_start, event_id)
VALUES (:customer_id, :meter, :period_start, :event_id)
ON CONFLICT DO NOTHING
RETURNING event_id
)
INSERT INTO usage_counters (customer_id, meter, period_start, period_end, total_quantity)
SELECT :customer_id, :meter, :period_start, :period_end, :quantity
FROM claim -- ✗ no row if already applied → no double count
ON CONFLICT (customer_id, meter, period_start)
DO UPDATE SET total_quantity = usage_counters.total_quantity + EXCLUDED.total_quantity;
Counters are commutative sums, so out-of-order arrival within a window is harmless — a + b == b + a. Ordering only matters for the watermark, not the math. The consumer commits its log offset only after the fold transaction commits, so a crash replays from the last durable point and the guard makes the replay a no-op.
4. Advance the watermark and route late events
A window closes when the watermark — min(event_time) still in flight, minus a grace allowance — passes its period_end. Events arriving after their window’s watermark are late. Do not drop them and do not retroactively mutate a rated window; route them to a carry-forward adjustment on the next open invoice.
def handle_event(event, window_for, watermark, carry_forward):
win = window_for(event["customer_id"], event["occurred_at"])
if event["occurred_at"] < watermark(win):
carry_forward(event) # ⚠️ late: adjust next invoice, never the closed one
else:
fold(event, win) # ✅ on-time: count into the open window
5. Publish the rated result through an outbox
When a window is rated, write the ledger entry and the outbound event in one transaction so a consumer crash can never publish twice or lose the event — the outbox pattern keeps the commit and the publish atomic.
BEGIN;
INSERT INTO ledger_entries (ledger_entry_id, customer_id, invoice_id, amount_cents, kind, window_key)
VALUES (gen_random_uuid(), :customer_id, :invoice_id, :amount_cents, 'usage', :window_key)
ON CONFLICT (window_key) DO NOTHING;
INSERT INTO outbox (event_type, payload, status)
VALUES ('usage.rated', :payload, 'PENDING');
COMMIT;
6. Apply backpressure instead of dropping
When ingestion outruns aggregation, the correct response is to lag, not to shed. The durable log is the shock absorber: ingestion keeps appending while consumers fall behind, and consumer lag becomes your alerting signal. If producers outrun the log itself, return 429 from ingestion so clients back off — never drop a billable event silently.
Verification & Testing
Drive the pipeline with a deterministic event generator and a controllable clock. Assert that replaying the entire log from offset zero yields byte-identical counters — full-replay idempotency is the headline guarantee. Kill a consumer mid-batch and assert no double counting after it resumes from its last committed offset. Inject duplicates, out-of-order events, and post-watermark late events in one run and assert: duplicates collapse, out-of-order events land in the right window, late events become carry-forward rows. Load-test ingestion to your target rate and assert p99 latency stays flat while consumer lag (not error rate) rises — proving backpressure works.
def test_full_replay_is_deterministic(pipeline):
events = generate_events(n=100_000, dup_rate=0.1, out_of_order_rate=0.2)
for e in events:
pipeline.ingest(e)
pipeline.consume_all()
snapshot = pipeline.counter_snapshot()
pipeline.reset_consumer_offsets() # simulate full replay
pipeline.consume_all()
assert pipeline.counter_snapshot() == snapshot # ✅ exactly-once under replay
Gotchas & Production Pitfalls
- Partitioning by event id instead of customer. You lose per-customer ordering and scatter a customer’s counter across consumers, forcing distributed locks. Partition by
customer_id. - Committing the log offset before the fold transaction. A crash in that gap loses the event entirely. Always commit the offset after the DB transaction succeeds, and lean on the applied-events guard to make the inevitable replay safe.
- Treating Redis as the dedup source of truth. A cache flush then admits duplicates. Redis is the fast path; the durable per-window guard (or a unique constraint on raw events) is the real exactly-once boundary.
- Mutating a rated window when a late event arrives. It corrupts an already-invoiced number and breaks the audit trail. Route late events to carry-forward only.
- Dropping events under load. Shedding billable events is silent revenue loss. Absorb bursts in the log and signal with
429and consumer-lag alerts instead. - Unbounded
applied_usage_eventsgrowth. It grows with every event forever. Partition it byperiod_startand drop partitions once their windows are rated and beyond any dispute horizon.