Shuffle shard dispatch order before fan-out (issue 197)#204
Conversation
espg
left a comment
There was a problem hiding this comment.
🤖 from Claude (review)
Fresh-context adversarial review of PR #204 (claude/197-dispatch-shuffle, head 01887ae) against issue #197 and CLAUDE.md. Diff is small, terse, and convention-clean; the one substantive problem is whether it achieves the issue's goal on the backend that motivated it.
Findings
- should-fix (correctness vs. issue #197's goal) —
_run_lambda's stable descending granule-count sort (runner.py:1297) largely restores morton/spatial locality: the shuffle survives only within exact-count tie groups, granule counts are spatially autocorrelated, and simulation shows a near-morton in-flight key-span (114 vs 99 morton / 1960 pure-shuffle) under smooth counts — with fully distinct counts the shuffle is a strict no-op. The high-concurrency lambda path is precisely where the S3 503s occur; the local thread-pool path that reliably benefits is not. Options for the maintainer are in the inline comment (coarsen the sort key, drop the exact sort, or accept local-only benefit). The PR body's "Questions for review" flags this but understates it as "tie groups only." - should-fix (test gap) — no test covers the composed lambda dispatch order (shuffle then count-sort); the
_datahelper gives all cells equal counts, the one regime where the shuffle fully survives. See inline comment in tests/test_runner.py. - question-for-author — seed-derivation caveats: resume with a different
max_cellsgets an unrelated order (unavoidable given truncate-first; worth a docstring clause), andRandom(str)+shufflecross-interpreter stability is de facto, not contractual. See inline comment. - nit — runner.py is now 2,412 lines, well past the ~1000-line guidance in CLAUDE.md §4. Pre-existing and not this PR's doing (net +9 lines); flagging per convention rather than asking this PR to split it.
Verified, no action needed: truncate-before-shuffle is genuinely pinned by test_shuffle_after_max_cells_truncation (a shuffle-first implementation fails it under this seed); morton_cell early-return stays unshuffled; empty/single selections are shuffle no-ops; the n_cells caller (runner.py:314) and _dry_run_summary are order-insensitive, so no hidden behavior change there; local random.Random instance avoids perturbing global RNG state; stdlib-only, no new dependency (§4); import placement and commit style match the repo.
Generated by Claude Code
| # subset) so concurrent fan-out doesn't write morton-contiguous -- i.e. | ||
| # byte-prefix-sharing -- S3 keys to one partition (issue #197). Seeded from | ||
| # the selected shard keys, so a rerun or resume sees the same order. | ||
| random.Random(",".join(str(k) for k, _ in pairs)).shuffle(pairs) |
There was a problem hiding this comment.
🤖 from Claude (review)
should-fix — the lambda path largely undoes this shuffle, and lambda is the path issue #197 targets.
_run_lambda re-sorts right after this call (runner.py:1296-1297):
cells = _select_cells(catalog_data, morton_cell=morton_cell, max_cells=max_cells)
if not morton_cell:
cells.sort(key=lambda kv: len(kv[1]), reverse=True)The PR body's claim that the stable sort preserves the shuffle "within equal-count tie groups" is technically correct, but it understates the consequence. Granule counts are spatially autocorrelated — morton-neighboring cells are crossed by nearly the same set of ATL06 granules, so len(kv[1]) varies smoothly across the keyspace. Two regimes follow:
- Counts near-unique: the sort totally determines the order and the shuffle is a no-op. If counts also correlate with key position, the dispatch order is (reversed) spatial order — exactly the pattern the issue calls worst-case.
- Counts smooth/near-monotone with small ties: simulation with
count = i//8 + rand(0,2)over 2,000 cells, shuffle-then-stable-sort: the mean key-span of a 100-wide in-flight window is 114 vs 99 for raw morton order and 1960 for a pure shuffle. That is ~85% of the morton-order locality restored.
The benefit only materializes when tie groups are large and each count value occurs at spatially scattered locations. So the fix reliably helps _run_local (a thread pool at small max_workers, where SlowDown was never the problem) and unreliably helps _run_lambda (~2,000 concurrent workers, the stated motivation). Concrete options to put to the maintainer: (a) coarsen the lambda sort key so tie groups are large and the shuffle survives, e.g. key=lambda kv: len(kv[1]) // B or int(log2(len(kv[1])+1)), descending; (b) drop the exact count sort on lambda — longest-first mainly trims tail latency at run end, worth measuring against the 503 cost; (c) accept local-only benefit and note it on issue #197. As written, the PR's headline claim ("single insertion point covers both backends") does not hold for the backend that matters.
Generated by Claude Code
| # Shuffle after selection/truncation (max_cells keeps its morton-first-N | ||
| # subset) so concurrent fan-out doesn't write morton-contiguous -- i.e. | ||
| # byte-prefix-sharing -- S3 keys to one partition (issue #197). Seeded from | ||
| # the selected shard keys, so a rerun or resume sees the same order. |
There was a problem hiding this comment.
🤖 from Claude (review)
question-for-author — seed stability caveats worth stating explicitly.
I checked the determinism claims; most hold, with two caveats:
- Same catalog + same
max_cells→ same order: holds.ShardMap.shard_keyscomes out ofmortie.morton_coveragedeterministically sorted, so the joined seed string is stable across reruns of the same catalog, andrandom.Random(str)seeding isPYTHONHASHSEED-immune (correct choice overhash()). - Resume with a different
max_cells(e.g. a--max-cells 100smoke run, then the full run into the same store): the seed string differs, so the 100 overlapping cells are visited in an unrelated order. No truncate-first scheme can avoid this (a different-length list permutes differently even under a shared seed), so it is fine — but the docstring's "reruns see the same order" silently assumes identical selection args. Worth one clause saying so. - Cross-version stability:
Random(str)version-2 seeding and Fisher-Yatesshufflehave been stable across CPython releases in practice, but that is de facto, not contractual. If a resume can happen from a different Python minor (CI matrix is 3.12 + 3.13), the order could in principle diverge. Is same-interpreter resume a safe assumption here?
Also a micro-nit: for a CONUS-scale catalog the joined seed string is built once and hashed — O(total key chars), negligible; no action needed.
Generated by Claude Code
| def test_shuffle_is_deterministic(self): | ||
| # Seeded from the selected shard keys (issue #197): same catalog and | ||
| # selection -> same order on a rerun/resume. | ||
| assert _select_cells(self._data(50)) == _select_cells(self._data(50)) | ||
|
|
||
| def test_shuffle_permutes_selection(self): | ||
| # Fan-out order is a permutation of the selection, not morton order. | ||
| pairs = _select_cells(self._data(50)) | ||
| keys = [k for k, _ in pairs] | ||
| assert sorted(keys) == list(range(50)) | ||
| assert keys != list(range(50)) | ||
|
|
||
| def test_shuffle_after_max_cells_truncation(self): | ||
| # max_cells keeps its morton-first-N semantics: truncate, then shuffle. | ||
| pairs = _select_cells(self._data(50), max_cells=10) | ||
| assert sorted(k for k, _ in pairs) == list(range(10)) |
There was a problem hiding this comment.
🤖 from Claude (review)
should-fix (test gap) — nothing pins the order that is actually dispatched on the lambda path.
The three new tests pin _select_cells in isolation, and they do it well:
test_shuffle_after_max_cells_truncationgenuinely pins truncate-before-shuffle: I verified a broken shuffle-first implementation yields keys[2, 3, 4, 19, 21, 28, 39, 40, 41, 42]under this seed, so the assertion fails — good.test_shuffle_is_deterministicwould also pass with a constant seed (random.Random(42)), but a constant seed still satisfies issue Shuffle shard dispatch order before fan-out to spread S3 prefix load #197, so that is acceptable coverage.
What is missing is the composition with _run_lambda's cells.sort(key=lambda kv: len(kv[1]), reverse=True) (runner.py:1297). The _data helper gives every cell exactly one granule — all counts tie — which is the one regime where the shuffle fully survives the sort. A test that builds a catalog with distinct granule counts and asserts the post-sort dispatch order is spread (or at least asserts what the composed order is) would have surfaced the locality-restoration problem flagged on runner.py:932. Suggest either testing at the _run_lambda dry-run level or extracting the lambda ordering (select + sort) into a helper that can be unit-tested directly.
Generated by Claude Code
|
🤖 from Claude Folded the adversarial-review findings (review) in 4bbcbc3:
Local after the fold: Generated by Claude Code |
|
🤖 from Claude Status: complete. Single phase done, all adversarial-review findings folded (4bbcbc3), CI fully green on the folded head (Lint, Tests, Lambda Build, Docs). Applying One process note: CLAUDE.md §2 says a PR in exactly this state should be flipped from draft to ready-for-review by the routine itself, but this run's routine configuration explicitly forbids marking PRs ready ("drafts only"). Per the stop-on-conflict rule I'm leaving it draft rather than guessing — flip it ready at your convenience; everything else about the §2 end-state (phases done, findings folded, CI green, Generated by Claude Code |
Closes #197
What / why
ShardMap.shard_keyscomes out ofmortie.morton_coveragespatially sorted, and_select_cellspreserved that order, sodispatch()fanned out morton-contiguous shards together. Their output S3 keys share long leading byte-prefixes, concentrating the write burst on one S3 partition (~3,500 PUT/s per-partition → 503 SlowDown at high concurrency).Fix, per the issue's "one line plus a test":
_select_cellsnow shuffles the selected(shard_key, granule_urls)pairs with a seeded localrandom.Randombefore returning:_run_local(src/zagg/runner.py:1104) uses the returned order directly._run_lambda(src/zagg/runner.py:1288) re-sorts by granule count descending — that sort is stable, so its throughput heuristic is preserved while equal-count tie groups (previously morton-contiguous runs) are now randomized. Then_cellscounting call inaggonly takeslen(), unaffected.--max-cells Nsemantics preserved. Truncation happens first, so N still selects the first N in morton order (a spatially coherent subset, per the issue note); only the selected set is shuffled.morton_cellpath returns early (single shard) — no shuffle, unchanged.Seed derivation
Seeded from the selected shard keys themselves:
",".join(str(k) for k, _ in pairs). There is no run id available at selection time (the async-channelrun_idat src/zagg/runner.py:1332 is a per-runuuid4, deliberately not stable across reruns), so the selected-key set is the config-identity input: the same catalog + samemax_cellsyields the same seed string, so a rerun or resume sees the identical order.random.Random(str)seeding is deterministic across processes (unlikehash()on strings, whichPYTHONHASHSEEDrandomizes), and a localRandominstance avoids perturbing the global RNG.Phases
_select_cells+ testsHow it was tested
uv run ruff check src/zagg/runner.py tests/test_runner.py— passes (see note below on a pre-existing repo-wide finding)uv run ruff format --check src/zagg/runner.py tests/test_runner.py— 2 files already formatteduv run pytest tests/test_runner.py -v— 155 passeduv run pytest -q— 1554 passed, 28 skipped (credential-gated), in 300sNew tests in
TestSelectCells: same inputs → identical order (rerun determinism); result is a permutation of the selection (keys match exactly, order differs from morton for n=50);max_cells=10of 50 still yields exactly keys 0–9 (truncate-then-shuffle). The two existing order-asserting tests now compare sorted keys.Questions for review
main(not touched here): with the pre-commit-pinned ruff 0.14.10,ruff check src testsfails N818 onsrc/zagg/registry.py:64(UnknownCapability), andruff format --check src testswould reformat 7 files (src/zagg/__init__.py,src/zagg/__main__.py,src/zagg/catalog/sources.py,src/zagg/viz/crs.py,tests/test_catalog.py,tests/test_extract.py,tests/test_integration.py). All reproduce on a cleanorigin/maincheckout; the PR lint bot's selection (--select=E,F,W,I --ignore=E501) passes repo-wide. Left as-is per convention (unrelated pre-existing failures are flagged, not fixed).Generated by Claude Code