This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Roger is an automated graph-data curation pipeline that transforms KGX (Knowledge Graph Exchange) files into a FalkorDB/RedisGraph database, orchestrated by Apache Airflow (3.x). It processes biomedical datasets (TopMed, BDC, AnVIL, dbGaP, HEAL, RADx, SPARC, NIDA, KFDRC, CRDC, CTN, BACPAC, etc.) through annotation (via Dug), Elasticsearch indexing, KGX merge/normalization, schema inference, and bulk loading.
Two largely independent workloads live here:
annotate_and_index— per-dataset Dug annotation, TranQL concept expansion, KGX conversion, and Elasticsearch indexing. This is the incremental one (see below).knowledge_graph_build— the KGX merge → schema → bulk-load → validate chain into FalkorDB.
make install # pip install -r requirements.txt (Python 3.12 in CI/Docker)
make test.lint # flake8 dags (CI uses: flake8 --ignore=E,W dags)
make test.unit # pytest tests/unit
make test.integration # pytest tests/integration
make test # unit + integration
# Single test
python3 -m pytest tests/unit/test_config.py::test_merge -v
# Local stack (Airflow UI on :8080, Postgres, Elasticsearch :9200, Redis Stack/FalkorDB :6379)
make stack.init # docker-compose up airflow-init (first time)
make stack # docker-compose up
make clean # wipe logs and local_storageThe package source lives in src/roger/; the Docker image sets PYTHONPATH=/opt/airflow/dags/repo/src/ so DAGs can import it.
make buildand anything usingVERSIONread./dags/_version.py, which does not exist. The real version isversion = 0.10.4.2insetup.cfg. Bump that and build/tag the image manually, or restoredags/_version.py(notedags/__init__.pystill doesfrom ._version import version as __version__, so that import is also dead).test.docand parts ofrm_dirsreference a removeddags/roger/copy.
There are no roger dependencies installed on the host — no Airflow, no Dug, no avalon. The system python3 is 3.11 and Dug requires 3.12 (typing.override). Run everything in a container instead.
Fastest path is the already-built roger image, which has every dependency including the exact Dug/dug_data_model versions in production:
docker run --rm -v $PWD:/repo:ro -w /repo --user root \
--entrypoint bash containers.renci.org/helxplatform/roger:<tag> -c \
"pip install -q pytest flake8; PYTHONPATH=/repo/src python -m pytest tests/unit -q; \
flake8 --ignore=E,W dags"Alternative (no image handy): a python:3.12-slim-trixie container plus a full pip install of requirements.txt's git dependencies — works, but takes ~5 minutes with a cold pip cache.
Notes:
dags/__pycache__is root-owned by docker, so hostpy_compileofdags/fails with EACCES. Useast.parsefor a syntax-only check.- Mount
:rofor reading/tests; mount read-write and pass--user $(id -u):$(id -g)for anything that writes, so files stay owned by you.
GET KGX files → MERGE nodes (dedup) → CREATE SCHEMA (type inference)
→ CREATE BULK CSVs → BULK LOAD into FalkorDB → VALIDATE (test queries) → CHECK TRANQL
src/roger/core/— graph operations.base.pyhas theRogerorchestrator class and top-level functions (get_kgx,merge_nodes,create_schema,create_bulk_load,bulk_load,validate).bulkload.pygenerates CSVs for the FalkorDB bulk loader;redis_graph.pywraps thefalkordbSDK;storage.pyowns all path conventions and file globbing.src/roger/models/kgx.py— KGX merge and schema inference. Type-conflict rules: bool/float/int conflicts → string; any list value → list.src/roger/pipelines/— one class per dataset, all inheritingDugPipeline(base.py). A pipeline definespipeline_name, optionalparser_name,input_version, file discovery, and the annotate/crawl/index/KGX-convert steps. Seesrc/roger/pipelines/README.mdfor adding a new dataset.src/roger/tasks.py— all Airflow glue: config injection, LakeFS I/O, incremental state, and the DAG-building helpers. The biggest and most subtle file in the repo.dags/—knowledge_graph_build.py,annotate_and_index.py,index_only.py.src/roger/cli.py— non-Airflow CLI:python3 -m roger --get-kgx --merge-kgx --create-schema --create-bulk --insert --validate -d <data_dir>.scripts/— one-off maintenance tooling; see the data-model migration section.
File-producing work and Elasticsearch work are deliberately separate task groups, because lakefs is the source of truth and ES is a derived index that gets rebuilt wholesale:
init
└─> {name}_dataset_pipeline_task_group (one per dataset, incremental)
annotate_{name}_files
├─> make_kgx_{name}
└─> crawl_{name}
└─> complete_{name} (trigger_rule="none_failed")
└─> wipe_es_indexes (ONE global task, all datasets)
└─> {name}_es_index_task_group (one per dataset, always full)
index_{name}_variables ─> validate_{name}_index_variables
index_{name}_concepts ─> validate_{name}_index_concepts
└─> complete_{name}
└─> finish (trigger_rule="none_failed")
Why it is split this way:
- ES index names are global and shared across every dataset (see
indexing.*_indexinconfig.yaml). A per-dataset wipe would destroy sibling datasets' documents, so there is exactly onewipe_es_indexestask sitting between the file groups and the ES groups. - The file groups run incrementally. The ES groups are built with
incremental_pull=False, so they always pull the complete set of annotate/crawl outputs from lakefs and reindex from scratch. That is what makes upstream deletions work: a file removed from a source repo is removed from lakefs, so it simply isn't there during the rebuild and its documents never come back. No per-document ES bookkeeping. - ES document ids are stable (
element.id,concept_id), so reindexing is an upsert and retries are safe.
Caveat: the wipe is unconditional. Every run that reaches it empties all indexes and then repopulates them. If a file group fails and its ES group is skipped, the indexes end up partially populated until the next successful run.
dags/index_only.py re-indexes Elasticsearch from annotate_and_index outputs already committed to the runtime repo — for example after merging the dev runtime branch into prod. No annotate or crawl re-runs. create_index_only_taskgroup pulls inputs by explicit path ({ANNOTATE_DAG_ID}/{group_id}.{task_id}/) from the configured runtime repo/branch, with params={"incremental": False}.
Note the input pairing, which is easy to get wrong: validate_indexed_variables reads only annotated elements, while validate_indexed_concepts pairs expanded concepts (crawl output) against annotated elements (annotate output) and asserts matching counts — so it needs both prefixes.
The problem: annotate_{slug}_files downloads from external LakeFS repos that keep growing, and re-annotating everything on every run is expensive for no gain. Since lakefs is version-oriented, each task instead diffs against the last commit it successfully consumed.
Per-task Airflow Variables, keyed by incremental_state_key():
roger_incr::{dag_id}::{task_id}::{repo}@{branch}
Use from airflow.sdk import Variable — airflow.models.Variable does direct DB access and is blocked on Airflow 3.x workers (tasks.py imports the SDK one with a fallback).
State is only advanced after a successful commit+merge, inside
avalon_commit_callback's try block, so a failed merge can never mark
unprocessed commits as consumed. That merge failure is re-raised rather than
logged and dropped: the clean_up at the end of the callback deletes the
local output, so swallowing it destroyed the work and reported success.
setup_input_data(thepre_executehook) groups the task's configured repos by(repo, branch). For each group it resolves the ref tip, reads the last-consumed commit from the Variable, and diffs.resolve_ref_tipusesrefs_api.log_commits(ref, amount=1)— notbranches_api.get_branch, because dataset version refs (e.g.topmed:v2.0) are frequently tags, not branches.get_changed_filesdoes the diff roger-side viarefs_api.diff_refs+pagination_helper, keepingremovedentries (avalon's ownget_changesdrops them) and prefix-filtering with trailing-slash normalization.'*'or empty means no filter;conflict/prefix_changedare ignored.- Downloads are pinned to the resolved tip commit, not the branch name, so a push mid-run can't produce a torn read.
- First run,
incremental=False,incremental_pull=False, or aNotFoundExceptionall fall back to a fullget_files(changes_only=False). - If nothing changed in any group, the task raises
AirflowSkipException, which skips it and cascades through the group.complete_{name}andfinishusetrigger_rule="none_failed"so a skipped group still finishes green. write_state_filepersists the resolved tips to a JSON file next to the task dir (generate_dir_name_from_task_instance(..., suffix='state')). This file is the channel frompre_executeto the success callback — the callback must never re-resolve the branch itself, or commits that landed mid-run would be marked consumed without being processed.
Downstream tasks are incremental the same way, diffing roger's own output-repo prefixes ({dag_id}/{upstream_task_id}/).
When the diff reports removals, avalon_commit_callback maps them to derived outputs and deletes those on the temp branch before the merge, so removals land atomically with the rest of the commit:
removed_bases()maps a removed source path to a base name — first path segment under{dag}/{task}/for roger-repo paths, basename minus extension for external repos.stale_output_paths()matches existing outputs asrel.startswith(base + '/')orrel == f"{base}_kgx.json".- Deletion goes through
objects_api.delete_objects(PathList(...))+ acommits_api.commit.
This is a heuristic, deliberately fail-safe: archive-style sources (one tarball expanding into many files) won't map 1:1, so nothing is deleted for them rather than the wrong thing being deleted. A .removed_files.json manifest is also written on every run with state — as a dotfile, because storage.py readers glob *.json/**/*.json over pulled task outputs and glob skips dotfiles. Writing it every run (not just on removals) guarantees put_files has content even on a removal-only run.
find_sibling_files() handles a wrinkle specific to dbGaP data dicts: the parser needs a sibling GapExchange_<dir> file in the same lakefs directory for study name/description, but an incremental diff only carries the changed data dicts. So each affected directory is listed and any marker file not already downloaded is pulled in.
params={"incremental": false} on a DAG run forces a full pull everywhere. Per-task, create_python_task(..., incremental_pull=False) opts a single task out permanently (this is what the ES groups use).
Annotation is the dominant cost in the whole repo, and two structural facts explain nearly all of it.
dbGaP parsers emit the study element into every data-dict file. So a study
with 55,000 data dicts annotates its study description 55,000 times.
Measured on bdc-parent (61,597 XML files, 24 distinct studies; Framingham
phs000007.v35.p16 alone is 54,986 files):
| per file | |
|---|---|
| study element (the same one every time) | ~53.5 s |
| variable element (what the file actually contributes) | ~1.4 s |
That is 55 s/file, 39 days for the dataset, 96% of it recomputing 24 answers.
Dug's cached session cached only one of its four calls.
DugFactory.build_http_session returns requests_cache.CachedSession, whose
allowable_methods defaults to ('GET', 'HEAD'). Of the four annotation
calls, only node normalization is a GET (DefaultNormalizer.make_request,
dug/core/annotators/_base.py); nemo token classification, sapbert, and
name-resolution synonyms are all POST and so were never cached.
roger.utils.http_utils.enable_post_caching fixes it
(annotation.cache_post_requests, on by default). The request body is part of
requests_cache's key for POST, so this is correct, not a heuristic. Faster
annotator endpoints do not help here: the bottleneck is call count.
The same function also sets expire_after, which dug never did — so the
normalizer GETs that were being cached had no expiry and grew unbounded.
See the eviction note below.
Keep annotation.http_cache_expire_seconds nonzero (default 30 days).
requests_cache's redis backend writes entries with SETEX only when an expiry
is set. That makes annotation cache keys volatile while the FalkorDB graph keys
in the same redis stay permanent — so redis can be given a maxmemory with
volatile-lru and will evict cache before it ever touches the graph. With no
expiry the cache is permanent and unbounded, and under noeviction (the
deployed default, with maxmemory 0) it grows until the pod is OOMKilled,
taking the loaded graph with it.
annotation.annotate_workers (default 4) threads annotate_files over input
files. Files are wholly independent — own parse, own Crawler, own output dir
— and the work is nearly all HTTP wait, so this scales despite the GIL. Each
worker gets its own session and annotator via
DugPipeline.thread_annotation_context; the response cache is shared, so
workers still see each other's annotations. Element-level concurrency is not
possible without changing dug: Crawler.annotate_elements is a serial loop,
and inside it AnnotateSapbert.__call__ does one classify call, then a sapbert
call per entity, then a normalize and a synonym call per identifier, all
sequentially. Log volume scales with worker count — see the ephemeral-storage
history in roger.logger.
Task output only reaches lakefs on task success, so a 39-day task that died at file 40,000 used to discard all of it and restart at zero. Three pieces make retries resume:
DugPipeline.annotation_is_completeskips input files whoseelements.txtandconcepts.txtboth exist and are non-empty. Both are required: they are written in sequence, so a kill between them leaves a directory that looks started but is unusable.clean_up(..., keep_output=True)is the failure callback, so the dead try's output survives.reuse_prior_try_outputs(fromsetup_input_data) hard-links earlier tries' outputs into the current try's dir — necessary becausegenerate_dir_name_from_task_instancestamps the try number into the path, so a retry otherwise starts in an empty directory. The successful commit then includes everything, andclean_up(..., all_tries=True)clears every try dir.
This covers retries within a dag run. It does not checkpoint mid-task: a
single try that never succeeds commits nothing, and a fresh dag run gets a new
run_id and therefore new dirs. Sharding a dataset across mapped tasks is the
next step if that becomes the binding constraint.
When ROGER_LAKEFS__CONFIG_ENABLED=true, each task pulls inputs from a LakeFS repo/branch (get_files()), works in a task-specific local dir (named by generate_dir_name_from_task_instance), writes outputs back (put_files()), and commits via a temp branch merged after task success (Merge(strategy="source-wins")). Without LakeFS, tasks read/write a shared local data root.
create_python_task() wires the callbacks:
| flag | effect |
|---|---|
no_input_files=True |
skip the pre_execute download entirely (used by wipe_es_indexes) |
no_output_files=True |
post_execute runs record_state_callback (advances state only) instead of avalon_commit_callback (commits output) |
incremental_pull=False |
always download full inputs, even when the DAG run is incremental |
pass_conf |
whether the DAG run conf is forwarded into the callable |
Output is committed from post_execute, not on_success_callback.
Airflow runs post_execute inside _execute_task, before it records
end_date and releases downstream; success callbacks run later, in
finalize(). Committing there let downstream tasks read the branch before the
output landed — BulkLoad loaded an edgeless graph 105s early, and
make_kgx built KGX from annotations 4.8 hours stale, both green. Airflow
also only logs exceptions raised by state-change callbacks
(_run_task_state_change_callbacks), so a failed upload or merge left the
task successful; from post_execute it fails the task.
on_failure_callback and on_skipped_callback both run clean_up — the skip variant matters because pre_execute creates the input dir before it can raise AirflowSkipException.
Dug's data classes were extracted into a separate dug_data_model library: DugElement (v2.base), DugConcept (v2.concept), DugVariable (v2.variable), DugStudy (v2.study), DugSection (v2.section). DugIdentifier still lives in dug.core.annotators._base.
Intermediate artifacts (elements.txt, concepts.txt, expanded_concepts.txt) are jsonpickle-encoded, meaning they embed fully-qualified class paths like "py/object": "dug_data_model.v2.concept.DugConcept".
The trap: when a stored class's module fails to import, jsonpickle does not raise — it silently returns the raw dict. The failure surfaces much later and much more confusingly, as 'dict' object has no attribute 'id' inside ES indexing. The old dug.core.parsers._base module is exactly this case: it no longer imports at all (circular import via dug.core.loaders.InputFile).
This interacts badly with incremental ingestion: artifacts are only rewritten when their source changes, so they can sit in lakefs across multiple Dug upgrades, pinned to long-dead class paths.
Do not fix this by re-annotating — that is a month of compute. Use the migration:
lakectl local clone lakefs://<runtime-repo>/<branch>/ ./roger-out
# what's in there, and does any stored field lack a home in the current model?
docker run --rm -v $PWD:/w -w /w --entrypoint python \
containers.renci.org/helxplatform/roger:<tag> \
/w/scripts/migrate_pickled_classes.py --scan ./roger-out
# rewrite in place (note the --user flag; the image user does not own your files)
docker run --rm -v $PWD:/w -w /w --user $(id -u):$(id -g) --entrypoint python \
containers.renci.org/helxplatform/roger:<tag> \
/w/scripts/migrate_pickled_classes.py --fix ./roger-out
lakectl local commit ./roger-out -m "restamp pickled dug classes"--scan prints each stored class as ok/STALE, then a field-drift table with the count of objects inspected per class. Two lines gate the migration:
!! no current class named: [...]— a class was renamed, not just moved; needs an explicit mapping.!! stored but not declared -> [...]— a field was renamed or dropped; migrating would silently lose those values.
--fix aliases the dead modules, decodes, fills in fields added since (via model_construct() defaults, because __setstate__ assigns __dict__ wholesale and new fields would otherwise just be absent), and re-encodes with current class paths. It is idempotent and skips already-current files. --self-check round-trips a synthetic legacy payload with no lakefs data needed.
Repeat per dataset prefix. Any future Dug release that moves data classes needs the same pass.
RogerConfig loads src/roger/config/config.yaml (override file via ROGER_CONFIG_FILE), then applies env vars prefixed ROGER_. Dots in the config path map to _, and literal underscores in key names are escaped as __:
ROGER_REDISGRAPH_HOST=... # redisgraph.host
ROGER_KGX_DATA__SETS=topmed:v1.0 # kgx.data_sets
ROGER_LAKEFS__CONFIG_ENABLED=true # lakefs_config.enabled
ROGER_DUG__INPUTS_DATA__SETS=topmed:v2.0,anvil:v1.0 # which pipelines the DAGs buildROGER_DUG__INPUTS_DATA__SETS is read directly by the DAG files as name:version pairs; the version becomes each pipeline class's input_version, i.e. the external lakefs ref (branch or tag) it pulls from.
Main sections: redisgraph, kgx, dug_inputs, bulk_loader, annotation (annotator type + normalizer/synonym service URLs), indexing (ES index names, TranQL queries, element_mapping), elasticsearch, lakefs_config, validation (test queries). dev-config.yaml and test-config.yaml sit alongside the default. RogerConfig.to_dug_conf() bridges Roger config to Dug.
The FalkorDB bulk loader requires every column populated, so bulkload.py groups entities by which attributes have values — output files look like data/bulk/nodes/<type>.csv-<group>-<hash>. Values are cast per the inferred schema; the column separator is 0x1E (configurable as bulk_loader.separator).
- Dug (helxplatform/dug,
DugModel2.0branch) — annotation, indexing, concept expansion - dug_data_model — the extracted Dug data classes (see the jsonpickle section)
- avalon (
lakefs-1.71.0fork) — LakeFS client, wrappinglakefs_sdk1.12 - falkordb +
falkordb-bulk-loader— graph database (not the old redisgraph package) - bmt — Biolink Model Tools (model version pinned in
kgx.biolink_model_version) - apache-airflow 3.2.0
- jsonpickle — unpinned; 4.x deprecation warnings about
keysdefaulting to True in 5.0 are expected noise
DugPipeline.search_obj/.index_objare lazily built. Any method that touches Elasticsearch first in a task must initialize them (clear_indexdoes this) or you get'NoneType' object has no attribute 'es'.- Dataset version refs are often tags. Never assume branch APIs work on them.
- Incremental state Variables have no compare-and-swap, so
annotate_and_indexandindex_onlyboth setmax_active_runs=1. - Task ids encode the group (
{name}_dataset_pipeline_task_group.annotate_{name}_files), and lakefs output paths are built from those ids — renaming a task or group orphans its previous outputs and resets its incremental state. - Pre-existing lint noise that is not worth "fixing" blind:
F401unused imports indags/__init__.pyandtasks.py,F824unusednonlocalinstorage.py.
GitHub Actions (.github/workflows/): code-checks.yml runs flake8 + pytest on Python 3.12 and a Docker build test; dev images push on develop branch; Trivy scans PRs. Default PR target branch is develop.