SACGF / SACGF/variantgrid

Analysis node grid: small gene-filtered source nodes time out — gene semi-join planned last

Open
#1,728 1 comment 0 reactions 0 assignees View on GitHub
Analysis
Dominant language
Python
Stars
30
Forks
3
Avg merge
9h 28m
Merged PRs (30d)
42

Description

🤖 Written by Claude

Small gene-filtered source nodes time out in the node grid. Rollbar shows repeated `OperationalError: canceling statement due to statement timeout` from `node_grid_handler` on `vgaws` (code version `9be0ee53b`), 2026-08-13/14.

Both nodes are `AllVariantsNode` restricted to gene symbol **KIF5B**, with tiny result sets:

| analysis | node | build | count |
|---|---|---|---|
| 12062 | 80816 | GRCh38 | 1074 |
| 12063 | 80818 | GRCh37 | 1153 |

(a third node in the report, 80857, has since been deleted)

These are far below `ANALYSIS_GRID_SORT_MAX_ROWS` (10,000), so sorting stays enabled — correctly. This is **not** the #1651 sort problem; the sort is not what blows up.

## Root cause: the gene filter is applied last

`VariantTranscriptAnnotation.get_overlapping_genes_q()` (`annotation/models/models.py:2223`) emits the gene restriction as a subquery:

```sql
pk IN (SELECT variant_id
FROM annotation_variantgeneoverlap_version_90
WHERE gene_id = ANY('{ENSG00000170759,3799,ENSG05220049235}') AND version_id = 90)
```

That inner scan returns 6,080 rows and costs ~6,700 — it is by far the most selective thing in the query. But Postgres plans it as a **semi-join applied at the very end**. It first builds the entire annotated variant relation for the build:

- parallel seq scan of `snpdb_locus` (43M rows)
- parallel seq scan of the `snpdb_variantzygositycount` partition (12M rows, from the node's zygosity count filter)
- sort + merge against all 4.4M rows of `annotation_clinvar_version_212`
- the `variantannotation` / `transcriptversion` / `geneversion` / `gene` / `hgnc` / `uniprot` / `dbnsfp` joins

...roughly **5M rows**, and only then semi-joins down to ~350. Estimated total cost 20.8M for node 80818 and 14.9M for 80816.

The trigger is Postgres's default `join_collapse_limit` / `from_collapse_limit` of **8**. The grid SQL is ~16.7 KB over **~18 relations** in the main join tree, so the planner cannot search the join-order space and is stuck with a left-deep order that applies the one selective predicate last.

## Measurements (EXPLAIN ANALYZE against prod, node 80818, `extra_filters=default`, `sidx=pk`)

| variant | planning | execution |
|---|---|---|
| baseline — what production runs | — | **timed out at 180s** |
| `enable_mergejoin=off` | — | **timed out at 180s** |
| `join_collapse_limit = from_collapse_limit = 20` | 378 ms | **260 ms** |
| explicit `Q(pk__in=[…1153 ids])` | 35 ms | **125 ms** |

Turning off merge join does not help, so the merge semi-join is a symptom rather than the cause. Materialising the node's PKs takes 0.1s, so the explicit-PK form is ~0.25s end to end, versus the 5-minute `MAJOR_OPERATION_STATEMENT_TIMEOUT_SECONDS` cancellation users are currently hitting.

With explicit PKs the plan becomes a bitmap index scan on `snpdb_variant_pkey` for the 1153 IDs, then nested loops for every annotation join — each annotation touched once per output row at any node size.

## Why the existing #546 mitigation doesn't fire

`AnalysisNode.get_small_parent_arg_q_dict()` (`analysis/models/nodes/analysis_node.py:571`) already performs exactly this substitution, and its docstring describes exactly this failure mode. But it only applies to a node's **parents**. These are source nodes with no parents, so it never runs. Separately, `ANALYSIS_NODE_STORE_ID_SIZE_MAX` is 1000, just below both node counts (1074, 1153), so even the parent path would have declined them.

## Scope

Not specific to KIF5B or to `AllVariantsNode`. The same `pk__in=(subquery)` shape against a mostly-unfiltered variant relation is used by `GeneListNode`, `MOINode`, `TissueNode`, `PhenotypeNode`, `TrioNode` and `QuadNode`.

## Possible fixes

1. **Extend the #546 explicit-PK substitution to a node's own filters in the grid** — when `node.count` is known and small, materialise PKs via the existing `AnalysisNode.get_cached_node_pks()` and filter the grid queryset on `Q(pk__in=[...])`. Fastest measured option, and robust because it doesn't depend on the planner finding a good join order. Needs a threshold decision — `ANALYSIS_NODE_STORE_ID_SIZE_MAX` (1000) excludes these very nodes; `ANALYSIS_GRID_SORT_MAX_ROWS` (10,000) is the natural bound since the grid already treats that as "small enough to handle row-wise".
2. **Raise `join_collapse_limit` / `from_collapse_limit`** for grid connections. One-line change, 700x improvement measured — but at ~18 relations it crosses `geqo_threshold` (12), so GEQO would pick plans non-deterministically. A stability sweep across collapse limits 10/12/16/20/30, with and without `geqo`, is still running; results to follow.

Related: #1651, #1628, #1279, #1176, #546.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with AnalysisNode.get_small_parent_arg_q_dict() and get_cached_node_pks() in analysis/models/nodes/analysis_node.py, then inspect VariantTranscriptAnnotation.get_overlapping_genes_q() in annotation/models/models.py. Reproduce the node-grid query and compare the proposed small-node substitution with the planner-setting alternative; done means small source-node grids avoid statement timeouts across the listed node types without breaking the existing row and sorting limits.

Written by the indexing model from the issue text.

Assessment

Tech stack
postgresql, python
Domain
backend, databases, performance
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.