Skip to content

feat(store)!: a version named on a put must be the next one - #327

Merged
earakely-scale merged 3 commits into
mainfrom
conditional-agent-put
Oct 25, 2026
Merged

earakely-scale merged 3 commits into
mainfrom
conditional-agent-put

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Why

A caller that reads an entity, decides from what it read, and then writes a new version can't tell whether another writer got there first. A version passed to a put doesn't help today, even though it reads like a guard:

  • Agents, tasks, evals, task steps and envs ignore it. The store takes the next free number.
  • Artifacts treat it as a starting point. If it's taken, the store moves to the next free one, so a document can land on a number its objects aren't stored under.

What

A version named on a put is stored as exactly that version, and it must be the id's next one (latest + 1). Anything else raises ConcurrentModificationError and writes nothing.

latest = k, the put names result
nothing (None, or 0 on an artifact) k+1, retried at the next free version if a concurrent writer takes it, as before
k+1 k+1, or ConcurrentModificationError if another write took it first
k, or anything lower ConcurrentModificationError
k+2 or higher ConcurrentModificationError
agent = A2AAgent.get("my-agent")                                    # version 43
A2AAgent.put(id="my-agent", docker_image_artifact=image, version=agent.version + 1)
# lands as 44, or raises ConcurrentModificationError if anything else became 44 first
  • The rule lives in VersionedEntityStore.insert_next, which VersionedEntityStore.put and ArtifactStore.put_document both use. It reads the latest version, then inserts. The (id, version) unique index settles two writers that both read k.
  • The error is the existing ConcurrentModificationError, the one update_metadata raises on a compare-and-set miss.

Artifact helpers

Every helper that reserved a number with next_version and wrote its document with it now leaves the document's version to the store. A concurrent writer moves the document to the next free version, as before, instead of failing it.

  • Only the document carried the number: DockerImageArtifact.put_tar, put_ref and put_context; FileArtifact.put_at and put_existing; the skill, CLI and file-artifact-universe puts; and the snapshot and collected-artifact registrations. They no longer reserve one.
  • The number keys an object or names an image tag: FileArtifact.put and put_bytes (object key), and DockerImageArtifact.put and put_from_github (:vN tag). They still reserve it for the key or tag, while the document takes the next free version. That's the same number unless a concurrent write took it.
  • ArtifactStore.check_writable(id) keeps the id check next_version made before an upload. FileArtifact.put_at calls it, since it no longer reserves a version.

So no helper in this repository names a version. The rule applies only to versions a caller passes.

Breaking

A caller that passed a version other than the next one used to get the next free version. Now it gets ConcurrentModificationError. Nothing in this repository does that. plugin-api reports no break to the signatures; the ! marks the behavior change.

Tests

  • Conformance suite, which every document-store backend runs:

    • versioned_entity_store_put_at_a_version: the next version lands. Taken, latest and past-the-next versions write nothing, and an unnamed put allocates.
    • racing_puts_at_one_version_have_exactly_one_winner: eight writers name the next version. One lands and seven are refused.
    • Both pass on sqlite, DynamoDB and the routing store. Its local-run mode skips them, like the existing versioned case.
  • test_store_put_document.py, rewritten for the rule:

    • a named version other than the next writes nothing;
    • a named version another write takes first is refused;
    • an allocated version another write takes first is reallocated, including past several;
    • retries run out.

    It also covers a FileArtifact.put_bytes whose version another writer publishes during the upload, which is still published.

  • put_version_test.py: A2AAgent.put with versions.

  • Full unit tier: 10,402 passed, 63 skipped. ruff is clean, and plugin-api reports no break.

Docs

The docs should say how version behaves on a put where they describe registering artifacts and A2A agents.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; the file helpers now preserve automatic retries.

Summary

Caller-supplied versions must now be the next version. Automatic writes still allocate a free version and retry collisions.

  • A named version can be written only when it is the next one.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Write artifact document] --> B{Version supplied?}
  B -->|Yes| C[Require the next version]
  C --> D{Insert succeeds?}
  D -->|No| E[Raise ConcurrentModificationError]
  B -->|No| F[Allocate a free version]
  F --> G{Insert succeeds?}
  G -->|Collision| F
  G -->|Yes| H[Return saved version and object_url]
  D -->|Yes| H
Loading

Reviews (3) · Last reviewed commit: "fix(store): FileArtifact.put and put_byt..." · Reviewed by Greptile

A caller that reads an entity, decides from what it read and then writes
a new version can't tell whether another writer got there first: versions
only go up, so its write lands on top and buries the other one.

VersionedEntityStore.put, A2AAgentStore.put_document and A2AAgent.put take
expected_version (0 for an id with no versions). The put then lands on
exactly expected_version + 1, with no retry, and the (id, version) unique
index refuses it with ConcurrentModificationError if another write took
that version first. Without the argument the put is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@earakely-scale
earakely-scale requested a review from a team as a code owner October 24, 2026 18:15
Replaces expected_version with the version an entity already carries. A put
that names a version is stored as exactly that one, which must be the id's
next (latest + 1): taken, the latest itself or past the next, it raises
ConcurrentModificationError and writes nothing, as when another write takes
it first. A put that names none takes the next free version, as before.

Agents, tasks, evals, task steps and envs ignored a named version until now;
artifacts used it as a starting point and retried at the next free one, so
a document could land on a number its objects weren't stored under. The
artifact helpers whose number only reaches the document now name none and
are allocated one, keeping their race behavior; the ones that bake it into
an object key or image tag keep naming it. FileArtifact.put_at checks the id
before uploading, through ArtifactStore.check_writable, now that it no
longer reserves a version.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@earakely-scale earakely-scale changed the title feat(store): let a versioned put require the version it replaces feat(store)!: a version named on a put must be the next one Oct 25, 2026
Comment thread src/agent_env/artifact/store.py
…r takes their version

They key the object by a version they reserve and wrote that version on the
document too, so a writer publishing it during the upload failed the put
under the strict rule. The document now takes the next free version, as the
Docker image helpers' already does: the same one unless a concurrent write
took it first.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@earakely-scale

Copy link
Copy Markdown
Collaborator Author

Chaos run

Setup: a throwaway Linux VM running a 3-node MongoDB 7 replica set in Docker with 2 s elections, driven through MongoDocumentStore with this branch. Three rounds of 15–20 minutes each.

Workload: writer processes on 2–3 ids, mixing four kinds of VersionedEntityStore.put:

  • no version (allocate);
  • the next version after the one just read;
  • a stale version, one that exists;
  • a skipped version, past the next.

A put that failed with a transport error was retried with the same document.

Faults:

  • 450 writer processes SIGKILLed at random;
  • 218 faults at the primary, one every 4–12 s: step-downs, SIGKILL then restart, docker pause freezes, and iptables partitions.

Checked after each round:

  • every id's history is contiguous, with no duplicate (id, version);
  • every put that reported landing is in the store at the version it named;
  • every refused put wrote nothing;
  • no strict put wrote twice.
round 1 round 2 round 3
puts 194,407 277,346 258,681
writer kills / primary faults 102 / 64 191 / 88 157 / 66
violations 0 0 0
  • Stale puts: all 72,780 were refused.
  • Strict retries after a transport error: twice the retry was refused because the put's own first write had committed. A strict put never wrote twice.

Two notes, both about puts that name no version, which this PR doesn't change:

  • Allocating retries can duplicate. An allocating put retried after a transport error wrote twice, 2 times in 730k puts. The store can't tell its own committed first attempt from another writer's.
  • The retry limit can run out. With 48 writers on 2 ids, 56 allocating puts used up their 5 retries and raised DuplicateKeyError.

🤖 Generated with Claude Code

@earakely-scale
earakely-scale merged commit 293783b into main Oct 25, 2026
15 checks passed
@earakely-scale
earakely-scale deleted the conditional-agent-put branch October 25, 2026 22:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant