Background
The LAPIS /component/nucleotideMutationsOverTime (and aminoAcidMutationsOverTime) endpointscurrently fan out 2M queries to RhyDB for each request, where M is the number of mutations being tracked:
- one query per mutation for count (sequences carrying that mutation, grouped by date)
- one query per mutation for coverage (sequences with a non-N symbol at that position, grouped by date)
These are parallelised via a thread pool in LAPIS, but the O(M) request count means network and per-request overhead scales linearly with the number of mutations. For a typical request tracking 50 mutations this is 100 round-trips between LAPIS and RhyDB.
Proposed approach
Both query types can be collapsed into 2 RhyDB requests total (one for counts, one for coverage) regardless of M, using unionAll with nucleotideEquals filters:
Count query:
unionAll(
default
.filter(baseFilter && nucleotideEquals(position:=44, symbol:='T'))
.map({mutation := '44T'})
.groupBy({count:=count()}, {date, mutation}),
default
.filter(baseFilter && nucleotideEquals(position:=774, symbol:='T'))
.map({mutation := '774T'})
.groupBy({count:=count()}, {date, mutation}),
...
)
Coverage query (same shape, using hasMutation or equivalent non-N filter per position).
Each branch still uses the per-position roaring bitmap index via nucleotideEquals, so the per-mutation cost is unchanged. Crucially, RhyDB executes unionAll branches in parallel through Arrow Acero's ExecPlan (both children are added to the same plan via MakeExecNode("union", ...)) <80><94> so the server-side parallelism is preserved without LAPIS having to manage a thread pool for it.
Expected benefits
- O(1) round-trips instead of O(M): 2 requests regardless of how many mutations are tracked
- Same server-side parallelism: Arrow Acero parallelises
unionAll branches within a single execution plan
- Simpler LAPIS code: no thread pool management, no data-version consistency retries across many concurrent requests
- Better fit for direct RhyDB access: if clients ever query RhyDB directly (e.g. via the WASM build), they can issue 2 requests rather than needing to replicate LAPIS's fan-out logic
Notes
- There is currently no query text size limit in RhyDB (
query_handler.cpp line 52, see TODO #1244), so large unionAll trees are accepted. If a size limit is introduced, batching into groups of N mutations per request remains an option while still giving O(M/N) instead of O(M).
- The coverage predicate needs clarification:
hasMutation excludes both N and the reference symbol, so it undercounts coverage. The right predicate is "symbol is not N", which may need a dedicated filter or a nucleotideEquals(symbol:='N') negation <80><94> worth verifying against the current coverage definition in LAPIS before implementing.
- Amino acid mutations follow the same pattern with
aminoAcidEquals / hasAAMutation.
Background
The LAPIS
/component/nucleotideMutationsOverTime(andaminoAcidMutationsOverTime) endpointscurrently fan out 2M queries to RhyDB for each request, where M is the number of mutations being tracked:These are parallelised via a thread pool in LAPIS, but the O(M) request count means network and per-request overhead scales linearly with the number of mutations. For a typical request tracking 50 mutations this is 100 round-trips between LAPIS and RhyDB.
Proposed approach
Both query types can be collapsed into 2 RhyDB requests total (one for counts, one for coverage) regardless of M, using
unionAllwithnucleotideEqualsfilters:Count query:
Coverage query (same shape, using
hasMutationor equivalent non-N filter per position).Each branch still uses the per-position roaring bitmap index via
nucleotideEquals, so the per-mutation cost is unchanged. Crucially, RhyDB executesunionAllbranches in parallel through Arrow Acero'sExecPlan(both children are added to the same plan viaMakeExecNode("union", ...)) <80><94> so the server-side parallelism is preserved without LAPIS having to manage a thread pool for it.Expected benefits
unionAllbranches within a single execution planNotes
query_handler.cppline 52, see TODO#1244), so largeunionAlltrees are accepted. If a size limit is introduced, batching into groups of N mutations per request remains an option while still giving O(M/N) instead of O(M).hasMutationexcludes both N and the reference symbol, so it undercounts coverage. The right predicate is "symbol is not N", which may need a dedicated filter or anucleotideEquals(symbol:='N')negation <80><94> worth verifying against the current coverage definition in LAPIS before implementing.aminoAcidEquals/hasAAMutation.