Skip to content

Patch delays - #142

Open
YaphetKG wants to merge 46 commits into
developfrom
patch-delays
Open

Patch delays#142
YaphetKG wants to merge 46 commits into
developfrom
patch-delays

Conversation

@YaphetKG

@YaphetKG YaphetKG commented Jun 15, 2026

Copy link
Copy Markdown

Summary

Incremental (delta) lakefs ingestion for the annotate/index DAG, plus fixes uncovered while testing it.

Changes

  • Incremental ingestion — tasks pull only added/changed files since the last consumed commit, tracked per task via Airflow Variables; first run / disabled / unreachable commit falls back to a full download pinned to the resolved tip.
  • dbGaP GapExchange siblingsfind_sibling_files() lists each changed file's lakefs directory and pulls any GapExchange_* sibling not in the diff. dbGaP data dicts depend on that sibling for study name/description, so a delta carrying only the data dict would otherwise fail to parse.
  • Dug indexer alignmentbuild_indexer_obj() / build_search_obj() no longer pass index-name lists; dug now reads index names from config (to_dug_conf() already supplies concepts/variables/studies/sections/kg).

Tests

  • tests/unit/test_tasks_incremental.py — state keys, ref resolution, diff bucketing/prefix filter, skip-on-no-change, first-run full download, incremental diff download, GapExchange sibling pull, manual-override precedence, state callback.

@YaphetKG
YaphetKG requested review from vladimir2217 and yskale and removed request for vladimir2217 June 16, 2026 20:19
YaphetKG and others added 17 commits August 11, 2026 17:22
Dug's data classes moved into the dug_data_model library, and the module
they used to live in (dug.core.parsers._base) no longer imports. jsonpickle
does not raise on that -- it returns the raw dict -- so artifacts written by
an older dug index as dicts and fail with "'dict' object has no attribute
'id'". With incremental ingestion those artifacts are only rewritten when
their source changes, so they can sit in lakefs across dug upgrades
indefinitely.

scripts/migrate_pickled_classes.py restamps them in place instead of
re-annotating: --scan reports which stored classes no longer import and
whether any stored field is missing from the current model, --fix decodes
through a legacy module alias, fills in fields added since, and re-encodes.

Also import random and time in pipelines/base.py -- init_annotator's retry
path used both, so a transient annotator failure raised NameError instead
of retrying.
README described a codebase that no longer exists: a KGX fork install, a
`bin/roger all` entrypoint, `dags/roger/config/`, Python 3.7, and ~450 lines
of pasted CLI output. Replaced with an orientation doc -- what Roger
produces, the two workflows and how they differ, an annotated repo tour with
a reading order, quickstart, how to run tests without host dependencies,
config, the concepts that bite early (lakefs as source of truth, incremental
runs, ES as a derived index, jsonpickle-encoded artifacts), and a
troubleshooting table.

Kept the KGX merge/schema type-conflict rules, the bulk-loader CSV grouping,
and the k8s/Helm prerequisites. Dropped the stale run log and screenshots.

CLAUDE.md covers what contributors need but the README should not carry:
the incremental state machine and why the success callback must not
re-resolve refs, deletion propagation, lakefs task wiring flags, the DAG
shapes, and the dug_data_model migration runbook.
trying out seperating indexing jobs and adding removal steps for new …
YaphetKG and others added 20 commits August 19, 2026 14:11
… over files

Annotation dominates the cost of annotate_and_index, and three separate
things were making it far more expensive than the work it actually does.

Cache POST responses. dug builds the annotation session with
requests_cache, whose allowable_methods defaults to ('GET', 'HEAD').
Only node normalization is a GET; token classification, sapbert, and
name-resolution synonyms are POSTs and so were never cached. Since the
request body is part of requests_cache's key for POST, and every one of
these services is a read-only lookup keyed entirely by that body,
caching them is correct rather than a heuristic. This matters most for
dbGaP-shaped data, where the parsers emit the study element into every
one of a study's data-dict files: bdc-parent re-annotated the same 24
study descriptions across 61,597 files, at ~53s each against ~1.4s for
the variable the file actually contributes.

http_cache_expire_seconds also sets expire_after, which dug never did.
requests_cache's redis backend only writes entries with SETEX when an
expiry is set, so this keeps annotation cache keys volatile while the
FalkorDB graph keys sharing that redis stay permanent -- redis can then
be given a maxmemory with volatile-lru and will evict cache before it
touches the graph. It also bounds the normalizer GETs, which dug was
caching with no expiry at all.

Resume mid-annotation. Task output only reaches lakefs on task success,
so a task that died at file 40,000 of 61,597 discarded all of it and the
retry restarted at zero. annotation_is_complete now skips input files
whose elements.txt and concepts.txt both exist and are non-empty (both,
because they are written in sequence and a kill between them leaves an
unusable directory); clean_up keeps the output dir on failure; and
reuse_prior_try_outputs hard-links earlier tries' output into the current
try, which is needed because generate_dir_name_from_task_instance stamps
the try number into the path. Successful commits then clean up all tries.
This covers retries within a dag run, not a fresh dag run.

Thread annotate_files over input files (annotate_workers, default 4).
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; the response cache is
shared, so workers still see each other's annotations. Element-level
concurrency would have to live in dug's Crawler.

Also fixes clean_up, which had its input and output suffixes swapped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `test` graph in the kebedey namespace has 3,889,306 nodes and zero
relationships despite BulkLoad, Validate and CheckTranql all reporting
success. Two independent defects combined to produce that.

BulkLoad read the branch before its input existed. Task output only
reaches lakefs in avalon_commit_callback, an on_success_callback, while
Airflow releases downstream tasks on task state -- so an upstream edge
guarantees the producer finished, not that its output is visible. That
window is normally negligible, but CreateBulkLoadEdges wrote ~12GB (one
subclass_of csv is 11.7GB), so the upload and merge outlasted it:

  16:52:57Z  BulkLoad resolves main tip -> c400de2f (no edge objects)
  16:54:37Z  CreateBulkLoadEdges' commit lands on main
  17:01:54Z  BulkLoad exits 0

The narrow fix here is incremental_pull=False. BulkLoad deletes the graph
and reloads it whole, so it always needs the complete node and edge csv
set; an incremental pull hands it only what changed and it rebuilds the
graph from that fragment. This is the same reasoning already applied to
the elasticsearch task groups in annotate_and_index, which BulkLoad has
the same wholesale-rebuild semantics as. It also matters immediately: the
recorded state for this task is c400de2f, so re-running it as-is would
diff to a set containing the edges but not the unchanged node csvs, wipe
the graph, and try to load edges with no nodes.

Nothing turned red because insert() only passes -R arguments when edge
csvs are present, and a nodes-only load is a perfectly valid loader
invocation. It now refuses to bulk load an edgeless graph when node csvs
are present, so a build that loses its edges fails where it breaks.

This does not close the underlying race, which affects every task that
commits in a success callback. Committing output before the task reports
success is the real fix and is deliberately left out of scope here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Airflow records end_date and releases downstream tasks before it runs
success callbacks, so wiring avalon_commit_callback as an
on_success_callback meant every task advertised completion while its
output was still uploading to lakefs. Downstream then pulled a branch
that did not yet contain it.

Both DAGs were losing data to this. In knowledge_graph_build,
CreateBulkLoadEdges ended at 16:52:52 and BulkLoad started 7s later,
but the 12GB of edge csvs did not land until 16:54:37 -- 105s after
the task was declared done -- so BulkLoad loaded 3.9M nodes with zero
relationships and exited 0. In annotate_and_index,
annotate_bdc-topmed_files ended at 21:17:37 and its commit landed at
21:25:52, while make_kgx started at 21:17:49 and built kgx from
commit 8ce02a35, 4.8 hours stale. Every one of those tasks was green.

post_execute is the right hook: airflow calls it inside _execute_task
immediately after execute() and before the SucceedTask message, so the
commit lands before downstream is eligible, and an exception there fails
the task rather than being swallowed.

Swallowing is the second half of this. _run_task_state_change_callbacks
only logs exceptions raised by callbacks, and avalon_commit_callback
logged and dropped merge failures of its own -- after which the clean_up
at the end of the callback deleted the local output. A failed merge
therefore destroyed the work and still reported success. The merge
failure is now re-raised, which fails the task and leaves the output in
place for the retry to resume from, since on_failure_callback already
passes keep_output=True.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BulkLoad ran at the chart default 2Gi. That was survivable while it was
silently loading nodes only -- 0.9GiB of csvs -- but the real load is
13.6GiB of edge csvs, about 78M edges, and the falkordb bulk loader keeps
a node-identifier to internal-id map in memory for the whole graph so it
can resolve edge endpoints. At 3.9M nodes that map alone is a large
fraction of 2Gi before any batching.

15Gi matches MergeNodes and CreateBulkLoadEdges, the other two tasks that
handle the graph at full scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`save 300 100000` triggers on write volume, and a bulk load is nothing but
write volume, so bgsave forks repeatedly while 78M edges are going in.
Copy-on-write on a graph this size can add most of it again on top of the
resident set, and the headroom between redis maxmemory and the pod memory
limit is not sized for that -- the pod gets OOMKilled and takes the
half-loaded graph with it.

A snapshot taken partway through a load is worthless anyway. The bulk csvs
in lakefs are the source of truth and insert() starts by deleting the
graph, so there is nothing worth persisting until the load finishes.

Doing it in the task rather than by hand around the run is the point: the
restore is in a finally, so a load that dies partway still puts snapshots
back. Failing to set it is not fatal and only warns, since a redis that
refuses CONFIG should not block a multi-hour load; failing to restore it
logs at error, because that one leaves durability changed behind us.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every full text index the bulk load tried to create failed:

  Unable to create Full Text Search Index on Label: biolink.GeographicLocation
  errMsg: Invalid input '.': expected ')'
  errCtx: CREATE FULLTEXT INDEX FOR (e: biolink.GeographicLocation) ON (e.name)

falkordb interpolates the label straight into the pattern -- see
Graph._create_typed_index, `pattern = f"(e:{label})"` -- so a label
containing a dot has to arrive quoted, and every biolink label contains a
dot. The range indexes on the same line were already backticked and
succeeded; the two full text ones were not and never did.

The loader catches the ResponseError and only prints it, so the task
still reported success. The graph has been built without name or synonym
full text indexes for as long as this line has looked like this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
adding batched annotator for nameres and node norm calls
The canary caught this: 6 of 183 bdc-heartfailure files came back with
CHEBI:3759 where the previous serial run had CHEBI:37941. Same identifier
count either way, so nothing was lost -- but one curie was no longer
being conflated.

The node normalizer does not default its flags the same way on both
verbs:

    GET   drug_chemical_conflate  default=True
    POST  drug_chemical_conflate  default=False

dug builds a GET url that sets conflate and description and says nothing
about the rest, so its lookups have always run with
drug_chemical_conflate=true. The batched POST omitted it and therefore
got false. conflate and include_taxa are the same shape of trap, both
defaulting true on GET, and the previous code sent false for anything
absent from the url.

So the flags are now built from the GET defaults and overridden by
whatever the url actually specifies, rather than defaulting to false.
Verified against the deployed normalizer: single GET and batched POST
agree on all probes including CHEBI:3759 -> CHEBI:37941.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bdc-biolincc was OOMKilled at the chart default of 2Gi. That default was
sized for the serial annotator. annotate_workers now runs four threads,
each holding one whole parsed file, and biolincc's largest files carry
13,299 elements -- jsonpickle.encode also builds the entire output string
in memory before anything is written, so the peak is four of those at
once.

annotation.annotate_memory (default 6Gi) so it can be tuned with
ROGER_ANNOTATION_ANNOTATE__MEMORY without another push, and so it moves
together with annotate_workers, which is what actually drives it.

Note this multiplies against dag parallelism: twelve datasets at 6Gi
exceeds the namespace quota, so tasks will queue rather than start. That
is the intended behaviour (task_publish_max_retries is -1) but it does
mean raising annotate_workers and annotate_memory together needs a look
at the quota.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five make_kgx tasks failed with

    (400) {"message":"update branch main: no changes"}

after doing their work correctly -- curesc logged "Wrote 4705 and 28114
edges" immediately before failing. lakefs answers a merge that would
change nothing with a 400, and a task whose output is byte-identical to
what the branch already holds produces exactly that. Deterministic work
over unchanged input lands there every time.

This is a regression from commit 6aefe2f. Before it, the merge exception
was logged and dropped, so the task went green. Re-raising was right for
a genuine merge failure -- the clean_up that follows deletes the local
output, so swallowing one destroyed the work and still reported success
-- but it made this benign case fatal too.

So the no-changes case is now recognised and treated as what it is:
state still advances, because the input was consumed and the branch holds
the correct content. Everything else still raises.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… output

Two problems from the bdc run, both rooted in defaults that were sized
for a smaller pipeline.

make_kgx and crawl still ran at the chart default 2Gi. 19ecbac gave
annotate its own limit but stopped there, and make_kgx_bdc-recover was
then OOMKilled building kgx for a 6090-file dataset. crawl has the same
shape -- it expands every concept through tranql and accumulates the
answers -- and crawl_bdc-parent, the largest of the twelve, had not run
yet. Both now take annotation.annotate_memory, so the three tasks that
hold a dataset in memory move together.

keep_output on failure is now opt-in, and only annotate opts in. It
exists so a retry can resume, which works because annotation_is_complete
skips input files whose output already exists. crawl and make_kgx have no
such check and redo everything, so their retained output is never read
again -- it is pure disk cost. That cost was not theoretical: three
failed crawls held 47GB of unusable output on the shared 120GB volume,
which is what filled it, and the volume filling is what had failed them.
Keeping their output made the failure permanent instead of transient.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants