Skip to main content

runledger_postgres/jobs/
replay.rs

1use runledger_core::jobs::{JobStage, JobStatus};
2use sqlx::types::Uuid;
3
4use crate::{DbPool, DbTx, Error, Result};
5
6use super::errors::{
7    job_replay_idempotency_conflict_error, job_replay_missing_existing_error,
8    validate_job_replay_request, workflow_requeue_not_supported_error,
9};
10use super::queue::advance::JOB_QUEUE_COLUMNS_SQL;
11use super::queue::enqueue_replayed_job_with_outcome_tx;
12use super::queue::events::EnqueuedEventPayload;
13use super::row_decode::parse_job_status;
14use super::rows::JobQueueRow;
15use super::transaction_isolation::{
16    ReadCommittedTx, begin_owned_read_committed_tx, ensure_read_committed_tx,
17    finish_owned_transaction,
18};
19use super::types::{
20    JobEnqueue, JobEnqueueDisposition, JobEnqueueOutcome, JobQueueRecord, JobScope,
21};
22
23/// An idempotent request to execute a successful direct job again as a fresh
24/// logical job.
25///
26/// This operation does not mutate the source row. Replay creates a new job ID
27/// and copies only the source payload and effective execution settings;
28/// progress, checkpoint, output, terminal timestamps, workflow ownership, and
29/// the original idempotency key are never copied.
30#[derive(Debug, Clone)]
31pub struct CompareAndReplaySucceededJob<'a> {
32    pub scope: JobScope,
33    pub source_job_id: Uuid,
34    pub expected_run_number: i32,
35    /// Stable identity for one replay action. Retrying the same action must use
36    /// the same key; another intentional replay must use a different key.
37    pub replay_request_key: &'a str,
38    pub reason: &'a str,
39}
40
41/// Result of compare-and-replay.
42#[derive(Debug, Clone)]
43#[must_use = "callers must inspect whether the expected successful job was replayed"]
44#[non_exhaustive]
45pub enum CompareAndReplaySucceededJobOutcome {
46    /// The replay exists. `replay.disposition` distinguishes a newly inserted
47    /// replay from an idempotent retry that resolved to the existing replay.
48    Replayed {
49        source_job_id: Uuid,
50        source_run_number: i32,
51        replay: JobEnqueueOutcome,
52    },
53    /// The exactly scoped source exists but no longer matches the successful
54    /// run observed by the caller.
55    ExpectationMismatch { actual: Box<JobQueueRecord> },
56    /// No source job exists in the exact requested scope.
57    NotFound,
58}
59
60#[derive(sqlx::FromRow)]
61struct ReplayCandidateRow {
62    #[sqlx(flatten)]
63    job: JobQueueRow,
64    workflow_managed: bool,
65    execution_resource_key: Option<String>,
66}
67
68struct ReplayCandidate {
69    job: JobQueueRecord,
70    workflow_managed: bool,
71    execution_resource_key: Option<String>,
72}
73
74#[derive(sqlx::FromRow)]
75struct ExistingReplayRow {
76    replay_job_id: Uuid,
77    replay_status: Option<String>,
78    replay_run_number: Option<i32>,
79    replay_reason: String,
80}
81
82async fn load_existing_replay_tx(
83    tx: &mut DbTx<'_>,
84    request: &CompareAndReplaySucceededJob<'_>,
85) -> Result<Option<ExistingReplayRow>> {
86    // Classify the key and conditionally lock a matching replay in one READ
87    // COMMITTED snapshot. A reason conflict still returns the lineage row, but
88    // the lateral branch stays empty so the replay job is not locked.
89    sqlx::query_as::<_, ExistingReplayRow>(
90        "SELECT
91            jr.replay_job_id,
92            replay.status::text AS replay_status,
93            replay.run_number AS replay_run_number,
94            jr.reason AS replay_reason
95         FROM job_replays jr
96         JOIN job_queue source ON source.id = jr.source_job_id
97         LEFT JOIN LATERAL (
98             SELECT replay.status, replay.run_number
99             FROM job_queue replay
100             WHERE replay.id = jr.replay_job_id
101               AND jr.reason = $5
102             FOR NO KEY UPDATE OF replay
103         ) replay ON TRUE
104         WHERE jr.source_job_id = $1
105           AND jr.source_run_number = $2
106           AND jr.replay_request_key = $3
107           AND source.organization_id IS NOT DISTINCT FROM $4::uuid",
108    )
109    .bind(request.source_job_id)
110    .bind(request.expected_run_number)
111    .bind(request.replay_request_key)
112    .bind(request.scope.organization_id())
113    .bind(request.reason)
114    .fetch_optional(&mut **tx)
115    .await
116    .map_err(|error| Error::from_query_sqlx_with_context("load existing job replay", error))
117}
118
119fn existing_replay_outcome(
120    request: &CompareAndReplaySucceededJob<'_>,
121    existing: ExistingReplayRow,
122) -> Result<CompareAndReplaySucceededJobOutcome> {
123    let replay_status = existing
124        .replay_status
125        .ok_or_else(job_replay_missing_existing_error)?;
126    let replay_run_number = existing
127        .replay_run_number
128        .ok_or_else(job_replay_missing_existing_error)?;
129    Ok(CompareAndReplaySucceededJobOutcome::Replayed {
130        source_job_id: request.source_job_id,
131        source_run_number: request.expected_run_number,
132        replay: JobEnqueueOutcome {
133            job_id: existing.replay_job_id,
134            status: parse_job_status(replay_status)?,
135            run_number: replay_run_number,
136            disposition: JobEnqueueDisposition::Existing,
137        },
138    })
139}
140
141async fn load_or_classify_existing_replay_tx(
142    tx: &mut DbTx<'_>,
143    request: &CompareAndReplaySucceededJob<'_>,
144) -> Result<Option<CompareAndReplaySucceededJobOutcome>> {
145    let Some(existing) = load_existing_replay_tx(tx, request).await? else {
146        return Ok(None);
147    };
148    if existing.replay_reason != request.reason {
149        return Err(job_replay_idempotency_conflict_error());
150    }
151    existing_replay_outcome(request, existing).map(Some)
152}
153
154fn replay_candidate_from_row(row: ReplayCandidateRow) -> Result<ReplayCandidate> {
155    let job = row.job.into_record()?;
156    Ok(ReplayCandidate {
157        job,
158        workflow_managed: row.workflow_managed,
159        execution_resource_key: row.execution_resource_key,
160    })
161}
162
163async fn lock_eligible_replay_source_tx(
164    tx: &mut DbTx<'_>,
165    request: &CompareAndReplaySucceededJob<'_>,
166) -> Result<Option<ReplayCandidate>> {
167    // Replay does not change the source's key, so NO KEY UPDATE composes with
168    // the replay-lineage foreign-key insert.
169    let sql = format!(
170        "SELECT
171            {JOB_QUEUE_COLUMNS_SQL},
172            EXISTS (
173                SELECT 1
174                FROM workflow_steps ws
175                WHERE ws.job_id = job_queue.id
176            ) AS workflow_managed,
177            execution_resource_key
178         FROM job_queue
179         WHERE id = $1
180           AND organization_id IS NOT DISTINCT FROM $2::uuid
181           AND status = 'SUCCEEDED'
182           AND run_number = $3::int4
183           AND NOT EXISTS (
184                SELECT 1
185                FROM workflow_steps ws
186                WHERE ws.job_id = job_queue.id
187           )
188         FOR NO KEY UPDATE"
189    );
190    let row = sqlx::query_as::<_, ReplayCandidateRow>(&sql)
191        .bind(request.source_job_id)
192        .bind(request.scope.organization_id())
193        .bind(request.expected_run_number)
194        .fetch_optional(&mut **tx)
195        .await
196        .map_err(|error| {
197            Error::from_query_sqlx_with_context("lock successful job replay source", error)
198        })?;
199
200    row.map(replay_candidate_from_row).transpose()
201}
202
203async fn load_replay_source_for_classification_tx(
204    tx: &mut DbTx<'_>,
205    request: &CompareAndReplaySucceededJob<'_>,
206) -> Result<Option<ReplayCandidate>> {
207    // A rejected or stale observation is read without retaining a row lock in
208    // the caller transaction.
209    let sql = format!(
210        "SELECT
211            {JOB_QUEUE_COLUMNS_SQL},
212            EXISTS (
213                SELECT 1
214                FROM workflow_steps ws
215                WHERE ws.job_id = job_queue.id
216            ) AS workflow_managed,
217            execution_resource_key
218         FROM job_queue
219         WHERE id = $1
220           AND organization_id IS NOT DISTINCT FROM $2::uuid"
221    );
222    let row = sqlx::query_as::<_, ReplayCandidateRow>(&sql)
223        .bind(request.source_job_id)
224        .bind(request.scope.organization_id())
225        .fetch_optional(&mut **tx)
226        .await
227        .map_err(|error| {
228            Error::from_query_sqlx_with_context("read successful job replay mismatch", error)
229        })?;
230
231    row.map(replay_candidate_from_row).transpose()
232}
233
234async fn lock_replay_source_tx(
235    tx: &mut DbTx<'_>,
236    request: &CompareAndReplaySucceededJob<'_>,
237) -> Result<std::result::Result<ReplayCandidate, CompareAndReplaySucceededJobOutcome>> {
238    loop {
239        if let Some(candidate) = lock_eligible_replay_source_tx(tx, request).await? {
240            debug_assert!(!candidate.workflow_managed);
241            return Ok(Ok(candidate));
242        }
243
244        let Some(actual) = load_replay_source_for_classification_tx(tx, request).await? else {
245            return Ok(Err(CompareAndReplaySucceededJobOutcome::NotFound));
246        };
247        if actual.job.status == JobStatus::Succeeded
248            && actual.job.run_number == request.expected_run_number
249        {
250            if actual.workflow_managed {
251                return Err(workflow_requeue_not_supported_error());
252            }
253
254            // READ COMMITTED gives the locking retry a fresh snapshot. If the
255            // source became eligible between statements, retry rather than
256            // report a contradictory mismatch.
257            continue;
258        }
259
260        return Ok(Err(
261            CompareAndReplaySucceededJobOutcome::ExpectationMismatch {
262                actual: Box::new(actual.job),
263            },
264        ));
265    }
266}
267
268/// Creates a fresh direct job from an exactly scoped successful source.
269///
270/// The caller transaction must use `READ COMMITTED`. This function neither
271/// commits nor rolls back. Idempotent retries with the same source run,
272/// `replay_request_key`, and reason return the existing replay job. Reusing a
273/// replay key with a different reason returns
274/// `job.replay_idempotency_conflict`.
275pub async fn compare_and_replay_succeeded_job_tx(
276    tx: &mut DbTx<'_>,
277    request: CompareAndReplaySucceededJob<'_>,
278) -> Result<CompareAndReplaySucceededJobOutcome> {
279    validate_job_replay_request(request.replay_request_key, request.reason)?;
280    let mut read_committed_tx = ensure_read_committed_tx(
281        tx,
282        "successful job compare-and-replay",
283        "job.compare_and_replay_unsupported_isolation",
284        "Successful job replay requires READ COMMITTED transaction isolation.",
285    )
286    .await?;
287
288    compare_and_replay_succeeded_job_read_committed_tx(&mut read_committed_tx, request).await
289}
290
291async fn compare_and_replay_succeeded_job_read_committed_tx(
292    tx: &mut ReadCommittedTx<'_, '_>,
293    request: CompareAndReplaySucceededJob<'_>,
294) -> Result<CompareAndReplaySucceededJobOutcome> {
295    let tx = tx.as_tx();
296    if let Some(existing) = load_or_classify_existing_replay_tx(tx, &request).await? {
297        return Ok(existing);
298    }
299
300    let source = match lock_replay_source_tx(tx, &request).await? {
301        Ok(source) => source,
302        Err(outcome) => return Ok(outcome),
303    };
304
305    // A concurrent replay with the same request key may have committed while
306    // this transaction waited for the source lock.
307    if let Some(existing) = load_or_classify_existing_replay_tx(tx, &request).await? {
308        return Ok(existing);
309    }
310
311    let replay_payload = JobEnqueue {
312        job_type: source.job.job_type.as_borrowed(),
313        organization_id: source.job.organization_id,
314        payload: &source.job.payload,
315        priority: Some(source.job.priority),
316        max_attempts: Some(source.job.max_attempts),
317        timeout_seconds: Some(source.job.timeout_seconds),
318        next_run_at: None,
319        idempotency_key: None,
320        stage: Some(JobStage::Queued),
321    };
322    let replay = enqueue_replayed_job_with_outcome_tx(
323        tx,
324        &replay_payload,
325        source.execution_resource_key.as_deref(),
326        EnqueuedEventPayload::SuccessfulReplay {
327            replayed_from_job_id: source.job.id,
328            replayed_from_run_number: source.job.run_number,
329            replay_request_key: request.replay_request_key,
330            reason: request.reason,
331        },
332    )
333    .await?;
334    debug_assert_eq!(replay.disposition, JobEnqueueDisposition::Inserted);
335
336    sqlx::query(
337        "INSERT INTO job_replays (
338            source_job_id,
339            source_run_number,
340            replay_request_key,
341            replay_job_id,
342            reason
343         )
344         VALUES ($1, $2, $3, $4, $5)",
345    )
346    .bind(source.job.id)
347    .bind(source.job.run_number)
348    .bind(request.replay_request_key)
349    .bind(replay.job_id)
350    .bind(request.reason)
351    .execute(&mut **tx)
352    .await
353    .map_err(|error| Error::from_query_sqlx_with_context("record successful job replay", error))?;
354
355    Ok(CompareAndReplaySucceededJobOutcome::Replayed {
356        source_job_id: source.job.id,
357        source_run_number: source.job.run_number,
358        replay,
359    })
360}
361
362/// Pool-owning convenience wrapper for [`compare_and_replay_succeeded_job_tx`].
363///
364/// Request identity is validated before this function acquires a connection or
365/// begins a transaction. The caller-owned transaction API independently runs
366/// the same validator so neither entry point can bypass the contract.
367pub async fn compare_and_replay_succeeded_job(
368    pool: &DbPool,
369    request: CompareAndReplaySucceededJob<'_>,
370) -> Result<CompareAndReplaySucceededJobOutcome> {
371    const OPERATION: &str = "successful job replay";
372
373    validate_job_replay_request(request.replay_request_key, request.reason)?;
374    let mut tx = begin_owned_read_committed_tx(pool, OPERATION).await?;
375    let result = {
376        let mut read_committed_tx = tx.as_read_committed_tx();
377        compare_and_replay_succeeded_job_read_committed_tx(&mut read_committed_tx, request).await
378    };
379    finish_owned_transaction(tx, OPERATION, result).await
380}