Skip to content

fix(store): the local object store writes atomically and keeps content types - #9

Merged
earakely-scale merged 8 commits into
mainfrom
edgararakelyan/local-store-atomic-writes
Sep 30, 2026
Merged

earakely-scale merged 8 commits into
mainfrom
edgararakelyan/local-store-atomic-writes

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

This is the first of three PRs that let agents running in local sandboxes exchange files with the local object store over short-lived HTTPS grants. Those grants are what the S3 store already issues. This PR makes the local store's writes safe to serve over HTTP. It doesn't serve anything yet.

  • Atomic writes. put and put_file write into a reserved .agentenv-tmp directory inside the root, then move the file into place:

    • an overwrite renames over whatever is there;
    • a write-once put claims the key with a hard link, so a put that loses a race raises ObjectAlreadyExistsError and leaves the winner in place;
    • on filesystems without hard links, the key's lock (below) makes the existence check and the rename one step, so a racing writer finds the winner. On Windows, the non-overwriting os.rename is the claim.

    Readers never see a partial object, and a crash mid-write leaves nothing at the key.

  • Each key has a lock. It is an exclusive flock for a write, held across placing the bytes and recording the type, and a shared one for a metadata read. So no write lands between another's two steps, and no reader sees one write's type with another's bytes, whatever the filesystem's timestamp resolution.

    • Keys share 64 lock files in the staging directory, so locking leaves no file behind per key.
    • The kernel releases a lock whose holder dies.
    • Windows has no flock and relies on the per-write timestamp below, which NTFS's 100 ns times preserve.
  • Content types are kept. A write's content type is recorded under a reserved .agentenv-meta tree and returned by get_object_metadata, as S3 returns it. The record carries the identity of the file it describes: its size, and a modification time each write makes unique (now, to the millisecond, plus a random remainder), which a rename keeps and so does a copy that keeps modification times. So a type recorded for another write reads back as unknown rather than wrong. An overwrite also drops the old type before its bytes land, so one cut short leaves the type unknown, never the previous one. The default application/octet-stream is not recorded, so a write that names no type still reads back as unknown. That keeps FileArtifact.put_existing and other readers guessing the type from the file name, as they do today. Objects written before this change read back as unknown too.

  • Leftover staged files are swept. A store's first write removes staged files that a dead write left behind, meaning ones untouched for an hour.

  • Reserved names. .agentenv-tmp and .agentenv-meta are refused as keys, like .gitignore, and left out of list / list_at.

Test plan

  • pytest tst/unit packages/agentenv-protocol/tests -n auto: 5635 passed. The new tests cover:
    • kept, default and overwritten content types, and objects written before types were kept;
    • a failed write leaving no object;
    • a write-once put losing a race;
    • the no-hard-link fallback: a failed write leaving no object, a racing put keeping the winner, and a claim that lasts exactly as long as its holder;
    • types surviving a copy that keeps modification times;
    • a type recorded for another write, and an untyped overwrite, reading back as unknown;
    • a metadata read waiting for a write holding the key, an overwrite cut short leaving the type unknown, and the sweep of dead writes' staged files;
    • reserved keys, and listings that skip the store's own files.
  • pytest -m 'not int_test_slow' tst/integration --ignore=tst/integration/env/gateway/gateway_test.py: 141 passed, run with the branch's venv first on PATH for the CLI tests.
  • Chaos testing, with a harness kept outside the repo, on macOS (APFS, and an exFAT disk image) and Linux (Ubuntu 22.04, kernel 6.8: ext4, tmpfs and a vfat loop mount).
    • Churn with random kills. 16 writers and 4 readers ran for 90 s. Writers did write-once puts on contested keys and overwrites, typed and untyped, while one writer was SIGKILLed every 20–200 ms: 409–772 kills per run. Across every filesystem there were zero violations: no torn object read or at rest, at most one winner per write-once key, no wrong type at rest, and no key blocked afterwards.
    • Same-key type races. Two processes overwrote one key at the same instant with equal-size payloads of different types. This targets filesystems whose timestamps are too coarse for the per-write timestamp (exFAT 10 ms, vfat 2 s). Before the key lock, exFAT mislabelled 5 of 4,129 races and vfat 87 of 4,727. With it there were 0 wrong types in 4,891 (exFAT), 6,173 (vfat), 7,018 (APFS), 6,587 (ext4) and 6,989 (tmpfs) races.
    • Disk full. A full exFAT volume made writes fail cleanly with ENOSPC, leaving no partial object.

🤖 Generated with Claude Code

RetriggerConfidence Score: 4/5

The PR does not yet appear safe to merge because a failed overwrite can still lose the saved content type of the unchanged object.

Fix All in CursorFindings

  1. P2 Old type records linger ▶
Fix with agent prompt
### Issue 1
src/agent_env/store/object_store/local_object_store.py:211-212
If removing a saved type record fails after an overwrite, the write succeeds but leaves that record in `.agentenv-tmp`. This store sweeps only on its first write, so a long-lived store will not remove it. Windows does not sweep at all. Repeated cleanup failures can leave old records behind and use disk space.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The local object store now stages writes before placing complete objects at their keys and saves non-default content types with the files they describe. It also keeps its staging and metadata paths out of object keys and listings.

  • Write-once puts keep the first successful claim, including when hard links are unavailable.
  • Metadata reads return a saved type only when its record matches the current file.
  • Listings skip the store’s reserved paths.

Reviews (8) · Last reviewed commit: "fix(store): an overwrite that lands is n..."

…t types

A write now lands whole or not at all. Bytes are staged in a reserved .agentenv-tmp directory inside the root and put in
place with a rename (an overwrite) or a hard link (a write-once put), so a crash or a concurrent reader never sees a
partial object, and a write-once put that loses a race raises ObjectAlreadyExistsError instead of replacing the winner.
Filesystems without hard links fall back to an exclusive create.

A write's content type is recorded in a reserved .agentenv-meta tree and returned by get_object_metadata. The default
type, which a writer that names none gets, reads back as unknown, so readers keep guessing from the name as before.
Both directories are refused as keys and left out of listings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@earakely-scale
earakely-scale requested review from a team and polakamtejas as code owners September 30, 2026 16:54
Comment thread src/agent_env/store/object_store/local_object_store.py Outdated
Comment thread src/agent_env/store/object_store/local_object_store.py Outdated
Comment thread src/agent_env/store/object_store/local_object_store.py Outdated
Comment thread src/agent_env/store/object_store/local_object_store.py
…a write-once put never exposes an empty object

A content type is recorded with the identity of the file it was written for (inode, size, mtime,
which a rename keeps), so a type recorded for another write - a racing overwrite that renamed
first, or an old record an overwrite could not remove - reads back as unknown, never as the wrong
type.

On a filesystem without hard links a write-once put now checks for the key and renames into place,
instead of reserving the key with an empty file that readers could see and a crash could leave.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread src/agent_env/store/object_store/local_object_store.py Outdated
earakely-scale and others added 2 commits September 30, 2026 07:58
…hard links

Where no hard link can claim the key, a write-once put now claims it with a marker created
exclusively in the staging directory, held only from the existence check to the rename. A racing
writer waits for the marker to go and then finds the winner's object; a marker a crash left behind
is taken over once it is ten seconds old.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… a copy that keeps modification times

Without hard links, a write-once put's claim marker now names its owner's pid and is taken over only
once that process has exited, not after ten seconds, so a slow live writer never loses it. On
Windows, where rename refuses an existing target, the rename itself is the claim.

A recorded content type is now paired with the file's size and modification time, and each write
stamps its staged file with a modification time no other write shares (now, to the millisecond,
plus a random remainder). Racing writes still never pair bytes with another write's type, and a copy
of the store that keeps modification times keeps its types, where the inode it no longer paired
with would not survive one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread src/agent_env/store/object_store/local_object_store.py Outdated
… its holder

A write-once put on a filesystem without hard links now claims its key with an exclusive flock on
a file in the staging directory rather than a marker naming a pid. The kernel drops the lock when
its holder exits, so a crashed writer never blocks the key and a reused pid can never pass for a
live one; a live holder is waited for however long it takes. The file stays in place so every
writer locks the same one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread src/agent_env/store/object_store/local_object_store.py Outdated
Comment thread tst/unit/store/local_object_store_test.py Outdated
@scaleapi scaleapi deleted a comment from linear-code Bot Sep 30, 2026
…k, and staged files of dead writes are swept

Chaos testing on exFAT, which keeps modification times to 10 ms, showed two same-size overwrites of
one key could leave one's type recorded against the other's bytes (5 in 4,129 races): the per-write
time stamp that tells writes apart is rounded away there.

A write now holds the key's lock (an exclusive flock) across placing its bytes and recording its
type, and a metadata read holds it shared, so no write lands between another's two steps and no
reader sees one write's type with another's bytes, whatever the filesystem's time resolution. An
overwrite drops the old type first, so one cut short leaves the type unknown, never wrong. Keys share
64 lock files, which also replace the per-key claim files the no-hard-link path used; Windows, which
has no flock, keeps relying on the stamp, which NTFS's 100 ns times preserve.

A store's first write also removes files staged by writes that died (untouched for an hour), which
crashes left behind in .agentenv-tmp.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread src/agent_env/store/object_store/local_object_store.py Outdated
Comment thread src/agent_env/store/object_store/local_object_store.py Outdated
Comment thread src/agent_env/store/object_store/local_object_store.py
…es any file a live write holds

An overwrite now sets the old type record aside while its bytes land and puts it back if they do
not, so a rename that fails keeps the old object's type; one cut short after its bytes land still
reads as unknown, never as the old type.

Each staged file is held under an exclusive flock by its writer for as long as the write lives, and
the sweep removes only staged files no process holds (and none younger than a minute, which may not
be held yet), so a write paused for any length of time never loses its file. Windows, which has no
flock, is not swept.

Keys now share 256 lock files rather than 64, so fewer unrelated keys wait on one another, and the
tests that hold a lock with flock skip on Windows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread src/agent_env/store/object_store/local_object_store.py Outdated
Comment thread src/agent_env/store/object_store/local_object_store.py Outdated
…a staged file that cannot be locked is cleaned up

Removing the set-aside type record after an overwrite's bytes land, and refreshing its time when it
is set aside, are now best effort: a failure there leaves a file the sweep removes, rather than
reporting a completed write as failed. A staged file whose lock cannot be taken is closed and
removed like any other.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment on lines +211 to +212
with contextlib.suppress(OSError): # the bytes have landed; a sweep takes what is left
aside.unlink(missing_ok=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Old type records linger

If removing a saved type record fails after an overwrite, the write succeeds but leaves that record in .agentenv-tmp. This store sweeps only on its first write, so a long-lived store will not remove it. Windows does not sweep at all. Repeated cleanup failures can leave old records behind and use disk space.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/store/object_store/local_object_store.py
Line: 211-212

Comment:
**Old type records linger**

If removing a saved type record fails after an overwrite, the write succeeds but leaves that record in `.agentenv-tmp`. This store sweeps only on its first write, so a long-lived store will not remove it. Windows does not sweep at all. Repeated cleanup failures can leave old records behind and use disk space.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is. A record is left behind only when deleting a file the store itself just renamed into its own staging directory fails. That means the filesystem is refusing deletes in a directory the store otherwise writes freely. The cost is one small hidden file per failure, and it never makes a type wrong. The next store instance on a POSIX filesystem sweeps it. On Windows it stays until removed by hand. Sweeping on every write, or retrying in the background, would add cost to the common path for a fault that already breaks writes elsewhere. On the summary line: an overwrite whose rename fails puts the old type back. The type is lost (read as unknown, never wrong) only if the set-aside record also vanished in the meantime, which requires another process's sweep while this commit was paused for over a minute.

@earakely-scale
earakely-scale merged commit c4b4363 into main Sep 30, 2026
13 checks passed
@earakely-scale
earakely-scale deleted the edgararakelyan/local-store-atomic-writes branch September 30, 2026 20:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant