feat: add should_complete to CompletionConfig - #605
Conversation
862e017 to
c72785b
Compare
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
c72785b to
306c81c
Compare
306c81c to
5483a62
Compare
This comment has been minimized.
This comment has been minimized.
| failed += 1 | ||
| running -= 1 | ||
| in_flight -= 1 | ||
| needs_snapshot_rebuild = True |
There was a problem hiding this comment.
items snapshot never reflects STARTED — is_started/None distinction is unreliable. needs_snapshot_rebuild is set only on the COMPLETED (321) and FAILED (327) cases. It is not set when a branch is first submitted (submit() flips it PENDING → RUNNING, i.e. None → STARTED) nor on the SUSPENDED / SUSPENDED_UNTIL cases (a status change to a started/suspended state). As a result the snapshot passed to the predicate keeps reporting started/suspended branches as status=None until some other branch reaches a terminal state and forces a rebuild.
This directly contradicts the advertised contract:
CompletionItemStatus.is_started— "True when this branch is actively running or suspended."_build_items_snapshotdocstring — "Distinguishes None (not yet scheduled) from STARTED (running or suspended) so predicates can reason about scheduling state."CompletionStatus.itemsdocstring encourages index-based reasoning over per-branch state.
Failure scenario: max_concurrency=2, 4 branches, predicate sum(i.is_started for i in status.items) >= 2. After both branches are submitted (and even after one suspends), the loop re-enters is_complete, but the snapshot still shows every branch as None, so is_started is False for all of them and the predicate never fires on scheduling state — the batch only completes via the all-items path. Success/failure-count predicates are unaffected (those transitions do rebuild), but any predicate inspecting scheduling state gets stale/incorrect data.
Fix: also set needs_snapshot_rebuild = True inside submit() and on the SUSPENDED / SUSPENDED_UNTIL cases (any transition that changes a branch's snapshot status), or rebuild unconditionally before each is_complete evaluation when a predicate is active. Add a test that keys the predicate off is_started to lock this in.
This comment has been minimized.
This comment has been minimized.
5483a62 to
fffee6d
Compare
This comment has been minimized.
This comment has been minimized.
| if custom_predicate_fired: | ||
| completion_reason: CompletionReason = CompletionReason.CUSTOM_COMPLETION | ||
| else: | ||
| completion_reason = self.policy.reason(succeeded, failed) |
There was a problem hiding this comment.
Low severity — reason() re-invokes the predicate with an empty items snapshot on the ORPHANED partial-completion path.
Every other call site that can reach the predicate now threads the per-branch snapshot: is_complete(... items_snapshot), the fallback in _create_result (self.policy.reason(succeeded, failed, fallback_items)), and from_items. This branch is the exception — it calls self.policy.reason(succeeded, failed) with the default items=().
When should_complete is set, the coordinator loop can only leave via is_complete returning True or an ORPHANED break (should_continue is always True while a predicate is active). On the is_complete/all-done exits, custom_predicate_fired is either True (→ short-circuits to CUSTOM_COMPLETION) or the batch is fully complete (reason() returns ALL_COMPLETED before touching the predicate). But on the ORPHANED break with succeeded + failed < total, custom_predicate_fired is False, so reason() runs the predicate branch and calls should_complete(_build_status(..., items=())).
Impact: a quorum-style predicate that indexes status.items (e.g. status.items[0].is_succeeded, as in parallel_with_should_complete.py) receives an empty tuple here. A predicate that doesn't guard for empty items raises IndexError, which escapes execute() on the coordinator thread and can turn otherwise-discarded orphan handling into a crash. The ORPHANED result is discarded upstream, so the reason value itself is immaterial — only the spurious predicate call matters.
Concrete fix — pass the snapshot already in scope, matching the other call sites:
| completion_reason = self.policy.reason(succeeded, failed) | |
| completion_reason = self.policy.reason(succeeded, failed, items_snapshot) |
This comment has been minimized.
This comment has been minimized.
fffee6d to
7ad4242
Compare
7ad4242 to
302330d
Compare
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
302330d to
ed124dd
Compare
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
| min_successful: int | None = None | ||
| tolerated_failure_count: int | None = None | ||
| tolerated_failure_percentage: int | float | None = None | ||
| should_complete: Callable[[CompletionStatus], bool] | None = None |
There was a problem hiding this comment.
Public docstring omits the "not evaluated until a terminal event" contract (low).
The coordinator only calls should_complete after at least one branch reaches a terminal state (is_complete(..., has_terminal_event) in models.py; _build_items_snapshot/has_terminal_event gating in executor.py). This is documented in the internal code comments but not in this user-facing CompletionConfig docstring, which only states the predicate "may be called multiple times per invocation."
Impact: a predicate written against scheduling/clean-state — e.g. lambda s: s.failure_count == 0 or a rule keyed on is_started items — behaves surprisingly. failure_count == 0 fires on the first successful branch (completing the whole batch at 1 item), and a purely is_started-based rule never fires at all (the parent just suspends when all in-flight branches suspend). Users can't reason about this from the public docs.
Fix: add a sentence to the should_complete docstring, e.g. "The predicate is not evaluated until at least one branch reaches a terminal (succeeded/failed) state; predicates that key only on scheduling state (is_started) or on the absence of failures will therefore first fire on the earliest terminal event."
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Codex AI review
Reviewed commit |
Claude AI reviewI reviewed the changes introduced by PR #605 (feat: add AssessmentThe core design is sound and replay determinism is well-preserved:
No confirmed high- or medium-severity correctness, determinism, concurrency, serialization, or API-compatibility defect survived verification. FindingsLow — docstrings overpromise scheduling-state predicates vs. the terminal-event gate Residual test risk
Reviewed commit |
| min_successful: int | None = None | ||
| tolerated_failure_count: int | None = None | ||
| tolerated_failure_percentage: int | float | None = None | ||
| should_complete: Callable[[CompletionStatus], bool] | None = None |
There was a problem hiding this comment.
JS uses shouldComplete: (status) => CompletionDecision with continueBatch() / completeBatch(outcome) factories and CompletionOutcome.SUCCEEDED | FAILED. The FAILED outcome marks the whole batch failed (throwIfError() throws) even with zero item failures for the quorum-can't-be-met case. A bool can't express that.
Is there a reason not to mirror the JS shape with a frozen CompletionDecision dataclass + factories?
| ALL_COMPLETED = "ALL_COMPLETED" | ||
| MIN_SUCCESSFUL_REACHED = "MIN_SUCCESSFUL_REACHED" | ||
| FAILURE_TOLERANCE_EXCEEDED = "FAILURE_TOLERANCE_EXCEEDED" | ||
| CUSTOM_COMPLETION = "CUSTOM_COMPLETION" |
There was a problem hiding this comment.
Single CUSTOM_COMPLETION vs JS has CUSTOM_COMPLETION_SUCCEEDED / CUSTOM_COMPLETION_FAILED. This string is serialized into checkpoints, so it is used. Follows on from the CompletionDecision comment on config.py.
| # replay concurrently and report terminal events quickly from | ||
| # their checkpoints. The predicate is only evaluated after these | ||
| # events arrive, so it sees accurate terminal states. | ||
| has_terminal_event: bool = False |
There was a problem hiding this comment.
The has_terminal_event gate means an always-true predicate runs exactly 1 item, but in JS the same predicate stops the batch before any item runs (explicit hang guard, empty result). So the same customer predicate results in different behavior across SDKs. Is there a reason to diverge here? The CI Codex bot is independently also flagging a related resumed-replay partial-snapshot concern.
| return self.min_successful is not None and succeeded >= self.min_successful | ||
|
|
||
| def reason(self, succeeded: int, failed: int) -> CompletionReason: | ||
| def reason( |
There was a problem hiding this comment.
Python checks all-completed before the predicate → ALL_COMPLETED; JS checks the predicate first → CUSTOM_COMPLETION_* when both are true at once. Is there a reason not to match the JS ordering, since customers may branch on this field?
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class CompletionItemStatus: |
There was a problem hiding this comment.
JS is {index, name?, status?}; this PR drops name (parallel branches can be named) and adds result/error_message, which JS deliberately doesn't expose. Is there a reason for the richer shape? Also the two snapshot builders disagree on error format (str(branch.error) vs item.error.message).
| When should_complete is set the predicate takes full precedence and | ||
| threshold fields are ignored, so threshold validation is skipped. | ||
| """ | ||
| if self.should_complete is not None: |
There was a problem hiding this comment.
JS makes the combo a compile-time error; Python's analog is a ValidationError in __post_init__, not silently ignoring thresholds. Is there a reason not to raise? (This would also make the _validate_for_total skip unnecessary.)
| item.error.message | ||
| if item.status is BatchItemStatus.FAILED | ||
| and item.error is not None | ||
| and hasattr(item.error, "message") |
There was a problem hiding this comment.
hasattr(item.error, "message") can never be False — error is typed ErrorObject | None and ErrorObject is a dataclass where the message attribute always exists. The item.error is not None check is the only guard needed. Please see CONTRIBUTING → Data Structures & Typing: rely on exact type declarations rather than duck typing.
| # Verify predicate observed correct statuses: | ||
| assert len(observed_items) == 4 | ||
| statuses: set = {status for _, status in observed_items} | ||
| from aws_durable_execution_sdk_python.config import BatchItemStatus |
There was a problem hiding this comment.
Inline from aws_durable_execution_sdk_python.config import BatchItemStatus inside the test body — please move to the top of the module with the other imports, per CONTRIBUTING → General style → Google Python Style Guide §3.13 ("imports are always put at the top of the file").
|
|
||
| if completion_reason is None: | ||
| completion_reason = self.policy.reason(succeeded, failed) | ||
| # Fallback: supply items snapshot so quorum predicates work here too. |
There was a problem hiding this comment.
For posterity: replay normally uses the checkpointed completion reason and never re-invokes the predicate, but this fallback and BatchResult.from_items (for checkpoints that carry no recorded reason) re-infer the reason and therefore call the predicate again, against reconstructed items. JS does the same in its legacy-summary fallback, so no action on the re-invocation itself — but note these need updating if the decision-API change is made and we align with JS.
| if self.policy.is_complete( | ||
| succeeded, failed | ||
| ) or not self.policy.should_continue(failed): | ||
| succeeded, failed, items_snapshot, has_terminal_event |
There was a problem hiding this comment.
P1] Restore checkpointed branch state before evaluating the predicate — packages/aws-durable-execution-sdk-python/src/aws_durable_execution_sdk_python/concurrency/executor.py:264
The Codex review raised a legit issue here? When a batch that suspended midway resumes, every branch restarts as PENDING and the coordinator only rediscovers checkpointed outcomes as replayed worker events arrive, in arbitrary thread order. The predicate gate opens on the first terminal event, so should_complete can evaluate a snapshot where an already checkpointed success still shows as STARTED. That means replay arrival order can change the decision, the completion reason, and which results the batch reports.
The fix Codex suggests is to initialize branch state from checkpoints, but this is troublesome. Marking branches COMPLETED up front on the coordinator thread means materializing results outside the worker and child context path, which is more invasive and touches the threading assumptions of the coordinator.
So maybe instead: count the terminal checkpoints before the loop, and defer predicate evaluation until that many terminal events have arrived again. Something like replacing the has_terminal_event bool with terminal_events_seen >= restored_target, where restored_target is computed up front via get_checkpoint_result per executable (cheap, checkpoints are already in memory). The replayed branches still flow through the normal worker path so results materialize correctly, and the first predicate evaluation is guaranteed to see everything the previous invocation knew. On a fresh run restored_target is 0, which also happens to restore the JS zero progress semantics from my other comment, so one rule closes both.
No deadlock risk. The gate only suppresses early completion, scheduling continues, and replayed branches complete near instantly, so the gate always opens and ALL_COMPLETED stays reachable.
Plus the regression test Codex asks for: suspend and resume with two terminal checkpointed branches forced to report again in reverse order, asserting the reason and result set are order independent.
Issue #, if available: #519
Description of changes:
Adds a
should_completeoption to CompletionConfig that lets users write custom logic for when a map or parallel batch should stop early.Today you can only use fixed thresholds (min_successful=3, tolerated_failure_count=2). With this change, you can pass any function that looks at current progress and decides "stop now" or "keep going":
The function receives a CompletionStatus snapshot with counts and per-item statuses, so you can write rules like "stop when branch A succeeds OR both B and C succeed".
Key design decisions -
Testing -
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.