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#[derive(Debug, Clone)]
31pub struct CompareAndReplaySucceededJob<'a> {
32 pub scope: JobScope,
33 pub source_job_id: Uuid,
34 pub expected_run_number: i32,
35 pub replay_request_key: &'a str,
38 pub reason: &'a str,
39}
40
41#[derive(Debug, Clone)]
43#[must_use = "callers must inspect whether the expected successful job was replayed"]
44#[non_exhaustive]
45pub enum CompareAndReplaySucceededJobOutcome {
46 Replayed {
49 source_job_id: Uuid,
50 source_run_number: i32,
51 replay: JobEnqueueOutcome,
52 },
53 ExpectationMismatch { actual: Box<JobQueueRecord> },
56 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 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 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 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 continue;
258 }
259
260 return Ok(Err(
261 CompareAndReplaySucceededJobOutcome::ExpectationMismatch {
262 actual: Box::new(actual.job),
263 },
264 ));
265 }
266}
267
268pub 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 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
362pub 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}