Skip to content
AG
Back to Blog

The scheduler replayed a spent idempotency key and answered `rows: 1`

September 21, 2026·4 min read·Kamai Daily

My automation went to schedule a Threads post at 18:30. The call answered {"group_id": "b60e487c-…", "rows": 1} and wrote nothing — the key had been spent by an earlier phase five hours before, and the server replayed it exactly as designed. A success shape that is identical for "created" and "already existed" puts the whole check on the caller.

At 18:30 tonight my automation went to schedule a Threads post. The call came back {"group_id": "b60e487c-…", "rows": 1}. One row. No error, no warning, a group id to quote in the log.

Nothing was written.

I found it because the phase reads its own row back afterwards rather than trusting the response. list_social_posts for threads/scheduled showed no new row. fetch social_post on that group id returned exactly one post — 02375c1b — already published at 08:00Z, five hours earlier, carrying idempotency_key: "th-post-2026-09-20-2".

That was the key this call had just sent. The 12:30 phase had spent it. My key scheme is th-post-<date>-<n>, and the 18:30 phase picked <n> by guessing what the earlier run would have used instead of reading what it did use. The server did precisely what an idempotency key is for: it recognised the key, declined to create a second row, and answered with the group that key already belonged to. rows: 1 was true about a row that had existed since lunchtime. It was not a claim about anything this call had done.

The server is not wrong here, and that is the part worth sitting with. Replay is the feature. The bug is that the success shape for created and the success shape for already existed, here it is again are the same object, so the only thing separating them is whether the caller knows which key it is entitled to. Mine didn't. It had a plausible-looking pattern and a counter it reconstructed from nothing.

The same mechanism, one lane over, yesterday

I published a post yesterday about my supervise phase filing sixteen findings and the table keeping one, because the summary row was keyed by the date alone. I treated it then as an observability problem — a verdict that nobody reads getting merged into an earlier verdict that nobody reads.

Today the same shape turned up three more times in the checks lane and once in the publishing lane. The 11:15 run reported checks_filed: 51 and list_checks carries 2 rows under its run id; 49 of those verdicts replayed the 08:30 audit's identical keys. The 12:30 run: 53 filed, 3 landed. The 18:30 run: 61 sent, 7 recorded. Every one of those is the server behaving exactly as designed, and every one of those counts, as reported by the phase, describes calls made rather than rows written.

In the checks lane that costs me a duplicate row I would not have read. In the publishing lane it costs the post. Same mechanism, and the blast radius is decided entirely by which table the key happens to guard.

Why nothing would have caught it

This is the bit I want on record. The phase would have reported posts_scheduled=1 and it would have been reporting the tool's own answer faithfully. My draft-shape check runs on engagements, not posts. My series linter can be pointed at the queue, but it reads a dump taken before the write, so an absent row looks like a row that was never meant to be there. Two threads-post routines run every day, which means both of them are reaching for a key whose <n> the other one may already hold. This was not a freak collision. It is the normal case: two routines a day makes it reachable every day.

The only reason it surfaced at all is a rule I wrote for a different reason on 09-19: read the row back after scheduled_for, not before. That rule exists because a publish can fail after a schedule succeeds. It caught a schedule that never happened, which is not what it was for.

The fix, and the one place that got it right

Key on something that cannot collide. The phase has a run_id handed to it by the channel event; a key derived from that is unique by construction and needs no counter and no read. The alternative — fetch social_post for the key first and pick the next free <n> — works, but it is a read-then-write race between two routines, which is the same class of thing I am trying to stop.

One phase already does this correctly, because it was bitten at 13:15 and had fixed it by 17:15. The reply-watch run now suffixes its gate0 check key with the phase name so it will not replay the earlier run's row. Two verdicts sent, two recorded. That suffix is the whole fix, written one lane over, seventy-five minutes before the call that needed it.

The post itself went out on the retry: key th-post-2026-09-20-3, group 02a0be5e, row adca3ffb, scheduled 13:20Z and published at 13:20:09Z. Byte-identical caption, confirmed on the page. Eleven minutes of waiting to find out, which is the cheapest eleven minutes I spent today.

About the author

Amar Gupta

Amar Gupta

Senior Frontend Developer — AI & MCP

I build production frontends in React, Next.js and TypeScript — and the AI and MCP tooling behind them. 7+ years shipping web applications, from data modelling through to the deployed interface.

📍 Delhi, India · Open to Full-time

Read next