Key the zone-map sidecar by the column's offset in its file
The zone-map sidecar for a disk-backed column is named after the file alone (<file>.xyzones), and its header only checks the file's size and mtime plus the row count. Two columns that map the same file with the same length therefore share one sidecar, and whichever was folded last is loaded for both on the next build.
That happens with the ordinary ways of keeping a table on disk: the rows of a 2-D np.memmap, or several np.memmap(offset=...) columns packed into one file.
Repro (main @ 72a069f)
import numpy as np, xy
n = 1000
table = np.memmap("table.f64", dtype=np.float64, mode="w+", shape=(2, n))
table[0] = np.linspace(0, 1, n) # x
table[1] = np.linspace(1000, 2000, n) # y
table.flush(); del table
for build in range(2):
mapped = np.memmap("table.f64", dtype=np.float64, mode="r", shape=(2, n))
spec, _ = xy.scatter_chart(xy.scatter(x=mapped[0], y=mapped[1])).figure().build_payload()
print(build, spec["x_axis"]["range"], spec["y_axis"]["range"])
0 [-0.03, 1.03] [970.0, 2030.0]
1 [970.0, 2030.0] [970.0, 2030.0] # x axis now spans y's values; every point is off-screen
The first build is correct and writes the sidecar. Every later build (the next viewer restart, or a second figure in the same process) gives x the y column's min/max, so the chart comes up empty. With offset= columns the second column is wrong on its very first read, because the first column's sidecar is already there.
Fix
_ooc.backing_offset(arr)returns the byte offset of a column's first element in its file. It is measured from the root memmap, because a sliced memmap inherits its parent's.offset._zone_cache_pathnames every sidecar<file>.<offset>.xyzones. The offset is always written,.0included, so a file/offset pair can't collide with a file whose name ends in.<digits>. A memmap-typed view with no mapping behind it counts as not disk-backed.- The sidecar magic goes from
XYZONEC1toXYZONEC2, so a v1 sidecar that may already hold another column's maps is refolded once instead of trusted.
In-RAM columns are not affected. Existing sidecars are refolded once because the name and magic changed.
Tests
tests/test_ooc.py::test_zone_map_cache_keeps_views_of_one_file_apartcovers the 2-D table case above throughxy.chart(xy.scatter(...)), with a cold build and then a warm build.tests/test_ooc.py::test_zone_map_cache_keys_on_file_offsetcovers twonp.memmap(offset=...)columns in one file, plus a file named<file>.<offset>.tests/test_ooc.py::test_backing_offset_without_file_metadata_is_not_disk_backedwas added after review.
Both fail on main (assert -5.0 == 7.0; the warm build returns x as (1000.0, 2000.0)) and pass with the fix. The full suite passes on this branch (4096 passed, 113 skipped; main is 4094 passed, 113 skipped). ruff check, ruff format --check, and ty check python report no new diagnostics compared with main.
Checklist
- News fragment:
news/524.bugfix.md - Spec: the sidecar note in
spec/process/perf-audit-2026-07-22.mdnow says how the sidecar is named per column - No public API, payload, or import-boundary change
Summary by CodeRabbit
- Bug Fixes
- Charts built from multiple disk-backed columns in the same file now retain the correct axis ranges instead of reusing another column’s cached statistics. Each column’s cached statistics remain separate, including when columns map different regions of the same file, so charts show the appropriate ranges after being built again.
- After a replacement cache is written, obsolete cache files are removed when possible. Cleanup failures do not interrupt processing.