pub struct WorkflowState { /* private fields */ }Expand description
Immutable, ordered workflow values validated at each operation boundary.
use app_kit::workflow::{JsonState, StateChange, WorkflowState};
use serde_json::{Map, Value};
let before = WorkflowState::new(Map::from_iter([("count".into(), Value::from(1))]))?;
let after = before.apply(&StateChange::Increment {
values: vec![("count".into(), Value::from(1))],
}, &JsonState::default())?;
assert_eq!(before.values()["count"], 1);
assert_eq!(after.values()["count"], 2);Changes go through the validated state operations:
use app_kit::workflow::WorkflowState;
let mut state = WorkflowState::default();
state.values.clear();Implementations§
Source§impl WorkflowState
impl WorkflowState
Sourcepub fn serialize(&self) -> Result<Map<String, Value>, AppError>
pub fn serialize(&self) -> Result<Map<String, Value>, AppError>
Encodes ordered state using one snapshot of the global field registry.
Unregistered fields keep their JSON values, including tagged objects. Field encoders must produce objects. The complete encoded state retains the ordinary JSON byte/depth bounds; failure leaves this state unchanged.
use app_kit::workflow::WorkflowState;
use serde_json::{Map, Value};
let values = Map::from_iter([("count".into(), Value::from(2))]);
let state = WorkflowState::new(values.clone())?;
assert_eq!(state.serialize()?, values);§Errors
Returns InvalidInput for a codec error, a field encoder returning a nonobject,
or exceeded JSON bounds; Integration reports registry lock poisoning.
§Panics
Panics if a registered field encoder panics. The registry lock is released before callbacks run.
Examples found in repository?
55fn main() -> Result<(), AppError> {
56 let policy = CountPolicy { maximum: 100 };
57 let initial = WorkflowState::new(Map::from_iter([
58 ("count".into(), json!(1)),
59 ("labels".into(), json!([])),
60 ]))?;
61 let counted = initial.apply(
62 &StateChange::Increment {
63 values: vec![("count".into(), json!(3)), ("count".into(), json!(4))],
64 },
65 &policy,
66 )?;
67 let labeled = counted.apply(
68 &StateChange::Append {
69 values: vec![
70 ("labels".into(), json!("draft")),
71 ("labels".into(), json!("queued")),
72 ],
73 },
74 &policy,
75 )?;
76 assert_eq!(initial.values()["count"], 1);
77 assert_eq!(labeled.values()["count"], 8);
78 assert_eq!(labeled.values()["labels"], json!(["draft", "queued"]));
79
80 // A later invalid entry rejects the complete operation, including count + 1.
81 let rejected = labeled.apply(
82 &StateChange::Increment {
83 values: vec![("count".into(), json!(1)), ("labels".into(), json!(1))],
84 },
85 &policy,
86 );
87 assert!(matches!(rejected, Err(AppError::InvalidInput(_))));
88 assert_eq!(labeled.values()["count"], 8);
89
90 // This registration is process-global. This standalone example owns its
91 // process; an application should choose registrations during its setup.
92 StateSerialization::global().register_field(
93 "labels".into(),
94 |value| Ok::<_, &'static str>(json!({"version": 1, "items": value})),
95 |value| value.get("items").cloned().ok_or("missing labels.items"),
96 )?;
97 let encoded = labeled.serialize()?;
98 assert_eq!(
99 encoded["labels"],
100 json!({"version":1,"items":["draft","queued"]})
101 );
102 let decoded = WorkflowState::deserialize(encoded.clone())?;
103 assert_eq!(decoded, labeled);
104
105 StateSerialization::global().register_tag("example-money".into(), |value| {
106 value.get("minor").cloned().ok_or("missing money.minor")
107 })?;
108 let tagged = WorkflowState::deserialize(Map::from_iter([(
109 "amount".into(),
110 json!({"__burr_serde__":"example-money","minor":120}),
111 )]))?;
112 assert_eq!(tagged.values()["amount"], 120);
113 println!("{}", Value::Object(encoded));
114 Ok(())
115}Sourcepub fn deserialize(values: Map<String, Value>) -> Result<Self, AppError>
pub fn deserialize(values: Map<String, Value>) -> Result<Self, AppError>
Decodes state with one snapshot of global field and tag registrations.
Top-level field decoders take precedence over recursive tagged decoding. Missing and malformed tags fail; handler outputs are not redispatched. Returned state preserves key order and passes the JSON boundary checks.
use app_kit::workflow::WorkflowState;
let values = serde_json::from_str(r#"{"count":2,"ready":true}"#)?;
let state = WorkflowState::deserialize(values)?;
assert_eq!(state.values()["count"], 2);
assert_eq!(state.values()["ready"], true);§Errors
Returns InvalidInput for malformed state, a nonstring or unregistered tag,
a decoder error, or invalid transformed values. Registry lock poisoning is
Integration; field/tag decoder outputs are not recursively redispatched.
§Panics
Panics if a registered field or tag decoder panics. The registry lock is released before callbacks run.
Examples found in repository?
55fn main() -> Result<(), AppError> {
56 let policy = CountPolicy { maximum: 100 };
57 let initial = WorkflowState::new(Map::from_iter([
58 ("count".into(), json!(1)),
59 ("labels".into(), json!([])),
60 ]))?;
61 let counted = initial.apply(
62 &StateChange::Increment {
63 values: vec![("count".into(), json!(3)), ("count".into(), json!(4))],
64 },
65 &policy,
66 )?;
67 let labeled = counted.apply(
68 &StateChange::Append {
69 values: vec![
70 ("labels".into(), json!("draft")),
71 ("labels".into(), json!("queued")),
72 ],
73 },
74 &policy,
75 )?;
76 assert_eq!(initial.values()["count"], 1);
77 assert_eq!(labeled.values()["count"], 8);
78 assert_eq!(labeled.values()["labels"], json!(["draft", "queued"]));
79
80 // A later invalid entry rejects the complete operation, including count + 1.
81 let rejected = labeled.apply(
82 &StateChange::Increment {
83 values: vec![("count".into(), json!(1)), ("labels".into(), json!(1))],
84 },
85 &policy,
86 );
87 assert!(matches!(rejected, Err(AppError::InvalidInput(_))));
88 assert_eq!(labeled.values()["count"], 8);
89
90 // This registration is process-global. This standalone example owns its
91 // process; an application should choose registrations during its setup.
92 StateSerialization::global().register_field(
93 "labels".into(),
94 |value| Ok::<_, &'static str>(json!({"version": 1, "items": value})),
95 |value| value.get("items").cloned().ok_or("missing labels.items"),
96 )?;
97 let encoded = labeled.serialize()?;
98 assert_eq!(
99 encoded["labels"],
100 json!({"version":1,"items":["draft","queued"]})
101 );
102 let decoded = WorkflowState::deserialize(encoded.clone())?;
103 assert_eq!(decoded, labeled);
104
105 StateSerialization::global().register_tag("example-money".into(), |value| {
106 value.get("minor").cloned().ok_or("missing money.minor")
107 })?;
108 let tagged = WorkflowState::deserialize(Map::from_iter([(
109 "amount".into(),
110 json!({"__burr_serde__":"example-money","minor":120}),
111 )]))?;
112 assert_eq!(tagged.values()["amount"], 120);
113 println!("{}", Value::Object(encoded));
114 Ok(())
115}Source§impl WorkflowState
impl WorkflowState
Sourcepub fn new(values: Map<String, Value>) -> Result<Self, AppError>
pub fn new(values: Map<String, Value>) -> Result<Self, AppError>
Validates ordered JSON values and takes ownership of them.
use app_kit::workflow::WorkflowState;
let values = serde_json::from_str(r#"{"first":1,"second":2}"#)?;
let state = WorkflowState::new(values)?;
assert_eq!(state.values().keys().collect::<Vec<_>>(), ["first", "second"]);§Errors
Returns InvalidInput for invalid state keys or exceeded JSON byte, nesting,
collection, or text bounds. Ownership is accepted only after full validation.
Examples found in repository?
37 fn construct_state(&self, data: &Self::Data) -> Result<WorkflowState, AppError> {
38 WorkflowState::new(Map::from_iter([
39 ("count".into(), json!(data.count)),
40 ("labels".into(), json!(data.labels)),
41 ]))
42 }
43
44 fn validate_state(&self, state: &WorkflowState) -> Result<(), AppError> {
45 let data = self.construct_data(state)?;
46 if !(0..=self.maximum).contains(&data.count) {
47 return Err(AppError::InvalidInput(
48 "count is outside its allowed range".into(),
49 ));
50 }
51 Ok(())
52 }
53}
54
55fn main() -> Result<(), AppError> {
56 let policy = CountPolicy { maximum: 100 };
57 let initial = WorkflowState::new(Map::from_iter([
58 ("count".into(), json!(1)),
59 ("labels".into(), json!([])),
60 ]))?;
61 let counted = initial.apply(
62 &StateChange::Increment {
63 values: vec![("count".into(), json!(3)), ("count".into(), json!(4))],
64 },
65 &policy,
66 )?;
67 let labeled = counted.apply(
68 &StateChange::Append {
69 values: vec![
70 ("labels".into(), json!("draft")),
71 ("labels".into(), json!("queued")),
72 ],
73 },
74 &policy,
75 )?;
76 assert_eq!(initial.values()["count"], 1);
77 assert_eq!(labeled.values()["count"], 8);
78 assert_eq!(labeled.values()["labels"], json!(["draft", "queued"]));
79
80 // A later invalid entry rejects the complete operation, including count + 1.
81 let rejected = labeled.apply(
82 &StateChange::Increment {
83 values: vec![("count".into(), json!(1)), ("labels".into(), json!(1))],
84 },
85 &policy,
86 );
87 assert!(matches!(rejected, Err(AppError::InvalidInput(_))));
88 assert_eq!(labeled.values()["count"], 8);
89
90 // This registration is process-global. This standalone example owns its
91 // process; an application should choose registrations during its setup.
92 StateSerialization::global().register_field(
93 "labels".into(),
94 |value| Ok::<_, &'static str>(json!({"version": 1, "items": value})),
95 |value| value.get("items").cloned().ok_or("missing labels.items"),
96 )?;
97 let encoded = labeled.serialize()?;
98 assert_eq!(
99 encoded["labels"],
100 json!({"version":1,"items":["draft","queued"]})
101 );
102 let decoded = WorkflowState::deserialize(encoded.clone())?;
103 assert_eq!(decoded, labeled);
104
105 StateSerialization::global().register_tag("example-money".into(), |value| {
106 value.get("minor").cloned().ok_or("missing money.minor")
107 })?;
108 let tagged = WorkflowState::deserialize(Map::from_iter([(
109 "amount".into(),
110 json!({"__burr_serde__":"example-money","minor":120}),
111 )]))?;
112 assert_eq!(tagged.values()["amount"], 120);
113 println!("{}", Value::Object(encoded));
114 Ok(())
115}More examples
63 async fn create_fork(
64 &self,
65 app: &App<StateStore, Agent>,
66 ) -> Result<(WorkspaceId, RunSelector, RunSelector), Box<dyn Error + Send + Sync>> {
67 let workspace = app
68 .create_workspace(WorkspaceConfig {
69 name: Some("history example".into()),
70 root: std::env::current_dir()?,
71 })
72 .await?;
73 let workflow = workspace.workflow("history-counter".parse()?).await?;
74 let mut source = workflow.prepare(WorkflowState::default()).await?;
75 let completed = source
76 .execution(ExecutionOptions::default())
77 .await?
78 .finish()
79 .await?;
80 if !matches!(completed.status, RunStatus::Succeeded)
81 || completed.state.values()["count"] != 2
82 {
83 return Err(
84 AppError::Integration("source did not complete both increments".into()).into(),
85 );
86 }
87
88 // An action ordinal selects the whole committed boundary containing it;
89 // it is independent of the checkpoint sequence and storage revision.
90 let first = source.checkpoint(CheckpointSelector::Action(0)).await?;
91 if !matches!(first.kind, CheckpointKind::Action)
92 || first.position.as_deref() != Some("first")
93 || first.state.values()["count"] != 1
94 {
95 return Err(
96 AppError::Integration("first action history is inconsistent".into()).into(),
97 );
98 }
99 let source_selector = RunSelector::Exact {
100 id: source.id().clone(),
101 occurrence: first.occurrence.clone(),
102 };
103 let mut fork = workflow
104 .restore(RestoreOptions {
105 source: source_selector.clone(),
106 checkpoint: CheckpointSelector::Action(0),
107 overrides: WorkflowState::new(Map::from_iter([("count".into(), json!(40))]))?,
108 position: ResumePosition::AfterCompleted,
109 fallback: None,
110 })
111 .await?;
112 let parked = fork.snapshot().await?;
113 if !matches!(parked.status, RunStatus::Paused)
114 || parked.checkpoint != 0
115 || parked.state.values()["count"] != 40
116 || !parked.outcomes.is_empty()
117 {
118 return Err(
119 AppError::Integration("restore did not create a parked clean copy".into()).into(),
120 );
121 }
122
123 source.delete(completed.checkpoint).await?;
124 if !matches!(source.snapshot().await, Err(AppError::NotFound(_))) {
125 return Err(
126 AppError::Integration("explicit source deletion did not finish".into()).into(),
127 );
128 }
129 // Deletion does not invalidate the copied state. AfterCompleted skips
130 // the first increment, so only the second changes the override: 40 -> 41.
131 let completed = fork
132 .execution(ExecutionOptions::default())
133 .await?
134 .finish()
135 .await?;
136 if !matches!(completed.status, RunStatus::Succeeded)
137 || completed.state.values()["count"] != 41
138 || completed.last_action_ordinal != Some(1)
139 {
140 return Err(
141 AppError::Integration("fork resumed from the wrong boundary".into()).into(),
142 );
143 }
144 println!("Source count: 2; copied count: 40; completed fork count: 41");
145 Ok((
146 workspace.id().clone(),
147 source_selector,
148 RunSelector::Exact {
149 id: fork.id().clone(),
150 occurrence: completed.occurrence,
151 },
152 ))
153 }780 async fn repeated(
781 &self,
782 app: &App<StateStore, Agent, WorkloadAction>,
783 probe: &WorkloadProbe,
784 ) -> Result<Value, Box<dyn Error + Send + Sync>> {
785 let workspace = app
786 .create_workspace(WorkspaceConfig {
787 name: None,
788 root: self.root.join("workspace"),
789 })
790 .await?;
791 let workflow = workspace.workflow("performance-leaf".parse()?).await?;
792 let started = Instant::now();
793 // Admit one run at a time, then directly poll at most 16 original
794 // executions together. No consumer task is spawned or detached.
795 let mut runs = std::pin::pin!(
796 stream::iter(0..100_u64)
797 .then(|value| {
798 let workflow = &workflow;
799 async move {
800 let started = Instant::now();
801 let run = workflow
802 .prepare(WorkflowState::new(Map::from_iter([(
803 "value".into(),
804 json!(value),
805 )]))?)
806 .await?;
807 Ok::<_, AppError>((value, run, started))
808 }
809 })
810 .map(|prepared| async move {
811 let (value, mut run, started) = prepared?;
812 let snapshot = run
813 .execution(ExecutionOptions::default())
814 .await?
815 .finish()
816 .await?;
817 assert!(matches!(snapshot.status, RunStatus::Succeeded));
818 assert!(snapshot.interactions.is_empty());
819 assert_eq!(snapshot.state.values()["value"], value);
820 assert_eq!(snapshot.state.values()["result"], value + 1);
821 assert_eq!(snapshot.last_action_ordinal, Some(0));
822 assert_eq!(snapshot.outcomes.len(), 1);
823 Ok::<_, AppError>((run.id().clone(), started.elapsed()))
824 })
825 .buffer_unordered(16)
826 );
827 tokio::select! {
828 _ = probe.wait_for(&probe.constructed, 16) => {}
829 result = runs.next() => panic!(
830 "run finished before the first 16 blocked sources: {:?}",
831 result.map(|result| result.map(|_| ())),
832 ),
833 }
834 assert_eq!(probe.active.load(Ordering::SeqCst), 16);
835 assert_eq!(probe.constructed.load(Ordering::SeqCst), 16);
836 probe.release_all();
837 let mut ids = BTreeSet::new();
838 let mut samples = Vec::with_capacity(100);
839 while let Some(result) = runs.next().await {
840 let (id, elapsed) = result?;
841 assert!(ids.insert(id));
842 samples.push(elapsed);
843 }
844 assert_eq!(ids.len(), 100);
845 assert_eq!(probe.constructed.load(Ordering::SeqCst), 100);
846 assert_eq!(probe.completed.load(Ordering::SeqCst), 100);
847 assert_eq!(probe.peak.load(Ordering::SeqCst), 16);
848 probe.assert_idle();
849 let rows = workflow
850 .list_runs(None, NonZeroUsize::new(128).unwrap())
851 .await?;
852 assert_eq!(rows.len(), 100);
853 assert_eq!(
854 rows.iter()
855 .map(|row| row.id.clone())
856 .collect::<BTreeSet<_>>(),
857 ids
858 );
859 assert!(
860 rows.iter()
861 .all(|row| matches!(row.status, RunStatus::Succeeded))
862 );
863 let elapsed = started.elapsed();
864 Ok(
865 json!({"dataset":"repeated100","runs":100,"caller_in_flight_limit":16,"admission":"sequential",
866 "production_active_limit":"unchanged; this measures only the caller's 16-journey envelope",
867 "execution_ns":elapsed.as_nanos(),"runs_per_second":100.0 / elapsed.as_secs_f64(),
868 "run_latency_including_admission":Self::samples(samples),"indefinite_stability_proof":false}),
869 )
870 }Sourcepub fn values(&self) -> &Map<String, Value>
pub fn values(&self) -> &Map<String, Value>
Borrows values in their insertion order.
use app_kit::workflow::WorkflowState;
let state = WorkflowState::default();
assert!(state.values().get("optional_input").is_none());Examples found in repository?
23 fn construct_data(&self, state: &WorkflowState) -> Result<Self::Data, AppError> {
24 let count = state.values().get("count").and_then(Value::as_i64);
25 let labels = state.values().get("labels").and_then(Value::as_array);
26 match (count, labels) {
27 (Some(count), Some(labels)) => Ok(Rc::new(CountData {
28 count,
29 labels: labels.clone(),
30 })),
31 _ => Err(AppError::InvalidInput(
32 "count and labels have invalid types".into(),
33 )),
34 }
35 }
36
37 fn construct_state(&self, data: &Self::Data) -> Result<WorkflowState, AppError> {
38 WorkflowState::new(Map::from_iter([
39 ("count".into(), json!(data.count)),
40 ("labels".into(), json!(data.labels)),
41 ]))
42 }
43
44 fn validate_state(&self, state: &WorkflowState) -> Result<(), AppError> {
45 let data = self.construct_data(state)?;
46 if !(0..=self.maximum).contains(&data.count) {
47 return Err(AppError::InvalidInput(
48 "count is outside its allowed range".into(),
49 ));
50 }
51 Ok(())
52 }
53}
54
55fn main() -> Result<(), AppError> {
56 let policy = CountPolicy { maximum: 100 };
57 let initial = WorkflowState::new(Map::from_iter([
58 ("count".into(), json!(1)),
59 ("labels".into(), json!([])),
60 ]))?;
61 let counted = initial.apply(
62 &StateChange::Increment {
63 values: vec![("count".into(), json!(3)), ("count".into(), json!(4))],
64 },
65 &policy,
66 )?;
67 let labeled = counted.apply(
68 &StateChange::Append {
69 values: vec![
70 ("labels".into(), json!("draft")),
71 ("labels".into(), json!("queued")),
72 ],
73 },
74 &policy,
75 )?;
76 assert_eq!(initial.values()["count"], 1);
77 assert_eq!(labeled.values()["count"], 8);
78 assert_eq!(labeled.values()["labels"], json!(["draft", "queued"]));
79
80 // A later invalid entry rejects the complete operation, including count + 1.
81 let rejected = labeled.apply(
82 &StateChange::Increment {
83 values: vec![("count".into(), json!(1)), ("labels".into(), json!(1))],
84 },
85 &policy,
86 );
87 assert!(matches!(rejected, Err(AppError::InvalidInput(_))));
88 assert_eq!(labeled.values()["count"], 8);
89
90 // This registration is process-global. This standalone example owns its
91 // process; an application should choose registrations during its setup.
92 StateSerialization::global().register_field(
93 "labels".into(),
94 |value| Ok::<_, &'static str>(json!({"version": 1, "items": value})),
95 |value| value.get("items").cloned().ok_or("missing labels.items"),
96 )?;
97 let encoded = labeled.serialize()?;
98 assert_eq!(
99 encoded["labels"],
100 json!({"version":1,"items":["draft","queued"]})
101 );
102 let decoded = WorkflowState::deserialize(encoded.clone())?;
103 assert_eq!(decoded, labeled);
104
105 StateSerialization::global().register_tag("example-money".into(), |value| {
106 value.get("minor").cloned().ok_or("missing money.minor")
107 })?;
108 let tagged = WorkflowState::deserialize(Map::from_iter([(
109 "amount".into(),
110 json!({"__burr_serde__":"example-money","minor":120}),
111 )]))?;
112 assert_eq!(tagged.values()["amount"], 120);
113 println!("{}", Value::Object(encoded));
114 Ok(())
115}More examples
252 fn verify_snapshot(snapshot: &RunSnapshot, actions: u64) {
253 assert!(matches!(snapshot.status, RunStatus::Succeeded));
254 assert!(snapshot.interactions.is_empty());
255 assert_eq!(
256 snapshot.state.values(),
257 &Map::from_iter([("count".into(), json!(actions))])
258 );
259 assert_eq!(snapshot.last_action_ordinal, Some(actions - 1));
260 assert_eq!(snapshot.outcomes.len(), 1);
261 assert_eq!(snapshot.outcomes[0].ordinal, actions - 1);
262 assert!(snapshot.outcomes[0].result.is_empty());
263 }
264
265 async fn history(
266 &self,
267 run: &WorkflowRun<StateStore, Agent, WorkloadAction>,
268 actions: u64,
269 linear: bool,
270 terminal_sequence: u64,
271 ) -> Result<Value, AppError> {
272 let ceiling = actions * 16 + 16;
273 let mut cursor = None;
274 let mut prior_sequence = None;
275 let mut rows = 0_u64;
276 let mut observed_actions = 0_u64;
277 let mut initial = 0;
278 let mut terminal = 0;
279 let mut controls = 0;
280 let mut halts = 0;
281 let mut samples = Vec::new();
282 let started = Instant::now();
283 loop {
284 assert!(
285 samples.len() < ceiling.div_ceil(PAGE_SIZE as u64) as usize + 1,
286 "history page ceiling exhausted without cursor completion"
287 );
288 let page_started = Instant::now();
289 let page = run
290 .history(cursor, NonZeroUsize::new(PAGE_SIZE).unwrap())
291 .await?;
292 samples.push(page_started.elapsed());
293 assert!(page.checkpoints.len() <= PAGE_SIZE);
294 assert!(
295 !page.checkpoints.is_empty() || page.next.is_none(),
296 "history cursor made no progress"
297 );
298 for row in &page.checkpoints {
299 assert_eq!(&row.run, run.id());
300 assert!(prior_sequence.is_none_or(|prior| row.sequence > prior));
301 prior_sequence = Some(row.sequence);
302 rows += 1;
303 assert!(
304 rows <= ceiling,
305 "declared retained-row workload ceiling exceeded"
306 );
307 match row.kind {
308 CheckpointKind::Initial => {
309 initial += 1;
310 assert_eq!(observed_actions, 0);
311 assert_eq!(row.sequence, 0);
312 }
313 CheckpointKind::Action => {
314 assert_eq!(row.outcomes.len(), 1);
315 let outcome = &row.outcomes[0];
316 assert_eq!(outcome.ordinal, observed_actions);
317 assert_eq!(
318 outcome.node,
319 if linear {
320 format!("n{observed_actions:04}")
321 } else {
322 "count".into()
323 }
324 );
325 assert!(outcome.result.is_empty());
326 observed_actions += 1;
327 assert_eq!(row.action_ordinal, Some(observed_actions - 1));
328 }
329 CheckpointKind::Terminal => {
330 terminal += 1;
331 assert!(matches!(row.status, RunStatus::Succeeded));
332 }
333 CheckpointKind::Control => {
334 assert!(row.outcomes.is_empty());
335 controls += 1;
336 if let RunStatus::Halted { node, result } = &row.status {
337 assert!(!linear);
338 assert_eq!(row.sequence, 4);
339 assert_eq!(node, "count");
340 assert_eq!(result, &Some(Map::new()));
341 halts += 1;
342 }
343 }
344 CheckpointKind::Failed { .. } => {
345 panic!("workload history contains a failed action")
346 }
347 }
348 assert_eq!(
349 row.state.values(),
350 &Map::from_iter([("count".into(), json!(observed_actions))])
351 );
352 if !linear && matches!(row.sequence, 4 | 5) {
353 assert!(matches!(row.kind, CheckpointKind::Control));
354 assert_eq!(observed_actions, 1);
355 assert_eq!(row.action_ordinal, Some(0));
356 assert_eq!(row.position.as_deref(), Some("count"));
357 if row.sequence == 5 {
358 assert!(matches!(row.status, RunStatus::Paused));
359 }
360 }
361 }
362 cursor = page.next;
363 // Each complete Checkpoint/WorkflowState is released with this page.
364 if cursor.is_none() {
365 break;
366 }
367 }
368 assert_eq!(initial, 1);
369 assert_eq!(terminal, 1);
370 assert_eq!(observed_actions, actions);
371 assert_eq!(prior_sequence, Some(terminal_sequence));
372 if !linear {
373 assert_eq!(rows, actions * 3 + 4);
374 assert_eq!(terminal_sequence, rows - 1);
375 assert_eq!(controls, actions * 2 + 2);
376 assert_eq!(halts, 1);
377 assert_eq!(samples.len(), (rows as usize).div_ceil(PAGE_SIZE));
378 }
379 let elapsed = started.elapsed();
380 let first = samples.first().copied().unwrap_or_default();
381 let subsequent: Duration = samples.iter().skip(1).copied().sum();
382 Ok(
383 json!({"rows":rows,"action_rows":observed_actions,"pages":samples.len(),
384 "elapsed_ns":elapsed.as_nanos(),"first_page_ns":first.as_nanos(),
385 "subsequent_pages_total_ns":subsequent.as_nanos(),
386 "rows_per_second":rows as f64 / elapsed.as_secs_f64(),"page_latency":Self::samples(samples)}),
387 )
388 }
389
390 async fn seed(
391 &self,
392 app: &App<StateStore, Agent, WorkloadAction>,
393 actions: u64,
394 linear: bool,
395 ) -> Result<Value, Box<dyn Error + Send + Sync>> {
396 let workspace = app
397 .create_workspace(WorkspaceConfig {
398 name: Some("performance consumer".into()),
399 root: self.root.join("workspace"),
400 })
401 .await?;
402 let workflow = workspace.workflow("performance".parse()?).await?;
403 let mut run = workflow.prepare(WorkflowState::default()).await?;
404 let mut execution = run
405 .execution(ExecutionOptions {
406 halt_after: if linear {
407 Vec::new()
408 } else {
409 vec!["count".into()]
410 },
411 ..ExecutionOptions::default()
412 })
413 .await?;
414 let mut samples = Vec::with_capacity(actions as usize);
415 let started = Instant::now();
416 for ordinal in 0..actions {
417 let step_started = Instant::now();
418 let event = execution.step().await?;
419 samples.push(step_started.elapsed());
420 let Some(ExecutionEvent::Committed { outcomes, .. }) = event else {
421 panic!("each serial workload step must commit one action");
422 };
423 assert_eq!(outcomes.len(), 1);
424 assert_eq!(outcomes[0].ordinal, ordinal);
425 assert_eq!(
426 outcomes[0].node,
427 if linear {
428 format!("n{ordinal:04}")
429 } else {
430 "count".into()
431 }
432 );
433 assert!(outcomes[0].result.is_empty());
434 if !linear && ordinal == 0 {
435 // One public halt and explicit resume add two control rows to
436 // Initial + three checkpoints per action + Terminal.
437 let halted = execution.finish().await?;
438 assert!(matches!(&halted.status, RunStatus::Halted { node, result }
439 if node == "count" && result == &Some(Map::new())));
440 assert_eq!(halted.checkpoint, 4);
441 assert_eq!(
442 halted.state.values(),
443 &Map::from_iter([("count".into(), json!(1))])
444 );
445 assert_eq!(halted.last_action_ordinal, Some(0));
446 assert!(halted.outcomes == outcomes);
447 assert!(halted.interactions.is_empty());
448 execution = run.execution(ExecutionOptions::default()).await?;
449 }
450 }
451 let snapshot = execution.finish().await?;
452 Self::verify_snapshot(&snapshot, actions);
453 let execution_elapsed = started.elapsed();
454 let history = self
455 .history(&run, actions, linear, snapshot.checkpoint)
456 .await?;
457 if let Command::SeedHistory { rows } = self.command {
458 assert_eq!(history["rows"], rows);
459 }
460 Ok(
461 json!({"dataset":if linear {"linear"} else {"history"},"actions":actions,
462 "workspace":workspace.id().to_string(),"run":run.id().to_string(),
463 "occurrence":snapshot.occurrence,
464 "expected_state":snapshot.state.values(),"last_action_ordinal":snapshot.last_action_ordinal,
465 "terminal_checkpoint":snapshot.checkpoint,"retained_rows":history["rows"],
466 "execution_ns":execution_elapsed.as_nanos(),
467 "actions_per_second":actions as f64 / execution_elapsed.as_secs_f64(),
468 "step_latency":Self::samples(samples),"history":history}),
469 )
470 }
471
472 async fn reopen(
473 &self,
474 app: &App<StateStore, Agent, WorkloadAction>,
475 manifest: &Value,
476 actions: u64,
477 open_only: bool,
478 ) -> Result<Value, Box<dyn Error + Send + Sync>> {
479 let started = Instant::now();
480 let workspace: WorkspaceId = manifest["workspace"]
481 .as_str()
482 .ok_or("missing workspace ID")?
483 .parse()?;
484 let run_id: RunId = manifest["run"].as_str().ok_or("missing run ID")?.parse()?;
485 let occurrence = manifest["occurrence"]
486 .as_str()
487 .ok_or("missing run occurrence")?
488 .to_owned();
489 let workspace = app.workspace(workspace).await?;
490 let workflow = workspace.workflow("performance".parse()?).await?;
491 let run = workflow
492 .open_run(RunSelector::Exact {
493 id: run_id,
494 occurrence: occurrence.clone(),
495 })
496 .await?;
497 let snapshot = run.snapshot().await?;
498 Self::verify_snapshot(&snapshot, actions);
499 assert_eq!(snapshot.occurrence, occurrence);
500 assert_eq!(json!(snapshot.checkpoint), manifest["terminal_checkpoint"]);
501 assert_eq!(json!(snapshot.state.values()), manifest["expected_state"]);
502 assert_eq!(
503 json!(snapshot.last_action_ordinal),
504 manifest["last_action_ordinal"]
505 );
506 let lookup = started.elapsed();
507 if open_only {
508 return Ok(
509 json!({"dataset":"history","actions":actions,"mode":"open-only",
510 "lookup_and_snapshot_ns":lookup.as_nanos(),
511 "seed_retained_rows":manifest["retained_rows"],"observed_history_rows":null}),
512 );
513 }
514 let history = self
515 .history(&run, actions, false, snapshot.checkpoint)
516 .await?;
517 assert_eq!(history["rows"], manifest["retained_rows"]);
518 Ok(
519 json!({"dataset":"history","actions":actions,"mode":"full-history",
520 "lookup_and_snapshot_ns":lookup.as_nanos(),"history":history}),
521 )
522 }
523
524 fn definitions(&self, actions: u64) -> Result<(Vec<WorkflowDefinition>, usize), AppError> {
525 if matches!(
526 self.command,
527 Command::Linear | Command::SeedHistory { .. } | Command::ReopenHistory { .. }
528 ) {
529 let (definition, bytes) =
530 Self::definition(matches!(self.command, Command::Linear), actions)?;
531 return Ok((vec![definition], bytes));
532 }
533 let leaf = json!({
534 "schema":"app-kit.workflow/v2","id":"performance-leaf","revision":"r1",
535 "state":{"value":0,"result":0},"required":["value","result"],
536 "reducers":{"value":"reject_concurrent","result":"reject_concurrent"},
537 "entry":"work","nodes":[{"id":"work","deadline_ms":NODE_DEADLINE_MS,
538 "step":{"kind":"action","action":"performance-action","revision":"r1"}}],"routes":{}
539 });
540 let documents = match self.command {
541 Command::Ready => {
542 let mut nodes = vec![json!({"id":"fork","step":{"kind":"state","mutations":[]}})];
543 let mut branches = Vec::new();
544 for index in 0..32 {
545 let node = format!("n{index:02}");
546 nodes.push(json!({"id":node,"deadline_ms":NODE_DEADLINE_MS,
547 "step":{"kind":"action","action":"performance-action","revision":"r1"}}));
548 branches.push(json!({"target":{"kind":"node","id":node}}));
549 }
550 vec![json!({
551 "schema":"app-kit.workflow/v2","id":"performance","revision":"r1",
552 "state":{"value":0,"result":0},"required":["value","result"],
553 "reducers":{"value":"reject_concurrent","result":"collect"},
554 "entry":"fork","nodes":nodes,
555 "routes":{"fork":{"mode":"broadcast","branches":branches}}
556 })]
557 }
558 Command::Map | Command::NestedCancel => {
559 let nested = matches!(self.command, Command::NestedCancel);
560 let items = if nested { 2 } else { 4_096 };
561 let mut documents = vec![
562 json!({
563 "schema":"app-kit.workflow/v2","id":"performance","revision":"r1",
564 "state":{"value":0,"result":0},"required":["value","result"],
565 "reducers":{"value":"reject_concurrent","result":"sum_integer"},
566 "entry":"map","nodes":[{"id":"map","deadline_ms":if nested {NODE_DEADLINE_MS} else {WORKLOAD_BUDGET.as_millis() as u64},
567 "step":{"kind":"map","workflow":if nested {"performance-mid"} else {"performance-leaf"},
568 "items":{"kind":"literal","value":(0..items).collect::<Vec<_>>()},
569 "input_key":"value","max_parallel":if nested {2} else {8},
570 "reductions":[{"source":"result","target":"result","reducer":"sum_integer"}]}}],"routes":{}
571 }),
572 leaf,
573 ];
574 if nested {
575 documents.push(json!({
576 "schema":"app-kit.workflow/v2","id":"performance-mid","revision":"r1",
577 "state":{"value":0,"result":0},"required":["value","result"],
578 "reducers":{"value":"reject_concurrent","result":"sum_integer"},
579 "entry":"map","nodes":[{"id":"map","deadline_ms":NODE_DEADLINE_MS,
580 "step":{"kind":"map","workflow":"performance-leaf",
581 "items":{"kind":"literal","value":(0..8).collect::<Vec<_>>()},
582 "input_key":"value","max_parallel":8,
583 "reductions":[{"source":"result","target":"result","reducer":"sum_integer"}]}}],"routes":{}
584 }));
585 }
586 documents
587 }
588 Command::Repeated | Command::DelayedObserver => vec![leaf],
589 Command::Linear | Command::SeedHistory { .. } | Command::ReopenHistory { .. } => {
590 unreachable!()
591 }
592 };
593 let mut bytes = 0;
594 let mut definitions = Vec::with_capacity(documents.len());
595 for document in documents {
596 let source = document.to_string();
597 assert!(source.len() <= 1_048_576);
598 bytes += source.len();
599 definitions.push(WorkflowDefinition::parse(&source)?);
600 }
601 Ok((definitions, bytes))
602 }
603
604 async fn ready(
605 &self,
606 app: &App<StateStore, Agent, WorkloadAction>,
607 probe: &WorkloadProbe,
608 ) -> Result<Value, Box<dyn Error + Send + Sync>> {
609 let workspace = app
610 .create_workspace(WorkspaceConfig {
611 name: None,
612 root: self.root.join("workspace"),
613 })
614 .await?;
615 let workflow = workspace.workflow("performance".parse()?).await?;
616 let mut waves = Vec::with_capacity(32);
617 let mut releases = Vec::with_capacity(32 * 31);
618 let mut barriers = Vec::with_capacity(32);
619 let started = Instant::now();
620 for permutation in 0..32 {
621 probe.assert_idle();
622 let mut run = workflow.prepare(WorkflowState::default()).await?;
623 let observer = workflow
624 .open_run(RunSelector::Latest(run.id().clone()))
625 .await?;
626 let mut execution = run.execution(ExecutionOptions::default()).await?;
627 let Some(ExecutionEvent::Committed { outcomes, .. }) = execution.step().await? else {
628 panic!("the native fork must commit before the ready wave");
629 };
630 assert_eq!(outcomes.len(), 1);
631 assert_eq!(
632 (outcomes[0].node.as_str(), outcomes[0].ordinal),
633 ("fork", 0)
634 );
635 let wave = Instant::now();
636 let mut finish = Box::pin(execution.finish());
637 tokio::select! {
638 _ = probe.wait_for(&probe.constructed, (permutation + 1) * 32) => {}
639 result = &mut finish => panic!("gated ready wave finished before admission: {}", result.is_ok()),
640 }
641 assert_eq!(probe.active.load(Ordering::SeqCst), 32);
642 let order = (0..32)
643 .map(|offset| (permutation + 31 - offset) % 32)
644 .collect::<Vec<_>>();
645 for (released, index) in order.iter().take(31).enumerate() {
646 let releasing = Instant::now();
647 probe.release(*index);
648 tokio::select! {
649 _ = probe.wait_for(&probe.completed, permutation * 32 + released + 1) => {}
650 result = &mut finish => panic!("a partial ready wave committed: {}", result.is_ok()),
651 }
652 releases.push(releasing.elapsed());
653 let visible = observer.snapshot().await?;
654 assert_eq!(
655 visible.state.values()["result"],
656 0,
657 "staged siblings cannot expose a partial barrier"
658 );
659 assert_eq!(visible.last_action_ordinal, Some(0));
660 }
661 let barrier = Instant::now();
662 probe.release(order[31]);
663 let snapshot = finish.await?;
664 barriers.push(barrier.elapsed());
665 waves.push(wave.elapsed());
666 assert!(matches!(snapshot.status, RunStatus::Succeeded));
667 assert!(snapshot.interactions.is_empty());
668 assert_eq!(
669 snapshot.state.values()["result"],
670 json!((1..=32).collect::<Vec<_>>())
671 );
672 assert_eq!(snapshot.last_action_ordinal, Some(32));
673 assert_eq!(snapshot.outcomes.len(), 32);
674 for (index, outcome) in snapshot.outcomes.iter().enumerate() {
675 assert_eq!(outcome.node, format!("n{index:02}"));
676 assert_eq!(outcome.ordinal, index as u64 + 1);
677 assert_eq!(
678 outcome.result,
679 Map::from_iter([("value".into(), json!(index + 1))])
680 );
681 }
682 assert_eq!(std::mem::take(&mut *probe.order.lock().unwrap()), order);
683 assert_eq!(
684 probe.completed.load(Ordering::SeqCst),
685 (permutation + 1) * 32
686 );
687 probe.assert_idle();
688 }
689 assert_eq!(probe.peak.load(Ordering::SeqCst), 32);
690 let elapsed = started.elapsed();
691 Ok(
692 json!({"dataset":"ready32","permutations":32,"ready_actions_per_wave":32,
693 "caller_action_commits":1024,"native_fork_commits":32,
694 "execution_ns":elapsed.as_nanos(),"caller_actions_per_second":1024.0 / elapsed.as_secs_f64(),
695 "wave_latency_including_snapshot_probes":Self::samples(waves),
696 "release_to_source_completion_latency":Self::samples(releases),
697 "last_release_to_committed_barrier_latency":Self::samples(barriers)}),
698 )
699 }
700
701 async fn mapped(
702 &self,
703 app: &App<StateStore, Agent, WorkloadAction>,
704 probe: &WorkloadProbe,
705 ) -> Result<Value, Box<dyn Error + Send + Sync>> {
706 let workspace = app
707 .create_workspace(WorkspaceConfig {
708 name: None,
709 root: self.root.join("workspace"),
710 })
711 .await?;
712 let workflow = workspace.workflow("performance".parse()?).await?;
713 let mut run = workflow.prepare(WorkflowState::default()).await?;
714 let observer = workflow
715 .open_run(RunSelector::Latest(run.id().clone()))
716 .await?;
717 let started = Instant::now();
718 let mut finish = Box::pin(run.execution(ExecutionOptions::default()).await?.finish());
719 tokio::select! {
720 _ = probe.wait_for(&probe.constructed, 8) => {}
721 result = &mut finish => panic!("map finished before its first eight blocked sources: {}", result.is_ok()),
722 }
723 assert_eq!(probe.active.load(Ordering::SeqCst), 8);
724 assert_eq!(probe.constructed.load(Ordering::SeqCst), 8);
725 let unchanged = observer.snapshot().await?;
726 assert_eq!(unchanged.state.values()["result"], 0);
727 assert!(unchanged.last_action_ordinal.is_none());
728 probe.release_all();
729 let snapshot = finish.await?;
730 let elapsed = started.elapsed();
731 assert!(matches!(snapshot.status, RunStatus::Succeeded));
732 assert!(snapshot.interactions.is_empty());
733 assert_eq!(snapshot.state.values()["result"], 4_096_u64 * 4_097 / 2);
734 assert_eq!(snapshot.last_action_ordinal, Some(0));
735 assert_eq!(snapshot.outcomes.len(), 1);
736 assert_eq!(snapshot.outcomes[0].node, "map");
737 assert_eq!(probe.constructed.load(Ordering::SeqCst), 4_096);
738 assert_eq!(probe.completed.load(Ordering::SeqCst), 4_096);
739 assert_eq!(probe.peak.load(Ordering::SeqCst), 8);
740 probe.assert_idle();
741 let child = workspace.workflow("performance-leaf".parse()?).await?;
742 let mut cursor = None;
743 let mut values = BTreeSet::new();
744 let verifying = Instant::now();
745 loop {
746 let page = child
747 .list_runs(cursor, NonZeroUsize::new(PAGE_SIZE).unwrap())
748 .await?;
749 assert!(page.len() <= PAGE_SIZE);
750 if page.is_empty() {
751 break;
752 }
753 for row in &page {
754 assert!(matches!(row.status, RunStatus::Succeeded));
755 let value = row.state.values()["value"].as_u64().unwrap();
756 assert!(
757 value < 4_096 && values.insert(value),
758 "every mapped input has exactly one retained child"
759 );
760 assert_eq!(row.state.values()["result"], value + 1);
761 assert_eq!(row.last_action_ordinal, Some(0));
762 assert_eq!(row.outcomes.len(), 1);
763 assert_eq!(row.outcomes[0].node, "work");
764 }
765 let last = page.last().unwrap();
766 cursor = Some(RunSelector::Exact {
767 id: last.id.clone(),
768 occurrence: last.occurrence.clone(),
769 });
770 assert!(values.len() <= 4_096);
771 }
772 assert_eq!(values.len(), 4_096);
773 Ok(json!({"dataset":"map4096","items":4096,"max_parallel":8,
774 "parent_node_deadline_ms":WORKLOAD_BUDGET.as_millis(),
775 "execution_ns":elapsed.as_nanos(),"child_actions_per_second":4096.0 / elapsed.as_secs_f64(),
776 "map_completion_latency":Self::samples(vec![elapsed]),
777 "child_inventory_verification_ns":verifying.elapsed().as_nanos(),"verified_children":values.len()}))
778 }
779
780 async fn repeated(
781 &self,
782 app: &App<StateStore, Agent, WorkloadAction>,
783 probe: &WorkloadProbe,
784 ) -> Result<Value, Box<dyn Error + Send + Sync>> {
785 let workspace = app
786 .create_workspace(WorkspaceConfig {
787 name: None,
788 root: self.root.join("workspace"),
789 })
790 .await?;
791 let workflow = workspace.workflow("performance-leaf".parse()?).await?;
792 let started = Instant::now();
793 // Admit one run at a time, then directly poll at most 16 original
794 // executions together. No consumer task is spawned or detached.
795 let mut runs = std::pin::pin!(
796 stream::iter(0..100_u64)
797 .then(|value| {
798 let workflow = &workflow;
799 async move {
800 let started = Instant::now();
801 let run = workflow
802 .prepare(WorkflowState::new(Map::from_iter([(
803 "value".into(),
804 json!(value),
805 )]))?)
806 .await?;
807 Ok::<_, AppError>((value, run, started))
808 }
809 })
810 .map(|prepared| async move {
811 let (value, mut run, started) = prepared?;
812 let snapshot = run
813 .execution(ExecutionOptions::default())
814 .await?
815 .finish()
816 .await?;
817 assert!(matches!(snapshot.status, RunStatus::Succeeded));
818 assert!(snapshot.interactions.is_empty());
819 assert_eq!(snapshot.state.values()["value"], value);
820 assert_eq!(snapshot.state.values()["result"], value + 1);
821 assert_eq!(snapshot.last_action_ordinal, Some(0));
822 assert_eq!(snapshot.outcomes.len(), 1);
823 Ok::<_, AppError>((run.id().clone(), started.elapsed()))
824 })
825 .buffer_unordered(16)
826 );
827 tokio::select! {
828 _ = probe.wait_for(&probe.constructed, 16) => {}
829 result = runs.next() => panic!(
830 "run finished before the first 16 blocked sources: {:?}",
831 result.map(|result| result.map(|_| ())),
832 ),
833 }
834 assert_eq!(probe.active.load(Ordering::SeqCst), 16);
835 assert_eq!(probe.constructed.load(Ordering::SeqCst), 16);
836 probe.release_all();
837 let mut ids = BTreeSet::new();
838 let mut samples = Vec::with_capacity(100);
839 while let Some(result) = runs.next().await {
840 let (id, elapsed) = result?;
841 assert!(ids.insert(id));
842 samples.push(elapsed);
843 }
844 assert_eq!(ids.len(), 100);
845 assert_eq!(probe.constructed.load(Ordering::SeqCst), 100);
846 assert_eq!(probe.completed.load(Ordering::SeqCst), 100);
847 assert_eq!(probe.peak.load(Ordering::SeqCst), 16);
848 probe.assert_idle();
849 let rows = workflow
850 .list_runs(None, NonZeroUsize::new(128).unwrap())
851 .await?;
852 assert_eq!(rows.len(), 100);
853 assert_eq!(
854 rows.iter()
855 .map(|row| row.id.clone())
856 .collect::<BTreeSet<_>>(),
857 ids
858 );
859 assert!(
860 rows.iter()
861 .all(|row| matches!(row.status, RunStatus::Succeeded))
862 );
863 let elapsed = started.elapsed();
864 Ok(
865 json!({"dataset":"repeated100","runs":100,"caller_in_flight_limit":16,"admission":"sequential",
866 "production_active_limit":"unchanged; this measures only the caller's 16-journey envelope",
867 "execution_ns":elapsed.as_nanos(),"runs_per_second":100.0 / elapsed.as_secs_f64(),
868 "run_latency_including_admission":Self::samples(samples),"indefinite_stability_proof":false}),
869 )
870 }
871
872 async fn nested_cancel(
873 &self,
874 app: &App<StateStore, Agent, WorkloadAction>,
875 probe: &WorkloadProbe,
876 ) -> Result<Value, Box<dyn Error + Send + Sync>> {
877 let workspace = app
878 .create_workspace(WorkspaceConfig {
879 name: None,
880 root: self.root.join("workspace"),
881 })
882 .await?;
883 let workflow = workspace.workflow("performance".parse()?).await?;
884 let run = workflow.start(ExecutionOptions::default()).await?;
885 tokio::select! {
886 _ = probe.wait_for(&probe.constructed, 16) => {}
887 result = run.wait() => panic!("nested run ended before its held leaf sources: {}", result.is_ok()),
888 }
889 assert_eq!(probe.active.load(Ordering::SeqCst), 16);
890 let cancelling = Instant::now();
891 let snapshot = tokio::time::timeout(Duration::from_secs(30), async {
892 loop {
893 let before = run.snapshot().await?;
894 assert_eq!(before.state.values()["result"], 0);
895 assert!(before.outcomes.is_empty() && before.last_action_ordinal.is_none());
896 match run.cancel(before.checkpoint).await {
897 Ok(_) => break,
898 Err(AppError::Conflict(_)) => tokio::task::yield_now().await,
899 Err(error) => return Err(error),
900 }
901 }
902 run.wait().await
903 })
904 .await
905 .map_err(|_| {
906 AppError::Timeout("nested cancellation exceeded its declared 30-second grace".into())
907 })??;
908 let elapsed = cancelling.elapsed();
909 assert!(matches!(snapshot.status, RunStatus::Cancelled));
910 assert!(snapshot.interactions.is_empty() && snapshot.outcomes.is_empty());
911 assert!(snapshot.last_action_ordinal.is_none());
912 assert_eq!(snapshot.state.values()["result"], 0);
913 assert_eq!(probe.constructed.load(Ordering::SeqCst), 16);
914 assert_eq!(probe.completed.load(Ordering::SeqCst), 0);
915 probe.assert_idle();
916 for (id, count) in [
917 ("performance", 1),
918 ("performance-mid", 2),
919 ("performance-leaf", 16),
920 ] {
921 let child = workspace.workflow(id.parse()?).await?;
922 let rows = child
923 .list_runs(None, NonZeroUsize::new(128).unwrap())
924 .await?;
925 assert_eq!(rows.len(), count);
926 for row in rows {
927 assert!(matches!(row.status, RunStatus::Cancelled));
928 assert!(row.outcomes.is_empty() && row.last_action_ordinal.is_none());
929 assert_eq!(row.state.values()["result"], 0);
930 }
931 }
932 Ok(
933 json!({"dataset":"nested-cancel","depth":2,"intermediate_children":2,"leaf_children":16,
934 "cooperative_cancel_grace_ms":30_000,"cancellation_latency":Self::samples(vec![elapsed]),
935 "source_replays":0,"caller_sources_dropped":16,"agent_unknown_replay":"not applicable: no Agent attempt"}),
936 )
937 }
938
939 async fn delayed_observer(
940 &self,
941 app: &App<StateStore, Agent, WorkloadAction>,
942 probe: &WorkloadProbe,
943 ) -> Result<Value, Box<dyn Error + Send + Sync>> {
944 let workspace = app
945 .create_workspace(WorkspaceConfig {
946 name: None,
947 root: self.root.join("workspace"),
948 })
949 .await?;
950 let workflow = workspace.workflow("performance-leaf".parse()?).await?;
951 let run = workflow.start(ExecutionOptions::default()).await?;
952 let observer = workflow
953 .open_run(RunSelector::Latest(run.id().clone()))
954 .await?;
955 // This public observation is deliberately never polled while the source
956 // produces its 256 partials and commits. It owns no execution work.
957 let delayed = observer.wait();
958 let produced = run.wait().await?;
959 assert!(matches!(produced.status, RunStatus::Succeeded));
960 assert_eq!(probe.completed.load(Ordering::SeqCst), 1);
961 probe.assert_idle();
962 tokio::time::sleep(Duration::from_secs(2)).await;
963 let observing = Instant::now();
964 let snapshot = delayed.await?;
965 let elapsed = observing.elapsed();
966 assert!(matches!(snapshot.status, RunStatus::Succeeded));
967 assert_eq!(snapshot.checkpoint, produced.checkpoint);
968 assert_eq!(snapshot.state.values()["result"], 1);
969 assert_eq!(snapshot.last_action_ordinal, Some(0));
970 assert_eq!(snapshot.outcomes.len(), 1);
971 assert_eq!(
972 snapshot.outcomes[0].result,
973 Map::from_iter([("value".into(), json!(1))])
974 );
975 assert_eq!(probe.constructed.load(Ordering::SeqCst), 1);
976 assert_eq!(probe.partials.load(Ordering::SeqCst), 256);
977 probe.assert_idle();
978 let terminal = run.snapshot().await?;
979 assert_eq!(terminal.checkpoint, snapshot.checkpoint);
980 assert_eq!(terminal.state, snapshot.state);
981 let mut cursor = None;
982 let mut actions = 0;
983 let mut rows = 0;
984 loop {
985 let page = run
986 .history(cursor, NonZeroUsize::new(PAGE_SIZE).unwrap())
987 .await?;
988 for row in &page.checkpoints {
989 rows += 1;
990 assert!(rows <= 32);
991 if matches!(row.kind, CheckpointKind::Action) {
992 actions += 1;
993 assert_eq!(row.action_ordinal, Some(0));
994 assert_eq!(row.outcomes.len(), 1);
995 }
996 }
997 cursor = page.next;
998 if cursor.is_none() {
999 break;
1000 }
1001 }
1002 assert_eq!(
1003 actions, 1,
1004 "partials cannot become extra durable action commits"
1005 );
1006 Ok(
1007 json!({"dataset":"delayed-observer","unobserved_partial_events":256,"delay_after_terminal_confirmation_ms":2_000,
1008 "observation_latency":Self::samples(vec![elapsed]),"retained_rows":rows,
1009 "memory_scope":"external process RSS; this consumer retains no partial payloads"}),
1010 )
1011 }
1012
1013 async fn close(
1014 app: &mut App<StateStore, Agent, WorkloadAction>,
1015 success: bool,
1016 ) -> Result<(), AppError> {
1017 let deadline = Instant::now() + CLEANUP_BUDGET;
1018 let mut first_failure = None;
1019 loop {
1020 let remaining = deadline.saturating_duration_since(Instant::now());
1021 if remaining.is_zero() {
1022 return Err(first_failure.unwrap_or_else(|| {
1023 AppError::Timeout("performance cleanup budget exhausted".into())
1024 }));
1025 }
1026 let policy = if success {
1027 Shutdown::Drain {
1028 timeout: CLEANUP_OBSERVATION,
1029 }
1030 } else {
1031 Shutdown::CancelAndDrain {
1032 timeout: CLEANUP_OBSERVATION,
1033 }
1034 };
1035 let result =
1036 tokio::time::timeout(remaining.min(CLEANUP_OBSERVATION), app.shutdown(policy))
1037 .await;
1038 match result {
1039 Ok(Ok(())) => return first_failure.map_or(Ok(()), Err),
1040 Ok(Err(error @ AppError::Timeout(_))) => {
1041 first_failure.get_or_insert(error);
1042 }
1043 Err(_) => {
1044 first_failure.get_or_insert_with(|| {
1045 AppError::Timeout("performance cleanup observation timed out".into())
1046 });
1047 }
1048 Ok(Err(error)) => return Err(first_failure.unwrap_or(error)),
1049 }
1050 // A timed-out observer reattaches to its original finalizer.
1051 }
1052 }
1053
1054 async fn run(mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
1055 let manifest = if matches!(self.command, Command::ReopenHistory { .. }) {
1056 self.root = self.root.canonicalize()?;
1057 Some(self.read_manifest()?)
1058 } else {
1059 fs::create_dir(&self.root)?;
1060 self.root = self.root.canonicalize()?;
1061 fs::create_dir(self.root.join("workspace"))?;
1062 let mut marker = OpenOptions::new()
1063 .write(true)
1064 .create_new(true)
1065 .open(self.root.join(".performance-root"))?;
1066 marker.write_all(MANIFEST_SCHEMA.as_bytes())?;
1067 marker.sync_all()?;
1068 None
1069 };
1070 let actions = match self.command {
1071 Command::Linear => 1_000,
1072 Command::SeedHistory { rows } => (rows - 4) / 3,
1073 Command::Ready => 32 * 32,
1074 Command::Map => 4_096,
1075 Command::Repeated => 100,
1076 Command::NestedCancel => 16,
1077 Command::DelayedObserver => 1,
1078 Command::ReopenHistory { .. } => {
1079 manifest.as_ref().unwrap()["actions"].as_u64().unwrap()
1080 }
1081 };
1082 let linear = matches!(self.command, Command::Linear);
1083 let (definitions, definition_bytes) = self.definitions(actions)?;
1084 let probe = match self.command {
1085 Command::Ready => Some(WorkloadProbe::new(SourceMode::Permuted, 1_024)),
1086 Command::Map | Command::Repeated => {
1087 Some(WorkloadProbe::new(SourceMode::Parallel, actions as usize))
1088 }
1089 Command::NestedCancel => Some(WorkloadProbe::new(SourceMode::Held, 16)),
1090 Command::DelayedObserver => Some(WorkloadProbe::new(SourceMode::Partials, 1)),
1091 Command::Linear | Command::SeedHistory { .. } | Command::ReopenHistory { .. } => None,
1092 };
1093 if let Some(manifest) = &manifest {
1094 assert_eq!(manifest["definition_bytes"], json!(definition_bytes));
1095 }
1096 let mut app = None;
1097 let started = Instant::now();
1098 let observed = AssertUnwindSafe(tokio::time::timeout(WORKLOAD_BUDGET, async {
1099 let connecting = Instant::now();
1100 let config = LanceStateStoreConfig::directory(self.root.join("state"))
1101 .with_operation_timeout(Duration::from_secs(30))?;
1102 let store = StateStore::connect(StateStoreConfig::Lance(config)).await?;
1103 let connect = connecting.elapsed();
1104 let opening = Instant::now();
1105 app = Some(
1106 App::open(
1107 AppConfig::<_, Agent>::new(store, Vec::new(), definitions).with_actions(
1108 probe
1109 .as_ref()
1110 .map(WorkloadAction::new)
1111 .into_iter()
1112 .collect(),
1113 ),
1114 )
1115 .await?,
1116 );
1117 let open = opening.elapsed();
1118 let mut result = match self.command {
1119 Command::Linear | Command::SeedHistory { .. } => {
1120 self.seed(app.as_ref().unwrap(), actions, linear).await?
1121 }
1122 Command::Ready => {
1123 self.ready(app.as_ref().unwrap(), probe.as_ref().unwrap())
1124 .await?
1125 }
1126 Command::Map => {
1127 self.mapped(app.as_ref().unwrap(), probe.as_ref().unwrap())
1128 .await?
1129 }
1130 Command::Repeated => {
1131 self.repeated(app.as_ref().unwrap(), probe.as_ref().unwrap())
1132 .await?
1133 }
1134 Command::NestedCancel => {
1135 self.nested_cancel(app.as_ref().unwrap(), probe.as_ref().unwrap())
1136 .await?
1137 }
1138 Command::DelayedObserver => {
1139 self.delayed_observer(app.as_ref().unwrap(), probe.as_ref().unwrap())
1140 .await?
1141 }
1142 Command::ReopenHistory { open_only } => {
1143 self.reopen(
1144 app.as_ref().unwrap(),
1145 manifest.as_ref().unwrap(),
1146 actions,
1147 open_only,
1148 )
1149 .await?
1150 }
1151 };
1152 result["store_connect_ns"] = json!(connect.as_nanos());
1153 result["app_open_ns"] = json!(open.as_nanos());
1154 result["workload_elapsed_ns"] = json!(started.elapsed().as_nanos());
1155 Ok::<_, Box<dyn Error + Send + Sync>>(result)
1156 }))
1157 .catch_unwind()
1158 .await;
1159 let success = matches!(&observed, Ok(Ok(Ok(_))));
1160 let closing = Instant::now();
1161 let cleanup = if let Some(mut app) = app.take() {
1162 let closed = AssertUnwindSafe(Self::close(&mut app, success))
1163 .catch_unwind()
1164 .await;
1165 drop(app);
1166 Some(closed)
1167 } else {
1168 None
1169 };
1170 if !success {
1171 eprintln!(
1172 "performance workload failed; cleanup completed={}",
1173 matches!(&cleanup, Some(Ok(Ok(()))))
1174 );
1175 }
1176 let mut result = match observed {
1177 Ok(Ok(result)) => result?,
1178 Ok(Err(_)) => {
1179 return Err(
1180 AppError::Timeout("performance workload budget exhausted".into()).into(),
1181 );
1182 }
1183 Err(panic) => resume_unwind(panic),
1184 };
1185 match cleanup {
1186 Some(Ok(result)) => result?,
1187 Some(Err(panic)) => resume_unwind(panic),
1188 None => return Err("successful workload did not retain its App cleanup owner".into()),
1189 }
1190 // All run/workflow/App owners and the caller-created store have dropped.
1191 // A cleanup timeout remains a failure even if reattachment later joined.
1192 result["schema"] = json!(MANIFEST_SCHEMA);
1193 result["settings"] = Self::settings(actions);
1194 if let Some(probe) = &probe {
1195 probe.assert_idle();
1196 result["caller_sources"] = probe.report();
1197 result["settings"]
1198 .as_object_mut()
1199 .unwrap()
1200 .remove("history_row_ceiling");
1201 result["settings"]["private_task_counts"] =
1202 json!("not exposed; App shutdown proves joined ownership");
1203 result["settings"]["lower_agents_and_tool_handlers"] = json!("none constructed");
1204 }
1205 result["definition_bytes"] = json!(definition_bytes);
1206 result["process_id"] = json!(std::process::id());
1207 result["os"] = json!(std::env::consts::OS);
1208 result["architecture"] = json!(std::env::consts::ARCH);
1209 result["package_version"] = json!(env!("CARGO_PKG_VERSION"));
1210 result["cleanup_joined"] = json!(true);
1211 result["cleanup_ns"] = json!(closing.elapsed().as_nanos());
1212 let encoded = serde_json::to_vec(&result)?;
1213 assert!(
1214 encoded.len() <= 16_384,
1215 "result stays within the manifest bound"
1216 );
1217 if matches!(self.command, Command::SeedHistory { .. }) {
1218 let mut file = OpenOptions::new()
1219 .write(true)
1220 .create_new(true)
1221 .open(self.root.join("manifest.json"))?;
1222 file.write_all(&encoded)?;
1223 file.sync_all()?;
1224 }
1225 println!("{}", std::str::from_utf8(&encoded)?);
1226 Ok(())
1227 }
1228}
1229
1230// These counters observe only this consumer's concrete sources. They expose no
1231// runtime internals and do not claim to count App tasks or lower tool handlers.
1232#[derive(Clone, Copy)]
1233enum SourceMode {
1234 Permuted,
1235 Parallel,
1236 Held,
1237 Partials,
1238}
1239
1240struct WorkloadProbe {
1241 mode: SourceMode,
1242 expected: usize,
1243 open: AtomicBool,
1244 constructed: AtomicUsize,
1245 active: AtomicUsize,
1246 peak: AtomicUsize,
1247 completed: AtomicUsize,
1248 dropped: AtomicUsize,
1249 partials: AtomicUsize,
1250 permitted: Mutex<BTreeSet<usize>>,
1251 wakers: Mutex<BTreeMap<usize, Waker>>,
1252 order: Mutex<Vec<usize>>,
1253 lifetimes: Mutex<Vec<Duration>>,
1254 changed: Notify,
1255}
1256
1257impl WorkloadProbe {
1258 fn new(mode: SourceMode, expected: usize) -> Arc<Self> {
1259 Arc::new(Self {
1260 mode,
1261 expected,
1262 open: AtomicBool::new(false),
1263 constructed: AtomicUsize::new(0),
1264 active: AtomicUsize::new(0),
1265 peak: AtomicUsize::new(0),
1266 completed: AtomicUsize::new(0),
1267 dropped: AtomicUsize::new(0),
1268 partials: AtomicUsize::new(0),
1269 permitted: Mutex::new(BTreeSet::new()),
1270 wakers: Mutex::new(BTreeMap::new()),
1271 order: Mutex::new(Vec::new()),
1272 lifetimes: Mutex::new(Vec::new()),
1273 changed: Notify::new(),
1274 })
1275 }
1276
1277 async fn wait_for(&self, counter: &AtomicUsize, expected: usize) {
1278 loop {
1279 let changed = self.changed.notified();
1280 tokio::pin!(changed);
1281 changed.as_mut().enable();
1282 if counter.load(Ordering::SeqCst) >= expected {
1283 return;
1284 }
1285 changed.await;
1286 }
1287 }
1288
1289 fn release(&self, index: usize) {
1290 assert!(self.permitted.lock().unwrap().insert(index));
1291 let waker = self.wakers.lock().unwrap().remove(&index);
1292 if let Some(waker) = waker {
1293 waker.wake();
1294 }
1295 }
1296
1297 fn release_all(&self) {
1298 self.open.store(true, Ordering::SeqCst);
1299 let wakers = std::mem::take(&mut *self.wakers.lock().unwrap());
1300 for (_, waker) in wakers {
1301 waker.wake();
1302 }
1303 }
1304
1305 fn assert_idle(&self) {
1306 assert_eq!(
1307 self.active.load(Ordering::SeqCst),
1308 0,
1309 "original caller sources must be dropped"
1310 );
1311 assert_eq!(
1312 self.dropped.load(Ordering::SeqCst),
1313 self.constructed.load(Ordering::SeqCst)
1314 );
1315 assert!(self.wakers.lock().unwrap().is_empty());
1316 assert!(self.permitted.lock().unwrap().is_empty());
1317 }
1318
1319 fn report(&self) -> Value {
1320 json!({"constructed":self.constructed.load(Ordering::SeqCst),
1321 "completed":self.completed.load(Ordering::SeqCst),
1322 "dropped":self.dropped.load(Ordering::SeqCst),
1323 "active_after_cleanup":self.active.load(Ordering::SeqCst),
1324 "peak_live_sources":self.peak.load(Ordering::SeqCst),
1325 "partial_events":self.partials.load(Ordering::SeqCst),
1326 "construction_to_drop_latency":Performance::samples(std::mem::take(&mut *self.lifetimes.lock().unwrap()))})
1327 }
1328}
1329
1330struct WorkloadAction {
1331 declaration: ActionDeclaration,
1332 probe: Arc<WorkloadProbe>,
1333}
1334
1335impl WorkloadAction {
1336 fn new(probe: &Arc<WorkloadProbe>) -> Self {
1337 Self {
1338 declaration: ActionDeclaration {
1339 key: "performance-action".into(),
1340 revision: "r1".into(),
1341 reads: vec!["value".into()],
1342 writes: vec!["result".into()],
1343 required_inputs: Vec::new(),
1344 optional_inputs: Vec::new(),
1345 bound_inputs: Map::new(),
1346 default_inputs: Map::new(),
1347 tags: Vec::new(),
1348 source: None,
1349 mode: ActionMode::Asynchronous,
1350 },
1351 probe: Arc::clone(probe),
1352 }
1353 }
1354}
1355
1356impl ActionBackend for WorkloadAction {
1357 type Stream = WorkloadSource;
1358
1359 fn declaration(&self) -> &ActionDeclaration {
1360 &self.declaration
1361 }
1362
1363 fn start(&self, request: ActionRequest) -> Self::Stream {
1364 let value = match self.probe.mode {
1365 SourceMode::Permuted => request
1366 .context
1367 .node
1368 .strip_prefix('n')
1369 .unwrap()
1370 .parse()
1371 .unwrap(),
1372 SourceMode::Parallel | SourceMode::Held | SourceMode::Partials => {
1373 usize::try_from(request.state.values()["value"].as_u64().unwrap()).unwrap()
1374 }
1375 };
1376 assert!(
1377 self.probe.constructed.fetch_add(1, Ordering::SeqCst) < self.probe.expected,
1378 "a duplicate source construction exceeds this workload's exact bound"
1379 );
1380 let active = self.probe.active.fetch_add(1, Ordering::SeqCst) + 1;
1381 self.probe.peak.fetch_max(active, Ordering::SeqCst);
1382 self.probe.changed.notify_waiters();
1383 WorkloadSource {
1384 probe: Arc::clone(&self.probe),
1385 value,
1386 started: Instant::now(),
1387 state: SourceState::Polling(0),
1388 }
1389 }67 fn calculate(state: WorkflowState, inputs: Map<String, Value>) -> Result<Self, AppError> {
68 let price = state
69 .values()
70 .get("unit_price")
71 .and_then(Value::as_u64)
72 .ok_or_else(|| {
73 AppError::InvalidInput("unit_price must be an unsigned integer".into())
74 })?;
75 let quantity = inputs
76 .get("quantity")
77 .and_then(Value::as_u64)
78 .ok_or_else(|| AppError::InvalidInput("quantity must be an unsigned integer".into()))?;
79 let total = price
80 .checked_mul(quantity)
81 .ok_or_else(|| AppError::InvalidInput("invoice total overflowed".into()))?;
82 let mut result = inputs;
83 result.insert("total".into(), json!(total));
84 Ok(Self::Completed { result, total })
85 }63 async fn create_fork(
64 &self,
65 app: &App<StateStore, Agent>,
66 ) -> Result<(WorkspaceId, RunSelector, RunSelector), Box<dyn Error + Send + Sync>> {
67 let workspace = app
68 .create_workspace(WorkspaceConfig {
69 name: Some("history example".into()),
70 root: std::env::current_dir()?,
71 })
72 .await?;
73 let workflow = workspace.workflow("history-counter".parse()?).await?;
74 let mut source = workflow.prepare(WorkflowState::default()).await?;
75 let completed = source
76 .execution(ExecutionOptions::default())
77 .await?
78 .finish()
79 .await?;
80 if !matches!(completed.status, RunStatus::Succeeded)
81 || completed.state.values()["count"] != 2
82 {
83 return Err(
84 AppError::Integration("source did not complete both increments".into()).into(),
85 );
86 }
87
88 // An action ordinal selects the whole committed boundary containing it;
89 // it is independent of the checkpoint sequence and storage revision.
90 let first = source.checkpoint(CheckpointSelector::Action(0)).await?;
91 if !matches!(first.kind, CheckpointKind::Action)
92 || first.position.as_deref() != Some("first")
93 || first.state.values()["count"] != 1
94 {
95 return Err(
96 AppError::Integration("first action history is inconsistent".into()).into(),
97 );
98 }
99 let source_selector = RunSelector::Exact {
100 id: source.id().clone(),
101 occurrence: first.occurrence.clone(),
102 };
103 let mut fork = workflow
104 .restore(RestoreOptions {
105 source: source_selector.clone(),
106 checkpoint: CheckpointSelector::Action(0),
107 overrides: WorkflowState::new(Map::from_iter([("count".into(), json!(40))]))?,
108 position: ResumePosition::AfterCompleted,
109 fallback: None,
110 })
111 .await?;
112 let parked = fork.snapshot().await?;
113 if !matches!(parked.status, RunStatus::Paused)
114 || parked.checkpoint != 0
115 || parked.state.values()["count"] != 40
116 || !parked.outcomes.is_empty()
117 {
118 return Err(
119 AppError::Integration("restore did not create a parked clean copy".into()).into(),
120 );
121 }
122
123 source.delete(completed.checkpoint).await?;
124 if !matches!(source.snapshot().await, Err(AppError::NotFound(_))) {
125 return Err(
126 AppError::Integration("explicit source deletion did not finish".into()).into(),
127 );
128 }
129 // Deletion does not invalidate the copied state. AfterCompleted skips
130 // the first increment, so only the second changes the override: 40 -> 41.
131 let completed = fork
132 .execution(ExecutionOptions::default())
133 .await?
134 .finish()
135 .await?;
136 if !matches!(completed.status, RunStatus::Succeeded)
137 || completed.state.values()["count"] != 41
138 || completed.last_action_ordinal != Some(1)
139 {
140 return Err(
141 AppError::Integration("fork resumed from the wrong boundary".into()).into(),
142 );
143 }
144 println!("Source count: 2; copied count: 40; completed fork count: 41");
145 Ok((
146 workspace.id().clone(),
147 source_selector,
148 RunSelector::Exact {
149 id: fork.id().clone(),
150 occurrence: completed.occurrence,
151 },
152 ))
153 }
154
155 async fn read_reopened(
156 &self,
157 app: &App<StateStore, Agent>,
158 workspace: WorkspaceId,
159 source: RunSelector,
160 fork: RunSelector,
161 ) -> Result<(), AppError> {
162 let workspace = app.workspace(workspace).await?;
163 let workflow = workspace.workflow("history-counter".parse()?).await?;
164 let fork = workflow.open_run(fork).await?;
165 let committed = fork.checkpoint(CheckpointSelector::Action(1)).await?;
166 let lineage = committed
167 .lineage
168 .as_ref()
169 .ok_or_else(|| AppError::Integration("reopened fork lost its source lineage".into()))?;
170 if committed.state.values()["count"] != 41
171 || !matches!(&source, RunSelector::Exact { id, occurrence }
172 if &lineage.source == id && &lineage.source_occurrence == occurrence)
173 || lineage.action_ordinal != Some(0)
174 {
175 return Err(AppError::Integration(
176 "reopened fork history changed".into(),
177 ));
178 }
179 println!(
180 "Reopened fork {} at count 41; source lineage remains readable",
181 fork.id()
182 );
183 Ok(())
184 }23 async fn run() -> Result<(), Box<dyn Error + Send + Sync>> {
24 let definition = WorkflowDefinition::parse(
25 &json!({
26 "schema":"app-kit.workflow/v2", "id":"invoice", "revision":"r1",
27 "state":{"unit_price":7,"total":0}, "required":["unit_price","total"],
28 "reducers":{"unit_price":"reject_concurrent","total":"reject_concurrent"},
29 "entry":"calculate",
30 "nodes":[{"id":"calculate","step":{
31 "kind":"action","action":"invoice","revision":"r1","project_result":false
32 }}], "routes":{}
33 })
34 .to_string(),
35 )?;
36 let store =
37 StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
38 // Native caller actions need no Agent value or provider service.
39 let mut app = App::open(
40 AppConfig::<_, Agent>::new(store, Vec::new(), vec![definition])
41 .with_actions(vec![InvoiceAction::new()]),
42 )
43 .await?;
44 let observed = tokio::time::timeout(Duration::from_secs(15), async {
45 let workspace = app
46 .create_workspace(WorkspaceConfig {
47 name: Some("invoice example".into()),
48 root: PathBuf::from(env!("CARGO_MANIFEST_DIR")),
49 })
50 .await?;
51 let workflow = workspace.workflow("invoice".parse()?).await?;
52 let mut run = workflow.prepare(WorkflowState::default()).await?;
53 let mut execution = run
54 .execution(ExecutionOptions {
55 inputs: Map::from_iter([
56 ("quantity".into(), json!(6)),
57 ("trace".into(), json!("sample")),
58 ]),
59 ..ExecutionOptions::default()
60 })
61 .await?;
62 let Some(ExecutionEvent::Partial { result, .. }) = execution.next().await? else {
63 return Err(AppError::Integration(
64 "expected the real action partial".into(),
65 ));
66 };
67 if result.get("progress") != Some(&json!("calculated")) {
68 return Err(AppError::Integration("partial changed".into()));
69 }
70 let Some(ExecutionEvent::Committed { outcomes, .. }) = execution.step().await? else {
71 return Err(AppError::Integration(
72 "expected one committed boundary".into(),
73 ));
74 };
75 let expected =
76 json!({"quantity":6,"label":"order","currency":"USD","trace":"sample","total":42});
77 if outcomes.len() != 1 || Value::Object(outcomes[0].result.clone()) != expected {
78 return Err(AppError::Integration(
79 "resolved inputs or action result changed".into(),
80 ));
81 }
82 let terminal = execution.finish().await?;
83 if !matches!(terminal.status, RunStatus::Succeeded)
84 || terminal.state.values()
85 != json!({"unit_price":7,"total":42}).as_object().unwrap()
86 {
87 return Err(AppError::Integration(
88 "only the explicit total write should persist".into(),
89 ));
90 }
91 println!(
92 "Partial: calculated; committed total: 42; transient inputs stayed out of state"
93 );
94 Ok::<(), AppError>(())
95 })
96 .await;
97 let closed = tokio::time::timeout(
98 Duration::from_secs(15),
99 app.shutdown(Shutdown::CancelAndDrain {
100 timeout: Duration::from_secs(10),
101 }),
102 )
103 .await;
104 drop(app);
105 observed.map_err(|_| AppError::Timeout("invoice execution timed out".into()))??;
106 closed.map_err(|_| AppError::Timeout("invoice cleanup timed out".into()))??;
107 Ok(())
108 }Sourcepub fn apply<T: StateTyping>(
&self,
change: &StateChange,
typing: &T,
) -> Result<Self, AppError>
pub fn apply<T: StateTyping>( &self, change: &StateChange, typing: &T, ) -> Result<Self, AppError>
Produces a new state with the pinned operation-specific validation order.
Primitive changes run pre/post transition validation, then full-state validation and roundtrip. Merge and subset construct new snapshots and run only full-state validation and roundtrip. Any failure leaves this state and its retained clones unchanged.
use app_kit::workflow::{JsonState, StateChange, WorkflowState};
use serde_json::{Map, Value};
let state = WorkflowState::new(Map::from_iter([
("count".into(), Value::from("unknown")),
]))?;
let change = StateChange::Increment {
values: vec![("count".into(), Value::from(1))],
};
assert!(state.apply(&change, &JsonState).is_err());
assert_eq!(state.values()["count"], "unknown");§Errors
Returns InvalidInput for incompatible operand types, signed-integer delta or
range errors, invalid state, or excessive operation payloads. Caller typing
errors propagate unchanged; failure never mutates the original state.
§Panics
Panics if the caller-provided typing implementation panics. Direct state operations do not contain caller panics; App admission/execution has its own boundary for classifying them.
Examples found in repository?
55fn main() -> Result<(), AppError> {
56 let policy = CountPolicy { maximum: 100 };
57 let initial = WorkflowState::new(Map::from_iter([
58 ("count".into(), json!(1)),
59 ("labels".into(), json!([])),
60 ]))?;
61 let counted = initial.apply(
62 &StateChange::Increment {
63 values: vec![("count".into(), json!(3)), ("count".into(), json!(4))],
64 },
65 &policy,
66 )?;
67 let labeled = counted.apply(
68 &StateChange::Append {
69 values: vec![
70 ("labels".into(), json!("draft")),
71 ("labels".into(), json!("queued")),
72 ],
73 },
74 &policy,
75 )?;
76 assert_eq!(initial.values()["count"], 1);
77 assert_eq!(labeled.values()["count"], 8);
78 assert_eq!(labeled.values()["labels"], json!(["draft", "queued"]));
79
80 // A later invalid entry rejects the complete operation, including count + 1.
81 let rejected = labeled.apply(
82 &StateChange::Increment {
83 values: vec![("count".into(), json!(1)), ("labels".into(), json!(1))],
84 },
85 &policy,
86 );
87 assert!(matches!(rejected, Err(AppError::InvalidInput(_))));
88 assert_eq!(labeled.values()["count"], 8);
89
90 // This registration is process-global. This standalone example owns its
91 // process; an application should choose registrations during its setup.
92 StateSerialization::global().register_field(
93 "labels".into(),
94 |value| Ok::<_, &'static str>(json!({"version": 1, "items": value})),
95 |value| value.get("items").cloned().ok_or("missing labels.items"),
96 )?;
97 let encoded = labeled.serialize()?;
98 assert_eq!(
99 encoded["labels"],
100 json!({"version":1,"items":["draft","queued"]})
101 );
102 let decoded = WorkflowState::deserialize(encoded.clone())?;
103 assert_eq!(decoded, labeled);
104
105 StateSerialization::global().register_tag("example-money".into(), |value| {
106 value.get("minor").cloned().ok_or("missing money.minor")
107 })?;
108 let tagged = WorkflowState::deserialize(Map::from_iter([(
109 "amount".into(),
110 json!({"__burr_serde__":"example-money","minor":120}),
111 )]))?;
112 assert_eq!(tagged.values()["amount"], 120);
113 println!("{}", Value::Object(encoded));
114 Ok(())
115}Trait Implementations§
Source§impl Clone for WorkflowState
impl Clone for WorkflowState
Source§impl Debug for WorkflowState
impl Debug for WorkflowState
Source§impl Default for WorkflowState
impl Default for WorkflowState
Source§impl PartialEq for WorkflowState
impl PartialEq for WorkflowState
impl StructuralPartialEq for WorkflowState
Auto Trait Implementations§
impl Freeze for WorkflowState
impl RefUnwindSafe for WorkflowState
impl Send for WorkflowState
impl Sync for WorkflowState
impl Unpin for WorkflowState
impl UnsafeUnpin for WorkflowState
impl UnwindSafe for WorkflowState
Blanket Implementations§
impl<T> Allocation for T
Source§impl<T> ArchivePointee for T
impl<T> ArchivePointee for T
Source§type ArchivedMetadata = ()
type ArchivedMetadata = ()
Source§fn pointer_metadata(
_: &<T as ArchivePointee>::ArchivedMetadata,
) -> <T as Pointee>::Metadata
fn pointer_metadata( _: &<T as ArchivePointee>::ArchivedMetadata, ) -> <T as Pointee>::Metadata
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
Source§impl<T> CloneToUninit for Twhere
T: Clone,
impl<T> CloneToUninit for Twhere
T: Clone,
impl<T> ErasedDestructor for Twhere
T: 'static,
Source§impl<T> FmtForward for T
impl<T> FmtForward for T
Source§fn fmt_binary(self) -> FmtBinary<Self>where
Self: Binary,
fn fmt_binary(self) -> FmtBinary<Self>where
Self: Binary,
self to use its Binary implementation when Debug-formatted.Source§fn fmt_display(self) -> FmtDisplay<Self>where
Self: Display,
fn fmt_display(self) -> FmtDisplay<Self>where
Self: Display,
self to use its Display implementation when
Debug-formatted.Source§fn fmt_lower_exp(self) -> FmtLowerExp<Self>where
Self: LowerExp,
fn fmt_lower_exp(self) -> FmtLowerExp<Self>where
Self: LowerExp,
self to use its LowerExp implementation when
Debug-formatted.Source§fn fmt_lower_hex(self) -> FmtLowerHex<Self>where
Self: LowerHex,
fn fmt_lower_hex(self) -> FmtLowerHex<Self>where
Self: LowerHex,
self to use its LowerHex implementation when
Debug-formatted.Source§fn fmt_octal(self) -> FmtOctal<Self>where
Self: Octal,
fn fmt_octal(self) -> FmtOctal<Self>where
Self: Octal,
self to use its Octal implementation when Debug-formatted.Source§fn fmt_pointer(self) -> FmtPointer<Self>where
Self: Pointer,
fn fmt_pointer(self) -> FmtPointer<Self>where
Self: Pointer,
self to use its Pointer implementation when
Debug-formatted.Source§fn fmt_upper_exp(self) -> FmtUpperExp<Self>where
Self: UpperExp,
fn fmt_upper_exp(self) -> FmtUpperExp<Self>where
Self: UpperExp,
self to use its UpperExp implementation when
Debug-formatted.Source§fn fmt_upper_hex(self) -> FmtUpperHex<Self>where
Self: UpperHex,
fn fmt_upper_hex(self) -> FmtUpperHex<Self>where
Self: UpperHex,
self to use its UpperHex implementation when
Debug-formatted.Source§impl<T> Instrument for T
impl<T> Instrument for T
Source§fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
Source§fn in_current_span(self) -> Instrumented<Self> ⓘ
fn in_current_span(self) -> Instrumented<Self> ⓘ
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left is true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left(&self) returns true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§impl<T> IntoRequest<T> for T
impl<T> IntoRequest<T> for T
Source§fn into_request(self) -> Request<T>
fn into_request(self) -> Request<T>
T in a tonic::RequestSource§impl<T> LayoutRaw for T
impl<T> LayoutRaw for T
Source§fn layout_raw(_: <T as Pointee>::Metadata) -> Result<Layout, LayoutError>
fn layout_raw(_: <T as Pointee>::Metadata) -> Result<Layout, LayoutError>
Source§impl<T, N1, N2> Niching<NichedOption<T, N1>> for N2
impl<T, N1, N2> Niching<NichedOption<T, N1>> for N2
Source§unsafe fn is_niched(niched: *const NichedOption<T, N1>) -> bool
unsafe fn is_niched(niched: *const NichedOption<T, N1>) -> bool
Source§fn resolve_niched(out: Place<NichedOption<T, N1>>)
fn resolve_niched(out: Place<NichedOption<T, N1>>)
out indicating that a T is niched.Source§impl<T> Pipe for Twhere
T: ?Sized,
impl<T> Pipe for Twhere
T: ?Sized,
Source§fn pipe<R>(self, func: impl FnOnce(Self) -> R) -> Rwhere
Self: Sized,
fn pipe<R>(self, func: impl FnOnce(Self) -> R) -> Rwhere
Self: Sized,
Source§fn pipe_ref<'a, R>(&'a self, func: impl FnOnce(&'a Self) -> R) -> Rwhere
R: 'a,
fn pipe_ref<'a, R>(&'a self, func: impl FnOnce(&'a Self) -> R) -> Rwhere
R: 'a,
self and passes that borrow into the pipe function. Read moreSource§fn pipe_ref_mut<'a, R>(&'a mut self, func: impl FnOnce(&'a mut Self) -> R) -> Rwhere
R: 'a,
fn pipe_ref_mut<'a, R>(&'a mut self, func: impl FnOnce(&'a mut Self) -> R) -> Rwhere
R: 'a,
self and passes that borrow into the pipe function. Read moreSource§fn pipe_borrow<'a, B, R>(&'a self, func: impl FnOnce(&'a B) -> R) -> R
fn pipe_borrow<'a, B, R>(&'a self, func: impl FnOnce(&'a B) -> R) -> R
Source§fn pipe_borrow_mut<'a, B, R>(
&'a mut self,
func: impl FnOnce(&'a mut B) -> R,
) -> R
fn pipe_borrow_mut<'a, B, R>( &'a mut self, func: impl FnOnce(&'a mut B) -> R, ) -> R
Source§fn pipe_as_ref<'a, U, R>(&'a self, func: impl FnOnce(&'a U) -> R) -> R
fn pipe_as_ref<'a, U, R>(&'a self, func: impl FnOnce(&'a U) -> R) -> R
self, then passes self.as_ref() into the pipe function.Source§fn pipe_as_mut<'a, U, R>(&'a mut self, func: impl FnOnce(&'a mut U) -> R) -> R
fn pipe_as_mut<'a, U, R>(&'a mut self, func: impl FnOnce(&'a mut U) -> R) -> R
self, then passes self.as_mut() into the pipe
function.Source§fn pipe_deref<'a, T, R>(&'a self, func: impl FnOnce(&'a T) -> R) -> R
fn pipe_deref<'a, T, R>(&'a self, func: impl FnOnce(&'a T) -> R) -> R
self, then passes self.deref() into the pipe function.Source§impl<T> Pointable for T
impl<T> Pointable for T
Source§impl<T> PolicyExt for Twhere
T: ?Sized,
impl<T> PolicyExt for Twhere
T: ?Sized,
impl<T> Read<Exclusive, BecauseExclusive> for Twhere
T: ?Sized,
Source§impl<T> Tap for T
impl<T> Tap for T
Source§fn tap_borrow<B>(self, func: impl FnOnce(&B)) -> Self
fn tap_borrow<B>(self, func: impl FnOnce(&B)) -> Self
Borrow<B> of a value. Read moreSource§fn tap_borrow_mut<B>(self, func: impl FnOnce(&mut B)) -> Self
fn tap_borrow_mut<B>(self, func: impl FnOnce(&mut B)) -> Self
BorrowMut<B> of a value. Read moreSource§fn tap_ref<R>(self, func: impl FnOnce(&R)) -> Self
fn tap_ref<R>(self, func: impl FnOnce(&R)) -> Self
AsRef<R> view of a value. Read moreSource§fn tap_ref_mut<R>(self, func: impl FnOnce(&mut R)) -> Self
fn tap_ref_mut<R>(self, func: impl FnOnce(&mut R)) -> Self
AsMut<R> view of a value. Read moreSource§fn tap_deref<T>(self, func: impl FnOnce(&T)) -> Self
fn tap_deref<T>(self, func: impl FnOnce(&T)) -> Self
Deref::Target of a value. Read moreSource§fn tap_deref_mut<T>(self, func: impl FnOnce(&mut T)) -> Self
fn tap_deref_mut<T>(self, func: impl FnOnce(&mut T)) -> Self
Deref::Target of a value. Read moreSource§fn tap_dbg(self, func: impl FnOnce(&Self)) -> Self
fn tap_dbg(self, func: impl FnOnce(&Self)) -> Self
.tap() only in debug builds, and is erased in release builds.Source§fn tap_mut_dbg(self, func: impl FnOnce(&mut Self)) -> Self
fn tap_mut_dbg(self, func: impl FnOnce(&mut Self)) -> Self
.tap_mut() only in debug builds, and is erased in release
builds.Source§fn tap_borrow_dbg<B>(self, func: impl FnOnce(&B)) -> Self
fn tap_borrow_dbg<B>(self, func: impl FnOnce(&B)) -> Self
.tap_borrow() only in debug builds, and is erased in release
builds.Source§fn tap_borrow_mut_dbg<B>(self, func: impl FnOnce(&mut B)) -> Self
fn tap_borrow_mut_dbg<B>(self, func: impl FnOnce(&mut B)) -> Self
.tap_borrow_mut() only in debug builds, and is erased in release
builds.Source§fn tap_ref_dbg<R>(self, func: impl FnOnce(&R)) -> Self
fn tap_ref_dbg<R>(self, func: impl FnOnce(&R)) -> Self
.tap_ref() only in debug builds, and is erased in release
builds.Source§fn tap_ref_mut_dbg<R>(self, func: impl FnOnce(&mut R)) -> Self
fn tap_ref_mut_dbg<R>(self, func: impl FnOnce(&mut R)) -> Self
.tap_ref_mut() only in debug builds, and is erased in release
builds.Source§fn tap_deref_dbg<T>(self, func: impl FnOnce(&T)) -> Self
fn tap_deref_dbg<T>(self, func: impl FnOnce(&T)) -> Self
.tap_deref() only in debug builds, and is erased in release
builds.