fix main: pin hierarchical seam in worker-double tests + template pins (issue #175)#176
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
the |
| "reader": "h5coro", | ||
| "driver": "s3", | ||
| "groups": ["gt1l"], | ||
| "index": {"backend": "hierarchical"}, |
There was a problem hiding this comment.
🤖 from Claude (review)
Non-blocking (coverage): after this pin, streaming worker-integration coverage runs exclusively on the non-default backend.
The pin itself is correct — verified the mechanism locally: an unpinned _config() resolves to InlineIndex (index_from_config, src/zagg/index/__init__.py), whose read_group never touches zagg.processing._read_group and instead raises against the object() h5obj stub (KeyError: 'coordinates' on this config), which the worker counts as a read error → the "(N group reads raised)" suffix from the issue #116 accounting (src/zagg/processing/worker.py). That reproduces exactly the 5 test_streaming failures in the red main run on 260221b. Test artifact, not regression: confirmed.
Two follow-on observations, neither blocking:
- Streaming × inline (the package default since issue Compiled (hidefix) read path is never exercised by default, and its fetch/decode is serial #170) is now covered nowhere.
tests/test_index.pyexercisesprocess_shardunder the inline default with a real_FakeH5stub, but only the pooled path; everyaggregation.streamingworker test here is pinned to hierarchical. The streaming seam is backend-agnostic (buffered.add_read(chunk)consumes whateverindex_backend.read_groupreturns), so risk is low — but one cheap hedge would be a single streaming test that fakes at the backend seam instead (monkeypatchzagg.processing.worker.index_from_configto a stubVirtualIndexyielding the granule DataFrames), proving streaming does not depend on the hierarchical delegation specifics. - Two pin styles now coexist for the same problem: the issue Compiled (hidefix) read path is never exercised by default, and its fetch/decode is serial #170 PR fixed
tests/test_processing.pyby monkeypatchingzagg.processing.worker.index_from_config → HierarchicalIndex(), while this PR pins via the config block. The config pin is arguably stronger (it exercises real resolution + validation), so no change requested — just noting the divergence so a future sweep can align on one idiom.
There was a problem hiding this comment.
🤖 from Claude
Folded in 82296ea: added test_streaming_via_backend_seam_unpinned_config — config left unpinned (inline default) with the backend stubbed at the worker's index_from_config seam, matching the test_processing.py idiom you cite. Exercising the compiled inline decode itself needs real HDF5 fixtures; noted as a follow-up gap in issue #175.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| - s3:PutObject | ||
| - s3:DeleteObject | ||
| Resource: arn:aws:s3:::sliderule-public-cors/* | ||
| Resource: arn:aws:s3:::sliderule-public-cors/zagg-index/* |
There was a problem hiding this comment.
🤖 from Claude (review)
Non-blocking: IAM restore verified as an exact reversal of the hammer broadening — with one operational caveat for the standing question.
Checked against git show 5c9b1e2: that commit changed exactly Get/Put on arn:aws:s3:::sliderule-public-cors/zagg-index/* → Get/Put/Delete on sliderule-public-cors/* (and, in the same commit, RecycleMaxInvocations "8" → "0", which 260221b then set to "1" — consistent with this PR's forensics). This hunk restores the issue #160 scope byte-for-byte. Corroboration for the PR's Questions-for-review item (1):
- nothing in-tree issues
s3:DeleteObject(greppedsrc/,deployment/,docs/— the onlysliderule-public-cors/*grant elsewhere is the benchmark-CICD instance role indocs/deployment/benchmark-cicd.md, a different principal); - the admin copy
deployment/aws/execution_role.yaml:83still carries the scoped ARN, so the two role copies are back in lockstep, andtest_execution_role_grants_zagg_index_storepins both.
The caveat: if the live stack was updated from the broadened template during the hammer runs and Delete was used operationally (e.g. clearing zagg-index manifests between cold-start baselines), merging this narrows the deployed role on the next stack update and that workflow silently loses the permission. That is exactly the PR's open question — leaving it standing for the repo owner; no change requested here.
There was a problem hiding this comment.
🤖 from Claude
Superseded by phase 3 (55ab362): espg confirmed the whole-bucket Get/Put/Delete grant is deliberate; both role copies now carry it in lockstep.
| (template default 8) invocations. Set either to `0`/empty to disable that | ||
| check. The next invocation then starts on a fresh container instead of | ||
| (template default 1 — recycle after every invocation, the cold-every-time | ||
| posture) invocations. Set either to `0`/empty to disable that check. The next invocation then starts on a fresh container instead of |
There was a problem hiding this comment.
🤖 from Claude (review)
Nit: re-wrap this line. The edit joined two previously wrapped lines into one ~120-char source line mid-bullet, while the rest of this paragraph wraps at ~76 columns. Rendered output is unaffected; source-only consistency nit.
Content-wise the claim checks out: _maybe_self_recycle (deployment/aws/lambda_handler.py) reads ZAGG_RECYCLE_MAX_INVOCATIONS via _recycle_limit, and _INVOCATIONS_SERVED increments at handler entry, so gen_limit=1 → generation >= 1 is true after every invocation — recycle-after-every-async-invocation is exactly what a "1" default does, and the sync/failed-mirror carve-outs stated here match tests/test_lambda_handler.py::test_sync_path_never_recycles / test_failed_mirror_skips_recycle.
|
🤖 from Claude Acknowledged — restored your scope in 55ab362 (phase 3): the role statement is back to Get/Put/Delete on One note, once: prefix-scoped statements ( |
| # (espg, PR #176): virtual-index write-back + sidecar reads | ||
| # (zagg-index/*, issue #160) AND worker-written output zarr | ||
| # stores (e.g. zagg-examples/*); Delete covers store overwrite | ||
| # and manifest cleanup. |
There was a problem hiding this comment.
🤖 from Claude (review)
Non-blocking (phase 3): confirm whole-bucket was the intended reading, and note the blast radius.
This supersedes my earlier comment on the phase-2 restore (that hunk is gone; net-vs-main the grant is unchanged and only this comment block moved). Two things for the record:
- Interpretation fork in the directing comment. The authorizing PR comment quotes the prefix-scoped ARN (
arn:aws:s3:::sliderule-public-cors/zagg-index/*) "with delete permissions enabled ... should be put back", but the stated rationale (output zarr arrays as well as the index cache) requires objects outsidezagg-index/, and "put back" most plausibly means the hammer-commit form (5c9b1e2: Get/Put/Delete onsliderule-public-cors/*) — which is what phase 3 keeps. Well-grounded reading; flagging only because the literal one ("zagg-index/*plus Delete") is narrower, and permission breadth deserves an explicit confirm rather than an inferred one. - Blast radius of whole-bucket Delete on the worker role.
sliderule-public-corsis also the Lambda distribution bucket:distribute_zips.shpublishes the layer/function zips under<minor>/and maintainsversions.json, andstand_up.shbootstraps stacks from those objects. With this statement every worker invocation can delete (or overwrite via Put) those distribution artifacts — a buggy output-store path or a hostile payload could take down the standup path for future deploys. If output stores settle on a stable prefix convention (this comment nameszagg-examples/*), a two-resource statement (zagg-index/*+ the output prefix) would keep the deliberate read/write/delete capability without covering the dist zips. Deliberate scope per the directing comment, so nothing blocking — just making the tradeoff explicit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| FilterPattern: >- | ||
| ?"Task timed out" ?"Runtime.OutOfMemory" | ||
| ?"Runtime exited with error" ?"[ERROR]" | ||
| ?"Traceback (most recent call last)" |
There was a problem hiding this comment.
🤖 from Claude (review)
Non-blocking — the Traceback term matches a tolerated warning path, so WorkerErrorCount can fire on invocations that return 200. src/zagg/processing/worker.py:307 logs the per-granule backend hook failure at WARNING with exc_info=True, and the comment right above it says "A failure here never fails the read":
try:
index_backend.finish_granule(h5obj, s3_url)
except Exception:
logger.warning(f" index backend finish_granule failed for {s3_url}", exc_info=True)Under the Lambda text-format runtime a single record's newlines are folded to \r, so this becomes one CloudWatch event that starts [WARNING] but contains Traceback (most recent call last): — the OR filter matches it. That undercuts the "genuine failure signatures only" claim in the resource comment / docs / PR body: a shard that completes successfully (statusCode 200) with a flaky finish_granule still increments ProcessWorkerErrorCount.
Two cheap fixes, either fine: (1) drop exc_info=True there and fold {e!r} into the message (the other two tolerated warnings at worker.py:298/314 already do exactly that, so this also restores consistency); or (2) keep it and soften the "genuine failure signatures only" wording in the docs to "failure signatures (including tolerated per-granule backend-hook tracebacks)". Option (1) is the one that keeps the alarm semantics clean.
Everything else about the pattern checks out: the ?"..." OR-list is AWS's own documented Lambda idiom (?"[ERROR]" ?"Task timed out"), the >- folded scalar parses to a valid single-line pattern (verified: 117 chars, single spaces), quoted "[ERROR]" treats the brackets literally, and the recycle path is genuinely disjoint — handler logs at [INFO] (lambda_handler.py:264) and the clean os._exit(0) yields "Runtime exited without providing a reason", which no term matches.
There was a problem hiding this comment.
🤖 from Claude
Folded in c35d633: the finish_granule warning now inlines the reason (: {e}, the sibling tolerated-warning style) instead of exc_info=True, and tests/test_index.py::test_finish_granule_failure_never_fails_the_read is strengthened to pin the inlined reason + exc_info is None. The "genuine failure signatures only" docs claim now holds.
| terms = re.findall(r'(\??)"([^"]*)"', pattern) | ||
| assert terms, f"unparsed filter pattern: {pattern!r}" | ||
| any_mode = all(q == "?" for q, _ in terms) | ||
| hits = [t in line for _, t in terms] |
There was a problem hiding this comment.
🤖 from Claude (review)
Nit — the evaluator silently mis-models a mixed pattern. For the two patterns the template actually ships (all-? and single required term) this is a faithful model of CloudWatch semantics: quoted-phrase matching is case-sensitive substring over the whole event, uniform ? terms are OR. But if a future edit produced a mixed pattern (some terms ?-prefixed, some not), any_mode goes False and the evaluator quietly demands ALL terms — whereas CloudWatch does not support combining required and optional terms in one pattern at all (the ? terms change meaning / the pattern is not doing what it looks like). Since the guard already asserts parseability, one more line would turn that foot-gun into a loud failure:
qs = {q for q, _ in terms}
assert len(qs) == 1, f"mixed required/optional terms unsupported by CW: {pattern!r}"(Then any_mode = qs == {"?"}.) Verified the rest is load-bearing, not tautological: weakening "Runtime exited with error" to "Runtime exited" flips recycle_lines[1] into a match and the test fails, which is exactly the regression this guards.
There was a problem hiding this comment.
🤖 from Claude
Folded in c35d633: the evaluator now asserts term-flag uniformity (mixed ?/plain terms fail loudly) before choosing any/all mode.
| assert meta["total_obs"] > 0 | ||
| assert highwater["n"] <= 2 # one read per granule (single group) | ||
|
|
||
| def test_streaming_via_backend_seam_unpinned_config(self, monkeypatch): |
There was a problem hiding this comment.
🤖 from Claude (review)
Nit — scope of what this actually covers, vs the PR-body phrasing. The test itself is correct and its comment is honest: PipelineConfig is a plain dataclass (no construction-time validation), so del cfg.data_source["index"] is equivalent to building without the key, and the monkeypatch target is right (worker.py imports index_from_config at module level, line 35). What it adds over the pinned twins is real but narrow: it proves the worker's streaming path resolves the backend only through the index_from_config seam — nothing else KeyErrors on the missing data_source["index"].
But the PR-body fold bullet says it covers "the unpinned-config route", which slightly overstates: the stub intercepts before index_from_config runs, so the inline-default resolution path (ds.get("index") is None → read-plan carve-out → InlineIndex) is never executed here — only tests/test_index_backends.py-style unit coverage (if any) or real-HDF5 fixtures would exercise it, as the in-test comment already concedes. Suggest tightening the PR-body wording to match the comment ("stubs the seam under an unpinned config") so nobody later reads it as inline-decode coverage. No code change needed.
There was a problem hiding this comment.
🤖 from Claude
Agreed — wording tightened in the PR body: the stub intercepts before index_from_config runs, so the test guards the streaming mechanics against seam coupling, not the inline-default resolution itself; compiled-inline integration coverage stays the follow-up gap noted in issue #175.
|
🤖 from Claude (review) Adversarial review of the phase-4 + fold commits ( Nit — every unhandled crash counts 2 (sometimes 3) in logger.error(f"Unhandled exception in Lambda handler: {e}")
logger.exception(e)Two CloudWatch events, both starting Nit — the Verified clean:
One inline finding (the |
…ty guard (issue #175) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 from Claude Re the pass-2 general findings (comment): (a) the crash-path double log ( |
Closes #175 — restores green
test.ymlonmain(red since the issue #170 merge chain; two more failures from the template edits after it), then per review direction materializes the CloudWatch error-signal split the cold-every-time posture requires. Full forensics in #175.Verdict first: the #170 group is a test artifact, not a regression
The six
test_apriori/test_streamingfailures all share one mechanism, and no production behavior changed:monkeypatch.setattr("zagg.processing._read_group", fake)+H5Coro = lambda *a, **k: object(). That is the hierarchical backend's seam:HierarchicalIndex.read_grouplate-binds_processing._read_groupspecifically so monkeypatched fakes keep working (its own docstring,src/zagg/index/hierarchical.py).InlineIndex.read_group(src/zagg/index/inline.py) never callszagg.processing._read_group— it routes through_planned_read_group/_read_group_fullwith a compiledread_fnthat triesbuild_chunk_map(h5obj, …)and degrades toh5obj.readDatasets(…). Against the fakeobject()that raises ('object' object has no attribute 'readDatasets'/'coordinates'), so the worker's per-group handler counts a read error and the issue gain_bias template reads 0 observations where tdigest reads 200k on the same shard (dem_h segment read) #116 accounting correctly reports"No data after filtering (N group reads raised)"."(N group reads raised)"split predates Compiled (hidefix) read path is never exercised by default, and its fetch/decode is serial #170 (issue gain_bias template reads 0 observations where tdigest reads 200k on the same shard (dem_h segment read) #116,src/zagg/processing/worker.py) and is byte-identical on both backends for real callers; the only thing Compiled (hidefix) read path is never exercised by default, and its fetch/decode is serial #170 changed here is which backend an unpinned test config exercises. Corroboration:test_apriori.py::TestWorkerSeam::test_granule_url_passed_when_enabledkept passing, because itschunk_boundariessource keeps the hierarchical default via the Compiled (hidefix) read path is never exercised by default, and its fetch/decode is serial #170 carve-out inindex_from_config. Independently reproduced by the adversarial review pass.Fix: pin
index: {backend: hierarchical}in the seam-testing configs — the same approach PR #173 took for the benchmark-config pins. Zerosrc/behavior change in this group.Phases
tests/test_apriori.py::TestWorkerSeam::test_flag_off_call_signature_unchangedandtests/test_streaming.py::_config()pinindex: {backend: hierarchical}with comments explaining the seam.test_self_recycle_env_defaultsnow pinsRecycleMaxInvocationsdefault"1"— the "1" default is espg's explicit choice (commit260221b): recycle after every invocation = cold-every-time posture. Template description,deployment/aws/lambda_handler.pydocstring, anddocs/deployment/lambda.md(which said "template 8") updated to match. (Phase 2 also narrowed the role's bucket grant; superseded by phase 3.)arn:aws:s3:::sliderule-public-cors/*is deliberate (virtual-index write-back underzagg-index/*and worker-written output zarr stores, e.g.zagg-examples/*). Implemented intemplate.yaml, brought the admin copydeployment/aws/execution_role.yamlinto lockstep (it still had the narrow Virtual index entry point: pluggable chunk-index backends for the read path #160 form), and re-scoped the pinning test →test_execution_role_grants_public_cors_bucket.RecycleMaxInvocations=1, every async invocation self-exits and lands in Lambda's rawErrorsmetric — 100% noise. FourAWS::Logs::MetricFilterresources (both functions' log groups) publish namespacezagg/lambda:Process/ExtractSelfRecycleCount(theZAGG_SELF_RECYCLEline) andProcess/ExtractWorkerErrorCount(genuine signatures only:[ERROR],Traceback (most recent call last),Task timed out,Runtime.OutOfMemory,Runtime exited with error). Patterns verified disjoint with the recycle path: the handler logs the recycle at[INFO]and exits 0, which the runtime reports asRuntime exited without providing a reason— matched by neither error term (a real nonzero exit isRuntime exited with error).docs/deployment/lambda.mdnow says "alarm onWorkerErrorCount, never rawErrors" and warns against asyncOnFailuredestinations under this posture.test_streaming_via_backend_seam_unpinned_config, theindex_from_configstub idiom fromtest_processing.py) guarding that the streaming mechanics don't depend on the config pin — the stub intercepts before backend resolution, so compiled-inline integration coverage remains the follow-up gap noted in main red after #170 merge + template hammer edits (test_apriori/test_streaming under the inline default; template pins) #175; docs line rewrap.finish_granuletolerated-path warning now inlines the reason instead ofexc_info=True(a folded traceback would have trippedWorkerErrorCounton a path that never fails the read — test strengthened to pin no-exc_info); the pattern evaluator rejects mixed?/plain term lists (outside the CloudWatch subset it models).Design note: MetricFilters reference the log groups by name
The
/aws/lambda/<fn>groups are created lazily by Lambda on first invocation. Owning them asAWS::Logs::LogGroupresources would fail stack CREATE/UPDATE with "already exists" on any deployed stack (the adoption landmine). Instead the filters reference the groups by name and are gated on aCreateLogMetricFiltersparameter (default"true"): existing deployments (groups already present) update cleanly; a fresh stack creates with"false", invokes once, then updates with"true"(documented in the parameter description anddocs/deployment/lambda.md).How it was tested
pytest -qlocally on the branch: green excepttest_lambda_build.py::TestFunctionBuild::test_function_build_succeeds, a local-environment artifact only (the build script's pip resolves against a local Python 3.10, sozarr>=3.1.5has no candidate; it passes on CI in both red runs and is untouched here).260221b(currentmain) reproduces exactly the 8 CI failures listed in main red after #170 merge + template hammer edits (test_apriori/test_streaming under the inline default; template pins) #175 plus that same environmental one.ZAGG_SELF_RECYCLEline, clean-exit runtime report,[ERROR]/traceback/timeout/OOM/nonzero-exit signatures); the backend-seam streaming test.ruff checkclean on all changed files;pre-commithooks clean except findings that pre-exist on pristinemain(see below).Questions for review
Was the IAM broadening intentional?Resolved: deliberate per espg's comment; implemented as directed in phase 3. One standing review-bot note (r3532665667): whole-bucket Delete also covers the Lambda distribution artifacts (versions.json, layer zips) in the same bucket — a two-prefix statement (zagg-index/*+zagg-examples/*) would exclude them, if ever desired.ProcessWorkerErrorCount/ExtractWorkerErrorCount) over a shared name + dimension: static dimensions aren't expressible inMetricTransformations(dimension values must come from pattern fields, and these are term filters). Flagging in case a different naming scheme is preferred for dashboards.ruffN818 onsrc/zagg/registry.py::UnknownCapability(present on pristine main; CI lint selects E,F,W,I only);ruff format --checkunder local ruff 0.15.20 flags 7 files the pinned pre-commit ruff v0.14.10 accepts (version skew, also on pristine main); pre-commitcheck-yamlcannot parse CloudFormation!Equalstags (pre-existing); mypytypes-PyYAMLstub missing in the hook env (pre-existing).🤖 Generated with Claude Code