feat(ENGKNOW-3770): cache simple link files, instrument the rest - #138
Conversation
Junit Tests - Summary4 803 tests +15 4 631 ✅ +14 17m 8s ⏱️ +14s Results for commit 3b3952a. ± Comparison against base commit 70f9c7d. This pull request skips 1 test.♻️ This comment has been updated with latest results. |
The link content cache could never hit. It was keyed on the StreamSource object identity, no source or wrapper type overrides equals/hashCode, and PluggableGorDriver.getDataSource builds a fresh source for every resolution, so the key was never equal to a previous one. Every link file resolution re-read the link file from storage while gor.driver.link.cache claimed otherwise. Cache simple link files, keyed on the link file path with the existing 5 minute expiry. A simple link file is a bare data path, rewritten so rarely that serving one slightly stale is an accepted trade, and it is by far the common case, so this is where the saving is. Versioned link files are still always re-read. They are rewritten in normal operation by appendEntry and by versioned-link GC, and a stale entry could resolve to a generation GC has already deleted, silently. Keying on last modified and length does not fix that: the metadata is itself served from a cache with its own expiry, so the key would be built from stale values and would still match. - LinkFileCacheStats records, per link file version, reads, cache hits, distinct paths, and how often the content behind one path changed between resolutions. Observe only, off unless gor.driver.link.cache.stats is set, path map capped. The change count for versioned links is what decides whether they can be cached later. - save() invalidates the cached content for its link file. Tolerating another process's rewrite until expiry is the trade; reading back stale content this process just replaced is not. Caught by UTestGorWrite. - An empty link file is never cached. It carries no link and is typically a placeholder about to be written, and caching it made a later read report the wrong version. - readLimitedLinkContent releases the read handle it opened. While the cache was keyed on the source object it pinned every source handed to it, hiding the fact that callers do not close them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
a6c73aa to
ce77310
Compare
|
I was trying claude
invalidateCachedContent(source) runs at the top of the private save(...), before the new content is written and — for FileSource with atomic write — before close() renames the temp file into place (FileSource.java:351-370). Any Suggest invalidating after the write completes — in save(long, FileReader) once the try-with-resources closes the output stream, ideally in a finally so a failed write also drops the entry.
invalidateCachedContent clears staticLinkCache plus the link cache of the session bound to the saving thread only. GorSession.currentSession is an InheritableThreadLocal (GorSession.java:39) that is never cleared, so reads and Suggest invalidating across all live session caches, or keying invalidation so it can't be missed (e.g. a per-path write generation).
The cache key is source.getFullPath(), and FileSource.getFullPath() returns filePath.toString() (FileSource.java:178-180), which is the raw url when the SourceReference carries no common root (FileSource.java:94-99). Not reachable through normal resolution: DriverBackedFileReader never leaves commonRoot empty — it falls back to the standalone root or DEFAULT_COMMON_ROOT = "./" (DriverBackedFileReader.java:68, :86-93) and always threads it The residual is the constructors that build a SourceReference with no root at all — FileSource.java:67 and :74, GorIndexFile.java:85, IndexCommand.java:146-148, GorBench.java:111 — where getFullPath() returns the url verbatim into
registerDump() calls Runtime.getRuntime().addShutdownHook(...) from the link-read path. If the first stats-enabled resolution happens while the JVM is already shutting down (inside a shutdown hook, or on the GC thread from
TrackedPath.version is final and set at first sight; only contentFingerprint is updated on change (:99-105). When a link is converted from simple to versioned (LinkUpdateCommand does exactly this via LinkFile.loadV1 + save), the Reads are not affected — recordRead derives its bucket fresh via versionOf(content) (:94-96), so reads and contentChanges land on the current version. The stale field is read in two places:
Net effect on the number this tally exists to inform: after a conversion, reads move to v1 while hits stay on v0, skewing the per-version hit rate in both directions. Fix is one line — make version non-final/volatile and update it |
Review of #138 found three ways the link content cache can keep serving content this process has already replaced. Invalidation ran at the top of save(), before the content was written and, for an atomic write, before close() renamed the temp file into place. A resolution in that window read the pre-write content and re-cached it, so the stale link was served for the full 5 minute expiry. It now runs in a finally after the stream is closed, so a failed write drops the entry too. That alone is not enough. Link content is cached per session, and GorSession.currentSession is an InheritableThreadLocal that nothing clears, so a read and a save of the same link file can land on different sessions: the save can only invalidate the cache bound to its own thread, and a resolution already in flight can put its pre-write content into another session's cache after the invalidate. Instead of trying to reach every cache, a save now records the time of the write, and a cached entry whose read started before that mark is dropped on lookup. Entries carry the time their read started, not the time they were cached: a read that opened its stream before the file was replaced holds the old content even though it finished afterwards. The marks are bounded and expire like the content caches, so a mark outlives every entry it has to retire. Two fixes in the resolution tally, which must never affect a read: - Registering the shutdown hook that dumps the tally threw IllegalStateException if the first stats-enabled resolution happened while the JVM was already shutting down, failing the read it was counting. - TrackedPath.version was fixed at first sight, so after a link was converted from simple to versioned its reads moved to the new version while its hits stayed on the old one, skewing the per-version hit rate in both directions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Went through all five. Four fixed in 3b3952a, one declined — reasoning below. 1. 2. Invalidation misses other sessions' caches — fixed. Confirmed: I went with the write-generation option rather than walking every live session cache, because a session registry does not close the whole hole: a resolution already in flight can 3. Normalising the cache key — declining, for now. Your own analysis is what convinced me: 4. 5. Stale Regression test per fix, and each one confirmed red against the pre-fix code:
Full suite green (2595 unit tests plus |
janeliutw
left a comment
There was a problem hiding this comment.
Looks good, thanks for addressing.
ENGKNOW-3770
Problem
LinkFile's link content cache had a 0% hit rate by construction.gor.driver.link.cache=trueis the default, but the cache could never serve an entry, so every link file resolution re-read the link file from storage — on a bucket already returning ~9.6M 429s per 14 days.Three facts, each verifiable:
Cache<StreamSource, String>— keyed on the object identity of the source.equals/hashCode. (FileSource.javadoes contain twohashCodeoverrides, but they belong to the innerFileSourceStream/FileSourceOutputStreamclasses, not toFileSourceitself.)PluggableGorDriver.getDataSourcereturnswrap(resolveDataSource(sourceReference))— a newExtendedRangeWrapperover a newRetryStreamSourceWrapperover a newS3Source— andhandleLinksthen callsLinkFile.load((StreamSource) source), performing exactly onelinkCache.get(source, ...).The key was therefore never equal to a previous one.
gor.driver.link.cache.session=truewas a no-op for the same reason. Two documented properties described a cache that did nothing.Found while fixing ENGKNOW-3722. Not a regression — the cache had never hit.
Fix
The two kinds of link file differ in how they get rewritten, and that is what decides whether each can be cached.
Simple link files are now cached, keyed on the link file path, with the existing 5 minute expiry. A simple link file is a bare data path, rewritten so rarely that serving one slightly stale is an accepted trade, and it is by far the more common kind — so this is where the saving is. A path key with a bounded expiry needs no metadata validation and no extra request, which is the right shape once a staleness window is acceptable.
Versioned link files are still always re-read. They are rewritten in normal operation by
appendEntryand by versioned-link GC, and a stale entry could resolve to a generation GC has already deleted — silently, rather than failing loudly.The version comes from the content, so the decision is made after the read, on the way into the cache:
LinkFileMeta.createOrLoad(content, null, false).getVersion().Why not simply key everything on the path
For versioned links that is worse than not caching. In
LinkFile.isMaxEntriesReached:fileTooLarge(content >LINK_FILE_MAX_SIZE, default 100000) bypasses both the min-count and min-age protections (defaults: 10 entries, 2 years). That is exactly the compaction behind ENGKNOW-3722, where a link file went from ~350 KB to 43 bytes. For a link file carryingDATA_LIFECYCLE_MANAGED,checkAndGCEntriesspawns a thread that deletes the superseded data urls, so a reader holding stale content can point at a file that no longer exists.Why not key on last-modified and length
Tried during ENGKNOW-3722 and reverted, with evidence.
(path, lastModified, length)looks like real validation, and four unit tests passed. The ENGKNOW-3722 integration test then caught it resolving to the pre-compaction entry after a rewrite:The key is built from
source.getSourceMetadata(), which for a freshly built source resolves through the shared S3 metadata cache — the one with its own 5 minute expiry. Both reads happened seconds apart, so it returned the pre-rewrite values, the key matched, the link cache hit, and the read to S3 never happened. No read means no 416, so the ENGKNOW-3722 self-heal never fired.Validating against a cached value is not validating: the content cache short-circuits the mechanism that repairs staleness. If versioned links are ever cached, the key has to be checked against a forced fresh HEAD, and that trade has to be measured against the 429 budget first.
Instrumentation
LinkFileCacheStats— observe-only, off unlessgor.driver.link.cache.stats=true, path map capped bygor.driver.link.cache.stats.maxpaths(default 50000), per-version summary logged on JVM shutdown.Per link file version it records reads, cache hits and distinct paths (what caching is worth), plus content changes — repeat resolutions where the content actually differed. That last number is what decides whether caching versioned links would be safe, not merely useful. Versioned links are never cached, so every resolution of one is a read and their change count is complete.
Counters are approximate under concurrency; two threads resolving the same path at once can each see the other's content as a change. That is fine for a measurement tally and not worth locking a read path over.
Three bugs the full suite caught
UTestGorWritefailed with the pre-write link target afterwrite ... -link—dbsnp.gorwheredbsnp2.gorwas expected. Not the accepted staleness window: a same-process write-then-read, where the link file is loaded (and cached), appended to, saved, and read back stale.save()now invalidates the cached content for its link file, in both the session and the fallback cache. Another process's rewrite going unnoticed until expiry is the trade; reading back content this process just replaced is not.""classified as simple and got cached, so a placeholder about to be written was served back empty and a later read reported version0instead of1— the second failure.readLimitedLinkContentreleases the read handle it opened. While the cache was keyed on the source object it pinned every source handed to it, hiding the fact that callers do not close them. Unpin them andFileSource.finalizestarts logging "Datasource closed via finalize method", nondeterministically, inside whichever test is capturing query output. Releasing it is transparent:FileSource.open()reopens lazily viaensureOpenForRead(),S3Source.close()is a no-op, andLinkFile.savereopens for writing.Tests
Written test-first; each was watched failing before the fix.
UTestLinkFileCache— a simple link file is served from cache after the file changes underneath it; a versioned one is re-read; content is keyed on the path, not on the source object; saving invalidates; an empty link file is not cached.UTestLinkFileCacheStats— repeat reads of one path count as one distinct path; a content change on one path is counted; simple and versioned are tallied separately; cache hits are counted; nothing is recorded unless enabled; the path map stays within its cap.UTestGorSessionCache— the link cache key type change, and the existing per-session isolation test.Mutation-checked. Removing
save()'s invalidation fails onlysavingALinkFileInvalidatesItsCachedContent; reverting the empty-content guard fails onlyemptyLinkFileIsNotCached../gradlew test— 2595 pass, 0 fail, 69 skipped, across two clean--rerun-tasksruns (the finalizer warning above is nondeterministic, so one green run proves little)./gradlew :drivers:integrationTestfor S3 + OCI — 88 pass against real object storesImpact
Simple link files are the common case, and each resolution of one previously cost a full read from object storage. Those now come from memory for up to 5 minutes.
Still open on the ticket, deliberately: run a representative workload with the stats enabled and decide from the measured content-change count whether versioned links can be cached too. Leaving them uncached with the numbers recorded is an acceptable outcome.
🤖 Generated with Claude Code