1use super::{
2 Budget, CheckpointId, Deserialize, Duration, Error, Future, NonZeroU32, NonZeroU64, Pin,
3 Serialize, Usage, Value, WorkflowInterruptRequest, WorkflowLineage, WorkflowWait, WorkflowWake,
4};
5
6pub type WorkflowStoreFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
8
9#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
11#[serde(transparent)]
12pub struct WorkflowTenantId(String);
13
14impl WorkflowTenantId {
15 pub fn parse(value: impl Into<String>) -> Result<Self, WorkflowStoreError> {
21 let value = value.into();
22 if value.is_empty()
23 || value.len() > 128
24 || !value
25 .bytes()
26 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
27 {
28 return Err(WorkflowStoreError::invalid_input(
29 "workflow tenant must contain 1..=128 portable ASCII characters",
30 ));
31 }
32 Ok(Self(value))
33 }
34
35 pub fn as_str(&self) -> &str {
37 &self.0
38 }
39}
40
41impl Default for WorkflowTenantId {
42 fn default() -> Self {
43 Self("default".into())
44 }
45}
46
47#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
49pub struct WorkflowTenantPolicy {
50 max_outstanding_tasks: NonZeroU32,
51 max_concurrent_leases: NonZeroU32,
52}
53
54impl WorkflowTenantPolicy {
55 pub fn new(
61 max_outstanding_tasks: u32,
62 max_concurrent_leases: u32,
63 ) -> Result<Self, WorkflowStoreError> {
64 let max_outstanding_tasks = NonZeroU32::new(max_outstanding_tasks).ok_or_else(|| {
65 WorkflowStoreError::invalid_input("tenant outstanding workflow limit must be positive")
66 })?;
67 let max_concurrent_leases = NonZeroU32::new(max_concurrent_leases).ok_or_else(|| {
68 WorkflowStoreError::invalid_input(
69 "tenant concurrent workflow lease limit must be positive",
70 )
71 })?;
72 if max_concurrent_leases > max_outstanding_tasks {
73 return Err(WorkflowStoreError::invalid_input(
74 "tenant concurrent workflow lease limit cannot exceed outstanding limit",
75 ));
76 }
77 Ok(Self {
78 max_outstanding_tasks,
79 max_concurrent_leases,
80 })
81 }
82
83 pub const fn max_outstanding_tasks(self) -> u32 {
85 self.max_outstanding_tasks.get()
86 }
87
88 pub const fn max_concurrent_leases(self) -> u32 {
90 self.max_concurrent_leases.get()
91 }
92}
93
94impl Default for WorkflowTenantPolicy {
95 fn default() -> Self {
96 Self {
97 max_outstanding_tasks: NonZeroU32::new(10_000)
98 .expect("default outstanding limit is positive"),
99 max_concurrent_leases: NonZeroU32::new(100)
100 .expect("default concurrent lease limit is positive"),
101 }
102 }
103}
104
105#[derive(Clone, Copy, Debug, Eq, PartialEq)]
107pub struct WorkflowTenantListLimit(NonZeroU32);
108
109impl WorkflowTenantListLimit {
110 pub fn new(value: u32) -> Result<Self, WorkflowStoreError> {
116 let value = NonZeroU32::new(value).ok_or_else(|| {
117 WorkflowStoreError::invalid_input("workflow tenant list limit must be positive")
118 })?;
119 if value.get() > 1_000 {
120 return Err(WorkflowStoreError::invalid_input(
121 "workflow tenant list limit cannot exceed 1,000",
122 ));
123 }
124 Ok(Self(value))
125 }
126
127 pub const fn get(self) -> u32 {
129 self.0.get()
130 }
131}
132
133impl Default for WorkflowTenantListLimit {
134 fn default() -> Self {
135 Self(NonZeroU32::new(100).expect("default tenant page size is positive"))
136 }
137}
138
139#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
141pub struct WorkflowTenantBudgetPolicy {
142 limit: Budget,
143 window_ms: NonZeroU64,
144 recovery_grace_ms: u64,
145}
146
147impl WorkflowTenantBudgetPolicy {
148 pub fn new(
155 limit: Budget,
156 window: Duration,
157 recovery_grace: Duration,
158 ) -> Result<Self, WorkflowStoreError> {
159 if budget_is_unbounded(limit) {
160 return Err(WorkflowStoreError::invalid_input(
161 "tenant budget policy must limit at least one resource",
162 ));
163 }
164 validate_budget_duration(limit)?;
165 let window_ms = u64::try_from(window.as_millis())
166 .ok()
167 .and_then(NonZeroU64::new)
168 .ok_or_else(|| {
169 WorkflowStoreError::invalid_input(
170 "tenant budget window must fit in positive whole milliseconds",
171 )
172 })?;
173 let recovery_grace_ms = u64::try_from(recovery_grace.as_millis()).map_err(|_| {
174 WorkflowStoreError::invalid_input(
175 "tenant budget recovery grace exceeds supported milliseconds",
176 )
177 })?;
178 Ok(Self {
179 limit,
180 window_ms,
181 recovery_grace_ms,
182 })
183 }
184
185 pub const fn limit(self) -> Budget {
187 self.limit
188 }
189
190 pub const fn window_millis(self) -> u64 {
192 self.window_ms.get()
193 }
194
195 pub const fn recovery_grace_millis(self) -> u64 {
197 self.recovery_grace_ms
198 }
199}
200
201#[derive(Clone, Debug, Eq, PartialEq)]
203pub struct WorkflowTenantBudgetSnapshot {
204 pub tenant_id: WorkflowTenantId,
206 pub limit: Budget,
208 pub window_started_at_ms: u64,
210 pub committed: Usage,
212 pub reserved: Usage,
214 pub active_reservations: u64,
216}
217
218#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
220#[serde(transparent)]
221pub struct WorkflowBudgetAuditCursor(u64);
222
223impl WorkflowBudgetAuditCursor {
224 pub const fn new(sequence: u64) -> Self {
226 Self(sequence)
227 }
228
229 pub const fn sequence(self) -> u64 {
231 self.0
232 }
233}
234
235#[derive(Clone, Copy, Debug, Eq, PartialEq)]
237pub struct WorkflowBudgetAuditLimit(NonZeroU32);
238
239impl WorkflowBudgetAuditLimit {
240 pub fn new(value: u32) -> Result<Self, WorkflowStoreError> {
246 let value = NonZeroU32::new(value).ok_or_else(|| {
247 WorkflowStoreError::invalid_input("workflow budget audit limit must be positive")
248 })?;
249 if value.get() > 1_000 {
250 return Err(WorkflowStoreError::invalid_input(
251 "workflow budget audit limit cannot exceed 1,000",
252 ));
253 }
254 Ok(Self(value))
255 }
256
257 pub const fn get(self) -> u32 {
259 self.0.get()
260 }
261}
262
263impl Default for WorkflowBudgetAuditLimit {
264 fn default() -> Self {
265 Self(NonZeroU32::new(100).expect("default audit page size is positive"))
266 }
267}
268
269#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
271#[serde(transparent)]
272pub struct WorkflowBudgetAuditProjectionId(String);
273
274impl WorkflowBudgetAuditProjectionId {
275 pub fn parse(value: impl Into<String>) -> Result<Self, WorkflowStoreError> {
281 let value = value.into();
282 if value.is_empty()
283 || value.len() > 128
284 || !value
285 .bytes()
286 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
287 {
288 return Err(WorkflowStoreError::invalid_input(
289 "workflow budget audit projection must contain 1..=128 portable ASCII characters",
290 ));
291 }
292 Ok(Self(value))
293 }
294
295 pub fn as_str(&self) -> &str {
297 &self.0
298 }
299}
300
301#[derive(Clone, Debug, Eq, PartialEq)]
303pub struct WorkflowBudgetAuditProjectionLease {
304 pub tenant_id: WorkflowTenantId,
306 pub projection_id: WorkflowBudgetAuditProjectionId,
308 pub owner: WorkerId,
310 pub cursor: WorkflowBudgetAuditCursor,
312 pub fencing_token: u64,
314 pub expires_at_ms: u64,
316}
317
318impl WorkflowBudgetAuditProjectionLease {
319 pub fn tenant_id(&self) -> &WorkflowTenantId {
321 &self.tenant_id
322 }
323
324 pub fn projection_id(&self) -> &WorkflowBudgetAuditProjectionId {
326 &self.projection_id
327 }
328
329 pub fn owner(&self) -> &WorkerId {
331 &self.owner
332 }
333
334 pub const fn cursor(&self) -> WorkflowBudgetAuditCursor {
336 self.cursor
337 }
338
339 pub const fn fencing_token(&self) -> u64 {
341 self.fencing_token
342 }
343
344 pub const fn expires_at_ms(&self) -> u64 {
346 self.expires_at_ms
347 }
348}
349
350#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
352#[non_exhaustive]
353pub enum WorkflowBudgetForfeitReason {
354 Cancelled,
356 RecoveryExpired,
358}
359
360#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
362#[non_exhaustive]
363pub enum WorkflowBudgetAuditKind {
364 PolicyConfigured,
366 Reserved,
368 Adopted,
370 AdmissionDenied,
372 UsageExceeded,
374 Settled,
376 Forfeited(WorkflowBudgetForfeitReason),
378 WindowReset,
380}
381
382#[derive(Clone, Debug, Eq, PartialEq)]
384pub struct WorkflowBudgetAuditEvent {
385 pub cursor: WorkflowBudgetAuditCursor,
387 pub tenant_id: WorkflowTenantId,
389 pub checkpoint_id: Option<CheckpointId>,
391 pub occurred_at_ms: u64,
393 pub kind: WorkflowBudgetAuditKind,
395 pub usage: Usage,
397 pub reservation_age_ms: Option<u64>,
399 pub limit: Budget,
401 pub committed: Usage,
403 pub reserved: Usage,
405}
406
407#[derive(Clone, Copy, Debug, Eq, PartialEq)]
409#[non_exhaustive]
410pub enum WorkflowBudgetReservationOutcome {
411 NotConfigured,
413 Reserved,
415}
416
417#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
419#[serde(transparent)]
420pub struct WorkerId(String);
421
422impl WorkerId {
423 pub fn parse(value: impl Into<String>) -> Result<Self, WorkflowStoreError> {
429 let value = value.into();
430 if value.is_empty()
431 || value.len() > 128
432 || !value
433 .bytes()
434 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
435 {
436 return Err(WorkflowStoreError::invalid_input(
437 "worker identity must contain 1..=128 portable ASCII characters",
438 ));
439 }
440 Ok(Self(value))
441 }
442
443 pub fn as_str(&self) -> &str {
445 &self.0
446 }
447}
448
449#[derive(Clone, Copy, Debug, Eq, PartialEq)]
451pub struct LeaseDuration(NonZeroU64);
452
453impl LeaseDuration {
454 pub fn new(duration: Duration) -> Result<Self, WorkflowStoreError> {
460 let millis = u64::try_from(duration.as_millis())
461 .ok()
462 .and_then(NonZeroU64::new)
463 .ok_or_else(|| {
464 WorkflowStoreError::invalid_input(
465 "workflow lease must fit in a positive whole-millisecond duration",
466 )
467 })?;
468 Ok(Self(millis))
469 }
470
471 pub const fn as_millis(self) -> u64 {
473 self.0.get()
474 }
475}
476
477#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
479pub struct WorkflowTask {
480 pub checkpoint_id: CheckpointId,
482 pub tenant_id: WorkflowTenantId,
484 pub workflow: String,
486 pub workflow_version: u32,
488 pub input: Value,
490 pub priority: i32,
492}
493
494impl WorkflowTask {
495 pub fn new(
501 workflow: impl Into<String>,
502 workflow_version: u32,
503 input: Value,
504 ) -> Result<Self, WorkflowStoreError> {
505 let workflow = workflow.into();
506 if workflow.trim().is_empty() || workflow.len() > 256 {
507 return Err(WorkflowStoreError::invalid_input(
508 "workflow name must contain 1..=256 bytes",
509 ));
510 }
511 if workflow_version == 0 {
512 return Err(WorkflowStoreError::invalid_input(
513 "workflow version must be greater than zero",
514 ));
515 }
516 Ok(Self {
517 checkpoint_id: CheckpointId::new(),
518 tenant_id: WorkflowTenantId::default(),
519 workflow,
520 workflow_version,
521 input,
522 priority: 0,
523 })
524 }
525
526 #[must_use]
528 pub fn with_tenant(mut self, tenant_id: WorkflowTenantId) -> Self {
529 self.tenant_id = tenant_id;
530 self
531 }
532
533 #[must_use]
535 pub const fn with_checkpoint_id(mut self, checkpoint_id: CheckpointId) -> Self {
536 self.checkpoint_id = checkpoint_id;
537 self
538 }
539
540 #[must_use]
542 pub const fn with_priority(mut self, priority: i32) -> Self {
543 self.priority = priority;
544 self
545 }
546
547 pub fn validate(&self) -> Result<(), WorkflowStoreError> {
553 if self.workflow.trim().is_empty() || self.workflow.len() > 256 {
554 return Err(WorkflowStoreError::invalid_input(
555 "workflow name must contain 1..=256 bytes",
556 ));
557 }
558 if self.workflow_version == 0 {
559 return Err(WorkflowStoreError::invalid_input(
560 "workflow version must be greater than zero",
561 ));
562 }
563 Ok(())
564 }
565}
566
567#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
569pub struct WorkflowLease {
570 pub checkpoint_id: CheckpointId,
572 pub tenant_id: WorkflowTenantId,
574 pub worker: WorkerId,
576 pub fencing_token: u64,
578 pub attempt: u64,
580 pub expires_at_ms: u64,
582}
583
584#[derive(Clone, Debug, PartialEq)]
586pub struct ClaimedWorkflow {
587 pub task: WorkflowTask,
589 pub lease: WorkflowLease,
591 pub wake: Option<WorkflowWake>,
593}
594
595#[derive(Clone, Debug, Eq, PartialEq)]
597#[non_exhaustive]
598pub enum WorkflowDisposition {
599 Completed,
601 RetryAfter(Duration),
603 Suspend(WorkflowWait),
605 Failed(String),
607 Cancelled,
609}
610
611#[derive(Clone, Copy, Debug, Eq, PartialEq)]
613#[non_exhaustive]
614pub enum WorkflowTaskStatus {
615 Queued,
617 Leased,
619 Waiting,
621 Completed,
623 Failed,
625 Cancelled,
627}
628
629#[derive(Clone, Copy, Debug, Eq, PartialEq)]
631pub struct WorkflowTaskRetention(NonZeroU64);
632
633impl WorkflowTaskRetention {
634 pub fn new(duration: Duration) -> Result<Self, WorkflowStoreError> {
640 let millis = u64::try_from(duration.as_millis())
641 .ok()
642 .and_then(NonZeroU64::new)
643 .ok_or_else(|| {
644 WorkflowStoreError::invalid_input(
645 "workflow Task retention must fit in positive whole milliseconds",
646 )
647 })?;
648 Ok(Self(millis))
649 }
650
651 pub const fn as_millis(self) -> u64 {
653 self.0.get()
654 }
655}
656
657#[derive(Clone, Copy, Debug, Eq, PartialEq)]
659pub struct WorkflowTaskCleanupLimit(NonZeroU32);
660
661impl WorkflowTaskCleanupLimit {
662 pub fn new(value: u32) -> Result<Self, WorkflowStoreError> {
668 let value = NonZeroU32::new(value).ok_or_else(|| {
669 WorkflowStoreError::invalid_input("workflow Task cleanup limit must be positive")
670 })?;
671 if value.get() > 1_000 {
672 return Err(WorkflowStoreError::invalid_input(
673 "workflow Task cleanup limit cannot exceed 1,000",
674 ));
675 }
676 Ok(Self(value))
677 }
678
679 pub const fn get(self) -> u32 {
681 self.0.get()
682 }
683}
684
685#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
687pub struct WorkflowTaskTombstoneCursor(u64);
688
689impl WorkflowTaskTombstoneCursor {
690 pub const fn new(sequence: u64) -> Self {
692 Self(sequence)
693 }
694
695 pub const fn get(self) -> u64 {
697 self.0
698 }
699}
700
701#[derive(Clone, Copy, Debug, Eq, PartialEq)]
703pub struct WorkflowTaskTombstoneLimit(NonZeroU32);
704
705impl WorkflowTaskTombstoneLimit {
706 pub fn new(value: u32) -> Result<Self, WorkflowStoreError> {
712 let value = NonZeroU32::new(value).ok_or_else(|| {
713 WorkflowStoreError::invalid_input("workflow Task tombstone limit must be positive")
714 })?;
715 if value.get() > 1_000 {
716 return Err(WorkflowStoreError::invalid_input(
717 "workflow Task tombstone limit cannot exceed 1,000",
718 ));
719 }
720 Ok(Self(value))
721 }
722
723 pub const fn get(self) -> u32 {
725 self.0.get()
726 }
727}
728
729#[derive(Clone, Debug, Eq, PartialEq)]
731pub struct WorkflowTaskCleanupLease {
732 pub tenant_id: WorkflowTenantId,
734 pub owner: WorkerId,
736 pub fencing_token: u64,
738 pub expires_at_ms: u64,
740}
741
742#[derive(Clone, Debug, Eq, PartialEq)]
744pub struct WorkflowTaskTombstone {
745 pub cursor: WorkflowTaskTombstoneCursor,
747 pub checkpoint_id: CheckpointId,
749 pub tenant_id: WorkflowTenantId,
751 pub workflow: String,
753 pub workflow_version: u32,
755 pub final_status: WorkflowTaskStatus,
757 pub created_at_ms: u64,
759 pub terminal_at_ms: u64,
761 pub deleted_at_ms: u64,
763}
764
765#[derive(Clone, Copy, Debug, Eq, PartialEq)]
767#[non_exhaustive]
768pub enum WorkflowCancelOutcome {
769 Cancelled,
771 AlreadyTerminal,
773}
774
775#[derive(Clone, Debug, Eq, PartialEq)]
777pub struct WorkflowTaskSnapshot {
778 pub checkpoint_id: CheckpointId,
780 pub tenant_id: WorkflowTenantId,
782 pub workflow: String,
784 pub workflow_version: u32,
786 pub status: WorkflowTaskStatus,
788 pub created_at_ms: u64,
790 pub updated_at_ms: u64,
792 pub attempts: u64,
794 pub fencing_token: u64,
796 pub owner: Option<WorkerId>,
798 pub lease_expires_at_ms: Option<u64>,
800 pub interrupt: Option<WorkflowInterruptRequest>,
802 pub failure_message: Option<String>,
804 pub lineage: Option<WorkflowLineage>,
806}
807
808#[derive(Clone, Copy, Debug, Eq, PartialEq)]
810#[non_exhaustive]
811pub enum WorkflowStoreErrorKind {
812 InvalidInput,
814 NotFound,
816 Conflict,
818 LeaseLost,
820 AdmissionDenied,
822 TenantMismatch,
824 Storage,
826}
827
828#[derive(Clone, Debug, Error, Eq, PartialEq)]
830#[error("{kind:?}: {message}")]
831pub struct WorkflowStoreError {
832 pub kind: WorkflowStoreErrorKind,
834 pub message: String,
836}
837
838impl WorkflowStoreError {
839 pub fn new(kind: WorkflowStoreErrorKind, message: impl Into<String>) -> Self {
841 Self {
842 kind,
843 message: message.into(),
844 }
845 }
846
847 pub(super) fn invalid_input(message: impl Into<String>) -> Self {
848 Self::new(WorkflowStoreErrorKind::InvalidInput, message)
849 }
850}
851
852fn budget_is_unbounded(budget: Budget) -> bool {
853 budget.tokens.is_none()
854 && budget.cost_microusd.is_none()
855 && budget.duration.is_none()
856 && budget.turns.is_none()
857 && budget.tool_calls.is_none()
858 && budget.delegations.is_none()
859}
860
861fn validate_budget_duration(budget: Budget) -> Result<(), WorkflowStoreError> {
862 if let Some(duration) = budget.duration {
863 duration_micros(duration)?;
864 }
865 Ok(())
866}
867
868pub(super) fn duration_micros(duration: Duration) -> Result<u64, WorkflowStoreError> {
869 u64::try_from(duration.as_micros()).map_err(|_| {
870 WorkflowStoreError::invalid_input("budget duration exceeds supported microseconds")
871 })
872}