Skip to content

Ingest ENCODE annotation files during sync — Closes #94 - #108

Merged
conradbzura merged 15 commits into
masterfrom
94-ingest-encode-annotation-files
Aug 18, 2026
Merged

Ingest ENCODE annotation files during sync — Closes #94#108
conradbzura merged 15 commits into
masterfrom
94-ingest-encode-annotation-files

Conversation

@conradbzura

@conradbzura conradbzura commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Add a second ingest path for type=Annotation&status=released, so the interpretive layer over the raw experiments cfdb already serves becomes reachable. A cCRE track is what a Gosling visualization usually wants alongside signal, and none of it was queryable before.

Which annotation types are ingested is configuration rather than a hardcoded assumption. ENCODE publishes 580,910 annotation datasets and 86% of them are footprints, so the allowlist exists from the outset rather than being retrofitted once volume becomes a problem. The default is the two types the issue names: 7,773 datasets, 29,140 files.

The two TSVs do not share a column set — Annotation publishes 32 columns to Experiment's 59, six of them renamed. Rather than a parallel transformation, the annotation columns are renamed to their experiment equivalents and run through the shared one, so a single mapping serves both and a column the TSV omits comes out unset instead of derived from something else. That is how an annotation document ends up with no subjects: there is no Donor(s) column to build one from, and inventing a donor would be worse than admitting there is none.

Going from one metadata stream per sync to N+1 is the part that needed the most care. The sync runs one phase per stream, and a phase failure costs only that stream: the phases share a single download budget rather than each claiming a fresh one, each phase replaces only the slice of files it reloads and only once it has rows to put back, and the run reports exactly what it committed on every path including the timeout. The task still fails at the end naming what broke.

Closes #94

Validation

Verified against the live corpus rather than only against fixtures. Three full syncs through a dockerised Mongo and API loaded 839,121 files — 809,981 experiments, 12,448 cCRE, 16,692 interaction predictions — with the reported total, the per-phase log and the database agreeing exactly every time, and the annotation counts matching the figures in ENCODE-SUPPLEMENT.md to the file.

A second sync over the populated corpus produced the same 839,121 documents with zero duplicate (submission, local_id) pairs, each phase clearing exactly its own slice. A deliberately exhausted budget then confirmed the failure path: the experiment phase consumed it and both annotation phases were refused without opening a request, the run reported 1,629 files against exactly 1,629 in the database, and both annotation slices survived intact at 12,448 and 16,692 because a phase that never receives a row never clears.

Proposed changes

Ingest released annotations per configured type

Extract the streaming loop so both TSVs share it, and give each stream a label it carries into its log lines — with several streams per sync, a timeout that does not say which one died is not actionable. ENCODE_ANNOTATION_TYPES bounds the allowlist; entries are trimmed and deduplicated, since files are written with insert_many into a collection with no unique key and a repeated token would load a whole type twice. One request per type, so a failure or a gateway timeout on one costs only that type.

An explicitly empty value disables annotation ingest and logs a warning saying so. The neighbouring ENCODE_METADATA_TIMEOUT_SECONDS reads an empty value the other way, as unset, and empty values arrive by accident routinely — an unset CloudFormation parameter, a docker-compose expansion that produced nothing. Turning the path off stays a legitimate operator choice; without the warning the only trace of an accidental one is an absence in the per-phase log.

Two departures from the experiment path are deliberate. The dataset collection is built from the accession alone rather than requiring a biosample term, because 48 released cCRE files name no biosample and gating on one would leave 24 dataset accessions unqueryable. And its persistent_id points at /annotations/, since ENCODE serves the two dataset kinds under different paths.

Run the ingest as isolated per-stream phases

A phase that raises is logged and recorded and the remaining phases still run. The task still fails at the end naming the phases that broke — reporting a clean sync over a partially loaded collection would be worse than a visible failure.

Accounting holds on every path. A phase reports the rows it committed rather than only those from a clean return, since a stream can die mid-flight and a return value is lost when it does. The trailing batch of a dead stream is flushed rather than discarded, but the flush on the clean path sits inside the try rather than the finally: a finally runs whether or not an exception is in flight, so suppressing its failure there suppressed it on the success path too, and a stream that drained cleanly and then failed to commit its last rows reported a clean sync over a short corpus. Each insert detaches its buffer before awaiting, because the driver stamps _id in place before sending and a resubmitted batch collides on the prefix that already landed while silently dropping the rest. Cancellation skips the flush entirely rather than writing on its way out.

The download budget covers the whole sync rather than each stream in it. Every phase runs inside the one cutover lock that gates the read surface, so a per-stream budget multiplied the outage by the phase count and carried the worst case past the sync lock's one-hour stale threshold — at which point a second sync is admitted and clears the corpus while the first is still writing into it. The budget is resolved once and each phase gets what remains; a phase that finds it spent refuses to open a request rather than passing the remainder to aiohttp, which reads a non-positive total as no timeout at all.

Isolation covers the destructive half too. files is no longer cleared corpus-wide before the fan-out. Each phase owns a slice — experiments are the documents carrying no annotation type, each annotation phase the documents carrying its own — and deletes that slice only once it has replacement rows in hand, so a phase that fails before delivering any leaves its previous rows being served. Clearing up front meant a failed experiment phase left the API serving the 29,140 annotation documents as the entire corpus. A stream that drains cleanly with no rows still clears, so a type ENCODE stops publishing stops being served.

Isolate per-DCC failures in the sync run

The same accounting one level out. A DCC that raised aborted every DCC after it, and get_all_dcc_names sorts, so one failed ENCODE phase cost the whole HuBMAP sync and the data-collection indexes built after the loop. Each DCC is now isolated the way the phases are: the failure is recorded, the remaining DCCs run, the indexes are ensured regardless, and the run fails once at the end naming every DCC that broke. A DCC that failed no longer logs itself as synced.

Give bedpe and bigInteract their own file_format terms

Both formats pair two loci per record and both were aliased onto the EDAM term for the single-interval format they resemble. Since processor lookup keys on the format name, that alias handed them to the BED tabix pipeline, which indexed the first locus of each record and left the second unindexed — a cached artifact that looked successful and was wrong, and one the byte-sniff guard cannot catch because both are plaintext or gzip.

EDAM has no term for either, verified against OLS4, so the tokens are minted under a cfdb: prefix rather than borrowed. Neither name appears in any processor's supported_formats, so a data request now streams the raw upstream file and an index request returns 404.

This reaches the .bedpe already published under type=Experiment, not only the annotation files that prompted it — and no further. FILE_FORMAT_TO_EDAM has exactly one consumer, the ENCODE transform; 4DN and HuBMAP take file_format from their upstream C2M2 datapackage or portal API and never consult it, so a 4DN .bedpe still arrives declaring BED and is still claimed by the tabix pipeline. Closing that is follow-up work.

FileFormat and the README type table both described the id as an EDAM term, which is what a consumer resolves it as and would now fail on for the 8,296 files carrying a minted one. Both now name MINTED_FORMAT_PREFIX as the discriminator to test against.

Make the annotation fields queryable

Eight new fields across the three enriched ENCODE models, mirrored onto the GraphQL inputs. annotation_type is held on the file as well as the dataset: the dataset is where the property belongs, but filtering files by it is the actual use case.

The age fields stay strings. The released corpus contains 2-4 and unknown alongside decimals and distinguishes 10.5 from 10.50, which is also why they do not go to Subject.age_at_sampling — a float in years could represent neither, and an annotation row names no donor to build a Subject from in any case. They land on the biosample, so a row naming no biosample term drops them; that is the row the annotation transform relaxed its biosample requirement to serve, and the mapping tables now say so rather than stating it unconditionally.

extra.encode.annotation_type, extra.encode.organism and extra.encode.assembly each gain an index and a place in the distinct-values allowlist. The ENCODE sync writes files directly and never reaches the materializer that owns the rest of its keys, so nothing else would create them, and the flagship filter would otherwise be a full scan of a ~300,000 document collection on an unauthenticated endpoint. The core genome_assembly field is indexed here for the same reason despite the materializer owning it: a database where ENCODE is the only DCC synced never runs the materializer, and genomeAssembly is the field the schema publishes and a client narrowing to GRCh38 actually filters on, so indexing only the extra.encode mirror would have left the documented path scanning. The allowlist entries matter because filtering by a facet is only useful if the vocabulary is discoverable.

Behavior changes for existing clients

  • .bedpe and bigInteract files already in the experiment corpus change file_format.name from BED/bigBed to bedpe/bigInteract, so a saved query filtering fileFormat: {name: ["BED"]} stops matching them. Measured against the live corpus: 1,892 .bedpe and 252 bigInteract files predate this change.
  • GET /index/... returns 404 for both formats. For .bedpe that replaces a wrong answer, since the index covered only the first mate of each record. For bigInteract it replaces a working one: the leading three columns are a genuine interval, so the old bigBedToBed index answered range queries correctly while degrading each interaction to a single locus. Those 252 pre-existing files go from degraded-but-usable to unavailable until the tileset endpoints land. A 404 states the truth where the degraded index quietly answered a question the caller did not ask.
  • file_format.id is no longer always an EDAM format: term. 8,296 files carry a cfdb:-prefixed token that resolves nowhere at OLS or edamontology. A client resolving ids against EDAM must skip that prefix rather than assume every id is resolvable.
  • extra.encode.assembly is populated for the first time. It has always been published in the SDL and never written, so a client filtering on it previously matched nothing at all.
  • ENCODE_METADATA_TIMEOUT_SECONDS changes meaning from a per-stream budget to a whole-sync one. An operator who raised it to cover a slow experiment fetch now gets that value as the ceiling for all phases combined rather than for each.

Follow-ups

Not addressed here, and worth an owner before or shortly after merge:

  • Restore indexing for paired-interval formats through tileset endpoints, which is what returns the 252 bigInteract files to a usable state.
  • Route non-ENCODE file formats through FILE_FORMAT_TO_EDAM, or refuse a .bedpe filename at the processor regardless of declared format, so the correction reaches 4DN and HuBMAP.
  • Fold a processor identity into cache_key. It derives dcc/local_id/artifact_kind/md5-vN with no processor identity, so two processors claiming the same file and artifact kind at equal processor_version read back each other's artifacts as cache hits. Nothing collides today only because each pair has one processor; a paired-interval processor is exactly what would break that, and the stale tabix artifacts for these files are still in the cache.
  • Purge those orphaned tabix artifacts, which are now unreachable storage cost.

Test cases

# Test Suite Given When Then Coverage Target
1 tests/test_ontology_mappings.py Any format in the table get_file_format is called with its key The id is an EDAM term or a minted token, and the name is non-empty Minted-prefix invariant
2 tests/test_ontology_mappings.py Any format whose entry carries a minted id get_file_format is called with its key The id is the prefix followed by the key Typo'd mint
3 tests/test_ontology_mappings.py Any format whose entry carries a minted id get_file_format is called with its key The name is shared with no EDAM entry and claimed by no processor Name-borrowing defeat of the mint
4 tests/test_ontology_mappings.py A paired-interval format and the registry the API wires A file document carrying it is looked up No processor claims it Routing for bedpe and bigInteract
5 tests/test_ontology_mappings.py A plain BED format and the same registry A file document carrying it is looked up The tabix processor claims it Positive control for row 4
6 tests/test_ontology_mappings.py Each annotation output type get_data_type is called It returns that value's documented CV term Annotation data_type coverage
7 tests/test_encode.py No ENCODE_ANNOTATION_TYPES in the environment annotation_types_from_env is called It returns exactly the two documented defaults Bounded default allowlist
8 tests/test_encode.py An override naming the same type twice annotation_types_from_env is called It returns that type once, in first-occurrence order Duplicate-phase prevention
9 tests/test_encode.py An override of only separators and whitespace annotation_types_from_env is called It returns no types at all Blank entry cannot widen the query
10 tests/test_encode.py Any text at all as the allowlist annotation_types_from_env is called Every entry is non-blank and already stripped Parser output invariant
11 tests/test_encode.py An explicitly empty allowlist annotation_types_from_env is called It warns, naming the variable and the disabled ingest Accidental empty value is visible
12 tests/test_encode.py No allowlist variable at all annotation_types_from_env is called It emits no warning Negative control for row 11
13 tests/test_encode.py A streamed response and a named annotation type The fetch is drained The query parses to exactly the three expected parameters Annotation URL construction
14 tests/test_encode.py An annotation type containing URL metacharacters The fetch is drained Status stays released and type stays Annotation Parameter injection
15 tests/test_encode.py A deadline half a minute out against an hour-long budget The fetch is drained The request is bounded by the time remaining Shared budget, not a fresh one
16 tests/test_encode.py A deadline that has already passed The fetch is drained It raises TimeoutError without opening a request Spent budget is refused, not unbounded
17 tests/test_encode.py An annotation stream failing by status, timeout or network error Each is drained Each message names annotation[<type>] Per-stream labelling
18 tests/test_encode.py An annotation stream failing any of those three ways It is drained and the error escapes The session is exited in every case Session release on failure
19 tests/test_encode.py A streamed body consumed part-way The generator is closed The session is exited Session release on abandonment
20 tests/test_encode.py An annotation row carrying the renamed columns transform_annotation_to_c2m2 is called Each value lands where its experiment-named twin would Column aliasing
21 tests/test_encode.py A cCRE annotation row transform_annotation_to_c2m2 is called annotation_type is set on both the file and its dataset Queryability of the annotation type
22 tests/test_encode.py An annotation row transform_annotation_to_c2m2 is called The library, replicate, modification and analysis fields are absent Experiment-only fields unset
23 tests/test_encode.py An annotation row transform_annotation_to_c2m2 is called Both the dataset and its biosample carry no subjects No fabricated donor
24 tests/test_encode.py An annotation row with a dataset accession transform_annotation_to_c2m2 is called The persistent_id points at /annotations/ Dataset link resolution
25 tests/test_encode.py An annotation row with no biosample term transform_annotation_to_c2m2 is called The dataset is still built, addressable and labelled The 48 biosample-less cCRE files
26 tests/test_encode.py An annotation row whose age is a range transform_annotation_to_c2m2 is called The three donor traits sit on the biosample, verbatim Age preserved as published
27 tests/test_encode.py An annotation row carrying donor traits but no biosample term transform_annotation_to_c2m2 is called The three are absent from the document Documented drop, not a silent one
28 tests/test_encode.py An annotation row whose Assay term name is empty transform_annotation_to_c2m2 is called The dataset carries no experiment_type and the file no assay_type The dominant real-corpus shape
29 tests/test_encode.py An experiment row naming an assembly transform_to_c2m2 is called extra.encode.assembly mirrors the top-level genome_assembly Mirror on the experiment column
30 tests/test_encode.py An annotation row naming an assembly transform_annotation_to_c2m2 is called extra.encode.assembly mirrors the top-level genome_assembly Mirror on the differently-named annotation column
31 tests/test_encode.py A full annotation row The emitted document is validated as a FileMetadataModel It validates and exposes all the annotation fields Write-to-read seam
32 tests/test_encode.py An experiment row with an accession and a biosample term transform_to_c2m2 is called The persistent_id points at /experiments/ Experiment path unchanged
33 tests/test_encode.py Experiment rows with and without a biosample term id transform_to_c2m2 is called Anatomy follows the term id, and the collection is built either way Anatomy guard
34 tests/test_encode.py An experiment row published as bedpe transform_to_c2m2 is called Its format names bedpe rather than BED Remap reaches the existing corpus
35 tests/test_sync.py A stream that yields rows and then dies, plus a healthy phase _sync_encode runs The reported count equals the documents actually present Mid-flight accounting
36 tests/test_sync.py Any fan-out, row counts, batch size and failure pattern _sync_encode runs The reported count always equals the documents committed The same invariant, generalized
37 tests/test_sync.py The same mid-flight failure _sync_encode runs Every transformed row is committed, partial batch included Partial-batch flush
38 tests/test_sync.py A stream that drains cleanly whose trailing batch fails to insert _sync_encode runs It raises and reports only the rows that committed Sink failure is not reported as success
39 tests/test_sync.py A batch the sink rejects mid-stream _sync_encode runs That batch never reaches the collection and is excluded from the count No resubmission of an attempted batch
40 tests/test_sync.py A stream that yields rows and then raises CancelledError _sync_encode runs The buffered rows are never committed No write on the way out of a cancellation
41 tests/test_sync.py Two configured annotation types, so three phases run _sync_encode runs Every phase receives one and the same deadline Whole-sync budget, not per-stream
42 tests/test_sync.py An experiment stream that raises and a healthy annotation stream _sync_encode runs The annotation documents still land and the sync fails naming the phase Phase isolation
43 tests/test_sync.py Two annotation types where the first raises _sync_encode runs The surviving type and the experiments both land Isolation between annotation phases
44 tests/test_sync.py A stale experiment document and an experiment phase that fails before yielding _sync_encode runs The stale document survives alongside the fresh annotation one Failed phase serves stale, not absent
45 tests/test_sync.py A stale document for a type whose stream then yields nothing _sync_encode runs The stale document is gone A withdrawn type stops being served
46 tests/test_sync.py A stale document and three healthy phases _sync_encode runs The stale document is gone and all three phases survive together No phase clears another's slice
47 tests/test_sync.py All phases raising over a stale corpus _sync_encode runs The stale corpus survives, the indexes are ensured and every failed label is named Total failure leaves last sync's data
48 tests/test_sync.py An experiment stream raising CancelledError _sync_encode runs The cancellation propagates and no later phase runs Cancellation is not a phase failure
49 tests/test_sync.py Two failing phases with distinguishable exceptions _sync_encode runs The raised error chains the first failure Traceback points at the cause
50 tests/test_sync.py Two configured types, one returning nothing _sync_encode runs The distribution reports the empty type as zero Upstream type going empty
51 tests/test_sync.py One healthy and one failing annotation phase _sync_encode runs The per-phase log records the failed one as zero Failure accounting is legible
52 tests/test_sync.py An allowlist naming the same type twice _sync_encode runs The type is fetched once and each document inserted once Duplicate ingest
53 tests/test_sync.py A batch size of three and streams of three, four or no rows _sync_encode runs Each row lands exactly once with no trailing empty insert Batching boundaries
54 tests/test_sync.py Three rows where the middle one does not transform _sync_encode runs Two documents are inserted and only two are tallied Skip path
55 tests/test_sync.py No ENCODE_ANNOTATION_TYPES in the environment _sync_encode runs It fetches exactly the two default types Default allowlist wiring
56 tests/test_sync.py A three-DCC sync whose first DCC raises _sync_dccs runs It still attempts both remaining DCCs before failing One DCC does not abort the run
57 tests/test_sync.py A two-DCC sync whose first DCC raises _sync_dccs runs The data indexes are still ensured once A partial run stays queryable
58 tests/test_sync.py A two-DCC sync whose first DCC raises _sync_dccs runs Only the DCC that succeeded is logged as synced and reported on No false success report
59 tests/test_sync.py A two-DCC sync where both DCCs raise distinguishably _sync_dccs runs The raised error chains the first failure Traceback points at the cause
60 tests/test_sync.py A two-DCC sync whose first DCC raises CancelledError _sync_dccs runs The cancellation propagates and the second DCC never runs Cancellation is not a DCC failure
61 tests/test_inputs.py A filter naming any of the eight new fields It is converted with to_dict then to_query It produces a predicate on the path the ingest writes Query path matches write path
62 tests/test_inputs.py Any text value on an ENCODE annotation field to_query builds the predicate The value is carried unaltered No folding of a case-significant vocabulary
63 tests/test_schema.py Two cCRE files, an interaction file and an experiment file The files query filters on the cCRE type Only the two cCRE files come back Filtering without matching filenames
64 tests/test_schema.py A human cCRE file and a mouse one The files query filters on organism Only the mouse file comes back Multi-organism narrowing
65 tests/test_schema.py cCRE files under three assemblies plus another type under mm10 The files query filters on type and assembly together Only the mm10 cCRE file comes back Composed narrowing
66 tests/test_schema.py One annotation file document The files query selects its ENCODE extra fields The annotation type, organism and assembly are returned Readability, not only filterability
67 tests/test_schema.py Files spanning two annotation types distinctValues is asked for the annotation type Both types are returned Vocabulary discovery
68 tests/test_schema.py ENCODE files spanning two assemblies distinctValues is asked for the assembly Both assemblies are returned Assembly vocabulary discovery
69 tests/test_index.py A paired-interval file and the registry the API wires stream_index_file is called It returns 404 without consulting the executor No index for a paired format
70 tests/test_data.py The same file and registry stream_file is called It reaches the direct streaming path rather than dispatching Raw file instead of a wrong index
71 tests/test_indexes.py The materialized files index specs Their key tuples are collected They are exactly the declared set, with no additions The list stays a deliberate enumeration
72 tests/test_indexes.py The materialized files index specs Their key tuples are collected They cover the annotation type, organism and assembly paths Annotation facet coverage
73 tests/test_indexes.py The materialized files index specs Their key tuples are collected They cover the core genome_assembly path The documented filter is indexed too
74 tests/test_fake_collection.py A doc whose nested path is populated find_one asserts $exists on that dotted path The doc is returned via the dotted-path resolver Dotted-path existence
75 tests/test_fake_collection.py One doc carrying a nested path and one without it find_one negates $exists on that dotted path Only the doc lacking the path is returned The predicate the per-slice clearing is built on

@conradbzura conradbzura self-assigned this Aug 13, 2026
Both formats pair two loci per record, and both were aliased onto the
EDAM term for the single-interval format they resemble -- bedpe to BED,
bigInteract to bigBed. Processor lookup keys on the format name, so that
alias handed them to the BED tabix pipeline, which sorted and indexed the
first locus of each record and left the second unindexed. The result was
a cached artifact that looked successful and was wrong, and the
byte-sniff guard cannot catch it because both are plaintext or gzip.

EDAM has no term for either, verified against OLS4, so the tokens are
minted under a cfdb: prefix rather than borrowed. The distinct name is
what does the work: neither appears in any processor's supported_formats,
so a data request now streams the raw upstream file and an index request
fails cleanly, instead of serving something silently incorrect.

This reaches the .bedpe already published under type=Experiment, not
only the annotation files that prompted it -- and no further. The format
table has exactly one consumer, the ENCODE transform. 4DN and HuBMAP
take file_format from their upstream C2M2 datapackage or portal API and
never consult it, so a 4DN .bedpe still arrives declaring BED and is
still claimed by the tabix pipeline. Routing every DCC through the same
table, or refusing the filename at the processor regardless of declared
format, is follow-up work.

The two formats do not lose the same thing. A bedpe index was wrong: the
second mate of every record was unindexed and invisible. A bigInteract
index was range-coherent, since the leading three columns are a genuine
interval, so it answered range queries correctly while degrading each
interaction to a single locus. An index request now returns 404 for
either, which means bigInteract goes from degraded-but-usable to
unavailable, for files already in the experiment corpus as well as new
ones. A 404 states the truth where the degraded index quietly answered a
question the caller did not ask. Tileset endpoints that understand
paired intervals are planned separately.

The published contract is updated to match. FileFormat and the README
type table both described the id as an EDAM term, which is what a
consumer would resolve it as and now fail on; both name the minted
prefix as the discriminator to test against instead.
These five values are the complete Output type domain of the two
annotation types the sync ingests, verified against the live metadata
TSVs, and none of them appeared in the table. Without them every one of
the ~29,000 annotation files would be stored with a null data_type.

The element and gene link types resolve to the generic data term rather
than to a features or track term. EDAM has nothing for a predicted
regulatory relationship between two loci, and the pre-existing chromatin
interactions entry already made that call the same way. A deliberately
vague term is preferable to claiming the files are something they are
not.
Eight fields the annotation metadata TSV publishes and the experiment one
does not: annotation_type and organism on the file, annotation_type,
software_used and encyclopedia_version on the dataset, and life_stage,
age and age_units on the biosample.

annotation_type is held on the file as well as the dataset. The dataset
is where the property belongs, but filtering files by it is the actual
use case, and routing that through a collection subdocument on every
query is not worth avoiding one duplicated string.

The age fields stay strings rather than becoming a number. The released
corpus contains "2-4" and "unknown" alongside decimals, and it
distinguishes "10.5" from "10.50". That is also why they do not go to
Subject.age_at_sampling, which is a float in years and could represent
neither the ranges nor the sentinels -- and an annotation row names no
donor to build a Subject from in any case.

The output types are derived from the models, so the SDL gains all eight
on both the input and the output side.
Everything ENCODE publishes as an Annotation -- candidate cis-regulatory
elements, element gene regulatory interaction predictions, chromatin
state, footprints -- was absent from cfdb, which fetched only
type=Experiment. Annotation files are the interpretive layer over the raw
experiments: a cCRE track is what a Gosling visualization usually wants
alongside signal.

The streaming loop is extracted so both TSVs share it, and each stream
now names itself in its log lines. It takes a deadline rather than
resolving its own budget, so several streams in one sync share a single
allowance; a stream that finds the budget already spent refuses to open
a request rather than passing the remainder through, since aiohttp reads
a non-positive total as no timeout and would turn the exhausted case
into an unbounded request.

Which annotation types are ingested is read from ENCODE_ANNOTATION_TYPES
rather than hardcoded: the full space is 580,910 datasets, 86% of it
footprints, so the allowlist has to exist from the outset rather than be
retrofitted once volume becomes a problem. Entries are trimmed and
deduplicated, since files are written with insert_many into a collection
with no unique key and a repeated token would load a whole type twice.
One request per type, so a failure or a gateway timeout on one costs
only that type.

An explicitly empty value disables annotation ingest, and now says so.
The neighbouring ENCODE_METADATA_TIMEOUT_SECONDS reads an empty value
the other way, as unset, and empty values arrive by accident routinely
-- an unset CloudFormation parameter, a docker-compose expansion that
produced nothing. Turning the path off stays a legitimate operator
choice, but without the warning the only trace of an accidental one is
an absence in the per-phase log.

The two TSVs do not share a column set -- Annotation publishes 32 columns
to Experiment's 59, six of them renamed. Rather than a second
transformation, the annotation columns are renamed to their experiment
equivalents and run through the shared one, so a single mapping serves
both and a column the TSV omits comes out unset instead of derived from
something else. That is how an annotation document ends up with no
subjects: there is no Donor(s) column to build one from, and inventing a
donor would be worse than admitting there is none.

Two deliberate departures from the experiment path. The dataset
collection is built from the accession alone rather than requiring a
biosample term, because 48 released cCRE files name no biosample and
gating on one would leave 24 dataset accessions unqueryable. And the
collection's persistent_id points at /annotations/, since ENCODE serves
the two dataset kinds under different paths.

Also populates extra.encode.assembly, which the schema has always
published and nothing ever wrote -- a client filtering on it matched
nothing at all. Multi-assembly cCREs, spanning GRCh38, mm10 and hg19, are
what make that worth fixing now.
The sync now runs one phase for released experiments plus one per
configured annotation type. A phase that raises is logged and recorded
and the remaining phases still run, so a transient failure on one stream
does not cost the corpus the others. The task still fails at the end
naming the phases that broke -- reporting a clean sync over a partially
loaded collection would be worse than a visible failure.

Getting that right needed more than a try/except around each phase. A
stream can die mid-flight, which is exactly what an asyncio.TimeoutError
against the metadata budget does, and reporting a phase's contribution
only on its clean return meant a failed phase contributed nothing to the
total even though its completed batches were already committed. The
tallies, mutated per row, kept counting the rows the partial batch then
discarded. Three numbers in one log block disagreeing with each other
and with the database. The row count is now reported by mutating an
accumulator, and a batch left buffered by a dead stream is flushed on
the way out, so a partial load reports itself accurately.

The clean-path flush is deliberately not in that finally. A finally runs
whether or not an exception is in flight, so suppressing the flush's
failure there suppressed it on the success path too: a stream that
drained cleanly and then failed to commit its trailing rows returned
normally, and the sync reported success over a corpus short by up to a
batch. Each insert also detaches its buffer before awaiting, because the
driver stamps _id in place before sending, so a resubmitted batch
collides on the prefix that already landed and silently drops the rest.
Cancellation skips the flush entirely: the decision to stop has been
made, and a CancelledError redelivered by that await would replace the
original and skip the phase's tally.

Isolation covers the destructive half too. Each phase owns a slice of
files -- experiments are the documents carrying no annotation type, each
annotation phase the documents carrying its own -- and deletes that
slice only once it has replacement rows in hand. Clearing the whole DCC
before the fan-out meant a failed experiment phase left the API serving
the 29,000 annotation documents as the entire corpus. A stream that
drains cleanly with no rows still clears, so a type ENCODE stops
publishing stops being served.

The download budget covers the whole sync rather than each stream in it.
Every phase runs inside the one cutover lock that gates the read
surface, so a per-stream budget multiplied the outage by the phase count
and carried the worst case past the sync lock's one-hour stale
threshold, at which point a second sync is admitted and clears the
corpus while the first is still writing into it. The budget is resolved
once and each phase gets what remains of it.

Isolating phases also means abandoning a generator mid-iteration on
every failure. The stream owns an aiohttp session inside its own context
manager, released only when the generator is finalized, so the streams
are closed explicitly rather than left to the garbage collector -- with
one stream per sync that was academic, with N+1 it is not.

The annotation tally is seeded from the configured allowlist so a type
ENCODE stops publishing reports zero rather than vanishing from the log.
An absent key is what nobody notices; a zero is what everybody does.

The broad except stays narrower than BaseException on purpose: a
CancelledError caught there would be logged as a phase failure and
followed by every remaining phase, which is the opposite of cancelling.
annotation_type is the field the whole annotation corpus is meant to be
reached through, organism narrows a result set spanning human and mouse,
and assembly narrows one spanning GRCh38, mm10 and hg19. All three
needed two things the schema alone does not give them.

An index, because the ENCODE sync writes the files collection directly
and never reaches the Rust materializer that owns the rest of its keys --
so nothing else would create these, and the flagship filter would be a
full scan of a ~300,000 document collection on an unauthenticated
endpoint.

The core genome_assembly field is indexed here for the same reason,
despite the materializer owning it. A database where ENCODE is the only
DCC synced never runs the materializer, and genome_assembly is the field
the schema publishes and a client narrowing to GRCh38 actually filters
on -- indexing only the extra.encode mirror would have left the
documented path scanning. Repeating a key the materializer also creates
costs nothing, because identical keys derive identical default names.

A place in the distinct-values allowlist, because filtering by
annotation type is only useful if the vocabulary is discoverable.
Otherwise a client has to already know ENCODE's exact spelling, which is
the string matching the annotation ingest set out to replace. All three
are small closed vocabularies, which is what that allowlist is for --
unlike the accessions, which are excluded from it deliberately because
enumerating one would return the whole corpus.
The module docstring is the reference for this service and described only
half of it once the annotation path landed. It now carries the second
metadata URL, the six renamed columns, the seven annotation-only ones,
and -- as importantly -- the list of columns the annotation TSV does not
publish, which are left unset rather than derived.

The supplement gains the same mapping, the allowlist and its configured
types with their real dataset and file counts, the two minted format
terms and why they are minted, and the annotation output types.

Three properties of the fan-out are recorded because a reader would
otherwise have to reconstruct each of them from the code. The download
budget bounds the whole sync rather than each stream in it, and the
reason is the cutover lock the phases run inside and the sync lock's
one-hour stale threshold beyond it. Each phase replaces only the slice
it reloads, and only once it has rows to put back, so a phase that dies
before delivering any leaves its previous rows being served. And the
paired-interval fix reaches ENCODE alone, since the format table has one
consumer and the other two DCCs never touch it.

What the mint costs is recorded alongside what it buys: the incorrect
tabix artifacts left orphaned in the cache for the re-typed files, and
that bigInteract goes from a degraded index to none at all until the
tileset endpoints land.

Two details are called out in both the docstring and the supplement. An
annotation document carries no subjects, since the TSV names no donor to
build one from, and its dataset is built from the accession alone so
that the 24 datasets whose files name no biosample stay queryable.

The donor traits are the one mapping the tables overstated. Life stage,
age and age units land on the biosample, so a row naming no biosample
term drops them -- which is exactly the row the annotation transform
relaxed its biosample requirement to serve. Nothing was lost against the
corpus as last measured, and inventing a biosample to hold them would be
worse than dropping them, but the mapping was written as unconditional
and it is not.
The minted prefix is the mechanism that stops an unrepresented format
being aliased onto a term meaning something else, and nothing tied the
exported constant to the table it governs. Three property tests do that,
each asserting through get_file_format rather than against the table
directly so the property covers what production reads: every id is
either an EDAM term or a minted one, a minted id is derived from its own
key, and a minted name is shared with no EDAM entry and claimed by no
processor. That last one is the load-bearing case -- since routing keys
on the name, a minted id paired with the name "BED" would drop the file
straight back into the pipeline the minting exists to keep it out of.

Each property's example budget is derived from the table it samples
rather than left at the default, so a closed domain is exhausted exactly
and grows with the table.

The routing assertion now goes through a registry wired the way the API
wires it, rather than two hand-picked classes, so it tracks what is
actually served. A positive control pins that plain BED still reaches the
tabix processor, since "no processor claims it" would also hold if lookup
were simply broken.

The annotation output types are asserted against their exact terms. The
previous test only checked for a non-null data term, so all five could
have pointed at the wrong one and passed.
The fixture carries the full 32-column annotation header with values from
a real released cCRE file, so the tests exercise the shapes the corpus
actually contains rather than an idealized row: an empty Assay term name,
which every one of the 12,448 cCRE rows has, a multi-valued Targets, and
a non-human organism.

On the fetch side: the URL is asserted by parsing its query rather than
by substring, which would pass against a stray or duplicated parameter,
and a type containing URL metacharacters is pinned as unable to smuggle
one in. Each failure mode -- HTTP status, timeout, network error -- is
checked to name its own stream, which is the entire reason the label was
threaded through, and to release its session. That last one caught a real
leak: async for does not close the iterator it abandons, so the
delegating wrapper left the session-owning inner generator suspended.

The shared deadline gets both of its cases. A stream given one bounds its
request by the time remaining rather than by a fresh full budget, and a
stream given a spent one refuses without opening a request at all --
aiohttp reads a non-positive total as no timeout, so passing the
remainder through would make the exhausted case unbounded.

Allowlist parsing gets its adversarial cases: all-blank values disable
ingest rather than widening it to an unfiltered query, repeats collapse,
and a property test pins that no configuration at all can produce a blank
entry. An explicitly empty value is pinned to warn, with a negative
control that the unset path stays silent, since the warning is what
separates a deliberate disable from a templating accident. The
default-when-unset test now deletes the variable first -- it previously
asserted against whatever the ambient environment held and passed with an
override exported.

On the transform side: the renames, the annotation-only fields, the
absence of the experiment-only ones, and the two behaviors that differ by
design -- no subjects without a donor column, and a dataset built from
the accession alone. The experiment path gets the pins it was missing:
its persistent_id, which was asserted nowhere in the repository despite
the URL path becoming a caller-supplied argument, and anatomy, which the
diff's guard change touched and no test read.

The assembly mirror is asserted once per transform rather than through
one parametrized case. The two read different columns, File assembly
against Assembly, which a shared parameter hid behind an argument name.

One document is validated through FileMetadataModel end to end. The
ingest writes plain dicts and the resolver reads them back through the
model on every row, so a key the model does not declare is dropped
silently -- the filter would exist and match nothing.
The central pin is that the reported total always equals the documents
actually committed. It is asserted for a stream that dies mid-flight --
the shape a metadata timeout takes -- and then generalized by a property
test over arbitrary fan-out, row counts, batch sizes and failure
patterns, since the example cases only fix a handful of shapes. Those
assertions are anchored to the whole progress line rather than matched
as substrings: "6 files" is contained in "16 files", so containment
passes on an order-of-magnitude error in the one number this design
exists to get right.

The failure cases now exercise the sink as well as the stream. Every
earlier one failed the metadata fetch, so nothing covered a stream that
drains cleanly and then cannot commit its last batch, which returned
normally and reported a clean sync over a short corpus. A batch the sink
rejects is pinned as never resubmitted, and a cancellation with rows
still buffered as never flushed.

Around those: the partial batch of a failed phase is committed rather
than discarded, a repeated allowlist entry loads its type once, a
configured type that returns nothing is reported as zero, a cancellation
propagates instead of being swallowed as a phase failure, and the error
chains the first failure so the traceback points at what started the
cascade. Every phase is pinned to receive one shared deadline, which no
test counting rows could otherwise observe.

The clearing tests follow the slice. A phase clears only what it
reloads, so the total-failure path leaves last sync's corpus intact
rather than emptying it, and a phase whose stream drains cleanly with no
rows empties its own slice rather than serving withdrawn documents
indefinitely.

Batching gets its boundaries -- exactly one batch, one plus a remainder,
and none -- plus the skip path for a row that does not transform.

The fake collection's $exists now resolves dotted paths. It tested for a
top-level key, so a filter on extra.encode.annotation_type reported
every document as lacking it and matched them all; the per-slice
clearing tests would have passed against a clear that deleted the
corpus.

The fixture that disables annotation phases moves from module scope to
the classes that drive the sync. It governs a network boundary -- without
it a unit test reaches the real portal once per configured type -- so it
belongs next to the tests that depend on it rather than silently over the
4DN and HuBMAP ones. A new test opts out of it deliberately, because
nothing otherwise joined the default allowlist to the phases that run.
A filter is only useful if the path it flattens to is the path the ingest
writes. A mismatch there would leave the whole annotation corpus
unfilterable while every ingest test still passed, so each of the eight
new fields is asserted from the GraphQL input through to its dotted
predicate -- including the five-segment biosample path, which crosses two
array levels and is the one most easily got wrong.

A property test pins that none of them is folded the way accessions are.
ENCODE's vocabulary is case- and space-significant, so folding any of
them would make the documents permanently unmatchable with nothing
raising.

The end-to-end tests then ask the questions a client would: give me the
cCRE files, narrow them to mouse, narrow them to mm10, and read the
fields back out. The distinct-values tests pin that the annotation types
and the assemblies can both be enumerated, since filtering by either is
only useful if the vocabulary is discoverable.

The index spec tests split by concern -- the accession paths, the
annotation facets, the core assembly field -- so each claim stays
readable now that the list covers more than accessions. A separate
assertion pins the full set by equality, because containment alone lets
an unintended spec merge unnoticed into a list that runs createIndex
against a public collection on every sync.
The point of giving bedpe and bigInteract their own format terms is what
the routes do with them, and that was asserted only at the mapping table.
Both routes are now pinned against a registry wired the way the API wires
it: a data request reaches the direct streaming path without dispatching
a workflow, and an index request returns 404 without consulting the
executor at all.

Serving the raw file is the correct outcome here rather than a
degradation. The alternative these tests rule out is the previous
behavior, where the pipeline claimed the file and committed an index
built from the first locus of each record.

Both fixtures inject the minted term rather than producing it, and each
carries a comment saying so. The format table has one consumer, the
ENCODE transform, so the 4DN ingest cannot mint it and a real 4DN .bedpe
still arrives declaring BED. The assertions hold as registry-routing
pins, since lookup keys on the format name irrespective of DCC, but read
as evidence that 4DN paired-interval files are safe they would be wrong.
A DCC that raised aborted every DCC after it. get_all_dcc_names sorts,
so one failed ENCODE phase cost the whole HuBMAP sync, and the
data-collection indexes built after the loop were skipped along with it.
The annotation fan-out did not introduce the cascade but made it
likelier: one network stream became three by default, against a portal
that returns 504 on requests it cannot assemble in time.

Each DCC is now isolated the way _sync_encode already isolates its
phases. A failure is logged and recorded, the remaining DCCs still run,
the indexes are ensured regardless, and the run fails once at the end
naming every DCC that broke, with the first failure chained as the
cause. The per-DCC success log and the accession coverage report move
inside the success path, so a DCC that failed no longer reports itself
as synced.

The except stays narrower than BaseException, so a CancelledError
cancels the run rather than being recorded as one DCC's failure and
followed by every remaining DCC.
cache_key derives dcc/local_id/artifact_kind/md5-vN. The processor's
identity is absent -- only its version number is present -- so two
processors claiming the same file and artifact kind at equal
processor_version derive the same key and read back each other's
artifacts as cache hits. That is a wrong answer rather than a miss.

Nothing collides today because each pair is claimed by at most one
processor, which is a property of the current registry rather than of
this function. Re-typing bedpe and bigInteract makes the case concrete:
those files still carry index artifacts built by TabixIntervalProcessor
before the re-typing, so a paired-interval processor is precisely what
would collide with them. A processor identity has to be folded into the
key before that processor lands.
@conradbzura
conradbzura force-pushed the 94-ingest-encode-annotation-files branch from 6ad8d07 to 4597276 Compare August 16, 2026 18:44
The contract suite already claimed to cover this. Its name said
``$exists`` resolves dotted paths and its docstring said the doc should
be returned, but the body only checked that the call did not raise, with
a comment conceding the matcher "may or may not return the doc" -- so it
passed against a matcher that resolved nothing. Both assertions now hold
the implementation to what the name claims, and both fail against the
previous one.

The negated case is the one with consequences. Matching by top-level key
reports every dotted path as absent, so a filter on
``extra.encode.annotation_type`` not existing matched the whole
collection -- and that predicate is how the ENCODE ingest selects the
experiment slice it clears before reloading. A delete built on it would
have taken the corpus rather than the slice, with the per-phase tests
passing throughout.
@conradbzura
conradbzura marked this pull request as ready for review August 18, 2026 15:04
@conradbzura
conradbzura merged commit d93d3ce into master Aug 18, 2026
4 checks passed
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.

Ingest ENCODE annotation files during sync

1 participant