Skip to main content

minco_sqlx_postgres/
jobs.rs

1//! Durable `PostgreSQL` job storage: job rows, publication generations,
2//! fenced execution leases and overlap locks.
3//!
4//! The job row owns execution state and the publication row owns pending
5//! transport delivery; the queue message is never authoritative. The
6//! execution claim is a single compare-and-set that mints an opaque lease
7//! identity; every mutation re-checks that identity, so a stale invocation
8//! — even one reusing the same worker name — cannot alter a newer claim's
9//! state. Retry state and the next publication generation commit in one
10//! transaction.
11
12use chrono::{DateTime, Utc};
13use minco_plugin_jobs::{
14    EnqueueOutcome, IngestOutcome, JobAttempt, JobClaim, JobError, JobPublication, JobRecord,
15    JobStatus, PublicationStatus, semantic_fingerprint, validate_worker_claim,
16};
17use sqlx::{PgPool, Postgres, Row, Transaction, postgres::PgRow};
18use uuid::Uuid;
19
20/// PostgreSQL-backed job store implementing every job port.
21#[derive(Debug, Clone)]
22pub struct PostgresJobStore {
23    pool: PgPool,
24}
25
26impl PostgresJobStore {
27    #[must_use]
28    pub const fn new(pool: PgPool) -> Self {
29        Self { pool }
30    }
31
32    /// Insert the job and its first publication generation inside the
33    /// caller's transaction so the business mutation and the durable
34    /// dispatch commit atomically. Rolling the caller's transaction back
35    /// leaves neither row.
36    pub async fn enqueue_in(
37        &self,
38        transaction: &mut Transaction<'_, Postgres>,
39        record: JobRecord,
40    ) -> Result<EnqueueOutcome, JobError> {
41        Self::enqueue_record_in(transaction, record, "pending").await
42    }
43
44    /// Insert the job with its publication recorded as already delivered
45    /// (Scheduler ingestion) inside the caller's transaction.
46    pub async fn ingest_in(
47        &self,
48        transaction: &mut Transaction<'_, Postgres>,
49        record: JobRecord,
50    ) -> Result<IngestOutcome, JobError> {
51        match Self::enqueue_record_in(transaction, record, "published").await? {
52            EnqueueOutcome::Inserted(job_id) => Ok(IngestOutcome::Ingested(job_id)),
53            EnqueueOutcome::Duplicate(existing) => Ok(IngestOutcome::Duplicate(existing)),
54        }
55    }
56
57    async fn enqueue_record_in(
58        transaction: &mut Transaction<'_, Postgres>,
59        record: JobRecord,
60        publication_status: &str,
61    ) -> Result<EnqueueOutcome, JobError> {
62        if record.status != JobStatus::Pending {
63            return Err(JobError::InvalidJob("jobs must be enqueued pending".into()));
64        }
65        record.envelope.validate()?;
66        let envelope_json = serde_json::to_value(&record.envelope).map_err(|error| {
67            JobError::Infrastructure(format!("job envelope encode failed: {error}"))
68        })?;
69        let attempts = serde_json::to_value(&record.attempts)
70            .map_err(|error| JobError::Infrastructure(format!("attempt encode failed: {error}")))?;
71        let fingerprint = semantic_fingerprint(&record.envelope);
72        // The insert runs inside a savepoint so a dedupe-key conflict can
73        // be recovered without aborting the caller's transaction.
74        sqlx::query("SAVEPOINT minco_job_enqueue")
75            .execute(&mut **transaction)
76            .await
77            .map_err(|_| JobError::Infrastructure("job savepoint failed".into()))?;
78        let result = sqlx::query(
79            "INSERT INTO minco_jobs (job_id, worker_profile, envelope, fingerprint, status, \
80             revision, available_at, attempt_count, lease_id, lease_expires_at, attempts, \
81             dedupe_key, failure_code, completed_at) \
82             VALUES ($1, $2, $3, $4, 'pending', $5, $6, $7, NULL, NULL, $8, $9, NULL, NULL)",
83        )
84        .bind(record.envelope.job_id)
85        .bind(&record.envelope.worker_profile)
86        .bind(&envelope_json)
87        .bind(&fingerprint)
88        .bind(i64::try_from(record.revision).unwrap_or(1))
89        .bind(record.envelope.available_at)
90        .bind(i32::try_from(record.attempt_count).unwrap_or(0))
91        .bind(&attempts)
92        .bind(record.envelope.dedupe_key.as_deref())
93        .execute(&mut **transaction)
94        .await;
95        match result {
96            Ok(_) => {
97                sqlx::query("RELEASE SAVEPOINT minco_job_enqueue")
98                    .execute(&mut **transaction)
99                    .await
100                    .map_err(|_| JobError::Infrastructure("job savepoint release failed".into()))?;
101            }
102            Err(error) => {
103                let constraint = error
104                    .as_database_error()
105                    .and_then(|database| database.constraint());
106                sqlx::query("ROLLBACK TO SAVEPOINT minco_job_enqueue")
107                    .execute(&mut **transaction)
108                    .await
109                    .map_err(|_| {
110                        JobError::Infrastructure("job savepoint rollback failed".into())
111                    })?;
112                if constraint == Some("minco_jobs_pkey") {
113                    let existing: Option<(String,)> =
114                        sqlx::query_as("SELECT fingerprint FROM minco_jobs WHERE job_id = $1")
115                            .bind(record.envelope.job_id)
116                            .fetch_optional(&mut **transaction)
117                            .await
118                            .map_err(|_| {
119                                JobError::Infrastructure("job identity probe failed".into())
120                            })?;
121                    return match existing {
122                        Some((existing_fingerprint,)) if existing_fingerprint == fingerprint => {
123                            Ok(EnqueueOutcome::Duplicate(record.envelope.job_id))
124                        }
125                        _ => Err(JobError::DuplicateJobIdentity(record.envelope.job_id)),
126                    };
127                }
128                if constraint == Some("minco_jobs_dedupe_key") {
129                    return dedupe_outcome(&record, &fingerprint, transaction).await;
130                }
131                return Err(infrastructure(&error));
132            }
133        }
134        sqlx::query(
135            "INSERT INTO minco_job_publications (publication_id, job_id, generation, \
136             worker_profile, status, attempt_count, available_at, claimed_by, \
137             claim_expires_at, lease_id, last_error) \
138             VALUES ($1, $2, 1, $3, $4, 0, $5, NULL, NULL, NULL, NULL)",
139        )
140        .bind(Uuid::now_v7())
141        .bind(record.envelope.job_id)
142        .bind(&record.envelope.worker_profile)
143        .bind(publication_status)
144        .bind(record.envelope.available_at)
145        .execute(&mut **transaction)
146        .await
147        .map_err(|_| JobError::Infrastructure("job publication insert failed".into()))?;
148        Ok(EnqueueOutcome::Inserted(record.envelope.job_id))
149    }
150}
151
152async fn dedupe_outcome(
153    record: &JobRecord,
154    fingerprint: &str,
155    transaction: &mut Transaction<'_, Postgres>,
156) -> Result<EnqueueOutcome, JobError> {
157    let existing: Option<(serde_json::Value, String)> =
158        sqlx::query_as("SELECT envelope, fingerprint FROM minco_jobs WHERE dedupe_key = $1")
159            .bind(record.envelope.dedupe_key.as_deref())
160            .fetch_optional(&mut **transaction)
161            .await
162            .map_err(|_| JobError::Infrastructure("job dedupe lookup failed".into()))?;
163    let Some((existing_envelope, existing_fingerprint)) = existing else {
164        return Err(JobError::Infrastructure(
165            "job dedupe conflict vanished during insert".into(),
166        ));
167    };
168    let existing_id = existing_envelope
169        .get("job_id")
170        .and_then(serde_json::Value::as_str)
171        .and_then(|value| value.parse::<Uuid>().ok());
172    match (existing_fingerprint == fingerprint, existing_id) {
173        (true, Some(existing_job_id)) => Ok(EnqueueOutcome::Duplicate(existing_job_id)),
174        (false, Some(existing_job_id)) => {
175            Err(JobError::DuplicateSubmissionConflict { existing_job_id })
176        }
177        _ => Err(JobError::Infrastructure(
178            "job dedupe conflict could not be resolved".into(),
179        )),
180    }
181}
182
183fn infrastructure(error: &sqlx::Error) -> JobError {
184    JobError::Infrastructure(format!("postgres job store failed: {error}"))
185}
186
187fn parse_status(status: &str) -> Result<JobStatus, JobError> {
188    match status {
189        "pending" => Ok(JobStatus::Pending),
190        "running" => Ok(JobStatus::Running),
191        "succeeded" => Ok(JobStatus::Succeeded),
192        "failed_permanently" => Ok(JobStatus::FailedPermanently),
193        "cancelled" => Ok(JobStatus::Cancelled),
194        other => Err(JobError::Infrastructure(format!(
195            "unknown job status {other}"
196        ))),
197    }
198}
199
200fn decode_job_row(row: &PgRow) -> Result<JobRecord, JobError> {
201    let envelope_json: serde_json::Value = row
202        .try_get("envelope")
203        .map_err(|_| JobError::Infrastructure("job envelope column was not valid JSON".into()))?;
204    let mut envelope: minco_plugin_jobs::JobEnvelope = serde_json::from_value(envelope_json)
205        .map_err(|error| {
206            JobError::Infrastructure(format!("job envelope decode failed: {error}"))
207        })?;
208    let status: String = row
209        .try_get("status")
210        .map_err(|_| JobError::Infrastructure("job status column unreadable".into()))?;
211    let attempt_count: i32 = row
212        .try_get("attempt_count")
213        .map_err(|_| JobError::Infrastructure("job attempt column unreadable".into()))?;
214    envelope.available_at = row
215        .try_get("available_at")
216        .map_err(|_| JobError::Infrastructure("job availability column unreadable".into()))?;
217    envelope.attempt = u32::try_from(attempt_count)
218        .unwrap_or(envelope.attempt)
219        .max(1);
220    let attempts_json: serde_json::Value = row
221        .try_get("attempts")
222        .map_err(|_| JobError::Infrastructure("job attempts column unreadable".into()))?;
223    let attempts: Vec<JobAttempt> = serde_json::from_value(attempts_json)
224        .map_err(|error| JobError::Infrastructure(format!("attempt decode failed: {error}")))?;
225    Ok(JobRecord {
226        envelope,
227        status: parse_status(&status)?,
228        revision: u64::try_from(
229            row.try_get::<i64, _>("revision")
230                .map_err(|_| JobError::Infrastructure("job revision unreadable".into()))?,
231        )
232        .unwrap_or(1),
233        lease_id: row
234            .try_get("lease_id")
235            .map_err(|_| JobError::Infrastructure("job lease identity unreadable".into()))?,
236        lease_expires_at: row
237            .try_get("lease_expires_at")
238            .map_err(|_| JobError::Infrastructure("job lease expiry unreadable".into()))?,
239        attempt_count: u32::try_from(attempt_count).unwrap_or(0),
240        attempts,
241        failure_code: row
242            .try_get("failure_code")
243            .map_err(|_| JobError::Infrastructure("job failure code unreadable".into()))?,
244        completed_at: row
245            .try_get("completed_at")
246            .map_err(|_| JobError::Infrastructure("job completion unreadable".into()))?,
247    })
248}
249
250fn attempt_entry(
251    attempt: u32,
252    worker_execution_id: &str,
253    now: DateTime<Utc>,
254    outcome: minco_plugin_jobs::JobAttemptOutcome,
255) -> Result<serde_json::Value, JobError> {
256    serde_json::to_value(JobAttempt {
257        attempt,
258        at: now,
259        worker_execution_id: worker_execution_id.to_owned(),
260        outcome,
261    })
262    .map_err(|error| JobError::Infrastructure(format!("attempt encode failed: {error}")))
263}
264
265fn decode_publication_row(row: &PgRow) -> Result<JobPublication, JobError> {
266    let status: String = row
267        .try_get("status")
268        .map_err(|_| JobError::Infrastructure("publication status unreadable".into()))?;
269    let status = match status.as_str() {
270        "pending" => PublicationStatus::Pending,
271        "claimed" => PublicationStatus::Claimed,
272        "published" => PublicationStatus::Published,
273        "failed" => PublicationStatus::Failed,
274        other => {
275            return Err(JobError::Infrastructure(format!(
276                "unknown publication status {other}"
277            )));
278        }
279    };
280    Ok(JobPublication {
281        publication_id: row
282            .try_get("publication_id")
283            .map_err(|_| JobError::Infrastructure("publication identity unreadable".into()))?,
284        job_id: row
285            .try_get("job_id")
286            .map_err(|_| JobError::Infrastructure("publication job unreadable".into()))?,
287        generation: u32::try_from(
288            row.try_get::<i32, _>("generation").map_err(|_| {
289                JobError::Infrastructure("publication generation unreadable".into())
290            })?,
291        )
292        .unwrap_or(1),
293        worker_profile: row
294            .try_get("worker_profile")
295            .map_err(|_| JobError::Infrastructure("publication profile unreadable".into()))?,
296        status,
297        attempt_count: u32::try_from(
298            row.try_get::<i32, _>("attempt_count")
299                .map_err(|_| JobError::Infrastructure("publication attempts unreadable".into()))?,
300        )
301        .unwrap_or(0),
302        available_at: row
303            .try_get("available_at")
304            .map_err(|_| JobError::Infrastructure("publication availability unreadable".into()))?,
305        claimed_by: row
306            .try_get("claimed_by")
307            .map_err(|_| JobError::Infrastructure("publication claimant unreadable".into()))?,
308        claim_expires_at: row
309            .try_get("claim_expires_at")
310            .map_err(|_| JobError::Infrastructure("publication claim expiry unreadable".into()))?,
311        lease_id: row
312            .try_get("lease_id")
313            .map_err(|_| JobError::Infrastructure("publication lease unreadable".into()))?,
314        last_error: row
315            .try_get("last_error")
316            .map_err(|_| JobError::Infrastructure("publication error unreadable".into()))?,
317    })
318}
319
320/// Verify a fenced job mutation affected exactly one row.
321async fn verify_job_update(
322    pool: &PgPool,
323    job_id: Uuid,
324    lease_id: Uuid,
325    result: &sqlx::postgres::PgQueryResult,
326) -> Result<(), JobError> {
327    if result.rows_affected() == 1 {
328        return Ok(());
329    }
330    let exists: bool =
331        sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM minco_jobs WHERE job_id = $1)")
332            .bind(job_id)
333            .fetch_one(pool)
334            .await
335            .map_err(|_| JobError::Infrastructure("job existence probe failed".into()))?;
336    if exists {
337        let _ = lease_id;
338        Err(JobError::LeaseFencedOut { job_id })
339    } else {
340        Err(JobError::MissingJob(job_id))
341    }
342}
343
344async fn verify_revision_guard(
345    pool: &PgPool,
346    job_id: Uuid,
347    expected_revision: u64,
348    result: &sqlx::postgres::PgQueryResult,
349) -> Result<(), JobError> {
350    if result.rows_affected() == 1 {
351        return Ok(());
352    }
353    let current: Option<i64> =
354        sqlx::query_scalar("SELECT revision FROM minco_jobs WHERE job_id = $1")
355            .bind(job_id)
356            .fetch_optional(pool)
357            .await
358            .map_err(|error| infrastructure(&error))?;
359    match current {
360        Some(revision) if u64::try_from(revision).unwrap_or(0) != expected_revision => {
361            Err(JobError::RevisionConflict {
362                job_id,
363                expected_revision,
364            })
365        }
366        Some(_) => Err(JobError::InvalidTransition { job_id }),
367        None => Err(JobError::MissingJob(job_id)),
368    }
369}
370
371async fn verify_publication_update(
372    pool: &PgPool,
373    publication_id: Uuid,
374    lease_id: Uuid,
375    result: &sqlx::postgres::PgQueryResult,
376) -> Result<(), JobError> {
377    if result.rows_affected() == 1 {
378        return Ok(());
379    }
380    let exists: bool = sqlx::query_scalar(
381        "SELECT EXISTS(SELECT 1 FROM minco_job_publications WHERE publication_id = $1)",
382    )
383    .bind(publication_id)
384    .fetch_one(pool)
385    .await
386    .map_err(|_| JobError::Infrastructure("publication probe failed".into()))?;
387    if exists {
388        let _ = lease_id;
389        Err(JobError::PublicationFencedOut { publication_id })
390    } else {
391        Err(JobError::MissingJob(publication_id))
392    }
393}
394
395#[async_trait::async_trait]
396impl minco_plugin_jobs::JobStore for PostgresJobStore {
397    async fn enqueue_with_intent(&self, record: JobRecord) -> Result<EnqueueOutcome, JobError> {
398        let mut transaction = self
399            .pool
400            .begin()
401            .await
402            .map_err(|error| infrastructure(&error))?;
403        let outcome = self.enqueue_in(&mut transaction, record).await?;
404        transaction
405            .commit()
406            .await
407            .map_err(|error| infrastructure(&error))?;
408        Ok(outcome)
409    }
410
411    async fn ingest_existing_delivery(&self, record: JobRecord) -> Result<IngestOutcome, JobError> {
412        let mut transaction = self
413            .pool
414            .begin()
415            .await
416            .map_err(|error| infrastructure(&error))?;
417        let outcome = self.ingest_in(&mut transaction, record).await?;
418        transaction
419            .commit()
420            .await
421            .map_err(|error| infrastructure(&error))?;
422        Ok(outcome)
423    }
424
425    async fn claim_execution(
426        &self,
427        job_id: Uuid,
428        worker_execution_id: &str,
429        lease_expires_at: DateTime<Utc>,
430        now: DateTime<Utc>,
431    ) -> Result<Option<JobClaim>, JobError> {
432        validate_worker_claim(worker_execution_id, 1, lease_expires_at, now)?;
433        let row = sqlx::query(
434            "UPDATE minco_jobs SET status = 'running', lease_id = gen_random_uuid(), \
435             lease_expires_at = $3, attempt_count = attempt_count + 1, revision = revision + 1 \
436             WHERE job_id = $1 AND ((status = 'pending' AND available_at <= $4) \
437             OR (status = 'running' AND lease_expires_at IS NOT NULL AND lease_expires_at <= $4)) \
438             RETURNING job_id, worker_profile, envelope, status, revision, available_at, \
439             attempt_count, lease_id, lease_expires_at, attempts, dedupe_key, failure_code, \
440             completed_at",
441        )
442        .bind(job_id)
443        .bind(worker_execution_id)
444        .bind(lease_expires_at)
445        .bind(now)
446        .fetch_optional(&self.pool)
447        .await
448        .map_err(|error| infrastructure(&error))?;
449        let Some(row) = row else {
450            return Ok(None);
451        };
452        let record = decode_job_row(&row)?;
453        let lease_id = record.lease_id.expect("claim mints a lease identity");
454        Ok(Some(JobClaim {
455            fence: record.revision,
456            record,
457            lease_id,
458        }))
459    }
460
461    async fn complete(&self, claim: &JobClaim, now: DateTime<Utc>) -> Result<(), JobError> {
462        let job_id = claim.record.envelope.job_id;
463        let entry = attempt_entry(
464            claim.record.attempt_count,
465            &format!("lease-{}", claim.lease_id),
466            now,
467            minco_plugin_jobs::JobAttemptOutcome::Succeeded,
468        )?;
469        let result = sqlx::query(
470            "UPDATE minco_jobs SET status = 'succeeded', lease_id = NULL, lease_expires_at = \
471             NULL, failure_code = NULL, completed_at = $3, revision = revision + 1, \
472             attempts = (CASE WHEN jsonb_array_length(attempts) >= 25 \
473             THEN attempts - 0 ELSE attempts END) || $4::jsonb \
474             WHERE job_id = $1 AND status = 'running' AND lease_id = $2",
475        )
476        .bind(job_id)
477        .bind(claim.lease_id)
478        .bind(now)
479        .bind(&entry)
480        .execute(&self.pool)
481        .await
482        .map_err(|error| infrastructure(&error))?;
483        verify_job_update(&self.pool, job_id, claim.lease_id, &result).await
484    }
485
486    async fn schedule_retry_and_publish(
487        &self,
488        claim: &JobClaim,
489        failure_code: &str,
490        next_available_at: DateTime<Utc>,
491        now: DateTime<Utc>,
492    ) -> Result<Uuid, JobError> {
493        let job_id = claim.record.envelope.job_id;
494        let entry = attempt_entry(
495            claim.record.attempt_count,
496            &format!("lease-{}", claim.lease_id),
497            now,
498            minco_plugin_jobs::JobAttemptOutcome::Retried {
499                code: failure_code.to_owned(),
500            },
501        )?;
502        let mut transaction = self
503            .pool
504            .begin()
505            .await
506            .map_err(|error| infrastructure(&error))?;
507        let result = sqlx::query(
508            "UPDATE minco_jobs SET status = 'pending', lease_id = NULL, lease_expires_at = \
509             NULL, available_at = $4, failure_code = $3, revision = revision + 1, \
510             attempts = (CASE WHEN jsonb_array_length(attempts) >= 25 \
511             THEN attempts - 0 ELSE attempts END) || $5::jsonb \
512             WHERE job_id = $1 AND status = 'running' AND lease_id = $2",
513        )
514        .bind(job_id)
515        .bind(claim.lease_id)
516        .bind(failure_code)
517        .bind(next_available_at)
518        .bind(&entry)
519        .execute(&mut *transaction)
520        .await
521        .map_err(|error| infrastructure(&error))?;
522        if result.rows_affected() != 1 {
523            transaction
524                .rollback()
525                .await
526                .map_err(|error| infrastructure(&error))?;
527            verify_job_update(&self.pool, job_id, claim.lease_id, &result).await?;
528            return Err(JobError::LeaseFencedOut { job_id });
529        }
530        let publication_id = Uuid::now_v7();
531        sqlx::query(
532            "INSERT INTO minco_job_publications (publication_id, job_id, generation, \
533             worker_profile, status, attempt_count, available_at, claimed_by, \
534             claim_expires_at, lease_id, last_error) \
535             SELECT $1, $2, COALESCE(MAX(generation), 0) + 1, $3, 'pending', 0, $4, NULL, \
536             NULL, NULL, NULL FROM minco_job_publications WHERE job_id = $2",
537        )
538        .bind(publication_id)
539        .bind(job_id)
540        .bind(&claim.record.envelope.worker_profile)
541        .bind(next_available_at)
542        .execute(&mut *transaction)
543        .await
544        .map_err(|error| infrastructure(&error))?;
545        transaction
546            .commit()
547            .await
548            .map_err(|error| infrastructure(&error))?;
549        Ok(publication_id)
550    }
551
552    async fn fail_permanently(
553        &self,
554        claim: &JobClaim,
555        failure_code: &str,
556        now: DateTime<Utc>,
557    ) -> Result<(), JobError> {
558        let job_id = claim.record.envelope.job_id;
559        let entry = attempt_entry(
560            claim.record.attempt_count,
561            &format!("lease-{}", claim.lease_id),
562            now,
563            minco_plugin_jobs::JobAttemptOutcome::FailedPermanently {
564                code: failure_code.to_owned(),
565            },
566        )?;
567        let result = sqlx::query(
568            "UPDATE minco_jobs SET status = 'failed_permanently', lease_id = NULL, \
569             lease_expires_at = NULL, failure_code = $3, completed_at = $4, revision = revision + 1, \
570             attempts = (CASE WHEN jsonb_array_length(attempts) >= 25 \
571             THEN attempts - 0 ELSE attempts END) || $5::jsonb \
572             WHERE job_id = $1 AND status = 'running' AND lease_id = $2",
573        )
574        .bind(job_id)
575        .bind(claim.lease_id)
576        .bind(failure_code)
577        .bind(now)
578        .bind(&entry)
579        .execute(&self.pool)
580        .await
581        .map_err(|error| infrastructure(&error))?;
582        verify_job_update(&self.pool, job_id, claim.lease_id, &result).await
583    }
584
585    async fn cancel(
586        &self,
587        job_id: Uuid,
588        expected_revision: u64,
589        now: DateTime<Utc>,
590    ) -> Result<(), JobError> {
591        let result = sqlx::query(
592            "UPDATE minco_jobs SET status = 'cancelled', completed_at = $3, revision = revision + 1 \
593             WHERE job_id = $1 AND revision = $2 AND status = 'pending'",
594        )
595        .bind(job_id)
596        .bind(i64::try_from(expected_revision).unwrap_or(-1))
597        .bind(now)
598        .execute(&self.pool)
599        .await
600        .map_err(|error| infrastructure(&error))?;
601        verify_revision_guard(&self.pool, job_id, expected_revision, &result).await
602    }
603
604    async fn retry_failed(
605        &self,
606        job_id: Uuid,
607        expected_revision: u64,
608        now: DateTime<Utc>,
609    ) -> Result<DateTime<Utc>, JobError> {
610        let mut transaction = self
611            .pool
612            .begin()
613            .await
614            .map_err(|error| infrastructure(&error))?;
615        let result = sqlx::query(
616            "UPDATE minco_jobs SET status = 'pending', failure_code = NULL, completed_at = NULL, \
617             available_at = $3, attempt_count = 0, lease_id = NULL, lease_expires_at = NULL, \
618             revision = revision + 1 WHERE job_id = $1 AND revision = $2 AND status = \
619             'failed_permanently'",
620        )
621        .bind(job_id)
622        .bind(i64::try_from(expected_revision).unwrap_or(-1))
623        .bind(now)
624        .execute(&mut *transaction)
625        .await
626        .map_err(|error| infrastructure(&error))?;
627        if result.rows_affected() != 1 {
628            transaction
629                .rollback()
630                .await
631                .map_err(|error| infrastructure(&error))?;
632            verify_revision_guard(&self.pool, job_id, expected_revision, &result).await?;
633            return Err(JobError::RevisionConflict {
634                job_id,
635                expected_revision,
636            });
637        }
638        let profile: String =
639            sqlx::query_scalar("SELECT worker_profile FROM minco_jobs WHERE job_id = $1")
640                .bind(job_id)
641                .fetch_one(&mut *transaction)
642                .await
643                .map_err(|error| infrastructure(&error))?;
644        let publication_id = Uuid::now_v7();
645        sqlx::query(
646            "INSERT INTO minco_job_publications (publication_id, job_id, generation, \
647             worker_profile, status, attempt_count, available_at, claimed_by, \
648             claim_expires_at, lease_id, last_error) \
649             SELECT $1, $2, COALESCE(MAX(generation), 0) + 1, $3, 'pending', 0, $4, NULL, \
650             NULL, NULL, NULL FROM minco_job_publications WHERE job_id = $2",
651        )
652        .bind(publication_id)
653        .bind(job_id)
654        .bind(&profile)
655        .bind(now)
656        .execute(&mut *transaction)
657        .await
658        .map_err(|error| infrastructure(&error))?;
659        transaction
660            .commit()
661            .await
662            .map_err(|error| infrastructure(&error))?;
663        Ok(now)
664    }
665
666    async fn recover_expired_leases(&self, now: DateTime<Utc>) -> Result<usize, JobError> {
667        let mut transaction = self
668            .pool
669            .begin()
670            .await
671            .map_err(|error| infrastructure(&error))?;
672        let recovered: Vec<(Uuid, String)> = sqlx::query_as(
673            "UPDATE minco_jobs SET status = 'pending', lease_id = NULL, lease_expires_at = \
674             NULL, available_at = $1, revision = revision + 1 \
675             WHERE status = 'running' AND lease_expires_at IS NOT NULL AND lease_expires_at <= $1 \
676             RETURNING job_id, worker_profile",
677        )
678        .bind(now)
679        .fetch_all(&mut *transaction)
680        .await
681        .map_err(|error| infrastructure(&error))?;
682        for (job_id, profile) in &recovered {
683            sqlx::query(
684                "INSERT INTO minco_job_publications (publication_id, job_id, generation, \
685                 worker_profile, status, attempt_count, available_at, claimed_by, \
686                 claim_expires_at, lease_id, last_error) \
687                 SELECT $1, $2, COALESCE(MAX(generation), 0) + 1, $3, 'pending', 0, $4, NULL, \
688                 NULL, NULL, NULL FROM minco_job_publications WHERE job_id = $2",
689            )
690            .bind(Uuid::now_v7())
691            .bind(job_id)
692            .bind(profile)
693            .bind(now)
694            .execute(&mut *transaction)
695            .await
696            .map_err(|error| infrastructure(&error))?;
697        }
698        transaction
699            .commit()
700            .await
701            .map_err(|error| infrastructure(&error))?;
702        Ok(recovered.len())
703    }
704
705    async fn get(&self, job_id: Uuid) -> Result<Option<JobRecord>, JobError> {
706        let row = sqlx::query(
707            "SELECT job_id, worker_profile, envelope, status, revision, available_at, \
708             attempt_count, lease_id, lease_expires_at, attempts, dedupe_key, failure_code, \
709             completed_at FROM minco_jobs WHERE job_id = $1",
710        )
711        .bind(job_id)
712        .fetch_optional(&self.pool)
713        .await
714        .map_err(|error| infrastructure(&error))?;
715        row.map(|row| decode_job_row(&row)).transpose()
716    }
717
718    async fn list_failed(&self, limit: usize) -> Result<Vec<JobRecord>, JobError> {
719        let rows = sqlx::query(
720            "SELECT job_id, worker_profile, envelope, status, revision, available_at, \
721             attempt_count, lease_id, lease_expires_at, attempts, dedupe_key, failure_code, \
722             completed_at FROM minco_jobs WHERE status = 'failed_permanently' \
723             ORDER BY completed_at NULLS LAST, job_id LIMIT $1",
724        )
725        .bind(i64::try_from(limit.min(100)).unwrap_or(100))
726        .fetch_all(&self.pool)
727        .await
728        .map_err(|error| infrastructure(&error))?;
729        rows.iter().map(decode_job_row).collect()
730    }
731}
732
733#[async_trait::async_trait]
734impl minco_plugin_jobs::JobPublicationStore for PostgresJobStore {
735    async fn claim_due(
736        &self,
737        worker_execution_id: &str,
738        limit: usize,
739        claim_expires_at: DateTime<Utc>,
740        now: DateTime<Utc>,
741    ) -> Result<Vec<JobPublication>, JobError> {
742        validate_worker_claim(worker_execution_id, limit, claim_expires_at, now)?;
743        let rows = sqlx::query(
744            "WITH candidates AS (SELECT publication_id FROM minco_job_publications \
745             WHERE (status IN ('pending', 'failed') AND available_at <= $1) \
746             OR (status = 'claimed' AND claim_expires_at IS NOT NULL AND claim_expires_at <= $1) \
747             ORDER BY available_at, publication_id FOR UPDATE SKIP LOCKED LIMIT $2) \
748             UPDATE minco_job_publications AS publication \
749             SET status = 'claimed', claimed_by = $3, claim_expires_at = $4, \
750             lease_id = gen_random_uuid(), attempt_count = publication.attempt_count + 1 \
751             FROM candidates WHERE publication.publication_id = candidates.publication_id \
752             RETURNING publication.publication_id, publication.job_id, publication.generation, \
753             publication.worker_profile, publication.status, publication.attempt_count, \
754             publication.available_at, publication.claimed_by, publication.claim_expires_at, \
755             publication.lease_id, publication.last_error",
756        )
757        .bind(now)
758        .bind(i64::try_from(limit.min(100)).unwrap_or(100))
759        .bind(worker_execution_id)
760        .bind(claim_expires_at)
761        .fetch_all(&self.pool)
762        .await
763        .map_err(|error| infrastructure(&error))?;
764        rows.iter().map(decode_publication_row).collect()
765    }
766
767    async fn mark_published(&self, publication_id: Uuid, lease_id: Uuid) -> Result<(), JobError> {
768        let result = sqlx::query(
769            "UPDATE minco_job_publications SET status = 'published', claimed_by = NULL, \
770             claim_expires_at = NULL, lease_id = NULL, last_error = NULL \
771             WHERE publication_id = $1 AND status = 'claimed' AND lease_id = $2",
772        )
773        .bind(publication_id)
774        .bind(lease_id)
775        .execute(&self.pool)
776        .await
777        .map_err(|error| infrastructure(&error))?;
778        verify_publication_update(&self.pool, publication_id, lease_id, &result).await
779    }
780
781    async fn mark_failed(
782        &self,
783        publication_id: Uuid,
784        lease_id: Uuid,
785        error: &str,
786        retry_at: DateTime<Utc>,
787    ) -> Result<(), JobError> {
788        let result = sqlx::query(
789            "UPDATE minco_job_publications SET status = 'failed', claimed_by = NULL, \
790             claim_expires_at = NULL, lease_id = NULL, available_at = $3, last_error = $4 \
791             WHERE publication_id = $1 AND status = 'claimed' AND lease_id = $2",
792        )
793        .bind(publication_id)
794        .bind(lease_id)
795        .bind(retry_at)
796        .bind(error)
797        .execute(&self.pool)
798        .await
799        .map_err(|error| infrastructure(&error))?;
800        verify_publication_update(&self.pool, publication_id, lease_id, &result).await
801    }
802
803    async fn recover_expired_claims(&self, now: DateTime<Utc>) -> Result<usize, JobError> {
804        let result = sqlx::query(
805            "UPDATE minco_job_publications SET status = 'pending', claimed_by = NULL, \
806             claim_expires_at = NULL, lease_id = NULL \
807             WHERE status = 'claimed' AND claim_expires_at IS NOT NULL AND claim_expires_at <= $1",
808        )
809        .bind(now)
810        .execute(&self.pool)
811        .await
812        .map_err(|error| infrastructure(&error))?;
813        Ok(usize::try_from(result.rows_affected()).unwrap_or(usize::MAX))
814    }
815}
816
817#[async_trait::async_trait]
818impl minco_plugin_jobs::OverlapLockStore for PostgresJobStore {
819    async fn acquire(
820        &self,
821        overlap_key: &str,
822        lease_id: Uuid,
823        expires_at: DateTime<Utc>,
824        now: DateTime<Utc>,
825    ) -> Result<bool, JobError> {
826        let inserted = sqlx::query(
827            "INSERT INTO minco_job_locks (overlap_key, owner, expires_at) VALUES ($1, $2, $3) \
828             ON CONFLICT (overlap_key) DO UPDATE SET owner = $2, expires_at = $3 \
829             WHERE minco_job_locks.expires_at <= $4",
830        )
831        .bind(overlap_key)
832        .bind(lease_id.to_string())
833        .bind(expires_at)
834        .bind(now)
835        .execute(&self.pool)
836        .await
837        .map_err(|error| infrastructure(&error))?;
838        Ok(inserted.rows_affected() == 1)
839    }
840
841    async fn refresh(
842        &self,
843        overlap_key: &str,
844        lease_id: Uuid,
845        expires_at: DateTime<Utc>,
846        now: DateTime<Utc>,
847    ) -> Result<bool, JobError> {
848        let result = sqlx::query(
849            "UPDATE minco_job_locks SET expires_at = $3 \
850             WHERE overlap_key = $1 AND owner = $2 AND expires_at > $4",
851        )
852        .bind(overlap_key)
853        .bind(lease_id.to_string())
854        .bind(expires_at)
855        .bind(now)
856        .execute(&self.pool)
857        .await
858        .map_err(|error| infrastructure(&error))?;
859        Ok(result.rows_affected() == 1)
860    }
861
862    async fn release(&self, overlap_key: &str, lease_id: Uuid) -> Result<(), JobError> {
863        sqlx::query("DELETE FROM minco_job_locks WHERE overlap_key = $1 AND owner = $2")
864            .bind(overlap_key)
865            .bind(lease_id.to_string())
866            .execute(&self.pool)
867            .await
868            .map_err(|error| infrastructure(&error))?;
869        Ok(())
870    }
871
872    async fn recover_expired(&self, now: DateTime<Utc>) -> Result<usize, JobError> {
873        let result = sqlx::query("DELETE FROM minco_job_locks WHERE expires_at <= $1")
874            .bind(now)
875            .execute(&self.pool)
876            .await
877            .map_err(|error| infrastructure(&error))?;
878        Ok(usize::try_from(result.rows_affected()).unwrap_or(usize::MAX))
879    }
880}
881
882#[cfg(test)]
883mod tests {
884    use super::*;
885    use minco_plugin_jobs::{
886        EnqueueOutcome, JobEnvelope, JobOptions, JobPublicationStore as _, JobStore as _,
887        OverlapLockStore as _, RetryPolicy,
888    };
889    use std::sync::Arc;
890
891    fn test_lock() -> &'static tokio::sync::Mutex<()> {
892        static LOCK: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
893        LOCK.get_or_init(tokio::sync::Mutex::default)
894    }
895
896    async fn pool() -> Option<PgPool> {
897        let Ok(url) = std::env::var("MINCO_TEST_POSTGRES_URL") else {
898            eprintln!("MINCO_TEST_POSTGRES_URL not set; PostgreSQL jobs proof skipped");
899            return None;
900        };
901        let pool = PgPool::connect(&url).await.ok()?;
902        crate::plugin_adapters::migrate_plugin_storage(&pool)
903            .await
904            .ok()?;
905        sqlx::raw_sql("TRUNCATE minco_job_publications, minco_jobs, minco_job_locks")
906            .execute(&pool)
907            .await
908            .ok()?;
909        Some(pool)
910    }
911
912    fn record(dedupe_key: Option<&str>) -> JobRecord {
913        let mut options = JobOptions::default().with_retry(RetryPolicy::fixed(5, 1));
914        if let Some(key) = dedupe_key {
915            options = options.with_dedupe_key(key);
916        }
917        let envelope = JobEnvelope::for_parts(
918            "orders.send-confirmation",
919            1,
920            serde_json::json!({ "order_id": "o-1" }),
921            "orders-notifications",
922            Uuid::now_v7(),
923        )
924        .expect("valid envelope")
925        .with(options);
926        minco_plugin_jobs::pending_record(envelope)
927    }
928
929    #[tokio::test]
930    async fn enqueue_in_rolls_back_with_the_callers_transaction() {
931        let Some(pool) = pool().await else { return };
932        let _guard = test_lock().lock().await;
933        let store = PostgresJobStore::new(pool.clone());
934        let mut transaction = pool.begin().await.unwrap();
935        let record = record(None);
936        store
937            .enqueue_in(&mut transaction, record.clone())
938            .await
939            .unwrap();
940        transaction.rollback().await.unwrap();
941        assert!(
942            store.get(record.envelope.job_id).await.unwrap().is_none(),
943            "rollback must leave no durable job"
944        );
945        let publications = store
946            .claim_due(
947                "probe",
948                10,
949                Utc::now() + chrono::TimeDelta::minutes(1),
950                Utc::now(),
951            )
952            .await
953            .unwrap();
954        assert!(publications.is_empty(), "rollback leaves no intent");
955    }
956
957    #[tokio::test]
958    async fn enqueue_in_commits_exactly_one_recoverable_generation() {
959        let Some(pool) = pool().await else { return };
960        let _guard = test_lock().lock().await;
961        let store = PostgresJobStore::new(pool.clone());
962        let mut transaction = pool.begin().await.unwrap();
963        let record = record(None);
964        match store
965            .enqueue_in(&mut transaction, record.clone())
966            .await
967            .unwrap()
968        {
969            EnqueueOutcome::Inserted(job_id) => assert_eq!(job_id, record.envelope.job_id),
970            EnqueueOutcome::Duplicate(existing) => {
971                panic!("expected insertion, got duplicate {existing}")
972            }
973        }
974        transaction.commit().await.unwrap();
975        let publications = store
976            .claim_due(
977                "probe",
978                10,
979                Utc::now() + chrono::TimeDelta::minutes(1),
980                Utc::now(),
981            )
982            .await
983            .unwrap();
984        assert_eq!(publications.len(), 1);
985        assert_eq!(publications[0].job_id, record.envelope.job_id);
986        assert_eq!(publications[0].generation, 1);
987        assert!(
988            publications[0].lease_id.is_some(),
989            "claims mint lease identities"
990        );
991    }
992
993    #[tokio::test]
994    async fn stale_claims_cannot_mutate_newer_claims_even_with_one_worker_name() {
995        let Some(pool) = pool().await else { return };
996        let _guard = test_lock().lock().await;
997        let store = PostgresJobStore::new(pool);
998        let record = record(None);
999        store.enqueue_with_intent(record.clone()).await.unwrap();
1000        let start = Utc::now();
1001        let stale = store
1002            .claim_execution(
1003                record.envelope.job_id,
1004                "same-worker-name",
1005                start + chrono::TimeDelta::minutes(1),
1006                start,
1007            )
1008            .await
1009            .unwrap()
1010            .expect("first claim");
1011        let newer = store
1012            .claim_execution(
1013                record.envelope.job_id,
1014                "same-worker-name",
1015                start + chrono::TimeDelta::minutes(30),
1016                start + chrono::TimeDelta::minutes(2),
1017            )
1018            .await
1019            .unwrap()
1020            .expect("reclaim after expiry");
1021        assert_ne!(stale.lease_id, newer.lease_id);
1022        let error = store.complete(&stale, start).await.unwrap_err();
1023        assert!(matches!(error, JobError::LeaseFencedOut { .. }));
1024        let error = store
1025            .schedule_retry_and_publish(
1026                &stale,
1027                "stale",
1028                start + chrono::TimeDelta::minutes(3),
1029                start,
1030            )
1031            .await
1032            .unwrap_err();
1033        assert!(matches!(error, JobError::LeaseFencedOut { .. }));
1034        let error = store
1035            .fail_permanently(&stale, "stale", start)
1036            .await
1037            .unwrap_err();
1038        assert!(matches!(error, JobError::LeaseFencedOut { .. }));
1039        store.complete(&newer, start).await.unwrap();
1040    }
1041
1042    #[tokio::test]
1043    async fn retry_state_and_next_generation_commit_together() {
1044        let Some(pool) = pool().await else { return };
1045        let _guard = test_lock().lock().await;
1046        let store = PostgresJobStore::new(pool);
1047        let record = record(None);
1048        store.enqueue_with_intent(record.clone()).await.unwrap();
1049        let now = Utc::now();
1050        let delivered = store
1051            .claim_due("dispatcher-1", 10, now + chrono::TimeDelta::minutes(1), now)
1052            .await
1053            .unwrap();
1054        assert_eq!(delivered.len(), 1, "generation 1 is delivered first");
1055        store
1056            .mark_published(
1057                delivered[0].publication_id,
1058                delivered[0].lease_id.expect("lease"),
1059            )
1060            .await
1061            .unwrap();
1062        let claim = store
1063            .claim_execution(
1064                record.envelope.job_id,
1065                "worker-exec-1",
1066                now + chrono::TimeDelta::minutes(5),
1067                now,
1068            )
1069            .await
1070            .unwrap()
1071            .expect("claim");
1072        let retry_at = now + chrono::TimeDelta::seconds(60);
1073        let publication_id = store
1074            .schedule_retry_and_publish(&claim, "notification-unavailable", retry_at, now)
1075            .await
1076            .unwrap();
1077        assert_ne!(publication_id, Uuid::nil());
1078        let generations: Vec<(i32, String)> = sqlx::query_as(
1079            "SELECT generation, status FROM minco_job_publications WHERE job_id = $1 ORDER BY \
1080             generation",
1081        )
1082        .bind(record.envelope.job_id)
1083        .fetch_all(&store.pool)
1084        .await
1085        .unwrap();
1086        assert_eq!(
1087            generations.len(),
1088            2,
1089            "generation 2 committed with the retry"
1090        );
1091        assert_eq!(generations[1].1, "pending");
1092        let error = store
1093            .schedule_retry_and_publish(&claim, "double", retry_at, now)
1094            .await
1095            .unwrap_err();
1096        assert!(matches!(error, JobError::LeaseFencedOut { .. }));
1097        let early = store
1098            .claim_due("d", 10, now + chrono::TimeDelta::minutes(1), now)
1099            .await
1100            .unwrap();
1101        assert!(early.is_empty(), "generation 2 is not due yet");
1102        let due = store
1103            .claim_due("d", 10, retry_at + chrono::TimeDelta::minutes(1), retry_at)
1104            .await
1105            .unwrap();
1106        assert_eq!(due.len(), 1);
1107        assert_eq!(due[0].generation, 2, "only the new generation is due");
1108    }
1109
1110    #[tokio::test]
1111    async fn concurrent_execution_claims_admit_exactly_one_owner() {
1112        let Some(pool) = pool().await else { return };
1113        let _guard = test_lock().lock().await;
1114        let store = Arc::new(PostgresJobStore::new(pool));
1115        let record = record(None);
1116        store.enqueue_with_intent(record.clone()).await.unwrap();
1117        let store_a = store.clone();
1118        let store_b = store.clone();
1119        let now = Utc::now();
1120        let lease = now + chrono::TimeDelta::minutes(10);
1121        let job_id = record.envelope.job_id;
1122        let a = tokio::spawn(async move {
1123            store_a
1124                .claim_execution(job_id, "worker-a", lease, now)
1125                .await
1126        });
1127        let b = tokio::spawn(async move {
1128            store_b
1129                .claim_execution(job_id, "worker-b", lease, now)
1130                .await
1131        });
1132        let owners: usize = [a.await.unwrap().unwrap(), b.await.unwrap().unwrap()]
1133            .into_iter()
1134            .flatten()
1135            .count();
1136        assert_eq!(owners, 1, "only one live owner may exist");
1137    }
1138
1139    #[tokio::test]
1140    async fn duplicate_dedupe_uses_the_semantic_fingerprint() {
1141        let Some(pool) = pool().await else { return };
1142        let _guard = test_lock().lock().await;
1143        let store = PostgresJobStore::new(pool);
1144        store
1145            .enqueue_with_intent(record(Some("orders.confirm:o-1")))
1146            .await
1147            .unwrap();
1148        match store
1149            .enqueue_with_intent(record(Some("orders.confirm:o-1")))
1150            .await
1151            .unwrap()
1152        {
1153            EnqueueOutcome::Duplicate(_) => {}
1154            EnqueueOutcome::Inserted(inserted) => {
1155                panic!("identical resubmission must be idempotent, got {inserted}")
1156            }
1157        }
1158        let conflicting = record(None);
1159        let mut envelope = conflicting.envelope.clone();
1160        envelope.payload = serde_json::json!({ "order_id": "o-2" });
1161        envelope.dedupe_key = Some("orders.confirm:o-1".into());
1162        let mut conflict_record = minco_plugin_jobs::pending_record(envelope);
1163        conflict_record.envelope.available_at = conflicting.envelope.available_at;
1164        let error = store
1165            .enqueue_with_intent(conflict_record)
1166            .await
1167            .unwrap_err();
1168        assert!(matches!(
1169            error,
1170            JobError::DuplicateSubmissionConflict { .. }
1171        ));
1172    }
1173
1174    #[tokio::test]
1175    async fn ingestion_creates_no_pending_publication() {
1176        let Some(pool) = pool().await else { return };
1177        let _guard = test_lock().lock().await;
1178        let store = PostgresJobStore::new(pool);
1179        let mut occurrence = record(None);
1180        occurrence.envelope.dedupe_key = Some("orders-nightly:2026-08-22T13:00:00Z".into());
1181        match store
1182            .ingest_existing_delivery(occurrence.clone())
1183            .await
1184            .unwrap()
1185        {
1186            minco_plugin_jobs::IngestOutcome::Ingested(job_id) => {
1187                assert_eq!(job_id, occurrence.envelope.job_id);
1188            }
1189            minco_plugin_jobs::IngestOutcome::Duplicate(existing) => {
1190                panic!("first occurrence ingests, got duplicate {existing}")
1191            }
1192        }
1193        let statuses: Vec<(String,)> =
1194            sqlx::query_as("SELECT status FROM minco_job_publications WHERE job_id = $1")
1195                .bind(occurrence.envelope.job_id)
1196                .fetch_all(&store.pool)
1197                .await
1198                .unwrap();
1199        assert_eq!(statuses.len(), 1);
1200        assert_eq!(statuses[0].0, "published", "no pending generation appears");
1201        match store.ingest_existing_delivery(occurrence).await.unwrap() {
1202            minco_plugin_jobs::IngestOutcome::Duplicate(_) => {}
1203            minco_plugin_jobs::IngestOutcome::Ingested(job_id) => {
1204                panic!("re-ingestion is idempotent, got {job_id}")
1205            }
1206        }
1207    }
1208
1209    #[tokio::test]
1210    async fn stale_overlap_owner_cannot_release_a_newer_lock() {
1211        let Some(pool) = pool().await else { return };
1212        let _guard = test_lock().lock().await;
1213        let store = PostgresJobStore::new(pool);
1214        let now = Utc::now();
1215        let stale_lease = Uuid::now_v7();
1216        assert!(
1217            store
1218                .acquire(
1219                    "orders.confirm:o-1",
1220                    stale_lease,
1221                    now + chrono::TimeDelta::minutes(1),
1222                    now
1223                )
1224                .await
1225                .unwrap()
1226        );
1227        let newer_lease = Uuid::now_v7();
1228        assert!(
1229            store
1230                .acquire(
1231                    "orders.confirm:o-1",
1232                    newer_lease,
1233                    now + chrono::TimeDelta::minutes(30),
1234                    now + chrono::TimeDelta::minutes(2)
1235                )
1236                .await
1237                .unwrap(),
1238            "the expired lock is reclaimable"
1239        );
1240        store
1241            .release("orders.confirm:o-1", stale_lease)
1242            .await
1243            .unwrap();
1244        let held: Option<(String,)> =
1245            sqlx::query_as("SELECT owner FROM minco_job_locks WHERE overlap_key = $1")
1246                .bind("orders.confirm:o-1")
1247                .fetch_optional(&store.pool)
1248                .await
1249                .unwrap();
1250        assert_eq!(
1251            held.map(|(owner,)| owner),
1252            Some(newer_lease.to_string()),
1253            "the stale owner cannot release the newer lock"
1254        );
1255    }
1256
1257    #[tokio::test]
1258    async fn stale_publication_claimant_cannot_mark_delivery() {
1259        let Some(pool) = pool().await else { return };
1260        let _guard = test_lock().lock().await;
1261        let store = PostgresJobStore::new(pool);
1262        store.enqueue_with_intent(record(None)).await.unwrap();
1263        let now = Utc::now();
1264        let stale = store
1265            .claim_due("dispatcher-a", 10, now + chrono::TimeDelta::minutes(1), now)
1266            .await
1267            .unwrap();
1268        assert_eq!(stale.len(), 1);
1269        let stale_lease = stale[0].lease_id.expect("claim lease");
1270        let newer = store
1271            .claim_due(
1272                "dispatcher-b",
1273                10,
1274                now + chrono::TimeDelta::minutes(30),
1275                now + chrono::TimeDelta::minutes(2),
1276            )
1277            .await
1278            .unwrap();
1279        assert_eq!(newer.len(), 1);
1280        let error = store
1281            .mark_published(stale[0].publication_id, stale_lease)
1282            .await
1283            .unwrap_err();
1284        assert!(matches!(error, JobError::PublicationFencedOut { .. }));
1285        store
1286            .mark_published(
1287                newer[0].publication_id,
1288                newer[0].lease_id.expect("newer lease"),
1289            )
1290            .await
1291            .unwrap();
1292    }
1293
1294    #[tokio::test]
1295    async fn concurrent_publication_claims_are_disjoint() {
1296        let Some(pool) = pool().await else { return };
1297        let _guard = test_lock().lock().await;
1298        let store = Arc::new(PostgresJobStore::new(pool));
1299        for _ in 0..4 {
1300            store.enqueue_with_intent(record(None)).await.unwrap();
1301        }
1302        let now = Utc::now();
1303        let a = {
1304            let store = store.clone();
1305            tokio::spawn(async move {
1306                store
1307                    .claim_due("dispatcher-a", 10, now + chrono::TimeDelta::minutes(1), now)
1308                    .await
1309            })
1310        };
1311        let b = {
1312            let store = store.clone();
1313            tokio::spawn(async move {
1314                store
1315                    .claim_due("dispatcher-b", 10, now + chrono::TimeDelta::minutes(1), now)
1316                    .await
1317            })
1318        };
1319        let claimed_a = a.await.unwrap().unwrap();
1320        let claimed_b = b.await.unwrap().unwrap();
1321        assert_eq!(claimed_a.len() + claimed_b.len(), 4);
1322        let overlap = claimed_a
1323            .iter()
1324            .filter(|publication| {
1325                claimed_b
1326                    .iter()
1327                    .any(|other| other.publication_id == publication.publication_id)
1328            })
1329            .count();
1330        assert_eq!(overlap, 0, "claims must be disjoint");
1331    }
1332
1333    #[tokio::test]
1334    async fn operator_transitions_are_revision_guarded() {
1335        let Some(pool) = pool().await else { return };
1336        let _guard = test_lock().lock().await;
1337        let store = PostgresJobStore::new(pool);
1338        let record = record(None);
1339        store.enqueue_with_intent(record.clone()).await.unwrap();
1340        let job_id = record.envelope.job_id;
1341        let current = store.get(job_id).await.unwrap().unwrap();
1342        let error = store
1343            .cancel(job_id, current.revision + 1, Utc::now())
1344            .await
1345            .unwrap_err();
1346        assert!(matches!(error, JobError::RevisionConflict { .. }));
1347        store
1348            .cancel(job_id, current.revision, Utc::now())
1349            .await
1350            .unwrap();
1351    }
1352}