test(chaos): extend the fault-injection harness to the S3 and local-folder backends - #221
Merged
Merged
Conversation
…older backends Adds an in-process fault-injecting S3 server plus a local-folder destination fixture, ten s3.9 rows across both backends, and makes the s6.3 invariant sweep backend-neutral via InvariantSurface.
Contributor
Coverage
Gate: passed - no coverage regression (epsilon 0.1 pp). |
This was referenced Jul 29, 2026
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.
TL;DR
The chaos harness only ever drove one destination: Google Drive, through
InMemoryRemoteStore. This adds nine rows covering the S3 and local-folderbackends, injects the S3 faults on the wire against the real
driven_s3::S3Store, constructs the local-folder faults byte-for-byte ondisk, and - the part that actually matters - makes the SPEC s6.3 invariant
sweep backend-independent instead of structurally welded to the fake.
No production behaviour changes. One additive test-double method
(
InMemoryRemoteStore::latch_dest_folder_missing) and nothing else outsidedriven-chaos- hencetest(chaos):. Rebased onto currentmain(post-#212and #203).
The load-bearing change:
assert_invariantsis no longer Drive-onlyassert_invariantstook a&InMemoryRemoteStoreand called two INHERENTmethods on it. So the claim "the s6.3 invariants are backend-independent" was
untestable by construction: a second backend could only be covered by a second,
forked copy of the checker - and two copies of "no duplicate
client_op_uuid"is exactly the drift s6.3 exists to prevent.
It is now generic over one narrow trait, with impls for all three backends:
All 34 existing call sites are unchanged (a delegating impl for
Arc<T>covers the ones holding an
Arc). There is still exactly one implementation ofeach invariant, now shared by every backend.
Two properties of that seam are deliberate:
(
auth.invalid_grant,NoSuchBucket, an unplugged drive) would make thesweep's own read fail, and the harness would report "could not verify" as
though it were "verified nothing wrong".
RemoteStore. On S3,list_folderreturns keys, sizes andETags but no user metadata, so a duplicate-
client_op_uuidcheck writtenagainst it would be silently vacuous on that backend - always green, never
looking. It is also non-recursive on every backend.
S3: why the faults are injected on the wire
The S3 failures that lose data rather than merely erroring live below the
trait, in the HTTP layer. Injected at the trait seam they arrive already
classified, which proves nothing about the code that does the classifying:
503 SlowDownis throttling wearing a 5xx costume. Read by status alone itbecomes a bounded-retry transient fault, and a throttled bucket strands files.
RequestTimeoutarrives as a 400 and must still be retried.CompleteMultipartUploadcan answer HTTP 200 with an error document,because S3 holds the connection open while assembling parts.
UploadPartcalls, or between the lastUploadPartandCompleteMultipartUpload, has no trait-level representationat all.
So
crates/driven-chaos/src/s3_server.rsruns an in-process, loopback-boundHTTP/1.1
FaultyS3Serverspeaking the S3 subsetdriven-s3uses, and the rowsdrive the real
S3Storeat it: real SigV4 presigning, realreqwestroundtrips, real XML parsing, real
classify_s3_response.Why not MinIO behind a proxy.
driven-s3's own integration suite alreadycovers real MinIO and real R2. The chaos jobs are a different question: per
chaos.ymlthey run Windows-only on every PR, andminiois installed onlyon the Linux legs of
ci.yml/coverage.yml. A MinIO-gated chaos row wouldSKIP in the one job that blocks a merge. In-process needs no external binary, no
port coordination beyond
:0, and behaves the same on all three platforms.No SigV4 validation, deliberately -
S3Storesigns as presigned URLs, sothe server never has to verify anything. Validating signatures would test
rusty-s3, not Driven.The server keeps real S3 semantics where a fault row's meaning depends on them:
Content-MD5is verified and a mismatch answered400 BadDigest; a single-PUTETag is the content md5 while a multipart ETag is
md5(concat(part md5s))-N;CompleteMultipartUploadassembles only the parts the body names anddiscards the rest (the property the crash-resume path relies on);
ListObjectsV2honoursmax-keysand emitsNextContinuationToken.A contract smoke test (
a_real_s3_store_round_trips_against_the_in_process_server)pins the whole non-faulted surface, so a broken server can never be mistaken for
a broken backend.
Local folder: no protocol server and no fault seam needed
Its destination IS a directory, so
crates/driven-chaos/src/localfs_fixture.rsconstructs each post-crash state byte-for-byte as the failure produces it and
reads the ground truth back with
std::fs- no production code has to know it isunder test:
swap_marker_identity()- the marker now names a different destination id,i.e. a different stick at the same mount point.
block_target_path()- an object-shaped DIRECTORY at the target path, so thestore really writes and
F_FULLFSYNCes its temp file and the realfsx::commit_renamereally fails. That is how the row reaches the instantbetween the sync and the rename.
plant_orphan_temp_file(bytes, age)- the residue a killed process leaves,with a controllable mtime so both sides of the sweep window are testable.
LocalFsOracleis the fault-free listing. It must reproduce the backend's ownnotion of what is NOT an object -
.driven-meta/,.driven-destination.json,.driven-tmp-*, and the macOS AppleDouble._*shadows the OS writes onexFAT/FAT32 - so it delegates to the backend's own
layout::is_control_entryrather than re-deriving the list.
._*files never appear on a tempdir, which isexactly why an oracle that forgot them would look correct in CI and lie on real
hardware.
Faults now injectable, per backend
S3 (wire-level, against the real store):
503 SlowDownsingle-shot and asa burst;
400 RequestTimeout;500 InternalError;404 NoSuchBucket(latching);
403 AllAccessDisabled(latching);QuotaExceededpast a bytebudget, charged per part so it can run out mid-upload; connection dropped
mid-
UploadPart; connection dropped atCompleteMultipartUpload; HTTP 200carrying an
<Error>document;404 NoSuchUploadat completion; per-responselatency for race widening;
clear_faults()to model recovery.Local folder (on-disk states): a marker naming a different destination;
a commit whose
renamecannot land; an abandoned temp file at any age.Google Drive (unchanged, plus one): the existing s5
with_*builders, plus aruntime
latch_dest_folder_missing()in the same shape as the already-presentarm_session_invalidated_after. "The destination vanished" is a mid-run event:asserting nothing already backed up is lost needs a healthy first cycle to
establish a synced baseline, which a construction-time-only surface cannot
express without rebuilding the store and discarding the very objects the
assertion is about.
The rows
s3-multipart-interrupted-between-partsUploadPart, one part already committeds3-multipart-interrupted-before-completeCompleteMultipartUploadsynceduntil a completion really succeedss3-complete-200-with-error-document<Error>, nothing publishedsyncedrow; recovery afterwardss3-slow-down-throttling503 SlowDowns3-request-timeout-retryable-400400 RequestTimeoutmid-transfers3-kill-mid-upload-then-rebootfile_staterow, no duplicate, nothing falselysynceds3-destination-full-mid-uploaddrive.quota_exhausted; nothing partial published; freeing space finishes the backuplocalfs-crash-between-temp-write-and-renamerenamecannot land, then the temp file a killed process leaves holding the FULL bytessynced, no temp residue from the failed commit; the orphan is invisible tolist_folderAND to the audit; stale swept, fresh preserved; a further cycle changes nothingdestination-vanished-across-backendsdrive.dest_folder_missing; nothing written after the destination went away; the baseline survivesEvery row ends in the shared
assert_invariantssweep, plus one assertion thesweep cannot make: for every
syncedrow, the destination's bytes equal thelocal file's bytes. A half-assembled multipart object, a dropped part, or a
completion that published a truncated object is caught even when every recorded
digest is self-consistent.
Registered in both
registry()andfault_injection_registry(), so thechaos-fake-drivejob - the one named after fault injection - actually runsthem.
Two details worth calling out
The fresh-temp-file assertion in the localfs crash row is a real
requirement, not symmetry for its own sake: the sweep uses the trait's 6-day
session window as its cutoff, so a sweep that reaped a fresh temp file would
destroy an upload the executor could still resume. Both sides are asserted.
That row also asserts on the data file's absence, never on a sidecar's
presence, because the backend commits data first and sidecar second on
purpose. A crash between them is benign by design, and a row that asserted the
opposite ordering would encode a bug as the expected behaviour.
Making the rows discriminate, not just pass
Two rows initially passed for the wrong reason. Both are fixed, and the fixes
are the most interesting part of this PR.
s3-slow-down-throttlingdid not discriminate. With ONE injectedSlowDown, the row passes whether or notclassify_s3_responsespecial-casesthe S3 code, because a transient 5xx is also retried. Only a burst longer than
the executor's finite 5xx budget (
MAX_TRANSIENT_RETRIES = 6) separates them:rate limiting retries indefinitely under the pacer and completes; a transient
5xx gives up and strands the file. The row now injects 10, on a small file so
the buffered
createpath answers with the retry POLICY rather than the nextscan cycle happening to succeed.
The localfs crash row's audit assertion was vacuous. "The audit returned
nothing" is trivially true on an empty destination. The row now recovers to a
real object FIRST, then plants the orphans, and asserts the audit returns
exactly one result - which is the difference between "the temp files were
filtered" and "the query found nothing".
Every row now proves its fault fired. The S3 server keeps per-fault firing
counters (
slow_down_fired,complete_error_documents_sent,quota_refusals,bucket_missing_refusals, ...) and each row asserts on the one it armed; thelocalfs rows assert an error was surfaced. This is the
append-only-loglessonapplied to fault injection: a green run that never reached its fault is not a
passing test, it is a test that did nothing.
Race windows are forced open, not hoped for (the #192 technique): the S3
rows whose fault lands mid-transfer arm a 3 ms per-response delay so the
intended path is taken on every run.
Negative controls
Each high-value row was re-run with the production defence it guards
deliberately disabled, and had to go RED. All controls were reverted;
crates/driven-s3andcrates/driven-localfsare untouched on this branch.localfs-crash-between-temp-write-and-renamelayout::is_control_entrystops filtering.driven-tmp-*an abandoned temp file must not appear as an object: expected the 1 real object, got ["backups/report.bin", "backups/.driven-tmp-..."]destination-vanished-across-backends(localfs arm)LocalFsStore::guard_rootstops comparing the destination ida marker belonging to a different volume must raise drive.dest_folder_missing, got []s3-complete-200-with-error-documentdriven-s3's defences, one at a timebig.bin recorded as synced with no object at the destinationThat last one is a finding in itself: a lying 200 is stopped three independent
ways, any one of which suffices - (1)
complete_multipartscans the body for<Erroreven on a 2xx; (2) failing that, the composed-ETag check finds no<ETag>in an error document and reportsChecksumMismatch; (3) failing both,drain_and_maybe_completeHEADs the key afterwards and an object that was neverpublished answers 404. The row therefore asserts the OUTCOME rather than pinning
one of the three, so a future refactor that consolidated the defences while
keeping the guarantee does not produce a false failure.
Finding: an abandoned S3 multipart upload leaks its parts
The very first run of
s3-multipart-interrupted-between-partsfailed on"multipart upload left in flight", which turned out to be real.
S3Storeaborts a multipart upload on exactly two paths: the non-resumablemultipart_streamfailure path, and a completion failureis_session_fatalcalls terminal. A transport failure mid-
UploadParton the resumablepath is neither -
resume_chunkpropagates the error and leaves the upload idalive. That part is correct on its own: the startup
reconcile->resume_persistedpath may still resume that exact session aftera crash, and aborting eagerly would destroy a recoverable upload.
The leak is on the other side.
executor.rs::upload_stage_resumable- thestreaming path every file >= 4 MiB takes - calls
open_resumable_sessionunconditionally at the top of each attempt, so the next cycle's retry mints
a fresh
CreateMultipartUploadand never touches the previous upload id again.Its parts stay on the bucket, billed, one abandoned upload per failed
attempt, and
S3Store::newperforms no sweep - unlikedriven-localfs, whichexplicitly sweeps abandoned temp files at construction using the trait's own
6-day session window (a mechanism this PR's localfs row now covers).
Not data loss, so no s6.3 invariant is violated. It is real money on a real
bucket, and it is unbounded across repeated failures.
How the rows treat it: they do NOT fail on it (that would make a
merge-blocking gate red over a known, unfixed cost issue), and they do NOT
ignore it.
settle_and_asserttakes a per-row cap, so the leak is BOUNDED - arow fails if more uploads strand than its faults account for, which is what
would catch it growing. Every occurrence is reported as an explicit
FINDING (cost, not data loss)note carrying the cause. The fix belongs indriven-s3(aListMultipartUploadssweep at construction, mirroring thelocalfs one), not in the harness, so it is left for a follow-up
fix(s3):rather than smuggled into a
test(chaos):PR.What is NOT covered
The localfs
ENOSPCerrno path, end to end. The out-of-space behaviour IScovered: the local-folder errno table maps
ENOSPC/EDQUOTonto exactly theDriveError::StorageQuotathats3-destination-full-mid-upload'sQuotaExceededproduces, and that row drives the whole chain mid-multipart withbytes already at the destination (account pauses, nothing partial published,
recovery once space is freed).
What is missing is the errno itself, because a real
ENOSPCneeds a realconstrained VOLUME: unprivileged on macOS (
hdiutil), root-only on Linux(
losetup), admin-only on Windows (New-VHD). The existingdisk-full-targetrow sets the precedent - capability-gated behind
DRIVEN_CHAOS_ALLOW_DISK_MOUNT- and a row gated the same way would SKIP in theWindows-only PR gate, the one job that blocks a merge. The errno table is already
unit-tested in
driven-localfs; what a chaos row adds beyond that is theorchestrator behaviour, which the S3 row proves. Recorded as a deliberate gap
rather than shipped as a scenario that is always SKIPPED.
Non-flakiness
Sequential runs against the prebuilt debug binary (M5 MacBook Pro, macOS 26).
Sequential on purpose: these rows bind loopback ports and share a debug-build
CPU budget, so parallel runs would measure contention rather than flakiness.
s3-multipart-interrupted-between-partss3-multipart-interrupted-before-completes3-complete-200-with-error-documents3-slow-down-throttlings3-request-timeout-retryable-400s3-kill-mid-upload-then-reboots3-destination-full-mid-uploadlocalfs-crash-between-temp-write-and-renamedestination-vanished-across-backendsPlus 22 full
run-all --fault-injectionsweeps (12 before the local-folderrows at
33 PASS / 2 SKIP, 10 after at34 PASS / 2 SKIP),0 FAIL / 0 FLAKYevery time - so the new rows do not interfere with the existing ones (shared
ports, shared
target/chaos-fixtures/).Worth stating plainly rather than implying the counts alone earned the
confidence: every one of these rows is deterministic by construction. The
fault is an armed flag or a constructed on-disk state, not a timing race, and the
fault-fired counters prove the intended path was taken on each run. That is what
makes 100 runs meaningful instead of 100 coin flips that happened to land the
same way - and it is the same technique #192 used to convert
append-only-logfrom a 5-10% flake into a deterministic row.Per-row wall clock is 65-610 ms locally and 236-779 ms on the Windows runner
(see the CI section), so the nine rows add ~5 s across both Windows chaos jobs.
CI on windows-latest, the platform the gate actually runs
All nine new rows pass on
windows-latest, which matters because the S3 serverhand-rolls HTTP/1.1 framing and the local-folder rows depend on
renamesemantics and directory handling:
chaos fake-drive (windows-latest): 36 PASS / 0 SKIP / 0 FAIL / 0 FLAKYchaos hermetic (windows-latest): 84 PASS / 10 SKIP / 0 FAIL / 0 FLAKYTotal added cost: ~5.1 s across both Windows jobs.
Pre-existing failure, macOS-only, unrelated to this branch
run-all --hermeticon the final rebased tree reports one FAIL on thismacOS host:
noaccess-file("exactly one local.io_error (the unreadable file);got 0").
Verified pre-existing by stashing every change and running it against unmodified
main, where it fails identically - and it PASSES onwindows-latestin thisPR's own run, which confirms it is a macOS permissions-model gap rather than
anything this branch touched. The chaos gate is Windows-only on a PR and macOS
only joins on a
v*tag, so it does not block here, but it is a real gap worthits own issue: on a
v*tag the macOS leg would go red.Gates
Local (M5 MacBook Pro, on the rebased tree):
SQLX_OFFLINE=true cargo test --workspace- green (53 test binaries, 0failures)
cargo clippy --workspace --all-targets -- -D warnings- cleancargo fmt --all -- --check- cleancargo run -p driven-chaos -- run-all --fault-injection- 34 PASS / 2 SKIP /0 FAIL (the 2 SKIPs are the Windows-only mutator rows)
git ls-files --eolCI (all against the pushed commit, which matches local
HEAD):rustfmt,cargo deny,coverage,ui build + lint + unit, all threetauri compilelegs,
CodeQL/Analyze (rust, actions, javascript-typescript),chaos hermetic (windows-latest),chaos fake-drive (windows-latest), and allthree
cargo test + clippylegs - every check green. The only non-passingrow is
real-drive e2e (tag-only), which skips by design outside av*tag.pnpm --dir ui test:unitwas not run locally:ui/node_modulesis absentin this worktree and the diff touches no UI file, so CI's
ui build + lint + unitjob (green) is the honest check rather than a local install. Statedrather than claimed.