Sharded vlen-bytes layout for ragged t-digest output (issue 209)#211
Sharded vlen-bytes layout for ragged t-digest output (issue 209)#211espg wants to merge 6 commits into
Conversation
espg
left a comment
There was a problem hiding this comment.
🤖 from Claude (review)
Fresh-context adversarial review of phase 1 (477225f), traced against the issue 209 decision record and the deep-dive layout argument, plus a local run of the new gates (TestRaggedVlenLayout + test_hive.py: 54 passed) and empirical checks in the repo venv.
No blocking-correctness findings
The data-loss surfaces I went after hardest all check out:
- Cell alignment: dense columns land via
values.reshape(inner_shape)intoregion; ragged payloads land via row-majornp.unravel_index(cid, grid.chunk_shape)into the sameregionview — consistent by construction for HEALPix (1-D) and rectilinear (2-D), on all three paths (sharded slab pass, per-chunk unsharded, hive leaf). The ragged array's shard geometry matches the dense arrays' exactly:cells_per_shard_objecton HEALPix (issue #133 object split included),(chunk_h, chunk_w)on rectilinear, whole leaf inshard_spec. - Empty vs n=0: both collapse to the
b""fill, identically on flat and hive (write_csrskipped empties too, so this is parity, not a change), andtest_one_object_per_shard_and_bitexact_roundtrippins it —payloadsexcludes the n=0 entries and the test assertsset(seen) == set(payloads), so a regression that started storing (or dropping) either side would fail. - Golden wire pin is the real thing:
test_golden_inner_chunk_framingparses the shard index footer manually (offset/nbytes pairs + sentinel), zstd-decodes the located inner chunk, and compares against a hand-builtstruct.packvector — no re-encoding. u64 lengths, an omitted count, or a framing reorder would all fail it. This is exactly the pin the issue-210 typed-dtype migration needs. - Deletions are clean: no stragglers for
_write_ragged_fanout/_block_index_key/ the #142 pool constants;fd_safe_max_workerssurvives only in its legitimate dispatcher uses; no dead imports in runner/handler (shard_labelin runner.py is still used for status labels). The #186 failure roll-call was ragged-fanout-only — dense writes raise synchronously fromset_block_selectionand never depended on it. (The #186 S3 retry pacing instore.pyis a separate, untouched aspect of that issue.) - Hive: the dense→ragged→sidecar→stamp ordering pin survives and now covers
write_ragged_leaf_to_zarr(test_stamp_is_the_final_write), and the torn-write test pins that a torn worker leaves no partial ragged data. - Warning scope, verified empirically (zarr 3.2.1 in the repo venv): the suppression regex matches the actual #3517 message; creation without the context manager emits exactly 1
UnstableSpecificationWarning;write_shard_to_zarr+open_array+ read emit zero — so leaving_set_ragged_block/readers unsuppressed is correct, and nothing else is swallowed. - Conventions:
lambda_handler.pyedit is app-source with PR precedent and is net −13 as claimed; no new deps, no CI/workflow/infra files touched;write.pyat 830 lines is under the §4 ceiling; CSR module + reader deliberately kept green for phase 2, as documented.
Findings (inline)
- should-fix — hive.py: the "small" memory claim for leaf ragged accumulation is unquantified; it's ~140 MB payload / ~200 MB peak at the o8 t-digest scale this fix exists to unlock, on the path built to be O(chunk) (#91/#143/#148). Accumulate-once is the right design; the bound should be stated and sanity-checked against the worker cap.
- should-fix — grids/base.py: no collision guard for the derived
{field}_locationsname; a colliding user field corrupts data silently in_accumulate_ragged_slabs. Includes my verdict on PR question 2 below. - should-fix — grids/healpix.py: the located channel is never tested through the sharded template/slab pass.
- nit — tests: the 2-GET assertion accepts a ranged GET spanning the whole object; assert the spans are small relative to the object.
Verdicts on the PR questions
- Unsharded flat K>1 — keep per-inner-chunk objects. The stream-and-free write (#91) is the point of that path, and collapsing to one object would force either whole-shard accumulation (recreating the hive memory question on a path that exists precisely to avoid it) or K read-modify-writes of a shared object (the anti-pattern this PR deletes). Crucially it is not a reader fork: the layout difference is fully self-describing in array metadata (regular chunk grid vs
sharding_indexed), so the phase-2 reader uses the same zarr API on both. Since that variant exists only for the benchmark A/B, the inconsistency is contained; just say so in the phase-3 docs. Don't fold it into phase 2. {field}_locations— keep the name, add the guard. A subgroup is impossible (array nodes can't have children — the same constraint that killed the CSR fourth array), and a reserved prefix buys nothing over a sibling. What's needed is avalidate_configrejection of colliding field names plus an attrs-declared linkage so the reader binds by metadata, not convention (detail in the inline comment). Phase 2 is the right slot, before the reader ships.
One stale-docs note (phase 3 fodder, no inline anchor — files untouched by this diff)
config.py (lines ~675, 718–719, 777, 1356), worker.py:132/159/201, aggregate.py (several), streaming.py:129, and stats/tdigest.py:70 still describe the on-disk layout as "companion CSR array" / "the CSR writer". config.py's are user-facing schema descriptions that now misdescribe the store. Fine to sweep in phase 3 with the notebook, but they shouldn't be forgotten — the PR body's sweep list doesn't mention them.
CI state for 477225f
All concluded checks green: test (3.12) ✅, ruff ✅, lambda build x86_64/arm64 ✅, docs build/check ✅ (deploy/pr-preview skipped, as expected on a fork-less draft). test (3.13) still shows in_progress at review time (started with the push; likely a stale/stuck runner given 3.12 finished in ~8 min) — worth a re-run if it hasn't concluded by the next pass. Draft state is correct while it's outstanding.
Generated by Claude Code
|
|
||
| single_chunk = int(getattr(grid, "chunks_per_shard", 1)) == 1 | ||
| # Ragged fields accumulate across the streamed chunks (leaf-LOCAL blocks) | ||
| # and are written ONCE after the stream (issue #209): the leaf's ragged | ||
| # vlen array is a single ShardingCodec object spanning the shard, so a | ||
| # per-chunk write here would read-modify-write that object K times. Only | ||
| # the ragged payloads (digest bytes) are held — small next to the dense | ||
| # slabs the sharded flat path already buffers — so the issue #91 | ||
| # stream-and-free bound on the dense side is unchanged. | ||
| ragged_chunks: list = [] |
There was a problem hiding this comment.
🤖 from Claude (review)
should-fix (quantify the memory claim): "small next to the dense slabs the sharded flat path already buffers" compares against the wrong baseline. This is the streaming path — the one issue #91/#143/#148 built to hold ~1 chunk of output, and on main the per-chunk CSR write freed each chunk's ragged payloads immediately. Accumulating ragged_chunks reintroduces an O(whole-shard-payload) term on exactly the path that was O(chunk):
- sparse NEON o8 fixture (the shard size this fix is meant to unlock, per the head-to-head): 17.6 M centroids × 8 B ≈ 141 MB raw payload, plus ~12–16 MB of per-
bytes-object + slab-pointer overhead inwrite_ragged_leaf_to_zarr's object slab, plus the assembled ~60 MB shard object during the singleset_block_selection— roughly ~200 MB peak added; - dense RGT-1336 o9: 12.9 M centroids ≈ ~103 MB payload, ~150 MB peak added.
That's likely fine under the Lambda budget (the flat sharded path already buffers all K carriers), and accumulate-then-write-once is the right call for a one-object-per-shard layout (per-chunk writes would RMW the shard object K times, as the docstring says). But the bound changed on the tight-memory path, so it should be stated with numbers, not "small": suggest replacing the parenthetical with the rough figure at the o8 t-digest scale, so the next memory investigation doesn't have to rediscover it — and confirming the o8 hive worker (payload + dense chunk + read side) was actually profiled or at least back-of-enveloped against the worker's memory cap. Not blocking: correctness is unaffected and the ordering/torn-write semantics are pinned by tests.
Generated by Claude Code
| def ragged_locations_name(field_name: str) -> str: | ||
| """On-disk array name of a located ragged field's uint64 channel (issue #87). | ||
|
|
||
| Under the vlen-bytes layout (issue #209) the location words are a SIBLING | ||
| vlen array (``{field}_locations``) row-aligned with the digest payload — | ||
| the CSR layout's fourth in-group array cannot nest under what is now an | ||
| array node. | ||
| """ | ||
| return f"{field_name}_locations" |
There was a problem hiding this comment.
🤖 from Claude (review)
should-fix (name-collision guard; answers PR question 2): the derived {field}_locations name has no guard anywhere. A config that declares a located ragged field foo and a user field literally named foo_locations collides twice, both silently:
- in
_group_spec'smembersdict (healpix.py:724 / rectilinear.py:633) — last assignment wins, so one array's template silently replaces the other; - in
_accumulate_ragged_slabs'slabsdict (write.py:596-598) — the user field's payload bytes and the location words interleave per cell into the same slab, i.e. actual data corruption, not just a template quirk.
Pre-existing coord names (cell_ids, morton, count) have the same theoretical hazard, but those are names the user can see; this PR mints a name the user never wrote, which makes the collision much easier to hit blind.
Verdict on the naming itself: keep {field}_locations — a subgroup is impossible for the same reason the CSR fourth array moved (an array node can't have children), and a reserved-prefix scheme (_locations/{field}) would put a group where the flat cell-grid arrays live for no gain. What's missing is (a) a validate_config rejection of any agg-field name that equals ragged_locations_name(other_located_field) — a 5-line check with a test — and (b) declaring the linkage in attrs (e.g. attrs["ragged"]["locations"] = "<sibling name>" on the payload array, or "location_of": "<field>" on the sibling) so the phase-2 reader binds by metadata rather than reconstructing the naming convention. Fine to land (a) as a phase-2 item since the reader hasn't shipped, but it should not slip past this PR series.
Generated by Claude Code
| if sig.get("location"): | ||
| members[ragged_locations_name(name)] = ragged_array_spec( | ||
| element_dtype="uint64", **rag_kw | ||
| ) |
There was a problem hiding this comment.
🤖 from Claude (review)
should-fix (test gap): the located channel is never exercised through the sharded layout. test_end_to_end_located_write_then_read pins {field}_locations row-alignment on the unsharded K==1 path (write_ragged_to_zarr), and TestRaggedVlenLayout exercises the sharded slab pass with an unlocated field only — so this branch (the uint64 sibling created with shard_shape) and the located arm of _accumulate_ragged_slabs under write_shard_to_zarr's per-object regions run in no test. The alignment code is shared, so I traced it and believe it's correct (payload and locations use the same region/pos, and the n_rows check keeps them row-aligned), but per-cell payload↔locations alignment across an object-split shard is precisely the kind of invariant this PR's other gates pin byte-hard. One test — a located field in _sharded_setup-style hand-built chunk_results through write_shard_to_zarr, asserting per-cell len(locations) == n_rows(digest) after reopen — would close it.
Generated by Claude Code
| (obj_key,) = self._data_keys(store, grid) | ||
| assert [k for k, _r in store.gets] == [obj_key, obj_key] | ||
| # Both GETs are ranged (index suffix, then one inner chunk) — never the | ||
| # whole object. | ||
| assert all(byte_range is not None for _k, byte_range in store.gets) |
There was a problem hiding this comment.
🤖 from Claude (review)
nit (loophole in the 2-GET assertion): byte_range is not None doesn't quite pin "never the whole shard" — a RangeByteRequest(0, len(object)) (or a suffix request spanning the object) is ranged and would pass while doing a whole-shard read under the hood. The GET count of exactly 2 is the strong half; the range assertion could be tightened cheaply, e.g. record the object's total size and assert each request's span is a small fraction of it (index suffix ≈ 16·K+4 bytes; the inner chunk ≪ object). As-is, a regression to "2 GETs that each fetch everything" would slip through.
Generated by Claude Code
|
🤖 from Claude Round-1 findings folded ( Review dispositions
Phase 2 — reader swap (
|
espg
left a comment
There was a problem hiding this comment.
🤖 from Claude (review)
Fresh-context adversarial review, round 2 — the two newest commits only: c0f6462 (round-1 folds) and bf85603 (phase 2: reader swap, csr.py deleted). Verified against the issue 209 decision record, with the new suites run locally (test_readers 36 passed; the fold tests in test_config/test_processing green) and the read paths probed empirically in the repo venv.
No blocking-correctness findings
The reader's math checks out where I attacked it hardest:
- One code path for both flat layouts, genuinely.
_stored_chunk_spansderives the span fromarr.shards or arr.chunksand sorts ordinals numerically (int(...)before sort —c/10cannot misorder beforec/2);test_sharded_and_regular_layouts_read_identicallypins identical tensors + chunk ids from the same logical data. Empty cells (b"") decode to the zero-length(0, *inner_shape)element on both layouts; all-fill chunks are skipped identically (absent object on regular, index-omitted on sharded). - Chunk identity is sound. I verified
_chunk_wordround-trips empirically: 256 order-12 children coarsen to exactly their order-8 parent word (clip2orderatcell_order − log4(cells_per_chunk)), the same coverage-cell id the CSR subgroup names carried; missing morton / missing attrs raise the pointed errors the tests pin. (The depth-from-slice-length arithmetic is only safe because HEALPix chunk sizes are powers of 4 and array shapes are chunk multiples — a truncated slice would silently coarsen one order short, but no zagg grid can produce one.) read_cellmatches the writer's rank convention (global index = block base + row-major cell id — pinned bytest_decodes_declared_element), and the 2-GET pin is now exact (index suffix== 16·K+4).- Deletion is clean. No
zagg.csr/read_csr/iter_csr_cellsimports survive insrc/ortests/(the notebook is the declared phase-3 item);readers/__init__.pyre-exports are consistent; the #191 consolidation-parity test still tests the same invariant — non-consolidated navigation byte-parity — now through all three vlen readers on a production-written located store, which is a strictly stronger rebuild. - Conventions: modules at 585/830/843 lines; no new deps; no CI/workflow/deploy files in the diff; the
types-PyYAMLpre-commit gap correctly left for a human (it edits the hook config).
Round-1 fold verdicts — all four genuinely folded by c0f6462
- Memory note (hive.py): folded; the arithmetic is right (17.6 M × 8 B ≈ 141 MB; ~200 MB peak with the ~60 MB assembled object) and the escape valve is named. One residual echo of the old claim survives in
write.py:502("the (small) ragged payloads") — nit, inline. - Collision guard: folded properly — rejects both a colliding field and a colliding coordinate, and allows the
_locationssuffix for unlocated fields; grid-agnostic since it sits invalidate_config. Three targeted tests. - Located channel through the sharded slab pass: folded with a real test — end-to-end
write_shard_to_zarr, mixed n=0 payloads and empty chunks, asserting per-cell row alignment (w.shape == (v.shape[0],)) and exact payload/location array equality after fresh reopen. - 2-GET assertion: folded stronger than asked — exact index-suffix size, inner chunk bounded ≪ object.
Findings (inline, priority order)
- should-fix —
read_cell: out-of-range or negativecelldies withValueError: not enough values to unpack(zarr clamps the slice), not a pointed error;-1neither wraps nor explains. Verified empirically. - should-fix — the whole-store sweep re-fetches the shard-index suffix once per inner chunk (measured: 16 identical suffix GETs + 4 chunk GETs on a K=16 shard) — ~256 redundant sequential GETs per shard at production geometry. One slice per stored span fixes it at the cost of the already-accepted ~141 MB span bound.
- should-fix — coverage: the writer-side ragged guards that replaced
write_csr's checks are real, but their raising branches are untested — the substance of 5–6 deletedtest_csr.pytests has no equivalent at head, and the PR body's "already under test" claim is wrong on that half. (Everything else deleted is genuinely superseded; the 1768→1756 count reconciles exactly — detail inline.) - question-for-author — the
attrs["ragged"]contract carries no version marker ahead of the issue 210 typed-dtype migration; and a garbageelement.dtypestring surfaces as a raw numpyTypeError, not this module's pointed style. Both one-pass decisions for the phase-3 freeze. - nit — the hive leaf-at-a-time reader contract is implicit (works, traced; but undocumented and untested through the readers) — phase-3 fodder alongside the notebook.
CI state for bf85603
ruff ✅, test (3.12) ✅, lambda build x86_64 + arm64 ✅, docs build ✅ (deploy / pr-preview skipped as expected on a draft). test (3.13) still in_progress at review time (started 01:50Z) — the same slow-3.13 pattern as phase 1, which eventually concluded green; mergeable_state: unstable reflects only that pending check. Draft state remains correct while phase 3 is open.
Generated by Claude Code
| included (its elements decode as ``(n,)`` uint64 words). | ||
| """ | ||
| arr, elem_dtype, elem_shape, _meta = _open_ragged(store, field, zarr_format) | ||
| (raw,) = arr[int(cell) : int(cell) + 1] |
There was a problem hiding this comment.
🤖 from Claude (review)
should-fix (unpointed error on a bad cell index): read_cell promises "an absent/empty cell returns the zero-length element", but an out-of-range or negative cell crashes with an unrelated message — verified in the repo venv against a production-template store (shape 201,326,592):
read_cell(store, "12/h_tdigest", n) # ValueError: not enough values to unpack (expected 1, got 0)
read_cell(store, "12/h_tdigest", n + 5) # same
read_cell(store, "12/h_tdigest", -1) # same (zarr's slice [-1:0] is empty — no numpy-style wrap)
zarr basic selection clamps the slice instead of raising, so the 1-tuple unpack is what fails. This module's own standard (which phase 2 explicitly adopts — missing attrs/morton are pointed errors, "no silent misinterpretation") argues for a bounds check: if not 0 <= cell < arr.shape[0]: raise IndexError(f"cell {cell} out of range for {field!r} with {arr.shape[0]} cells") — three lines plus a two-line test. Note the -1 case is the sharpest edge: a caller expecting numpy negative-index semantics gets neither the last cell nor a comprehensible error.
Generated by Claude Code
| for span_start, span_stop in _stored_chunk_spans(arr): | ||
| for start in range(span_start, span_stop, cells_per_chunk): | ||
| block = arr[start : start + cells_per_chunk] | ||
| populated = [(pos, block[pos]) for pos in range(len(block)) if len(block[pos])] | ||
| if populated: | ||
| yield start, populated |
There was a problem hiding this comment.
🤖 from Claude (review)
should-fix (sweep re-reads the shard index once per inner chunk): results are correct (I verified sharded/regular parity), but slicing arr[start : start + cells_per_chunk] per inner chunk on a ShardingCodec'd array re-fetches the shard-index suffix on every slice — zarr caches nothing across __getitem__ calls. Measured with a counting store on a K=16 shard with 4 populated chunks: the sweep issued 20 GETs on one object — 16 identical SuffixByteRequest(260) index reads + 4 inner-chunk reads. At the production K=256 (the o9 CONUS geometry in the issue 209 head-to-head) that is ~256 sequential index GETs per shard, paid even for all-empty inner chunks — a whole-store sweep on S3 becomes latency-bound on redundant round trips, which cuts against the decision record's read-efficiency argument.
One-slice-per-stored-span fixes it: iterate _stored_chunk_spans and read arr[span_start:span_stop] once (1 index GET + populated-chunk GETs only; zarr's sharded partial read skips index-omitted chunks), then split into read chunks in memory. The cost is holding one span's decoded payload — ~141 MB at the o8 t-digest scale, i.e. exactly the bound process_and_write_hive now documents and accepts on the write side, and this is the client-side bulk reader, not the Lambda path. If the memory bound is instead the reason for per-chunk slicing, that tradeoff deserves the same named-and-quantified treatment the hive accumulation comment got in c0f6462.
Generated by Claude Code
| if len(values_list) != len(cell_ids): | ||
| raise ValueError( | ||
| f"values_list (len {len(values_list)}) and cell_ids (len {len(cell_ids)}) " | ||
| "must have the same length" | ||
| ) | ||
| sig = _ragged_sig(name, grid) | ||
| if name not in slabs: | ||
| slabs[name] = np.full(slab_shape, b"", dtype=object) | ||
| out = slabs[name][region] | ||
| loc_out = None | ||
| if locations_list is not None: | ||
| if len(locations_list) != len(values_list): | ||
| raise ValueError( | ||
| f"ragged field {name!r} is resolution: chunk but carries a location " | ||
| f"channel; located ragged fields are cell-resolution only" | ||
| f"locations_list (len {len(locations_list)}) and values_list " | ||
| f"(len {len(values_list)}) must have the same length" |
There was a problem hiding this comment.
🤖 from Claude (review)
should-fix (coverage lost with test_csr.py — the guard error branches are untested): the PR body says the deleted write_csr guards "live on in _place-path checks in write.py, already under test". The guards live on; the "already under test" half is not accurate. I grepped the suite at head — none of these raising branches is exercised anywhere:
values_list/cell_idslength mismatch (this line) — wastest_csr.py::test_mismatched_lengths_raiseslocations_list/values_listlength mismatch (L591) — wastest_locations_list_length_mismatch_raises- per-cell row-misalignment at L609, which also subsumes nonempty-locations-on-empty-payload (
n_rows == 0forces shape(0,)) — wastest_per_cell_location_length_mismatch_raises+test_nonempty_locations_on_empty_payload_raise - payload does not tile
inner_shape(_ragged_payload_bytes, L556) — wastest_inconsistent_inner_shape_raises - located
resolution: chunkrejection (_write_ragged_companions, L640) — new in this PR, also untested
These are exactly the malformed-worker-output paths that would otherwise surface as silent corruption or a confusing numpy error deep in the slab pass, and they were the substance of 5–6 of test_csr.py's 18 deleted tests (the other 12 — round-trips, empties, dtype — have genuine new equivalents in TestRaggedVlenLayout and the reader round-trips; the 6 deleted subgroup-name-recovery tests in old test_readers.py are superseded by the no-fallback design and its pointed-error tests, no loss there). One small pytest.raises class covering the five messages closes it; the PR-body claim should be corrected either way.
For the record, the 1768 → 1756 arithmetic reconciles exactly: −18 (test_csr.py) + 2 (test_readers.py 34→36 items) + 3 (collision-guard tests) + 1 (located sharded slab test) = −12.
Generated by Claude Code
| element = {"dtype": str(element_dtype), "shape": [-1, *(int(s) for s in inner_shape)]} | ||
| ragged_meta: dict = {"element": element} | ||
| if locations is not None: | ||
| ragged_meta["locations"] = str(locations) | ||
| return ArraySpec( | ||
| attributes={RAGGED_ELEMENT_ATTR: ragged_meta}, |
There was a problem hiding this comment.
🤖 from Claude (review)
question-for-author (attrs contract shape ahead of issue 210): the contract itself is in good shape — built at this single choke point, so all three emit variants (sharded flat, hive leaf shard_spec, unsharded regular) and both grids carry identical attrs, and the reader binds strictly (missing/malformed element → pointed error, locations by declaration only). Two small choices worth settling before the phase-3 doc freezes it:
- No version marker. Issue 210's typed-vlen-dtype migration will presumably drop or supersede
attrs["ragged"]; a reader then has to discriminate by dtype name alone. Options: (a) stamp{"version": 1}intoragged_metanow (one line, and the migration bumps or removes it), or (b) declare the zarrdata_typestring the discriminator and say so in the phase-3 doc. Either works — which do you want on disk before stores proliferate? - Garbage dtype string decodes to a raw numpy error, not this layout's pointed style:
np.dtype(str(element["dtype"]))in_open_raggedraisesTypeError: data type 'x' not understoodwith no mention of the field or the attrs contract. Not garbage-in-garbage-out (nothing decodes wrong), but wrapping it in the sameValueError(f"{field!r} declares an unreadable element dtype ...")pattern would match the missing-attrs branch. Fine as a phase-3 rider.
Generated by Claude Code
| The read plan honors the layout the writer chose: a whole-store sweep LISTs the | ||
| array's stored chunk objects (one object per shard under the ShardingCodec, or | ||
| one per inner chunk on the unsharded flat layout — both self-describing in the | ||
| array metadata, so one code path reads either), then decodes per read chunk. | ||
| Each read chunk is a square ``(side, side)`` block of cells (row-major | ||
| ``cell_id`` within the chunk, ``side = isqrt(cells_per_chunk)`` — 64 for the | ||
| production ``chunk_inner`` configs); its coverage-cell morton id is derived | ||
| from the sibling ``morton`` coordinate. Random access to one cell | ||
| (:func:`read_cell`) indexes the vlen array directly — 2 ranged GETs on a | ||
| sharded store (index suffix + one inner chunk), never the whole shard. |
There was a problem hiding this comment.
🤖 from Claude (review)
nit (hive contract is implicit — say it, and pin it once): I traced the hive question and the one-code-path claim holds: a hive leaf's ragged array is the same ragged_array_spec product (whole-leaf ShardingCodec → one stored span), the leaf carries the morton sibling and identical attrs, and write_ragged_leaf_to_zarr writes under the same grid.group_path — so pointing this reader at a leaf store works through exactly this sweep. But that contract is only implicit: the reader is store-scoped (no digit-tree traversal — same as the old CSR reader, which took one field prefix in one store), the docstring never says "for a hive product, open one leaf store and pass the same group path", and no test opens an actual hive leaf through a reader — the flat↔leaf parity pin (test_hive_leaf_parity_with_flat_sharded) lives on the write side and compares arrays, not reader output. Since the phase-3 checklist already owns docs + the notebook rewrite, fold two things into it: the explicit leaf-at-a-time sentence here, and one cheap test that runs read_tensors/read_cell against a written hive leaf store. Non-blocking for phase 2.
While in this file for phase 3: write.py:502's "collects the (small) ragged payloads" is the last echo of the unquantified memory claim that c0f6462 correctly replaced at the hive.py accumulation site — worth pointing that phrase at the quantified comment rather than re-asserting "small".
Generated by Claude Code
|
🤖 from Claude Phase 3 instructions (docs + inventory) — for the agent picking this phase upContext: phases 1–2 are on this branch ( Scope:
Definition of done: full local green — Separately, answering espg's thread-adjacent question about worker threading in this PR — see the clarification below (posted next). Generated by Claude Code |
|
🤖 from Claude Clarification (espg asked whether this PR removes "the k workers for the multithreaded shard agg/write") — there are three distinct "K/worker" concepts in the shard path, and this PR touches exactly one:
So: aggregation stays multithreaded; the write-side threading disappears because the problem it solved disappeared. Generated by Claude Code |
|
🤖 from Claude Round-2 review findings folded in
Verified before push: affected suites and the full suite green (1780 passed, 13 skipped); ruff clean on all changed files ( Note: this fold commit was completed and verified but its original authoring session ended before pushing; it was pushed from the verified worktree state. Phase 3 (docs + inventory) is unstarted and picked up next per the instructions above. PR stays draft. Generated by Claude Code |
|
🤖 from Claude Phase 3 (docs + inventory) landed in New narrative doc — Notebook — Stale-CSR sweep across Benchmark harness — PR body — phase 3 checked; a Post-merge validation heading records the o8 900 s-timeout re-test (o8/o9/o10 sweep, espg-side credentialed, feeding issue #202's cost model and checking the ~200 MB hive-accumulation envelope). Green locally: Generated by Claude Code |
espg
left a comment
There was a problem hiding this comment.
🤖 from Claude (review)
Fresh-context adversarial review of the newest commit 71213a0 (phase 3 of issue #209 — docs page + notebook rewrite + stale-CSR docstring sweep). Docs phase, so I checked every technical claim against the code at head. No blocking findings. The page is accurate to the implementation; the notebook runs end-to-end; the nav wires up and builds. One inline nit on the GET-count table plus the small items below.
What I verified (all check out)
- attrs contract —
docs/ragged_layout.mdclaimsspec/element/locationsunder theraggedkey withspec == "zagg-ragged/1". Exact match:grids/base.pyhasRAGGED_ELEMENT_ATTR = "ragged",RAGGED_SPEC = "zagg-ragged/1", andragged_array_specemits{"spec": RAGGED_SPEC, "element": {"dtype": str(...), "shape": [-1, *inner_shape]}}(+ optional"locations") at the single choke point. Doc example (shape [-1, 2],"locations": "h_tdigest_locations") matches what the located config produces. - Wire framing — doc's
u32 cell_countthen per-cellu32 length + payload(LE, VLenBytes/VLenArray) is byte-for-byte the golden vector intest_processing.py::TestRaggedVlenLayout::test_golden_inner_chunk_framing(struct.pack("<I", len(cells)) + b"".join(struct.pack("<I", len(c)) + c ...)). The#210"metadata-only migration" claim follows correctly from that pin — a typedvlen-array<f32>framesu32 len + array.tobytes(), identical bytes to the currentu32 len + ascontiguousarray(value).tobytes(). - read_cell / sweep GET claims — see inline. Sharded 2-GET is exact and test-pinned; unsharded 1-GET is analytically correct but not test-pinned. Neither count includes the array-open metadata read. Not blocking.
- Symbols the doc names all exist:
_open_raggedstrict-spec-gate (raises on unknown spec),_stored_chunk_spans,ragged_array_spec(shard_shape=…),write_shard_to_zarr/write_ragged_leaf_to_zarr,hive.shard_leaf_path,hive._ZSTD_LEVEL == 3. Shard footer "K (offset,nbytes) u64 pairs + crc32c" and the2^64-1empty-chunk sentinel match the sharding index codecs + the16*K+4suffix assertion. - mkdocs nav — built the docs at 71213a0 in a throwaway worktree:
ragged_layout/index.htmlrenders, the in-page anchor#issue-210-typed-dtype-migrationresolves to an existingid, andhive_layout.mdrenders to../hive_layout/. No broken-link/nav warnings for the new page (the only build errors are pre-existing network-blockedobjects.invinventory fetches, unrelated). - Notebook — ran all 10 code cells end-to-end under
zagg[test,analysis]against the phase-2 runtime (phase 3 is docs/comment-only insrc, so runtime is identical): builds a store via the production template +write_ragged_to_zarr(ragged, store, grid=, chunk_idx=),read_cell(store, FIELD, base+5),read_tensors,read_raw_values, all fit policies — every symbol it imports exists and every cell executes with sensible output. nbformat 4.5, unique stable cell ids, outputs cleared, all code cellsast.parse-clean. Binder claim holds:.binder/postBuildinstallszagg[analysis,catalog,viz], andmatplotlib>=3.10.8is in theanalysisextra; data source is in-memoryrng, no credentialed calls. - Config-YAML edits — accurate to what the writer produces: the located config's old
{field}/{shard}/locationson-disk description is correctly replaced by "the{field}_locationssibling … row-aligned", and the threesharded:-block "K CSR writes" comments → "K per-inner-chunk writes". - src diff is truly comment-only — every hunk in
config.py/aggregate.py/streaming.py/worker.py/grids/{base,healpix,morton}.py/stats/tdigest.pyis inside#comments or docstrings; no runtime line changed. No CI/workflow/deploy edits. The reconciled present-tense CSR references now read correctly.
Minor items (non-blocking)
- nit (notebook, markdown "read_tensors" section): the prose says the chunk morton id is "recovered from the sibling
{field}/mortoncoordinate array". WithFIELD = "12/h_tdigest", the backtick path{field}/mortonreads as a child of the field array (12/h_tdigest/morton), which doesn't exist — the array is the field's sibling at{group}/morton(12/morton). The word "sibling" is right; only the path template contradicts it. The Summary cell gets it right ("the siblingmortoncoordinate"). Trivial wording fix. - observation (stale-CSR sweep, out of this commit's diff): the "deliberately left" historical-contrast CSR mentions I spot-checked are genuinely historical and don't misdescribe current behavior —
grids/base.py:67("the CSR layout's fourth in-group array cannot nest…"),readers/tdigest_tensor.py:355("the same parent cell the CSR layout named its subgroups by"), and the "replaces the per-inner-chunk CSR subgroups" notes are all accurate. The twowrite.pydocstrings that credit an invariant towrite_csr("the guardwrite_csrenforced structurally", L551; "the per-cell length contractwrite_csrenforced", L576) are past-tense and don't mislead about current behavior, butwrite_csrwas deleted in phase 2, so a maintainer greps a symbol that no longer exists while the live guard sits inline in the same function. Sincewrite.pyisn't in this commit's diff it's out of scope to fold here — noting it only for completeness; reword to point at the inline check if the file is touched again.
2-GET / 1-GET doc-claim verdict
Both directions of the table are factually correct. Sharded single-cell read_cell = exactly 2 ranged data-object GETs (shard-index suffix + one inner chunk), and this is hard-pinned by test_two_ranged_gets_on_sharded_store. Unsharded per-inner-chunk read_cell = 1 data-object GET (a regular vlen array indexes the single chunk holding the cell) — correct, but analytic, not test-pinned. Both counts exclude the one-time array-open metadata read. Recommend the two half-sentence caveats in the inline comment; no correctness problem.
CI state @ 71213a0
ruff success; build / build-x86_64 / build-arm64 success; deploy / pr-preview skipped; test (3.12) and test (3.13) in_progress at review time. The PR body reports the full suite green at this head (1765 passed / 28 skipped) with ruff/format/codespell clean on the changed docs and touched lines; the two test jobs were still running when I pulled check-runs.
Generated by Claude Code
| | layout | when | on disk | single-cell read | | ||
| |---|---|---|---| | ||
| | **sharded** (`ShardingCodec`) | sharded flat path (`write_shard_to_zarr`) and every hive leaf (`write_ragged_leaf_to_zarr`) | ONE object per shard; the shard's K inner chunks live inside it with an internal index | 2 GETs (index suffix + one ranged inner chunk) | | ||
| | **per-inner-chunk** (regular array) | UNSHARDED per-chunk write (`write_ragged_to_zarr`, the runner / Lambda streaming callback) | one object per inner chunk | 1 GET (the object) | |
There was a problem hiding this comment.
🤖 from Claude (review)
nit (GET-count table — one column is pinned, one is asserted): I verified both cells against the code. The sharded 2 GETs is exact and test-pinned — tests/test_readers.py::test_two_ranged_gets_on_sharded_store filters data_gets to /h_tdigest/c/ and asserts len == 2, with the index-suffix span == 16*chunks_per_shard + 4 and the inner-chunk read <= obj_size // 4. The 1 GET for the per-inner-chunk regular array is correct in principle (a regular vlen array indexes exactly the one chunk holding the cell → one data-object GET) but has no equivalent test pin — the only unsharded read_cell test (test_out_of_range_and_negative_raise_pointed) checks bounds, not GET count. Two half-sentences would keep the table honest: (a) note the 1-GET column is the analytic expectation rather than a counting-store assertion, and (b) note both counts are data-object GETs and exclude the one-time array-open metadata read (zarr.json/attrs in _open_ragged), so a reader tallying raw S3 requests isn't surprised. Non-blocking; the numbers are right.
Generated by Claude Code
|
🤖 from Claude Final-review nits folded in
Verified: notebook nbformat-valid (4.5, 21 cells, all 10 code cells All three phases complete; three adversarial-review rounds folded. Applying Standing for review (nothing blocking): the pre-existing Generated by Claude Code |
Closes #209
Decision record
Adopts the sharded vlen-bytes layout for ragged fields, per the espg-ratified decision on issue 209: each
kind: raggedfield becomes ONE zarr array with thevariable_length_bytesdtype and a[vlen-bytes, zstd(level=3)]inner codec chain riding inside aShardingCodec, replacing the per-inner-chunk CSR subgroups (~1,792 objects/shard → 1). Grounding:VLenArrayframing).claude/209-vlen-bytes-probe(be6535f); this PR implements it properly in the production write path.Hard break, pre-1.0, no migration shims: existing ragged stores become debris.
Phases
477225f; review folds inc0f6462)bf85603:tdigest_tensorreads the vlen layout — attrs-driven decode, 2-GET single-cellread_cell, whole-store sweep over stored objects; CSR group-walk andzagg.csrdeleted)71213a0: newdocs/ragged_layout.mdwired into the mkdocs nav, notebook rewritten to the vlen reader API, config/worker/aggregate/streaming/grids/tdigest + shipped config-YAML docstrings reconciled; the benchmark harness carries no CSR/ragged references, so nothing to change there)What phase 1 does
Template (
src/zagg/grids/base.py::ragged_array_spec, wired inhealpix.py/rectilinear.py::_group_spec):variable_length_bytesarray on the cell grid, fillb"". Each populated cell holds the raw little-endian bytes of its(n, *inner_shape)payload; the interpretation is self-describing via array attrs:attrs["ragged"] = {"element": {"dtype": "float32", "shape": [-1, 2]}}(key:RAGGED_ELEMENT_ATTR).[vlen-bytes, zstd(level=3)](level 3 matches the coverage-sidecar precedent). The chain sits insideShardingCodec(chunk_shape=(cells_per_chunk,))when the grid issharded(outer chunk mirrors the dense arrays' issue-update benchmarks #133 object split) and across the whole leaf in a hiveshard_spec— one object per shard on both. The unsharded flat layout stays a regular array chunked at the inner chunk (one object per inner chunk), because that path streams per-chunk writes (issue Stream-and-free chunk writes in the multi-chunk worker (don't accumulate all K) #91) and a shared shard object would be read-modify-written K times.{field}_locationsuint64 vlen array, row-aligned with the payload, and (phase 2) the payload array's attrs declare the linkage:attrs["ragged"]["locations"] = "".resolution: chunkragged (issue Vector (and ragged?) chunk companions:resolution: chunkfor non-scalar kinds #82) sits on the chunk grid (one payload per chunk), like the scalar/vector companions.UnstableSpecificationWarningat creation is the dtype naming only (variable_length_bytesvs the registry'sbytes, zarr-python#3517); suppressed message-scoped at the emit sites (vlen_dtype_warning_suppressed, thecoverage.mocprecedent).Writer (
src/zagg/processing/write.py):write_shard_to_zarr's per-object slab pass — the whole shard's payloads are assembled into oneb""-filled object slab and written in ONEset_block_selection, exactly like the dense columns. Deleted:_write_ragged_fanout, the issue-ShardingCodec doesn't consolidate ragged (tdigest) writes — K serial CSR groups dominate shard write time #142 thread pool, the issue-Sharded-path parallel CSR writes race under real CPU parallelism (fails 3/3 on 6 GB workers, largest shard) #186 failure roll-call,_block_index_key, and thewrite_csrdependency — all existed only to manage the object storm this layout removes.hive.py::process_and_write_hive+ newwrite_ragged_leaf_to_zarr): the streamed chunks' ragged payloads accumulate (leaf-local blocks) and are written as ONE object after the stream, preserving the pinned leaf order dense → ragged → coverage sidecar → stamp. The memory bound is quantified at the accumulation site (review fold): ~141 MB held payload / ~200 MB peak at the o8 t-digest scale, accepted under the 4 GB workers (issue Skip zarr metadata consolidation by default (opt-in) #193); the named escape valve is spilling back to per-inner-chunk writes._write_chunk):write_ragged_to_zarris re-cut to(ragged, store, grid=, chunk_idx=)— the same block addressing aswrite_dataframe_to_zarr— deleting the K==1-shard-label / K>1-block-key naming split (both call sites simplify).b""fill and all-empty inner chunks are omitted from the shard index (sub-shard sparsity, same as dense).validate_configrejects an agg field/coordinate named{located_field}_locations(review fold — the silent template/slab collision).What phase 2 does
Reader swap (
src/zagg/readers/tdigest_tensor.py): the CSR group-walk (_shard_groups/_resolve_chunk_morton/_subgroup_wordsand theread_csrconsumption) is gone.c/objects (never a probe of every chunk of a mostly-fill array), then per-read-chunk decode via the attrs element spec — the reader decodes what the writer declared, no hardcoded f32. One code path reads BOTH flat layouts (ShardingCodec'd and per-inner-chunk regular): the difference is self-describing in array metadata, per the round-1 Q1 verdict.mortonper-cell coordinate (any written cell's word coarsened to the chunk order) — the same coverage-cell id the CSR subgroup names carried. Missing morton sibling or missing element attrs → pointed errors (no silent misinterpretation; no legacy-name fallback).read_cell(store, field, cell)indexes the vlen array directly — 2 ranged GETs on a sharded store, pinned by a counting-store test (index suffix size asserted exactly; inner-chunk GET asserted ≪ object). Works on the{field}_locationssibling too (it has its own element attrs).read_locationsbinds the sibling throughattrs["ragged"]["locations"](writer emits it; no derived-name fallback — one convention, per the Q2 verdict).src/zagg/csr.py+tests/test_csr.py— production-orphaned once the reader swapped (the last importers). Its write-side guards (per-cell inner-shape tiling, locations row-alignment, empty-on-empty) live on in_place-path checks inwrite.py, already under test.side = isqrt(cells_per_chunk)— identical output for the productionchunk_innerconfigs (4096-cell chunks), and lets the tests exercise real grids at smaller orders.Reader tests (
tests/test_readers.py, rewritten): fixtures build REAL stores through the production template + writers (no synthetic CSR): round-trip tensors/raw-values/locations on the K==1 layout, a sharded-vs-regular layout parity test (identical tensors + chunk ids from the same logical data — the Q1 pin), locations alignment withread_raw_values,read_celldecode + 2-GET pin, missing-attrs and missing-morton pointed errors, and the issue #191 consolidation-parity test rebuilt on the vlen layout.What phase 3 does
docs/ragged_layout.md(new; wired into the mkdocsDesignnav next tosharding.md/hive_layout.md): the vlen-bytes layout end to end — the on-disk arrays ({field}/{field}_locations/morton), theattrs["ragged"]contract (spec = "zagg-ragged/1",element,locations— exact key names), the golden-pinned wire framing (u32 cell_countthen per cellu32 length + payload, little-endian numcodecsVLenBytes/VLenArrayframing), sharded vs per-inner-chunk layouts and the Q1 rationale (unsharded flat K>1 keeps per-inner-chunk objects to preserve the Stream-and-free chunk writes in the multi-chunk worker (don't accumulate all K) #91 stream-and-free bound; both self-describing, one reader path), the hive-leaf reader contract (leaf carries themortonsibling + ragged attrs under the same group path), the two read paths (whole-store sweep;read_cell2-GET random access), and the issue Typed vlen-array<float32> Zarr data type + codec (follow-up to the issue 209 vlen-bytes layout) #210 typed-vlen-array<T>dtype migration note (metadata-only, guaranteed by the golden framing pin).notebooks/tdigest_reader_example.ipynbrewritten to the new reader API: builds the synthetic store through the production template +write_ragged_to_zarr(waszagg.csr.write_csr), peeks a cell withread_cell, keeps its in-memory Binder-runnable data source, stable cell ids, outputs cleared; verified nbformat-valid, every code cellast.parse-clean and executes end-to-end (the one matplotlib cell ast-parsed only where the extra is absent).src/: reconciled present-tense CSR references inconfig.py,processing/{worker,aggregate,streaming}.py,grids/{morton,healpix,base}.py,stats/tdigest.py, and the shipped config YAMLs (atl03_tdigest_healpix,atl03_tdigest_located_healpix,atl03_gain_bias_healpix) — "the CSR writer" → the ragged/vlen writer, "companion CSR array" → companion vlen array, "CSR subgroup names" (as a current external surface) → hive leaf ids, and the located config's stale{field}/{shard}/locationson-disk description → the{field}_locationssibling. Left standing (deliberate historical contrast, not stale): the "replaces the per-inner-chunk CSR subgroups" / "pre-issue-209 CSR stores are a hard break" / "the CSR layout named its subgroups by" / "write_csrenforced" notes inwrite.py,readers/tdigest_tensor.py,grids/base.py,grids/healpix.py,grids/rectilinear.py, which accurately describe what the layout replaced..github/scripts/run_benchmark.pycarries no CSR object-count / ragged-layout references (checked), so it needed no change;.github/workflows/anddeployment/aws/untouched.Questions for review — RESOLVED (round-1 verdicts adopted)
test_sharded_and_regular_layouts_read_identically). Docs note lands in phase 3 (docs/ragged_layout.md).{field}_locationsname stays. The collision guard is invalidate_config(phase-1 fold), and the attrs-declared linkage (attrs["ragged"]["locations"]) landed with the phase-2 reader, which binds by metadata only.How it was tested
tests/test_processing.py::TestRaggedVlenLayout: one-object-per-shard, bit-exact fresh-reopen round trip (variable lengths incl. n=0 payloads and empty cells), empty-inner-chunk omission against the shard-index sentinel, the golden framing vector (the wire pin for the typed-dtype migration), single-cell read = exactly 2 ranged GETs with tightened span assertions, flat-sharded vs hive-leaf parity, and (review fold) the located channel end-to-end through the sharded slab pass.uv run pytest -q→ 1765 passed, 28 skipped at71213a0(skips pre-existing / optional-dep-gated).ruff check/ruff format --checkclean on every touched file; codespell clean on the changed docs and the touched lines. The phase-3 diff is docs/comment-only insrc/plus the new markdown page, the notebook, and the mkdocs nav — no runtime code changed.477225ffully green including test (3.13). Phase-2 headbf85603running at last check.Post-merge validation
Notes for review
deployment/aws/lambda_handler.pyis touched (phase 1: the_write_chunkragged call + dropped imports, net −13 lines). It is the Lambda application source, routinely edited by prior issue PRs (main red after #170 merge + template hammer edits (test_apriori/test_streaming under the inline default; template pins) #175/Granule-level read concurrency: the worker's serial granule x group loop is the dominant read-wall term #180/Adopt morton decimal shard ids + hive-partitioned output layout (manifest, commit stamp) #199/Write store coverage MOC at end of run (dispatcher-side domain declaration) #200), not one of the §1 infra/deploy scripts.zarr.core.sync.sync(zarr 3 exposes no public sync listing) — one semi-private import, commented at the use site.ruff check srcN818 onsrc/zagg/registry.py(on main too);ruff format --checkdrift on__init__.py/catalog/sources.py/viz/crs.py/test_catalog.py/test_extract.py/test_integration.py(untouched here); codespellgir(grids/rectilinear.py:276-277) andanc(grids/morton.py:153-154) on lines this PR did not touch; and the mypy hook lackstypes-PyYAML(a one-liner in.pre-commit-config.yaml, left for a human call since it edits the hook's dependency list).