Read-modify-write is a race, even when your agent is the only caller

6 min read

When Junkt launched it had four tools: list_keys, get_value, set_value and delete_key. The pitch said it was for storing "a timestamp, a flag, a counter".

Two of those three were true.

You could store a counter, in the sense that you could put the characters 7 into a key. What you could not do is reliably make it 8.

The sequence that looks fine

To increment a number with only get and set, you do this:

get_value("digest:runs")     → "7"
                             (parse to 7, add 1)
set_value("digest:runs", "8")

Three steps. Read the current value, work out the new one, write it back. This is such a normal shape that it takes a moment to see the problem, and the problem only appears when two of these run at once.

run A: get_value → "7"
run B: get_value → "7"
run A: set_value("8")
run B: set_value("8")

Two increments. One counted. The final value is 8 and it should be 9.

Nothing failed. Both calls returned success, both agents believe they counted, and the number is quietly wrong. There is no error in any log, because from the store's point of view it received two perfectly valid writes and honoured both.

"But my agent only runs once"

This was roughly my reasoning for shipping without it, and it is wrong in a specific way.

A scheduled agent is not one caller. It is one caller per invocation, and invocations overlap more often than you would think:

  • The schedule fires twice because of a retry, a queue redelivery, or an infrastructure hiccup. This is the same failure that made me build Junkt in the first place.
  • You have a scheduled task and a chat session touching the same key, because you asked the model a question while the job happened to be running.
  • The agent itself decides to parallelise. Give a model a list of twelve things and a tool, and it will quite reasonably fire several tool calls in the same turn.
  • You approved connectors on your laptop and your phone, and both are live.

The gap between the read and the write does not need to be long. It needs to be non-zero, and it always is — there is a network round trip, model inference time, and possibly a whole reasoning step in the middle. For an agent, that gap is not microseconds. It is often several seconds. Seconds is an enormous window to lose a write in.

Why you cannot fix this in the prompt

The instinct is to write better instructions. Tell the model to read again after writing and check. Tell it to retry if the value looks wrong.

This does not work, and it is worth being precise about why. The problem is not that the model behaves incorrectly — in the sequence above both runs behaved perfectly. Every individual step was correct. The correctness is lost between the steps, in a place no instruction can reach, because there is no instant at which either agent could have observed the other.

Check-after-write does not help either. Both runs check, both see 8, and 8 is exactly what each of them expected to see. The bug is invisible from inside.

You cannot solve a concurrency problem with prose. It has to be solved by the thing that owns the data.

The fix

One operation instead of three:

increment("digest:runs")
→ { key: "digest:runs", value: 8, created: false }

The store reads, adds and writes as a single atomic step, holding a row lock for the duration, so two concurrent calls serialise and both count. Run it twice and you get 9. Run it fifty times concurrently and you get fifty.

amount is optional and signed, so decrementing is the same tool with a negative number rather than a second tool to learn.

The part I did not expect to need

Making increment atomic was the easy half. The harder question was what happens when a key is used as both things.

Say a counter lives at digest:runs, and somewhere in a long prompt the model decides to write "done" there. With a plain upsert, that write succeeds. Your counter is now the string done, the next increment has nothing to add to, and the count is gone.

So the type is locked, in both directions:

  • set_value refuses to overwrite a counter.
  • increment refuses to touch a key holding a plain value.

Both raise an error and leave the existing key exactly as it was. The messages are written to be read by the model rather than by me:

The key "digest:runs" is a counter. Use the increment tool to change it; it cannot be overwritten with set_value.

That sentence is the actual fix for the actual failure mode. An agent that reads it usually recovers on its own and calls the right tool, which is the whole reason error strings in an MCP server deserve as much attention as the happy path. A stack trace teaches the model nothing. A sentence explaining what to do instead teaches it everything it needs.

What this generalises to

Any time an agent reads a value, decides something from it, and writes back, you have this problem. Counting is just the clearest case. Cooldowns and locks have the same shape, and the same non-answer of "check first, then set", which leaves a window between the check and the set.

For those, expiry is usually the better primitive than a counter — a key with a TTL is a lock that releases itself, with no arithmetic for the model to get wrong:

set_value("deploys:staging:cooldown", "held", ttl: 3600)

The rule I keep coming back to, for a tool surface aimed at models: if correct usage requires more than one call in a specific order, that is not a tool, it is a footgun with documentation attached. Make it one call.

increment is in the free tier along with everything else. The tool reference has the full shape.

Give your agent a memory

Junkt is a hosted key/value store your agents reach over MCP. Free tier, 25 keys, no card, and about ten minutes from signup to a working tool call.

All posts

Ask an AI

Junkt is built for agents, so ask one

Pick a question and where to ask it. Each link opens a fresh chat with the question already written, including a nudge to give you a straight answer rather than a sales pitch.