fix(previews): atomic writes, single-flight regeneration, and diagnosable failures for image previews - #5507
Conversation
…able Root-cause fixes for silently broken image previews (griptape-ai/internal#240): a stale preview arrived as several concurrent editor requests, each regenerated the same .webp with an in-place truncate+write the static server could stream mid-rewrite, and every failure was logged at DEBUG. - WriteFileRequest OVERWRITE now writes via a private sibling temp file and renames into place (atomic_write_bytes: fsync + mode preservation + best-effort parent-dir sync); readers never observe a torn file, a failed write leaves the previous file intact, ENOSPC maps to DISK_FULL with an artist-readable message, Windows rename contention maps to FILE_LOCKED via winerror. portalocker unchanged for append and exclusive-create. - Preview lookup/regeneration is single-flight per canonicalized source path via a new KeyedMutex (threading.Lock per key acquired through to_thread; asyncio.Lock cannot survive the sync-dispatch loop-per-call/arbitrary-thread reality). - Staleness compares size exactly and mtime within a 2s tolerance (file_utils.mtimes_match) instead of exact float equality that branded synced/copied files permanently stale. - Preview generation verifies the source didn't change mid-render and retries once; still-changing sources record a deliberately stale stat so the next request heals. - Preview fallback now logs at WARNING and returns a human-readable preview_failure_reason (presence-only contract) so editors can say why a preview is missing; ?t= cache-buster is millisecond-resolution. - The original macro template flows into preview metadata (source_macro_path) instead of a machine-specific absolute path. - GetFileInfo takes one stat() for size+mtime so the pair can't straddle a concurrent replace. Refs: review iteration 1/5 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ough in atomic overwrites Two behavior regressions the atomic OVERWRITE path introduced against the old in-place open(mode="w") write, caught in review: - str content is translated "\n" -> os.linesep before encoding, so text saves on Windows keep CRLF instead of silently switching to bare LF; pinned by a test that patches os.linesep. - atomic_write_bytes resolves a symlinked destination before the temp+rename, so the link survives and its target receives the new content (a naive rename replaced the link itself and left the target stale); dangling links create their target, as open() would. Pinned by two symlink tests. Refs: review iteration 2/5 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… workers Waiters previously blocked inside to_thread on the event loop's bounded default executor while the lock HOLDER needed a worker from that same pool to run its critical section (get_artifact_metadata under the single-flight lock). Enough same-key waiters — exactly the editor storm single-flight exists to tame — exhausted the pool and starved the holder into a hard deadlock. - Waiters now use a non-blocking acquire plus a 20ms async sleep: no thread is ever parked, and cancellation is structurally safe because the lock is only taken by a synchronous successful acquire with no await before the try/finally that releases it (the acquisition-flag machinery is gone with the hazard it existed for). - ASYNC110 suppressed with justification: the suggested asyncio.Event is loop-bound, which is the precise constraint this class exists to avoid. - Document on WriteFileRequest that OVERWRITE sits deliberately outside the file-lock protocol: concurrent overwrites are last-rename-wins, and an append racing an overwrite has no coherent outcome with or without locking. Refs: review iteration 3/5 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
⏳ GriptapeOps is working… on @SavagePencil's request |
…ps, empty except Windows CI: - The ffmpeg preview promote now retries a transiently denied rename (two generations racing one destination, or a reader holding the old file, deny the replace for microseconds on Windows). A persistent denial still raises. Pinned by a cross-platform test that forces two denials before success. This flake predates the branch; the schema guard test tripped it here. - test_preserves_existing_file_mode skips on win32: POSIX permission bits are not representable there (chmod honors only the read-only bit). Code quality bot: - _fsync_directory_best_effort logs its suppressed OSErrors at DEBUG instead of silently passing. Patch coverage (89% -> the previously uncovered error branches): - KeyedMutex: cancelled waiter checks in and never enters the critical section (covers the cancellation branch). - file_utils: unopenable directory and failing fsync are swallowed (covers both best-effort branches); keyed_mutex and file_utils now at 100%. - ArtifactManager: generator exception returns a failure result; a source deleted mid-generation keeps the generated preview (covers the can't-verify break). - StaticFilesManager: an exception escaping preview generation serves the original with preview_failure_reason set. - OSManager: ENCODING_ERROR, IS_DIRECTORY, and winerror-based FILE_LOCKED mappings each pinned by a test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
⏳ GriptapeOps is working… on @SavagePencil's request
Review posted at 94d1374. |
There was a problem hiding this comment.
Aimed at the right place: the atomicity fix sits on the write primitive and the single-flight sits on the lookup, not downstream of either.
1 correctness finding, 3 advisory:
artifact_manager.py:581— the mid-generation verify inherits the 2s mtime tolerance, so a same-size rewrite reads as unchangedstatic_files_manager.py:249— advisory: WARNING on every fallback re-adds the noise #4443 removedfile_utils.py:84and:39— advisory: unhidden temp siblings; import-time umask mutationos_manager.py:3192— advisory: diff-relative comments
- The mid-generation verify compares mtime exactly instead of borrowing the staleness check's 2s tolerance: both stats come from one filesystem seconds apart, where any difference is a real write, and a tolerant compare made a same-size in-place rewrite (re-rendered frame, cp over the file) invisible here — the one place it can be caught, since the recorded-metadata staleness check is blind to it by design. Pinned by a same-size rewrite test. - The preview fallback logs at DEBUG when the source itself is missing (routine, unactionable, fires once per component) and WARNING for everything else; the distinction rides a new structured source_file_missing field on GetPreviewForArtifactResultFailure rather than string-matching, and preview_failure_reason reaches the editor either way. - atomic_write_bytes scratch files are now .gtn-write-partial-<uuid> (hidden, self-identifying if a power loss strands one) and are created with O_CREAT|O_EXCL and an explicit 0o666 so the kernel applies the process umask — removing the import-time umask snapshot and its single-threaded-import assumption entirely. - Reworded diff-relative comments to state invariants rather than describe replaced code; replaced two bare awaits in tests with gather(return_exceptions=True) and wait_for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@griptapeops re-review |
|
✅ GriptapeOps finished @SavagePencil's request in 32m 12s
Review posted at 30d6ef0. |
There was a problem hiding this comment.
All 5 findings from the last round are fixed and resolved; the mtime fix is the right one and its test genuinely exercises the mtime arm (reverting to mtimes_match locally fails it).
The new scratch-file naming introduced 2 correctness findings, both open:
file_utils.py:81— keepingpath.suffixmakes the scratch file match an extension glob over the destination directory; a mid-write poll sees a duplicate model-download rowfile_utils.py:83— the scratch file holds the full new content at0o666 & ~umaskbefore the chmod tightens it, so a 0600 destination's replacement is briefly world-readable
Also still open: test_file_utils.py:654 on the LLMisms thread.
Third review round, both on atomic_write_bytes's scratch file: - The scratch name now ends in a fixed ".partial" after the destination's suffix. pathlib.glob matches dotfiles, so a scratch ending in the destination's own extension read as a second record to extension globs (the model-download poller globs *.json over a directory this function writes into every second). Pinned by a mid-write glob spy test. - The scratch is created at a 0600 floor and aligned to the destination's exact mode while still EMPTY, replacing the create-at-0o666-then-chmod sequence that exposed a strict destination's full payload (a 0600 .env of API keys) at the umask default for the duration of the write. New destinations keep the kernel-masked 0o666 default. Pinned by a mid-write mode spy test. - Reworded the one remaining stale docstring that described mkstemp behavior the code no longer has. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@griptape-ops re-review |
|
@griptapeops re-review |
|
✅ GriptapeOps finished @SavagePencil's request in 9m 46s Re-review (cycle 8):
Review posted at 7dadbbe. |
There was a problem hiding this comment.
All 3 open findings fixed and resolved, and 7dadbbe is clean against the rubric. Verified atomic_write_bytes and its tests against the current code: the scratch name can no longer match an extension glob, and the mode observed mid-write now equals the destination's own in every case I staged — strict 0600, looser 0664, tight umask, setgid, and brand-new (identical to open(mode="w")). Also re-checked the paths the earlier stat() could have disturbed: read-only destination, directory destination, symlink write-through, and no stranded scratch on failure.
make check clean on the changed files; tests/unit/utils/test_file_utils.py 49 passed. The two test_git_utils failures in my run are my clone's tokenized remote and reproduce on main.
Close the last two uncovered patch lines: an extensionless file and an unsupported format both serve the original with no failure reason, the former exercised through the full download-URL handler so the serving-full-image branch is covered too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
I'm a little weary of the complexity this PR adds. Breaking down the root cause a bit:
A stale preview arrives as several concurrent editor requests (one per component rendering the artifact).
With no single-flight guard, each judged the preview stale and regenerated it; the PIL generators wrote via WriteFileRequest(OVERWRITE), an in-place open(mode="w") truncate+write that a concurrent browser GET could stream mid-rewrite.
So the first request would start writing the preview file. Meanwhile, a second request would generate a preview for a partially written preview file.
Am I understanding this correctly?
If so: seems like we could cut out this whole class of problem if we make the preview's file name the cache key. i.e.
def preview_key(source: Path, gen: str, params: dict, schema: str) -> str:
st = source.stat()
return blake2b(
f"{source}|{st.st_size}|{st.st_mtime_ns}|{gen}|{canonical_json(params)}|{schema}".encode(),
digest_size=8,
).hexdigest()
# cat.png → cat.png-a3f29b41.webpThen, resolving looks something like:
def resolve_preview_url(source: Path) -> str:
key = preview_key(source, *current_settings())
return f"/previews/{rel(source)}/{source.name}-{key}.webp"What do you think?
There was a problem hiding this comment.
Close on the mechanics, one correction: the second request doesn't read a partial preview — both requests regenerate from the source, and the tear happens because each writes the .webp in place while the browser is streaming that same file from the static server. The reader at risk is outside the engine entirely, which is why nothing engine-side ever noticed.
On the proposal: I think immutable cache entries are the right north star, and I'd genuinely like to get there. Three things the sketch needs solved before it replaces what's here, though:
- It still needs the atomic write and single-flight underneath. Every consumer of one source computes the same new key, so N concurrent requests on a stale source all write the same new filename — a browser fetching request A's URL while request B is mid-write to that name is the same torn read on a new name. Content-addressing makes old URLs immortal; it doesn't make new-file creation safe.
st_mtime_nsin the key makes the cache machine-local. The previews dir lives inside the project, so the moment a workspace syncs, every mtime shifts, every key changes, every collaborator regenerates everything — and the old hash-named files orphan on every machine. That's the GC story this design owes before it ships (today's overwrite-in-place layout is self-cleaning by construction).- Path-in-key means no dedupe either — the same bytes at two paths (or via a symlink) get independent previews. Fine, but worth being explicit that this is a location+stat cache, not a file cache; a true content hash would dedupe but costs reading the full source (or caching content-hash-by-stat, which reinvents the sidecar).
There's a cheaper cut of your insight that dodges all three: keep the stable filename, put the key in the URL — a deterministic stat-derived ?v=<key> instead of the mint-time ?t=. Same content → same URL → browser cache hits (better than today, where every mint busts); changed content → new URL; combined with this PR's atomic rename, the file is never torn and the URL is never stale. No renamed files, no GC, no template migration.
Proposal: land this PR as the bleeding-stopper (Jason's bug is live in 0.100), and I'll file the content-addressed design as a follow-up issue capturing your sketch plus the GC/sync/dedupe questions — with ?v= as the incremental step we could take immediately. Worth saying: about half this PR is orthogonal to the naming question and needed under either design (atomic OVERWRITE as a general primitive, preview_failure_reason/log levels, macro portability, DISK_FULL mapping).
|
Do we want to expand this to include videos as well (is there a media-generic implementation possible?): #5579 |
|
@cjkindel Good timing — most of #5579 is already in this PR, and the rest is a clean extraction:
Related: #5607 now tracks the larger immutable-preview-cache design from @collindutter's thread, which would subsume promote collisions entirely for the serve case (old URLs keep serving old bytes; nothing rewrites a served name). |
collindutter
left a comment
There was a problem hiding this comment.
Couple code quality comments, will defer to you. As mentioned in my other comment, I have some concerns on the low-level complexity this adds around our file writing operations. I'm trusting that you've proven through testing that this complexity is warranted to solve the root issue.
| except PermissionError: | ||
| if attempt == replace_attempts: | ||
| raise | ||
| await anyio.sleep(0.05) |
There was a problem hiding this comment.
An arbitrary sleep feels a bit fragile. At the very least we can extract this retry logic to a dedicated module? Don't want to repeat this throughout the engine.
There was a problem hiding this comment.
Agreed on both counts — done in #5616: the retry moved to a shared tenacity-based promote_scratch_file/_async in file_utils (tenacity was already the idiom in http_utils, so this extends it rather than adding a second pattern), and both the ffmpeg promote and atomic_write_bytes now go through it. The PR body carries a full audit of every retry-shaped site in src/ — the only remaining hand-rolled loop is _load_status_file, left deliberately because its per-cause budgets (#5471) would need a custom stateful stop bigger than the loop.
| generation_attempt = 1 | ||
| while True: | ||
| try: | ||
| preview_file_names = await provider_instance.attempt_generate_preview( |
There was a problem hiding this comment.
See above comment, seems like we're re-implementing retry logic quite a bit. Can we extract and/or use something like tenacity?
There was a problem hiding this comment.
Agreed on both counts — done in #5616: the retry moved to a shared tenacity-based promote_scratch_file/_async in file_utils (tenacity was already the idiom in http_utils, so this extends it rather than adding a second pattern), and both the ffmpeg promote and atomic_write_bytes now go through it. The PR body carries a full audit of every retry-shaped site in src/ — the only remaining hand-rolled loop is _load_status_file, left deliberately because its per-cause budgets (#5471) would need a custom stateful stop bigger than the loop.
Fixes the silent broken-image-preview failure reported in griptape-ai/internal#240.
What changes for users
.webpconcurrently, truncating it in place while the static server streams it to the browser; the browser caches the torn body under a same-second?t=URL and the image is permanently broken, with every failure logged at DEBUGpreview_failure_reasonso the editor can tell the artist why. Engine-side: real failures (provider error, failed write) log at WARNING; a vanished source — routine, unactionable, once per component — stays at DEBUG, carried by a structuredsource_file_missingfield rather than message matchingDO_NOT_GENERATE, with a valid.webpsitting on diskcpover the file)DISK_FULLfailure with an artist-readable message; the previous file is untouched.env) is rewrittensource_macro_pathrecorded this machine's resolved absolute path (/Users/jason/...){inputs}/images/...)Root cause
A stale preview arrives as several concurrent editor requests (one per component rendering the artifact). With no single-flight guard, each judged the preview stale and regenerated it; the PIL generators wrote via
WriteFileRequest(OVERWRITE), an in-placeopen(mode="w")truncate+write that a concurrent browser GET could stream mid-rewrite. The 1-second?t=cache-buster made same-second URLs identical strings, so the browser cached the torn body — and the editor's<img>has noonError, making one bad load permanent. #5445 fixed this exact race for video (and documented it) but left the image generators on the torn path; the same commit's schema bump then invalidated every preview at once, maximizing the collision window. Preview failures have logged at DEBUG since #4443.Changes
WriteFileRequestOVERWRITE goes through a private sibling scratch file + fsync + rename (atomic_write_bytes). The scratch is named.gtn-write-partial-<uuid><ext>.partial: dot-prefixed (hidden from artists), self-identifying if a power loss strands one, and ending in a fixed.partialso it can never match an extension glob over the directory (pathlib.globmatches dotfiles; pollers glob*.jsonin directories this writes into). Mode handling: existing destinations keep their exact permissions with no looser-mode exposure window; new files get the kernel-applied umask default. Also: symlink write-through, text-mode newline translation, best-effort parent-dir fsync, Windows rename-contention →FILE_LOCKEDviawinerror,ENOSPC→DISK_FULL. portalocker is unchanged for append and exclusive-create; OVERWRITE's position outside the lock protocol is documented on the request.KeyedMutex(per-keythreading.Lock, non-blocking acquire + async sleep polling) serializes preview lookup+regeneration per canonicalized source path.asyncio.Lockis unusable here: synchandle_requestdrives async handlers on a transient event loop per call from arbitrary threads. Polling (rather than parkingto_threadworkers) avoids deadlocking the bounded executor the lock holder itself needs.file_utils.mtimes_match(2s window) replaces exact float mtime equality for the recorded-metadata staleness check; size still compared exactly.GetFileInfotakes onestat()so size/mtime can't straddle a concurrent replace.CreateStaticFileDownloadUrlFromPathResultSuccess.preview_failure_reason(additive, human-readable, presence-only contract) gives the editor something to show; fallback logging is WARNING for real failures and DEBUG for the routine missing-source case (structuredsource_file_missingon the failure payload);?t=is millisecond-resolution so same-second URLs can't share a cached bad response.{inputs}/…template flows through the download-URL path into preview metadata instead of a machine-specific absolute path.No editor dependency: all changes are engine-internal or additive; current editors ignore the new fields.
Verification
make checkclean; full unit suite passes (the onetest_config_project_layer_dropoutfailure pre-exists onmain).preview_failure_reason+ log-level split, macro preservation, ffmpeg promote retry. Patch coverage 100%.Follow-ups (separate repos)
<img>onError/retry, surfacepreview_failure_reason, request dedupe — issue to be filed.select_from_grid.py(branch ready, ships separately).Path()round-trip in the degenerate-macro fallback.🤖 Generated with Claude Code