1pub mod readiness;
9
10use std::cell::{Cell, RefCell};
11use std::collections::{BTreeMap, HashSet};
12use std::panic::{catch_unwind, AssertUnwindSafe};
13use std::rc::Rc;
14
15use crate::ctx::Ctx;
16use crate::graph::{Graph, GraphNodeOpts};
17use crate::identity::{canonical_tuple_key, compound_tuple_key};
18use crate::messaging::{
19 attach_message_bus_deferred_command_sink, DataIssue, MessageBus, MessageBusAvailablePage,
20 MessageBusAvailableParams, MessageBusCommand, MessageBusStatus, MessageBusStatusKind,
21 MessageBusSubscriptionFrom, MessageBusSubscriptionOptions, MessageEnvelope,
22};
23use crate::node::{Core, Node, NodeOpts};
24use crate::protocol::{LockId, Message, PullDemand};
25use crate::resilience::{BackoffPolicy, RetryPolicy};
26
27#[derive(Debug, Clone, PartialEq)]
28pub struct WorkQueueSubmit<T> {
30 pub payload: T,
32 pub work_id: Option<String>,
34 pub priority: Option<i64>,
36 pub tags: Vec<String>,
38 pub requirements: Vec<String>,
40 pub not_before_ms: Option<u64>,
42 pub deadline_ms: Option<u64>,
44}
45
46impl<T> WorkQueueSubmit<T> {
47 pub fn new(payload: T) -> Self {
49 Self {
50 payload,
51 work_id: None,
52 priority: None,
53 tags: Vec::new(),
54 requirements: Vec::new(),
55 not_before_ms: None,
56 deadline_ms: None,
57 }
58 }
59
60 pub fn with_work_id(mut self, work_id: impl Into<String>) -> Self {
62 self.work_id = Some(work_id.into());
63 self
64 }
65
66 pub fn with_priority(mut self, priority: i64) -> Self {
68 self.priority = Some(priority);
69 self
70 }
71
72 pub fn with_not_before_ms(mut self, not_before_ms: u64) -> Self {
74 self.not_before_ms = Some(not_before_ms);
75 self
76 }
77}
78
79#[derive(Clone)]
80pub struct WorkQueueOptions<T> {
82 pub queue_id: String,
84 pub bus: MessageBus<WorkQueueSubmit<T>>,
86 pub topic: String,
88 pub subscription_id: String,
90 pub from: MessageBusSubscriptionFrom,
92 pub name: Option<String>,
94 pub now: Rc<dyn Fn() -> u64>,
96 pub lease_duration_ms: u64,
98 pub retry: RetryPolicy,
100}
101
102impl<T> WorkQueueOptions<T> {
103 pub fn new(
105 queue_id: impl Into<String>,
106 bus: MessageBus<WorkQueueSubmit<T>>,
107 topic: impl Into<String>,
108 subscription_id: impl Into<String>,
109 ) -> Self {
110 Self {
111 queue_id: queue_id.into(),
112 bus,
113 topic: topic.into(),
114 subscription_id: subscription_id.into(),
115 from: MessageBusSubscriptionFrom::Earliest,
116 name: None,
117 now: Rc::new(|| 0),
118 lease_duration_ms: 30_000,
119 retry: RetryPolicy::new(3, BackoffPolicy::None),
120 }
121 }
122
123 pub fn named(mut self, name: impl Into<String>) -> Self {
125 self.name = Some(name.into());
126 self
127 }
128
129 pub fn with_now(mut self, now: impl Fn() -> u64 + 'static) -> Self {
131 self.now = Rc::new(now);
132 self
133 }
134
135 pub fn with_lease_duration_ms(mut self, lease_duration_ms: u64) -> Self {
137 self.lease_duration_ms = lease_duration_ms;
138 self
139 }
140
141 pub fn with_retry(mut self, retry: RetryPolicy) -> Self {
143 self.retry = retry;
144 self
145 }
146}
147
148#[derive(Debug, Clone, PartialEq)]
149pub enum WorkQueueCommand<T = ()> {
151 Submit {
153 payload: T,
155 command_id: String,
157 queue_id: Option<String>,
159 idempotency_key: Option<String>,
161 },
162 Claim {
164 command_id: String,
166 queue_id: Option<String>,
168 idempotency_key: Option<String>,
170 worker_id: String,
172 requested_work_ids: Vec<String>,
174 limit: Option<usize>,
176 lease_duration_ms: Option<u64>,
178 now_ms: Option<u64>,
180 },
181 RenewLease {
183 command_id: String,
185 queue_id: Option<String>,
187 idempotency_key: Option<String>,
189 work_id: String,
191 lease_id: String,
193 attempt: u32,
195 worker_id: String,
197 lease_duration_ms: Option<u64>,
199 lease_expires_at_ms: Option<u64>,
201 now_ms: Option<u64>,
203 },
204 Release {
206 command_id: String,
208 queue_id: Option<String>,
210 idempotency_key: Option<String>,
212 work_id: String,
214 lease_id: String,
216 attempt: u32,
218 worker_id: String,
220 reason: Option<String>,
222 now_ms: Option<u64>,
224 },
225 Complete {
227 command_id: String,
229 queue_id: Option<String>,
231 idempotency_key: Option<String>,
233 work_id: String,
235 lease_id: String,
237 attempt: u32,
239 worker_id: String,
241 result: Option<String>,
243 now_ms: Option<u64>,
245 },
246 Fail {
248 command_id: String,
250 queue_id: Option<String>,
252 idempotency_key: Option<String>,
254 work_id: String,
256 lease_id: String,
258 attempt: u32,
260 worker_id: String,
262 error: Option<String>,
264 retryable: Option<bool>,
266 now_ms: Option<u64>,
268 },
269 Cancel {
271 command_id: String,
273 queue_id: Option<String>,
275 idempotency_key: Option<String>,
277 work_id: String,
279 reason: Option<String>,
281 now_ms: Option<u64>,
283 },
284 Schedule {
286 command_id: String,
288 queue_id: Option<String>,
290 idempotency_key: Option<String>,
292 work_id: String,
294 schedule_id: Option<String>,
296 not_before_ms: u64,
298 deadline_ms: Option<u64>,
300 reason: Option<String>,
302 now_ms: Option<u64>,
304 },
305 ExpireLeases {
307 command_id: String,
309 queue_id: Option<String>,
311 idempotency_key: Option<String>,
313 work_ids: Vec<String>,
315 limit: Option<usize>,
317 now_ms: Option<u64>,
319 },
320}
321
322impl<T> WorkQueueCommand<T> {
323 fn command_id(&self) -> &str {
324 match self {
325 Self::Submit { command_id, .. }
326 | Self::Claim { command_id, .. }
327 | Self::RenewLease { command_id, .. }
328 | Self::Release { command_id, .. }
329 | Self::Complete { command_id, .. }
330 | Self::Fail { command_id, .. }
331 | Self::Cancel { command_id, .. }
332 | Self::Schedule { command_id, .. }
333 | Self::ExpireLeases { command_id, .. } => command_id,
334 }
335 }
336
337 fn queue_id(&self) -> Option<&str> {
338 match self {
339 Self::Submit { queue_id, .. }
340 | Self::Claim { queue_id, .. }
341 | Self::RenewLease { queue_id, .. }
342 | Self::Release { queue_id, .. }
343 | Self::Complete { queue_id, .. }
344 | Self::Fail { queue_id, .. }
345 | Self::Cancel { queue_id, .. }
346 | Self::Schedule { queue_id, .. }
347 | Self::ExpireLeases { queue_id, .. } => queue_id.as_deref(),
348 }
349 }
350
351 fn idempotency_key(&self) -> Option<&str> {
352 match self {
353 Self::Submit {
354 idempotency_key, ..
355 }
356 | Self::Claim {
357 idempotency_key, ..
358 }
359 | Self::RenewLease {
360 idempotency_key, ..
361 }
362 | Self::Release {
363 idempotency_key, ..
364 }
365 | Self::Complete {
366 idempotency_key, ..
367 }
368 | Self::Fail {
369 idempotency_key, ..
370 }
371 | Self::Cancel {
372 idempotency_key, ..
373 }
374 | Self::Schedule {
375 idempotency_key, ..
376 }
377 | Self::ExpireLeases {
378 idempotency_key, ..
379 } => idempotency_key.as_deref(),
380 }
381 }
382
383 fn now_ms(&self) -> Option<u64> {
384 match self {
385 Self::Claim { now_ms, .. }
386 | Self::RenewLease { now_ms, .. }
387 | Self::Release { now_ms, .. }
388 | Self::Complete { now_ms, .. }
389 | Self::Fail { now_ms, .. }
390 | Self::Cancel { now_ms, .. }
391 | Self::Schedule { now_ms, .. }
392 | Self::ExpireLeases { now_ms, .. } => *now_ms,
393 Self::Submit { .. } => None,
394 }
395 }
396}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
399pub struct WorkQueueMessageBusRef {
401 pub topic: String,
403 pub seq: u64,
405 pub subscription_id: String,
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
410pub enum WorkQueueDerivedState {
412 Scheduled,
414 Ready,
416 Leased,
418 RetryWait,
420 Completed,
422 Canceled,
424 DeadLettered,
426}
427
428#[derive(Debug, Clone, PartialEq)]
429pub enum WorkQueueRecord<T> {
431 WorkAdmitted {
433 record_seq: u64,
435 queue_id: String,
437 work_id: String,
439 payload: T,
441 message_bus: WorkQueueMessageBusRef,
443 priority: Option<i64>,
445 tags: Vec<String>,
447 requirements: Vec<String>,
449 not_before_ms: Option<u64>,
451 deadline_ms: Option<u64>,
453 recorded_at_ms: u64,
455 },
456 AdmissionDeduped {
458 record_seq: u64,
460 queue_id: String,
462 work_id: String,
464 message_bus: WorkQueueMessageBusRef,
466 reason: String,
468 existing_work_id: String,
470 recorded_at_ms: u64,
472 },
473 WorkScheduled {
475 record_seq: u64,
477 queue_id: String,
479 work_id: String,
481 command_id: String,
483 schedule_id: Option<String>,
485 not_before_ms: u64,
487 deadline_ms: Option<u64>,
489 reason: Option<String>,
491 recorded_at_ms: u64,
493 },
494 WorkClaimed {
496 record_seq: u64,
498 queue_id: String,
500 work_id: String,
502 command_id: String,
504 lease_id: String,
506 attempt: u32,
508 worker_id: String,
510 claimed_at_ms: u64,
512 lease_expires_at_ms: u64,
514 },
515 LeaseRenewed {
517 record_seq: u64,
519 queue_id: String,
521 work_id: String,
523 command_id: String,
525 lease_id: String,
527 attempt: u32,
529 worker_id: String,
531 previous_lease_expires_at_ms: u64,
533 lease_expires_at_ms: u64,
535 renewed_at_ms: u64,
537 },
538 WorkReleased {
540 record_seq: u64,
542 queue_id: String,
544 work_id: String,
546 command_id: String,
548 lease_id: String,
550 attempt: u32,
552 worker_id: String,
554 released_at_ms: u64,
556 reason: Option<String>,
558 },
559 LeaseExpired {
561 record_seq: u64,
563 queue_id: String,
565 work_id: String,
567 command_id: Option<String>,
569 lease_id: String,
571 attempt: u32,
573 worker_id: String,
575 lease_expires_at_ms: u64,
577 expired_at_ms: u64,
579 },
580 AttemptCompleted {
582 record_seq: u64,
584 queue_id: String,
586 work_id: String,
588 command_id: String,
590 lease_id: String,
592 attempt: u32,
594 worker_id: String,
596 result: Option<String>,
598 recorded_at_ms: u64,
600 },
601 WorkCompleted {
603 record_seq: u64,
605 queue_id: String,
607 work_id: String,
609 command_id: String,
611 lease_id: String,
613 attempt: u32,
615 worker_id: String,
617 result: Option<String>,
619 recorded_at_ms: u64,
621 },
622 AttemptFailed {
624 record_seq: u64,
626 queue_id: String,
628 work_id: String,
630 command_id: String,
632 lease_id: String,
634 attempt: u32,
636 worker_id: String,
638 error: Option<String>,
640 retryable: Option<bool>,
642 recorded_at_ms: u64,
644 },
645 RetryScheduled {
647 record_seq: u64,
649 queue_id: String,
651 work_id: String,
653 command_id: String,
655 retry_at_ms: u64,
657 delay_ms: u64,
659 reason: Option<String>,
661 recorded_at_ms: u64,
663 },
664 WorkDeadLettered {
666 record_seq: u64,
668 queue_id: String,
670 work_id: String,
672 command_id: String,
674 reason: String,
676 exhausted_attempts: Option<u32>,
678 recorded_at_ms: u64,
680 },
681 WorkCanceled {
683 record_seq: u64,
685 queue_id: String,
687 work_id: String,
689 command_id: String,
691 reason: Option<String>,
693 canceled_at_ms: u64,
695 canceled_lease_id: Option<String>,
697 attempt: Option<u32>,
699 },
700}
701
702impl<T> WorkQueueRecord<T> {
703 pub fn record_seq(&self) -> u64 {
705 match self {
706 Self::WorkAdmitted { record_seq, .. }
707 | Self::AdmissionDeduped { record_seq, .. }
708 | Self::WorkScheduled { record_seq, .. }
709 | Self::WorkClaimed { record_seq, .. }
710 | Self::LeaseRenewed { record_seq, .. }
711 | Self::WorkReleased { record_seq, .. }
712 | Self::LeaseExpired { record_seq, .. }
713 | Self::AttemptCompleted { record_seq, .. }
714 | Self::WorkCompleted { record_seq, .. }
715 | Self::AttemptFailed { record_seq, .. }
716 | Self::RetryScheduled { record_seq, .. }
717 | Self::WorkDeadLettered { record_seq, .. }
718 | Self::WorkCanceled { record_seq, .. } => *record_seq,
719 }
720 }
721
722 pub fn work_id(&self) -> &str {
724 match self {
725 Self::WorkAdmitted { work_id, .. }
726 | Self::AdmissionDeduped { work_id, .. }
727 | Self::WorkScheduled { work_id, .. }
728 | Self::WorkClaimed { work_id, .. }
729 | Self::LeaseRenewed { work_id, .. }
730 | Self::WorkReleased { work_id, .. }
731 | Self::LeaseExpired { work_id, .. }
732 | Self::AttemptCompleted { work_id, .. }
733 | Self::WorkCompleted { work_id, .. }
734 | Self::AttemptFailed { work_id, .. }
735 | Self::RetryScheduled { work_id, .. }
736 | Self::WorkDeadLettered { work_id, .. }
737 | Self::WorkCanceled { work_id, .. } => work_id,
738 }
739 }
740
741 fn admission_message_bus(&self) -> Option<&WorkQueueMessageBusRef> {
742 match self {
743 Self::WorkAdmitted { message_bus, .. } | Self::AdmissionDeduped { message_bus, .. } => {
744 Some(message_bus)
745 }
746 _ => None,
747 }
748 }
749}
750
751#[derive(Debug, Clone, Copy, PartialEq, Eq)]
752pub enum WorkQueueStatusKind {
754 CommandAccepted,
756 CommandRejected,
758 AdmissionAccepted,
760 AdmissionRejected,
762 ProjectionReady,
764 ProjectionPartial,
766 MaintenanceApplied,
768 MaintenanceNoop,
770 PolicyWarning,
772}
773
774#[derive(Debug, Clone, PartialEq, Eq)]
775pub struct WorkQueueStatus {
777 pub kind: WorkQueueStatusKind,
779 pub queue_id: String,
781 pub work_id: Option<String>,
783 pub command_id: Option<String>,
785 pub record_seq: Option<u64>,
787 pub as_of_record_seq: Option<u64>,
789 pub issue_code: Option<String>,
791 pub timestamp_ms: u64,
793 pub details: Option<String>,
795}
796
797#[derive(Debug, Clone, PartialEq)]
798pub struct WorkQueueAvailableItem<T> {
800 pub work_id: String,
802 pub state: WorkQueueDerivedState,
804 pub payload: T,
806 pub admission_seq: u64,
808 pub priority: Option<i64>,
810 pub tags: Vec<String>,
812 pub requirements: Vec<String>,
814 pub not_before_ms: Option<u64>,
816 pub retry_at_ms: Option<u64>,
818 pub deadline_ms: Option<u64>,
820}
821
822#[derive(Debug, Clone, PartialEq)]
823pub struct WorkQueueAvailablePage<T> {
825 pub items: Vec<WorkQueueAvailableItem<T>>,
827 pub next_after_work_id: Option<String>,
829 pub next_after_admission_seq: Option<u64>,
831 pub has_more: bool,
833 pub as_of_record_seq: u64,
835}
836
837#[derive(Debug, Clone, PartialEq, Eq)]
838pub struct WorkQueueActiveLease {
840 pub lease_id: String,
842 pub attempt: u32,
844 pub worker_id: String,
846 pub lease_expires_at_ms: u64,
848}
849
850#[derive(Debug, Clone, PartialEq)]
851pub struct WorkQueueWorkSnapshot<T> {
853 pub work_id: String,
855 pub state: Option<WorkQueueDerivedState>,
857 pub payload: Option<T>,
859 pub active_lease: Option<WorkQueueActiveLease>,
861 pub records: Vec<WorkQueueRecord<T>>,
863 pub as_of_record_seq: u64,
865}
866
867#[derive(Debug, Clone, PartialEq)]
868pub struct WorkQueueDeadLetterPage<T> {
870 pub entries: Vec<WorkQueueRecord<T>>,
872 pub next_after_dead_letter_seq: Option<u64>,
874 pub has_more: bool,
876 pub as_of_record_seq: u64,
878}
879
880#[derive(Debug, Clone, PartialEq, Eq, Default)]
881pub struct WorkQueueAvailableParams {
883 pub limit: Option<usize>,
885 pub after_work_id: Option<String>,
887 pub after_admission_seq: Option<u64>,
889 pub now_ms: Option<u64>,
891}
892
893#[derive(Debug, Clone, PartialEq, Eq, Default)]
894pub struct WorkQueueDeadLetterParams {
896 pub limit: Option<usize>,
898 pub after_dead_letter_seq: Option<u64>,
900 pub after_work_id: Option<String>,
902}
903
904#[derive(Clone)]
905pub struct WorkQueueProjection<TPage> {
907 pub snapshot: Node<TPage>,
909 pub snapshot_pull_id: LockId,
911 pub status: Node<WorkQueueStatus>,
913 pub issues: Node<DataIssue>,
915}
916
917#[derive(Clone)]
918pub struct WorkQueueAvailableProjection<T> {
920 pub available: Node<WorkQueueAvailablePage<T>>,
922 pub available_pull_id: LockId,
924 pub status: Node<WorkQueueStatus>,
926 pub issues: Node<DataIssue>,
928}
929
930#[derive(Clone)]
931pub struct WorkQueue<T> {
933 name: Rc<String>,
934 queue_id: Rc<String>,
935 topic: Rc<String>,
936 bus: MessageBus<WorkQueueSubmit<T>>,
937 command_seq: Rc<Cell<u64>>,
938 pub commands: Node<WorkQueueCommand<T>>,
940 pub records: Node<WorkQueueRecord<T>>,
942 pub status: Node<WorkQueueStatus>,
944 pub issues: Node<DataIssue>,
946 state: Rc<RefCell<RuntimeState<T>>>,
947 graph: Graph,
948 _admission_ack_commands: Node<MessageBusCommand<WorkQueueSubmit<T>>>,
949 _retain: Rc<WorkQueueRetain>,
950}
951
952struct WorkQueueRetain {
953 releases: RefCell<Vec<Box<dyn FnOnce()>>>,
954}
955
956impl WorkQueueRetain {
957 fn new(releases: Vec<Box<dyn FnOnce()>>) -> Self {
958 Self {
959 releases: RefCell::new(releases),
960 }
961 }
962}
963
964impl Drop for WorkQueueRetain {
965 fn drop(&mut self) {
966 for release in self.releases.borrow_mut().drain(..) {
967 release();
968 }
969 }
970}
971
972#[derive(Clone)]
973enum QueueEvent<T> {
974 Record(WorkQueueRecord<T>),
975 Status(WorkQueueStatus),
976 Issue(DataIssue),
977}
978
979#[derive(Clone)]
980struct WorkState<T> {
981 work_id: String,
982 payload: T,
983 state: WorkQueueDerivedState,
984 admission_seq: u64,
985 priority: Option<i64>,
986 tags: Vec<String>,
987 requirements: Vec<String>,
988 not_before_ms: Option<u64>,
989 retry_at_ms: Option<u64>,
990 deadline_ms: Option<u64>,
991 attempt: u32,
992 lease_id: Option<String>,
993 worker_id: Option<String>,
994 lease_expires_at_ms: Option<u64>,
995}
996
997struct RuntimeState<T> {
998 record_seq: u64,
999 lease_seq: u64,
1000 works: BTreeMap<String, WorkState<T>>,
1001 source_seqs: HashSet<String>,
1002 command_ids: HashSet<String>,
1003 idempotency_keys: HashSet<String>,
1004 records: Vec<WorkQueueRecord<T>>,
1005 dead_letters: Vec<WorkQueueRecord<T>>,
1006}
1007
1008impl<T> Default for RuntimeState<T> {
1009 fn default() -> Self {
1010 Self {
1011 record_seq: 0,
1012 lease_seq: 0,
1013 works: BTreeMap::new(),
1014 source_seqs: HashSet::new(),
1015 command_ids: HashSet::new(),
1016 idempotency_keys: HashSet::new(),
1017 records: Vec::new(),
1018 dead_letters: Vec::new(),
1019 }
1020 }
1021}
1022
1023impl<T: Clone + 'static> WorkQueue<T> {
1024 pub fn submit(
1026 &self,
1027 payload: T,
1028 opts: WorkQueueSubmitOptions,
1029 ) -> MessageBusCommand<WorkQueueSubmit<T>> {
1030 let command_id = opts
1031 .command_id
1032 .unwrap_or_else(|| self.next_command_id("submit"));
1033 self.bus.publish(
1034 (*self.topic).clone(),
1035 WorkQueueSubmit {
1036 payload,
1037 work_id: opts.work_id,
1038 priority: opts.priority,
1039 tags: opts.tags,
1040 requirements: opts.requirements,
1041 not_before_ms: opts.not_before_ms,
1042 deadline_ms: opts.deadline_ms,
1043 },
1044 None,
1045 Some(command_id),
1046 opts.idempotency_key,
1047 )
1048 }
1049
1050 pub fn claim(&self, opts: WorkQueueClaimOptions) -> WorkQueueCommand<T> {
1052 self.publish_command(WorkQueueCommand::Claim {
1053 command_id: opts
1054 .command_id
1055 .unwrap_or_else(|| self.next_command_id("claim")),
1056 queue_id: opts.queue_id,
1057 idempotency_key: opts.idempotency_key,
1058 worker_id: opts.worker_id,
1059 requested_work_ids: opts.requested_work_ids,
1060 limit: opts.limit,
1061 lease_duration_ms: opts.lease_duration_ms,
1062 now_ms: opts.now_ms,
1063 })
1064 }
1065
1066 pub fn renew_lease(
1068 &self,
1069 work_id: impl Into<String>,
1070 lease_id: impl Into<String>,
1071 attempt: u32,
1072 worker_id: impl Into<String>,
1073 command_id: impl Into<String>,
1074 ) -> WorkQueueCommand<T> {
1075 self.publish_command(WorkQueueCommand::RenewLease {
1076 command_id: command_id.into(),
1077 queue_id: None,
1078 idempotency_key: None,
1079 work_id: work_id.into(),
1080 lease_id: lease_id.into(),
1081 attempt,
1082 worker_id: worker_id.into(),
1083 lease_duration_ms: None,
1084 lease_expires_at_ms: None,
1085 now_ms: None,
1086 })
1087 }
1088
1089 pub fn release(
1091 &self,
1092 work_id: impl Into<String>,
1093 lease_id: impl Into<String>,
1094 attempt: u32,
1095 worker_id: impl Into<String>,
1096 command_id: impl Into<String>,
1097 ) -> WorkQueueCommand<T> {
1098 self.publish_command(WorkQueueCommand::Release {
1099 command_id: command_id.into(),
1100 queue_id: None,
1101 idempotency_key: None,
1102 work_id: work_id.into(),
1103 lease_id: lease_id.into(),
1104 attempt,
1105 worker_id: worker_id.into(),
1106 reason: None,
1107 now_ms: None,
1108 })
1109 }
1110
1111 pub fn complete(
1113 &self,
1114 work_id: impl Into<String>,
1115 lease_id: impl Into<String>,
1116 attempt: u32,
1117 worker_id: impl Into<String>,
1118 command_id: impl Into<String>,
1119 result: Option<String>,
1120 ) -> WorkQueueCommand<T> {
1121 self.publish_command(WorkQueueCommand::Complete {
1122 command_id: command_id.into(),
1123 queue_id: None,
1124 idempotency_key: None,
1125 work_id: work_id.into(),
1126 lease_id: lease_id.into(),
1127 attempt,
1128 worker_id: worker_id.into(),
1129 result,
1130 now_ms: None,
1131 })
1132 }
1133
1134 pub fn fail(
1136 &self,
1137 work_id: impl Into<String>,
1138 lease_id: impl Into<String>,
1139 attempt: u32,
1140 worker_id: impl Into<String>,
1141 command_id: impl Into<String>,
1142 retryable: Option<bool>,
1143 ) -> WorkQueueCommand<T> {
1144 self.publish_command(WorkQueueCommand::Fail {
1145 command_id: command_id.into(),
1146 queue_id: None,
1147 idempotency_key: None,
1148 work_id: work_id.into(),
1149 lease_id: lease_id.into(),
1150 attempt,
1151 worker_id: worker_id.into(),
1152 error: None,
1153 retryable,
1154 now_ms: None,
1155 })
1156 }
1157
1158 pub fn cancel(
1160 &self,
1161 work_id: impl Into<String>,
1162 command_id: impl Into<String>,
1163 reason: Option<String>,
1164 ) -> WorkQueueCommand<T> {
1165 self.publish_command(WorkQueueCommand::Cancel {
1166 command_id: command_id.into(),
1167 queue_id: None,
1168 idempotency_key: None,
1169 work_id: work_id.into(),
1170 reason,
1171 now_ms: None,
1172 })
1173 }
1174
1175 pub fn schedule(
1177 &self,
1178 work_id: impl Into<String>,
1179 not_before_ms: u64,
1180 command_id: impl Into<String>,
1181 ) -> WorkQueueCommand<T> {
1182 self.publish_command(WorkQueueCommand::Schedule {
1183 command_id: command_id.into(),
1184 queue_id: None,
1185 idempotency_key: None,
1186 work_id: work_id.into(),
1187 schedule_id: None,
1188 not_before_ms,
1189 deadline_ms: None,
1190 reason: None,
1191 now_ms: None,
1192 })
1193 }
1194
1195 pub fn expire_leases(&self, command_id: impl Into<String>) -> WorkQueueCommand<T> {
1197 self.publish_command(WorkQueueCommand::ExpireLeases {
1198 command_id: command_id.into(),
1199 queue_id: None,
1200 idempotency_key: None,
1201 work_ids: Vec::new(),
1202 limit: None,
1203 now_ms: None,
1204 })
1205 }
1206
1207 pub fn available(&self) -> WorkQueueAvailableProjection<T> {
1209 self.available_named(None::<String>)
1210 }
1211
1212 pub fn available_named(
1214 &self,
1215 name: Option<impl Into<String>>,
1216 ) -> WorkQueueAvailableProjection<T> {
1217 let available_pull_id = LockId::new(format!("{}/available", self.name));
1218 let state = self.state.clone();
1219 let snapshot = self.graph.node_opts::<WorkQueueAvailablePage<T>, _>(
1220 vec![self.records.erased()],
1221 move |ctx| {
1222 let params = pull_params::<WorkQueueAvailableParams>(ctx);
1223 ctx.emit(available_page(&state.borrow(), ¶ms));
1224 },
1225 pull_node_opts(
1226 name.map(Into::into)
1227 .unwrap_or_else(|| format!("{}/available", self.name)),
1228 "workQueueAvailable",
1229 available_pull_id.clone(),
1230 ),
1231 );
1232 WorkQueueAvailableProjection {
1233 available: snapshot,
1234 available_pull_id,
1235 status: self.status.clone(),
1236 issues: self.issues.clone(),
1237 }
1238 }
1239
1240 pub fn work(
1242 &self,
1243 work_id: impl Into<String>,
1244 ) -> WorkQueueProjection<WorkQueueWorkSnapshot<T>> {
1245 self.work_named(work_id, None::<String>)
1246 }
1247
1248 pub fn work_named(
1250 &self,
1251 work_id: impl Into<String>,
1252 name: Option<impl Into<String>>,
1253 ) -> WorkQueueProjection<WorkQueueWorkSnapshot<T>> {
1254 let work_id = work_id.into();
1255 let snapshot_pull_id = LockId::new(format!("{}/{work_id}/snapshot", self.name));
1256 let state = self.state.clone();
1257 let work_id_for_fn = work_id.clone();
1258 let snapshot = self.graph.node_opts::<WorkQueueWorkSnapshot<T>, _>(
1259 vec![self.records.erased()],
1260 move |ctx| {
1261 let _ = ctx.pull();
1262 ctx.emit(work_snapshot(&state.borrow(), &work_id_for_fn));
1263 },
1264 pull_node_opts(
1265 name.map(Into::into)
1266 .unwrap_or_else(|| format!("{}/{work_id}", self.name)),
1267 "workQueueWorkSnapshot",
1268 snapshot_pull_id.clone(),
1269 ),
1270 );
1271 WorkQueueProjection {
1272 snapshot,
1273 snapshot_pull_id,
1274 status: self.status.clone(),
1275 issues: self.issues.clone(),
1276 }
1277 }
1278
1279 pub fn dead_letter(&self) -> WorkQueueProjection<WorkQueueDeadLetterPage<T>> {
1281 self.dead_letter_named(None::<String>)
1282 }
1283
1284 pub fn dead_letter_named(
1286 &self,
1287 name: Option<impl Into<String>>,
1288 ) -> WorkQueueProjection<WorkQueueDeadLetterPage<T>> {
1289 let snapshot_pull_id = LockId::new(format!("{}/deadLetter", self.name));
1290 let state = self.state.clone();
1291 let snapshot = self.graph.node_opts::<WorkQueueDeadLetterPage<T>, _>(
1292 vec![self.records.erased()],
1293 move |ctx| {
1294 let params = pull_params::<WorkQueueDeadLetterParams>(ctx);
1295 ctx.emit(dead_letter_page(&state.borrow(), ¶ms));
1296 },
1297 pull_node_opts(
1298 name.map(Into::into)
1299 .unwrap_or_else(|| format!("{}/deadLetter", self.name)),
1300 "workQueueDeadLetter",
1301 snapshot_pull_id.clone(),
1302 ),
1303 );
1304 WorkQueueProjection {
1305 snapshot,
1306 snapshot_pull_id,
1307 status: self.status.clone(),
1308 issues: self.issues.clone(),
1309 }
1310 }
1311
1312 fn publish_command(&self, command: WorkQueueCommand<T>) -> WorkQueueCommand<T> {
1313 self.commands.set(command.clone());
1314 command
1315 }
1316
1317 fn next_command_id(&self, kind: &str) -> String {
1318 let next = self.command_seq.get() + 1;
1319 self.command_seq.set(next);
1320 compound_tuple_key(
1321 "work-queue-command",
1322 &[&self.queue_id, kind, &next.to_string()],
1323 )
1324 }
1325}
1326
1327#[derive(Debug, Clone, Default, PartialEq, Eq)]
1328pub struct WorkQueueSubmitOptions {
1330 pub command_id: Option<String>,
1332 pub idempotency_key: Option<String>,
1334 pub work_id: Option<String>,
1336 pub priority: Option<i64>,
1338 pub tags: Vec<String>,
1340 pub requirements: Vec<String>,
1342 pub not_before_ms: Option<u64>,
1344 pub deadline_ms: Option<u64>,
1346}
1347
1348#[derive(Debug, Clone, PartialEq, Eq)]
1349pub struct WorkQueueClaimOptions {
1351 pub command_id: Option<String>,
1353 pub queue_id: Option<String>,
1355 pub idempotency_key: Option<String>,
1357 pub worker_id: String,
1359 pub requested_work_ids: Vec<String>,
1361 pub limit: Option<usize>,
1363 pub lease_duration_ms: Option<u64>,
1365 pub now_ms: Option<u64>,
1367}
1368
1369impl WorkQueueClaimOptions {
1370 pub fn new(worker_id: impl Into<String>) -> Self {
1372 Self {
1373 command_id: None,
1374 queue_id: None,
1375 idempotency_key: None,
1376 worker_id: worker_id.into(),
1377 requested_work_ids: Vec::new(),
1378 limit: None,
1379 lease_duration_ms: None,
1380 now_ms: None,
1381 }
1382 }
1383
1384 pub fn command_id(mut self, command_id: impl Into<String>) -> Self {
1386 self.command_id = Some(command_id.into());
1387 self
1388 }
1389
1390 pub fn requested_work_ids(mut self, ids: impl IntoIterator<Item = impl Into<String>>) -> Self {
1392 self.requested_work_ids = ids.into_iter().map(Into::into).collect();
1393 self
1394 }
1395
1396 pub fn idempotency_key(mut self, idempotency_key: impl Into<String>) -> Self {
1398 self.idempotency_key = Some(idempotency_key.into());
1399 self
1400 }
1401
1402 pub fn now_ms(mut self, now_ms: u64) -> Self {
1404 self.now_ms = Some(now_ms);
1405 self
1406 }
1407}
1408
1409pub fn work_queue<T: Clone + 'static>(graph: &Graph, opts: WorkQueueOptions<T>) -> WorkQueue<T> {
1411 assert_non_empty(&opts.queue_id, "workQueue.queueId");
1412 assert_non_empty(&opts.topic, "workQueue.topic");
1413 assert_non_empty(&opts.subscription_id, "workQueue.subscriptionId");
1414 assert!(
1415 opts.lease_duration_ms > 0,
1416 "workQueue: leaseDurationMs must be positive"
1417 );
1418 assert!(
1419 opts.retry.max_attempts > 0,
1420 "workQueue: retry.maxAttempts must be positive"
1421 );
1422
1423 let queue_id = Rc::new(opts.queue_id.clone());
1424 let name = Rc::new(
1425 opts.name
1426 .clone()
1427 .unwrap_or_else(|| format!("workQueue/{}", opts.queue_id)),
1428 );
1429 let topic = Rc::new(opts.topic.clone());
1430 let admission = opts.bus.subscription(
1431 MessageBusSubscriptionOptions::new(opts.topic.clone(), opts.subscription_id.clone())
1432 .from(opts.from)
1433 .named(format!("{name}/admission")),
1434 );
1435 let commands = graph.node_opts::<WorkQueueCommand<T>, _>(
1436 Vec::new(),
1437 work_queue_command_body::<T>(0),
1438 node_opts(format!("{name}/commands"), "workQueueCommands"),
1439 );
1440 let state = Rc::new(RefCell::new(RuntimeState::default()));
1441 let admission_kick = graph.state_opts(
1442 "poll".to_owned(),
1443 node_opts(format!("{name}/admissionKick"), "workQueueAdmissionKick"),
1444 );
1445 let admission_pages = graph.node_opts::<MessageBusAvailablePage<WorkQueueSubmit<T>>, _>(
1446 vec![
1447 admission.available.erased(),
1448 admission_kick.erased(),
1449 opts.bus.messages.erased(),
1450 opts.bus.status.erased(),
1451 ],
1452 {
1453 let admission_pull_id = admission.available_pull_id.clone();
1454 let topic = opts.topic.clone();
1455 let subscription_id = opts.subscription_id.clone();
1456 move |ctx| {
1457 for page in ctx.batch::<MessageBusAvailablePage<WorkQueueSubmit<T>>>(0) {
1458 ctx.emit((*page).clone());
1459 }
1460 let mut should_pull = !ctx.batch::<String>(1).is_empty();
1461 for message in ctx.batch::<MessageEnvelope<WorkQueueSubmit<T>>>(2) {
1462 if message.topic == topic {
1463 should_pull = true;
1464 }
1465 }
1466 for status in ctx.batch::<MessageBusStatus>(3) {
1467 if should_poll_admission(&status, &topic, &subscription_id) {
1468 should_pull = true;
1469 }
1470 }
1471 if should_pull {
1472 ctx.up_next_toward(
1473 0,
1474 vec![Message::Pull(PullDemand::with_params(
1475 admission_pull_id.clone(),
1476 MessageBusAvailableParams {
1477 limit: Some(100),
1478 after_seq: None,
1479 },
1480 ))],
1481 );
1482 }
1483 }
1484 },
1485 {
1486 let mut opts = node_opts(format!("{name}/admissionPages"), "workQueueAdmissionPages");
1487 opts.node.partial = true;
1488 opts
1489 },
1490 );
1491 let runtime_state = state.clone();
1492 let runtime_opts = RuntimeOptions {
1493 queue_id: opts.queue_id.clone(),
1494 subscription_id: opts.subscription_id.clone(),
1495 now: opts.now.clone(),
1496 lease_duration_ms: opts.lease_duration_ms,
1497 retry: opts.retry,
1498 };
1499 let runtime = graph.node_opts::<QueueEvent<T>, _>(
1500 vec![commands.erased(), admission_pages.erased()],
1501 move |ctx| {
1502 for page in ctx.batch::<MessageBusAvailablePage<WorkQueueSubmit<T>>>(1) {
1503 for message in &page.messages {
1504 let now_ms = timestamp_or_zero(&runtime_opts.now);
1505 let events = {
1506 let mut state = runtime_state.borrow_mut();
1507 admit_message(&runtime_opts, &mut state, message, now_ms)
1508 };
1509 for event in events {
1510 ctx.emit(event);
1511 }
1512 }
1513 }
1514 for command in ctx.batch::<WorkQueueCommand<T>>(0) {
1515 let now_ms = command
1516 .now_ms()
1517 .unwrap_or_else(|| timestamp_or_zero(&runtime_opts.now));
1518 let events = {
1519 let mut state = runtime_state.borrow_mut();
1520 reduce_queue_command(&runtime_opts, &mut state, (*command).clone(), now_ms)
1521 };
1522 for event in events {
1523 ctx.emit(event);
1524 }
1525 }
1526 },
1527 {
1528 let mut opts = node_opts(format!("{name}/runtime"), "workQueueRuntime");
1529 opts.node.partial = true;
1530 opts
1531 },
1532 );
1533 let records = graph.node_opts::<WorkQueueRecord<T>, _>(
1534 vec![runtime.erased()],
1535 move |ctx| {
1536 for event in ctx.batch::<QueueEvent<T>>(0) {
1537 if let QueueEvent::Record(record) = event.as_ref() {
1538 ctx.emit(record.clone());
1539 }
1540 }
1541 },
1542 node_opts(format!("{name}/records"), "workQueueRecords"),
1543 );
1544 let status = graph.node_opts::<WorkQueueStatus, _>(
1545 vec![runtime.erased()],
1546 move |ctx| {
1547 for event in ctx.batch::<QueueEvent<T>>(0) {
1548 if let QueueEvent::Status(status) = event.as_ref() {
1549 ctx.emit(status.clone());
1550 }
1551 }
1552 },
1553 node_opts(format!("{name}/status"), "workQueueStatus"),
1554 );
1555 let issues = graph.node_opts::<DataIssue, _>(
1556 vec![runtime.erased()],
1557 move |ctx| {
1558 for event in ctx.batch::<QueueEvent<T>>(0) {
1559 if let QueueEvent::Issue(issue) = event.as_ref() {
1560 ctx.emit(issue.clone());
1561 }
1562 }
1563 },
1564 node_opts(format!("{name}/issues"), "workQueueIssues"),
1565 );
1566 let admission_ack_commands = graph.node_opts::<MessageBusCommand<WorkQueueSubmit<T>>, _>(
1567 vec![records.erased()],
1568 {
1569 move |ctx| {
1570 for record in ctx.batch::<WorkQueueRecord<T>>(0) {
1571 if let Some(command) = admission_ack_command(&record) {
1572 ctx.emit(command);
1573 }
1574 }
1575 }
1576 },
1577 node_opts(
1578 format!("{name}/admissionAckCommands"),
1579 "workQueueAdmissionAckCommands",
1580 ),
1581 );
1582 let ack_release =
1583 attach_message_bus_deferred_command_sink(graph, &opts.bus, &admission_ack_commands);
1584 let runtime_release = graph.retain(&runtime, &format!("{name}.workQueue.runtime"));
1585 WorkQueue {
1586 name,
1587 queue_id,
1588 topic,
1589 bus: opts.bus,
1590 command_seq: Rc::new(Cell::new(0)),
1591 commands,
1592 records,
1593 status,
1594 issues,
1595 state,
1596 graph: graph.clone(),
1597 _admission_ack_commands: admission_ack_commands,
1598 _retain: Rc::new(WorkQueueRetain::new(vec![runtime_release, ack_release])),
1599 }
1600}
1601
1602struct RuntimeOptions {
1603 queue_id: String,
1604 subscription_id: String,
1605 now: Rc<dyn Fn() -> u64>,
1606 lease_duration_ms: u64,
1607 retry: RetryPolicy,
1608}
1609
1610fn work_queue_command_body<T: Clone + 'static>(
1611 command_source_count: usize,
1612) -> impl Fn(&Ctx) + 'static {
1613 move |ctx| {
1614 for index in 0..command_source_count {
1615 for command in ctx.batch::<WorkQueueCommand<T>>(index) {
1616 ctx.emit((*command).clone());
1617 }
1618 }
1619 }
1620}
1621
1622fn should_poll_admission(status: &MessageBusStatus, topic: &str, subscription_id: &str) -> bool {
1623 if status.topic.as_deref() != Some(topic) {
1624 return false;
1625 }
1626 matches!(
1627 status.kind,
1628 MessageBusStatusKind::MessagePublished | MessageBusStatusKind::RetentionTrimmed
1629 ) || (status.subscription_id.as_deref() == Some(subscription_id)
1630 && matches!(
1631 status.kind,
1632 MessageBusStatusKind::SubscriptionAcked | MessageBusStatusKind::SubscriptionSought
1633 ))
1634}
1635
1636fn admission_ack_command<T>(
1637 record: &WorkQueueRecord<T>,
1638) -> Option<MessageBusCommand<WorkQueueSubmit<T>>> {
1639 let message_bus = record.admission_message_bus()?;
1640 Some(MessageBusCommand::Ack {
1641 topic: message_bus.topic.clone(),
1642 subscription_id: message_bus.subscription_id.clone(),
1643 seq: message_bus.seq,
1644 command_id: Some(compound_tuple_key(
1645 "work-queue-admission-ack",
1646 &[
1647 record_queue_id(record),
1648 &message_bus.topic,
1649 &message_bus.subscription_id,
1650 &message_bus.seq.to_string(),
1651 ],
1652 )),
1653 })
1654}
1655
1656fn admit_message<T: Clone + 'static>(
1657 opts: &RuntimeOptions,
1658 state: &mut RuntimeState<T>,
1659 message: &MessageEnvelope<WorkQueueSubmit<T>>,
1660 now_ms: u64,
1661) -> Vec<QueueEvent<T>> {
1662 let source = canonical_tuple_key(&[&message.topic, &message.seq.to_string()]);
1663 if !state.source_seqs.insert(source) {
1664 return Vec::new();
1665 }
1666 let submit = &message.payload;
1667 let work_id = submit.work_id.clone().unwrap_or_else(|| {
1668 compound_tuple_key(
1669 "work-queue-work",
1670 &[&opts.queue_id, &message.topic, &message.seq.to_string()],
1671 )
1672 });
1673 let message_bus = WorkQueueMessageBusRef {
1674 topic: message.topic.clone(),
1675 seq: message.seq,
1676 subscription_id: opts.subscription_id.clone(),
1677 };
1678 if state.works.contains_key(&work_id) {
1679 let record = append_record(
1680 state,
1681 WorkQueueRecord::AdmissionDeduped {
1682 record_seq: 0,
1683 queue_id: opts.queue_id.clone(),
1684 work_id: work_id.clone(),
1685 message_bus,
1686 reason: "duplicate-work".to_owned(),
1687 existing_work_id: work_id.clone(),
1688 recorded_at_ms: now_ms,
1689 },
1690 );
1691 return vec![
1692 QueueEvent::Record(record.clone()),
1693 status_event(
1694 opts,
1695 WorkQueueStatusKind::AdmissionRejected,
1696 now_ms,
1697 StatusFields {
1698 work_id: Some(work_id),
1699 record_seq: Some(record.record_seq()),
1700 issue_code: Some("duplicate-work".to_owned()),
1701 ..StatusFields::default()
1702 },
1703 ),
1704 ];
1705 }
1706 let initial_state = if submit.not_before_ms.is_some_and(|t| t > now_ms) {
1707 WorkQueueDerivedState::Scheduled
1708 } else {
1709 WorkQueueDerivedState::Ready
1710 };
1711 state.works.insert(
1712 work_id.clone(),
1713 WorkState {
1714 work_id: work_id.clone(),
1715 payload: submit.payload.clone(),
1716 state: initial_state,
1717 admission_seq: message.seq,
1718 priority: submit.priority,
1719 tags: submit.tags.clone(),
1720 requirements: submit.requirements.clone(),
1721 not_before_ms: submit.not_before_ms,
1722 retry_at_ms: None,
1723 deadline_ms: submit.deadline_ms,
1724 attempt: 0,
1725 lease_id: None,
1726 worker_id: None,
1727 lease_expires_at_ms: None,
1728 },
1729 );
1730 let record = append_record(
1731 state,
1732 WorkQueueRecord::WorkAdmitted {
1733 record_seq: 0,
1734 queue_id: opts.queue_id.clone(),
1735 work_id: work_id.clone(),
1736 payload: submit.payload.clone(),
1737 message_bus,
1738 priority: submit.priority,
1739 tags: submit.tags.clone(),
1740 requirements: submit.requirements.clone(),
1741 not_before_ms: submit.not_before_ms,
1742 deadline_ms: submit.deadline_ms,
1743 recorded_at_ms: now_ms,
1744 },
1745 );
1746 vec![
1747 QueueEvent::Record(record.clone()),
1748 status_event(
1749 opts,
1750 WorkQueueStatusKind::AdmissionAccepted,
1751 now_ms,
1752 StatusFields {
1753 work_id: Some(work_id),
1754 record_seq: Some(record.record_seq()),
1755 ..StatusFields::default()
1756 },
1757 ),
1758 ]
1759}
1760
1761fn reduce_queue_command<T: Clone + 'static>(
1762 opts: &RuntimeOptions,
1763 state: &mut RuntimeState<T>,
1764 command: WorkQueueCommand<T>,
1765 now_ms: u64,
1766) -> Vec<QueueEvent<T>> {
1767 if let Some(error) = validate_queue_command(&opts.queue_id, &command) {
1768 return reject_queue_command(opts, &command, &error.0, &error.1, now_ms);
1769 }
1770 if state.command_ids.contains(command.command_id()) {
1771 return reject_queue_command(
1772 opts,
1773 &command,
1774 "duplicate-command",
1775 "duplicate commandId",
1776 now_ms,
1777 );
1778 }
1779 if let Some(idempotency_key) = command.idempotency_key() {
1780 if state.idempotency_keys.contains(idempotency_key) {
1781 return reject_queue_command(
1782 opts,
1783 &command,
1784 "duplicate-command",
1785 "duplicate idempotencyKey",
1786 now_ms,
1787 );
1788 }
1789 }
1790 state.command_ids.insert(command.command_id().to_owned());
1791 if let Some(idempotency_key) = command.idempotency_key() {
1792 state.idempotency_keys.insert(idempotency_key.to_owned());
1793 }
1794 match command {
1795 WorkQueueCommand::Submit { command_id, .. } => vec![status_event(
1796 opts,
1797 WorkQueueStatusKind::CommandAccepted,
1798 now_ms,
1799 StatusFields {
1800 command_id: Some(command_id),
1801 details: Some("submit uses messageBus".to_owned()),
1802 ..StatusFields::default()
1803 },
1804 )],
1805 WorkQueueCommand::Claim { .. } => claim_work(opts, state, command, now_ms),
1806 WorkQueueCommand::RenewLease { .. } => renew_lease(opts, state, command, now_ms),
1807 WorkQueueCommand::Release { .. } => release_work(opts, state, command, now_ms),
1808 WorkQueueCommand::Complete { .. } => complete_work(opts, state, command, now_ms),
1809 WorkQueueCommand::Fail { .. } => fail_work(opts, state, command, now_ms),
1810 WorkQueueCommand::Cancel { .. } => cancel_work(opts, state, command, now_ms),
1811 WorkQueueCommand::Schedule { .. } => schedule_work(opts, state, command, now_ms),
1812 WorkQueueCommand::ExpireLeases { .. } => expire_leases(opts, state, command, now_ms),
1813 }
1814}
1815
1816fn claim_work<T: Clone + 'static>(
1817 opts: &RuntimeOptions,
1818 state: &mut RuntimeState<T>,
1819 command: WorkQueueCommand<T>,
1820 now_ms: u64,
1821) -> Vec<QueueEvent<T>> {
1822 let WorkQueueCommand::Claim {
1823 command_id,
1824 worker_id,
1825 requested_work_ids,
1826 limit,
1827 lease_duration_ms,
1828 ..
1829 } = command
1830 else {
1831 return Vec::new();
1832 };
1833 let limit = positive_limit(limit.unwrap_or_else(|| requested_work_ids.len().max(1)));
1834 let requested = requested_work_ids.iter().cloned().collect::<HashSet<_>>();
1835 let mut events = Vec::new();
1836 let expired = state
1837 .works
1838 .values()
1839 .filter(|work| {
1840 (requested.is_empty() || requested.contains(&work.work_id)) && is_expired(work, now_ms)
1841 })
1842 .map(|work| work.work_id.clone())
1843 .collect::<Vec<_>>();
1844 for work_id in expired {
1845 if let Some(work) = state.works.get(&work_id).cloned() {
1846 events.extend(materialize_lease_expired(
1847 opts,
1848 state,
1849 work,
1850 Some(command_id.clone()),
1851 now_ms,
1852 None,
1853 ));
1854 }
1855 }
1856 let mut candidates = state
1857 .works
1858 .values()
1859 .filter(|work| requested.is_empty() || requested.contains(&work.work_id))
1860 .filter(|work| is_ready(work, now_ms))
1861 .map(|work| (work.admission_seq, work.work_id.clone()))
1862 .collect::<Vec<_>>();
1863 candidates
1864 .sort_by(|(a_seq, a_id), (b_seq, b_id)| a_seq.cmp(b_seq).then_with(|| a_id.cmp(b_id)));
1865 let candidates = candidates
1866 .into_iter()
1867 .take(limit)
1868 .map(|(_, work_id)| work_id)
1869 .collect::<Vec<_>>();
1870 if candidates.is_empty() {
1871 if requested.is_empty() {
1872 events.extend(reject_queue_command_by_id(
1873 opts,
1874 Some(command_id),
1875 "not-ready",
1876 "no ready work",
1877 now_ms,
1878 ));
1879 } else {
1880 events.extend(claim_miss_events(
1881 opts,
1882 state,
1883 &command_id,
1884 &requested,
1885 &HashSet::new(),
1886 now_ms,
1887 ));
1888 }
1889 return events;
1890 }
1891 let mut claimed = HashSet::new();
1892 let lease_duration_ms = lease_duration_ms.unwrap_or(opts.lease_duration_ms);
1893 let lease_expires_at_ms = match checked_timestamp(
1894 opts,
1895 Some(command_id.clone()),
1896 now_ms,
1897 lease_duration_ms,
1898 "lease duration overflows timestamp",
1899 ) {
1900 Ok(value) => value,
1901 Err(events) => return events,
1902 };
1903 for work_id in candidates {
1904 let lease_seq = state.lease_seq + 1;
1905 state.lease_seq = lease_seq;
1906 let work = state
1907 .works
1908 .get_mut(&work_id)
1909 .expect("candidate work id came from state");
1910 claimed.insert(work_id.clone());
1911 work.state = WorkQueueDerivedState::Leased;
1912 work.attempt += 1;
1913 work.lease_id = Some(compound_tuple_key(
1914 "work-queue-lease",
1915 &[&work.work_id, &lease_seq.to_string()],
1916 ));
1917 work.worker_id = Some(worker_id.clone());
1918 work.lease_expires_at_ms = Some(lease_expires_at_ms);
1919 let lease_id = work.lease_id.clone().expect("lease just set");
1920 let record = append_record(
1921 state,
1922 WorkQueueRecord::WorkClaimed {
1923 record_seq: 0,
1924 queue_id: opts.queue_id.clone(),
1925 work_id: work_id.clone(),
1926 command_id: command_id.clone(),
1927 lease_id,
1928 attempt: state.works[&work_id].attempt,
1929 worker_id: worker_id.clone(),
1930 claimed_at_ms: now_ms,
1931 lease_expires_at_ms: state.works[&work_id]
1932 .lease_expires_at_ms
1933 .expect("lease expiry just set"),
1934 },
1935 );
1936 events.push(QueueEvent::Record(record.clone()));
1937 events.push(status_event(
1938 opts,
1939 WorkQueueStatusKind::CommandAccepted,
1940 now_ms,
1941 StatusFields {
1942 work_id: Some(work_id),
1943 command_id: Some(command_id.clone()),
1944 record_seq: Some(record.record_seq()),
1945 ..StatusFields::default()
1946 },
1947 ));
1948 }
1949 if !requested.is_empty() {
1950 events.extend(claim_miss_events(
1951 opts,
1952 state,
1953 &command_id,
1954 &requested,
1955 &claimed,
1956 now_ms,
1957 ));
1958 }
1959 events
1960}
1961
1962fn claim_miss_events<T: Clone + 'static>(
1963 opts: &RuntimeOptions,
1964 state: &RuntimeState<T>,
1965 command_id: &str,
1966 requested: &HashSet<String>,
1967 claimed: &HashSet<String>,
1968 now_ms: u64,
1969) -> Vec<QueueEvent<T>> {
1970 let mut events = Vec::new();
1971 for work_id in requested {
1972 if claimed.contains(work_id) {
1973 continue;
1974 }
1975 let Some(work) = state.works.get(work_id) else {
1976 events.extend(reject_queue_command_by_id(
1977 opts,
1978 Some(command_id.to_owned()),
1979 "unknown-work",
1980 &format!("unknown work '{work_id}'"),
1981 now_ms,
1982 ));
1983 continue;
1984 };
1985 if is_ready(work, now_ms) {
1986 continue;
1987 }
1988 let code = if is_terminal(work.state) {
1989 "terminal-work"
1990 } else if work.state == WorkQueueDerivedState::Leased {
1991 "already-leased"
1992 } else {
1993 "not-ready"
1994 };
1995 events.extend(reject_queue_command_by_id(
1996 opts,
1997 Some(command_id.to_owned()),
1998 code,
1999 &format!("requested work '{work_id}' is not claimable"),
2000 now_ms,
2001 ));
2002 }
2003 events
2004}
2005
2006fn renew_lease<T: Clone + 'static>(
2007 opts: &RuntimeOptions,
2008 state: &mut RuntimeState<T>,
2009 command: WorkQueueCommand<T>,
2010 now_ms: u64,
2011) -> Vec<QueueEvent<T>> {
2012 let WorkQueueCommand::RenewLease {
2013 command_id,
2014 work_id,
2015 lease_id,
2016 attempt,
2017 worker_id,
2018 lease_duration_ms,
2019 lease_expires_at_ms,
2020 ..
2021 } = command
2022 else {
2023 return Vec::new();
2024 };
2025 let checked = current_lease(
2026 opts,
2027 state,
2028 LeaseCheck {
2029 work_id: &work_id,
2030 lease_id: &lease_id,
2031 attempt,
2032 worker_id: &worker_id,
2033 command_id: Some(command_id.clone()),
2034 now_ms,
2035 },
2036 );
2037 if let Err(events) = checked {
2038 return events;
2039 }
2040 let work = state.works.get_mut(&work_id).expect("checked lease");
2041 let previous = work.lease_expires_at_ms.expect("checked lease expiry");
2042 let next = match lease_expires_at_ms {
2043 Some(value) => value,
2044 None => match checked_timestamp(
2045 opts,
2046 Some(command_id.clone()),
2047 now_ms,
2048 lease_duration_ms.unwrap_or(opts.lease_duration_ms),
2049 "lease renewal duration overflows timestamp",
2050 ) {
2051 Ok(value) => value,
2052 Err(events) => return events,
2053 },
2054 };
2055 work.lease_expires_at_ms = Some(next);
2056 let record = append_record(
2057 state,
2058 WorkQueueRecord::LeaseRenewed {
2059 record_seq: 0,
2060 queue_id: opts.queue_id.clone(),
2061 work_id: work_id.clone(),
2062 command_id: command_id.clone(),
2063 lease_id,
2064 attempt,
2065 worker_id,
2066 previous_lease_expires_at_ms: previous,
2067 lease_expires_at_ms: next,
2068 renewed_at_ms: now_ms,
2069 },
2070 );
2071 accepted(opts, record, Some(work_id), Some(command_id), now_ms)
2072}
2073
2074fn release_work<T: Clone + 'static>(
2075 opts: &RuntimeOptions,
2076 state: &mut RuntimeState<T>,
2077 command: WorkQueueCommand<T>,
2078 now_ms: u64,
2079) -> Vec<QueueEvent<T>> {
2080 let WorkQueueCommand::Release {
2081 command_id,
2082 work_id,
2083 lease_id,
2084 attempt,
2085 worker_id,
2086 reason,
2087 ..
2088 } = command
2089 else {
2090 return Vec::new();
2091 };
2092 if let Err(events) = current_lease(
2093 opts,
2094 state,
2095 LeaseCheck {
2096 work_id: &work_id,
2097 lease_id: &lease_id,
2098 attempt,
2099 worker_id: &worker_id,
2100 command_id: Some(command_id.clone()),
2101 now_ms,
2102 },
2103 ) {
2104 return events;
2105 }
2106 clear_lease(state.works.get_mut(&work_id).expect("checked lease"));
2107 state.works.get_mut(&work_id).expect("checked lease").state = WorkQueueDerivedState::Ready;
2108 let record = append_record(
2109 state,
2110 WorkQueueRecord::WorkReleased {
2111 record_seq: 0,
2112 queue_id: opts.queue_id.clone(),
2113 work_id: work_id.clone(),
2114 command_id: command_id.clone(),
2115 lease_id,
2116 attempt,
2117 worker_id,
2118 released_at_ms: now_ms,
2119 reason,
2120 },
2121 );
2122 accepted(opts, record, Some(work_id), Some(command_id), now_ms)
2123}
2124
2125fn complete_work<T: Clone + 'static>(
2126 opts: &RuntimeOptions,
2127 state: &mut RuntimeState<T>,
2128 command: WorkQueueCommand<T>,
2129 now_ms: u64,
2130) -> Vec<QueueEvent<T>> {
2131 let WorkQueueCommand::Complete {
2132 command_id,
2133 work_id,
2134 lease_id,
2135 attempt,
2136 worker_id,
2137 result,
2138 ..
2139 } = command
2140 else {
2141 return Vec::new();
2142 };
2143 if let Err(events) = current_lease(
2144 opts,
2145 state,
2146 LeaseCheck {
2147 work_id: &work_id,
2148 lease_id: &lease_id,
2149 attempt,
2150 worker_id: &worker_id,
2151 command_id: Some(command_id.clone()),
2152 now_ms,
2153 },
2154 ) {
2155 return events;
2156 }
2157 let work = state.works.get_mut(&work_id).expect("checked lease");
2158 work.state = WorkQueueDerivedState::Completed;
2159 clear_lease(work);
2160 let attempt_record = append_record(
2161 state,
2162 WorkQueueRecord::AttemptCompleted {
2163 record_seq: 0,
2164 queue_id: opts.queue_id.clone(),
2165 work_id: work_id.clone(),
2166 command_id: command_id.clone(),
2167 lease_id: lease_id.clone(),
2168 attempt,
2169 worker_id: worker_id.clone(),
2170 result: result.clone(),
2171 recorded_at_ms: now_ms,
2172 },
2173 );
2174 let done = append_record(
2175 state,
2176 WorkQueueRecord::WorkCompleted {
2177 record_seq: 0,
2178 queue_id: opts.queue_id.clone(),
2179 work_id: work_id.clone(),
2180 command_id: command_id.clone(),
2181 lease_id,
2182 attempt,
2183 worker_id,
2184 result,
2185 recorded_at_ms: now_ms,
2186 },
2187 );
2188 vec![
2189 QueueEvent::Record(attempt_record),
2190 QueueEvent::Record(done.clone()),
2191 status_event(
2192 opts,
2193 WorkQueueStatusKind::CommandAccepted,
2194 now_ms,
2195 StatusFields {
2196 work_id: Some(work_id),
2197 command_id: Some(command_id),
2198 record_seq: Some(done.record_seq()),
2199 ..StatusFields::default()
2200 },
2201 ),
2202 ]
2203}
2204
2205fn fail_work<T: Clone + 'static>(
2206 opts: &RuntimeOptions,
2207 state: &mut RuntimeState<T>,
2208 command: WorkQueueCommand<T>,
2209 now_ms: u64,
2210) -> Vec<QueueEvent<T>> {
2211 let WorkQueueCommand::Fail {
2212 command_id,
2213 work_id,
2214 lease_id,
2215 attempt,
2216 worker_id,
2217 error,
2218 retryable,
2219 ..
2220 } = command
2221 else {
2222 return Vec::new();
2223 };
2224 if let Err(events) = current_lease(
2225 opts,
2226 state,
2227 LeaseCheck {
2228 work_id: &work_id,
2229 lease_id: &lease_id,
2230 attempt,
2231 worker_id: &worker_id,
2232 command_id: Some(command_id.clone()),
2233 now_ms,
2234 },
2235 ) {
2236 return events;
2237 }
2238 let attempt_for_policy = state.works.get(&work_id).expect("checked lease").attempt;
2239 let should_retry = retryable.unwrap_or(true) && opts.retry.should_retry(attempt_for_policy);
2240 let retry_delay_ms = if should_retry {
2241 opts.retry
2242 .next_delay_ms(attempt_for_policy.saturating_add(1))
2243 .unwrap_or_default()
2244 } else {
2245 0
2246 };
2247 let retry_at_ms = if should_retry {
2248 match checked_timestamp(
2249 opts,
2250 Some(command_id.clone()),
2251 now_ms,
2252 retry_delay_ms,
2253 "retry delay overflows timestamp",
2254 ) {
2255 Ok(value) => value,
2256 Err(events) => return events,
2257 }
2258 } else {
2259 now_ms
2260 };
2261 let failed = append_record(
2262 state,
2263 WorkQueueRecord::AttemptFailed {
2264 record_seq: 0,
2265 queue_id: opts.queue_id.clone(),
2266 work_id: work_id.clone(),
2267 command_id: command_id.clone(),
2268 lease_id,
2269 attempt,
2270 worker_id,
2271 error,
2272 retryable,
2273 recorded_at_ms: now_ms,
2274 },
2275 );
2276 let work = state.works.get_mut(&work_id).expect("checked lease");
2277 if !should_retry {
2278 work.state = WorkQueueDerivedState::DeadLettered;
2279 clear_lease(work);
2280 let dead = append_record(
2281 state,
2282 WorkQueueRecord::WorkDeadLettered {
2283 record_seq: 0,
2284 queue_id: opts.queue_id.clone(),
2285 work_id: work_id.clone(),
2286 command_id: command_id.clone(),
2287 reason: "attempts-exhausted".to_owned(),
2288 exhausted_attempts: Some(attempt),
2289 recorded_at_ms: now_ms,
2290 },
2291 );
2292 state.dead_letters.push(dead.clone());
2293 return vec![
2294 QueueEvent::Record(failed),
2295 QueueEvent::Record(dead.clone()),
2296 status_event(
2297 opts,
2298 WorkQueueStatusKind::CommandAccepted,
2299 now_ms,
2300 StatusFields {
2301 work_id: Some(work_id),
2302 command_id: Some(command_id),
2303 record_seq: Some(dead.record_seq()),
2304 ..StatusFields::default()
2305 },
2306 ),
2307 ];
2308 }
2309 work.state = if retry_delay_ms > 0 {
2310 WorkQueueDerivedState::RetryWait
2311 } else {
2312 WorkQueueDerivedState::Ready
2313 };
2314 work.retry_at_ms = Some(retry_at_ms);
2315 clear_lease(work);
2316 let retry = append_record(
2317 state,
2318 WorkQueueRecord::RetryScheduled {
2319 record_seq: 0,
2320 queue_id: opts.queue_id.clone(),
2321 work_id: work_id.clone(),
2322 command_id: command_id.clone(),
2323 retry_at_ms,
2324 delay_ms: retry_delay_ms,
2325 reason: Some("retry-policy".to_owned()),
2326 recorded_at_ms: now_ms,
2327 },
2328 );
2329 vec![
2330 QueueEvent::Record(failed),
2331 QueueEvent::Record(retry.clone()),
2332 status_event(
2333 opts,
2334 WorkQueueStatusKind::CommandAccepted,
2335 now_ms,
2336 StatusFields {
2337 work_id: Some(work_id),
2338 command_id: Some(command_id),
2339 record_seq: Some(retry.record_seq()),
2340 ..StatusFields::default()
2341 },
2342 ),
2343 ]
2344}
2345
2346fn cancel_work<T: Clone + 'static>(
2347 opts: &RuntimeOptions,
2348 state: &mut RuntimeState<T>,
2349 command: WorkQueueCommand<T>,
2350 now_ms: u64,
2351) -> Vec<QueueEvent<T>> {
2352 let WorkQueueCommand::Cancel {
2353 command_id,
2354 work_id,
2355 reason,
2356 ..
2357 } = command
2358 else {
2359 return Vec::new();
2360 };
2361 let Some(work) = state.works.get_mut(&work_id) else {
2362 return reject_queue_command_by_id(
2363 opts,
2364 Some(command_id),
2365 "unknown-work",
2366 "unknown work",
2367 now_ms,
2368 );
2369 };
2370 if is_terminal(work.state) {
2371 return reject_queue_command_by_id(
2372 opts,
2373 Some(command_id),
2374 "terminal-work",
2375 "work is terminal",
2376 now_ms,
2377 );
2378 }
2379 let canceled_lease_id = work.lease_id.clone();
2380 let attempt = (work.attempt > 0).then_some(work.attempt);
2381 work.state = WorkQueueDerivedState::Canceled;
2382 clear_lease(work);
2383 let record = append_record(
2384 state,
2385 WorkQueueRecord::WorkCanceled {
2386 record_seq: 0,
2387 queue_id: opts.queue_id.clone(),
2388 work_id: work_id.clone(),
2389 command_id: command_id.clone(),
2390 reason,
2391 canceled_at_ms: now_ms,
2392 canceled_lease_id,
2393 attempt,
2394 },
2395 );
2396 accepted(opts, record, Some(work_id), Some(command_id), now_ms)
2397}
2398
2399fn schedule_work<T: Clone + 'static>(
2400 opts: &RuntimeOptions,
2401 state: &mut RuntimeState<T>,
2402 command: WorkQueueCommand<T>,
2403 now_ms: u64,
2404) -> Vec<QueueEvent<T>> {
2405 let WorkQueueCommand::Schedule {
2406 command_id,
2407 work_id,
2408 schedule_id,
2409 not_before_ms,
2410 deadline_ms,
2411 reason,
2412 ..
2413 } = command
2414 else {
2415 return Vec::new();
2416 };
2417 let Some(work) = state.works.get_mut(&work_id) else {
2418 return reject_queue_command_by_id(
2419 opts,
2420 Some(command_id),
2421 "unknown-work",
2422 "unknown work",
2423 now_ms,
2424 );
2425 };
2426 if is_terminal(work.state) {
2427 return reject_queue_command_by_id(
2428 opts,
2429 Some(command_id),
2430 "terminal-work",
2431 "work is terminal",
2432 now_ms,
2433 );
2434 }
2435 if work.state == WorkQueueDerivedState::Leased {
2436 return reject_queue_command_by_id(
2437 opts,
2438 Some(command_id),
2439 "schedule-conflict",
2440 "leased work cannot be scheduled without release or cancel",
2441 now_ms,
2442 );
2443 }
2444 work.state = WorkQueueDerivedState::Scheduled;
2445 work.not_before_ms = Some(not_before_ms);
2446 work.deadline_ms = deadline_ms;
2447 let record = append_record(
2448 state,
2449 WorkQueueRecord::WorkScheduled {
2450 record_seq: 0,
2451 queue_id: opts.queue_id.clone(),
2452 work_id: work_id.clone(),
2453 command_id: command_id.clone(),
2454 schedule_id,
2455 not_before_ms,
2456 deadline_ms,
2457 reason,
2458 recorded_at_ms: now_ms,
2459 },
2460 );
2461 accepted(opts, record, Some(work_id), Some(command_id), now_ms)
2462}
2463
2464fn expire_leases<T: Clone + 'static>(
2465 opts: &RuntimeOptions,
2466 state: &mut RuntimeState<T>,
2467 command: WorkQueueCommand<T>,
2468 now_ms: u64,
2469) -> Vec<QueueEvent<T>> {
2470 let WorkQueueCommand::ExpireLeases {
2471 command_id,
2472 work_ids,
2473 limit,
2474 ..
2475 } = command
2476 else {
2477 return Vec::new();
2478 };
2479 let requested = work_ids.into_iter().collect::<HashSet<_>>();
2480 let expired = state
2481 .works
2482 .values()
2483 .filter(|work| is_expired(work, now_ms))
2484 .filter(|work| requested.is_empty() || requested.contains(&work.work_id))
2485 .take(positive_limit(limit.unwrap_or(usize::MAX)))
2486 .map(|work| work.work_id.clone())
2487 .collect::<Vec<_>>();
2488 if expired.is_empty() {
2489 return vec![status_event(
2490 opts,
2491 WorkQueueStatusKind::MaintenanceNoop,
2492 now_ms,
2493 StatusFields {
2494 command_id: Some(command_id),
2495 ..StatusFields::default()
2496 },
2497 )];
2498 }
2499 let mut events = Vec::new();
2500 for work_id in expired.iter() {
2501 if let Some(work) = state.works.get(work_id).cloned() {
2502 events.extend(materialize_lease_expired(
2503 opts,
2504 state,
2505 work,
2506 Some(command_id.clone()),
2507 now_ms,
2508 None,
2509 ));
2510 }
2511 }
2512 events.push(status_event(
2513 opts,
2514 WorkQueueStatusKind::MaintenanceApplied,
2515 now_ms,
2516 StatusFields {
2517 command_id: Some(command_id),
2518 details: Some(format!("expired={}", expired.len())),
2519 ..StatusFields::default()
2520 },
2521 ));
2522 events
2523}
2524
2525struct LeaseCheck<'a> {
2526 work_id: &'a str,
2527 lease_id: &'a str,
2528 attempt: u32,
2529 worker_id: &'a str,
2530 command_id: Option<String>,
2531 now_ms: u64,
2532}
2533
2534fn current_lease<T: Clone + 'static>(
2535 opts: &RuntimeOptions,
2536 state: &mut RuntimeState<T>,
2537 check: LeaseCheck<'_>,
2538) -> Result<(), Vec<QueueEvent<T>>> {
2539 let Some(work) = state.works.get(check.work_id).cloned() else {
2540 return Err(reject_queue_command_by_id(
2541 opts,
2542 check.command_id,
2543 "unknown-work",
2544 "unknown work",
2545 check.now_ms,
2546 ));
2547 };
2548 if is_terminal(work.state) {
2549 return Err(reject_queue_command_by_id(
2550 opts,
2551 check.command_id,
2552 "terminal-work",
2553 "work is terminal",
2554 check.now_ms,
2555 ));
2556 }
2557 if work.state != WorkQueueDerivedState::Leased {
2558 return Err(reject_queue_command_by_id(
2559 opts,
2560 check.command_id,
2561 "lease-not-current",
2562 "work is not leased",
2563 check.now_ms,
2564 ));
2565 }
2566 if work.lease_id.as_deref() != Some(check.lease_id) {
2567 return Err(reject_queue_command_by_id(
2568 opts,
2569 check.command_id,
2570 "stale-lease",
2571 "lease is not current",
2572 check.now_ms,
2573 ));
2574 }
2575 if work.attempt != check.attempt {
2576 return Err(reject_queue_command_by_id(
2577 opts,
2578 check.command_id,
2579 "attempt-mismatch",
2580 "attempt mismatch",
2581 check.now_ms,
2582 ));
2583 }
2584 if work.worker_id.as_deref() != Some(check.worker_id) {
2585 return Err(reject_queue_command_by_id(
2586 opts,
2587 check.command_id,
2588 "worker-mismatch",
2589 "worker mismatch",
2590 check.now_ms,
2591 ));
2592 }
2593 if is_expired(&work, check.now_ms) {
2594 let command_id = check.command_id;
2595 let mut events =
2596 materialize_lease_expired(opts, state, work, command_id.clone(), check.now_ms, None);
2597 events.extend(reject_queue_command_by_id(
2598 opts,
2599 command_id,
2600 "lease-expired",
2601 "lease expired",
2602 check.now_ms,
2603 ));
2604 return Err(events);
2605 }
2606 Ok(())
2607}
2608
2609fn materialize_lease_expired<T: Clone + 'static>(
2610 opts: &RuntimeOptions,
2611 state: &mut RuntimeState<T>,
2612 work: WorkState<T>,
2613 command_id: Option<String>,
2614 now_ms: u64,
2615 rejection: Option<(&str, &str)>,
2616) -> Vec<QueueEvent<T>> {
2617 let Some(current) = state.works.get_mut(&work.work_id) else {
2618 return Vec::new();
2619 };
2620 current.state = WorkQueueDerivedState::Ready;
2621 clear_lease(current);
2622 let record = append_record(
2623 state,
2624 WorkQueueRecord::LeaseExpired {
2625 record_seq: 0,
2626 queue_id: opts.queue_id.clone(),
2627 work_id: work.work_id.clone(),
2628 command_id: command_id.clone(),
2629 lease_id: work.lease_id.unwrap_or_default(),
2630 attempt: work.attempt,
2631 worker_id: work.worker_id.unwrap_or_default(),
2632 lease_expires_at_ms: work.lease_expires_at_ms.unwrap_or(now_ms),
2633 expired_at_ms: now_ms,
2634 },
2635 );
2636 let mut events = vec![QueueEvent::Record(record)];
2637 if let Some((code, message)) = rejection {
2638 events.extend(reject_queue_command_by_id(
2639 opts, command_id, code, message, now_ms,
2640 ));
2641 }
2642 events
2643}
2644
2645fn accepted<T: Clone>(
2646 opts: &RuntimeOptions,
2647 record: WorkQueueRecord<T>,
2648 work_id: Option<String>,
2649 command_id: Option<String>,
2650 now_ms: u64,
2651) -> Vec<QueueEvent<T>> {
2652 vec![
2653 QueueEvent::Record(record.clone()),
2654 status_event(
2655 opts,
2656 WorkQueueStatusKind::CommandAccepted,
2657 now_ms,
2658 StatusFields {
2659 work_id,
2660 command_id,
2661 record_seq: Some(record.record_seq()),
2662 ..StatusFields::default()
2663 },
2664 ),
2665 ]
2666}
2667
2668fn validate_queue_command<T>(
2669 queue_id: &str,
2670 command: &WorkQueueCommand<T>,
2671) -> Option<(String, String)> {
2672 if command.command_id().is_empty() {
2673 return Some((
2674 "malformed-command".to_owned(),
2675 "commandId must be non-empty".to_owned(),
2676 ));
2677 }
2678 if command.queue_id().is_some_and(|id| id != queue_id) {
2679 return Some((
2680 "queue-mismatch".to_owned(),
2681 "command queueId does not match this queue".to_owned(),
2682 ));
2683 }
2684 match command {
2685 WorkQueueCommand::Claim {
2686 worker_id,
2687 requested_work_ids,
2688 limit,
2689 lease_duration_ms,
2690 ..
2691 } => {
2692 if worker_id.is_empty() {
2693 return Some((
2694 "malformed-command".to_owned(),
2695 "claim workerId is required".to_owned(),
2696 ));
2697 }
2698 if limit.is_some_and(|limit| limit == 0)
2699 || lease_duration_ms.is_some_and(|duration| duration == 0)
2700 || requested_work_ids.iter().any(String::is_empty)
2701 {
2702 return Some((
2703 "malformed-command".to_owned(),
2704 "claim options are malformed".to_owned(),
2705 ));
2706 }
2707 }
2708 WorkQueueCommand::RenewLease {
2709 work_id,
2710 lease_id,
2711 attempt,
2712 worker_id,
2713 lease_duration_ms,
2714 ..
2715 } => {
2716 if work_id.is_empty()
2717 || lease_id.is_empty()
2718 || worker_id.is_empty()
2719 || *attempt == 0
2720 || lease_duration_ms.is_some_and(|duration| duration == 0)
2721 {
2722 return Some((
2723 "malformed-command".to_owned(),
2724 "lease command is malformed".to_owned(),
2725 ));
2726 }
2727 }
2728 WorkQueueCommand::Release {
2729 work_id,
2730 lease_id,
2731 attempt,
2732 worker_id,
2733 ..
2734 }
2735 | WorkQueueCommand::Complete {
2736 work_id,
2737 lease_id,
2738 attempt,
2739 worker_id,
2740 ..
2741 }
2742 | WorkQueueCommand::Fail {
2743 work_id,
2744 lease_id,
2745 attempt,
2746 worker_id,
2747 ..
2748 } => {
2749 if work_id.is_empty() || lease_id.is_empty() || worker_id.is_empty() || *attempt == 0 {
2750 return Some((
2751 "malformed-command".to_owned(),
2752 "lease command is malformed".to_owned(),
2753 ));
2754 }
2755 }
2756 WorkQueueCommand::Cancel { work_id, .. } | WorkQueueCommand::Schedule { work_id, .. } => {
2757 if work_id.is_empty() {
2758 return Some((
2759 "malformed-command".to_owned(),
2760 "workId must be non-empty".to_owned(),
2761 ));
2762 }
2763 }
2764 WorkQueueCommand::ExpireLeases {
2765 work_ids, limit, ..
2766 } => {
2767 if limit.is_some_and(|limit| limit == 0) || work_ids.iter().any(String::is_empty) {
2768 return Some((
2769 "malformed-command".to_owned(),
2770 "expire-leases options are malformed".to_owned(),
2771 ));
2772 }
2773 }
2774 WorkQueueCommand::Submit { .. } => {}
2775 }
2776 None
2777}
2778
2779fn reject_queue_command<T: Clone + 'static>(
2780 opts: &RuntimeOptions,
2781 command: &WorkQueueCommand<T>,
2782 code: &str,
2783 message: &str,
2784 now_ms: u64,
2785) -> Vec<QueueEvent<T>> {
2786 reject_queue_command_by_id(
2787 opts,
2788 Some(command.command_id().to_owned()),
2789 code,
2790 message,
2791 now_ms,
2792 )
2793}
2794
2795fn reject_queue_command_by_id<T>(
2796 opts: &RuntimeOptions,
2797 command_id: Option<String>,
2798 code: &str,
2799 message: &str,
2800 now_ms: u64,
2801) -> Vec<QueueEvent<T>> {
2802 vec![
2803 QueueEvent::Issue(DataIssue {
2804 kind: "issue".to_owned(),
2805 code: code.to_owned(),
2806 message: message.to_owned(),
2807 severity: "error".to_owned(),
2808 source: "workQueue".to_owned(),
2809 topic: None,
2810 details: Some(format!("queueId={}", opts.queue_id)),
2811 }),
2812 status_event(
2813 opts,
2814 WorkQueueStatusKind::CommandRejected,
2815 now_ms,
2816 StatusFields {
2817 command_id,
2818 issue_code: Some(code.to_owned()),
2819 ..StatusFields::default()
2820 },
2821 ),
2822 ]
2823}
2824
2825fn checked_timestamp<T>(
2826 opts: &RuntimeOptions,
2827 command_id: Option<String>,
2828 now_ms: u64,
2829 delta_ms: u64,
2830 message: &str,
2831) -> Result<u64, Vec<QueueEvent<T>>> {
2832 now_ms.checked_add(delta_ms).ok_or_else(|| {
2833 reject_queue_command_by_id(opts, command_id, "clock-overflow", message, now_ms)
2834 })
2835}
2836
2837#[derive(Default)]
2838struct StatusFields {
2839 work_id: Option<String>,
2840 command_id: Option<String>,
2841 record_seq: Option<u64>,
2842 as_of_record_seq: Option<u64>,
2843 issue_code: Option<String>,
2844 details: Option<String>,
2845}
2846
2847fn status_event<T>(
2848 opts: &RuntimeOptions,
2849 kind: WorkQueueStatusKind,
2850 timestamp_ms: u64,
2851 fields: StatusFields,
2852) -> QueueEvent<T> {
2853 QueueEvent::Status(WorkQueueStatus {
2854 kind,
2855 queue_id: opts.queue_id.clone(),
2856 work_id: fields.work_id,
2857 command_id: fields.command_id,
2858 record_seq: fields.record_seq,
2859 as_of_record_seq: fields.as_of_record_seq,
2860 issue_code: fields.issue_code,
2861 timestamp_ms,
2862 details: fields.details,
2863 })
2864}
2865
2866fn append_record<T: Clone>(
2867 state: &mut RuntimeState<T>,
2868 record: WorkQueueRecord<T>,
2869) -> WorkQueueRecord<T> {
2870 state.record_seq += 1;
2871 let record = with_record_seq(record, state.record_seq);
2872 state.records.push(record.clone());
2873 record
2874}
2875
2876fn with_record_seq<T>(record: WorkQueueRecord<T>, next: u64) -> WorkQueueRecord<T> {
2877 match record {
2878 WorkQueueRecord::WorkAdmitted {
2879 queue_id,
2880 work_id,
2881 payload,
2882 message_bus,
2883 priority,
2884 tags,
2885 requirements,
2886 not_before_ms,
2887 deadline_ms,
2888 recorded_at_ms,
2889 ..
2890 } => WorkQueueRecord::WorkAdmitted {
2891 record_seq: next,
2892 queue_id,
2893 work_id,
2894 payload,
2895 message_bus,
2896 priority,
2897 tags,
2898 requirements,
2899 not_before_ms,
2900 deadline_ms,
2901 recorded_at_ms,
2902 },
2903 WorkQueueRecord::AdmissionDeduped {
2904 queue_id,
2905 work_id,
2906 message_bus,
2907 reason,
2908 existing_work_id,
2909 recorded_at_ms,
2910 ..
2911 } => WorkQueueRecord::AdmissionDeduped {
2912 record_seq: next,
2913 queue_id,
2914 work_id,
2915 message_bus,
2916 reason,
2917 existing_work_id,
2918 recorded_at_ms,
2919 },
2920 WorkQueueRecord::WorkScheduled {
2921 queue_id,
2922 work_id,
2923 command_id,
2924 schedule_id,
2925 not_before_ms,
2926 deadline_ms,
2927 reason,
2928 recorded_at_ms,
2929 ..
2930 } => WorkQueueRecord::WorkScheduled {
2931 record_seq: next,
2932 queue_id,
2933 work_id,
2934 command_id,
2935 schedule_id,
2936 not_before_ms,
2937 deadline_ms,
2938 reason,
2939 recorded_at_ms,
2940 },
2941 WorkQueueRecord::WorkClaimed {
2942 queue_id,
2943 work_id,
2944 command_id,
2945 lease_id,
2946 attempt,
2947 worker_id,
2948 claimed_at_ms,
2949 lease_expires_at_ms,
2950 ..
2951 } => WorkQueueRecord::WorkClaimed {
2952 record_seq: next,
2953 queue_id,
2954 work_id,
2955 command_id,
2956 lease_id,
2957 attempt,
2958 worker_id,
2959 claimed_at_ms,
2960 lease_expires_at_ms,
2961 },
2962 WorkQueueRecord::LeaseRenewed {
2963 queue_id,
2964 work_id,
2965 command_id,
2966 lease_id,
2967 attempt,
2968 worker_id,
2969 previous_lease_expires_at_ms,
2970 lease_expires_at_ms,
2971 renewed_at_ms,
2972 ..
2973 } => WorkQueueRecord::LeaseRenewed {
2974 record_seq: next,
2975 queue_id,
2976 work_id,
2977 command_id,
2978 lease_id,
2979 attempt,
2980 worker_id,
2981 previous_lease_expires_at_ms,
2982 lease_expires_at_ms,
2983 renewed_at_ms,
2984 },
2985 WorkQueueRecord::WorkReleased {
2986 queue_id,
2987 work_id,
2988 command_id,
2989 lease_id,
2990 attempt,
2991 worker_id,
2992 released_at_ms,
2993 reason,
2994 ..
2995 } => WorkQueueRecord::WorkReleased {
2996 record_seq: next,
2997 queue_id,
2998 work_id,
2999 command_id,
3000 lease_id,
3001 attempt,
3002 worker_id,
3003 released_at_ms,
3004 reason,
3005 },
3006 WorkQueueRecord::LeaseExpired {
3007 queue_id,
3008 work_id,
3009 command_id,
3010 lease_id,
3011 attempt,
3012 worker_id,
3013 lease_expires_at_ms,
3014 expired_at_ms,
3015 ..
3016 } => WorkQueueRecord::LeaseExpired {
3017 record_seq: next,
3018 queue_id,
3019 work_id,
3020 command_id,
3021 lease_id,
3022 attempt,
3023 worker_id,
3024 lease_expires_at_ms,
3025 expired_at_ms,
3026 },
3027 WorkQueueRecord::AttemptCompleted {
3028 queue_id,
3029 work_id,
3030 command_id,
3031 lease_id,
3032 attempt,
3033 worker_id,
3034 result,
3035 recorded_at_ms,
3036 ..
3037 } => WorkQueueRecord::AttemptCompleted {
3038 record_seq: next,
3039 queue_id,
3040 work_id,
3041 command_id,
3042 lease_id,
3043 attempt,
3044 worker_id,
3045 result,
3046 recorded_at_ms,
3047 },
3048 WorkQueueRecord::WorkCompleted {
3049 queue_id,
3050 work_id,
3051 command_id,
3052 lease_id,
3053 attempt,
3054 worker_id,
3055 result,
3056 recorded_at_ms,
3057 ..
3058 } => WorkQueueRecord::WorkCompleted {
3059 record_seq: next,
3060 queue_id,
3061 work_id,
3062 command_id,
3063 lease_id,
3064 attempt,
3065 worker_id,
3066 result,
3067 recorded_at_ms,
3068 },
3069 WorkQueueRecord::AttemptFailed {
3070 queue_id,
3071 work_id,
3072 command_id,
3073 lease_id,
3074 attempt,
3075 worker_id,
3076 error,
3077 retryable,
3078 recorded_at_ms,
3079 ..
3080 } => WorkQueueRecord::AttemptFailed {
3081 record_seq: next,
3082 queue_id,
3083 work_id,
3084 command_id,
3085 lease_id,
3086 attempt,
3087 worker_id,
3088 error,
3089 retryable,
3090 recorded_at_ms,
3091 },
3092 WorkQueueRecord::RetryScheduled {
3093 queue_id,
3094 work_id,
3095 command_id,
3096 retry_at_ms,
3097 delay_ms,
3098 reason,
3099 recorded_at_ms,
3100 ..
3101 } => WorkQueueRecord::RetryScheduled {
3102 record_seq: next,
3103 queue_id,
3104 work_id,
3105 command_id,
3106 retry_at_ms,
3107 delay_ms,
3108 reason,
3109 recorded_at_ms,
3110 },
3111 WorkQueueRecord::WorkDeadLettered {
3112 queue_id,
3113 work_id,
3114 command_id,
3115 reason,
3116 exhausted_attempts,
3117 recorded_at_ms,
3118 ..
3119 } => WorkQueueRecord::WorkDeadLettered {
3120 record_seq: next,
3121 queue_id,
3122 work_id,
3123 command_id,
3124 reason,
3125 exhausted_attempts,
3126 recorded_at_ms,
3127 },
3128 WorkQueueRecord::WorkCanceled {
3129 queue_id,
3130 work_id,
3131 command_id,
3132 reason,
3133 canceled_at_ms,
3134 canceled_lease_id,
3135 attempt,
3136 ..
3137 } => WorkQueueRecord::WorkCanceled {
3138 record_seq: next,
3139 queue_id,
3140 work_id,
3141 command_id,
3142 reason,
3143 canceled_at_ms,
3144 canceled_lease_id,
3145 attempt,
3146 },
3147 }
3148}
3149
3150fn available_page<T: Clone>(
3151 state: &RuntimeState<T>,
3152 params: &WorkQueueAvailableParams,
3153) -> WorkQueueAvailablePage<T> {
3154 let limit = positive_limit(params.limit.unwrap_or(100));
3155 let mut items = state
3156 .works
3157 .values()
3158 .filter(|work| is_ready_for_projection(work, params.now_ms))
3159 .filter(
3160 |work| match (params.after_admission_seq, params.after_work_id.as_ref()) {
3161 (Some(after_seq), Some(after_id)) => {
3162 (work.admission_seq, &work.work_id) > (after_seq, after_id)
3163 }
3164 (Some(after_seq), None) => work.admission_seq > after_seq,
3165 (None, Some(after_id)) => &work.work_id > after_id,
3166 (None, None) => true,
3167 },
3168 )
3169 .map(|work| WorkQueueAvailableItem {
3170 work_id: work.work_id.clone(),
3171 state: work.state,
3172 payload: work.payload.clone(),
3173 admission_seq: work.admission_seq,
3174 priority: work.priority,
3175 tags: work.tags.clone(),
3176 requirements: work.requirements.clone(),
3177 not_before_ms: work.not_before_ms,
3178 retry_at_ms: work.retry_at_ms,
3179 deadline_ms: work.deadline_ms,
3180 })
3181 .collect::<Vec<_>>();
3182 items.sort_by(|a, b| {
3183 a.admission_seq
3184 .cmp(&b.admission_seq)
3185 .then_with(|| a.work_id.cmp(&b.work_id))
3186 });
3187 let has_more = items.len() > limit;
3188 let page = items.into_iter().take(limit).collect::<Vec<_>>();
3189 let next_after_work_id = has_more
3190 .then(|| page.last().map(|item| item.work_id.clone()))
3191 .flatten();
3192 let next_after_admission_seq = has_more
3193 .then(|| page.last().map(|item| item.admission_seq))
3194 .flatten();
3195 WorkQueueAvailablePage {
3196 items: page,
3197 next_after_work_id,
3198 next_after_admission_seq,
3199 has_more,
3200 as_of_record_seq: state.record_seq,
3201 }
3202}
3203
3204fn work_snapshot<T: Clone>(state: &RuntimeState<T>, work_id: &str) -> WorkQueueWorkSnapshot<T> {
3205 let work = state.works.get(work_id);
3206 WorkQueueWorkSnapshot {
3207 work_id: work_id.to_owned(),
3208 state: work.map(|work| work.state),
3209 payload: work.map(|work| work.payload.clone()),
3210 active_lease: work.and_then(|work| {
3211 if work.state != WorkQueueDerivedState::Leased {
3212 return None;
3213 }
3214 Some(WorkQueueActiveLease {
3215 lease_id: work.lease_id.clone()?,
3216 attempt: work.attempt,
3217 worker_id: work.worker_id.clone()?,
3218 lease_expires_at_ms: work.lease_expires_at_ms?,
3219 })
3220 }),
3221 records: state
3222 .records
3223 .iter()
3224 .filter(|record| record.work_id() == work_id)
3225 .cloned()
3226 .collect(),
3227 as_of_record_seq: state.record_seq,
3228 }
3229}
3230
3231fn dead_letter_page<T: Clone>(
3232 state: &RuntimeState<T>,
3233 params: &WorkQueueDeadLetterParams,
3234) -> WorkQueueDeadLetterPage<T> {
3235 let limit = positive_limit(params.limit.unwrap_or(100));
3236 let entries = state
3237 .dead_letters
3238 .iter()
3239 .filter(|record| {
3240 params
3241 .after_dead_letter_seq
3242 .is_none_or(|after| record.record_seq() > after)
3243 && params
3244 .after_work_id
3245 .as_ref()
3246 .is_none_or(|after| record.work_id() > after.as_str())
3247 })
3248 .cloned()
3249 .collect::<Vec<_>>();
3250 let has_more = entries.len() > limit;
3251 let page = entries.into_iter().take(limit).collect::<Vec<_>>();
3252 let next_after_dead_letter_seq = has_more
3253 .then(|| page.last().map(WorkQueueRecord::record_seq))
3254 .flatten();
3255 WorkQueueDeadLetterPage {
3256 entries: page,
3257 next_after_dead_letter_seq,
3258 has_more,
3259 as_of_record_seq: state.record_seq,
3260 }
3261}
3262
3263fn clear_lease<T>(work: &mut WorkState<T>) {
3264 work.lease_id = None;
3265 work.worker_id = None;
3266 work.lease_expires_at_ms = None;
3267}
3268
3269fn is_ready<T>(work: &WorkState<T>, now_ms: u64) -> bool {
3270 match work.state {
3271 WorkQueueDerivedState::Scheduled => work.not_before_ms.is_some_and(|t| t <= now_ms),
3272 WorkQueueDerivedState::RetryWait => work.retry_at_ms.is_some_and(|t| t <= now_ms),
3273 WorkQueueDerivedState::Ready => true,
3274 _ => false,
3275 }
3276}
3277
3278fn is_ready_for_projection<T>(work: &WorkState<T>, now_ms: Option<u64>) -> bool {
3279 match work.state {
3280 WorkQueueDerivedState::Scheduled | WorkQueueDerivedState::RetryWait => {
3281 now_ms.is_some_and(|now| is_ready(work, now))
3282 }
3283 WorkQueueDerivedState::Ready => true,
3284 _ => false,
3285 }
3286}
3287
3288fn is_expired<T>(work: &WorkState<T>, now_ms: u64) -> bool {
3289 work.state == WorkQueueDerivedState::Leased
3290 && work
3291 .lease_expires_at_ms
3292 .is_some_and(|expires| expires <= now_ms)
3293}
3294
3295fn is_terminal(state: WorkQueueDerivedState) -> bool {
3296 matches!(
3297 state,
3298 WorkQueueDerivedState::Completed
3299 | WorkQueueDerivedState::Canceled
3300 | WorkQueueDerivedState::DeadLettered
3301 )
3302}
3303
3304fn record_queue_id<T>(record: &WorkQueueRecord<T>) -> &str {
3305 match record {
3306 WorkQueueRecord::WorkAdmitted { queue_id, .. }
3307 | WorkQueueRecord::AdmissionDeduped { queue_id, .. }
3308 | WorkQueueRecord::WorkScheduled { queue_id, .. }
3309 | WorkQueueRecord::WorkClaimed { queue_id, .. }
3310 | WorkQueueRecord::LeaseRenewed { queue_id, .. }
3311 | WorkQueueRecord::WorkReleased { queue_id, .. }
3312 | WorkQueueRecord::LeaseExpired { queue_id, .. }
3313 | WorkQueueRecord::AttemptCompleted { queue_id, .. }
3314 | WorkQueueRecord::WorkCompleted { queue_id, .. }
3315 | WorkQueueRecord::AttemptFailed { queue_id, .. }
3316 | WorkQueueRecord::RetryScheduled { queue_id, .. }
3317 | WorkQueueRecord::WorkDeadLettered { queue_id, .. }
3318 | WorkQueueRecord::WorkCanceled { queue_id, .. } => queue_id,
3319 }
3320}
3321
3322fn pull_params<T: Clone + Default + 'static>(ctx: &Ctx) -> T {
3323 ctx.pull()
3324 .and_then(|pull| pull.params::<T>())
3325 .map(|params| (*params).clone())
3326 .unwrap_or_default()
3327}
3328
3329fn timestamp_or_zero(now: &Rc<dyn Fn() -> u64>) -> u64 {
3330 catch_unwind(AssertUnwindSafe(|| now())).unwrap_or(0)
3331}
3332
3333fn positive_limit(limit: usize) -> usize {
3334 assert!(limit > 0, "workQueue: limit must be positive");
3335 limit
3336}
3337
3338fn assert_non_empty(value: &str, owner: &str) {
3339 assert!(!value.is_empty(), "{owner}: must be a non-empty string");
3340}
3341
3342fn node_opts(name: impl Into<String>, factory: impl Into<String>) -> GraphNodeOpts {
3343 let mut opts = GraphNodeOpts::named(name);
3344 opts.node = NodeOpts {
3345 factory: Some(factory.into()),
3346 complete_when_deps_complete: false,
3347 error_when_deps_error: false,
3348 ..opts.node
3349 };
3350 opts
3351}
3352
3353fn pull_node_opts(
3354 name: impl Into<String>,
3355 factory: impl Into<String>,
3356 pull_id: LockId,
3357) -> GraphNodeOpts {
3358 let mut opts = node_opts(name, factory);
3359 opts.node.pull_id = Some(pull_id);
3360 opts.node.partial = true;
3361 opts
3362}
3363
3364fn _assert_no_process_or_worker_deps(_: Option<Core>) {}