Skip to content

Speed up the loader, make Redis persistence explicit, and reorganize loader docs#391

Merged
gaurav merged 6 commits into
mainfrom
load-faster
Jul 15, 2026
Merged

Speed up the loader, make Redis persistence explicit, and reorganize loader docs#391
gaurav merged 6 commits into
mainfrom
load-faster

Conversation

@gaurav

@gaurav gaurav commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Why

A full mode: load takes 0.5–1.5 days. Reading the loader together with the
translator-devops charts, the ceiling is the two single-threaded,
write-heavy
Redis instances — eq_id_to_id_db and id_to_eqids_db
(150–220 GB) — that every compendium Job writes to at once. On top of that,
those instances ran --save 300 1000000, so during the write storm each hot DB
forked a multi-GB BGSAVE every ~5 min — pure copy-on-write waste, because the
backend DBs are write-once and persisted by a deliberate BGSAVE at the end.

Loader changes (low-risk, in-repo)

  • Disable periodic RDB snapshots during load. load_all calls
    disable_periodic_save()CONFIG SET save "" on each backend at load start.
    Best-effort (logs & continues if CONFIG SET is refused). Accepted trade-off: a
    crashed load leaves a DB empty (re-runnable), not partial.
  • pipeline(transaction=False) everywhere — a bulk load isn't an atomic
    update, so the per-block MULTI/EXEC framing is pure overhead.
  • Count source prefixes once per line (_accumulate_source_prefixes) instead
    of re-splitting every identifier once per Biolink ancestor type
    (was O(ancestors × identifiers)).

Deferred to v2.6.0 as issues (the riskier / bigger levers): MSET batching
(#387, held back because it's the one change that could silently drop data —
needs a correctness test first), redis-py 3.5.3→4/5 upgrade (#388), and
overlapping the four per-block flushes (#389).

Companion chart change — translator-devops #1044

Persistence is made explicit rather than periodic (no save <sec> <changes>
policy can snapshot only after a populate without also forking repeatedly
during it):

  • redis-r3-external: save "" on all seven instances (no automatic snapshots),
    appendonly no kept. mode: load disables saves at runtime (this PR) and
    BGSAVEs manually at the end; mode: restore BGSAVEs at the end of run_pipe.sh.
  • run_pipe.sh: the end-of-restore BGSAVE was fire-and-forget; it now waits
    for completion (LASTSAVE advances, rdb_last_bgsave_status:ok) and fails
    loudly on error/timeout, so a restored DB is guaranteed persisted before the
    Job exits. Addresses mode: restore BGSAVE is fire-and-forget; restored DB may not persist #390 (which will be closed when #1044 merges — GitHub can't auto-close it across repos).

Docs reorg

documentation/Development.md mixed loader internals, general dev setup, and the
frontend. Split into Loader.md (everything loader: the load process, the
write-once lifecycle, why persistence is explicit, the perf choices, a local
benchmark recipe, integration-test gotchas, the requests/docker landmine, the
v2.6.0 backlog) and CONTRIBUTOR.md (two-tools overview, running locally,
tests, a short Frontend section, the v2.5.0 backlog). References in CLAUDE.md
and the loader docstring updated.

Testing

  • pytest -m "not integration" — loader unit tests pass, incl. a new
    test_accumulate_source_prefixes equivalence test.
  • pytest -m integration — the loader integration test passes end-to-end through
    the new load_all (which exercises disable_periodic_save) against a real Redis.
  • Verified against a real Redis that CONFIG SET save "" empties the save schedule.
  • Local before/after benchmark on 2M lines of Protein.txt.02: ~11% from the
    client-side changes alone, output byte-identical (identical per-DB DBSIZE).
  • Pre-existing failures in test_norm::test_empty / test_callback are unrelated
    (confirmed identical on the base commit).

🤖 Generated with Claude Code

gaurav and others added 2 commits July 15, 2026 02:49
The full mode:load takes 0.5-1.5 days, bounded by the two single-threaded,
write-heavy Redis instances (eq_id_to_id_db, id_to_eqids_db) that every
compendium Job writes to at once. Three low-risk loader changes:

- disable_periodic_save(): load_all issues CONFIG SET save "" on each backend
  at load start. The default `save 300 1000000` forks a multi-GB BGSAVE every
  few minutes during the write storm, which is wasted work: the backend DBs are
  write-once and persisted by a manual BGSAVE per the loader SOP. Best-effort
  (logs and continues if CONFIG SET is refused) and auto-scoped to mode:load,
  so mode:restore instances keep their periodic-save safety net.
- pipeline(transaction=False) everywhere: this is a bulk load, not an atomic
  update, so the per-block MULTI/EXEC framing is pure overhead.
- _accumulate_source_prefixes(): count a line's CURIE prefixes once and fold
  them into every implied Biolink type, instead of re-splitting each identifier
  once per ancestor type (was O(ancestors x identifiers) per line).

Also ignore the documented nodeNormalization-env venv.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
documentation/Development.md mixed loader internals, general dev setup, and the
frontend. Split it:

- Loader.md: everything loader — mode:load/restore, how a load runs, the
  write-once DB lifecycle, why saves are disabled during load, the perf choices
  (transaction=False, prefix loop), the integration-test gotchas, the
  requests/docker landmine, and the v2.6.0 loader-speed backlog (#387-390).
  Absorbs the load-process content.
- CONTRIBUTOR.md: two-tools overview, running locally, tests, a short Frontend
  section, and the project-wide v2.5.0 backlog (#379-383).
- Development.md: removed.

Updates the references in CLAUDE.md and the loader module docstring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gaurav and others added 2 commits July 15, 2026 04:59
Resolves conflicts from main's loader-overhaul (#384) landing:

- loader.py: kept main's biolink_version pinning, yaml.safe_load,
  config.get("password"), fail-fast get_compendia, and unfiltered
  keys("file-*"); kept this branch's disable_periodic_save,
  transaction=False pipelines, and once-per-line prefix accumulation.
  Aligned disable_periodic_save onto yaml.safe_load for consistency.
- tests/test_loader.py: combined both sides' imports and tests.
- documentation/Development.md was split on this branch into Loader.md +
  CONTRIBUTOR.md; folded main's Development.md updates (biolink_version
  config key, the `/` and `/1.5/` frontend paths, and the expanded #380
  merge race note) into those two files.

Loader unit + integration tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Captures the local benchmark recipe (subset via head, redis:6.2-alpine,
drive load_all with a temp config.json) and the three non-obvious gotchas:
warm the bmt toolkit before timing (it downloads the pinned biolink model),
CONFIG SET save is server-wide and sticky across runs, and identical per-db
DBSIZE across runs is a free correctness check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread documentation/Loader.md Outdated
gaurav and others added 2 commits July 15, 2026 05:08
Rework "Why the loader disables periodic saves" into "Why persistence is
explicit, not periodic": the redis-r3-external instances are now configured
with `save ""` (no automatic snapshots), and persistence is driven explicitly
per mode — mode:load disables saves and BGSAVEs manually; mode:restore's
run_pipe.sh now BGSAVEs and waits. Mark #390 as addressed in the chart.

The chart side lives in translator-devops PR #1044.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gaurav gaurav changed the title Speed up the loader; disable Redis snapshots during load; reorganize loader docs Speed up the loader, make Redis persistence explicit, and reorganize loader docs Jul 15, 2026
@gaurav
gaurav requested a review from Copilot July 15, 2026 09:33
@gaurav
gaurav merged commit 3d49cdd into main Jul 15, 2026
2 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in NodeNorm sprints Jul 15, 2026
@gaurav
gaurav deleted the load-faster branch July 15, 2026 09:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR speeds up the NodeNorm loader’s Redis write path and makes Redis persistence behavior explicit during mode: load, while reorganizing developer/loader documentation to separate loader-specific operational guidance from general contributor setup.

Changes:

  • Disable Redis periodic RDB snapshots during non-test loads (CONFIG SET save "") and remove pipeline MULTI/EXEC overhead via pipeline(transaction=False).
  • Optimize per-line CURIE-prefix aggregation by counting prefixes once per line (_accumulate_source_prefixes) and add a unit test to pin equivalence.
  • Reorganize docs: split prior Development.md into documentation/Loader.md and documentation/CONTRIBUTOR.md, and update references.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/test_loader.py Updates fake Redis pipeline signature and adds a unit test for _accumulate_source_prefixes.
node_normalizer/loader/loader.py Adds disable_periodic_save(), uses pipeline(transaction=False) throughout, and optimizes prefix stats accumulation.
node_normalizer/loader/__init__.py Exposes disable_periodic_save as part of the loader public API.
documentation/Loader.md New loader-focused operational/performance doc, including explicit persistence rationale and testing notes.
documentation/Development.md Removed in favor of the new split docs.
documentation/CONTRIBUTOR.md New general contributor guide for running locally, tests, and frontend overview.
CLAUDE.md Updates documentation pointers and loader behavior summary.
.gitignore Ignores nodeNormalization-env/.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +109 to +110
Turn off automatic RDB snapshots (`CONFIG SET save ""`) on every backend
Redis for the duration of the load.
Comment on lines 318 to +321
if test_mode == 1:
logger.debug("Test mode enabled. No data will be produced.")
else:
disable_periodic_save()
gaurav added a commit that referenced this pull request Jul 15, 2026
## Context

NodeNorm picks a preferred label for every clique. What began as "first
label in the list" has grown into a ~90-line algorithm in
`create_node()` (conflation label-hunting, boost-prefix sorting,
CHEMBL/length filtering) that tries to *replicate* the label Babel would
pick. Babel now computes this itself and emits a clique-level
`preferred_name` on every compendium line, so per #299 NodeNorm should
just **use that field**.

The catch: `preferred_name` is clique-level and was **stored nowhere**
in Redis — the loader persists per-CURIE `l`/`d`/`t` inside the
`id_to_eqids_db` blob, and clique-level `type`/`ic` in their own DBs,
but not `preferred_name`.

This is adjacent to #306 (consolidate all clique-level properties into
one DB). Rather than the full overhaul, this takes the first step:
**repurpose the existing `info_content_db` (db 5) into a clique-property
store** whose value is a JSON dict. For 2.5 it holds `preferred_name`
and `ic`; later (#306) `type` etc. move in. No new database is
provisioned — db 5's values just get richer.

> **Not** `taxa`, though: the clique-level `taxa` list is just the union
of the per-identifier `t` fields, which the loader already persists
inside the `id_to_eqids_db` blob. Copying it into db 5 would be
redundant, so taxa are read from `id_to_eqids_db` instead. This is
recorded in a new `node_normalizer/loader/CLAUDE.md` so future edits
don't re-add it.

> **Rebased onto the loader overhaul.** This originally stacked on #384;
`load-faster` (#391) has since been merged into this branch, so it now
targets `main` directly and includes the reorganized
`node_normalizer/loader/` package.

## What changed (by commit)

1. **Store `preferred_name` in Redis** — `load_compendium` writes
`{"preferred_name", "ic"}` JSON to db 5 for every clique (previously
only IC-bearing cliques got a key). Config key name kept to avoid churn;
rename to `clique_props_db` deferred to #306. Integration test asserts
the new format.
2. **Use it, keep the old algorithm as a frozen fallback** —
`create_node` uses the stored `preferred_name` (looked up by the primary
subclique's canonical id `eids[0]['i']`, so conflated cliques get the
gene-first name). When absent — e.g. querying an older DB load — it
falls through to the legacy algorithm, now marked **do-not-modify** and
slated for deletion once every deployment loads `preferred_name`
(Babel's replacement:
[`babel_utils.py#L581`](https://github.com/NCATSTranslator/Babel/blob/16e370be4ba1b9f69b1ce091ccbb111941f35016/src/babel_utils.py#L581)).
A new `_clique_props()` helper reads db 5, tolerating both the new JSON
and the old bare-float format.
3. **Read-path unit tests + tidy-ups** — added `test_normalizer.py` unit
tests covering `create_node`'s new branch (see Verification), aligned
the new db-5 read on `encoding='utf8'` with its neighbours, and fixed an
"algorithem" comment typo in the frozen fallback (Copilot review).

## Verification

- `pytest -m "not integration"` → 29 passed (query fixtures have no
`preferred_name`, so they exercise the fallback unchanged — no
regression). New `test_normalizer.py` unit tests exercise the read path
directly (no Redis — only the conflation fallback touches it):
- stored `preferred_name` is used verbatim for the label (and bypasses
the fallback's CHEMBL/length filters)
  - an empty `""` preferred_name falls through to the legacy algorithm
- `_clique_props()` tolerates the new JSON dict and the legacy
bare-float/int formats
- under conflation, `get_normalized_nodes` returns the **leading**
sub-clique's identifier and `preferred_name` (mock-Redis: a gene/protein
clique resolves to the gene's name, not the queried protein's)
- `pytest -m integration` (real Redis testcontainer) → passes. The
loader test covers all three clique properties end-to-end:
- **preferred_name** — empty `""` (Cell) and populated (Disease) via db
5
- **ic** — string `"100"` (Cell) and float `100.0` (Disease) via db 5,
tolerating both source formats
- **taxa** — a human-taxon clique (PhenotypicFeature) whose
per-identifier `t` field round-trips via `id_to_eqids_db`, confirming
taxa are retrievable without a db-5 copy
- **Gene/protein conflation, end-to-end on real Babel data** (the CDK2
clique: `NCBIGene:1017` conflated with four UniProtKB proteins,
gene-first) — two integration tests over the same freshly-loaded Redis:
- *loading* — every clique member maps to the gene-first list in
`gene_protein_db`, and the gene's `preferred_name` "CDK2" lands in db 5
- *querying* — the real frontend `RedisConnectionFactory` is attached to
the loaded Redis and `get_normalized_nodes` is driven directly: any
protein in the clique normalizes to `NCBIGene:1017` / "CDK2" under
conflation, and keeps its own identity/name without it

## Scope / follow-ups

- Renaming `info_content_db` → `clique_props_db` and migrating `type`
(etc.) into it: deferred to #306. The JSON value makes each a one-liner
on both sides. `taxa` is deliberately excluded — it stays in
`id_to_eqids_db` (see above).
- **`ic` type is inconsistent in the source data** — Babel emits it as a
JSON string in some compendia (`"100"`) and a number in others
(`100.0`), and the loader passes it through untyped, so db 5 stores
whichever came in. `_clique_props()` tolerates both. Normalizing to
float on write is a candidate cleanup if any consumer starts doing math
on it.
- `preferred_name_boost_prefixes` / `demote_labels_longer_than` config
stays (used only by the frozen fallback); delete alongside the fallback.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants