Callback-array parallel safety: central view/copy guard, honest exceptions, no dead snapshots (#379 items 3+4b)#381
Conversation
Adversarial review — round 1Two independent reviewers (numpy-subclass semantics; codebase regressions) attacked this branch. The core guard survived everything thrown at it, with a structural reason worth recording: numpy's view base-collapse only walks past bases of matching type, and the canonical array's own base is always a plain ndarray — so the base chain always stops exactly at the canonical. Verified empirically for slice/nested/reshape/transpose/newaxis/dtype-changed views, zero-size slices on empty selections, masked and integer fancy writes (exactly one firing via parent write-back, including when the parent is itself a view), and detached copies (correctly silent — previously these packed the copy's wrong-sized contents, so skipping is strictly better). The deleted factories are confirmed dead (no caller in src/ or tests/), and Findings to fix (this branch)
Recorded, no action
Fixes follow on this branch; the stacked branches will be rebased over them. Underworld development team with AI support from Claude Code |
…e-homeable canonical callbacks (#381 review) Adversarial review round 1 (two independent reviewers) confirmed the base-chain guard sound and the deletions dead, and surfaced six items, all addressed here: 1. A rejected in-place write to mesh.X.coords no longer poisons the coordinate cache: the write lands before the guard fires, so the rejection path now restores the canonical from the DM before the error surfaces — 'rejected' now means rejected. Regression test in test_0540 (previously the module only printed). 2. The delayed-callback executors stop swallowing exceptions, matching the immediate path — 'exceptions propagate' now holds on this branch alone, including through the mesh.access() compatibility route. 3. sync_data's different-size branch re-registers canonical callbacks against the new object instead of copying wrappers bound (by weakref) to the old array's identity — previously every write to the returned array was classified as a foreign copy and silently skipped. 4. remove_callback() can now remove a canonical callback by its original function (the list stores the guarding wrapper; the wrapper records the original as _wrapped, which sync_data's re-homing also uses). 5. The swarm canonical callback resolves its variable through array.owner like the mesh side, honouring the no-cycle rationale the guard documents instead of strongly capturing the variable. 6. Governing docs updated: data-access.md no longer points at the deleted factory; the NDArrays developer note and the patterns guide reflect old_value=None and the canonical registration path. New tests: exception propagation (immediate + delayed), old_value always None, sync_data re-homing, canonical remove_callback, rejected coordinate write leaves mesh.X.coords agreeing with the DM. Quick gate: 409 passed. Underworld development team with AI support from Claude Code
Round 1 — response (fixed in 1f42128)All six findings addressed:
Quick gate: 409 passed, 1 pre-existing xfail. The stacked branches (#382, #383, #385) will be rebased over this; their own round-1 fixes follow on their threads. Underworld development team with AI support from Claude Code |
…nical-storage callbacks (#379) Callbacks written for an array's canonical storage inherit onto every derived array via __array_finalize__, so they fire on views and on temporary fancy-index copies. Firing on a copy is the #376 parallel hang: copy contents are partition-dependent, so a PETSc sync inside the callback runs its collectives on some ranks only. add_canonical_callback() applies the fix once, centrally: a dispatch wrapper resolves view-vs-copy by identity in numpy's base chain (never np.may_share_memory, which is False for zero-size arrays and would re-create the rank asymmetry on empty ranks). Views hand the full canonical array to the callback; copies are skipped because numpy's write-back re-fires through the parent's __setitem__. Tests written first and shown to fail (test_0059, level_1/tier_a). Underworld development team with AI support from Claude Code
…ck; delete dead array factories (#379) The mesh- and swarm-variable canonical callbacks now go through the central view/copy guard instead of firing on whatever derived array triggered them. The mesh callback body moves to MeshVariable._flush_canonical_to_petsc(), the single write-back path (reused by the deferred batch flush planned for the synchronised_array_update rework). _create_variable_array (mesh + swarm) and _create_flat_data_array had no callers and embodied the unguarded pattern — deleted. Structural tests 0500/0510 now assert the real factory, _create_canonical_data_array. swarm.points keeps its legacy callback for now, marked TODO(BUG): the whole writable stack is removed by #379 item 1 (mesh.points treatment) rather than ported. Quick gate (level_1 and tier_a): 405 passed. Underworld development team with AI support from Claude Code
…napshots (#379) Two class-level cleanups to NDArray_With_Callback: 1. The immediate callback path no longer swallows exceptions into logger.warning. A swallowed callback failure leaves PETSc out of sync with the canonical array on this rank only — the silent desynchronisation that hid the #376 parallel hang. The delayed-replay swallows are untouched here; they are removed with the synchronised_array_update rework. 2. Internal operations no longer snapshot prior values into change_info['old_value']. No registered callback ever read the key, and the snapshot was a FULL-array copy on every in-place operation (and a slice copy on every __setitem__) — pure memory/bandwidth waste on large variables. The key remains, always None, for dict-shape compatibility. Underworld development team with AI support from Claude Code
…e-homeable canonical callbacks (#381 review) Adversarial review round 1 (two independent reviewers) confirmed the base-chain guard sound and the deletions dead, and surfaced six items, all addressed here: 1. A rejected in-place write to mesh.X.coords no longer poisons the coordinate cache: the write lands before the guard fires, so the rejection path now restores the canonical from the DM before the error surfaces — 'rejected' now means rejected. Regression test in test_0540 (previously the module only printed). 2. The delayed-callback executors stop swallowing exceptions, matching the immediate path — 'exceptions propagate' now holds on this branch alone, including through the mesh.access() compatibility route. 3. sync_data's different-size branch re-registers canonical callbacks against the new object instead of copying wrappers bound (by weakref) to the old array's identity — previously every write to the returned array was classified as a foreign copy and silently skipped. 4. remove_callback() can now remove a canonical callback by its original function (the list stores the guarding wrapper; the wrapper records the original as _wrapped, which sync_data's re-homing also uses). 5. The swarm canonical callback resolves its variable through array.owner like the mesh side, honouring the no-cycle rationale the guard documents instead of strongly capturing the variable. 6. Governing docs updated: data-access.md no longer points at the deleted factory; the NDArrays developer note and the patterns guide reflect old_value=None and the canonical registration path. New tests: exception propagation (immediate + delayed), old_value always None, sync_data re-homing, canonical remove_callback, rejected coordinate write leaves mesh.X.coords agreeing with the DM. Quick gate: 409 passed. Underworld development team with AI support from Claude Code
…to the central guard The inline guard deleted in the rebase onto development documented one genuinely unresolvable per-write corner (reshape/ravel of a non-contiguous derived view: copy on non-empty ranks, view on zero-size ranks). Preserve it on add_canonical_callback, pointing at the #383 per-variable flush as the real fix. Underworld development team with AI support from Claude Code
1f42128 to
d2cf355
Compare
…ginal error (#381 review round 2) If a deform failure replaced the coordinate Vec at a different size (mid-rebuild, degree>1 coordinate spaces), the rollback copyto would raise a broadcast error inside the except handler and mask the real exception. Restore only when the cache and DM shapes agree; otherwise let the original error surface untouched. Underworld development team with AI support from Claude Code
…cal-phase success before collective flushes (#383 review round 2) Round 2 verified all round-1 fixes closed and found one blocking new defect introduced by the coords canonical registration: _deform_mesh re-wraps the coordinate array after a rebuild and copied the callback list verbatim — the canonical dispatch wrapper is bound (by weakref) to the OLD array's identity, so on the replacement it classified every write as a foreign copy and silently returned. A SECOND mesh.X.coords write did nothing: no deform, no version bump, no error. The re-wrap now re-homes canonical callbacks via their recorded original function (exactly the sync_data re-homing from #381 round 1). Regression test writes coordinates twice and checks the DM. Also hardened (round-2 note): rank-local flush phases (legacy replay, swarm packs) can raise rank-locally; when a collective flush follows, ranks now agree on local-phase success first, so no rank enters the per-variable collectives while another unwinds. Underworld development team with AI support from Claude Code
Round 2 — all round-1 fixes verified closed (plus one diagnostics guard, ca406f1)The verifier confirmed: the coord-rejection restore is genuine (the cache never aliases the live DM buffer, so the rollback is real, shapes verified on bare and variable-carrying meshes, test run on two builds); the delayed-path swallow removal, Underworld development team with AI support from Claude Code |
…cal-phase success before collective flushes (#383 review round 2) Round 2 verified all round-1 fixes closed and found one blocking new defect introduced by the coords canonical registration: _deform_mesh re-wraps the coordinate array after a rebuild and copied the callback list verbatim — the canonical dispatch wrapper is bound (by weakref) to the OLD array's identity, so on the replacement it classified every write as a foreign copy and silently returned. A SECOND mesh.X.coords write did nothing: no deform, no version bump, no error. The re-wrap now re-homes canonical callbacks via their recorded original function (exactly the sync_data re-homing from #381 round 1). Regression test writes coordinates twice and checks the DM. Also hardened (round-2 note): rank-local flush phases (legacy replay, swarm packs) can raise rank-locally; when a collective flush follows, ranks now agree on local-phase success first, so no rank enters the per-variable collectives while another unwinds. Underworld development team with AI support from Claude Code
First of the #379 series (see the issue for the full map). Draft until #378 merges — the two canonical callbacks this PR migrates are the same closures #378 patches inline, so this PR merges second and absorbs a small, mechanical conflict (the inline guard block is deleted here because the guard moves into the class).
What this does
A central guard for canonical-storage callbacks. Callbacks written for a variable's canonical storage are inherited by every array derived from it, so they also fire on views and on temporary fancy-index copies. A copy's contents differ from rank to rank, so a PETSc sync run from inside the callback executes its collective operations on some ranks only — that is the #376 parallel hang. The new
add_canonical_callback()applies the fix once, on the class: views hand the full canonical array to the callback, copies are skipped (numpy's write-back re-fires through the parent), and view-vs-copy is decided by identity in numpy's base chain — nevernp.may_share_memory, which is false for any zero-size array and would re-create the rank asymmetry on ranks with no local data. Any future callback registered this way gets the protection for free instead of re-inheriting the trap.The mesh-variable and swarm-variable canonical callbacks now register through it. The mesh callback body moves to
MeshVariable._flush_canonical_to_petsc(), which becomes the single write-back path (and is reused by the plannedsynchronised_array_updaterework, the next PR in the series).Callback exceptions now propagate. The immediate callback path used to swallow every exception into a
logger.warning. A swallowed failure leaves PETSc out of sync with the canonical array on one rank only — the silent desynchronisation that hid #376. Failures now surface where they happen.Dead weight removed.
_create_variable_array(mesh and swarm) and_create_flat_data_arrayhad no callers and embodied the unguarded pattern — deleted. Every write also used to snapshot prior values intochange_info['old_value'](a full-array copy per in-place operation); nothing ever read it, so the snapshot is gone and the key stays asNonefor compatibility.Not touched here: the
swarm.pointslegacy callback (markedTODO(BUG); removed by the #379 item-1 PR) and the delayed-replay machinery (rebuilt in the item-2 PR).Verification
tests/test_0059_canonical_callback_guard.py(level_1 / tier_a, written first and shown to fail): view/copy classification including the zero-size-view empty-rank case, exactly-once firing for masked+=writes, and a no-reference-cycle check.level_1 and tier_a: 405 passed, 1 pre-existing xfail.Underworld development team with AI support from Claude Code