Repository navigation
fix(compaction): preserve S3 prefix in subprocess; drop dead ResilientBackend (#320, #560) - #561
Merged
Merged
Conversation
added 2 commits
July 29, 2026 10:08
…tBackend (#320, #560) #560 -- compaction runs in a forked subprocess for DuckDB memory isolation, and the child rebuilds its storage backend from the parent's Type() and ConfigJSON(). S3Backend.ConfigJSON emits "prefix", but the subprocess parse struct had no matching field, so json.Unmarshal silently discarded it and the rebuilt backend carried an empty prefix. The prefix is applied via prefixedKey() to every key the S3 backend touches -- read, write, delete, list -- so on any deployment with storage.s3_prefix set, compaction subprocesses operated against the bucket root instead of the configured prefix, with no error raised. It defaults to empty, so "prefixed" and "unprefixed" were the same string everywhere the code was exercised. This is the config-key-at-its-default shape from #534. The prefix is now parsed and forwarded. A round-trip test drives the real createStorageBackendFromConfig and compares the rebuilt backend's ConfigJSON against the parent's, so a field added to ConfigJSON without a matching parse field fails immediately. Azure and local have no equivalent gap: each emits exactly the fields the subprocess parses. #320 -- internal/storage/resilient.go wrapped a backend with retry and a circuit breaker, but nothing ever constructed one: no call sites in cmd/ or internal/, no tests, no type assertions, and a single commit dating to the original Go migration. Having drifted behind the Backend interface (missing ReadToAt, StatFile, Type, ConfigJSON) it no longer satisfied the interface it was written against, which is what surfaced it. Removed rather than completed. Retry and circuit-breaking around cloud storage are worth having, but an unused and untested wrapper would drift again at the next interface change; a future implementation should be written against the interface as it stands then and actually wired in. The build is unchanged -- the code was unreachable. Verified against a running binary: 3 files compacted to 1 through the real subprocess path, all rows still queryable, zero storage-config errors, clean graceful shutdown.
Building the image from a working tree that has been used for local testing copied the whole tree into the build context, including a 27GB data/ directory of parquet and 137MB of release tarballs. The builder ran out of disk before compiling the binary. Everything excluded is either gitignored (data/), a build artifact (zarf-package-*.tar.zst, uds/), or not needed to compile and run the binary (docs/, deploy/, helm/, *.md). Found while running an end-to-end MinIO test for the S3 prefix fix.
This was referenced Jul 29, 2026
xe-nvdk
added a commit
that referenced
this pull request
Jul 29, 2026
…async (#314, #337, #305) (#565) Three independent bug fixes closing out the 26.09.1 bug sprint. #314 -- ListManifests discarded every storage error and returned an empty list, with no log line. "No manifests exist" and "we could not find out" are opposite instructions: an empty set means nothing is being compacted and the candidate may proceed. filterCandidateFiles already had the right guard -- it skips the partition when the lookup fails, explicitly "to avoid re-compaction" -- but swallowing the error made that guard unreachable, so a transient S3 throttle or credential blip let Arc re-compact files another job already had in flight. Errors now propagate. A missing manifest directory stays a non-error: LocalBackend.List skips absent directories and the object stores return an empty listing for an empty prefix, so anything reaching this layer is a real failure. #337 -- a column that was entirely null within a batch was dropped, since no type can be inferred from it. Readers mostly absorbed this because they union schemas by name, but a column null in EVERY batch never appeared in any file, so querying it failed to bind rather than returning NULLs. Verified against the running binary: pre-fix the query returns `Binder Error: Referenced column "depth" not found`; after, three NULL rows. Such columns are now written as an all-null placeholder with a validity mask, and a later batch with real values still infers its own type. time is exempt and an all-null time is rejected outright -- a VARCHAR time column makes a partition un-compactable. #305 -- wal.sync_mode accepted fdatasync and defaults to it, but both fsync and fdatasync called the same full Sync(). Linux now uses a real syscall.Fdatasync, retried on EINTR so an interrupted call cannot report durability it did not achieve. Go does not expose fdatasync on darwin or windows, so those fall back to Sync() via build-tagged files and now log that fact once at startup instead of reporting a mode they do not perform. Verified by running the Linux test binary in a container, not just cross-compiling it. Expect no throughput change from #305: WAL syncs run off a 100ms ticker, not per-write, so this is a correctness and honesty fix. Also closes #293 and #294 as obsolete -- both describe ResilientBackend, deleted in #561. Co-authored-by: Ignacio Van Droogenbroeck <ignacio@vandroogenbroeck.net>
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.
Closes #320. Closes #560.
Investigating #320 surfaced a live correctness bug (#560) in code that #320's own subject —
ConfigJSON()— feeds. Both are here.#560 — S3 prefix silently dropped in compaction subprocesses (the real bug)
Compaction forks a subprocess for DuckDB memory isolation; the child rebuilds its storage backend from the parent's
Type()+ConfigJSON()(manager.go:248-249 → subprocess.go:308).S3Backend.ConfigJSON()emitsprefix(s3.go:712), but the subprocess parse struct had noPrefixfield (subprocess.go:320-326) and the reconstructedS3Confignever set it.json.Unmarshaldiscarded it silently.The prefix is applied via
prefixedKey()to every key the backend touches — read, write, delete, list (s3.go:235, 269, 309, 339, 370, 403, 429). So on any deployment withstorage.s3_prefixset, compaction subprocesses operated against the bucket root instead of the configured prefix. No error raised.S3Config.Prefixis a real operator-settable key (config.go:539) documented for multi-tenant use (instances/abc123/).Why it survived:
prefixdefaults to empty, so "prefixed" and "unprefixed" are the same string in every test and default deployment. Exactly the shape CLAUDE.md flags after #534 — a config key whose default makes two different values identical.The failing round-trip shows it directly:
Azure and local have no equivalent gap — each emits exactly the fields the subprocess parses. I checked.
#320 — ResilientBackend removed rather than completed
internal/storage/resilient.go(395 lines) wrapped a backend with retry + circuit breaker. The issue asks for pass-throughType()/ConfigJSON()to satisfyBackend.It's dead code. Every reference lives inside the file itself — zero call sites in
cmd/orinternal/, no tests, no type assertions, no doc/config mentions.git logshows one commit: the original Go migration. It was never wired in.It's missing four methods, not two (
ReadToAt,StatFile,Type,ConfigJSON) — it drifted as the interface grew, and nothing regressed precisely because nothing constructs one.Removed rather than completed: retry/circuit-breaking around cloud storage is worth having, but an unused untested wrapper drifts again at the next interface change. A future implementation should target the interface as it stands then and actually be wired in. Follows the #448 sharding-deletion precedent. The build is unchanged — the code was unreachable.
Configuration matrix
go build ./cmd/... ./internal/...clean after deletioncreateStorageBackendFromConfig"local" branchs3_prefixunset (default)s3_prefixSET (NON-DEFAULT)Test plan
TestCreateStorageBackendFromConfig_PreservesS3Prefix(new) — verified it fails against the pre-fix code, printing the exact prefix divergence aboveTestCreateStorageBackendFromConfig_PreservesLocalConfig(new) — same contract for the default backendTestCreateStorageBackendFromConfig_RejectsUnknownType(new) — unknown type must error, not fall back to a default backendgo test ./internal/compaction/...and./internal/storage/...passgo build ./cmd/... ./internal/...,go vet,gofmt -lcleanBinary run — drove the real subprocess path end to end:
hourly_min_age_hours(compaction keys off the filename timestamp, not the data timestamp), triggered compactionCompaction cycle complete | succeeded=1,compaction-subprocess ... files_compacted=3, bytes_before=3381, bytes_after=492SELECT count(*) → 3)unsupported storage type/failed to parse storage config/failed to create storage backendlinesGraceful shutdown completeEnd-to-end against real S3 (MinIO), with the prefix set — the caveat in the original PR description is now resolved. Built two images from this branch, identical except for the four-line fix, and ran the same scenario against
deploy/docker-compose/oss-s3-style MinIO withARC_STORAGE_S3_PREFIX=instances/abc123/:files_compacted=0, bytes_before=0, bytes_after=0files_compacted=3, bytes_before=3402, bytes_after=606success:true,total_succeeded=1success:true,total_succeeded=1*_compacted.parquetproducedinstances/abc123/.../mem_..._compacted.parquetSELECT count(*) → 9, all rows intactThe pre-fix failure is worse than "compaction targets the wrong location". The subprocess listed the bucket root, found nothing to compact, and reported success —
files_compacted=0withtotal_succeeded=1and no error at any log level. So on a prefixed S3 deployment, compaction silently never ran: files accumulated forever while every cycle reported success. That is a silent-no-op, not a loud failure, which is why it could persist unnoticed.The parent process honors the prefix correctly in both images (writes landed under
instances/abc123/pre-fix too) — confirming the bug was isolated to the subprocess's backend reconstruction.Also included:
.dockerignoreBuilding the image from this working tree failed — the build context pulled in a 27GB local
data/directory and 137MB of release tarballs, exhausting the builder's disk before the binary compiled. Added a.dockerignorecovering gitignored runtime data, packaging artifacts, and docs/deploy trees that aren't needed to compile or run the binary. Unrelated to the bug, but it blocked the end-to-end verification above and will block anyone else building locally.One process note
My first attempt at a regression guard was a test that compared
ConfigJSON()against a struct literal hand-written in the test file rather than the real parse struct. It passed against the buggy code — it could never have caught the bug, since I controlled both sides. I deleted it and replaced it with the round-trip above, which drives the production function. Flagging it because a guard that cannot fail is worse than no guard: it reads as coverage.