Conversation
…ion caches GOR queries failed intermittently with S3 416 Range Not Satisfiable when opening versioned .link files, each burning ~167 s of retries first (220 occurrences in clinops-prd over 14 days). S3Source clamps every ranged GET to the object length it has cached. Object stores have no append, so LinkFile.save and versioned-link GC replace the whole object; a writer in another pod shrinking a .link file leaves every other reader holding the pre-rewrite length. Those readers then request a range starting at or past the new end of the object and get a deterministic 416, which S3RetryHandler did not classify, so it ran the full retry ladder before failing anyway. - S3Source/S3SourceAsync/OCIObjectStorageSource: on a 416, invalidate the cached metadata, re-read the object length and reissue the range once. The retry is immediate and local, so it never reaches the retry handler. - S3RetryHandler/OCIObjectStorageRetryHandler: classify 416 as non-retryable, so a genuinely unsatisfiable range fails in milliseconds. - GorSessionCache: bound s3MetadataCache (10000 entries, 5 minute expiry), matching the static fallback cache. It was unbounded and never expired, and gor-worker sessions are long-lived, so a length could be cached for the lifetime of the process. This also caps the quiet half of the bug: a cached length shorter than the object truncates link content silently. - GorSessionCache: linkCache is now per session rather than static, with the same bounds, so link content read by one session is no longer served to every later one. Key remains the StreamSource identity. - Metadata cache keys now use separators, and the OCI key includes the namespace. Previously bucket "ab" + key "c" and bucket "a" + key "bc" both produced "abc", and two OCI namespaces shared one entry. Regression tests reproduce the production signature, including an integration test that reads a versioned .link file, has a second writer compact it, and reads it again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…est fails The assertion only said the read failed, not what it returned. Including the resolved url distinguishes a 416 from a stale cached resolution, which is the difference between the object length being wrong and the link content being wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
andrimar1
reviewed
Sep 2, 2026
andrimar1
approved these changes
Sep 2, 2026
gmagnu
deleted the
ENGKNOW-3722-gor-s-3-416-range-not-satisfiable-on-link-files-stale-session-s-3-metadata-cache-after-link-file-rewrite
branch
September 2, 2026 15:46
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.
ENGKNOW-3722
Problem
GOR queries fail intermittently with
S3 Status Code: 416(Range Not Satisfiable) when opening versioned.linkfiles, each burning ~167 s in retries before hard-failing. 220 occurrences inclinops-prdover 14 days (namespacegregor163,workflows57;gor-worker153,executor32, Nextflow tasks 22,gregor-web11).S3Source.open(RequestRange)clamps every ranged GET to the object length it has cached (S3Source.java:174). Object stores have no append —S3Source.getOutputStreamrejects it outright — soLinkFile.appendEntry→save()overwrites the whole object, and versioned-link GC (LinkFile.gcEntries) shrinks it. The writer callsinvalidateMeta(), but that only clears its own JVM. Every other pod keeps the pre-rewrite length, requests a range starting at or past the new end, and gets a 416.The staleness window was effectively unbounded:
gor.s3.meta.cache.session=trueis the default, and the session cache had no expiry and no size bound (GorSessionCache.java:51), while gor-worker sessions are long-lived.S3RetryHandler.checkIfShouldRetryExceptionclassifies 400/401/403/404 as non-retryable but never 416, so a deterministic failure ran the full ladder — turning a millisecond-wide race into a ~3-minute query outage.How the range arises
Worth spelling out, because the numbers look arbitrary.
LinkFile.readLimitedLinkContentcallsStreamUtils.readString(is, 200000), a single 200000-byte read. That exceedsExtendedRangeStream's 128 kb bookkeeping immediately, so it drains the first request, then reopens at the position reached with double the length:Distinct signatures from Loki over 14 days, all showing a cached length larger than the actual:
bytes=8841-8844bytes=98674-98824bytes=196608-352972bytes=196608-458751The last two are a link file that shrank from ~350 KB to a 43-byte single-line link — a GC/compaction rewrite — while readers still held the pre-rewrite length.
Unrelated to the chronic 429 throttling on the same bucket. Those do not correlate: Aug 1–3 had 416 bursts on baseline 429 traffic, Aug 8 had ~2.5M 429s and zero 416s. The 429 volume is a real separate problem and still needs its own ticket.
Fix
Self-heal, in the source. On a 416, invalidate the cached metadata, re-read the object length, and reissue the range once. The retry is immediate and local, so it never reaches
RetryStreamSourceWrapperand costs one HEAD rather than 167 s of sleeps.Wiring
invalidateMeta()in asRetryHandlerBase.perform's existingpreRetryOpwas considered — the hook is there andRetryStreamSourceWrapperpassesnullfor it. Rejected:invalidateMeta()is private and not onStreamSource, so it needs an interface change, and the hook only fires after a sleep.Fail fast on what remains.
S3RetryHandlerandOCIObjectStorageRetryHandlernow classify 416 as non-retryable. Anything reaching them has already survived the self-heal, so it is a genuinely unsatisfiable range and should fail in milliseconds.Bound the session caches.
s3MetadataCachegetsmaximumSize(10000).expireAfterWrite(5, MINUTES), matching the static fallback. This is also what caps the quiet half of the bug: a cached length shorter than the object makesExtendedRangeWrapperstop at the stale EOF, soreadLimitedLinkContentreturns truncated link content with no error at all and resolves the wrong data source. Self-healing 416 fixes only the loud half; the expiry bounds both.linkCachebecomes per-session instead ofstatic, with the same bounds, so link content read by one session is no longer served to every later one in the process.Cache keys. Keys were
bucket + keywith no separator, so bucketab+ keycand bucketa+ keybcboth producedabc— two unrelated objects sharing a cached length, and invalidating one clearing the other. The OCI key was worse: it omitted the namespace entirely, so the same bucket and object name in two namespaces shared one entry.Applied to
S3Source,S3SourceAsyncandOCIObjectStorageSource. The OCI variant matches onBmcException.getStatusCode();S3SourceAsynccatchesRuntimeExceptionrather thanGorResourceExceptionbecause itsopenRequestdoes not wrap S3 failures — the async branch lets theCompletionExceptionthrough and the sync branch catches onlySdkClientException.linkCachekeeps itsStreamSource-identity key. Re-keying it on path would switch on real link-content caching and reintroduce exactly the cross-pod staleness class this PR closes, one level up. That belongs in its own ticket.Semantics of the self-heal
A 416 means
start >= actual length, so after the refresh the range is empty in every real case and the read reports EOF —readStringreturns the bytes it already has. If the first chunk and the remainder straddle a rewrite, that content can mix generations; it then fails loudly in link parsing rather than as a 416, which is strictly better than a hard query failure. The cache expiry is what narrows that window. The "object grew between the 416 and the re-HEAD" branch reissues the GET and is covered by its own test.Tests
Written test-first; every test was watched failing against the production stack before the fix.
UTestS3SourceStaleMetadata— 416 on a shrunk object reads as EOF; the range is reissued if the refreshed length still covers it; non-416 errors do not trigger a metadata refresh (guards against over-broad invalidation); cache keys do not collide across the bucket boundary. Uses the real caller arguments (262144), derived from the trace above, not hand-picked ones.UTestS3RetryHandler/UTestOCIObjectStorageRetryHandler— 416 fails on the first attempt; 500 still retries.UTestOCIObjectStorageSourceStaleMetadata— same three cases plus namespace isolation in the cache key.UTestGorSessionCache— metadata cache entries expire and the cache is size-bounded; link content cached by one session is not visible to another.ITestS3LinkFileRewrite— end to end against real S3: write a versioned.link, read it through the production wrapper chain to seed the cache, have a second writer compact it via the rawS3Client(going throughS3Sourcewould callinvalidateMeta()and hide the bug, whereas in production the writer is a different pod), then read again. Before the fix this failed withStatus Code: 416andGiving up after ... retries; after, it resolves the compacted entry.Mutation-checked. The check caught that the OCI 416 branch had been added without a failing test first; that was corrected properly — test written, branch removed, failure observed, branch restored.
./gradlew test— 2595 pass, 0 fail, 69 skipped (pre-existing)./gradlew :drivers:integrationTestfor S3 + OCI — 88 pass against real object storesdrivershad no mockito; addedtestImplementation Testing.mockito.core.Not included:
S3SourceAsync.javais untracked work-in-progress in the working tree, so its fix and its test are held back until that file lands, keeping this PR to the bug. Also fixed in passing:OCIObjectStorageSourcereported failures as"Failed to open S3 object".Impact
Every in-flight reader of a
.linkfile was taken down at once whenever that file was rewritten — the bursty shape in the logs, ~6 distinct days in 14. Each failure cost 167 s of retry latency plus a failed query, and the retries held streams and metadata alive longer, marginally widening the race for everyone else.The quiet half never showed up in the logs at all: a stale-shorter length silently truncated link content and resolved the wrong data source, with no error raised.
Immediate mitigation without this build remains
-Dgor.s3.meta.cache.session=false, which falls back to the 5-minute-TTL static cache. Do not use-Dgor.s3.meta.cache=false— disabling caching entirely adds substantial HEAD traffic and would worsen the existing 429 throttling.🤖 Generated with Claude Code