1use serde_json::Value;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use crate::DbBackendConfig;
7use crate::operations::{
8 Aggregate, AggregateQuery, AppSchema, DatabaseError, DatabaseOperations, Mutation, Predicate,
9 Query, SortKey, Transaction, WriteValue,
10};
11use std::collections::BTreeMap;
12
13pub fn control_job(
17 adapter: &dyn DatabaseOperations,
18 config: &DbBackendConfig,
19 schema: &AppSchema,
20 actor_id: i64,
21 execution_id: i64,
22 retry: bool,
23 now: &str,
24) -> Result<(), DatabaseError> {
25 if actor_id <= 0 || execution_id <= 0 {
26 return Err(DatabaseError::InvalidInput);
27 }
28 let statuses = if retry {
29 vec![
30 Value::String("failed".to_owned()),
31 Value::String("cancelled".to_owned()),
32 ]
33 } else {
34 vec![
35 Value::String("queued".to_owned()),
36 Value::String("running".to_owned()),
37 ]
38 };
39 let current = adapter.query(
40 config,
41 schema,
42 &Query {
43 entity: "job_execution".to_owned(),
44 fields: vec!["id".to_owned(), "execution_status".to_owned()],
45 filter: Some(Predicate::Equal("id".to_owned(), Value::from(execution_id))),
46 order: Vec::new(),
47 limit: 1,
48 offset: 0,
49 },
50 )?;
51 let row = current.first().ok_or(DatabaseError::MissingRecord)?;
52 let status = row
53 .get("execution_status")
54 .cloned()
55 .ok_or(DatabaseError::StorageFailure)?;
56 if !statuses.contains(&status) {
57 return Err(DatabaseError::Conflict);
58 }
59 let mut values = BTreeMap::from([
60 (
61 "execution_status".to_owned(),
62 WriteValue::Literal(Value::String(
63 if retry { "queued" } else { "cancelled" }.to_owned(),
64 )),
65 ),
66 (
67 "lease_expires_at".to_owned(),
68 WriteValue::Literal(Value::Null),
69 ),
70 ]);
71 if retry {
72 values.insert(
73 "available_at".to_owned(),
74 WriteValue::Literal(Value::String(now.to_owned())),
75 );
76 values.insert("claimed_at".to_owned(), WriteValue::Literal(Value::Null));
77 values.insert("failed_at".to_owned(), WriteValue::Literal(Value::Null));
78 values.insert("completed_at".to_owned(), WriteValue::Literal(Value::Null));
79 values.insert(
80 "attempt_count".to_owned(),
81 WriteValue::Literal(Value::from(0)),
82 );
83 values.insert(
84 "last_error".to_owned(),
85 WriteValue::Literal(Value::String(String::new())),
86 );
87 }
88 adapter.transact(
89 config,
90 schema,
91 &Transaction {
92 steps: vec![
93 Mutation::RequireCount {
94 entity: "user_account".to_owned(),
95 filter: Predicate::All(vec![
96 Predicate::Equal("id".to_owned(), Value::from(actor_id)),
97 Predicate::Equal("is_active".to_owned(), Value::Bool(true)),
98 ]),
99 minimum: 1,
100 maximum: Some(1),
101 },
102 Mutation::Update {
103 entity: "job_execution".to_owned(),
104 filter: Predicate::All(vec![
105 Predicate::Equal("id".to_owned(), Value::from(execution_id)),
106 Predicate::Equal("execution_status".to_owned(), status),
107 ]),
108 values,
109 expected_rows: Some(1),
110 },
111 ],
112 idempotency_key: None,
113 },
114 )?;
115 Ok(())
116}
117
118pub fn job_detail_view(
120 adapter: &dyn DatabaseOperations,
121 config: &DbBackendConfig,
122 schema: &AppSchema,
123 execution_id: i64,
124) -> Result<Value, DatabaseError> {
125 if execution_id <= 0 {
126 return Err(DatabaseError::InvalidInput);
127 }
128 let fields = |name: &str| -> Result<Vec<String>, DatabaseError> {
129 Ok(schema
130 .entities
131 .iter()
132 .find(|entity| entity.name == name)
133 .ok_or(DatabaseError::InvalidContract)?
134 .fields
135 .iter()
136 .map(|field| field.name.clone())
137 .collect())
138 };
139 let executions = adapter.query(
140 config,
141 schema,
142 &Query {
143 entity: "job_execution".to_owned(),
144 fields: fields("job_execution")?,
145 filter: Some(Predicate::Equal("id".to_owned(), Value::from(execution_id))),
146 order: Vec::new(),
147 limit: 1,
148 offset: 0,
149 },
150 )?;
151 if executions.is_empty() {
152 return Err(DatabaseError::MissingRecord);
153 }
154 let attempts = adapter.query(
155 config,
156 schema,
157 &Query {
158 entity: "job_execution_attempt".to_owned(),
159 fields: fields("job_execution_attempt")?,
160 filter: Some(Predicate::Equal(
161 "job_execution_id".to_owned(),
162 Value::from(execution_id),
163 )),
164 order: vec![SortKey {
165 field: "id".to_owned(),
166 descending: false,
167 }],
168 limit: 257,
169 offset: 0,
170 },
171 )?;
172 if attempts.len() > 256 {
173 return Err(DatabaseError::CapacityExceeded);
174 }
175 Ok(serde_json::json!({"job_executions": executions, "job_execution_attempts": attempts}))
176}
177
178pub fn job_queue_page(
181 adapter: &dyn DatabaseOperations,
182 config: &DbBackendConfig,
183 schema: &AppSchema,
184 status: &str,
185 search: &str,
186 requested_page: usize,
187) -> Result<Value, DatabaseError> {
188 if search.chars().count() > 160 || requested_page == 0 || requested_page > 50_000 {
189 return Err(DatabaseError::InvalidInput);
190 }
191 let mut filters = Vec::new();
192 if !status.is_empty() {
193 if !matches!(
194 status,
195 "queued" | "running" | "succeeded" | "failed" | "cancelled"
196 ) {
197 return Err(DatabaseError::InvalidInput);
198 }
199 filters.push(Predicate::Equal(
200 "execution_status".to_owned(),
201 Value::String(status.to_owned()),
202 ));
203 }
204 if !search.is_empty() {
205 filters.push(Predicate::Any(vec![
206 Predicate::Contains("job_id".to_owned(), search.to_owned()),
207 Predicate::Contains("handler_id".to_owned(), search.to_owned()),
208 ]));
209 }
210 let filter = (!filters.is_empty()).then_some(Predicate::All(filters));
211 let count = adapter
212 .aggregate(
213 config,
214 schema,
215 &AggregateQuery {
216 entity: "job_execution".to_owned(),
217 filter: filter.clone(),
218 aggregate: Aggregate::Count,
219 },
220 )?
221 .as_u64()
222 .ok_or(DatabaseError::StorageFailure)?;
223 let page_count = count.div_ceil(20).max(1);
224 let page = (requested_page as u64).min(page_count);
225 let definition = schema
226 .entities
227 .iter()
228 .find(|entity| entity.name == "job_execution")
229 .ok_or(DatabaseError::InvalidContract)?;
230 let rows = adapter.query(
231 config,
232 schema,
233 &Query {
234 entity: "job_execution".to_owned(),
235 fields: definition
236 .fields
237 .iter()
238 .map(|field| field.name.clone())
239 .collect(),
240 filter,
241 order: vec![SortKey {
242 field: "id".to_owned(),
243 descending: false,
244 }],
245 limit: 20,
246 offset: u32::try_from((page - 1) * 20).map_err(|_| DatabaseError::InvalidInput)?,
247 },
248 )?;
249 let mut counts = serde_json::Map::new();
250 for status in ["queued", "running", "succeeded", "failed", "cancelled"] {
251 let count = adapter.aggregate(
252 config,
253 schema,
254 &AggregateQuery {
255 entity: "job_execution".to_owned(),
256 filter: Some(Predicate::Equal(
257 "execution_status".to_owned(),
258 Value::String(status.to_owned()),
259 )),
260 aggregate: Aggregate::Count,
261 },
262 )?;
263 counts.insert(status.to_owned(), count);
264 }
265 Ok(
266 serde_json::json!({"job_executions": rows, "_job_page": {"counts": counts, "matched_count": count, "page": page}}),
267 )
268}
269
270pub fn pending_queue_work(
274 adapter: &dyn DatabaseOperations,
275 config: &DbBackendConfig,
276 schema: &AppSchema,
277) -> Result<Option<bool>, DatabaseError> {
278 let families = [
279 (
280 "notification_delivery",
281 "delivery_status",
282 &["queued", "retryable"][..],
283 ),
284 (
285 "integration_delivery",
286 "delivery_status",
287 &["queued", "retryable"][..],
288 ),
289 ("job_execution", "execution_status", &["queued"][..]),
290 ];
291 if families.iter().any(|(entity, _, _)| {
292 !schema
293 .entities
294 .iter()
295 .any(|declared| declared.name == *entity)
296 }) {
297 return Ok(None);
298 }
299 requeue_local_jobs(adapter, config, schema, false, 32)?;
300 let now = SystemTime::now()
301 .duration_since(UNIX_EPOCH)
302 .map_err(|_| DatabaseError::Unavailable)?
303 .as_secs()
304 .to_string();
305 let now_utc = chrono::DateTime::<chrono::Utc>::from_timestamp(
306 now.parse::<i64>()
307 .map_err(|_| DatabaseError::InvalidInput)?,
308 0,
309 )
310 .ok_or(DatabaseError::InvalidInput)?
311 .to_rfc3339();
312 for (entity, status_field, statuses) in families {
313 let filter = if entity == "job_execution" {
314 Predicate::All(vec![
315 Predicate::Equal(status_field.to_owned(), Value::String("queued".to_owned())),
316 Predicate::AtMost("available_at".to_owned(), Value::String(now_utc.clone())),
317 ])
318 } else if statuses.contains(&"retryable") {
319 Predicate::Any(vec![
320 Predicate::Equal(status_field.to_owned(), Value::String("queued".to_owned())),
321 Predicate::All(vec![
322 Predicate::Equal(
323 status_field.to_owned(),
324 Value::String("retryable".to_owned()),
325 ),
326 Predicate::AtMost("next_attempt_at".to_owned(), Value::String(now.clone())),
327 ]),
328 ])
329 } else {
330 Predicate::In(
331 status_field.to_owned(),
332 statuses
333 .iter()
334 .map(|status| Value::String((*status).to_owned()))
335 .collect(),
336 )
337 };
338 let rows = adapter.query(
339 config,
340 schema,
341 &Query {
342 entity: entity.to_owned(),
343 fields: vec!["id".to_owned()],
344 filter: Some(filter),
345 order: Vec::new(),
346 limit: 1,
347 offset: 0,
348 },
349 )?;
350 if !rows.is_empty() {
351 return Ok(Some(true));
352 }
353 }
354 Ok(Some(false))
355}
356
357pub fn scoped_job_worker_snapshot(
360 adapter: &dyn DatabaseOperations,
361 config: &DbBackendConfig,
362 schema: &AppSchema,
363) -> Result<Option<Value>, DatabaseError> {
364 for name in [
365 "notification_delivery",
366 "integration_delivery",
367 "job_execution",
368 "job_execution_attempt",
369 "workflow_execution_event",
370 "integration_delivery_attempt",
371 ] {
372 if !schema.entities.iter().any(|entity| entity.name == name) {
373 return Ok(None);
374 }
375 }
376 let epoch = i64::try_from(
377 SystemTime::now()
378 .duration_since(UNIX_EPOCH)
379 .map_err(|_| DatabaseError::Unavailable)?
380 .as_secs(),
381 )
382 .map_err(|_| DatabaseError::InvalidInput)?;
383 let all_fields = |entity: &str| -> Result<Vec<String>, DatabaseError> {
384 Ok(schema
385 .entities
386 .iter()
387 .find(|definition| definition.name == entity)
388 .ok_or(DatabaseError::InvalidContract)?
389 .fields
390 .iter()
391 .map(|field| field.name.clone())
392 .collect())
393 };
394 let mut deliveries = BTreeMap::new();
395 for (entity, section) in [
396 ("notification_delivery", "notification_deliveries"),
397 ("integration_delivery", "integration_deliveries"),
398 ] {
399 let rows = adapter.query(
400 config,
401 schema,
402 &Query {
403 entity: entity.to_owned(),
404 fields: all_fields(entity)?,
405 filter: Some(Predicate::Any(vec![
406 Predicate::Equal(
407 "delivery_status".to_owned(),
408 Value::String("queued".to_owned()),
409 ),
410 Predicate::All(vec![
411 Predicate::Equal(
412 "delivery_status".to_owned(),
413 Value::String("retryable".to_owned()),
414 ),
415 Predicate::AtMost(
416 "next_attempt_at".to_owned(),
417 Value::String(epoch.to_string()),
418 ),
419 ]),
420 ])),
421 order: vec![SortKey {
422 field: "id".to_owned(),
423 descending: false,
424 }],
425 limit: 1,
426 offset: 0,
427 },
428 )?;
429 deliveries.insert(section, rows);
430 }
431 let due = adapter.query(
432 config,
433 schema,
434 &Query {
435 entity: "job_execution".to_owned(),
436 fields: schema
437 .entities
438 .iter()
439 .find(|item| item.name == "job_execution")
440 .ok_or(DatabaseError::InvalidContract)?
441 .fields
442 .iter()
443 .map(|field| field.name.clone())
444 .collect(),
445 filter: Some(Predicate::All(vec![
446 Predicate::Equal(
447 "execution_status".to_owned(),
448 Value::String("queued".to_owned()),
449 ),
450 Predicate::AtMost(
451 "available_at".to_owned(),
452 Value::String(
453 chrono::DateTime::<chrono::Utc>::from_timestamp(epoch, 0)
454 .ok_or(DatabaseError::InvalidInput)?
455 .to_rfc3339(),
456 ),
457 ),
458 ])),
459 order: vec![SortKey {
460 field: "id".to_owned(),
461 descending: false,
462 }],
463 limit: 1,
464 offset: 0,
465 },
466 )?;
467 if due.is_empty() && deliveries.values().all(Vec::is_empty) {
468 return Ok(None);
469 }
470 let latest = |entity_name: &str| -> Result<Vec<serde_json::Map<String, Value>>, DatabaseError> {
471 let _entity = schema
472 .entities
473 .iter()
474 .find(|item| item.name == entity_name)
475 .ok_or(DatabaseError::InvalidContract)?;
476 adapter.query(
477 config,
478 schema,
479 &Query {
480 entity: entity_name.to_owned(),
481 fields: vec!["id".to_owned()],
482 filter: None,
483 order: vec![SortKey {
484 field: "id".to_owned(),
485 descending: true,
486 }],
487 limit: 1,
488 offset: 0,
489 },
490 )
491 };
492 let mut notification = deliveries
493 .remove("notification_deliveries")
494 .ok_or(DatabaseError::InvalidContract)?;
495 let mut integrations = deliveries
496 .remove("integration_deliveries")
497 .ok_or(DatabaseError::InvalidContract)?;
498 for row in &mut integrations {
499 if let Some(value) = row.get("signature_timestamp_header").cloned() {
500 row.insert("timestamp_header".to_owned(), value);
501 }
502 }
503 let mut users = Vec::new();
504 let mut resets = Vec::new();
505 let mut invites = Vec::new();
506 let mut businesses = Vec::new();
507 for row in &mut notification {
508 let kind = row
509 .get("template_id")
510 .and_then(Value::as_str)
511 .ok_or(DatabaseError::StorageFailure)?
512 .to_owned();
513 if matches!(kind.as_str(), "password_reset" | "business_invite") {
514 let secret = row
515 .get("resource_id")
516 .and_then(Value::as_str)
517 .ok_or(DatabaseError::StorageFailure)?
518 .to_owned();
519 let hash = crate::operations::bytes_fingerprint(secret.as_bytes());
520 let entity = if kind == "password_reset" {
521 "password_reset"
522 } else {
523 "invite"
524 };
525 let records = adapter.query(
526 config,
527 schema,
528 &Query {
529 entity: entity.to_owned(),
530 fields: all_fields(entity)?,
531 filter: Some(Predicate::Equal(
532 "token_hash".to_owned(),
533 Value::String(hash),
534 )),
535 order: Vec::new(),
536 limit: 2,
537 offset: 0,
538 },
539 )?;
540 if records.len() > 1 {
541 return Err(DatabaseError::Conflict);
542 }
543 if kind == "password_reset" {
544 let ids = records
545 .iter()
546 .filter_map(|row| row.get("user_id").cloned())
547 .collect::<Vec<_>>();
548 users = crate::operations::query_related_rows(
549 adapter,
550 config,
551 schema,
552 crate::operations::RelatedRowsQuery {
553 entity: "user_account",
554 foreign_key: "id",
555 keys: &ids,
556 fields: &[
557 "id".to_owned(),
558 "display_name".to_owned(),
559 "username".to_owned(),
560 "email".to_owned(),
561 ],
562 maximum: 1,
563 },
564 )?;
565 resets = records;
566 } else {
567 for mut invite in records {
568 let tenant_id = invite
569 .get("target_tenant_id")
570 .cloned()
571 .ok_or(DatabaseError::StorageFailure)?;
572 let tenants = adapter.query(
573 config,
574 schema,
575 &Query {
576 entity: "tenant".to_owned(),
577 fields: vec!["source_business_id".to_owned()],
578 filter: Some(Predicate::Equal("id".to_owned(), tenant_id)),
579 order: Vec::new(),
580 limit: 1,
581 offset: 0,
582 },
583 )?;
584 let business_ids = tenants
585 .iter()
586 .filter_map(|row| row.get("source_business_id").cloned())
587 .collect::<Vec<_>>();
588 businesses = crate::operations::query_related_rows(
589 adapter,
590 config,
591 schema,
592 crate::operations::RelatedRowsQuery {
593 entity: "business",
594 foreign_key: "id",
595 keys: &business_ids,
596 fields: &["id".to_owned(), "name".to_owned()],
597 maximum: 1,
598 },
599 )?;
600 if let Some(id) = business_ids.first() {
601 invite.insert("business_id".to_owned(), id.clone());
602 }
603 invites.push(invite);
604 }
605 }
606 row.insert("secret".to_owned(), Value::String(secret));
607 row.insert("delivery_kind".to_owned(), Value::String(kind));
608 if matches!(
609 row.get("adapter_id").and_then(Value::as_str),
610 Some("local_reset_queue" | "local_invite_queue")
611 ) {
612 row.insert(
613 "adapter_kind".to_owned(),
614 Value::String("notification_local".to_owned()),
615 );
616 }
617 }
618 }
619 let mut snapshot = serde_json::json!({
620 "_worker_scope": "queue",
621 "job_executions": due,
622 "job_execution_attempts": latest("job_execution_attempt")?,
623 "workflow_execution_events": latest("workflow_execution_event")?,
624 "notification_deliveries": notification,
625 "integration_deliveries": integrations,
626 "integration_delivery_attempts": latest("integration_delivery_attempt")?,
627 "users": users, "password_resets": resets, "business_invites": invites, "businesses": businesses,
628 });
629 claim_worker_rows(adapter, config, schema, &mut snapshot, epoch)?;
630 if snapshot
631 .get("_worker_claims")
632 .and_then(Value::as_array)
633 .is_none_or(Vec::is_empty)
634 {
635 return Ok(None);
636 }
637 Ok(Some(snapshot))
638}
639
640fn claim_worker_rows(
644 adapter: &dyn DatabaseOperations,
645 config: &DbBackendConfig,
646 schema: &AppSchema,
647 snapshot: &mut Value,
648 epoch: i64,
649) -> Result<(), DatabaseError> {
650 use crate::operations::Claim;
651 let mut claims = Vec::new();
652 for (section, entity, status, lease) in [
653 (
654 "notification_deliveries",
655 "notification_delivery",
656 "delivery_status",
657 "next_attempt_at",
658 ),
659 (
660 "integration_deliveries",
661 "integration_delivery",
662 "delivery_status",
663 "next_attempt_at",
664 ),
665 (
666 "job_executions",
667 "job_execution",
668 "execution_status",
669 "lease_expires_at",
670 ),
671 ] {
672 let rows = snapshot
673 .get_mut(section)
674 .and_then(Value::as_array_mut)
675 .ok_or(DatabaseError::InvalidInput)?;
676 let mut retained = Vec::new();
677 for row in rows.drain(..) {
678 let id = row
679 .get("id")
680 .and_then(Value::as_i64)
681 .ok_or(DatabaseError::InvalidInput)?;
682 let old_status = row
683 .get(status)
684 .cloned()
685 .ok_or(DatabaseError::InvalidInput)?;
686 let expires = epoch.checked_add(120).ok_or(DatabaseError::InvalidInput)?;
687 let job = entity == "job_execution";
688 let lease_value = if job {
689 Value::String(
690 chrono::DateTime::<chrono::Utc>::from_timestamp(expires, 0)
691 .ok_or(DatabaseError::InvalidInput)?
692 .to_rfc3339(),
693 )
694 } else {
695 Value::String(expires.to_string())
696 };
697 let claim_status = Value::String(if job { "running" } else { "retryable" }.to_owned());
698 let mut filter = vec![
699 Predicate::Equal("id".to_owned(), Value::from(id)),
700 Predicate::Equal(status.to_owned(), old_status),
701 ];
702 if let Some(previous) = row.get(lease) {
703 filter.push(if previous.is_null() {
704 Predicate::IsNull(lease.to_owned())
705 } else {
706 Predicate::Equal(lease.to_owned(), previous.clone())
707 });
708 }
709 let values = BTreeMap::from([
710 (status.to_owned(), WriteValue::Literal(claim_status.clone())),
711 (lease.to_owned(), WriteValue::Literal(lease_value.clone())),
712 ]);
713 if adapter.claim_one(
714 config,
715 schema,
716 &Claim {
717 entity: entity.to_owned(),
718 filter: Predicate::All(filter),
719 order: vec![crate::operations::SortKey {
720 field: "id".to_owned(),
721 descending: false,
722 }],
723 values,
724 },
725 )? == Some(id)
726 {
727 claims.push(serde_json::json!({"entity": entity, "id": id, "fields": {status: claim_status, lease: lease_value}}));
728 retained.push(row);
729 }
730 }
731 *rows = retained;
732 }
733 snapshot
734 .as_object_mut()
735 .ok_or(DatabaseError::InvalidInput)?
736 .insert("_worker_claims".to_owned(), Value::Array(claims));
737 Ok(())
738}
739
740pub fn complete_one_local_job(
743 adapter: &dyn DatabaseOperations,
744 config: &DbBackendConfig,
745 schema: &AppSchema,
746) -> Result<Option<i64>, DatabaseError> {
747 let rows = adapter.query(
748 config,
749 schema,
750 &Query {
751 entity: "job_execution".to_owned(),
752 fields: [
753 "id",
754 "attempt_count",
755 "max_attempts",
756 "workflow_execution_id",
757 "job_id",
758 ]
759 .into_iter()
760 .map(str::to_owned)
761 .collect(),
762 filter: Some(Predicate::All(vec![
763 Predicate::Equal(
764 "execution_status".to_owned(),
765 Value::String("queued".to_owned()),
766 ),
767 Predicate::AtMost(
768 "available_at".to_owned(),
769 Value::String(chrono::Utc::now().to_rfc3339()),
770 ),
771 ])),
772 order: vec![SortKey {
773 field: "id".to_owned(),
774 descending: false,
775 }],
776 limit: 1,
777 offset: 0,
778 },
779 )?;
780 let Some(row) = rows.first() else {
781 return Ok(None);
782 };
783 let id = row
784 .get("id")
785 .and_then(Value::as_i64)
786 .ok_or(DatabaseError::StorageFailure)?;
787 let attempts = row
788 .get("attempt_count")
789 .and_then(Value::as_i64)
790 .ok_or(DatabaseError::StorageFailure)?
791 .checked_add(1)
792 .ok_or(DatabaseError::InvalidInput)?;
793 if attempts
794 > row
795 .get("max_attempts")
796 .and_then(Value::as_i64)
797 .ok_or(DatabaseError::StorageFailure)?
798 {
799 return Err(DatabaseError::Conflict);
800 }
801 let now = SystemTime::now()
802 .duration_since(UNIX_EPOCH)
803 .map_err(|_| DatabaseError::Unavailable)?;
804 let epoch = i64::try_from(now.as_secs()).map_err(|_| DatabaseError::InvalidInput)?;
805 let completed = chrono::DateTime::<chrono::Utc>::from_timestamp(epoch, 0)
806 .ok_or(DatabaseError::InvalidInput)?
807 .to_rfc3339();
808 let workflow_id = row.get("workflow_execution_id").and_then(Value::as_i64);
809 let job_id = row
810 .get("job_id")
811 .and_then(Value::as_str)
812 .unwrap_or_default();
813 let mut steps = vec![Mutation::Update {
814 entity: "job_execution".to_owned(),
815 filter: Predicate::All(vec![
816 Predicate::Equal("id".to_owned(), Value::from(id)),
817 Predicate::Equal(
818 "execution_status".to_owned(),
819 Value::String("queued".to_owned()),
820 ),
821 Predicate::Equal("attempt_count".to_owned(), Value::from(attempts - 1)),
822 ]),
823 values: BTreeMap::from([
824 (
825 "execution_status".to_owned(),
826 WriteValue::Literal(Value::String("succeeded".to_owned())),
827 ),
828 (
829 "completed_at".to_owned(),
830 WriteValue::Literal(Value::String(completed)),
831 ),
832 (
833 "attempt_count".to_owned(),
834 WriteValue::Literal(Value::from(attempts)),
835 ),
836 ]),
837 expected_rows: Some(1),
838 }];
839 if schema
840 .entities
841 .iter()
842 .any(|entity| entity.name == "job_execution_attempt")
843 {
844 steps.push(Mutation::Insert {
845 entity: "job_execution_attempt".to_owned(),
846 values: BTreeMap::from([
847 (
848 "job_execution_id".to_owned(),
849 WriteValue::Literal(Value::from(id)),
850 ),
851 (
852 "attempt_number".to_owned(),
853 WriteValue::Literal(Value::from(attempts)),
854 ),
855 (
856 "execution_status".to_owned(),
857 WriteValue::Literal(Value::String("succeeded".to_owned())),
858 ),
859 (
860 "payload_json".to_owned(),
861 WriteValue::Literal(Value::String("{}".to_owned())),
862 ),
863 ]),
864 });
865 }
866 if let Some(workflow_id) = workflow_id.filter(|id| *id > 0)
867 && schema
868 .entities
869 .iter()
870 .any(|entity| entity.name == "workflow_execution_event")
871 {
872 steps.push(Mutation::Insert {
873 entity: "workflow_execution_event".to_owned(),
874 values: BTreeMap::from([
875 (
876 "workflow_execution_id".to_owned(),
877 WriteValue::Literal(Value::from(workflow_id)),
878 ),
879 (
880 "event_kind".to_owned(),
881 WriteValue::Literal(Value::String("job_succeeded".to_owned())),
882 ),
883 (
884 "event_key".to_owned(),
885 WriteValue::Literal(Value::String(job_id.to_owned())),
886 ),
887 (
888 "payload_json".to_owned(),
889 WriteValue::Literal(Value::String("{}".to_owned())),
890 ),
891 ]),
892 });
893 }
894 adapter.transact(
895 config,
896 schema,
897 &Transaction {
898 steps,
899 idempotency_key: None,
900 },
901 )?;
902 Ok(Some(id))
903}
904
905pub fn local_job_report(
907 adapter: &dyn DatabaseOperations,
908 config: &DbBackendConfig,
909 schema: &AppSchema,
910) -> Result<(Vec<Value>, Value), DatabaseError> {
911 let rows = adapter.query(
912 config,
913 schema,
914 &Query {
915 entity: "job_execution".to_owned(),
916 fields: [
917 "id",
918 "job_id",
919 "handler_id",
920 "queue_name",
921 "execution_status",
922 "attempt_count",
923 ]
924 .into_iter()
925 .map(str::to_owned)
926 .collect(),
927 filter: None,
928 order: vec![SortKey {
929 field: "id".to_owned(),
930 descending: false,
931 }],
932 limit: 256,
933 offset: 0,
934 },
935 )?;
936 let jobs = rows
937 .into_iter()
938 .map(|mut row| {
939 if let Some(id) = row.remove("id") {
940 row.insert("execution_id".to_owned(), id);
941 }
942 Value::Object(row)
943 })
944 .collect();
945 let mut counts = serde_json::Map::new();
946 for status in ["queued", "running", "succeeded", "failed"] {
947 let count = adapter.aggregate(
948 config,
949 schema,
950 &AggregateQuery {
951 entity: "job_execution".to_owned(),
952 filter: Some(Predicate::Equal(
953 "execution_status".to_owned(),
954 Value::String(status.to_owned()),
955 )),
956 aggregate: Aggregate::Count,
957 },
958 )?;
959 counts.insert(status.to_owned(), count);
960 }
961 Ok((jobs, Value::Object(counts)))
962}
963
964pub fn local_workflow_report(
966 adapter: &dyn DatabaseOperations,
967 config: &DbBackendConfig,
968 schema: &AppSchema,
969) -> Result<Vec<Value>, DatabaseError> {
970 adapter
971 .query(
972 config,
973 schema,
974 &Query {
975 entity: "workflow_execution".to_owned(),
976 fields: ["id", "workflow_id", "execution_status", "replay_trace_id"]
977 .into_iter()
978 .map(str::to_owned)
979 .collect(),
980 filter: None,
981 order: vec![SortKey {
982 field: "id".to_owned(),
983 descending: false,
984 }],
985 limit: 256,
986 offset: 0,
987 },
988 )
989 .map(|rows| {
990 rows.into_iter()
991 .map(|mut row| {
992 if let Some(id) = row.remove("id") {
993 row.insert("execution_id".to_owned(), id);
994 }
995 Value::Object(row)
996 })
997 .collect()
998 })
999}
1000
1001pub fn requeue_local_jobs(
1004 adapter: &dyn DatabaseOperations,
1005 config: &DbBackendConfig,
1006 schema: &AppSchema,
1007 retry_failed: bool,
1008 limit: u32,
1009) -> Result<usize, DatabaseError> {
1010 if limit == 0 || limit > 256 {
1011 return Err(DatabaseError::InvalidInput);
1012 }
1013 let status = if retry_failed { "failed" } else { "running" };
1014 let now = SystemTime::now()
1015 .duration_since(UNIX_EPOCH)
1016 .map_err(|_| DatabaseError::Unavailable)?;
1017 let epoch = i64::try_from(now.as_secs()).map_err(|_| DatabaseError::InvalidInput)?;
1018 let timestamp = chrono::DateTime::<chrono::Utc>::from_timestamp(epoch, 0)
1019 .ok_or(DatabaseError::InvalidInput)?
1020 .to_rfc3339();
1021 let mut processed = 0;
1022 let mut last_id = 0;
1023 loop {
1024 if processed >= limit as usize {
1025 break;
1026 }
1027 let rows = adapter.query(
1028 config,
1029 schema,
1030 &Query {
1031 entity: "job_execution".to_owned(),
1032 fields: ["id", "attempt_count", "max_attempts", "lease_expires_at"]
1033 .into_iter()
1034 .map(str::to_owned)
1035 .collect(),
1036 filter: Some(Predicate::All(vec![
1037 Predicate::Equal(
1038 "execution_status".to_owned(),
1039 Value::String(status.to_owned()),
1040 ),
1041 Predicate::GreaterThan("id".to_owned(), Value::from(last_id)),
1042 if retry_failed {
1043 Predicate::GreaterThan("max_attempts".to_owned(), Value::from(0))
1044 } else {
1045 Predicate::LessThan(
1046 "lease_expires_at".to_owned(),
1047 Value::String(timestamp.clone()),
1048 )
1049 },
1050 ])),
1051 order: vec![SortKey {
1052 field: "id".to_owned(),
1053 descending: false,
1054 }],
1055 limit: 256,
1056 offset: 0,
1057 },
1058 )?;
1059 if rows.is_empty() {
1060 break;
1061 }
1062 let page_len = rows.len();
1063 for row in rows {
1064 let id = row
1065 .get("id")
1066 .and_then(Value::as_i64)
1067 .ok_or(DatabaseError::StorageFailure)?;
1068 last_id = id;
1069 let attempt_count = row
1070 .get("attempt_count")
1071 .and_then(Value::as_i64)
1072 .ok_or(DatabaseError::StorageFailure)?;
1073 let max_attempts = row
1074 .get("max_attempts")
1075 .and_then(Value::as_i64)
1076 .ok_or(DatabaseError::StorageFailure)?;
1077 if retry_failed && attempt_count >= max_attempts {
1078 continue;
1079 }
1080 if !retry_failed {
1081 let expired = row
1082 .get("lease_expires_at")
1083 .and_then(Value::as_str)
1084 .and_then(|lease| {
1085 lease.parse::<i64>().ok().or_else(|| {
1086 chrono::DateTime::parse_from_rfc3339(lease)
1087 .ok()
1088 .map(|time| time.timestamp())
1089 })
1090 })
1091 .is_some_and(|lease| lease < epoch);
1092 if !expired {
1093 continue;
1094 }
1095 }
1096 adapter.transact(
1097 config,
1098 schema,
1099 &Transaction {
1100 steps: vec![Mutation::Update {
1101 entity: "job_execution".to_owned(),
1102 filter: Predicate::All(vec![
1103 Predicate::Equal("id".to_owned(), Value::from(id)),
1104 Predicate::Equal(
1105 "execution_status".to_owned(),
1106 Value::String(status.to_owned()),
1107 ),
1108 Predicate::Equal(
1109 "attempt_count".to_owned(),
1110 Value::from(attempt_count),
1111 ),
1112 if retry_failed {
1113 Predicate::Equal(
1114 "max_attempts".to_owned(),
1115 Value::from(max_attempts),
1116 )
1117 } else {
1118 Predicate::Equal(
1119 "lease_expires_at".to_owned(),
1120 row.get("lease_expires_at")
1121 .cloned()
1122 .ok_or(DatabaseError::StorageFailure)?,
1123 )
1124 },
1125 ]),
1126 values: BTreeMap::from([
1127 (
1128 "execution_status".to_owned(),
1129 WriteValue::Literal(Value::String("queued".to_owned())),
1130 ),
1131 ("claimed_at".to_owned(), WriteValue::Literal(Value::Null)),
1132 (
1133 "lease_expires_at".to_owned(),
1134 WriteValue::Literal(Value::Null),
1135 ),
1136 (
1137 "available_at".to_owned(),
1138 WriteValue::Literal(Value::String(timestamp.clone())),
1139 ),
1140 ]),
1141 expected_rows: Some(1),
1142 }],
1143 idempotency_key: None,
1144 },
1145 )?;
1146 processed += 1;
1147 if processed >= limit as usize {
1148 break;
1149 }
1150 }
1151 if page_len < 256 {
1152 break;
1153 }
1154 }
1155 Ok(processed)
1156}
1157
1158pub fn local_job_replay(
1160 adapter: &dyn DatabaseOperations,
1161 config: &DbBackendConfig,
1162 schema: &AppSchema,
1163 execution_id: i64,
1164) -> Result<Value, DatabaseError> {
1165 if execution_id <= 0 {
1166 return Err(DatabaseError::InvalidInput);
1167 }
1168 let jobs = adapter.query(
1169 config,
1170 schema,
1171 &Query {
1172 entity: "job_execution".to_owned(),
1173 fields: [
1174 "id",
1175 "job_id",
1176 "queue_name",
1177 "execution_status",
1178 "workflow_execution_id",
1179 "replay_trace_id",
1180 ]
1181 .into_iter()
1182 .map(str::to_owned)
1183 .collect(),
1184 filter: Some(Predicate::Equal("id".to_owned(), Value::from(execution_id))),
1185 order: vec![],
1186 limit: 1,
1187 offset: 0,
1188 },
1189 )?;
1190 let Some(job) = jobs.first() else {
1191 return Err(DatabaseError::MissingRecord);
1192 };
1193 let trace = job.get("replay_trace_id").cloned().unwrap_or(Value::Null);
1194 let workflow_id = job.get("workflow_execution_id").and_then(Value::as_i64);
1195 let attempts = adapter.query(
1196 config,
1197 schema,
1198 &Query {
1199 entity: "job_execution_attempt".to_owned(),
1200 fields: ["attempt_number", "execution_status", "error_message"]
1201 .into_iter()
1202 .map(str::to_owned)
1203 .collect(),
1204 filter: Some(Predicate::Equal(
1205 "job_execution_id".to_owned(),
1206 Value::from(execution_id),
1207 )),
1208 order: vec![SortKey {
1209 field: "attempt_number".to_owned(),
1210 descending: false,
1211 }],
1212 limit: 256,
1213 offset: 0,
1214 },
1215 )?;
1216 let events = if let Some(workflow_id) = workflow_id.filter(|id| *id > 0) {
1217 adapter.query(
1218 config,
1219 schema,
1220 &Query {
1221 entity: "workflow_execution_event".to_owned(),
1222 fields: ["event_kind", "event_key", "payload_json"]
1223 .into_iter()
1224 .map(str::to_owned)
1225 .collect(),
1226 filter: Some(Predicate::Equal(
1227 "workflow_execution_id".to_owned(),
1228 Value::from(workflow_id),
1229 )),
1230 order: vec![SortKey {
1231 field: "id".to_owned(),
1232 descending: false,
1233 }],
1234 limit: 256,
1235 offset: 0,
1236 },
1237 )?
1238 } else {
1239 Vec::new()
1240 };
1241 let mut job = job.clone();
1242 job.remove("workflow_execution_id");
1243 job.remove("replay_trace_id");
1244 if let Some(id) = job.remove("id") {
1245 job.insert("execution_id".to_owned(), id);
1246 }
1247 Ok(serde_json::json!({
1248 "replay_trace_id": trace,
1249 "jobs": [job],
1250 "attempts": attempts,
1251 "events": events,
1252 }))
1253}