Skip to content

fix(ENGKNOW-3766): guard empty tabix contig set in VcfGzTabixGenomicIterator - #136

Merged
gmagnu merged 1 commit into
mainfrom
ENGKNOW-3766-gor-empty-header-only-vcf-crashes-vcf-gz-tabix-genomic-iterator-with-no-such-element-exception
Sep 1, 2026
Merged

fix(ENGKNOW-3766): guard empty tabix contig set in VcfGzTabixGenomicIterator#136
gmagnu merged 1 commit into
mainfrom
ENGKNOW-3766-gor-empty-header-only-vcf-crashes-vcf-gz-tabix-genomic-iterator-with-no-such-element-exception

Conversation

@gmagnu

@gmagnu gmagnu commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Problem

A header-only bgzipped VCF (valid header, zero data rows) produces a valid tabix index with an empty sequence dictionary. Reading one throws instead of returning zero rows:

org.gorpipe.exceptions.GorSystemException: class java.util.NoSuchElementException: null
  at java.util.TreeMap$KeyIterator.next(TreeMap.java:1540)
  at org.gorpipe.gor.model.VcfGzTabixGenomicIterator.hasNext(VcfGzTabixGenomicIterator.java:150)

init() builds the chromosome map purely from reader.getChromosomes() — the contigs listed in the .tbi index, never from the VCF body — then sets hgSeekIndex = chrs.keySet().iterator(). With an empty index that map is empty, and both hasNext() and next() called hgSeekIndex.next() with no guard, so the very first hasNext() threw before any row was read.

seek() was already guarded (while (hgSeekIndex.hasNext())), which is why gor -p chr1 <file> returned zero rows cleanly on the same file while an unpositioned gor <file> threw. That asymmetry is a quick confirmation test.

Introduced in bad1981 (2022-08-16), present through 36bd22a.

Repro

printf '##fileformat=VCFv4.2\n#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\n' | bgzip > empty.vcf.gz
tabix -p vcf empty.vcf.gz
tabix -l empty.vcf.gz          # prints nothing - zero contigs
gorpipe 'gor empty.vcf.gz | top 5'

Expected zero rows; got the exception above.

Fix

  • hasNext() guards hgSeekIndex.hasNext() before advancing and returns false once the contig set is exhausted. The tail recursion over chromosomes becomes a while loop while we're in there — same behavior, no stack growth on many-contig files.
  • next() delegates to hasNext() and returns null when exhausted. This removes the second unguarded call site and also fixes a latent createRow(null) NPE when the current chromosome's iterator drained without a preceding hasNext().

Returning null on exhaustion preserves the existing contract and matches the sibling GorGzGenomicIterator. That iterator was checked for the same pattern and has no unguarded call.

A try/catch (NoSuchElementException) was considered instead. Rejected: the guard costs one field-null-check per chromosome rollover (not per row), while catching would also swallow a NoSuchElementException raised for a genuine reason deeper in htsjdk, turning a real bug into a silent empty result.

Tests

Four regression tests in UTestVcfTabixGenomicIterator. The header-only .vcf.gz and its index are built at test time with BlockCompressedOutputStream + IndexFactory.createTabixIndex into the TemporaryFolder rule's work dir, so no test-data submodule change is needed.

  • hasNext() is false
  • next() is null
  • the header still parses
  • gor <file> | top 5 runs end to end and yields header-only output

Verified as a proper red/green cycle: with the production fix stashed the three behavioral tests fail with the exact stack from the report; restored, all pass. The header test passes either way, confirming header parsing was never at fault.

  • ./gradlew :model:test — 1514 pass, 17 skipped
  • ./gradlew :gortools:test for the tabix/VCF consumers (UTestGorTabix, UTestGorVcfWithHtsjdk, UTestNor, UTestProcessSource) — 73 pass, 13 skipped

Impact

Any header-only VCF — for example a caller that legitimately emits no calls for a sample — surfaced as an opaque system exception with a null message instead of an empty result, giving no indication that the file is simply empty and sending triage toward the storage layer, which was not at fault.

🤖 Generated with Claude Code

…terator

A header-only bgzipped VCF produces a valid tabix index with an empty
sequence dictionary. The iterator builds its chromosome map purely from
the index contigs, so that map is empty, and both hasNext() and next()
advanced hgSeekIndex without a hasNext() guard. The first call threw
NoSuchElementException, surfacing as an opaque GorSystemException with a
null message rather than an empty result. seek() was already guarded,
which is why "gor -p chr1 <file>" returned zero rows cleanly on the same
file while an unpositioned "gor <file>" threw.

Guard the iterator in hasNext() and return false once the contig set is
exhausted, and flip the tail recursion over chromosomes to a loop. next()
now delegates to hasNext(), which removes the second unguarded call site
and also fixes a latent createRow(null) NPE when the current
chromosome's iterator drained without a preceding hasNext().

Add regression tests covering a header-only bgzipped VCF built at test
time: hasNext() is false, next() is null, the header still parses, and
the query runs end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Junit Tests - Summary

4 772 tests  +4   4 601 ✅ +4   17m 9s ⏱️ - 1m 15s
  489 suites ±0     171 💤 ±0 
  489 files   ±0       0 ❌ ±0 

Results for commit e8b42db. ± Comparison against base commit 36bd22a.

@gmagnu
gmagnu merged commit d828019 into main Sep 1, 2026
14 checks passed
@gmagnu
gmagnu deleted the ENGKNOW-3766-gor-empty-header-only-vcf-crashes-vcf-gz-tabix-genomic-iterator-with-no-such-element-exception branch September 1, 2026 23:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants