Skip to content
echogeco
The harness in code · Code exercises

A webhook deduper

Implement the deduper that applies at-least-once, any-order deliveries in sequence order with one cursor, and hands a gap to the backfill after a set window.

codemediumExercise · 4 min · 43 of 75
On this page

A cursor is a smaller memory than a set of everything seen, and unlike the set it knows what is missing. This deduper keeps one number and a short hold, and it is what makes an at-least-once, any-order webhook safe to apply in order. Webhooks versus polling covers senders without sequence numbers; why at-least-once forces deduplication at all is idempotency.

The situation

The 40-person wholesaler this module uses in place of the agency, from why we write these five by hand, supplies 900 cafés. Its accounting system numbers every event on its ledger (invoice.issued, invoice.updated, payment.received, credit_note.issued) with a sequence number, contiguous from 1, and delivers each by webhook at least once, in no guaranteed order. The automation that keeps the CRM in step applied deliveries as they arrived.

Last month about 10,300 events arrived as 11,200 deliveries: 900 duplicates, and 180 that landed after a later event. Eleven payment.received events were applied before their invoice existed, failed and were dropped, and eleven cafés were chased for invoices they had paid. One event never arrived at all.

Of the 180 late deliveries, 176 landed within a minute of their successor and 4 within 9 minutes. The sender retries a delivery it believes failed for up to 48 hours. A missing event can be fetched from the API by its sequence number, one call each. At the Friday peak about 300 deliveries arrive in an hour.

The question

Implement the deduper so the examples below hold. The window it runs with in production is yours to set from the figures above.

def solve(deliveries, start, window):
    """deliveries: [(t, seq)] in arrival order, t non-decreasing, in seconds; seq is the
    sender's sequence number, contiguous per ledger. start: the first seq not yet applied.
    window: the longest a delivery may be held, in seconds.
    A delivery below the cursor, or one already held, is a duplicate and is dropped.
    Otherwise it is held, and everything contiguous from the cursor is applied. Then, while
    the delivery held longest has waited at least window, every seq below it that has not
    arrived is given up on and the cursor moves past it.
    Returns (applied, missing): the seqs applied, in order, and the seqs handed to the
    backfill, ascending. Deliveries still held after the last one are in neither list."""
solve([(0, 1), (2, 3), (3, 2), (4, 3), (5, 4)], 1, 600)      -> ([1, 2, 3, 4], [])
solve([(0, 1), (10, 3), (40, 4), (70, 5)], 1, 60)            -> ([1, 3, 4, 5], [2])
solve([(0, 1), (10, 3), (40, 4), (70, 5), (80, 2)], 1, 60)   -> ([1, 3, 4, 5], [2])
solve([(0, 1), (10, 3), (70, 2)], 1, 60)                     -> ([1, 2, 3], [])

A strong answer · Rubric · Where answers go wrong · Solution

Write your answer first, then open