fix(cache): reject the pre-#90 cache format instead of trusting it (#87 follow-up) - #95
Conversation
… follow-up) #90 stopped *writing* a region-restricted transcript set to the sidecar path. It could do nothing about the ones already on disk, and nothing rejects them. Every `<gff3>.fastvep.cache` written by a build between 2026-06-10 (`6bc6bd5`, which added the tabix read path) and 2026-08-18 (#90) may hold only the transcripts overlapping one input VCF's variants. Reading one back is the #87 failure exactly: 47,196 of 47,196 chr17 variants reported as intergenic at exit code 0, all 2,761 P/LP BRCA1 variants among them. Nothing in the file distinguishes a poisoned cache from a whole-file one: - the magic is `FSTVEP02` either way, and #90 did not bump it - the bytes after the header are the same bincode - the transcript count is plausible either way, and is not recorded anywhere to compare against - the path is keyed on the GFF3 alone - `cache_is_fresh` compares mtimes only, so a cache written *after* its GFF3 was downloaded is fresh - which is every poisoned cache, since it was written by a run that read that GFF3 So there is no check to add. The only way to tell a trustworthy cache from an ambiguous one is to stamp the guarantee into the format, which means a version bump: `FSTVEP03` means "written by a build that cannot persist a region-restricted set". What happens now - `save_cache` writes `FSTVEP03`. - `FSTVEP02` and `FSTVEP01` are recognised and refused, with an error that names the format, says what goes wrong if it were read, and says how to recover. Recognising them specifically is the point: a user with an old cache should be told to rebuild, not told their file is corrupt. - The two recovery paths that #90 built then apply unchanged and correctly: a *sidecar* cache is rebuilt from its GFF3 and republished as V3, while an explicit `--transcript-cache` stops the run, because there is nothing to substitute and annotating without it reports every variant as intergenic. Rejecting costs one rebuild, which the sidecar path does by itself. Reading costs a silently wrong annotation that nothing downstream can detect. Same trade as #88's decision to error rather than annotate against an empty transcript set. V1 is rejected for the same reason rather than kept as read-only legacy support: it predates the zstd format and therefore predates the tabix path too, so it carries the identical ambiguity. `test_legacy_gzip_cache_loads` is replaced by the test asserting it does not. Tests Six, all mutation-checked (V2 accepted again -> each fails): a_pre_90_cache_is_rejected_rather_than_read a_legacy_gzip_cache_is_rejected_with_the_same_reason a_v3_cache_written_now_round_trips the_published_magic_is_v3 a_pre_90_sidecar_cache_is_rebuilt_rather_than_trusted a_pre_90_explicit_transcript_cache_stops_the_run The two integration tests build a real FSTVEP02 cache with the real codecs and backdate its mtime an hour into the future, so the run genuinely attempts the load rather than skipping it as stale. They assert the annotation survives (`missense_variant`, no `intergenic_variant`) and that the rebuild republishes as V3. 931 tests pass, up from 926. clippy clean, rustfmt clean. Operational note: anyone holding a reference GFF3 with a sidecar cache beside it gets one rebuild on the next run. Worth mentioning in release notes, because on a 1.2 GB Ensembl GFF3 that is minutes, and someone will wonder. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Unrelated to the cache change, but master is red without it and this PR cannot be green while it is. `cargo clippy --workspace --all-targets -- -D warnings` on current stable fails on `dna.chunks_exact(3)` in `translate_seq` under `chunks_exact_to_as_chunks`, a lint new in 1.98. `as_chunks::<3>()` is also the better code: it hands `translate` a `&[u8; 3]` directly instead of rebuilding the array from a slice. Stable since 1.88, so no toolchain floor moves. Worth noting what this says about #83. `rust-toolchain.toml` pins `channel = "stable"` so that a local check means what CI means, and its commit message named the exact failure it was closing: "a branch passed clippy locally on 1.94 and failed CI on 1.97 over ten lints that only exist in the gap." That recurrence happened again here, same version, with this branch: local clippy on 1.94.1 clean, CI on 1.98 red. The file pins a *channel*, and a developer who has not run `rustup update` is still on whatever stable meant when they last did - so the gap it was written to close is still open, just narrower. A CI step that prints `rustc --version`, or a `cargo --version` check in the contributing docs, would make the drift visible rather than surprising. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rejection message for a pre-#90 `--transcript-cache` was assembled by wrapping the format explanation in the generic load-failure wording, so a user was told both "it is in the FSTVEP02 format, rebuild it" and "it is most likely truncated or corrupt", with `fastvep cache` suggested twice and a stray double period between them. The file is intact; a disk problem is not what they have. `StaleCacheFormat` makes the distinction a type rather than a string, so the explicit path can pick the right sentence with `downcast_ref` and the sidecar path keeps reporting it verbatim before rebuilding. Pinned by a new assertion that the pre-#90 error does not also blame corruption, which fails against the previous wording.
Verified before mergeSuite: 931 pass, Mutation check. With V2 accepted again (writer back to V2, gzip reader restored), 5 of the 6 new tests fail: The round-trip one can't fail under that mutation - writer and reader move together - so The failure this prevents, measured end to end. A poisoned pre-#90 sidecar (a whole-file cache built from a different GFF3, magic flipped to
Master reproduces #87 in miniature: a complete, well-formed, entirely wrong VCF at exit code 0. This branch rejects, rebuilds, republishes Also ran it against the genuine article - the Forward compatibility, since a shared reference directory will see both binaries (relevant to the One thing fixed on the branch:
|
#90 stopped writing region-restricted transcript sets to the sidecar path. It could do nothing about the ones already on disk, and nothing rejects them.
Every
<gff3>.fastvep.cachewritten by a build between 2026-06-10 (6bc6bd5, which added the tabix read path) and 2026-08-18 (#90) may hold only the transcripts overlapping one input VCF's variants. Reading one back is #87 exactly: 47,196 of 47,196 chr17 variants reported asintergenic_variantat exit code 0, all 2,761 P/LP BRCA1 variants among them.There is no check to add
Nothing in the file distinguishes a poisoned cache from a whole-file one:
FSTVEP02either way — fix(cache): three ways an incomplete transcript set became a silent wrong answer (#87, #88, #89) #90 did not bump itcache_is_freshcompares mtimes only, so a cache written after its GFF3 was downloaded is fresh — which is every poisoned cache, since it was written by a run that had just read that GFF3Both released versions are inside the affected window: v0.2.0 was tagged the same day the tabix path landed, v0.3.0 on 2026-07-28.
So the only way to tell a trustworthy cache from an ambiguous one is to stamp the guarantee into the format.
FSTVEP03means "written by a build that cannot persist a region-restricted set."What happens now
save_cachewritesFSTVEP03.FSTVEP02andFSTVEP01are recognised and refused, with an error naming the format, what goes wrong if it were read, and how to recover. Recognising them specifically is the point — a user with an old cache should be told to rebuild, not told their file is corrupt.--transcript-cachestops the run, because there is nothing to substitute and annotating without it reports every variant as intergenic.Rejecting costs one rebuild, which the sidecar path does by itself. Reading costs a silently wrong annotation nothing downstream can detect. Same trade as #88's decision to error rather than annotate against an empty transcript set.
V1 is refused for the same reason rather than kept as read-only legacy support: it predates the zstd format and therefore predates the tabix path too, so it carries the identical ambiguity.
test_legacy_gzip_cache_loadsis replaced by the test asserting it does not load.Tests
Six, all mutation-checked — with V2 accepted again, each one fails:
The two integration tests build a real
FSTVEP02cache with the real codecs and set its mtime an hour into the future, so the run genuinely attempts the load rather than skipping it as stale. They assert the annotation survives (missense_variantpresent,intergenic_variantabsent) and that the rebuild republishes as V3.931 tests pass, up from 926.
clippy --all-targets -D warningsclean,rustfmtclean.Release note worth writing
Anyone holding a reference GFF3 with a sidecar cache beside it gets one rebuild on the next run. On a 1.2 GB Ensembl GFF3 that is minutes, and someone will wonder why.
Concretely relevant to
Huang-lab/germline-plp-carrier-nf, whosesetup_fastvep.shclones fastVEPmasterunpinned and builds it, so its Minerva reference directory has a sidecar cache of unknown provenance. That pipeline is not exposed to #87 today —setup_fastvep.shdownloads the plain.gff3andgunzips it, so there is no.tbiand the whole-file path is taken — but it fans out onebsubper chunk against a single shared--gff3, so it becomes exposed the moment anyone bgzips and tabixes that GFF3 to share it with VEP. Filed as Huang-lab/germline-plp-carrier-nf#3.Separately
The
anchor_candidatesfirst-match ambiguity from #93 is filed as its own issue rather than bundled here — different subject, and it is narrow.🤖 Generated with Claude Code