Skip to main content

pharos_runtime_database/
queue.rs

1//! Bounded queue readiness reads over the configured database adapter.
2
3use 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
13/// Applies a declared queue control with a compare-and-write transition.
14/// Admission and role policy belong to the caller; active actor state and the
15/// expected job status are checked atomically with the mutation.
16pub 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
118/// Reads one execution and its bounded attempts for declared detail rendering.
119pub 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
178/// Reads one queue page and aggregate counts without exporting unrelated app
179/// state. The declared route policy authorizes queue access before this call.
180pub 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
270/// Checks for pending work without exporting the app state document.
271/// A schema without the complete queue contract returns `None` so its caller
272/// can retain its existing worker behavior during adoption.
273pub 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
357/// Reads the next due records and related notification display data. Evidence
358/// ID seeds contain no historical payloads or unrelated users.
359pub 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
640/// Acquires adapter-owned leases before any delivery side effect. The transient
641/// view retains the due status expected by the existing processors, while
642/// completion guards compare the committed lease to prevent stale acknowledgments.
643fn 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
740/// Marks one queued local job complete with a guarded, backend-neutral write.
741/// This operator operation has no external handler side effects.
742pub 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
905/// Reads the bounded operator job list and complete status counts.
906pub 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
964/// Lists bounded workflow executions for the operator jobs report.
965pub 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
1001/// Moves bounded failed or expired-lease jobs back to the queue. The status
1002/// guard prevents an operator command from overwriting a concurrent worker.
1003pub 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
1158/// Reads one job and its bounded attempt/event history through the adapter.
1159pub 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}