1use 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#[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 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 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 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
320async 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}