Skip to content

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

Draft
conradbzura wants to merge 12 commits into
masterfrom
94-ingest-encode-annotation-files
Draft

Ingest ENCODE annotation files during sync — Closes #94#108
conradbzura wants to merge 12 commits into
masterfrom
94-ingest-encode-annotation-files

Conversation

@conradbzura

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.

The sync now runs one phase per stream, isolated so a failure in one does not cost the corpus the others, while still failing the task and naming what broke.

Verified against the live corpus rather than only against fixtures: all 29,140 real rows from both annotation TSVs transform and validate through FileMetadataModel with zero failures, and every field the issue requires is populated on every document.

Closes #94

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.

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.

A stream can die mid-flight, which is what an asyncio.TimeoutError against the metadata budget does, so a phase reports the rows it committed rather than only those from a clean return, and its trailing partial batch is flushed rather than discarded. The streams are closed explicitly rather than left to the garbage collector, since isolating phases means abandoning a generator that owns an HTTP session on every failure. The annotation tally is seeded from the allowlist so a type ENCODE stops publishing reports zero rather than vanishing from the log.

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.

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.

Both extra.encode.annotation_type and extra.encode.organism 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 allowlist entry matters because filtering by annotation type 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. Their /index responses become 404 and /data serves the raw upstream file. This is the intended correction — the index they returned before was built from the first locus only. Tileset endpoints that understand paired intervals are planned separately.
  • 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.

Test cases

# Test Suite Given When Then Coverage Target
1 tests/test_ontology_mappings.py Any entry in the format table Its id is inspected It is an EDAM term or a minted token, and its name is non-empty Minted-prefix invariant
2 tests/test_ontology_mappings.py Any minted entry Its id is compared with its table key The id is the prefix followed by the key Typo'd mint
3 tests/test_ontology_mappings.py Any minted entry Its name is checked against the EDAM entries and every processor It 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 A streamed response and a named annotation type The fetch is drained The query parses to exactly the three expected parameters Annotation URL construction
12 tests/test_encode.py An annotation type containing URL metacharacters The fetch is drained Status stays released and type stays Annotation Parameter injection
13 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
14 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
15 tests/test_encode.py A streamed body consumed part-way The generator is closed The session is exited Session release on abandonment
16 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
17 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
18 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
19 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
20 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
21 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
22 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
23 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
24 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
25 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
26 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
27 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
28 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
29 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
30 tests/test_sync.py The same mid-flight failure _sync_encode runs Every transformed row is committed, partial batch included Partial-batch flush
31 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
32 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
33 tests/test_sync.py All phases raising over a stale corpus _sync_encode runs The indexes are still ensured and every failed label is named Total-failure path
34 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
35 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
36 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
37 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
38 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
39 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
40 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 Clear precedes the fan-out
41 tests/test_sync.py No ENCODE_ANNOTATION_TYPES in the environment _sync_encode runs It fetches exactly the two default types Default allowlist wiring
42 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
43 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
44 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
45 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
46 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
47 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
48 tests/test_schema.py Files spanning two annotation types distinctValues is asked for the annotation type Both types are returned Vocabulary discovery
49 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
50 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
51 tests/test_indexes.py The materialized files index specs Their key tuples are collected They cover the accession and the annotation facet paths Index coverage

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 applies to the .bedpe already published under type=Experiment, not
only to the annotation files that prompted it. Those files lose an index
they should never have had; tileset endpoints that understand paired
intervals are planned separately.
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. 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.

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 the trailing batch is flushed in a finally, so a partial
load reports itself accurately.

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, and organism is what narrows a result set spanning human
and mouse. Both 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.

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. Both 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, the annotation output types, and a note on
how phase isolation behaves when a stream dies partway.

Two things are called out in both places because they are the kind of
detail a reader would otherwise have to reconstruct from the code: that
an annotation document carries no subjects, since the TSV names no donor
to build one from, and that its dataset is built from the accession alone
so that the 24 datasets whose files name no biosample stay queryable.
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:
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.

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.

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. 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.

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.

Around it: 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. The
total-failure path is pinned too, since clearing before loading means
every phase failing leaves the collection empty.

Batching gets its boundaries -- exactly one batch, one plus a remainder,
and none -- plus the skip path for a row that does not transform. Note
that the ingest clears the same list it hands to insert_many, so these
assert on call counts and the resulting documents, never on recorded call
arguments, which read back empty.

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 test pins that the annotation types
can be enumerated, since filtering by one is only useful if the
vocabulary is discoverable.

The index spec test moves from exact equality to containment and gains a
sibling for the annotation keys, so the accession claim stays readable
now that the list covers more than accessions.
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.
@conradbzura conradbzura self-assigned this Aug 13, 2026
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