Skip to content

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

Draft
conradbzura wants to merge 14 commits into
masterfrom
82-higlass-tileset-endpoints
Draft

Add HiGlass-compatible tileset endpoints for Hi-C contact maps — Closes #82#118
conradbzura wants to merge 14 commits into
masterfrom
82-higlass-tileset-endpoints

Conversation

@conradbzura

Copy link
Copy Markdown
Collaborator

Summary

Gosling compiles MatrixData to HiGlass's native heatmap track, which issues thousands of small, latency-sensitive tile requests and cannot be handed a job id to poll. Neither /data nor /index can satisfy that access pattern, so 4,364 indexed contact maps are unreachable from Gosling. Implement the HiGlass wire protocol directly in FastAPI over the existing workflow cache, rather than deploying higlass-server — a Django application whose database and tileset registry would duplicate what the files collection already is.

The design separates two channels. GET /tileset_info and GET /tiles are pure readers that never dispatch work: a contact map whose artifact has not been built is a 404. Building that artifact belongs to a preparation channel — POST /tilesets/{dcc}/{local_id} plus a side-effect-free readiness probe — that a UI drives before mounting the Gosling spec. The artifact itself is a third workflow artifact kind, tileset, produced by a worker-side matrix processor: a byte-identical copy for .mcool, a cooler zoomify coarsening for the 10 flat .cool files, which carry no resolution pyramid and cannot be tiled as-is.

Wire compatibility is verified rather than assumed. Against higlass.io, server.gosling-lang.org, and a local higlass-server run over the same .mcool, payload shapes match exactly (dense/dtype/min_value/max_value/size, 256×256, no shape field), and tile values are bit-identical to direct cooler reads at every intrachromosomal resolution, modulo the per-tile float16 quantization both reference servers also perform. One representational difference is documented and shown to be client-invisible: cfdb sends the full symmetric matrix where higlass-server sends half, and the HiGlass client's own mirroring and symmetrization make the two indistinguishable.

Two deliberate deviations from the issue as filed. The endpoints speak HiGlass's actual wire form (repeatable d query parameters) rather than the path-style routes the issue sketched, because the client drives /tiles itself and expects that contract. And .hic is recognized but refused with a specific 501 rather than served via hicstraw: every available reader consumes local paths only, and the ENCODE corpus (~78 TB, median 10.3 GB, largest 315 GB) exceeds Fargate ephemeral storage at any budget. The issue's open design decision — read in place versus convert once — is resolved as defer, with partial-coverage cost quantified in the README for when it is revisited.

The tile backend is the private abdenlab/clodius fork's tiles_v2, consumed as an optional tiles extra behind a single import gate; images built from the Dockerfiles answer 501 on the tile routes until the fork is published, which is tracked as follow-up. The branch also carries an independent fix: a clean checkout could not boot MongoDB at all once the sample dump left the repo.

Closes #82

Proposed changes

Workflow layer: the tileset artifact kind

Add ArtifactKind.TILESET for files prepared for random-access tile reads. Unlike data and index it is never streamed: it exists to be opened locally by the tile server and read a 256×256 block at a time, which is why no router serves it and why the tile endpoints 404 rather than 202 in its absence. Expose LocalFsCache.path_for, since h5py opens a file rather than consuming a byte stream, and bound artifact sources with CFDB_TILESET_MAX_SOURCE_BYTES (default 20 GiB), checked before a byte is downloaded.

Worker: MatrixTilesetProcessor

Both contact-map formats are multi-resolution pyramids built at creation time, so no pre-aggregation is performed — the artifact for an .mcool is a copy, and a flat .cool is coarsened with cooler zoomify (unbalanced, deliberately: ICE on a large flat cooler costs minutes to hours and clodius falls back cleanly when no weight column exists). The processor claims files by filename extension because the ontology cannot discriminate: hic, cool, mcool, and h5ad all map onto the single EDAM HDF5 term.

API: the tileset read stack (cfdb.tilesets)

Six modules, layered so only service and store touch a tile backend: formats (contact-map classification, stdlib-only so the worker can import it), backend (the single clodius import gate — absence degrades to a clear 501 instead of an ImportError at startup), store (cached artifact to local path; local artifacts open in place, S3 artifacts hydrate onto an LRU-bounded disk budget), service (handle pool, byte-bounded tile cache, and a dedicated thread pool so synchronous h5py reads cannot stall the event loop), wire (the fields clodius's file-derived TilesetInfo leaves to the server: name, uid, coordSystem), and errors (cfdb's failure vocabulary, distinct from clodius's per-tile request errors). Tunables: CFDB_TILESET_DISK_CACHE_BYTES, CFDB_TILESET_OPEN_MAX, CFDB_TILE_CACHE_BYTES, CFDB_TILE_THREADS, CFDB_TILESET_HYDRATE_TIMEOUT_S.

API: routers and lifespan

Serve /tileset_info and /tiles with the batch semantics HiGlass relies on: up to 64 d parameters, absent tiles omitted rather than reported, and a malformed id yielding a per-tile error object so it cannot blank the track. Serve the preparation channel with an idempotent POST (an existing artifact answers 200 {"ready": true}) and a probe that never dispatches. Wire the service in the lifespan only when the workflow subsystem is enabled and the build carries clodius, and close it — not merely null it — on teardown, so no h5py handle or thread pool leaks into the next app instantiation.

Routers: shared request preamble

Extract the per-request sequence /data and /index each duplicated — DCC validation, database check, lookup, HuBMAP access control — into resolve_file_doc, which the tileset routers would otherwise have made a fourth copy of. Add a shared path-param constraint and carry genome_assembly in the file projection; it is the only path that holds an assembly, and coordSystem derives from it.

Build and local dev

Add the tiles extra with a python_version >= '3.12' marker (the fork's floor; cfdb still supports 3.11) and a non-editable path source pointed at the sibling checkout. Add make api-tiles/make worker-tiles, which vendor the checkout into the build context and share a cache volume between API and worker — interim scaffolding, retired when the fork is public.

Infrastructure

Raise the API task's ephemeral storage (ApiEphemeralStorageGiB, default 50) and pass the tile disk budget alongside it (TilesetDiskCacheBytes), so the budget and the disk it shares with SYNC_DATA_DIR move together.

Independent fix: clean-checkout MongoDB bootstrap

The mongodb image baked the sample dump in with COPY database/, but that directory is gitignored and absent on a clean checkout, failing the build outright. Mount the dump read-only at run time instead, degrade a missing dump to an empty database with a pointer at /sync, and treat getIndexes NamespaceNotFound as the empty list so index creation survives never-written collections.

Test cases

# Test Suite Given When Then Coverage Target
1 TestMatrixSourceKind / TestResolution File documents whose format name is HDF5 Classified by filename extension .mcool/.cool/.hic are recognized as contact maps and .h5ad is not Contact-map discrimination
2 TestLocalFsFastPath A tileset artifact in a LocalFsCache A local path is requested The cached file is opened in place without copying Local-profile fast path
3 TestHydration / TestEviction Artifacts behind an S3-profile store with a byte budget Copies exceed the budget Least-recently-used copies are evicted and in-use copies survive Disk cache bounds
4 TestTilesetInfo / TestTiles (service) An open service over a real mcool Tileset info and tiles are read Payloads match a direct cooler read Read correctness
5 TestTileCache / TestOpenTilesetLifetime Repeated reads of the same tile and an evicted open handle The cache holds the entry; a reader is still active The backend is not re-read, and the handle closes only after its last reader finishes Cache and handle lifetimes
6 TestOverHttp / TestRequestValidation The API over seeded mcool fixtures /tileset_info and /tiles receive repeatable d parameters Responses carry the HiGlass wire shape and malformed ids yield per-tile error objects Wire protocol
7 TestHicIsDeferred An ENCODE .hic file document Any tile route is hit A specific 501 is returned Deferred format
8 TestDegradedModes A build without clodius, or a disabled workflow subsystem Tile routes are hit Clean 501/503 answers rather than partial failure Degraded modes
9 TestStatusProbe / TestPrepare Files with and without built artifacts The probe and POST are called The probe never dispatches and POST is idempotent Preparation channel
10 TestDataFallThrough / TestIndexFallThrough / TestRegistryLookup The matrix processor claiming the HDF5 format Contact maps hit /data and /index They stream from upstream rather than dispatching preprocessing HDF5 routing regression
11 TestApplicability / TestSourceSizeGuard Documents of each contact-map kind and an oversized source The processor admits work Only tileable formats are claimed and oversized sources are refused before download Worker admission
12 TestRun / TestZoomLadder Real cooler inputs The artifact is built An mcool is copied byte-identically and a flat cool gains a resolutions ladder Artifact build
13 test_lifespan_tilesets Lifespans with and without clodius and SYNC_DATA_DIR The app starts and stops The service is wired only when both hold, and is closed on teardown Lifespan wiring
14 TestFileDocProjection / TestResolveFileDoc Each router's preamble inputs resolve_file_doc runs DCC validation, lookup, and access control agree across routers Shared preamble
15 test_cloudformation The rendered backend template Tile parameters are inspected Ephemeral storage and cache-byte parameters carry documented defaults and reach the task definition Stack parameters
16 test_tiles_e2e A real cooler through the full chain Processed into a real cache and served over HTTP Tile payloads equal direct cooler reads End to end

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 maps need a third artifact kind alongside ``data`` and
``index``: a file prepared for random-access tile reads. Unlike the
other two it is never streamed to a client -- it exists to be opened
locally by the tile server and read a 256x256 block at a time, which is
why no router serves it and why the tile endpoints 404 rather than 202
when it is absent.

``LocalFsCache`` grows ``path_for`` because h5py opens a file rather
than consuming a byte stream, so ``get`` is the wrong shape for the tile
server; on this backend the cached artifact already is a local file, so
it can be opened in place. That is also what lets the tile subsystem be
unit-tested without S3.

``CFDB_TILESET_MAX_SOURCE_BYTES`` (default 20 GiB) bounds the source a
tileset artifact may be built from, checked before a byte is downloaded:
the artifact for an ``.mcool`` is a copy of the upstream file, so an
oversized or mislabelled source would otherwise fill the worker's disk
and fail somewhere far less legible than at admission.
The worker-side half of matrix tile serving. For an ``.mcool`` the
artifact is a byte-identical copy of the upstream file -- both formats
are already multi-resolution pyramids built at creation time, so no
pre-aggregation is required. A flat ``.cool`` is coarsened with
``cooler zoomify`` first, because it has no ``resolutions`` group and
cannot be tiled as-is; balancing is skipped deliberately, since ICE on a
large flat cooler costs minutes to hours and clodius falls back cleanly
to unbalanced when no ``weight`` column exists.

The processor claims files by filename extension rather than by
``file_format.name``, because the ontology mapping folds ``hic``,
``cool``, ``mcool`` and ``h5ad`` all onto HDF5 -- format name alone
cannot distinguish a contact map from an AnnData file.
The API-side machinery a tile request needs between a cached artifact
and a HiGlass payload, layered so that only ``service`` and ``store``
ever touch a tile backend:

- ``formats`` decides which files are contact maps, and of what kind.
  The ontology cannot: ``hic``, ``cool``, ``mcool`` and ``h5ad`` all map
  onto the single EDAM HDF5 term, so the filename extension is the only
  discriminator the file document carries. Stdlib-only, so the
  worker-side processor can import it.
- ``backend`` is the single import gate for clodius. The fork is
  private, so production images do not carry it; concentrating the
  import lets that degrade to a clear 501 on the tile routes instead of
  an ImportError at startup or a subsystem that half-works.
- ``store`` turns a cached artifact into a local filesystem path. h5py
  needs random access to real bytes, so ``CacheBackend.get``'s byte
  stream is the wrong shape; local artifacts open in place, S3 artifacts
  are pulled onto an LRU-bounded local disk budget.
- ``service`` owns the open-handle pool and the byte-bounded tile cache,
  and runs the synchronous h5py reads on a dedicated thread pool so a
  multi-second tile read cannot stall the event loop.
- ``wire`` supplies what clodius's file-derived ``TilesetInfo``
  deliberately leaves to the server: the dataset's name, its uid, and
  the coordinate system.
- ``errors`` is cfdb's own failure vocabulary -- reasons a tileset
  cannot be produced at all, translated by the routers into status
  codes, distinct from clodius's per-tile request errors.
/data and /index each carried their own copy of the per-request
preamble -- DCC validation against the registry, the database-ready
check, the file lookup, and HuBMAP access control -- and the tileset
routers would have made it four copies. ``resolve_file_doc`` now holds
that sequence in the one ordering all of them agree on, alongside a
shared path-param constraint (accessions across every DCC are subsets of
``[A-Za-z0-9._-]``; the length cap defends Mongo and log lines from
unbounded input).

``FILE_DOC_PROJECTION`` additionally carries ``genome_assembly``, which
every DCC's sync promotes to the top level of the file document and
which the tileset routers read to derive HiGlass's ``coordSystem`` --
the ``extra.*.genome_assembly`` fields declared on the enriched models
are never written, so this is the only path that carries an assembly.
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. A tile position the
  resolution ladder does not hold is absent from the response rather
  than reported, and a tile id that fails to parse gets an ``error``
  object in its place so one malformed id cannot blank the track.
- ``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: hictkpy reads local paths 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; the
``tiles`` extra stays optional because the fork is private and requires
Python 3.12, so images built today answer 501 on these routes.
``make api`` builds an image with no clodius, so its tile routes answer
501 -- correct for production while the fork is private, but it leaves
the whole tile chain untestable through the documented Docker flow.
``make api-tiles`` layers the backend on and adds the one piece
``make api`` does not need: a cache volume shared with a worker, because
a tileset artifact is built by the worker and then opened locally by the
API with h5py.

The sibling ``../clodius`` checkout is vendored into ``.clodius-src/``
(gitignored) first, because a ``[tool.uv.sources]`` path dependency
pointing outside the build context is invisible to COPY. The worker
reuses the API image rather than ``cfdb-wool``: the matrix processor
needs cooler and h5py, which arrive with clodius and are absent from the
worker image. All of this is interim scaffolding, retired once the fork
is public and ``tiles`` becomes an ordinary dependency.
h5py opens a file, so under the S3 profile an mcool must be pulled onto
local disk before any tile can be read from it. The Fargate task's
ephemeral storage is raised above the 20 GiB default
(``ApiEphemeralStorageGiB``, default 50) and the tile subsystem's byte
budget rides along as ``TilesetDiskCacheBytes`` -- the two are passed
together so the budget stays comfortably below the disk it shares with
``SYNC_DATA_DIR`` and the container image, rather than filling the tile
cache taking /data and /sync down with it.
Formats classification, the local store's hydration and LRU eviction,
and the service's handle pool, tile cache, and thread offload -- run
against real coolers rather than mocks. The synthetic builders are
ported from the clodius fork's test harness, because the payload
assertions (a 256x256 dense block, base64-encoded, with no ``shape``
field) are only meaningful against bytes clodius actually produced.

The HDF5 routing regression suite pins what /data and /index do once a
processor claims the HDF5 format: ``MatrixTilesetProcessor`` declares
``supported_formats = {"HDF5"}``, so ``ProcessorRegistry.lookup_for``
now returns a processor for files it previously returned ``None`` for,
and those endpoints must keep streaming contact maps from upstream
rather than dispatching a workflow for them.
The tile endpoints are exercised over HTTP against real mcool fixtures:
the HiGlass wire shape (repeatable ``d`` parameters, the 64-id cap,
absent-vs-error tile semantics, no ``shape`` field), the uid parse
rules, and the status codes the README table promises. The preparation
channel's POST idempotency, the ``.hic`` 501, the md5-less 409, and the
never-dispatching status probe are pinned alongside. The shared
``resolve_file_doc`` preamble gets its own suite so the four routers
that consume it agree on ordering and error codes by construction.
The processor suite runs real coolers through the artifact build -- the
mcool byte-copy path, the flat-cool zoomify path, and the source-size
admission guard. The lifespan suite pins when the service exists at all:
wired only when the workflow subsystem is enabled and the build carries
clodius, and closed -- not merely nulled -- during teardown, so an open
h5py handle or a live thread pool cannot leak into the next app
instantiation.
ApiEphemeralStorageGiB and TilesetDiskCacheBytes exist, carry the
documented defaults and bounds, and are actually wired through to the
task definition -- a parameter that renders but reaches no container
would pass every deploy while changing nothing.
The whole chain against bytes rather than mocks: build a contact map,
put it through the matrix processor into a real cache, then serve
/tileset_info and /tiles from the resulting artifact and compare tile
payloads against a direct cooler read.
The README section carries the parts a consumer cannot recover from the
code: why the endpoints exist at all (Gosling's MatrixData compiles to
HiGlass's native heatmap track, which cannot poll a job id), the
two-channel contract and its status tables, the wire-compatibility
findings against higlass.io and a local higlass-server (cfdb sends the
full symmetric matrix where higlass-server sends half; verified
invisible to the client, and why ``mirror_tiles`` must never be
emitted), the local-dev flow, and the limitations -- the private-fork
501, the deferred ``.hic`` corpus with its partial-coverage cost table,
and the per-task locality of the tileset copies.

CLAUDE.md points agents at the README rather than duplicating it.
@conradbzura conradbzura self-assigned this Sep 9, 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.

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

1 participant