1use std::sync::Arc;
4
5use async_trait::async_trait;
6use sqlx::SqlitePool;
7
8use super::status;
9use crate::timestamp::TimestampSource;
10use crate::{DbError, DbResultExt};
11
12pub struct SessionOperationRow {
14 pub cancel_requested: bool,
16 pub finished_at: Option<i64>,
18 pub heartbeat_at: Option<i64>,
20 pub id: String,
22 pub kind: String,
24 pub last_error: Option<String>,
26 pub queued_at: i64,
28 pub session_id: String,
30 pub started_at: Option<i64>,
32 pub status: String,
34}
35
36#[cfg_attr(test, mockall::automock)]
38#[async_trait]
39pub trait OperationRepository: Send + Sync {
40 async fn fail_unfinished_session_operations(&self, reason: &str) -> Result<(), DbError>;
42
43 async fn is_cancel_requested_for_operation(&self, operation_id: &str) -> Result<bool, DbError>;
45
46 async fn is_session_operation_unfinished(&self, operation_id: &str) -> Result<bool, DbError>;
48
49 async fn load_unfinished_session_operations(&self)
51 -> Result<Vec<SessionOperationRow>, DbError>;
52
53 async fn mark_session_operation_canceled(
55 &self,
56 operation_id: &str,
57 reason: &str,
58 ) -> Result<(), DbError>;
59
60 async fn mark_session_operation_done(&self, operation_id: &str) -> Result<(), DbError>;
62
63 async fn mark_session_operation_failed(
65 &self,
66 operation_id: &str,
67 error: &str,
68 ) -> Result<(), DbError>;
69
70 async fn mark_session_operation_running(&self, operation_id: &str) -> Result<(), DbError>;
72
73 async fn claim_session_operation(
79 &self,
80 operation_id: &str,
81 session_id: &str,
82 kind: &str,
83 ) -> Result<bool, DbError>;
84
85 async fn insert_session_operation(
87 &self,
88 operation_id: &str,
89 session_id: &str,
90 kind: &str,
91 ) -> Result<(), DbError>;
92
93 async fn request_cancel_for_session_operations(&self, session_id: &str) -> Result<(), DbError>;
95}
96
97#[derive(Clone)]
99pub(crate) struct SqliteOperationRepository {
100 pool: SqlitePool,
101 timestamp_source: Arc<dyn TimestampSource>,
102}
103
104impl SqliteOperationRepository {
105 pub(crate) fn new(pool: SqlitePool, timestamp_source: Arc<dyn TimestampSource>) -> Self {
107 Self {
108 pool,
109 timestamp_source,
110 }
111 }
112
113 fn now(&self) -> i64 {
115 self.timestamp_source.now_timestamp_seconds()
116 }
117}
118
119struct RequiredBoolValueRow {
121 value: bool,
122}
123
124#[async_trait]
125impl OperationRepository for SqliteOperationRepository {
126 async fn fail_unfinished_session_operations(&self, reason: &str) -> Result<(), DbError> {
127 let now = self.now();
128
129 sqlx::query!(
130 r"
131UPDATE session_operation
132SET status = 'failed',
133 finished_at = ?,
134 heartbeat_at = ?,
135 last_error = ?,
136 cancel_requested = 1
137WHERE status IN ('queued', 'running')
138",
139 now,
140 now,
141 reason
142 )
143 .execute(&self.pool)
144 .await
145 .db_context("fail unfinished session operations")?;
146
147 Ok(())
148 }
149
150 async fn is_cancel_requested_for_operation(&self, operation_id: &str) -> Result<bool, DbError> {
151 let row = sqlx::query_as!(
152 RequiredBoolValueRow,
153 r#"
154SELECT EXISTS(
155 SELECT 1
156 FROM session_operation
157 WHERE id = ?
158 AND cancel_requested = 1
159 AND status IN ('queued', 'running')
160) AS "value!: _"
161"#,
162 operation_id
163 )
164 .fetch_one(&self.pool)
165 .await?;
166
167 Ok(row.value)
168 }
169
170 async fn is_session_operation_unfinished(&self, operation_id: &str) -> Result<bool, DbError> {
171 let row = sqlx::query_as!(
172 RequiredBoolValueRow,
173 r#"
174SELECT EXISTS(
175 SELECT 1
176 FROM session_operation
177 WHERE id = ?
178 AND status IN ('queued', 'running')
179) AS "value!: _"
180"#,
181 operation_id
182 )
183 .fetch_one(&self.pool)
184 .await?;
185
186 Ok(row.value)
187 }
188
189 async fn load_unfinished_session_operations(
190 &self,
191 ) -> Result<Vec<SessionOperationRow>, DbError> {
192 let rows = sqlx::query_as!(
193 SessionOperationRow,
194 r#"
195SELECT id AS "id!", session_id AS "session_id!", kind AS "kind!", status AS "status!",
196 queued_at, started_at, finished_at,
197 heartbeat_at, last_error,
198 cancel_requested AS "cancel_requested: _"
199FROM session_operation
200WHERE status IN ('queued', 'running')
201ORDER BY queued_at ASC, id ASC
202 "#
203 )
204 .fetch_all(&self.pool)
205 .await?;
206 for row in &rows {
207 status::validate_operation(&row.status)?;
208 }
209
210 Ok(rows)
211 }
212
213 async fn mark_session_operation_canceled(
214 &self,
215 operation_id: &str,
216 reason: &str,
217 ) -> Result<(), DbError> {
218 let now = self.now();
219
220 sqlx::query!(
221 r"
222UPDATE session_operation
223SET status = 'canceled',
224 finished_at = ?,
225 heartbeat_at = ?,
226 last_error = ?
227WHERE id = ?
228",
229 now,
230 now,
231 reason,
232 operation_id
233 )
234 .execute(&self.pool)
235 .await?;
236
237 Ok(())
238 }
239
240 async fn mark_session_operation_done(&self, operation_id: &str) -> Result<(), DbError> {
241 let now = self.now();
242
243 sqlx::query!(
244 r"
245UPDATE session_operation
246SET status = 'done',
247 finished_at = ?,
248 heartbeat_at = ?,
249 last_error = NULL
250WHERE id = ?
251",
252 now,
253 now,
254 operation_id
255 )
256 .execute(&self.pool)
257 .await?;
258
259 Ok(())
260 }
261
262 async fn mark_session_operation_failed(
263 &self,
264 operation_id: &str,
265 error: &str,
266 ) -> Result<(), DbError> {
267 let now = self.now();
268
269 sqlx::query!(
270 r"
271UPDATE session_operation
272SET status = 'failed',
273 finished_at = ?,
274 heartbeat_at = ?,
275 last_error = ?
276WHERE id = ?
277",
278 now,
279 now,
280 error,
281 operation_id
282 )
283 .execute(&self.pool)
284 .await?;
285
286 Ok(())
287 }
288
289 async fn mark_session_operation_running(&self, operation_id: &str) -> Result<(), DbError> {
290 let now = self.now();
291
292 sqlx::query!(
293 r"
294UPDATE session_operation
295SET status = 'running',
296 started_at = COALESCE(started_at, ?),
297 heartbeat_at = ?,
298 last_error = NULL
299WHERE id = ?
300",
301 now,
302 now,
303 operation_id
304 )
305 .execute(&self.pool)
306 .await?;
307
308 Ok(())
309 }
310
311 async fn claim_session_operation(
312 &self,
313 operation_id: &str,
314 session_id: &str,
315 kind: &str,
316 ) -> Result<bool, DbError> {
317 let queued_at = self.now();
318
319 let claimed = sqlx::query!(
320 r#"
321INSERT INTO session_operation (id, session_id, kind, status, queued_at)
322VALUES (?, ?, ?, 'queued', ?)
323ON CONFLICT(id) DO UPDATE SET
324 session_id = excluded.session_id,
325 kind = excluded.kind,
326 status = 'queued',
327 queued_at = excluded.queued_at,
328 started_at = NULL,
329 finished_at = NULL,
330 heartbeat_at = NULL,
331 last_error = NULL,
332 cancel_requested = 0
333WHERE session_operation.status IN ('failed', 'canceled')
334RETURNING id AS "id!: String"
335"#,
336 operation_id,
337 session_id,
338 kind,
339 queued_at
340 )
341 .fetch_optional(&self.pool)
342 .await
343 .db_context("claim session operation")?;
344
345 Ok(claimed.is_some())
346 }
347
348 async fn insert_session_operation(
349 &self,
350 operation_id: &str,
351 session_id: &str,
352 kind: &str,
353 ) -> Result<(), DbError> {
354 let queued_at = self.now();
355
356 sqlx::query!(
357 r"
358INSERT INTO session_operation (id, session_id, kind, status, queued_at)
359VALUES (?, ?, ?, 'queued', ?)
360",
361 operation_id,
362 session_id,
363 kind,
364 queued_at
365 )
366 .execute(&self.pool)
367 .await?;
368
369 Ok(())
370 }
371
372 async fn request_cancel_for_session_operations(&self, session_id: &str) -> Result<(), DbError> {
373 sqlx::query!(
374 r"
375UPDATE session_operation
376SET cancel_requested = 1
377WHERE session_id = ?
378 AND status IN ('queued', 'running')
379",
380 session_id
381 )
382 .execute(&self.pool)
383 .await?;
384
385 Ok(())
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use crate::{AppRepositories, DbError};
392
393 #[tokio::test]
394 async fn test_claim_session_operation_recovers_only_terminal_failures() {
397 let database = AppRepositories::in_memory().await.expect("db should open");
399 let project_id = database
400 .projects()
401 .upsert_project("/tmp/operation-project", Some("main".to_string()))
402 .await
403 .expect("failed to insert project");
404 database
405 .sessions()
406 .insert_session("session-a", "gpt-5.6-sol", "main", "Review", project_id)
407 .await
408 .expect("failed to insert session");
409
410 let first_claim = database
412 .operations()
413 .claim_session_operation("rollup-1", "session-a", "reply")
414 .await
415 .expect("failed to claim new operation");
416 let queued_claim = database
417 .operations()
418 .claim_session_operation("rollup-1", "session-a", "reply")
419 .await
420 .expect("failed to inspect queued operation");
421 database
422 .operations()
423 .mark_session_operation_failed("rollup-1", "restart")
424 .await
425 .expect("failed to mark operation failed");
426 let recovered_claim = database
427 .operations()
428 .claim_session_operation("rollup-1", "session-a", "reply")
429 .await
430 .expect("failed to reclaim failed operation");
431 database
432 .operations()
433 .mark_session_operation_done("rollup-1")
434 .await
435 .expect("failed to mark operation done");
436 let done_claim = database
437 .operations()
438 .claim_session_operation("rollup-1", "session-a", "reply")
439 .await
440 .expect("failed to inspect completed operation");
441
442 assert!(first_claim);
444 assert!(!queued_claim);
445 assert!(recovered_claim);
446 assert!(!done_claim);
447 }
448
449 #[tokio::test]
450 async fn recovery_failure_reports_semantic_operation_context() {
451 let (database, pool) = AppRepositories::in_memory_with_pool()
453 .await
454 .expect("db should open");
455 sqlx::query("DROP TABLE session_operation")
456 .execute(&pool)
457 .await
458 .expect("failed to drop operation table");
459
460 let error = database
462 .operations()
463 .fail_unfinished_session_operations("restart")
464 .await
465 .expect_err("recovery should fail without its table");
466
467 assert!(matches!(
469 error,
470 DbError::QueryContext {
471 operation: "fail unfinished session operations",
472 ..
473 }
474 ));
475 }
476}