Skip to content

Add HiGlass-compatible tileset endpoints for Hi-C contact maps — Closes #82 - #118

Merged
conradbzura merged 14 commits into
masterfrom
82-higlass-tileset-endpoints
Sep 29, 2026
Merged

conradbzura merged 14 commits into
masterfrom
82-higlass-tileset-endpoints

Conversation

@conradbzura

@conradbzura conradbzura commented Sep 9, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Add a pure-reader pair of HiGlass-compatible tile endpoints — GET /tileset_info/?d=<uid> and GET /tiles/?d=<uid>.<z>.<x>.<y> — plus a separate POST /tilesets/{dcc}/{local_id} preparation channel that builds the tileset artifact those readers serve from. This lets Gosling's MatrixData (which compiles to HiGlass's native heatmap track, not gosling-track) render Hi-C contact maps against a live tile API instead of a static file.

The tile endpoints never dispatch a workflow — a heatmap track issues thousands of small requests and cannot be handed a job ID to poll — so a contact map with no cached artifact is a plain 404. Building the artifact is the preparation channel's job, mirroring the existing 202-plus-poll pattern used by /data and /index.

Tile reading is backed by a private fork of clodius (abdenlab/clodius, tiles_v2), pinned to a commit sha on its main branch rather than a floating reference, since tiles_v2 is unstable and cfdb is its first consumer. The dependency is an optional extra (uv sync --extra tiles) gated to Python >= 3.12, so cfdb keeps its 3.11 support and images built without the extra answer 501 on the tile routes rather than failing to boot.

Deviation from the issue's open design decision: the issue proposed reading ENCODE .hic in place via hicstraw and HTTP range requests. In practice, clodius's .hic tileset is built on hictkpy, whose entry points take a local path only — there is no URL, HTTP, or S3 surface in the library. Serving .hic would require materializing it first, and the ENCODE corpus makes that infeasible at any disk budget: 3,903 files, ~78 TB total, a median of 10.3 GB, and a largest file of 315 GB, versus a 200 GiB Fargate ephemeral-storage ceiling. .hic is recognized and refused with a specific 501 rather than served; only 4DN .mcool (773 files) and .cool (10 files, coarsened via cooler zoomify first) are servable today. This is documented as a known limitation rather than resolved.

This branch also carries two fixes unrelated to tiling, found while validating the feature against real data:

  • The HuBMAP outbound-URL allowlist was missing the DRS metadata broker host, so every HuBMAP file fetch failed with a 502 before ever reaching the asset host.
  • HuBMAP's non-public-dataset pruning only matched via the file_in_collection junction table, which HuBMAP's C2M2 export never populates — the materializer's persistent_id fallback join was invisible to it, so files from non-public and dbGaP-controlled-access datasets were leaking into the API stamped data_access_level: public.

And one fix to local dev bootstrap: a clean checkout's database/ directory is gitignored, so the MongoDB image's COPY database/ failed the build outright on a fresh clone (it only appeared to work when a stray file like .DS_Store kept the directory alive). The dump is now mounted read-only at run time instead, and a related getIndexes NamespaceNotFound crash on a from-scratch database is handled.

Closes #82

Proposed changes

Tileset domain layer (src/cfdb/tilesets/)

  • backend.py — the single import gate for clodius, so a build without the tiles extra degrades to a clear 501 instead of an ImportError at startup. Classifies clodius's whole deliberate-error hierarchy (TilesetError, not just the narrower TileError) as "render per-tile-id," so a malformed id, an unusable transform, and an out-of-ladder position all become a per-tile {"error": ...} object rather than failing the batch.
  • service.py — owns tileset lifetime: a bounded LRU of open h5py handles (reference-counted, so an eviction never closes a handle a reader is still using), a byte-bounded tile cache, and a dedicated thread pool since clodius reads are synchronous h5py + numpy.
  • formats.py — classifies a file as mcool, cool, or hic from its filename and file_format, since the C2M2 ontology collapses all HDF5-shaped formats (including AnnData .h5ad) onto one term.
  • store.py, wire.py, errors.py — artifact hydration onto local disk, HiGlass tileset_info payload assembly (adding the four fields clodius doesn't model: datatype, name, uuid, coordSystem), and the failure vocabulary that becomes HTTP status codes.

Workflow integration

  • A new ArtifactKind.TILESET alongside data/index, sharing the existing content-addressed cache-key derivation (upstream md5, processor version).
  • MatrixTilesetProcessor fetches an .mcool byte-identical into the cache, or coarsens a flat .cool with cooler zoomify first (clodius refuses a cooler with no resolutions group). Enforces CFDB_TILESET_MAX_SOURCE_BYTES before a byte is downloaded, including sources whose size is reported as a string.

HTTP endpoints (src/cfdb/api/routers/)

  • tiles.py — /tileset_info and /tiles, read-only, capped at 64 repeatable d parameters per request. The response carries exactly one entry per requested id (out-of-ladder and malformed ids render inline as errors rather than being omitted), so a response can safely be built by zipping request against result.
  • tilesets.py — POST /tilesets/{dcc}/{local_id} and its /status probe, idempotent (an already-built artifact answers 200 {"ready": true} rather than queuing a no-op job).
  • _helpers.py/data.py/index.py — Extract the DCC-normalize/lookup/access-control preamble that /data and /index already shared, so the two new routers reuse it instead of a third and fourth restatement.

Local dev and infra

  • Dockerfile.api-tiles and new make api-tiles/make worker-tiles targets layer clodius onto the API image for local testing, since make api's image intentionally carries no clodius while the extra stays optional.
  • cloudformation/backend.yml adds ApiEphemeralStorageGiB/TilesetDiskCacheBytes parameters, since h5py needs a tileset artifact on local disk (not just a byte stream) before any tile can be read from it under the S3 profile.

Documentation

  • New "Matrix tile serving" README section: the two-channel design, wire protocol and status codes, environment variables, local-dev flow, and documented limitations — including empirical wire-compatibility notes checked against higlass.io and server.gosling-lang.org's live tile servers, and one deliberate divergence from them (an out-of-ladder tile is now a per-tile error object rather than a silently omitted entry).

Test cases

# Test Suite Given When Then Coverage Target
1 TestLoadBackend An environment where clodius is installed load_backend is called It returns a backend whose cooler_tileset is clodius's CoolerTileset and whose error attributes are the split clodius exception types Import gate happy path
2 TestIsTileError A loaded backend and a MalformedTileId instance is_tile_error is called It returns True, even though MalformedTileId is no longer a subclass of the narrower TileError Per-tile vs 500 classification
3 TestMatrixSourceKind A file document labelled HDF5 with each supported extension matrix_source_kind classifies it It returns the matching MatrixSource, including case-insensitively Contact-map format discrimination
4 TestMatrixSourceKind A file document labelled HDF5 with an .h5ad/bare .h5 extension matrix_source_kind classifies it It returns None — the HDF5 ontology collision is escaped by filename Non-contact-map exclusion
5 TestResolution (service) A contact map with no cached tileset artifact tileset_info/tiles is called It raises TilesetNotReady without dispatching anything Pure-reader contract
6 TestTiles (service) A batch pairing a valid tile with one past the resolution ladder tiles is called Both ids appear in the response — the valid tile returns data, the out-of-ladder one gets a per-tile {"error": ...} object One-entry-per-id wire contract
7 TestTiles (service) A tile id naming a balancing column the cooler does not carry, alongside a valid untransformed sibling tiles is called Only the bad-modifier id errors — clodius batches per (zoom, modifier), so the valid sibling is unaffected Finer-grained blast radius
8 TestOpenTilesetLifetime An open tileset handle evicted past the open cap while a reader is inside checkout Eviction runs concurrently with the read The close is deferred until the last reader exits Reference-counted handle safety
9 TestHydration (store) A non-local cache artifact The store opens it The artifact is downloaded onto local disk once and reused on a repeat request Single-flight hydration
10 TestEviction (store) An artifact currently being read The store evicts to stay under budget The in-use artifact is never unlinked Disk-budget safety under live reads
11 TestTilesetInfoPayload (wire) A file document carrying no genome_assembly tileset_info_payload builds the response coordSystem is omitted entirely rather than emitted as an empty string Chromsizes fallback safety
12 TestSourceSizeGuard (matrix processor) A source file whose reported size exceeds CFDB_TILESET_MAX_SOURCE_BYTES, including a string-typed size The processor checks applicability It refuses before a byte is downloaded Oversized/mislabelled source guard
13 TestStatusProbe/TestPrepare (router) The tile backend absent (no clodius) The status probe or prepare endpoint is called It raises 501 naming the tile backend and dispatches nothing Backendless-build degradation
14 TestPrepare (router) An executor at the active-workflow admission ceiling prepare_tileset is called It raises 429 carrying the executor's own Retry-After Load shedding parity with /data//index
15 TestTilesOverHttp (e2e) A real cooler built through the matrix processor into a committed cache artifact /tiles is requested over HTTP The decoded tile matches a direct cooler read End-to-end byte fidelity
16 TestEvictionUnderLiveRead (e2e) Concurrent tile reads against a tileset being evicted from the open-handle cache Requests run concurrently No reader sees a closed handle or a segfault-class failure Concurrency safety under real load
17 TestS3ProfileTileChain A real mcool committed through the processor into a moto-backed S3Cache Tiles are requested The store hydrates once from S3 then serves warm, respecting the disk budget and hydration timeout S3 profile hydration path
18 TestLifespanProfileMatrix Every workflow profile (local, S3, mTLS) The app lifespan boots The tile service wires against real cache and discovery implementations, not fakes, for each profile Lifespan wiring across deployment profiles
19 test_physical_names_should_be_environment_scoped (cloudformation) The CloudFormation templates, including the new ephemeral-storage parameters Pinned physical names are checked None is a hard-coded literal that would collide across dev/prod Environment-scoping invariant
20 TestPruneNonPublicHubmapRawRecords A non-public HuBMAP dataset whose files carry the dataset DOI as their own persistent_id, with no file_in_collection entry at all _prune_non_public_hubmap_raw_records is called The file is deleted via the persistent_id fallback match, even though the file_in_collection-based path finds nothing Access-control leak fix
21 TestMongoCleanCheckoutBoot A clean checkout with no database/ dump, built and run through real docker The MongoDB container boots It survives with an empty, fully-indexed database rather than crashing on the missing dump Clean-checkout bootstrap fix
22 TestIndexSpec The jobs collection's index script running against a namespace that has never been written to createIndex runs NamespaceNotFound is swallowed and the index is created rather than crashing the container under set -e Bootstrap index-creation robustness
23 3.11 CI leg (tile test suite) An environment where the tiles extra (and therefore clodius/numpy) is not installed, matching Python 3.11 CI The tile test suite is collected and run Clodius-dependent tests skip cleanly via pytest.importorskip rather than failing collection or asserting against a backend that cannot exist Cross-version CI correctness

@conradbzura conradbzura self-assigned this Sep 9, 2026
Every HuBMAP access_url resolves through drs.hubmapconsortium.org
before bytes flow from assets.hubmapconsortium.org, but the worker
outbound-URL allowlist only carried the asset host. Every HuBMAP
file fetch failed with a 502, since the DRS resolution step itself
was rejected as unsafe before it ever reached the asset host.
HuBMAP's C2M2 export never populates file_in_collection, so the Rust
materializer falls back to matching file.persistent_id against
collection.persistent_id to join a file to its dataset. The pruning
step that removes files belonging to non-public datasets only knew
about the file_in_collection join, so it was a complete no-op for
HuBMAP: every non-public and dbGaP-controlled-access file survived
pruning and was stamped data_access_level=public regardless of its
true access level.
The mongodb image baked the sample dump in with COPY database/, but
that directory has been gitignored since the 4DN backup was removed
from the repo, so on a clean checkout the COPY failed the build
outright. The build only appeared to work on machines where a stray
file such as .DS_Store happened to keep the directory alive.

The dump is now mounted read-only at run time instead: the image no
longer varies with whatever a developer has on disk, and obtaining a
dump later costs a container restart rather than a rebuild. A missing
or empty database/ degrades to "empty database" with a pointer at
/sync, while a dump that IS present and fails to restore still stops
the container rather than leaving a half-loaded database looking
healthy.

An empty database then surfaced a second bootstrap break: getIndexes
raises NamespaceNotFound on a collection that has never been written
to, which under set -e took the whole container down. A namespace
that does not exist trivially holds no conflicting index, so the
helper treats it as the empty list and falls through to createIndex,
which creates the collection along with the index. Only that one
error code is swallowed -- anything else still propagates.
Contact-map tile serving needs a third cached artifact kind alongside
data and index: a tileset is a byte-identical local copy of the
upstream .mcool, opened directly with h5py rather than streamed back
over HTTP. This adds ArtifactKind.TILESET and the cache-key plumbing
that keys it the same way as the other two, so the existing
content-addressed cache invalidation (upstream md5, processor version)
applies to it for free.
Adds MatrixTilesetProcessor, the workflow-layer piece that turns an
upstream .mcool or .cool into a servable tileset artifact: an .mcool
is fetched byte-identical into the cache, while a flat .cool is
coarsened with cooler zoomify first, since clodius refuses a cooler
with no resolutions group. Guards against an oversized or
mislabelled source: CFDB_TILESET_MAX_SOURCE_BYTES is enforced before
a byte is downloaded, including sources that report their size as a
string rather than an int, so a bad record fails admission instead of
filling the worker's disk.
Adds cfdb.tilesets: the read path that opens a cached tileset
artifact and serves HiGlass tileset_info and tile payloads from it.

- backend.py is the single import gate for the clodius tile backend,
  so a build without the tiles extra degrades to a clear 501 on the
  tile routes instead of an ImportError at startup. Its tile_error
  classification is bound to clodius's TilesetError root -- the base
  of the whole deliberate-error hierarchy, not just the narrower
  TileError node -- so a parse-time failure (a malformed id) and a
  read-time failure (an out-of-ladder position, an unusable
  transform) both degrade to a per-tile error payload rather than a
  bug.
- service.py owns tileset lifetime: a bounded LRU of open h5py
  handles, reference-counted so an eviction never closes a handle a
  reader is still using, plus a byte-bounded tile cache. Reads run on
  a dedicated thread pool since clodius is synchronous h5py + numpy.
- formats.py classifies a file as mcool, cool, or hic from its
  filename and file_format, since the C2M2 ontology collapses all of
  them onto one HDF5 term.
- store.py, wire.py, and errors.py round out the artifact-hydration,
  HiGlass-payload-assembly, and failure-vocabulary pieces.
/data and /index both open with the same sequence: normalize the DCC,
look up the file document, and check consortium/protected access.
Pulling that into _helpers.py lets the new tile and tileset-prep
routers reuse it verbatim instead of restating the access-control
logic a third and fourth time.
Gosling's MatrixData compiles to HiGlass's native heatmap track,
which issues thousands of small tile requests and cannot be handed a
job id to poll -- neither /data nor /index can satisfy it. Two
channels, deliberately separate:

- /tileset_info and /tiles speak the HiGlass wire protocol
  (repeatable d parameters, capped at 64; uid {dcc}/{local_id}) and
  are pure readers that never dispatch a workflow -- a contact map
  whose artifact has not been built is a 404. The response carries
  exactly one entry per requested id: a tile position the resolution
  ladder does not hold, or an id that fails to parse, gets a per-tile
  error object in its slot rather than being omitted or failing the
  whole batch.
- POST /tilesets/{dcc}/{local_id} builds the artifact, and its
  /status probe mirrors the /data and /index readiness probes. POST
  is idempotent: an artifact that already exists answers
  200 {"ready": true} rather than creating a no-op job.

.hic is recognized and refused with a specific 501 rather than
served: the tile backend reads local files only, and the ENCODE
corpus (~78 TB, largest file 315 GB) cannot be materialized onto
Fargate ephemeral storage at any budget. The lifespan wires the
service only when the workflow subsystem is enabled and the build
carries clodius, which is why the tiles extra exists as a separate,
optional dependency group.
make api builds from Dockerfile.api, which carries no clodius, so its
tile routes answer 501 -- correct while the tiles extra stays
optional, but it leaves the whole tile chain untestable through the
documented Docker flow. Dockerfile.api-tiles and the new
api-tiles/worker-tiles Makefile targets layer the tile backend on and
add the one piece make api does not need: a cache volume shared with
a worker, since a tileset artifact is built by the worker and then
opened locally by the API with h5py. Both are interim scaffolding,
retired once tiles becomes an ordinary dependency.
h5py opens a file rather than consuming a byte stream, so under the
S3 profile an mcool must be pulled onto local disk before any tile
can be read from it. Adds ApiEphemeralStorageGiB and
TilesetDiskCacheBytes parameters to backend.yml so the task's
ephemeral storage is sized to hold the tileset disk cache alongside
SYNC_DATA_DIR and the container image, instead of leaving the two
budgets to silently compete for the same disk.
The fork was private during initial development, so clodius resolved
from a sibling checkout on disk. Now that it is public, pin
[tool.uv.sources] to a commit sha on the fork's main instead --
still a sha rather than a branch reference, since clodius considers
tiles_v2 unstable and cfdb is its first consumer, so what runs must
be exactly what was validated.

main does not carry clodius.tiles_v2.hic -- load_hic_tileset degrades
to TileBackendUnavailable for it, same as a build with no clodius at
all. That is a no-op for cfdb today: .hic is refused at the call site
regardless, because the module reads local files only, which does
not fit this corpus at any disk budget. Serving .hic at all is
tracked as separate follow-up work, not something this pin is
expected to grow.
Covers the parts unit tests cannot: real wool worker dispatch
building a tileset artifact and serving tiles from it over HTTP under
concurrency and eviction pressure, the S3-profile tile chain under
moto, and the lifespan wiring across every profile (local, S3, mTLS)
booting real cache and discovery implementations rather than fakes.
Extends the shared integration scenario model and its sample-file
fixtures to cover the preparation channel alongside the existing
data/index paths.
Adds the Matrix tile serving section: the two-channel design (pure
readers vs. the preparation channel that builds artifacts), the wire
protocol and status codes, the tileset environment variables, and the
Dockerfile.api-tiles / make api-tiles / make worker-tiles local-dev
flow. Also records the documented limitations -- .hic is recognized
and refused rather than served, the tileset artifact's disk footprint
per API task, and the per-tile dtype and interchromosomal-tile
behavior verified against live HiGlass reference servers.
@conradbzura
conradbzura force-pushed the 82-higlass-tileset-endpoints branch from 982bd12 to 66d74e6 Compare September 28, 2026 20:28
CI's Python 3.11 leg installs the dev extra without tiles, since
clodius is gated python_version >= '3.12' -- by design, cfdb still
supports 3.11 without the tile backend. Two gaps let that leg fail
outright instead of skipping cleanly:

- Five test modules imported numpy unconditionally at module scope
  (tests/fixtures/coolers.py and its transitive importers, plus
  test_tiles.py and test_tilesets/test_service.py directly), so
  collection itself raised ModuleNotFoundError before any skip logic
  could run. Two of the five already guarded on cooler/clodius but not
  numpy, so the bare numpy import failed first regardless.
- 25 tests across test_tilesets/test_backend.py,
  test_api/test_lifespan_tilesets.py, and test_tilesets_router.py
  exercise the "clodius present" path against the real backend rather
  than a mock, and were never actually reached on 3.11 before now --
  pytest aborts the whole run on collection errors, which the numpy
  ImportError always triggered first, masking these underneath it.

Guard both classes of test with pytest.importorskip("clodius") (or
"numpy" where that is the more precise dependency), leaving the
absent-clodius degradation tests -- the ones that matter most for a
3.11 build -- to keep running unconditionally.
@conradbzura
conradbzura marked this pull request as ready for review September 29, 2026 14:51
@conradbzura
conradbzura merged commit b621d18 into master Sep 29, 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.

Add HiGlass-compatible tileset endpoints for Hi-C contact maps

1 participant