Skip to content

Commit 7a4d60b

Browse files
Address PR #71 review: error metadata + pairing + bool validation
Four CoPilot threads addressed: 1. CheckpointRecordInvalid (proposal 0029 count-drift raise) now references the LOADED record's invocation_id via `context.resume_invocation or context.invocation_id` — on resume the new run gets a freshly minted id but the error should point at the saved record being validated, not the new run. 2. _step_fan_out_node's CheckpointError branch now dispatches a `completed` event with a NodeException wrapper before re-raising the original CheckpointError. Preserves spec proposal 0012's started/completed pairing contract while keeping NodeEvent.error typed as RuntimeGraphError | None (CheckpointError is sibling- typed). Observers see complete pairing with the original CheckpointError on `__cause__`; the invoke() caller still receives the unwrapped checkpoint error via the bare `raise`. 3. SQLite checkpointer's `result_is_error` deserialization now distinguishes key-absent (pre-0027 records, default False for backward compat) from key-present (strictly validates it's a bool and raises CheckpointRecordInvalid on mismatch). The naive bool() coercion would have misclassified a corrupted record with `"result_is_error": "false"` (string) as True since non-empty strings are truthy. Threads invocation_id through _fan_out_progress_from_dict so the raise points at the correct persisted record. 4. result_present matcher comment + variable rename make the semantic explicit: "result is a non-None value" rather than the fuzzier "field present" framing. Documents the hypothetical `result = None` on a completed-success entry as out of scope — spec frames result as "the durable contribution" and no fixture exercises a None contribution on a completed entry.
1 parent 107236b commit 7a4d60b

4 files changed

Lines changed: 85 additions & 29 deletions

File tree

src/openarmature/checkpoint/backends/sqlite.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,15 @@ def _fan_out_progress_to_dict(fp: FanOutProgress) -> dict[str, Any]:
113113
}
114114

115115

116-
def _fan_out_progress_from_dict(d: dict[str, Any]) -> FanOutProgress:
116+
def _fan_out_progress_from_dict(d: dict[str, Any], invocation_id: str) -> FanOutProgress:
117117
"""Inverse of :func:`_fan_out_progress_to_dict` — rebuild a frozen
118118
:class:`FanOutProgress` from its dict shape, restoring positions
119-
as :class:`NodePosition` instances."""
119+
as :class:`NodePosition` instances.
120+
121+
``invocation_id`` is threaded in so ``CheckpointRecordInvalid``
122+
raises (e.g., a non-bool ``result_is_error`` field) can point at
123+
the correct persisted record.
124+
"""
120125
instances: list[FanOutInstanceProgress] = []
121126
for inst in cast("list[dict[str, Any]]", d["instances"]):
122127
inner_positions = tuple(
@@ -129,16 +134,31 @@ def _fan_out_progress_from_dict(d: dict[str, Any]) -> FanOutProgress:
129134
)
130135
for p in cast("list[dict[str, Any]]", inst.get("completed_inner_positions", []))
131136
)
137+
# Per proposal 0027: distinguish key-absent (pre-0027 records,
138+
# default ``False`` for backward compat) from key-present
139+
# (strictly validate it's a bool). The naive
140+
# ``bool(inst.get("result_is_error", False))`` would coerce
141+
# truthy non-bools (e.g. the string ``"false"``) to ``True``
142+
# and misclassify resume routing — the JSON deserializer can
143+
# in principle land a non-bool here from a corrupted record,
144+
# and silently coercing it would route a success contribution
145+
# through the errors_field bucket on resume.
146+
if "result_is_error" in inst:
147+
raw_rie = inst["result_is_error"]
148+
if not isinstance(raw_rie, bool):
149+
raise CheckpointRecordInvalid(
150+
invocation_id,
151+
f"fan_out_progress instance result_is_error must be bool, "
152+
f"got {type(raw_rie).__name__}: {raw_rie!r}",
153+
)
154+
result_is_error = raw_rie
155+
else:
156+
result_is_error = False
132157
instances.append(
133158
FanOutInstanceProgress(
134159
state=inst["state"],
135160
result=inst.get("result"),
136-
# Pre-0027 records omit ``result_is_error``; the default
137-
# ``False`` (matching the dataclass default) is correct
138-
# for those — pre-0027 was atomic-restart-fan-out resume
139-
# under proposal 0008, no error-record state machine, so
140-
# the field's absence semantically means "not an error."
141-
result_is_error=bool(inst.get("result_is_error", False)),
161+
result_is_error=result_is_error,
142162
completed_inner_positions=inner_positions,
143163
)
144164
)
@@ -381,7 +401,9 @@ def _do() -> tuple[Any, ...] | None:
381401
recorded_serialization,
382402
invocation_id,
383403
)
384-
fan_out_progress = tuple(_fan_out_progress_from_dict(fp) for fp in fan_out_progress_dicts)
404+
fan_out_progress = tuple(
405+
_fan_out_progress_from_dict(fp, invocation_id) for fp in fan_out_progress_dicts
406+
)
385407
return CheckpointRecord(
386408
invocation_id=invocation_id,
387409
correlation_id=correlation_id,

src/openarmature/graph/compiled.py

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1628,17 +1628,35 @@ async def innermost(s: Any) -> Mapping[str, Any]:
16281628
fan_out_config=fan_out_event_config,
16291629
)
16301630
raise
1631-
except CheckpointError:
1632-
# CheckpointError categories (proposal 0029's
1633-
# count-drift raise, the migration-category errors,
1634-
# save-failed) are sibling-typed to RuntimeGraphError
1635-
# and propagate to the invoke() caller unwrapped so
1636-
# callers can branch on ``e.category``. No completed
1637-
# event is dispatched here — the `NodeEvent.error`
1638-
# field is typed as ``RuntimeGraphError | None`` per
1639-
# spec §6 and CheckpointError isn't in that
1640-
# hierarchy. Matches ``_step_function_node``'s
1641-
# CheckpointError branch (also raise-only).
1631+
except CheckpointError as e:
1632+
# Spec proposal 0012's pairing contract requires
1633+
# every started event have a paired completed
1634+
# event. CheckpointError categories (notably
1635+
# proposal 0029's count-drift raise) are sibling-
1636+
# typed to RuntimeGraphError and propagate to the
1637+
# invoke() caller unwrapped so callers can branch
1638+
# on ``e.category``. To preserve pairing while
1639+
# keeping ``NodeEvent.error`` typed as
1640+
# ``RuntimeGraphError | None`` per spec §6, the
1641+
# completed event carries a ``NodeException``
1642+
# wrapper whose ``__cause__`` is the original
1643+
# CheckpointError. The bare ``raise`` re-raises
1644+
# the active exception (the CheckpointError, not
1645+
# the wrapper) so the caller still sees the
1646+
# checkpoint category. Mirrors the ``except
1647+
# Exception`` branch below structurally; the
1648+
# difference is what gets re-raised.
1649+
wrapped = NodeException(node_name=current, cause=e, recoverable_state=s)
1650+
self._dispatch_completed(
1651+
context,
1652+
current,
1653+
namespace,
1654+
step,
1655+
s,
1656+
error=wrapped,
1657+
attempt_index=attempt_index,
1658+
fan_out_config=fan_out_event_config,
1659+
)
16421660
raise
16431661
except Exception as e:
16441662
wrapped = NodeException(node_name=current, cause=e, recoverable_state=s)

src/openarmature/graph/fan_out.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,14 @@ async def run_with_context(
196196
# ``CheckpointSaveFailed`` imports below in this file).
197197
from openarmature.checkpoint.errors import CheckpointRecordInvalid # noqa: PLC0415
198198

199+
# ``context.resume_invocation`` identifies the SAVED record
200+
# being validated (per spec §10.4 step 3); ``context.invocation_id``
201+
# is freshly minted for the resumed run (step 4). The fresh-
202+
# run fallback is defensive only — the count-drift path can
203+
# only fire on resume since fan_out_progress_state is empty
204+
# on a fresh first run.
199205
raise CheckpointRecordInvalid(
200-
context.invocation_id,
206+
context.resume_invocation or context.invocation_id,
201207
f"fan_out {self.name!r} at namespace {context.namespace_prefix!r}: "
202208
f"saved instance_count={exec_state.instance_count} does not match "
203209
f"resolved instance_count={instance_count} on resume",

tests/conformance/test_checkpoint.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -838,16 +838,26 @@ def _assert_fan_out_instance(
838838
f"actual={actual.result_is_error!r}, expected={expected['result_is_error']!r}"
839839
)
840840
if "result_present" in expected:
841-
# Spec §10.11 (proposal 0027): assert the ``result`` field
842-
# exists on the saved record without constraining its shape
843-
# (the value remains impl-defined per §9.5). Pair with
841+
# Spec §10.11 (proposal 0027): assert the captured
842+
# contribution is a non-None value — the fixture's way of
843+
# saying "an entry was recorded" without constraining its
844+
# shape (the value remains impl-defined per §9.5). Pair with
844845
# ``result_is_error: true`` to assert "an error contribution
845-
# was captured" without locking the test to one impl's error
846-
# record format.
847-
result_present_actual = actual.result is not None
848-
assert result_present_actual == expected["result_present"], (
846+
# was captured" portably across implementations whose
847+
# error_record formats differ.
848+
#
849+
# Note on the semantic: ``FanOutInstanceProgress.result`` is
850+
# a dataclass field that always exists as an attribute with
851+
# a None default; "presence" here means "result is a
852+
# non-None value." A hypothetical legitimate ``result =
853+
# None`` on a completed-success entry would be treated as
854+
# "not present" by this check, but spec frames ``result`` as
855+
# "the durable contribution" and no fixture exercises a
856+
# None contribution on a completed entry.
857+
result_has_value = actual.result is not None
858+
assert result_has_value == expected["result_present"], (
849859
f"fan_out_progress[{node_name!r}].instances[{idx}].result_present: "
850-
f"actual={result_present_actual!r}, expected={expected['result_present']!r}"
860+
f"actual={result_has_value!r}, expected={expected['result_present']!r}"
851861
)
852862
if "completed_inner_positions" in expected:
853863
positions_expected = cast("list[Mapping[str, Any]]", expected["completed_inner_positions"])

0 commit comments

Comments
 (0)