Repository navigation
fix(storage): route the read path through the key contract (#746) - #754
Merged
Merged
Conversation
#743 made the key rule a property of every Backend, which covers writes. Reads never go through a Backend at all: Arc builds an s3:// or azure:// URI and hands it to DuckDB, and iceberg-go writes through its own FileIO. Both read the object store directly, so the contract did not reach them. That gap was not theoretical. It had already produced two live bugs, both on deployments with storage.s3_prefix set, and both found by auditing the URI builders rather than by any failing test: - Retention deleted nothing at all. buildParquetPath built "s3://{bucket}/{key}" with no GetPrefix(), so every file it examined 404'd, the per-file read errored, and deleteOldFiles logged a warning and skipped it. The run then recorded itself as "completed" with zero deleted, so execution history showed a series of successful runs while data never aged out. Live since v26.03.2: the commit that added the prefix option updated the identical builder in delete.go and missed this one. - Single-tier partition pruning silently stopped working. The pruner turned a partition URL back into a listing prefix by stripping the scheme and bucket but not the configured prefix, and List prepends the prefix itself, so the listing ran against "prefix/prefix/..." and came back empty with no error. Every partition was judged absent and the query fell back to the full glob. Results stayed correct, cost did not. Both were measured against a live MinIO with DuckDB httpfs, not reasoned about, and both have regression tests that fail against the old code. There is now one validated builder, storage.ObjectURI, used by retention, delete and iceberg, so a backend's prefix cannot be honoured in one copy and forgotten in another. GetStoragePath returns an error and validates its arguments as single segments, because ValidateKey(db + "/" + m) happily accepts a measurement of "a/b" and reads a different directory. The read path also needs one rule the write path does not. ValidateKey accepts "*", "?", "[" and "{" because a write of such a key names exactly one object; interpolated into read_parquet they are pattern operators, so "cpu*" reads every measurement starting with "cpu". ValidateGlobSafe is applied at the sinks that interpolate into a DuckDB path, not inside ObjectURI, because iceberg-go and os.Open read the same key literally and rejecting it there would fail an export over a file that resolves fine. A rejected name fails the query with an explicit error rather than resolving to a glob that matches nothing: Arc turns "no files matched" into an empty 200 and counts it as a query success, so the sentinel would have reported "your query succeeded and your data is gone". The error leaves the regexp-driven transform through a context-carried struct, the same escape hatch pruning.WithVolatileResult already uses, so no rewriter had to change shape. Also reconciles the five spellings of "is this name safe as a path", which disagreed with each other. The cluster fetch validator was believed to be stricter than the contract and was measurably looser, accepting backslashes and over-long keys. The delete endpoint refused any name containing "..", so a measurement called "a..b" could be written and queried but not deleted from. They now share one rule, with the divergences documented and pinned by a test that asserts direction rather than an exception list: the Raft manifest validator stays looser because it runs during log replay, and the HTTP API additionally hides dot-prefixed names, which is a display rule. That test immediately earned itself twice. It found that the manifest validator is STRICTER than the contract on one axis, rejecting any colon while validateSpokeID accepts "rocket:01", so those files are stored and can never be registered (#751). And on rebase it caught that #744 reserved PartSuffix in ValidateKey while ValidateListPrefix's doc still claimed to be looser "in exactly two ways"; the behaviour is right, since a prefix cannot name an object and refusing one would make staged partials unlistable, so the comment is corrected and the list is now checked rather than asserted. Other changes, all internal: - storage.ValidateKeySegment is the primitive whose absence caused the duplication: five callers needed a per-segment rule and this package exported none, so each wrote its own. - Retention reports files it had to skip instead of recording a clean success, since such a file is skipped on every future run too. - A storage root of "/" no longer disables partition pruning; the glob parser required a non-empty root. - Removed three unused S3 URI builders rather than leave further copies. - Local listings gained coverage for ListObjects and for an unusable key that is not a write-staging partial. The filtering itself is #744's; these are the cases its own tests do not reach, and ListObjects matters because the reconciler prefers it over List. Filed while auditing: #751 above and #752 (four spellings of the DuckDB path escaper). Closes #746
xe-nvdk
force-pushed
the
fix/746-read-path-key-contract
branch
from
September 12, 2026 18:37
c953d94 to
959a394
Compare
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 #746.
What this is
#743 made the key rule a property of every
Backend. That covers writes. Reads never go through aBackendat all: Arc builds ans3://orazure://URI and hands it to DuckDB, and iceberg-go writes through its own FileIO. The contract did not reach either.The issue framed this as a structural gap. Auditing it turned up two live bugs, both on deployments with
storage.s3_prefixset, and both measured against a live MinIO with DuckDB httpfs rather than reasoned about.Live bug 1: retention deleted nothing
buildParquetPathbuilts3://{bucket}/{key}with noGetPrefix(), while every write goes under the prefix. So the URL named an object that does not exist,getFileMaxTimeAndRowCounterrored, anddeleteOldFileslogged a warning and skipped the file. Every file took that path.Measured, prefix
tenant:Nothing said so.
runPolicythen calledrecordExecutionComplete(..., "completed", 0, ...), so the API and the execution history reported a successful run. An operator checking whether retention was working saw green.Provenance, not inferred:
git log -Sputs the builder atfe5a294(#170); the prefix option arrived later in409fe0b(#258), andgit show --stat 409fe0btouchesinternal/api/delete.go, which is wheregetQueryPathgot itsGetPrefix(), and does not touchinternal/api/retention.go.git tag --contains 409fe0bputs it live in v26.03.2 through v26.09.1.That is exactly the failure #746 is about: two longhand copies of one builder, one updated, the other not found. Patching the single line would have left the next copy to be missed the same way.
Live bug 2: single-tier partition pruning was dead
extractStoragePrefixstripped the scheme and bucket but not the configured prefix, andList/ListDirectoriesprepend the prefix themselves. Measured:Empty with no error, so every partition was judged absent and
OptimizeTablePathfell back to the unpruned glob. Results stayed correct; the optimisation silently never applied.tier_pruning.go:8-12documents this exact hazard and the tiered path avoids it. Only the single-tier path was left doing scheme-and-bucket surgery.The structural change
storage.ObjectURIis the one validated key-to-location builder. Retention, delete and iceberg use it, so a prefix cannot be honoured in one copy and forgotten in another.storage.GetStoragePathreturns an error and validates its arguments as single segments.ValidateKey(db + "/" + m)accepts a measurement ofa/band reads a different directory.storage.ValidateKeySegmentis the primitive whose absence caused the sprawl: five callers needed a per-segment rule, this package exported none, so each wrote its own.storage.ValidateGlobSafeis the rule reads need and writes do not.ValidateKeyaccepts*,?,[,{because a write of such a key names exactly one object; inread_parquetthey are pattern operators, socpu*reads every measurement starting withcpu. It is applied at the sinks that interpolate into a DuckDB path, not insideObjectURI, because iceberg-go andos.Openread the same key literally.Reachable rather than theoretical:
validateSpokeIDhas no character allowlist, and a spoke ID is the first path segment of everything that spoke writes.Why an error and not an inert path
My first draft resolved a rejected name to a glob matching nothing, following the
arcInvalidIdentifierSentinelprecedent. That was wrong here.isNoFilesFoundErrorturns a no-match glob intoSuccess: true, RowCount: 0and callsm.IncQuerySuccess(), so a rejected name would have been reported as a successful query over an empty table, and cached. The sentinel's own doc says it is a backstop for a caseValidateSQLRequestalready 400s; aGetStoragePathfailure has no upstream rejection.The error leaves the regexp-driven transform through a context-carried struct, the same escape hatch
pruning.WithVolatileResultalready uses for the same reason, so no rewriter changed shape.Validator reconciliation
The issue asked for this. They disagreed with each other:
cluster.sanitizeFetchPath., backslashes, 256-byte segments, 1401-byte keysdelete.goinline..substringa..b, which is writable and queryable but was not deletableapi.isSafeStoragePathSegmentraft.ValidateManifestPathThey now share one rule. The two deliberate divergences are pinned by a test that asserts direction rather than an exception list, because raft is looser on six independent axes and a list degenerates into an allowlist of inputs someone happened to think of.
That test earned itself twice. It found that raft is stricter on one axis, rejecting any colon, while
validateSpokeIDacceptsrocket:01, so those files are stored and can never be registered in the manifest. Out of scope here, filed as #751 and pinned by the test so it cannot widen silently. Also filed #752 for the four spellings of the DuckDB path escaper.And on the rebase onto #744 it caught a stale claim in freshly merged code: #744 reserved
PartSuffixinValidateKey, whileValidateListPrefix's doc still said it was looser "in exactly two ways". The behaviour is right (a prefix cannot name an object, and refusing one ending in.partwould make the staged partials that suffix was reserved for unlistable), so the comment is corrected and the list is now checked by the test rather than only asserted in prose.Rebased onto #744 and #747
Both landed while this was open and both touched
internal/storage/local.go. Two things changed as a result:LocalBackend.List/ListObjectsfiltering is dropped: Remaining many-to-one path mappings outside LocalBackend.validatePath #744 already added it, with aToSlashmine lacked. Only the test coverage noted above survives.validateKeyBody's segment-length check now uses Remaining many-to-one path mappings outside LocalBackend.validatePath #744'sMaxUsableKeySegmentLen, which leaves headroom for the staging suffix, so the extractedcheckSegmentfollows it rather than reintroducingMaxKeySegmentLen.Tests
Written against #743's lesson that an accept/reject-set test cannot see a mapping bug.
objectstore-tagged regressions against real MinIO, one per live bug. Both verified to fail against the old code: reintroducing the missing prefix reproduces the same 404.TestObjectURIAndGetStoragePathShareOneRootandTestGlobParseYieldsTheBackendRootpin the three participants (builder, glob, and the pruner's regex parse of that glob) against each other. The seam between them is where both bugs lived.TestObjectURINamesTheFileTheBackendWritesasserts against the filesystem, not against a string this package also computes.Also in here
ListObjectsand for an unusable key that is not a write-staging partial. The filtering itself is Remaining many-to-one path mappings outside LocalBackend.validatePath #744's; these are the cases its own tests do not reach, andListObjectsmatters because the reconciler prefers it overList./no longer disables partition pruning; the glob parser required a non-empty root.Upgrade note
On prefixed S3, the first retention run after this fix will delete the whole backlog that accumulated while retention was doing nothing. That is the policy doing what it was configured to do, but it is not small and not reversible. The release notes tell operators to dry-run each policy first; the dry run is now accurate where it previously reported zero.
Verification
go build,go vetandgo test -raceall with-tags=duckdb_arrow, which is what the Makefile and CI use, plus theobjectstoresuite against MinIO. All green.