Skip to content

feat: marker state machine#142

Merged
timl3136 merged 8 commits into
cadence-workflow:mainfrom
timl3136:ltim/marker-1
Jul 21, 2026
Merged

feat: marker state machine#142
timl3136 merged 8 commits into
cadence-workflow:mainfrom
timl3136:ltim/marker-1

Conversation

@timl3136

Copy link
Copy Markdown
Member

What changed?
Implement marker state machine

Why?
Need the state machine for marker feature

How did you test it?
unit tests

Potential risks

Release notes

Documentation Changes

Signed-off-by: Tim Li <ltim@uber.com>
@codecov

codecov Bot commented Jun 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.89051% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...internal/workflow/statemachine/decision_manager.py 88.23% 3 Missing and 3 partials ⚠️
cadence/testing/_workflow_environment.py 50.00% 1 Missing ⚠️
Files with missing lines Coverage Δ
cadence/_internal/workflow/context.py 93.38% <100.00%> (+1.13%) ⬆️
...ce/_internal/workflow/statemachine/cancellation.py 100.00% <100.00%> (ø)
...rnal/workflow/statemachine/marker_state_machine.py 100.00% <100.00%> (ø)
.../_internal/workflow/statemachine/nondeterminism.py 95.16% <100.00%> (+2.94%) ⬆️
cadence/workflow.py 95.51% <100.00%> (+0.04%) ⬆️
cadence/testing/_workflow_environment.py 85.14% <50.00%> (-0.21%) ⬇️
...internal/workflow/statemachine/decision_manager.py 92.41% <88.23%> (-1.38%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread cadence/_internal/workflow/statemachine/decision_manager.py
from cadence.api.v1 import decision, history
from cadence.api.v1.common_pb2 import Payload

MARKER_ID_HEADER_KEY = "__cadence_python_marker_id"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this compare to the MarkerName in RecordMarkerDecisionAttributes?

I'm not super familiar with Markers, but the Go SDK seems to use that as the ID.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked with Go SDK, and I added a permalink for the formatting "%v_%v" in the comments

DecisionId(DecisionType.MARKER, marker_id),
{
"marker_name": attrs.marker_name,
"details": attrs.details,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to store the details in the Expectation, I think we'll have to handle it special in DecisionManager. The Expectation exists only for non-determinism tracking.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I removed details from both handlers.

# on name+id alone. The history-side handler (below) carries 'details' so DecisionManager
# can retrieve the recorded value and return it to the caller on replay.
@to_expectation.register
def _(attrs: decision.RecordMarkerDecisionAttributes) -> Expectation | None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Go SDK (haven't looked at Java) names markers with as %s_%s, where the first parameter is the type of the marker (local activity, side effect, version, etc.) and the second is the contextual identifier for that marker (activity ID, side effect ID, version marker). I think our state machine will unfortunately have to do something similar and behave differently depending on the type. We could even consider a different state machine for each, but LocalActivities is really the only one that's massively different.

Adding a new Version Marker for example is totally fine and we don't want to create an Expectation for either the decision or the event.

Adding a new LocalActivity on the other hand should be flagged on nondeterminism. I think side effects are similar.

For LocalActivities we'll also have to do immediate cancellation and expose a Future and everything, but we can ignore that for now.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I have made those changes.

)

def _validate_expectation(self, actual: Expectation) -> None:
def _validate_expectation(self, actual: Expectation) -> Expectation:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Can you document the new return value?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added comments for checking expectation.

timl3136 and others added 2 commits July 6, 2026 10:45
Comment thread cadence/_internal/workflow/statemachine/marker_state_machine.py Outdated
@gitar-bot

gitar-bot Bot commented Jul 6, 2026

Copy link
Copy Markdown
Code Review 👍 Approved with suggestions 1 resolved / 2 findings

Implements the Marker state machine for tracking decision lifecycles and enforcing replay determinism. Update decode_marker_details to ensure foreign or raw payloads are handled correctly.

💡 Edge Case: Marker detail decoding can misread foreign/raw payloads

📄 cadence/_internal/workflow/statemachine/marker_state_machine.py:38-52 📄 cadence/_internal/workflow/statemachine/decision_manager.py:291-305

decode_marker_details (marker_state_machine.py) treats any Details blob whose first 4 bytes form a length that fits within the payload and whose next ctx_len bytes are valid UTF-8 as an encoded (context_id, user_data) pair. A raw marker Details value produced by another SDK, or a pre-encoding history event, can accidentally satisfy this heuristic, causing a bogus context_id to be extracted and the wrong slice returned as user_data. This then feeds _index_marker_details / _state_machine_for_marker_event in decision_manager.py, potentially returning a truncated/incorrect value to the caller of side_effect on replay instead of falling through to the context_id is None (foreign-SDK) path.

Within a pure Python client this is unlikely because every marker this SDK writes is length-prefixed, so the risk is low; hence minor. If cross-SDK histories are ever replayed, consider adding a distinguishing magic prefix/version byte to the encoding so foreign payloads are reliably rejected rather than heuristically parsed.

✅ 1 resolved
Quality: Marker event routing silently no-ops on unknown ids

📄 cadence/_internal/workflow/statemachine/decision_manager.py:241-248 📄 cadence/_internal/workflow/statemachine/decision_manager.py:273-282
_state_machine_for_marker_event returns None (causing handle_history_event to return early as a no-op) whenever a marker has no SDK id or no matching state machine, whereas _state_machine_for_event raises KeyError for unknown non-marker events. This asymmetry is intentional for the preload phase (markers are processed before workflow code runs), and genuine non-determinism is still caught by the DeterminismTracker (complete_replay reports unmet expectations, since expectations are added from decision_events.output). However, a foreign/internal marker carrying the SDK header but with no corresponding machine during the Phase-4 output pass would be silently dropped without any log. Consider adding a debug log in the machine is None branch so unexpected marker routing is observable, since the silent path can mask wiring issues that aren't covered by the determinism layer (e.g. when running without replay validation).

🤖 Prompt for agents
Code Review: Implements the Marker state machine for tracking decision lifecycles and enforcing replay determinism. Update `decode_marker_details` to ensure foreign or raw payloads are handled correctly.

1. 💡 Edge Case: Marker detail decoding can misread foreign/raw payloads
   Files: cadence/_internal/workflow/statemachine/marker_state_machine.py:38-52, cadence/_internal/workflow/statemachine/decision_manager.py:291-305

   `decode_marker_details` (marker_state_machine.py) treats any Details blob whose first 4 bytes form a length that fits within the payload and whose next `ctx_len` bytes are valid UTF-8 as an encoded `(context_id, user_data)` pair. A raw marker Details value produced by another SDK, or a pre-encoding history event, can accidentally satisfy this heuristic, causing a bogus `context_id` to be extracted and the wrong slice returned as `user_data`. This then feeds `_index_marker_details` / `_state_machine_for_marker_event` in decision_manager.py, potentially returning a truncated/incorrect value to the caller of `side_effect` on replay instead of falling through to the `context_id is None` (foreign-SDK) path.
   
   Within a pure Python client this is unlikely because every marker this SDK writes is length-prefixed, so the risk is low; hence minor. If cross-SDK histories are ever replayed, consider adding a distinguishing magic prefix/version byte to the encoding so foreign payloads are reliably rejected rather than heuristically parsed.

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

Format: 4-byte big-endian length of context_id, then context_id UTF-8, then user_data.
"""
ctx = context_id.encode("utf-8")
return len(ctx).to_bytes(4, "big") + ctx + user_data

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the other SDKs align on this at least being a JSON object, let's do the same rather than introducing a more complex encoding.

I think we can punt that to a subsequent PR as well. For the state machine and non-determinism I don't think we really care what specifically is in the Marker, just the type (which we infer from the name).

When adding LocalActivities or other things we might alter the non-determinism

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, they are now encoded as a json object.

if 4 + ctx_len > len(data):
return None, data
try:
context_id = data[4 : 4 + ctx_len].decode("utf-8")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can parse this from the marker name rather than the data.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we encode them as json object, for decoding part, we can just json.decode to get the context_id and data

LOCAL_ACTIVITY_MARKER_NAME = "LocalActivity"
MUTABLE_SIDE_EFFECT_MARKER_NAME = "MutableSideEffect"

KNOWN_MARKER_NAMES = frozenset(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One additional one is the immediate cancellation marker Cancel_ which is unique to Python.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a cancel bypass in decision_manager.

return None
return Expectation(
DecisionId(DecisionType.MARKER, f"{attrs.marker_name}_{context_id}"),
{"marker_name": attrs.marker_name},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can have an empty expectation body, just the correct DecisionID is enough for now. That covers type and name. When we add LocalActivities and other things with more parameters we might introduce additional checks, but we want to be really specifi with them.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed.

@gitar-bot

gitar-bot Bot commented Jul 21, 2026

Copy link
Copy Markdown
Code Review ✅ Approved 2 resolved / 2 findings

Implements the MarkerStateMachine and side_effect API to ensure deterministic marker recording and replay. Resolved silent no-ops on unknown marker IDs and potential payload decoding errors.

✅ 2 resolved
Quality: Marker event routing silently no-ops on unknown ids

📄 cadence/_internal/workflow/statemachine/decision_manager.py:241-248 📄 cadence/_internal/workflow/statemachine/decision_manager.py:273-282
_state_machine_for_marker_event returns None (causing handle_history_event to return early as a no-op) whenever a marker has no SDK id or no matching state machine, whereas _state_machine_for_event raises KeyError for unknown non-marker events. This asymmetry is intentional for the preload phase (markers are processed before workflow code runs), and genuine non-determinism is still caught by the DeterminismTracker (complete_replay reports unmet expectations, since expectations are added from decision_events.output). However, a foreign/internal marker carrying the SDK header but with no corresponding machine during the Phase-4 output pass would be silently dropped without any log. Consider adding a debug log in the machine is None branch so unexpected marker routing is observable, since the silent path can mask wiring issues that aren't covered by the determinism layer (e.g. when running without replay validation).

Edge Case: Marker detail decoding can misread foreign/raw payloads

📄 cadence/_internal/workflow/statemachine/marker_state_machine.py:38-52 📄 cadence/_internal/workflow/statemachine/decision_manager.py:291-305
decode_marker_details (marker_state_machine.py) treats any Details blob whose first 4 bytes form a length that fits within the payload and whose next ctx_len bytes are valid UTF-8 as an encoded (context_id, user_data) pair. A raw marker Details value produced by another SDK, or a pre-encoding history event, can accidentally satisfy this heuristic, causing a bogus context_id to be extracted and the wrong slice returned as user_data. This then feeds _index_marker_details / _state_machine_for_marker_event in decision_manager.py, potentially returning a truncated/incorrect value to the caller of side_effect on replay instead of falling through to the context_id is None (foreign-SDK) path.

Within a pure Python client this is unlikely because every marker this SDK writes is length-prefixed, so the risk is low; hence minor. If cross-SDK histories are ever replayed, consider adding a distinguishing magic prefix/version byte to the encoding so foreign payloads are reliably rejected rather than heuristically parsed.

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

@timl3136
timl3136 merged commit 817c33c into cadence-workflow:main Jul 21, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants