Skip to content

Commit 4120231

Browse files
committed
Preserve Event Group Markers on cancellation commands
1 parent 4a97144 commit 4120231

12 files changed

Lines changed: 501 additions & 133 deletions

CHANGELOG.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ relevant information.
5858
`TEMPORAL_WORKFLOW_TASK_DURATION_WARN_SECONDS` to change the threshold.
5959
* `SignalWorkflowOptions::summary` attaches a single-line summary to a signal sent to another
6060
workflow, which the UI and CLI display alongside the resulting history event.
61-
* Core now supports attaching `EventGroupMarker`s to various workflow commands.
6261

6362
### Changed
6463
* Cancellation errors propagated after workflow cancellation now complete the workflow as cancelled

crates/sdk-core/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ relevant information.
4141
disable the reporting.
4242
* Workers now log a `[TMPRL1104]` warning when a workflow task takes longer than 5 seconds. Set
4343
`TEMPORAL_WORKFLOW_TASK_DURATION_WARN_SECONDS` to change the threshold.
44+
* Core now supports attaching `EventGroupMarker`s to most workflow commands.
4445

4546
### Breaking Changes :boom:
4647
* Activity failures now include the latest heartbeat details atomically instead of force-flushing a
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
//! Event group markers and user metadata attached to a lang command must survive the
2+
//! indirections Core puts between that command and the `Command` it eventually sends to the
3+
//! server. Two of those indirections mean the annotations cannot simply be copied across as they
4+
//! are for a directly translated command: a cancellation is produced by the machine of the
5+
//! command being cancelled, and a patch synthesizes a search attribute upsert of Core's own
6+
//! making.
7+
//!
8+
//! These tests drive a bare worker with hand-built activation completions because lang, not the
9+
//! Rust SDK, is what annotates commands; the Rust SDK has no Event Groups API to express a
10+
//! cancellation carrying markers of its own.
11+
12+
use crate::{
13+
replay::{TestHistoryBuilder, canned_histories, default_act_sched},
14+
test_help::{MockPollCfg, build_mock_pollers, mock_worker, start_timer_cmd},
15+
};
16+
use std::time::Duration;
17+
use temporalio_common::protos::{
18+
coresdk::{
19+
AsJsonPayloadExt,
20+
child_workflow::ChildWorkflowCancellationType,
21+
workflow_commands::{
22+
ActivityCancellationType, CancelChildWorkflowExecution, CancelTimer,
23+
CompleteWorkflowExecution, RequestCancelActivity, ScheduleActivity, SetPatchMarker,
24+
StartChildWorkflowExecution, WorkflowCommand, workflow_command,
25+
},
26+
workflow_completion::{WorkflowActivationCompletion, workflow_activation_completion},
27+
},
28+
temporal::api::{
29+
command::v1::Command,
30+
enums::v1::{CommandType, EventType},
31+
sdk::v1::{
32+
EventGroupMarker, UserMetadata,
33+
event_group_marker::{Label, Variant},
34+
},
35+
},
36+
};
37+
38+
fn plain(cmd: impl Into<workflow_command::Variant>) -> WorkflowCommand {
39+
cmd.into().into()
40+
}
41+
42+
/// Tag a command with a marker and a summary both derived from `group`, so that a single name
43+
/// identifies the annotations expected downstream and both fields are checked to travel together.
44+
fn annotate(cmd: impl Into<workflow_command::Variant>, group: &str) -> WorkflowCommand {
45+
let mut cmd = plain(cmd);
46+
cmd.event_group_markers = vec![EventGroupMarker {
47+
variant: Some(Variant::Label(Label {
48+
id: group.to_string(),
49+
label: Some(group.as_json_payload().unwrap()),
50+
})),
51+
}];
52+
cmd.user_metadata = Some(UserMetadata {
53+
summary: Some(group.as_json_payload().unwrap()),
54+
details: None,
55+
});
56+
cmd
57+
}
58+
59+
#[track_caller]
60+
fn assert_annotated(cmd: &Command, group: &str) {
61+
let expected = annotate(CompleteWorkflowExecution::default(), group);
62+
assert_eq!(cmd.event_group_markers, expected.event_group_markers);
63+
assert_eq!(cmd.user_metadata, expected.user_metadata);
64+
}
65+
66+
fn complete(run_id: String, cmds: Vec<WorkflowCommand>) -> WorkflowActivationCompletion {
67+
WorkflowActivationCompletion {
68+
run_id,
69+
status: Some(workflow_activation_completion::Status::Successful(
70+
cmds.into(),
71+
)),
72+
..Default::default()
73+
}
74+
}
75+
76+
#[rstest::rstest]
77+
#[tokio::test]
78+
async fn cancel_timer_command_is_annotated(#[values(false, true)] lang_annotates_cancel: bool) {
79+
let cancelled_timer_seq = 2;
80+
let t = canned_histories::cancel_timer("1", &cancelled_timer_seq.to_string());
81+
let mut mock_cfg = MockPollCfg::from_hist_builder(t);
82+
let expected_group = if lang_annotates_cancel {
83+
"cancel-group"
84+
} else {
85+
"timer-group"
86+
};
87+
mock_cfg.completion_asserts_from_expectations(|mut asserts| {
88+
asserts.then(|_| {}).then(move |wft| {
89+
assert_eq!(wft.commands[0].command_type(), CommandType::CancelTimer);
90+
assert_annotated(&wft.commands[0], expected_group);
91+
});
92+
});
93+
let mut mock = build_mock_pollers(mock_cfg);
94+
mock.worker_cfg(|wc| wc.max_cached_workflows = 1);
95+
let core = mock_worker(mock);
96+
97+
let act = core.poll_workflow_activation().await.unwrap();
98+
core.complete_workflow_activation(complete(
99+
act.run_id,
100+
vec![
101+
annotate(
102+
start_timer_cmd(cancelled_timer_seq, Duration::from_secs(1)),
103+
"timer-group",
104+
),
105+
plain(start_timer_cmd(1, Duration::from_secs(1))),
106+
],
107+
))
108+
.await
109+
.unwrap();
110+
111+
let cancel = CancelTimer {
112+
seq: cancelled_timer_seq,
113+
};
114+
let cancel = if lang_annotates_cancel {
115+
annotate(cancel, "cancel-group")
116+
} else {
117+
plain(cancel)
118+
};
119+
let act = core.poll_workflow_activation().await.unwrap();
120+
core.complete_workflow_activation(complete(
121+
act.run_id,
122+
vec![cancel, plain(CompleteWorkflowExecution::default())],
123+
))
124+
.await
125+
.unwrap();
126+
}
127+
128+
#[rstest::rstest]
129+
#[tokio::test]
130+
async fn cancel_activity_command_is_annotated(#[values(false, true)] lang_annotates_cancel: bool) {
131+
let activity_seq = 1;
132+
let t = canned_histories::cancel_scheduled_activity_with_activity_task_cancel(
133+
"fake_activity",
134+
"signal",
135+
);
136+
let mut mock_cfg = MockPollCfg::from_hist_builder(t);
137+
let expected_group = if lang_annotates_cancel {
138+
"cancel-group"
139+
} else {
140+
"activity-group"
141+
};
142+
mock_cfg.completion_asserts_from_expectations(|mut asserts| {
143+
asserts.then(|_| {}).then(move |wft| {
144+
assert_eq!(
145+
wft.commands[0].command_type(),
146+
CommandType::RequestCancelActivityTask
147+
);
148+
assert_annotated(&wft.commands[0], expected_group);
149+
});
150+
});
151+
let mut mock = build_mock_pollers(mock_cfg);
152+
mock.worker_cfg(|wc| wc.max_cached_workflows = 1);
153+
let core = mock_worker(mock);
154+
155+
let act = core.poll_workflow_activation().await.unwrap();
156+
core.complete_workflow_activation(complete(
157+
act.run_id,
158+
vec![annotate(
159+
ScheduleActivity {
160+
seq: activity_seq,
161+
activity_id: "fake_activity".to_string(),
162+
cancellation_type: ActivityCancellationType::WaitCancellationCompleted as i32,
163+
..default_act_sched()
164+
},
165+
"activity-group",
166+
)],
167+
))
168+
.await
169+
.unwrap();
170+
171+
let cancel = RequestCancelActivity { seq: activity_seq };
172+
let cancel = if lang_annotates_cancel {
173+
annotate(cancel, "cancel-group")
174+
} else {
175+
plain(cancel)
176+
};
177+
let act = core.poll_workflow_activation().await.unwrap();
178+
core.complete_workflow_activation(complete(act.run_id, vec![cancel]))
179+
.await
180+
.unwrap();
181+
182+
let act = core.poll_workflow_activation().await.unwrap();
183+
core.complete_workflow_activation(complete(
184+
act.run_id,
185+
vec![plain(CompleteWorkflowExecution::default())],
186+
))
187+
.await
188+
.unwrap();
189+
}
190+
191+
/// Cancelling a child is doubly indirect: the child machine asks Core to create a whole other
192+
/// machine for the external cancel, and that machine's command is the one the server sees.
193+
#[rstest::rstest]
194+
#[tokio::test]
195+
async fn cancel_child_workflow_command_is_annotated(
196+
#[values(false, true)] lang_annotates_cancel: bool,
197+
) {
198+
let child_wf_id = "child-1";
199+
let child_seq = 1;
200+
let t = canned_histories::single_child_workflow_try_cancelled(child_wf_id);
201+
let mut mock_cfg = MockPollCfg::from_hist_builder(t);
202+
let expected_group = if lang_annotates_cancel {
203+
"cancel-group"
204+
} else {
205+
"child-group"
206+
};
207+
mock_cfg.completion_asserts_from_expectations(|mut asserts| {
208+
asserts.then(|_| {}).then(move |wft| {
209+
assert_eq!(
210+
wft.commands[0].command_type(),
211+
CommandType::RequestCancelExternalWorkflowExecution
212+
);
213+
assert_annotated(&wft.commands[0], expected_group);
214+
});
215+
});
216+
let mut mock = build_mock_pollers(mock_cfg);
217+
mock.worker_cfg(|wc| wc.max_cached_workflows = 1);
218+
let core = mock_worker(mock);
219+
220+
let act = core.poll_workflow_activation().await.unwrap();
221+
core.complete_workflow_activation(complete(
222+
act.run_id,
223+
vec![annotate(
224+
StartChildWorkflowExecution {
225+
seq: child_seq,
226+
workflow_id: child_wf_id.to_string(),
227+
workflow_type: "child".to_string(),
228+
cancellation_type: ChildWorkflowCancellationType::TryCancel as i32,
229+
..Default::default()
230+
},
231+
"child-group",
232+
)],
233+
))
234+
.await
235+
.unwrap();
236+
237+
let cancel = CancelChildWorkflowExecution {
238+
child_workflow_seq: child_seq,
239+
reason: "because".to_string(),
240+
};
241+
let cancel = if lang_annotates_cancel {
242+
annotate(cancel, "cancel-group")
243+
} else {
244+
plain(cancel)
245+
};
246+
let act = core.poll_workflow_activation().await.unwrap();
247+
core.complete_workflow_activation(complete(act.run_id, vec![cancel]))
248+
.await
249+
.unwrap();
250+
251+
let act = core.poll_workflow_activation().await.unwrap();
252+
core.complete_workflow_activation(complete(
253+
act.run_id,
254+
vec![plain(CompleteWorkflowExecution::default())],
255+
))
256+
.await
257+
.unwrap();
258+
}
259+
260+
/// The `TemporalChangeVersion` upsert exists only to make the patch searchable, so it belongs to
261+
/// the same group as the patch marker rather than to no group at all.
262+
#[tokio::test]
263+
async fn patch_search_attribute_upsert_is_annotated() {
264+
let patch_id = "the-patch";
265+
let mut t = TestHistoryBuilder::default();
266+
t.add_by_type(EventType::WorkflowExecutionStarted);
267+
t.add_full_wf_task();
268+
t.add_has_change_marker(patch_id, false);
269+
t.add_workflow_execution_completed();
270+
271+
let mut mock_cfg = MockPollCfg::from_hist_builder(t);
272+
mock_cfg.completion_asserts_from_expectations(|mut asserts| {
273+
asserts.then(|wft| {
274+
assert_eq!(wft.commands[0].command_type(), CommandType::RecordMarker);
275+
assert_annotated(&wft.commands[0], "patch-group");
276+
assert_eq!(
277+
wft.commands[1].command_type(),
278+
CommandType::UpsertWorkflowSearchAttributes
279+
);
280+
assert_annotated(&wft.commands[1], "patch-group");
281+
});
282+
});
283+
let mut mock = build_mock_pollers(mock_cfg);
284+
mock.worker_cfg(|wc| wc.max_cached_workflows = 1);
285+
let core = mock_worker(mock);
286+
287+
let act = core.poll_workflow_activation().await.unwrap();
288+
core.complete_workflow_activation(complete(
289+
act.run_id,
290+
vec![
291+
annotate(
292+
SetPatchMarker {
293+
patch_id: patch_id.to_string(),
294+
deprecated: false,
295+
},
296+
"patch-group",
297+
),
298+
plain(CompleteWorkflowExecution::default()),
299+
],
300+
))
301+
.await
302+
.unwrap();
303+
}

crates/sdk-core/src/core_tests/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod activity_tasks;
2+
mod event_groups;
23
mod queries;
34
mod replay_flag;
45
mod updates;

0 commit comments

Comments
 (0)