Repository navigation
Add HiGlass-compatible tileset endpoints for Hi-C contact maps — Closes #82 - #118
Merged
Merged
Conversation
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
force-pushed
the
82-higlass-tileset-endpoints
branch
from
September 28, 2026 20:28
982bd12 to
66d74e6
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Add a pure-reader pair of HiGlass-compatible tile endpoints —
GET /tileset_info/?d=<uid>andGET /tiles/?d=<uid>.<z>.<x>.<y>— plus a separatePOST /tilesets/{dcc}/{local_id}preparation channel that builds the tileset artifact those readers serve from. This lets Gosling'sMatrixData(which compiles to HiGlass's nativeheatmaptrack, notgosling-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 existing202-plus-poll pattern used by/dataand/index.Tile reading is backed by a private fork of
clodius(abdenlab/clodius,tiles_v2), pinned to a commit sha on itsmainbranch rather than a floating reference, sincetiles_v2is 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 answer501on the tile routes rather than failing to boot.Deviation from the issue's open design decision: the issue proposed reading ENCODE
.hicin place viahicstrawand HTTP range requests. In practice, clodius's.hictileset is built onhictkpy, whose entry points take a local path only — there is no URL, HTTP, or S3 surface in the library. Serving.hicwould 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..hicis recognized and refused with a specific501rather than served; only 4DN.mcool(773 files) and.cool(10 files, coarsened viacooler zoomifyfirst) 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:
502before ever reaching the asset host.file_in_collectionjunction table, which HuBMAP's C2M2 export never populates — the materializer'spersistent_idfallback join was invisible to it, so files from non-public and dbGaP-controlled-access datasets were leaking into the API stampeddata_access_level: public.And one fix to local dev bootstrap: a clean checkout's
database/directory is gitignored, so the MongoDB image'sCOPY database/failed the build outright on a fresh clone (it only appeared to work when a stray file like.DS_Storekept the directory alive). The dump is now mounted read-only at run time instead, and a relatedgetIndexesNamespaceNotFoundcrash 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 thetilesextra degrades to a clear501instead of anImportErrorat startup. Classifies clodius's whole deliberate-error hierarchy (TilesetError, not just the narrowerTileError) 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 asmcool,cool, orhicfrom its filename andfile_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
ArtifactKind.TILESETalongsidedata/index, sharing the existing content-addressed cache-key derivation (upstream md5, processor version).MatrixTilesetProcessorfetches an.mcoolbyte-identical into the cache, or coarsens a flat.coolwithcooler zoomifyfirst (clodius refuses a cooler with noresolutionsgroup). EnforcesCFDB_TILESET_MAX_SOURCE_BYTESbefore a byte is downloaded, including sources whose size is reported as a string.HTTP endpoints (
src/cfdb/api/routers/)tiles.py—/tileset_infoand/tiles, read-only, capped at 64 repeatabledparameters 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/statusprobe, idempotent (an already-built artifact answers200 {"ready": true}rather than queuing a no-op job)._helpers.py/data.py/index.py— Extract the DCC-normalize/lookup/access-control preamble that/dataand/indexalready shared, so the two new routers reuse it instead of a third and fourth restatement.Local dev and infra
Dockerfile.api-tilesand newmake api-tiles/make worker-tilestargets layer clodius onto the API image for local testing, sincemake api's image intentionally carries no clodius while the extra stays optional.cloudformation/backend.ymladdsApiEphemeralStorageGiB/TilesetDiskCacheBytesparameters, 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
higlass.ioandserver.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
TestLoadBackendload_backendis calledcooler_tilesetis clodius'sCoolerTilesetand whose error attributes are the split clodius exception typesTestIsTileErrorMalformedTileIdinstanceis_tile_erroris calledTrue, even thoughMalformedTileIdis no longer a subclass of the narrowerTileErrorTestMatrixSourceKindmatrix_source_kindclassifies itMatrixSource, including case-insensitivelyTestMatrixSourceKind.h5ad/bare.h5extensionmatrix_source_kindclassifies itNone— the HDF5 ontology collision is escaped by filenameTestResolution(service)tileset_info/tilesis calledTilesetNotReadywithout dispatching anythingTestTiles(service)tilesis called{"error": ...}objectTestTiles(service)tilesis calledTestOpenTilesetLifetimecheckoutTestHydration(store)TestEviction(store)TestTilesetInfoPayload(wire)genome_assemblytileset_info_payloadbuilds the responsecoordSystemis omitted entirely rather than emitted as an empty stringTestSourceSizeGuard(matrix processor)CFDB_TILESET_MAX_SOURCE_BYTES, including a string-typed sizeTestStatusProbe/TestPrepare(router)501naming the tile backend and dispatches nothingTestPrepare(router)prepare_tilesetis called429carrying the executor's ownRetry-After/data//indexTestTilesOverHttp(e2e)/tilesis requested over HTTPcoolerreadTestEvictionUnderLiveRead(e2e)TestS3ProfileTileChainS3CacheTestLifespanProfileMatrixtest_physical_names_should_be_environment_scoped(cloudformation)TestPruneNonPublicHubmapRawRecordspersistent_id, with nofile_in_collectionentry at all_prune_non_public_hubmap_raw_recordsis calledpersistent_idfallback match, even though thefile_in_collection-based path finds nothingTestMongoCleanCheckoutBootdatabase/dump, built and run through real dockerTestIndexSpecjobscollection's index script running against a namespace that has never been written tocreateIndexrunsNamespaceNotFoundis swallowed and the index is created rather than crashing the container underset -etilesextra (and therefore clodius/numpy) is not installed, matching Python 3.11 CIpytest.importorskiprather than failing collection or asserting against a backend that cannot exist