fix: treat missing tarball objects as permanent and defer superseded deletes until commit - #356
Merged
Conversation
…deletes until commit
Contributor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Automation "run now" failing with an opaque
Internal errorthat repeated on every schedule tick because the automation's tarball object was missing from MinIO while itsTarballUploadrow survived. This lands fix (2) from the issue in its narrowed form and fix (1) at both delete-before-commit sites; fix (3) (MinIO durability) is infra-side and not part of this repo.Supersedes the draft #336, which caught bare
FileNotFoundErrorand is marked "do not merge as-is" for the over-trigger described below.The bug
Two independent defects, both verified against
main:_download_internal_tarballalready raisesTarballNotFoundErrorwhen the upload row is missing, but the subsequentstore.read(...)was unguarded. A missing object surfaced as plainFileNotFoundError, sailed past theexcept PermanentDispatchErrorhandler in dispatch step 4, and landed in_execute_run_safe: the run was marked FAILED with the literal string"Internal error"and the automation stayed enabled, re-failing forever (12 and 16 consecutive failures for the two affected C24 automations).Catching bare
FileNotFoundErroris not a safe fix:s3.py::_handle_client_errormaps everyClientErrortoFileNotFoundError—NoSuchBucket,AccessDenied, and a catch-all covering 5xx /SlowDown/ expired credentials. On C24's OOM-crash-looping MinIO, a read landing during a restart would permanently disable a healthy automation.regenerate_preset_prompt_tarball(prompt edits) and the git-sync import's_delete_superseded_uploaddestroyed the old storage object and then soft-deleted its row inside a transaction that commits later. Any post-delete rollback reverted the soft-delete and thetarball_pathupdate but not the storage delete, leaving a liveCOMPLETEDupload row and an automation pointing at an object that no longer exists — exactly the failure state in (1). A related latent bug:except FileNotFoundError: return Nonearound the regeneration read meant a transient storage error during a prompt edit returned HTTP 200 with thepromptcolumn updated while the tarball silently kept the old baked prompt.The fix
Storage layer — new
ObjectNotFoundError(FileNotFoundError), raised only at confirmed-absence sites: the S3404/NoSuchKeybranch, the local store's missing file, and both GCSNotFoundhandlers. All other storage errors keep raising plainFileNotFoundError, and the seven existingexcept FileNotFoundErrorhandlers keep working unchanged via the parent class.Dispatcher — the tarball read catches
ObjectNotFoundErrorand re-raises it asTarballNotFoundError(already aPermanentDispatchError): the run fails with anerror_detailnaming the missing storage path and the remedy, and the automation is disabled instead of retrying a permanently unreadable object. Transient storage errors keep today's behavior (fail, stay enabled, retry).Prompt edits — the superseded row is soft-deleted in-transaction; the object delete moves to a FastAPI background task.
update_automationdeclaresDepends(get_session, scope="function"): with the default request scope, background tasks run before the dependency-teardown commit (verified against FastAPI 0.136 / Starlette sources), which would reintroduce the bug. Function scope makes the order handler → commit → response → delete, and a commit failure means the delete never runs. The regeneration guard is narrowed toObjectNotFoundError, so a transient storage error now fails the edit (500, rollback) instead of silently keeping the old prompt.Git sync —
_delete_superseded_uploadbecomes soft-delete-and-queue; a per-directory savepoint rollback discards its queued entries; the queue is drained best-effort only after the cycle's import commit succeeds.In both deferred paths the worst case (a crash between commit and delete) leaks an orphaned object whose row is already soft-deleted — recoverable and identifiable, unlike the previous worst case of destroying an object a live row still points at.
Tests
Nine new tests plus five tightened assertions, all extending existing files. Verified to fail with the source changes selectively reverted (the one exception,
test_transient_storage_error_is_not_reclassified, guards against the #336-style over-broad fix rather than againstmain):test_storage_s3.py—NoSuchKey/404now assertObjectNotFoundError; new test pins that a transientServiceUnavailableerror is notObjectNotFoundError.test_storage_local.py,test_storage.py(GCS mocks),test_storage_s3_integration.py(real MinIO) — confirmed-absence sites assertObjectNotFoundError.test_disable_automation.py— liveCOMPLETEDrow with a missing object raisesTarballNotFoundError(cause chained); a transient storage error propagates unreclassified.test_router.py— a request failing after regeneration leaves the current tarball object intact; a transient storage error fails the edit instead of silently succeeding; a confirmed-missing source tarball still skips regeneration with a 200.test_git_sync.py— superseding an upload leaves its object intact until the cycle commits; the lifecycle test now also asserts the object is removed after a successful cycle.Validation
uv run pre-commit run --all-files— ruff format, ruff lint, pycodestyle, pyright all pass.uv run python -m pytest tests/(Docker up, so the Postgres/MinIO testcontainer suites run): 1333 passed, 1 failed — the failure (test_create_automation_shares_template_identity_with_presets, a telemetry distinct-id assertion) reproduces identically on cleanmainand is unrelated to this change.