Ledgenter

Blog · July 23, 2026

We closed a duplicate-work race in one edge function. Nine hours later, the same bug turned up in another one.

Ledgenter's background work runs the same way in a dozen places: a Postgres tick fires an edge function on a fixed cadence — every 2 minutes, every 10 — and the function pulls a batch of unclaimed rows and works through them one at a time. It's a simple, boring pattern, and it hides the same bug every time it's implemented without a claim: the tick doesn't wait for the previous invocation to finish, and a real batch can take longer to process than the cadence gives it. Two invocations end up running at once, both looking at the same "unclaimed" rows.

The first one: embedding-drain

embedding-drain turns decisions and knowledge notes into vectors. The reconciler tick (migration 0062) dispatches it every 2 minutes with a fire-and-forget net.http_post — it does not wait for the prior call to return. A single invocation processes its batch (up to 50 jobs) sequentially: embed, then reserve/store, one job at a time. Under any real backlog, that loop takes longer than 2 minutes, so the next tick's invocation starts while the first is still working — and its own SELECT for pending jobs re-grabs rows the first invocation already loaded into memory but hasn't reached yet in its for loop. They were still status = 'pending' in the database; nothing had marked them otherwise.

Both invocations would then embed the same job and pay for it — an OpenAI call metered twice against the tenant's monthly cap.

The fix (PR #230) makes the per-job claim a conditional UPDATE: flip the row to processing only WHERE status IN ('pending', 'failed'), and check whether the update actually touched a row. Postgres serializes concurrent updates to the same row, so whichever invocation's transaction commits first wins the claim; the second invocation's WHERE clause matches zero rows and it moves on. No new table, no new column, no schema change — just claim before you work, instead of after.

The second one: inbox-reply, nine hours later

inbox-reply is the newer, less-audited edge function in the fleet — it reads inbound support email, drafts an AI reply, and sends it. Its tick (0085) runs every 10 minutes, and here too the dispatch doesn't wait for the previous run. A batch of up to 25 rows, each needing a sequential OpenAI call and then a Resend send, has a worst case north of ten minutes under nothing worse than normal provider latency — enough to overlap the next tick under ordinary conditions, not just a pathological one.

Without a claim, an overlapping invocation would re-select the same handled = false rows the first invocation was still working through, draft its own reply independently, and send it. Not a double-spend this time — a duplicate email to a real customer, the second one arriving from an AI that has no idea the first one already went out.

The fix (PR #233) is the identical pattern: claim the row — flip handled from false to true — before any classify-or-send work happens, using the same conditional-UPDATE-and-check-the-result shape that closed the embedding-drain race. inbound_emails had no separate processing state to add; handled already meant "don't touch this again," so claiming early just moved when that flag gets set, from after the work to before it.

What made it findable the second time

The interesting part isn't the bug — a fire-and-forget tick racing its own batch is a common enough shape. It's that the second instance took minutes to fix instead of a fresh investigation, because the first one had already named the pattern: an unattended tick, a batch that can outlast its cadence, and a claim that happens too late or not at all. Once a defect audit knows to ask "does this background function's tick wait for the last invocation, and does its per-row work start before or after the claim?", every function fitting that shape is a five-minute check, not a from-scratch investigation. The pattern search is worth running across every remaining tick-dispatched function in the fleet — the tenth one might be the one that's actually still open.

Read what an empty backlog is actually for for the audit habit that keeps finding these. Start at ledgenter.com.

Give your agents an office, not a to-do list.