Speed up the loader, make Redis persistence explicit, and reorganize loader docs#391
Merged
Conversation
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>
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>
gaurav
commented
Jul 15, 2026
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>
Contributor
There was a problem hiding this comment.
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 viapipeline(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.mdintodocumentation/Loader.mdanddocumentation/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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A full
mode: loadtakes 0.5–1.5 days. Reading the loader together with thetranslator-devopscharts, the ceiling is the two single-threaded,write-heavy Redis instances —
eq_id_to_id_dbandid_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 DBforked 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)
load_allcallsdisable_periodic_save()→CONFIG SET save ""on each backend at load start.Best-effort (logs & continues if
CONFIG SETis refused). Accepted trade-off: acrashed load leaves a DB empty (re-runnable), not partial.
pipeline(transaction=False)everywhere — a bulk load isn't an atomicupdate, so the per-block MULTI/EXEC framing is pure overhead.
_accumulate_source_prefixes) insteadof 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 nokept.mode: loaddisables saves at runtime (this PR) andBGSAVEs manually at the end;
mode: restoreBGSAVEs at the end ofrun_pipe.sh.run_pipe.sh: the end-of-restore BGSAVE was fire-and-forget; it now waitsfor completion (
LASTSAVEadvances,rdb_last_bgsave_status:ok) and failsloudly 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.mdmixed loader internals, general dev setup, and thefrontend. Split into
Loader.md(everything loader: the load process, thewrite-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.mdand the loader docstring updated.
Testing
pytest -m "not integration"— loader unit tests pass, incl. a newtest_accumulate_source_prefixesequivalence test.pytest -m integration— the loader integration test passes end-to-end throughthe new
load_all(which exercisesdisable_periodic_save) against a real Redis.CONFIG SET save ""empties the save schedule.Protein.txt.02: ~11% from theclient-side changes alone, output byte-identical (identical per-DB
DBSIZE).test_norm::test_empty/test_callbackare unrelated(confirmed identical on the base commit).
🤖 Generated with Claude Code