Replace df.break() JSON sentinel with typed NodeError (#148)#229
Conversation
Refactor break propagation from an in-band {"__break__": true} sentinel string to a typed NodeError::Break enum so Rust's ? operator auto-propagates break through compound nodes (THEN/IF/JOIN/RACE/subtree). The loop node is now the sole break catcher, so forgetting to propagate a break is a compile error rather than a silently-ignored value.
- Add NodeError { Break, Failure } + NodeResult; From<String>/<&str> map to Failure so activity/helper errors auto-convert.
- Carry break across the sub-orchestration boundary via a typed SubtreeControl field on SubtreeEnvelope instead of a sentinel in result.
- Preserve in-flight-upgrade safety: parse_subtree_envelope re-raises a legacy {"__break__"} sentinel (written by <=0.2.2 binaries) as NodeError::Break so breaks persisted by an older binary are not silently swallowed after upgrade.
- Document the Break-is-control-flow contract on the execute() entry point.
- Add 10 unit tests for envelope parsing (legacy + typed) and tighten the break-in-join-race E2E to assert the exact break value.
Fixes microsoft#148
There was a problem hiding this comment.
Reviewed with Opus 4.8
One real finding
The legacy fallback re-introduces the exact sentinel-collision the refactor claims to eliminate, for the JOIN/RACE subtree path.
In parse_subtree_envelope:
SubtreeControl::Normal => match parse_legacy_break_sentinel(&envelope.result) {
Some(value) => Err(NodeError::Break(value)),
None => Ok(envelope.result),
},Because control uses #[serde(default)], a new-binary normal branch (which writes control = Normal) is indistinguishable at parse time from an old-binary envelope (no field). So parse_legacy_break_sentinel runs on every normal subtree result, including fresh ones. A JOIN/RACE branch whose legitimate SQL result is shaped like {"__break__": true, "value": ...} would be falsely re-raised as a Break and exit the loop.
Issue #148 explicitly lists "user payloads cannot impersonate control flow" as benefit #2 — this is honored for the top-level/THEN/IF paths but silently reintroduced for subtree branch results. Low probability, but it's the same bug class, and it's a data-driven control-flow hijack rather than a crash.
Concrete fix — make field presence distinguishable instead of defaulting:
struct SubtreeEnvelope {
#[serde(default)]
control: Option<SubtreeControl>, // None = pre-#148 binary
result: String,
results: HashMap<String, String>,
}match envelope.control {
Some(SubtreeControl::Break) => Err(NodeError::Break(envelope.result)),
Some(SubtreeControl::Normal) => Ok(envelope.result), // new binary: NO legacy check
None => match parse_legacy_break_sentinel(&envelope.result) { // only old envelopes
Some(value) => Err(NodeError::Break(value)),
None => Ok(envelope.result),
},
}This keeps in-flight-upgrade safety (old envelopes still decode) while closing the collision for all envelopes written by the new binary. The existing tests would need their control arg adjusted, but the test helper already supports omitting the field for the legacy case.
Minor notes
- Behavior change on resume: an orchestration started under ≤0.2.2 that later hits a top-level break will now fail instead of complete if it resumes under the new binary. This only affects the misuse case the PR intends to fix, but it's worth a one-line mention in the PR's upgrade notes.
- The two status-recording arms (
OkandErr(Break)) inexecute_function_node_with_varsbuild a near-identicalcompletedupdate_node_statuspayload. These could be collapsed by extracting the value first, then having a singlefailedvscompletedbranch. ~12 duplicated lines.
Verdict
Solid, well-motivated refactor that achieves its primary goal with good attention to replay determinism and test coverage. I'd treat the SubtreeControl collision via #[serde(default)] as a should-fix before merge (simple Option change), since leaving it half-undercuts the stated security benefit. Everything else is polish.
Address PR review (pinodeca): make SubtreeEnvelope.control an
Option<SubtreeControl> so a new-binary normal subtree result is never run
through the legacy {"__break__"} sentinel check. Only an absent control field
(None, written by <= v0.2.2 binaries) takes the legacy fallback path;
Some(Normal) passes through untouched. This closes the
payload-impersonates-control-flow collision for JOIN/RACE branches whose
genuine SQL result happens to be sentinel-shaped.
- Make control Option<SubtreeControl>; drop Default/#[default] from the enum.
Wire format is unchanged: Some(Normal) still serializes to "Normal", an
absent field still deserializes (now to None), so replay is byte-identical.
- Collapse the three duplicated node-status-recording arms into one
(status, result) match + a single update_node_status schedule, keeping the
recorded orchestration history identical.
- Add regression test
envelope_new_format_normal_with_sentinel_shaped_result_is_not_reraised.
|
@pinodeca thanks for the careful review — all three points are addressed in 5f2e9a6. Main finding (legacy fallback ran on every normal result). You're right: with
I also dropped Minor — duplicated status-recording arms. Collapsed the three near-identical arms in Minor — resume behavior change. Added an Validation after the change: |
pinodeca
left a comment
There was a problem hiding this comment.
Thanks for addressing the issues raised - and for the fast turnaround!
LGTM - let's merge it!
Summary
Refactors
df.break()propagation from an in-band JSON sentinel string(
{"__break__": true, "value": ...}) to a typedNodeError::Breakenum, so Rust's?operator auto-propagates break through every compound node (THEN / IF / JOIN / RACEand the sub-orchestration boundary). The loop node is now the sole break catcher,
which turns "forgetting to propagate a break" from a recurring silent-bug class into a
compile error.
Fixes #148.
Changes
NodeError { Break, Failure }+NodeResult;From<String>/From<&str>map plain errors toFailureso activity/helper callskeep their
?ergonomics.control: Option<SubtreeControl>field onSubtreeEnvelopeinstead of a sentinelsmuggled in
result. TheOptionmakes an absent field (an envelope written by a<= 0.2.2binary) distinguishable from a new-binarySome(Normal), so the legacysentinel fallback runs only on genuinely old envelopes — a fresh JOIN/RACE branch
result can never impersonate a break.
controlis absent (None),parse_subtree_envelopere-raises a legacy
{"__break__"}sentinel (written by<= 0.2.2binaries) asNodeError::Break, so a break persisted by an older binary is not silently swallowedacross a binary upgrade.
execute()entry pointand in
docs/ARCHITECTURE.md.new-binary
Normalresult shaped like the legacy sentinel is not re-raised as a break);tightened the break-in-join-race E2E to assert the exact break value.
Validation
cargo fmt -p pg_durable -- --check— cleancargo build --features pg17— cleancargo clippy --features pg17— clean (only the pre-existingextract_hostwarning)./scripts/test-unit.sh— 156 passed, 0 failed./scripts/test-e2e-local.sh— 32 passed, 0 failedUpgrade notes
The only behavior change for already-running instances affects a misuse case: an
orchestration started under
<= 0.2.2that hits a top-leveldf.break()(one usedoutside any
df.loop()) previously completed carrying the sentinel value; under the newbinary that uncaught break surfaces as a clear failure ("df.break() was called outside of a
loop"). JOIN/RACE-in-loop breaks persisted by an old binary still unwind correctly via the
legacy-sentinel fallback. No schema migration is required.
Review
Reviewed via a multi-specialist pass (correctness, edge-cases, architecture,
maintainability, testing) plus synthesis. All three must-fix findings are addressed in
this PR; remaining should-fix / consider items were deferred per the synthesis
dispositions.
Notes
A pre-existing, unrelated bug exists where a JOIN/RACE inside a loop running