Skip to content

feat!: store the whole figpack bundle as the object - #8

Open
MilagrosMarin wants to merge 3 commits into
mainfrom
feat/store-the-whole-bundle
Open

MilagrosMarin wants to merge 3 commits into
mainfrom
feat/store-the-whole-bundle

Conversation

@MilagrosMarin

Copy link
Copy Markdown
Contributor

Closes #7. Also closes the design half of #4 and closes #3.

A figpack figure is the folder. Storing only data.zarr and reassembling a viewer at render time made the stored object pure Zarr with a figpack marker on it, and cost three things:

  • the serving container needed a compatible figpack installed
  • extension views had nowhere to keep their JavaScript, so validate() refused them at insert
  • serve_under() had to assume a layout figpack does not promise — a custom view may name its Zarr folder differently, or carry several

Settled with Jeremy Magland (Flatiron Institute) on 2026-09-04.

Changes

encode() uploads the bundle value.save() produced, not a subtree of it
validate() no longer rejects ExtensionView — extension JavaScript travels with the figure
serve_under() downloads and publishes the stored bundle. No viewer overlay, no import figpack, no reach into the private figpack-figure-dist path, no synthesized extension manifest
show() serves the bundle over HTTP on an ephemeral port and returns the URL
load() removed

serve_under()'s layout guard moves from data.zarr/.zmetadata to index.html, which is what a servable bundle must have at its root. The concurrency race, orphan sweep, idempotence and TTL touch all still hold — they now apply to a downloaded bundle rather than an assembled one.

Two decisions worth reviewing

load() is removed rather than left raising. #7 asked for a decision. Its contract was to reconstruct a FigpackView from stored data, and that no longer describes what is stored. is_loaded went with it: nothing caches a view any more, so it would have reported "not loaded" forever and __repr__ would have said so.

show() serves over HTTP. A figpack viewer fetches Zarr by range request, so opening index.html from the filesystem cannot work. The server runs in a daemon thread on an ephemeral port and lives as long as the process — nothing to clean up, and concurrent shows cannot collide.

Storage cost, deliberately accepted

About 2.2 MB per figure for the viewer dist, essentially one JS bundle. Jeremy's read, which #7 records: at realistic figure sizes the data dwarfs the viewer, and separating them only pays for roughly 1 MB figures in the millions. Content-addressed sharing stays an optimization layer to add later — never a change to what the object is.

Tests

Written first, against the old behavior, all five failing before the implementation:

  • encode() stores the whole bundle
  • validate() accepts an ExtensionView
  • extension JavaScript survives the round trip — a sentinel is found in the stored bundle
  • serve_under() serves the stored bundle verbatim — the stored index.html is marked, and the mark survives, proving no dist overlay
  • serve_under() imports no figpack__import__ is patched to raise on it

Plus show() over HTTP, and that load() is gone. Suite 30 → 35, with the two obsolete rejection tests removed and six existing tests' premises inverted rather than deleted.

Mutation-checked: reverting encode() to store only data.zarr breaks 12 tests; reverting the download to nest into data.zarr breaks 9.

Net −184/+109 — most of this is deletion.

Breaking, and what follows

Figures written by earlier versions store bare Zarr and will not serve: serve_under() fails loud with a message naming #7 rather than publishing a bundle with no entry point. Only demo pipelines hold figpack data, so reseed rather than convert.

Draft until the sequence after it is agreed: release 0.3.0 → bump the dashboard pin → reseed the dj-inc_rna-sequencing demo → then the blog's storage prose and the platform captures, which must come from the reseeded demo.

Not in this PR, per #7: the <htmlbundle@store> generalization gets its own issue after this lands.

A figpack figure is the folder. Storing only data.zarr and reassembling a viewer
at render time made the stored object pure Zarr with a figpack marker on it, and
cost three things: the serving container needed a compatible figpack installed,
extension views had nowhere to keep their JavaScript and were refused at insert,
and serve_under() had to assume a layout figpack does not promise — a custom view
may name its Zarr folder differently or carry several.

Settled with Jeremy Magland (Flatiron Institute) on 2026-09-04. Closes #7, the
design half of #4, and #3.

- encode() uploads the bundle value.save() produced, not a subtree of it.
- validate() no longer rejects ExtensionView; extension JavaScript now travels
  with the figure. A round-trip test proves the JS survives.
- serve_under() downloads and publishes the stored bundle. No viewer overlay, no
  import figpack, no reach into the private figpack-figure-dist path, no
  synthesized extension manifest. Its guard moves from data.zarr/.zmetadata to
  index.html, which is what a servable bundle must have at its root.
- show() serves the bundle over HTTP on an ephemeral port and returns the URL. A
  figpack viewer fetches Zarr chunks by range request, so file:// cannot work.
- load() is removed. Its contract was to reconstruct a FigpackView from stored
  data, which no longer describes what is stored; is_loaded went with it, since
  nothing caches a view any more.

Storage grows by the viewer dist, about 2.2 MB per figure. Deliberately accepted:
at realistic figure sizes the data dwarfs it, and content-addressed sharing is an
optimization layer to add later if a pipeline shows the small-figure, high-count
profile — never a change to what the object is.

Breaking: figures written by earlier versions store bare Zarr and will not serve.
Only demo pipelines hold figpack data; reseed rather than convert.
@MilagrosMarin
MilagrosMarin marked this pull request as ready for review September 16, 2026 12:24
show() served the bundle with Python's stock SimpleHTTPRequestHandler, which has
no Range support. figpack packs many small chunks into large consolidated files
and the viewer ranges into them, so a browser would have pulled whole files —
the difference between a few kilobytes and a gigabyte. figpack's own server
subclasses the stock handler to add exactly this; adding it here rather than
importing theirs keeps the dependency out of the render path.

The first version passed its test only because the sample figure is tiny and
fetching index.html needs no range. The new test asks for a prefix range, a
suffix range, and one past the end, and asserts 206 / Content-Range / 416.

show()'s temp directory is now removed at exit rather than left behind.

The dashboard path was never affected: dash-datajoint-components serves through
Flask's send_from_directory, and Werkzeug handles Range.
Module, class and encode docstrings still said the codec stores Zarr folders,
which described the old design. Also drops the FigpackView type import that
removing load() orphaned.
@MilagrosMarin

Copy link
Copy Markdown
Contributor Author

Two more commits since the draft went up, from a second review pass.

show() had a real defect. It served the bundle with Python's stock SimpleHTTPRequestHandler, which has no Range support — and figpack packs many small chunks into large consolidated files that the viewer ranges into, so a browser would have pulled whole files. figpack's own _server_manager.CORSRequestHandler subclasses the stock handler to add exactly this; I added range handling here instead of importing theirs, which would have put the dependency back in the render path. The first test passed only because the sample figure is tiny and index.html needs no range — the new one asks for a prefix range, a suffix range, and one past the end, asserting 206 / Content-Range / 416. The dashboard path was never affected: dash-datajoint-components serves through Flask's send_from_directory, and Werkzeug handles Range.

Also: show() no longer leaks its temp directory, the FigpackView type import that removing load() orphaned is gone, and the module/class/encode docstrings no longer say the codec stores "Zarr folders".

Five decisions I would rather you made than I did.

1. Version number. Breaking change on a 0.x line — 0.3.0, or is this the moment for 1.0.0 now that the stored object's shape is settled?

2. Changelog. This repo has none, and the version is tag-derived, so nothing in-tree records that the release breaks stored objects. Both consumers pin by git tag — the dashboard at @0.2.1, the pipeline at @0.2.0 — so whoever bumps a pin gets no signal. datajoint-python archived its CHANGELOG.md and moved to release-drafter generating GitHub Releases from PR labels; this repo has no drafter config. Add one here, or is the PR history the record?

3. PyPI. 0.2.0 was cut as a GitHub Release and its Publish to PyPI job failed — the trusted publisher was never configured, and the package 404s on PyPI today. That is why I tagged 0.2.1 without a Release. Cutting 0.3.0 as a Release repeats that failure unless someone with PyPI access sets up the pending publisher. Does 0.3.0 go to PyPI, or stay tag-only?

4. CI. Nothing runs the 36 tests. cd.yml only builds the distribution and publishes on release. For a breaking change whose only guarantee is the suite, that seems worth closing — happy to add a test workflow here or in a follow-up, whichever you prefer.

5. The .zarr extension. _build_path is still called with ext=".zarr", so a bundle containing index.html and data.zarr/ lives in a folder named fig_XXXX.zarr. That is now misleading. It changes stored path shape, so it is your call — but the demo is being reseeded anyway, which makes this the cheapest moment it will ever be to change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant