1use chrono::{DateTime, Utc};
4use sqlx::{Row, SqlitePool, sqlite::SqliteRow};
5
6use crate::{
7 convert::{enum_to_text, text_to_enum},
8 error::{DbError, DbResult},
9};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum LiveOperationKind {
14 Tune,
15 PidWrite,
16 PidRevert,
17 Recovery,
18 OpcWrite,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum LiveOwnershipState {
24 Active,
25 Released,
26 Orphaned,
27 Recovered,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum MutationStepStatus {
33 Intent,
34 Confirmed,
35 Failed,
36 NotNeeded,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum RecoveryAttemptStatus {
42 Running,
43 Confirmed,
44 Incomplete,
45 Failed,
46}
47
48#[derive(Debug, Clone, PartialEq, serde::Serialize)]
49pub struct NewLiveOwnership {
50 pub run_id: Option<i64>,
51 pub operation_kind: LiveOperationKind,
52 pub database_key: String,
53 pub resource_key: String,
54 pub owner_pid: i64,
55 pub acquired_at: DateTime<Utc>,
56 pub restore_intent_json: Option<String>,
57}
58
59#[derive(Debug, Clone, PartialEq, serde::Serialize)]
60pub struct LiveOwnershipRow {
61 pub id: i64,
62 pub run_id: Option<i64>,
63 pub operation_kind: LiveOperationKind,
64 pub database_key: String,
65 pub resource_key: String,
66 pub owner_pid: i64,
67 pub acquired_at: DateTime<Utc>,
68 pub heartbeat_at: DateTime<Utc>,
69 pub released_at: Option<DateTime<Utc>>,
70 pub state: LiveOwnershipState,
71 pub restore_intent_json: Option<String>,
72 pub orphan_eligible: bool,
73 pub orphan_evidence_json: Option<String>,
74}
75
76#[derive(Debug, Clone, PartialEq, serde::Serialize)]
77pub struct LiveMutationStepRow {
78 pub id: i64,
79 pub owner_id: i64,
80 pub run_id: Option<i64>,
81 pub step: String,
82 pub target_json: String,
83 pub previous_json: Option<String>,
84 pub status: MutationStepStatus,
85 pub started_at: DateTime<Utc>,
86 pub completed_at: Option<DateTime<Utc>>,
87 pub readback_json: Option<String>,
88 pub detail: Option<String>,
89}
90
91#[derive(Debug, Clone, PartialEq, serde::Serialize)]
92pub struct NewLiveMutationStep {
93 pub owner_id: i64,
94 pub run_id: Option<i64>,
95 pub step: String,
96 pub target_json: String,
97 pub previous_json: Option<String>,
98 pub started_at: DateTime<Utc>,
99}
100
101#[derive(Debug, Clone, PartialEq, serde::Serialize)]
102pub struct TuneRecoveryAttemptRow {
103 pub id: i64,
104 pub run_id: i64,
105 pub source_owner_id: i64,
106 pub recovery_owner_id: i64,
107 pub export_path: String,
108 pub started_at: DateTime<Utc>,
109 pub completed_at: Option<DateTime<Utc>>,
110 pub status: RecoveryAttemptStatus,
111 pub detail: Option<String>,
112 pub evidence_json: String,
113}
114
115pub struct OrphanOwnerUpdate<'a> {
116 pub owner_id: i64,
117 pub expected_heartbeat: DateTime<Utc>,
118 pub expected_claim_key: &'a str,
119 pub now: DateTime<Utc>,
120 pub evidence_json: &'a str,
121 pub eligible: bool,
122 pub detail: &'a str,
123}
124
125pub struct UnrecoverableOwnerUpdate<'a> {
126 pub owner_id: i64,
127 pub expected_heartbeat: DateTime<Utc>,
128 pub expected_claim_key: &'a str,
129 pub now: DateTime<Utc>,
130 pub evidence_json: &'a str,
131 pub fail_running_run_id: Option<i64>,
132 pub detail: &'a str,
133}
134
135pub struct CompleteRecoveryUpdate<'a> {
136 pub attempt_id: i64,
137 pub recovery_owner_id: i64,
138 pub source_owner_id: i64,
139 pub run_id: i64,
140 pub claim_key: &'a str,
141 pub status: RecoveryAttemptStatus,
142 pub now: DateTime<Utc>,
143 pub detail: Option<&'a str>,
144 pub evidence_json: &'a str,
145}
146
147pub struct RestartRecoveryUpdate<'a> {
148 pub attempt_id: i64,
149 pub recovery_owner_id: i64,
150 pub expected_heartbeat: DateTime<Utc>,
151 pub source_owner_id: i64,
152 pub run_id: i64,
153 pub claim_key: &'a str,
154 pub now: DateTime<Utc>,
155 pub detail: &'a str,
156 pub evidence_json: &'a str,
157}
158
159impl LiveOwnershipRow {
160 pub async fn create_and_claim(
164 pool: &SqlitePool,
165 new: NewLiveOwnership,
166 claim_key: &str,
167 ) -> DbResult<Option<Self>> {
168 let mut transaction = pool.begin().await.map_err(DbError::Query)?;
169 let row = sqlx::query(
170 r"
171 INSERT INTO live_operation_owners (
172 run_id, operation_kind, database_key, resource_key, owner_pid,
173 acquired_at, heartbeat_at, state, restore_intent_json
174 ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?)
175 RETURNING *
176 ",
177 )
178 .bind(new.run_id)
179 .bind(enum_to_text(&new.operation_kind)?)
180 .bind(new.database_key)
181 .bind(new.resource_key)
182 .bind(new.owner_pid)
183 .bind(new.acquired_at)
184 .bind(new.acquired_at)
185 .bind(new.restore_intent_json)
186 .fetch_one(&mut *transaction)
187 .await
188 .map_err(DbError::Query)?;
189 let owner = row_to_live_ownership(row)?;
190
191 let claim_result = sqlx::query(
192 r"
193 INSERT INTO live_operation_claims (claim_key, owner_id, claimed_at)
194 VALUES (?, ?, ?)
195 ON CONFLICT(claim_key) DO NOTHING
196 ",
197 )
198 .bind(claim_key)
199 .bind(owner.id)
200 .bind(new.acquired_at)
201 .execute(&mut *transaction)
202 .await
203 .map_err(DbError::Query)?;
204
205 if claim_result.rows_affected() != 1 {
206 transaction.rollback().await.map_err(DbError::Query)?;
207 return Ok(None);
208 }
209 transaction.commit().await.map_err(DbError::Query)?;
210 Ok(Some(owner))
211 }
212
213 pub async fn heartbeat(pool: &SqlitePool, owner_id: i64, now: DateTime<Utc>) -> DbResult<()> {
214 let result = sqlx::query(
215 r"
216 UPDATE live_operation_owners
217 SET heartbeat_at = ?
218 WHERE id = ? AND state = 'active'
219 AND EXISTS (
220 SELECT 1 FROM live_operation_claims WHERE owner_id = ?
221 )
222 ",
223 )
224 .bind(now)
225 .bind(owner_id)
226 .bind(owner_id)
227 .execute(pool)
228 .await
229 .map_err(DbError::Query)?;
230 if result.rows_affected() != 1 {
231 return Err(DbError::LiveOwnershipLost);
232 }
233 Ok(())
234 }
235
236 pub async fn release(pool: &SqlitePool, owner_id: i64, now: DateTime<Utc>) -> DbResult<()> {
237 let mut transaction = pool.begin().await.map_err(DbError::Query)?;
238 let result = sqlx::query(
239 r"
240 UPDATE live_operation_owners
241 SET state = 'released', released_at = ?
242 WHERE id = ? AND state = 'active'
243 AND EXISTS (
244 SELECT 1 FROM live_operation_claims WHERE owner_id = ?
245 )
246 ",
247 )
248 .bind(now)
249 .bind(owner_id)
250 .bind(owner_id)
251 .execute(&mut *transaction)
252 .await
253 .map_err(DbError::Query)?;
254 if result.rows_affected() != 1 {
255 transaction.rollback().await.map_err(DbError::Query)?;
256 return Err(DbError::LiveOwnershipLost);
257 }
258 let claim_result = sqlx::query("DELETE FROM live_operation_claims WHERE owner_id = ?")
259 .bind(owner_id)
260 .execute(&mut *transaction)
261 .await
262 .map_err(DbError::Query)?;
263 if claim_result.rows_affected() != 1 {
264 transaction.rollback().await.map_err(DbError::Query)?;
265 return Err(DbError::LiveOwnershipLost);
266 }
267 transaction.commit().await.map_err(DbError::Query)
268 }
269
270 pub async fn get(pool: &SqlitePool, id: i64) -> DbResult<Option<Self>> {
271 let row = sqlx::query("SELECT * FROM live_operation_owners WHERE id = ?")
272 .bind(id)
273 .fetch_optional(pool)
274 .await
275 .map_err(DbError::Query)?;
276 row.map(row_to_live_ownership).transpose()
277 }
278
279 pub async fn has_claim(
280 pool: &SqlitePool,
281 owner_id: i64,
282 expected_claim_key: &str,
283 ) -> DbResult<bool> {
284 let claimed: i64 = sqlx::query_scalar(
285 "SELECT EXISTS(SELECT 1 FROM live_operation_claims WHERE owner_id = ? AND claim_key = ?)",
286 )
287 .bind(owner_id)
288 .bind(expected_claim_key)
289 .fetch_one(pool)
290 .await
291 .map_err(DbError::Query)?;
292 Ok(claimed == 1)
293 }
294
295 pub async fn list_for_run(pool: &SqlitePool, run_id: i64) -> DbResult<Vec<Self>> {
296 let rows = sqlx::query(
297 "SELECT * FROM live_operation_owners WHERE run_id = ? ORDER BY acquired_at, id",
298 )
299 .bind(run_id)
300 .fetch_all(pool)
301 .await
302 .map_err(DbError::Query)?;
303 rows.into_iter().map(row_to_live_ownership).collect()
304 }
305
306 pub async fn stale_active_owners(
307 pool: &SqlitePool,
308 stale_before: DateTime<Utc>,
309 ) -> DbResult<Vec<Self>> {
310 let rows = sqlx::query(
311 r"
312 SELECT owners.*
313 FROM live_operation_owners AS owners
314 WHERE owners.state = 'active'
315 AND owners.heartbeat_at < ?
316 AND EXISTS (
317 SELECT 1 FROM live_operation_claims
318 WHERE live_operation_claims.owner_id = owners.id
319 )
320 ORDER BY owners.heartbeat_at, owners.id
321 ",
322 )
323 .bind(stale_before)
324 .fetch_all(pool)
325 .await
326 .map_err(DbError::Query)?;
327 rows.into_iter().map(row_to_live_ownership).collect()
328 }
329
330 pub async fn set_restore_intent(
331 pool: &SqlitePool,
332 owner_id: i64,
333 intent_json: &str,
334 ) -> DbResult<()> {
335 let result = sqlx::query(
336 r"
337 UPDATE live_operation_owners SET restore_intent_json = ?
338 WHERE id = ? AND state = 'active'
339 AND EXISTS (SELECT 1 FROM live_operation_claims WHERE owner_id = ?)
340 ",
341 )
342 .bind(intent_json)
343 .bind(owner_id)
344 .bind(owner_id)
345 .execute(pool)
346 .await
347 .map_err(DbError::Query)?;
348 if result.rows_affected() != 1 {
349 return Err(DbError::LiveOwnershipLost);
350 }
351 Ok(())
352 }
353
354 pub async fn mark_orphaned(pool: &SqlitePool, update: OrphanOwnerUpdate<'_>) -> DbResult<bool> {
355 let mut transaction = pool.begin().await.map_err(DbError::Query)?;
356 let result = sqlx::query(
357 r"
358 UPDATE live_operation_owners
359 SET state = 'orphaned', released_at = ?, orphan_eligible = ?,
360 orphan_evidence_json = ?
361 WHERE id = ? AND state = 'active' AND heartbeat_at = ?
362 AND operation_kind = 'tune'
363 AND EXISTS (
364 SELECT 1 FROM tune_runs
365 WHERE tune_runs.id = live_operation_owners.run_id
366 AND tune_runs.driver = 'opcda'
367 AND tune_runs.outcome = 'running'
368 )
369 AND EXISTS (
370 SELECT 1 FROM live_operation_claims
371 WHERE live_operation_claims.owner_id = live_operation_owners.id
372 AND live_operation_claims.claim_key = ?
373 )
374 ",
375 )
376 .bind(update.now)
377 .bind(update.eligible)
378 .bind(update.evidence_json)
379 .bind(update.owner_id)
380 .bind(update.expected_heartbeat)
381 .bind(update.expected_claim_key)
382 .execute(&mut *transaction)
383 .await
384 .map_err(DbError::Query)?;
385 if result.rows_affected() != 1 {
386 transaction.rollback().await.map_err(DbError::Query)?;
387 return Ok(false);
388 }
389
390 let state = if update.eligible {
391 "eligible"
392 } else {
393 "not_recoverable"
394 };
395 let run_result = sqlx::query(
396 r"
397 UPDATE tune_runs
398 SET outcome = 'failed', completed_at = ?, failure_reason = ?,
399 restore_status = 'incomplete', restore_detail = ?,
400 recovery_state = ?, recovery_evidence_json = ?
401 WHERE id = (
402 SELECT run_id FROM live_operation_owners WHERE id = ?
403 ) AND outcome = 'running'
404 ",
405 )
406 .bind(update.now)
407 .bind(update.detail)
408 .bind(update.detail)
409 .bind(state)
410 .bind(update.evidence_json)
411 .bind(update.owner_id)
412 .execute(&mut *transaction)
413 .await
414 .map_err(DbError::Query)?;
415 if run_result.rows_affected() != 1 {
416 transaction.rollback().await.map_err(DbError::Query)?;
417 return Ok(false);
418 }
419
420 if !update.eligible {
421 sqlx::query("DELETE FROM live_operation_claims WHERE owner_id = ?")
422 .bind(update.owner_id)
423 .execute(&mut *transaction)
424 .await
425 .map_err(DbError::Query)?;
426 }
427 transaction.commit().await.map_err(DbError::Query)?;
428 Ok(true)
429 }
430
431 pub async fn mark_orphaned_unrecoverable(
435 pool: &SqlitePool,
436 update: UnrecoverableOwnerUpdate<'_>,
437 ) -> DbResult<bool> {
438 let mut transaction = pool.begin().await.map_err(DbError::Query)?;
439 let owner_result = sqlx::query(
440 r"
441 UPDATE live_operation_owners
442 SET state = 'orphaned', released_at = ?, orphan_eligible = 0,
443 orphan_evidence_json = ?
444 WHERE id = ? AND state = 'active' AND heartbeat_at = ?
445 AND EXISTS (
446 SELECT 1 FROM live_operation_claims
447 WHERE owner_id = live_operation_owners.id AND claim_key = ?
448 )
449 ",
450 )
451 .bind(update.now)
452 .bind(update.evidence_json)
453 .bind(update.owner_id)
454 .bind(update.expected_heartbeat)
455 .bind(update.expected_claim_key)
456 .execute(&mut *transaction)
457 .await
458 .map_err(DbError::Query)?;
459 if owner_result.rows_affected() != 1 {
460 transaction.rollback().await.map_err(DbError::Query)?;
461 return Ok(false);
462 }
463
464 if let Some(run_id) = update.fail_running_run_id {
465 let run_result = sqlx::query(
466 r"
467 UPDATE tune_runs
468 SET outcome = 'failed', completed_at = ?, failure_reason = ?,
469 restore_status = 'incomplete', restore_detail = ?,
470 recovery_state = 'not_recoverable', recovery_evidence_json = ?
471 WHERE id = ? AND outcome = 'running'
472 AND EXISTS (
473 SELECT 1 FROM live_operation_owners
474 WHERE id = ? AND run_id = ? AND state = 'orphaned'
475 )
476 ",
477 )
478 .bind(update.now)
479 .bind(update.detail)
480 .bind(update.detail)
481 .bind(update.evidence_json)
482 .bind(run_id)
483 .bind(update.owner_id)
484 .bind(run_id)
485 .execute(&mut *transaction)
486 .await
487 .map_err(DbError::Query)?;
488 if run_result.rows_affected() != 1 {
489 transaction.rollback().await.map_err(DbError::Query)?;
490 return Ok(false);
491 }
492 }
493
494 let claim_result =
495 sqlx::query("DELETE FROM live_operation_claims WHERE owner_id = ? AND claim_key = ?")
496 .bind(update.owner_id)
497 .bind(update.expected_claim_key)
498 .execute(&mut *transaction)
499 .await
500 .map_err(DbError::Query)?;
501 if claim_result.rows_affected() != 1 {
502 transaction.rollback().await.map_err(DbError::Query)?;
503 return Ok(false);
504 }
505 transaction.commit().await.map_err(DbError::Query)?;
506 Ok(true)
507 }
508}
509
510impl LiveMutationStepRow {
511 pub async fn begin(pool: &SqlitePool, new: NewLiveMutationStep) -> DbResult<Self> {
512 let row = sqlx::query(
513 r"
514 INSERT INTO live_mutation_steps (
515 owner_id, run_id, step, target_json, previous_json, status, started_at
516 )
517 SELECT ?, ?, ?, ?, ?, 'intent', ?
518 WHERE EXISTS (
519 SELECT 1 FROM live_operation_owners
520 WHERE id = ? AND run_id IS ? AND state = 'active'
521 )
522 AND EXISTS (
523 SELECT 1 FROM live_operation_claims WHERE owner_id = ?
524 )
525 RETURNING *
526 ",
527 )
528 .bind(new.owner_id)
529 .bind(new.run_id)
530 .bind(new.step)
531 .bind(new.target_json)
532 .bind(new.previous_json)
533 .bind(new.started_at)
534 .bind(new.owner_id)
535 .bind(new.run_id)
536 .bind(new.owner_id)
537 .fetch_optional(pool)
538 .await
539 .map_err(DbError::Query)?
540 .ok_or(DbError::LiveOwnershipLost)?;
541 row_to_live_mutation_step(row)
542 }
543
544 pub async fn finish(
545 pool: &SqlitePool,
546 step_id: i64,
547 status: MutationStepStatus,
548 now: DateTime<Utc>,
549 readback_json: Option<&str>,
550 detail: Option<&str>,
551 ) -> DbResult<Self> {
552 if status == MutationStepStatus::Intent {
553 return Err(DbError::LiveOwnershipLost);
554 }
555 let row = sqlx::query(
556 r"
557 UPDATE live_mutation_steps
558 SET status = ?, completed_at = ?, readback_json = ?, detail = ?
559 WHERE id = ? AND status = 'intent'
560 RETURNING *
561 ",
562 )
563 .bind(enum_to_text(&status)?)
564 .bind(now)
565 .bind(readback_json)
566 .bind(detail)
567 .bind(step_id)
568 .fetch_one(pool)
569 .await
570 .map_err(DbError::Query)?;
571 row_to_live_mutation_step(row)
572 }
573
574 pub async fn not_needed(
575 pool: &SqlitePool,
576 owner_id: i64,
577 run_id: i64,
578 step: &str,
579 now: DateTime<Utc>,
580 detail: &str,
581 ) -> DbResult<Self> {
582 let row = sqlx::query(
583 r"
584 INSERT INTO live_mutation_steps (
585 owner_id, run_id, step, target_json, status, started_at, completed_at, detail
586 )
587 SELECT ?, ?, ?, '{}', 'not_needed', ?, ?, ?
588 WHERE EXISTS (
589 SELECT 1 FROM live_operation_owners
590 WHERE id = ? AND run_id IS ? AND state = 'active'
591 )
592 AND EXISTS (
593 SELECT 1 FROM live_operation_claims WHERE owner_id = ?
594 )
595 RETURNING *
596 ",
597 )
598 .bind(owner_id)
599 .bind(run_id)
600 .bind(step)
601 .bind(now)
602 .bind(now)
603 .bind(detail)
604 .bind(owner_id)
605 .bind(run_id)
606 .bind(owner_id)
607 .fetch_optional(pool)
608 .await
609 .map_err(DbError::Query)?
610 .ok_or(DbError::LiveOwnershipLost)?;
611 row_to_live_mutation_step(row)
612 }
613
614 pub async fn list_for_owner(pool: &SqlitePool, owner_id: i64) -> DbResult<Vec<Self>> {
615 let rows = sqlx::query(
616 "SELECT * FROM live_mutation_steps WHERE owner_id = ? ORDER BY started_at, id",
617 )
618 .bind(owner_id)
619 .fetch_all(pool)
620 .await
621 .map_err(DbError::Query)?;
622 rows.into_iter().map(row_to_live_mutation_step).collect()
623 }
624
625 pub async fn list_for_run(pool: &SqlitePool, run_id: i64) -> DbResult<Vec<Self>> {
626 let rows = sqlx::query(
627 "SELECT * FROM live_mutation_steps WHERE run_id = ? ORDER BY started_at, id",
628 )
629 .bind(run_id)
630 .fetch_all(pool)
631 .await
632 .map_err(DbError::Query)?;
633 rows.into_iter().map(row_to_live_mutation_step).collect()
634 }
635}
636
637impl TuneRecoveryAttemptRow {
638 pub async fn start_with_claim(
639 pool: &SqlitePool,
640 new_owner: NewLiveOwnership,
641 claim_key: &str,
642 source_owner_id: i64,
643 run_id: i64,
644 export_path: &str,
645 evidence_json: &str,
646 ) -> DbResult<Option<(LiveOwnershipRow, Self)>> {
647 let mut transaction = pool.begin().await.map_err(DbError::Query)?;
648 let owner_row = sqlx::query(
649 r"
650 INSERT INTO live_operation_owners (
651 run_id, operation_kind, database_key, resource_key, owner_pid,
652 acquired_at, heartbeat_at, state, restore_intent_json
653 ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?)
654 RETURNING *
655 ",
656 )
657 .bind(new_owner.run_id)
658 .bind(enum_to_text(&new_owner.operation_kind)?)
659 .bind(&new_owner.database_key)
660 .bind(&new_owner.resource_key)
661 .bind(new_owner.owner_pid)
662 .bind(new_owner.acquired_at)
663 .bind(new_owner.acquired_at)
664 .bind(&new_owner.restore_intent_json)
665 .fetch_one(&mut *transaction)
666 .await
667 .map_err(DbError::Query)?;
668 let recovery_owner = row_to_live_ownership(owner_row)?;
669
670 let claim_result = sqlx::query(
671 r"
672 UPDATE live_operation_claims
673 SET owner_id = ?, claimed_at = ?
674 WHERE claim_key = ? AND owner_id = ?
675 AND EXISTS (
676 SELECT 1 FROM live_operation_owners
677 WHERE id = ? AND run_id = ? AND operation_kind = 'tune'
678 AND state = 'orphaned' AND orphan_eligible = 1
679 AND database_key = ? AND resource_key = ?
680 )
681 ",
682 )
683 .bind(recovery_owner.id)
684 .bind(new_owner.acquired_at)
685 .bind(claim_key)
686 .bind(source_owner_id)
687 .bind(source_owner_id)
688 .bind(run_id)
689 .bind(&new_owner.database_key)
690 .bind(&new_owner.resource_key)
691 .execute(&mut *transaction)
692 .await
693 .map_err(DbError::Query)?;
694 if claim_result.rows_affected() != 1 {
695 transaction.rollback().await.map_err(DbError::Query)?;
696 return Ok(None);
697 }
698
699 let attempt_row = sqlx::query(
700 r"
701 INSERT INTO tune_recovery_attempts (
702 run_id, source_owner_id, recovery_owner_id, export_path, started_at,
703 status, evidence_json
704 )
705 SELECT ?, ?, ?, ?, ?, 'running', ?
706 WHERE EXISTS (
707 SELECT 1 FROM live_operation_owners
708 WHERE id = ? AND run_id = ? AND operation_kind = 'tune'
709 AND state = 'orphaned' AND orphan_eligible = 1
710 )
711 AND EXISTS (
712 SELECT 1 FROM live_operation_owners
713 WHERE id = ? AND run_id = ? AND operation_kind = 'recovery'
714 AND state = 'active'
715 )
716 AND EXISTS (
717 SELECT 1 FROM live_operation_claims
718 WHERE owner_id = ? AND claim_key = ?
719 )
720 RETURNING *
721 ",
722 )
723 .bind(run_id)
724 .bind(source_owner_id)
725 .bind(recovery_owner.id)
726 .bind(export_path)
727 .bind(new_owner.acquired_at)
728 .bind(evidence_json)
729 .bind(source_owner_id)
730 .bind(run_id)
731 .bind(recovery_owner.id)
732 .bind(run_id)
733 .bind(recovery_owner.id)
734 .bind(claim_key)
735 .fetch_optional(&mut *transaction)
736 .await
737 .map_err(DbError::Query)?
738 .ok_or(DbError::LiveOwnershipLost)?;
739 let attempt = row_to_recovery_attempt(attempt_row)?;
740
741 let run_result = sqlx::query(
742 r"
743 UPDATE tune_runs
744 SET recovery_state = 'running', recovery_evidence_json = ?
745 WHERE id = ? AND outcome = 'failed'
746 AND recovery_state IN ('eligible', 'incomplete')
747 AND EXISTS (
748 SELECT 1 FROM live_operation_owners
749 WHERE id = ? AND run_id = ? AND state = 'orphaned'
750 AND orphan_eligible = 1
751 )
752 ",
753 )
754 .bind(evidence_json)
755 .bind(run_id)
756 .bind(source_owner_id)
757 .bind(run_id)
758 .execute(&mut *transaction)
759 .await
760 .map_err(DbError::Query)?;
761 if run_result.rows_affected() != 1 {
762 transaction.rollback().await.map_err(DbError::Query)?;
763 return Err(DbError::LiveOwnershipLost);
764 }
765
766 transaction.commit().await.map_err(DbError::Query)?;
767 Ok(Some((recovery_owner, attempt)))
768 }
769
770 pub async fn list_for_run(pool: &SqlitePool, run_id: i64) -> DbResult<Vec<Self>> {
771 let rows = sqlx::query(
772 "SELECT * FROM tune_recovery_attempts WHERE run_id = ? ORDER BY started_at, id",
773 )
774 .bind(run_id)
775 .fetch_all(pool)
776 .await
777 .map_err(DbError::Query)?;
778 rows.into_iter().map(row_to_recovery_attempt).collect()
779 }
780
781 pub async fn complete_with_ownership(
782 pool: &SqlitePool,
783 update: CompleteRecoveryUpdate<'_>,
784 ) -> DbResult<()> {
785 let (restore_status, recovery_state, owner_state, eligible, released_at) = match update
786 .status
787 {
788 RecoveryAttemptStatus::Confirmed => (
789 Some("confirmed"),
790 "confirmed",
791 "recovered",
792 false,
793 Some(update.now),
794 ),
795 RecoveryAttemptStatus::Incomplete => (
796 Some("incomplete"),
797 "incomplete",
798 "orphaned",
799 true,
800 Some(update.now),
801 ),
802 RecoveryAttemptStatus::Failed => (None, "eligible", "orphaned", true, Some(update.now)),
803 RecoveryAttemptStatus::Running => return Err(DbError::LiveOwnershipLost),
804 };
805 let mut transaction = pool.begin().await.map_err(DbError::Query)?;
806 let attempt_result = sqlx::query(
807 r"
808 UPDATE tune_recovery_attempts
809 SET status = ?, completed_at = ?, detail = ?, evidence_json = ?
810 WHERE id = ? AND run_id = ? AND recovery_owner_id = ? AND status = 'running'
811 ",
812 )
813 .bind(enum_to_text(&update.status)?)
814 .bind(update.now)
815 .bind(update.detail)
816 .bind(update.evidence_json)
817 .bind(update.attempt_id)
818 .bind(update.run_id)
819 .bind(update.recovery_owner_id)
820 .execute(&mut *transaction)
821 .await
822 .map_err(DbError::Query)?;
823 if attempt_result.rows_affected() != 1 {
824 transaction.rollback().await.map_err(DbError::Query)?;
825 return Err(DbError::LiveOwnershipLost);
826 }
827
828 let run_result = sqlx::query(
829 r"
830 UPDATE tune_runs
831 SET restore_status = COALESCE(?, restore_status),
832 restore_detail = CASE WHEN ? IS NULL THEN restore_detail ELSE ? END,
833 recovery_state = ?,
834 recovery_evidence_json = ?
835 WHERE id = ? AND outcome = 'failed'
836 AND EXISTS (
837 SELECT 1 FROM live_operation_owners
838 WHERE id = ? AND run_id = ? AND state = 'orphaned'
839 AND orphan_eligible = 1
840 )
841 ",
842 )
843 .bind(restore_status)
844 .bind(restore_status)
845 .bind(update.detail)
846 .bind(recovery_state)
847 .bind(update.evidence_json)
848 .bind(update.run_id)
849 .bind(update.source_owner_id)
850 .bind(update.run_id)
851 .execute(&mut *transaction)
852 .await
853 .map_err(DbError::Query)?;
854 if run_result.rows_affected() != 1 {
855 transaction.rollback().await.map_err(DbError::Query)?;
856 return Err(DbError::LiveOwnershipLost);
857 }
858
859 let recovery_owner_result = sqlx::query(
860 "UPDATE live_operation_owners SET state = 'released', released_at = ? WHERE id = ? AND state = 'active'",
861 )
862 .bind(update.now)
863 .bind(update.recovery_owner_id)
864 .execute(&mut *transaction)
865 .await
866 .map_err(DbError::Query)?;
867 if recovery_owner_result.rows_affected() != 1 {
868 transaction.rollback().await.map_err(DbError::Query)?;
869 return Err(DbError::LiveOwnershipLost);
870 }
871
872 let source_owner_result = sqlx::query(
873 r"
874 UPDATE live_operation_owners
875 SET state = ?, released_at = ?, orphan_eligible = ?
876 WHERE id = ? AND run_id = ? AND state = 'orphaned' AND orphan_eligible = 1
877 ",
878 )
879 .bind(owner_state)
880 .bind(released_at)
881 .bind(eligible)
882 .bind(update.source_owner_id)
883 .bind(update.run_id)
884 .execute(&mut *transaction)
885 .await
886 .map_err(DbError::Query)?;
887 if source_owner_result.rows_affected() != 1 {
888 transaction.rollback().await.map_err(DbError::Query)?;
889 return Err(DbError::LiveOwnershipLost);
890 }
891
892 let claim_result =
893 sqlx::query("DELETE FROM live_operation_claims WHERE claim_key = ? AND owner_id = ?")
894 .bind(update.claim_key)
895 .bind(update.recovery_owner_id)
896 .execute(&mut *transaction)
897 .await
898 .map_err(DbError::Query)?;
899 if claim_result.rows_affected() != 1 {
900 transaction.rollback().await.map_err(DbError::Query)?;
901 return Err(DbError::LiveOwnershipLost);
902 }
903 if update.status != RecoveryAttemptStatus::Confirmed {
904 sqlx::query(
905 "INSERT INTO live_operation_claims (claim_key, owner_id, claimed_at) VALUES (?, ?, ?)",
906 )
907 .bind(update.claim_key)
908 .bind(update.source_owner_id)
909 .bind(update.now)
910 .execute(&mut *transaction)
911 .await
912 .map_err(DbError::Query)?;
913 }
914 transaction.commit().await.map_err(DbError::Query)
915 }
916
917 pub async fn abandon_after_restart(
921 pool: &SqlitePool,
922 update: RestartRecoveryUpdate<'_>,
923 ) -> DbResult<bool> {
924 let mut transaction = pool.begin().await.map_err(DbError::Query)?;
925 let attempt_result = sqlx::query(
926 r"
927 UPDATE tune_recovery_attempts
928 SET status = 'incomplete', completed_at = ?, detail = ?, evidence_json = ?
929 WHERE id = ? AND run_id = ? AND recovery_owner_id = ? AND status = 'running'
930 ",
931 )
932 .bind(update.now)
933 .bind(update.detail)
934 .bind(update.evidence_json)
935 .bind(update.attempt_id)
936 .bind(update.run_id)
937 .bind(update.recovery_owner_id)
938 .execute(&mut *transaction)
939 .await
940 .map_err(DbError::Query)?;
941 if attempt_result.rows_affected() != 1 {
942 transaction.rollback().await.map_err(DbError::Query)?;
943 return Ok(false);
944 }
945
946 let run_result = sqlx::query(
947 r"
948 UPDATE tune_runs
949 SET restore_status = 'incomplete', restore_detail = ?,
950 recovery_state = 'incomplete', recovery_evidence_json = ?
951 WHERE id = ? AND outcome = 'failed' AND recovery_state = 'running'
952 AND EXISTS (
953 SELECT 1 FROM live_operation_owners AS source
954 WHERE source.id = ? AND source.run_id = ? AND source.operation_kind = 'tune'
955 AND source.state = 'orphaned' AND source.orphan_eligible = 1
956 )
957 AND EXISTS (
958 SELECT 1 FROM live_operation_owners AS recovery
959 WHERE recovery.id = ? AND recovery.run_id = ?
960 AND recovery.operation_kind = 'recovery' AND recovery.state = 'active'
961 )
962 ",
963 )
964 .bind(update.detail)
965 .bind(update.evidence_json)
966 .bind(update.run_id)
967 .bind(update.source_owner_id)
968 .bind(update.run_id)
969 .bind(update.recovery_owner_id)
970 .bind(update.run_id)
971 .execute(&mut *transaction)
972 .await
973 .map_err(DbError::Query)?;
974 if run_result.rows_affected() != 1 {
975 transaction.rollback().await.map_err(DbError::Query)?;
976 return Ok(false);
977 }
978
979 let recovery_owner_result = sqlx::query(
980 r"
981 UPDATE live_operation_owners
982 SET state = 'released', released_at = ?
983 WHERE id = ? AND run_id = ? AND operation_kind = 'recovery'
984 AND state = 'active' AND heartbeat_at = ?
985 AND EXISTS (
986 SELECT 1 FROM live_operation_claims
987 WHERE owner_id = live_operation_owners.id AND claim_key = ?
988 )
989 ",
990 )
991 .bind(update.now)
992 .bind(update.recovery_owner_id)
993 .bind(update.run_id)
994 .bind(update.expected_heartbeat)
995 .bind(update.claim_key)
996 .execute(&mut *transaction)
997 .await
998 .map_err(DbError::Query)?;
999 if recovery_owner_result.rows_affected() != 1 {
1000 transaction.rollback().await.map_err(DbError::Query)?;
1001 return Ok(false);
1002 }
1003
1004 let claim_result = sqlx::query(
1005 r"
1006 UPDATE live_operation_claims
1007 SET owner_id = ?, claimed_at = ?
1008 WHERE claim_key = ? AND owner_id = ?
1009 AND EXISTS (
1010 SELECT 1 FROM live_operation_owners AS source
1011 WHERE source.id = ? AND source.run_id = ? AND source.operation_kind = 'tune'
1012 AND source.state = 'orphaned' AND source.orphan_eligible = 1
1013 )
1014 ",
1015 )
1016 .bind(update.source_owner_id)
1017 .bind(update.now)
1018 .bind(update.claim_key)
1019 .bind(update.recovery_owner_id)
1020 .bind(update.source_owner_id)
1021 .bind(update.run_id)
1022 .execute(&mut *transaction)
1023 .await
1024 .map_err(DbError::Query)?;
1025 if claim_result.rows_affected() != 1 {
1026 transaction.rollback().await.map_err(DbError::Query)?;
1027 return Ok(false);
1028 }
1029
1030 transaction.commit().await.map_err(DbError::Query)?;
1031 Ok(true)
1032 }
1033}
1034
1035fn row_to_live_ownership(row: SqliteRow) -> DbResult<LiveOwnershipRow> {
1036 let operation_kind: String = row.try_get("operation_kind").map_err(DbError::Query)?;
1037 let state: String = row.try_get("state").map_err(DbError::Query)?;
1038 Ok(LiveOwnershipRow {
1039 id: row.try_get("id").map_err(DbError::Query)?,
1040 run_id: row.try_get("run_id").map_err(DbError::Query)?,
1041 operation_kind: text_to_enum("operation_kind", &operation_kind)?,
1042 database_key: row.try_get("database_key").map_err(DbError::Query)?,
1043 resource_key: row.try_get("resource_key").map_err(DbError::Query)?,
1044 owner_pid: row.try_get("owner_pid").map_err(DbError::Query)?,
1045 acquired_at: row.try_get("acquired_at").map_err(DbError::Query)?,
1046 heartbeat_at: row.try_get("heartbeat_at").map_err(DbError::Query)?,
1047 released_at: row.try_get("released_at").map_err(DbError::Query)?,
1048 state: text_to_enum("state", &state)?,
1049 restore_intent_json: row.try_get("restore_intent_json").map_err(DbError::Query)?,
1050 orphan_eligible: row.try_get("orphan_eligible").map_err(DbError::Query)?,
1051 orphan_evidence_json: row
1052 .try_get("orphan_evidence_json")
1053 .map_err(DbError::Query)?,
1054 })
1055}
1056
1057fn row_to_live_mutation_step(row: SqliteRow) -> DbResult<LiveMutationStepRow> {
1058 let status: String = row.try_get("status").map_err(DbError::Query)?;
1059 Ok(LiveMutationStepRow {
1060 id: row.try_get("id").map_err(DbError::Query)?,
1061 owner_id: row.try_get("owner_id").map_err(DbError::Query)?,
1062 run_id: row.try_get("run_id").map_err(DbError::Query)?,
1063 step: row.try_get("step").map_err(DbError::Query)?,
1064 target_json: row.try_get("target_json").map_err(DbError::Query)?,
1065 previous_json: row.try_get("previous_json").map_err(DbError::Query)?,
1066 status: text_to_enum("status", &status)?,
1067 started_at: row.try_get("started_at").map_err(DbError::Query)?,
1068 completed_at: row.try_get("completed_at").map_err(DbError::Query)?,
1069 readback_json: row.try_get("readback_json").map_err(DbError::Query)?,
1070 detail: row.try_get("detail").map_err(DbError::Query)?,
1071 })
1072}
1073
1074fn row_to_recovery_attempt(row: SqliteRow) -> DbResult<TuneRecoveryAttemptRow> {
1075 let status: String = row.try_get("status").map_err(DbError::Query)?;
1076 Ok(TuneRecoveryAttemptRow {
1077 id: row.try_get("id").map_err(DbError::Query)?,
1078 run_id: row.try_get("run_id").map_err(DbError::Query)?,
1079 source_owner_id: row.try_get("source_owner_id").map_err(DbError::Query)?,
1080 recovery_owner_id: row.try_get("recovery_owner_id").map_err(DbError::Query)?,
1081 export_path: row.try_get("export_path").map_err(DbError::Query)?,
1082 started_at: row.try_get("started_at").map_err(DbError::Query)?,
1083 completed_at: row.try_get("completed_at").map_err(DbError::Query)?,
1084 status: text_to_enum("status", &status)?,
1085 detail: row.try_get("detail").map_err(DbError::Query)?,
1086 evidence_json: row.try_get("evidence_json").map_err(DbError::Query)?,
1087 })
1088}
1089
1090#[cfg(test)]
1091mod tests;