Skip to content

feat: fold Codex-compressed rollouts, fold on a tight disk, and fix mount descriptor exhaustion - #3

Open
jstar0 wants to merge 8 commits into
mainfrom
feat/native-zst-folding
Open

jstar0 wants to merge 8 commits into
mainfrom
feat/native-zst-folding

Conversation

@jstar0

@jstar0 jstar0 commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Codex now compresses every rollout that has been idle for seven days into a sibling rollout-*.jsonl.zst and deletes the .jsonl. That pulled sessions out of the store one by one. On a real machine, the saved figure fell from 69% to 38%, and 3,623 .zst files held 24.2 GB that CodexFold never touched. This branch has the service fold those files too, keeps folding working when the disk is nearly full, and fixes three production faults found while running it.

Folding Codex .zst files

Each settled .zst is folded in place:

  • Byte-exact proof first. libzstd 1.5.7 (the version Codex links, via cgo github.com/DataDog/zstd) must re-encode the decoded content to the file's exact bytes.
  • Space is released without changing the file. The data blocks are hole-punched and a marker attribute is set. Path, inode, size, times and attributes stay.
  • The mount serves the original bytes. It re-encodes them through one shared encoder per file, refuses writes, and returns EIO rather than empty blocks if it cannot.
  • Only when it pays. A dry run prices each file first, and a file is folded only when that costs under 80% of its .zst.
  • fs native-zst status and fs native-zst restore --all --apply are added. Run the restore before installing a build without this support.

Folding on a tight disk

  • Streamed .zst folds. A .zst is folded straight from its decoder instead of staging its decoded content.
  • Exact dry-run pricing. When space is short, the planner and fold --apply measure the real cost instead of assuming three times the raw size. Prices are cached in enrollment/prices.json.
  • Selective pack compaction. A pack build rewrites only the old pack files worth rewriting, within free space, and hard-links the rest.
  • Better pack compression. Pack builds entropy-code small blocks, which saves about 2.1 GB on a real 14.8 GB pack.

Production fixes

  • "Too many open files" on every mount read.
    • Cause: the FSKit extension runs at launchd's 256-descriptor limit. A folded file was handed a shared read window that primed nine prefetch channels, about 14 descriptors per open file.
    • Fix: folded files no longer get that window. The extension raises its descriptor limit at start and caps prefetch channels at 48 across all files. App build is bumped to 117.
  • The same .zst re-punched every 30 s. APFS kept a few extents of one punched file allocated, so it was proved and punched again on every pass. It is now left alone until it changes.
  • Automatic enrollment stalled, and service start rolled back.
    • Cause: a deleted managed session's native snapshot is hidden by the mount on purpose, but the readiness probe waited for it. Enrollment reported mount-warming for 1,626 sessions and enrolled nothing.
    • Fix: the probe skips deletion-tombstone routes.
    • A failed native FSKit start also left the mount attached with no backend. It now reclaims the mount the way an explicit stop does.

Validation

  • go vet, Go tests for cli, mountfs, nativezst, vfs, enroll, pack and codexzstd, and Windows and Linux cross-builds.
  • Swift ReadCacheTests, and a type check of the extension.
  • Deployed on the maintainer's Mac and checked through the real mount:
    • every folded file reads back byte-identical;
    • 1,200 files held open at once with no error (1,438 extension descriptors);
    • 20 concurrent folded reads peaked at 64 descriptors.

Windows

  • The new .zst code compiles for Windows, but Windows does not fold .zst files yet: in-place folding needs hole punching and marker attributes.
  • Windows CI covers compilation and adapter tests on this branch. It has not been run against a real WinFsp drive.

Codex now compresses every rollout idle for seven days to a sibling
rollout-*.jsonl.zst and deletes the .jsonl. On a real machine that took
1,288 managed sessions out of the store, dropped the saved figure from 69%
to 38%, and left 3,623 .zst files (24.2 GB) that CodexFold never touched.

The service now folds each settled .zst in place. libzstd 1.5.7, the
version Codex links (via github.com/DataDog/zstd), re-encodes the decoded
content to the file's exact bytes; that is proven per file, the content is
folded into the store, a marker attribute binds the file to its manifest,
and its data blocks are released with a hole punch. Path, inode, size,
mode and times are kept. The mount re-encodes on read, refuses writes,
hides the marker and fails with EIO rather than serve empty blocks. All
3,623 real files reproduce byte for byte. Unreferenced manifests are
released after two scans fifteen minutes apart; a file that lost its
marker is found by inode and re-marked. `fs native-zst restore` writes the
original bytes back before a downgrade.

Folding no longer needs room for its pessimistic peak first:
- .zst content is folded straight from the decoder, priced by a dry run
  when space is short, and a small-peak fold that frees four times as much
  may run down to 1 GiB free.
- Pack builds rewrite only the old pack files worth it that fit the free
  space and hard-link the rest; dropped objects no longer force copying the
  whole pack, and dead pack bytes trigger a later build once there is room.
- The enrollment planner asks for a cached dry-run price when the 3x raw
  estimate does not fit; `fold --apply` measures the same way, and pack
  estimates price loose objects by their compressed size.

Also: deleted sessions now trigger a compacting build (maintenance only
looked at top-level manifests newer than the pack), pack blocks use
SpeedBetterCompression and stay raw only when compression does not help,
and the storage figures count the native session tree.
A rollout that shares nothing with the store folds larger than Codex's own
zstd: content-defined chunks compress independently, so a real 205 MB
rollout needed 83.5 MB against its 68.6 MB .zst. Every file is now priced
by a dry run first and folded only below 80% of its .zst; the savings come
from content shared with other sessions, which per-file zstd cannot see.

Loose objects use the pack builder's compression level through one shared
encoder; SpeedDefault left entropy-only text nearly uncompressed, which also
overpriced the dry run.
The FSKit extension prefetches a file through several handles at once,
each taking whichever block is next. With an encoder per handle, every
handle re-encoded the frame from its start and a read behind a handle's
window restarted it, so a folded 1.4 GB rollout read through the mount at
about 0.5 MB/s.

All readers of a file now share one encoder whose output is spilled to an
unlinked temporary file as it is produced, so each byte is encoded once in
whatever order and through whatever handle it is read. The encoder only
runs as far as reads ask plus 8 MiB, so listing threads still reads just
their heads. A stream that produced little is dropped at close; a large one
is kept two minutes for a reopen. Proofs and restores read sequentially and
use a direct stream that spills nothing. Four handles reading a real 325
MiB folded file out of order now take 5.3 s.
The FSKit extension runs with launchd's 256-descriptor soft limit. A
folded .zst has no native descriptor, so the daemon handed it a shared
read window, and a window makes the extension prime up to nine prefetch
channels on open, each with its own socket and window descriptor: about
fourteen descriptors per open file. A few concurrent readers (Codex
resuming a thread while another tool scanned rollouts) pushed the
extension past the limit and every mount read failed with EMFILE.

The daemon no longer offers a shared window for a folded handle, so
opening one primes nothing. The extension raises its descriptor soft
limit at start and bounds prefetch channels across all open files to 48;
a file that gets none is still read through its own handle.

Also stop re-proving the same .zst every pass: APFS can keep some extents
of a hole-punched file allocated, so a fold that frees nothing more is
remembered by size and mtime and left alone until the file changes, and
only bytes actually released are counted as freed.
When a managed session is deleted, its native snapshot can stay at its
route, and the mount deliberately hides it. The canonical readiness probe
required every native file to be visible in the mount, so one such
leftover failed it permanently:

- automatic enrollment saw "mount warming" on every cycle and enrolled
  nothing new;
- `fs service start` timed out on its health gate and rolled back.

The probe now skips the routes of deletion tombstones, the same set the
mount hides at startup.

A failed native FSKit start also left the supervisor's mount attached
after stopping both jobs, so every read in it failed with no backend. It
now reclaims the mount the way an explicit stop does.

Bump the FSKit App build to 117 for the descriptor-limit extension fix.
Linux accepts only namespaced extended attribute names, so the fixture's
com.example.kept failed there with ENOTSUP; use user.example.kept. Move
the xattr calls behind platform test helpers so the package's tests
compile for Windows, where these folds are not supported and skip.
ext4 and XFS count a block of extended attributes in st_blocks, and the
fold marker itself is one, so a fully punched file still reports one
block there. Treating that as unreleased made every service start re-prove
each folded file and kept a file that lost its marker from being
re-marked. A file now counts as released when its allocation is within
one filesystem block on Linux; APFS does not count attributes, so macOS
still requires zero.
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