Repository navigation
Accept a file-like source in the bbi tilesets — Closes #15 - #19
Merged
Merged
Conversation
conradbzura
force-pushed
the
15-file-like-source-bbi
branch
2 times, most recently
from
October 6, 2026 16:33
7f036dd to
154d85d
Compare
H5Backed held the lazy-open, own-what-you-opened, release-on-failure protocol for the two HDF5 tilesets. BBITileset holds a second hand-written copy of the same protocol for pybigtools, and the BBI source slice is about to need every fix that went into the first one. Two copies is what made the duplication a blocking review finding, so the protocol moves to FileBacked and the readers keep only what differs: which library call opens the target, and whether a given source can be handed over as a path at all. Generic in the reader type, so H5Backed.file keeps its exact h5py.File annotation rather than widening to object. The close path now closes the reader unconditionally, which is what both h5py and pybigtools need and what the BBI copy never did. No behavior change for the HDF5 tilesets: their suites pass unmodified.
The five BBI tilesets now take a path or a zero-argument callable that opens a fresh binary handle, so a server can serve tiles from remote storage without first writing the file to local disk. This is the second of four backend slices; the Source foundation landed with the HDF5 one. The extension dispatch stays. A path ending in one of the four suffixes pybigtools recognizes still reaches pybigtools.open as the path string, which is the faster route and the call it always was. Every other source takes the handle arm, which already existed for a bigInteract but which no caller could reach, because the handle was always opened here. Two defects go with the adoption. Construction read the chromsizes out of the file after opening it and let a failure there raise without releasing what it had opened, so the caller never received an object to close; and close dropped the pybigtools reader for the garbage collector instead of closing it, so a path-backed tileset released its file whenever the collector got to it. Both are fixed by inheriting the shared protocol, which also brings the lock the lazy open lacked. BREAKING CHANGE: the first parameter of every BBI tileset is renamed from path to source and annotated SourceLike, matching CoolerTileset and MultivecTileset. It accepts a path or a factory, so path named something it no longer is. Every construction in the tree passes it positionally, so nothing moves unless a caller used the keyword.
pybigtools.open dispatches on the file extension and accepts exactly six spellings: .bw, .bigwig, .bigWig, .bb, .bigbed and .bigBed. The suffix check case-folded before comparing, so it claimed every case variant was dispatchable and handed the path string over for names like signal.BW, which pybigtools then refused outright as an invalid file type. The handle route opens every one of those files, so the fast path was turning an openable file into an unopenable tileset rather than merely a slower one. Matching the exact spellings makes an unrecognized suffix cost one extra open instead of the whole tileset. Pre-existing, and carried into the shared protocol unchanged by the file-like source work; it had never been covered by a test.
The widened first parameter was documented only on BBITileset. A subclass that defines any docstring stops inheriting the base's, so help(BBISignalTileset), help(BBIAnnotationTileset) and help(BBIInteraction2DTileset) each showed a first parameter with nothing saying a zero-argument callable was accepted -- which is the entire public contract the file-like source feature ships. BBIInteractionLinksTileset hid it twice over: it swept source into *args to make room for its own link_policy keyword, so the parameter was invisible to inspect.signature and to help as well as to the docstring. It is named explicitly now and forwarded. No behavior change; every call site passes the argument positionally.
The bbi module goes from 32 test cases to 107. The new coverage is organized by what it pins rather than by class, because the five tilesets share one implementation of all of it: Equivalence -- tile-for-tile equality between a path-constructed and a factory-constructed tileset across the whole resolution ladder, for all four concrete types and all seven signal display modes. Dense payloads are compared through the decoded array so that NaN in the same position counts as equal, and every case asserts some tile came back populated so a build serving nothing cannot pass vacuously. Contract -- every shape SourceLike admits, the refusal of an already open file, and the refusals of a text or unseekable handle, each across all four types. Dispatch -- which argument pybigtools is handed. This is the dimension no equality assertion can see: both routes serve byte-identical tiles, so the argument type is the only observable difference, and it is what the equivalent h5py defect hid behind. Lifecycle -- that close releases both the reader and the handle, that it is idempotent, that a closed tileset reopens, and that a constructor failing after its open does not orphan what it opened. Concurrency -- that the lazy open calls a caller's factory exactly once under threads released together, that payloads are identical across concurrent readers, that descriptors return to baseline after close, and that close does not deadlock against a thread serving tiles. Run against the pre-change implementation, 54 of the 107 cases fail. The other 53 are the 32 that existed plus 21 regression guards, which is the honest split: both arms of the extension dispatch were already correct, so those tests guard rather than catch.
The open-and-release protocol is pinned once per backend, through a real h5py or pybigtools reader. That leaves the arms that only fire when cleanup itself fails untested, because neither reader can be asked to fail that way on demand -- and every one of those arms is an `except Exception: pass`, so when one is wrong it is wrong silently and the error it swallows is the one the caller needed. Four of them, reached through construction rather than directly: a header read that fails over a reader whose close also fails, where the construction error must survive rather than being replaced by the teardown one; the same precedence on the validation path; a release on the success path that must not be able to fail a constructor that otherwise worked; and an override of `_needs_handle` that narrows the one contract the base says it must not, which the base refuses at the seam rather than letting reach the reader as a complaint about a path the caller never passed. A double rather than a real backend, for the reason the arms were uncovered to begin with.
conradbzura
force-pushed
the
15-file-like-source-bbi
branch
from
October 8, 2026 19:38
154d85d to
31c66f3
Compare
conradbzura
marked this pull request as ready for review
October 9, 2026 14:06
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.
Description
A source is one of two things: a filesystem path, or a factory — a zero-argument callable returning a freshly opened, seekable binary handle.
fsspec,smart_openand a plainlambda: open(p, "rb")all produce factories. Adopt that abstraction in the five BBI tilesets, so a bigWig, bigBed or bigInteract can be served from remote storage without first materializing the file on local disk. This is the second of four backend slices under #13; theSourcefoundation and the HDF5 adoption landed in #18.A factory rather than an open handle, because the two readers disagree about what they want and only the factory satisfies both. oxbow may reopen its source between queries, so it needs a callable;
h5pyandpybigtoolsneed a live object, which a factory yields by being called once. A handle cannot be un-consumed, and the repair a caller reaches for —lambda: handle— is a factory that lies, returning the same exhausted handle on every call.Source.coercetherefore refuses a handle at construction, with a message naming the fix.Extension dispatch is preserved for path-backed sources, and the asymmetry is worth stating precisely:
pybigtools.open(<path>)selects a parser from the suffix whilepybigtools.open(<handle>)sniffs the bytes, so failing to recognize a suffix costs a different open, and recognizing one commits to the flavour it names. Handing over the path string is the callpybigtoolswould have received had this module never existed, and the six spellings it accepts take that route. Every other source is handed a live handle — the branch that already existed for a bigInteract and that no caller could reach, because the handle was always opened internally.The diff reaches past
bbi.pyfor one reason.BBITilesetheld a second hand-written copy of the open-and-release protocolH5Backedholds for the HDF5 tilesets, and every defect #18's review found in that protocol lived in one copy or the other. The protocol moves toFileBackedinclodius/tiles_v2/_backed.py, and each backend keeps only what differs: which library call opens the target, whether a given source can be handed over as a path, and what makes an opened file the wrong format.Closes #15
Proposed changes
clodius/tiles_v2/_backed.py— the shared protocolFileBacked[_R]is generic in the reader a subclass opens, so each backend keeps a precise return type. It owns four things a backend used to own twice.The lazy open. Constructing a tileset over an unreachable source is not an error; the reader opens on first use. The guard is double-checked under a lock, because concurrent first access without one calls a caller's factory N times and leaves N−1 handles
closecan never reach, and a caller's handle has no finalizer to fall back on. The reader and its handle are published in a single statement, soclosecannot observe one without the other.The ownership rule. clodius closes what clodius opened and nothing else. A path a reader accepts is handed over as the path string, so only a source the reader cannot take as a path produces a handle, and that handle is the tileset's to close. Neither
h5py.File.close()norBBIReader.close()closes a Python file object it was handed, so releasing a tileset means closing both, reader first, because the reader reads from its handle lazily.Construction. A subclass does its header read inside
_configuring(), a context manager entered aftersuper().__init__(source). Both exits release. On a raise that is cleanup, widened toBaseExceptionso a cancellation landing between the open and the end of construction does not orphan what was opened; a failingcloseduring teardown is suppressed rather than allowed to replace the construction error, since a server renders aTilesetUnavailableas a refusal and anOSErroras a 500. On a normal return the release is the registration is free property: a server registers tilesets it may never serve, and for a factory source each one held open is a live remote connection. Measured at 0 descriptors across 50 factory-backed registrations, against 50 before. Two limits bound the claim — a caller who supplies every header-derived parameter skips the only read, which moves the format refusal to the first request; and nothing reaps a reader once it has been used, so steady-state descriptor count equals the number of served tilesets rather than zero, measured at 300 held by 300 served tilesets.Release.
closedetaches the reader and the handle under the lock and closes them outside it, because for a factory source the handle is the caller's remote connection and its teardown is a round trip — holding the lock across it stalls every concurrent reopen on a tileset that is already detached, measured at a 355 ms wait behind a handle whoseclosetakes 400 ms. A reader whose owncloseraises is retained in a quarantine slot rather than dropped, so a laterclosereclaims it; it is not returned to_reader, because the lazy guard reads that field without the lock and would republish a reader over a handle the same call just closed.clodius/tiles_v2/bbi.py— the adoption, and four defectsBBITilesetderives fromFileBacked. Its own reader accessor and release path are deleted, and it implements three hooks:_needs_handle(false for a path whose suffixpybigtoolsdispatches on),_reader_open, and_validate.The suffix check was case-folding.
pybigtools.open(path)accepts exactly six spellings, case-sensitively, sosignal.BWfolded to a match, took the path branch, and was refused outright as an invalid file type — while the handle route opens every one of those files. The fast path turned an openable file into an unopenable tileset. The check now compares exact spellings.close()never closed the reader. It dropped the reference and left thepybigtools.BBIReaderto the garbage collector, so a path-backed tileset released its file whenever the collector next ran rather than when it was told to.A factory-backed tileset could not serve two tiles at once.
pybigtoolsholds a borrow on its reader for the duration of a query, so two threads driving one handle-backed reader raiseRuntimeError: Already borrowed— andRuntimeErroris not aTileError, so it escaped the per-tile boundary and failed the whole batch as a 500. Measured before the fix: 200 of 400 requests served, 4 of 8 threads dead. Serialization lives inreading(), a context manager that is also the seam a caller reaching pasttiles()should use, since the reader it yields carries a constraint the tileset owns. It is entered per reader call rather than per batch: the borrow lasts one query and a batch is mostly work that never touches the reader, so a batch-wide lock put every concurrent request behind all of it — a trivial concurrent tile went from 833 ms at p50 to 7.5 ms. The lock is reentrant, becausereading()is published and a caller may nest it.close()interrupted the read it raced. Becauseclosedeliberately does not hold the read lock, it could callBBIReader.close()while a borrow was held, which raises.BBITileset.closenow acquires the read lock first. Measured on a factory-backed tileset costing 10 ms a read, closing 50 ms into a request, 5 of 5 trials: before,closeraised and the request died withread of closed file; after,closereturns and the request is served, for a wait of 37–46 ms. Under page-cached load across 8 threads the wait moved from a median of 0.11 ms to 0.15 ms.The contract
reading()publishes, and its one preconditionTwo statements in this area are load-bearing and both are measured rather than argued.
Every read a
reading()block starts must also finish inside it. The borrow is released when the query returns, which is what makes one call the right scope — butrecords()hands back a lazyBigBedEntriesIterator, and advancing one is a read. The eagerness of the two read helpers is therefore load-bearing:fetchwrites into a preallocated array andfetch_recordsmaterializes its list before the block ends. Measured on a handle-backed reader, five threads by twenty scans, taking the iterator inside the block and draining it after: 76 of 100 queries died withpyo3_runtime.PanicException ... BadData, and all 24 that returned returned truncated lists — 72, 172, 196 and 372 records against a truth of 900, which raises nothing and reaches a client as a well-formed wrong tile. Draining inside the block: 0 of 100, every count exact. Streaming records rather than accumulating them is the obvious next optimization inBBIAnnotationTileset._tile, and it is the one change this contract forbids.A path-backed reader is exempt from the lock, on a precondition of the process rather than a property of the library. The borrow is on the
BBIReaderobject — a PyO3RefCell— and not on the Python handle, so a borrow exists to contend on either route: the same eight-thread workload against a path-backed reader in an interpreter wherenumpyhas not been imported refuses 2800 of 3200 queries. Withnumpyimported, a thread asking to wake 90 ms into a 165 ms path-backedvalues()regains the interpreter only as that query returns, and the same workload refuses 0 of 3200.bbi.pyimportsnumpyat module scope, so the exemption holds here; nothing enforces it, and the dependency is recorded at the import it rests on. No mechanism linking the two measurements is asserted.Published surface
BBI*Tileset(path=…)→BBI*Tileset(source=…), annotatedSourceLikepathnamed something it no longer is. This reverses an instruction in the issue, for the reason #18's review reversed the identical instruction in #14. Every construction in the tree passes it positionallyBBITileset.fileremoved;reading()is the only route to a readerpybigtools.BBIReaderas often as anh5py.File, sofilemisdescribed it half the timeCoolerTileset.reader→block_reader_cls, typedtype[BlockReader]batched, not a reader. A substitute subclasses the publicBlockReader, which the docstring now statesBBIInteractionLinksTileset.link_policyis a read-only propertyLinkPolicycoercion, after which the tile loop's catch-all arm served every interaction crossing the tile under the id of whatever was asked for, raising nothingclodius.core.BinaryHandleadded;Source.openreturns itread,seek,tell,close— exactly what the handle check enforces. AnnotatedIO[bytes], the method promised two dozen members a conforming handle is never asked for and a minimal one does not have, sowith source.open() as h:type-checked and raisedSource.for_readerremovedSource.pathinsteadHonest scope
A factory-backed tileset's reads do not scale with a worker pool. One tileset holds one reader and
reading()serializes every read through it, measured flat from 1 to 8 threads at 177–213 queries/s and 632–648 tiles/s, with 4.2–7.8× recoverable by giving each thread its own reader. The lock is the correct minimal fix — removing it refuses tiles rather than slowing them — and a bounded reader pool is filed against #13. Path-backed tilesets are unaffected.Three functions on
Source—optional,open_textandsize— are public, exported, and have no caller inclodius. They belong to the foundation slice and are left to it.Four review rounds and one blind verification pass are recorded in
.sdlc/reviews/issue-#15/. Incidental findings are carried there as deferrals rather than addressed here, since none pertains to this issue.Test cases
test_bbitest_bbitest_bbitest_bbistr,bytes,os.PathLikeandSourcesource, for all four tilesetsstrbaselineSourceLikeadmitstest_bbiTypeErrornaming the callable that would worktest_bbiTypeErrortest_bbipybigtoolsis reachedtest_bbichromsizessupplied so construction defers the opentest_bbisourcekeywordtest_bbilink_policytest_bbi"both"link_policyis readLinkPolicy.BOTHtest_bbilink_policyAttributeErrortest_bbi.bw,.bb,.bigWigand.BIGBEDshould route by their exact spelling and.BWand.bigInteractby handletest_bbitest_bbitest_bbitest_bbitest_bbitest_bbitest_bbipybigtoolsdoes not dispatch on, so the path still produces a handletest_bbitest_bbitest_bbiwithtest_bbitest_bbitest_bbitest_bbitest_bbireading()blocktest_bbitest_bbicloseshould return, every thread finish, and the tileset remain closeable and reopenablecloseunder loadtest_bbicloseused to raise, deterministicallytest_bbitest_bbireading()are countedtest_bbireading()twicetest_bbitest_bbiRuntimeErrortest_bbi.bb, opened by path and by factorytest_bbitest_backedclosealso failstest_backedclosefailstest_backedcloseis made to failtest_backed_needs_handlereturns false for itTypeErrornaming the override