返回 CodeWhale
persistence_actor.rs
根目录 / crates / tui / src / tui / persistence_actor.rs
1 //! Dedicated persistence actor for session save / checkpoint I/O.
2 //!
3 //! ## Motivation
4 //!
5 //! Before this module, `persist_checkpoint` and `persist_session_snapshot` ran
6 //! synchronously on the tokio worker thread that drives the TUI event loop.
7 //! Each call serialised all API messages to JSON, wrote a temp file, and
8 //! renamed it atomically — blocking keyboard input for the duration.
9 //! `save_session` additionally called `cleanup_old_sessions`, which listed all
10 //! session files, parsed metadata from every one, sorted, and deleted the
11 //! oldest — scaling O(session-bytes + file-count) with every turn.
12 //!
13 //! ## Design
14 //!
15 //! - **One dedicated tokio task** spawned at TUI startup. All disk I/O moves
16 //! to this task. The UI merely `try_send`s a request (non-blocking,
17 //! bounded-channel drop) and returns immediately — keystrokes are never
18 //! gated on write completion.
19 //! - **Latest-wins coalescing per session**: when multiple `SaveCheckpoint`,
20 //! `SessionSnapshot`, or offline-queue requests pile up before the actor's
21 //! next write cycle, only the most recent one per session is written.
22 //! Checkpoints and clears are keyed by session id, so concurrent sessions
23 //! never coalesce into (or clear) each other's slot.
24 //! - **Durability reporting**: every write/removal result is collected; a
25 //! `FlushAndReport` request drains pending work and replies with the
26 //! aggregated results since the last report. Cycles with no listener log
27 //! their failures instead of discarding them.
28 //! - **Unbounded channel** for `try_send` to always succeed; the actor
29 //! naturally backpressures via the spawn pool. A few outstanding
30 //! `SavedSession` values in the channel (< 1 MB) is negligible pressure.
31
32 use std::collections::{BTreeMap, BTreeSet};
33 use std::sync::OnceLock;
34
35 use tokio::sync::{mpsc, oneshot};
36
37 use crate::session_manager::{OfflineQueueState, SavedSession, SessionManager};
38 use crate::utils::spawn_supervised;
39
40 // ---------------------------------------------------------------------------
41 // Request type
42 // ---------------------------------------------------------------------------
43
44 /// Persistence work item sent to the actor.
45 #[derive(Debug)]
46 pub enum PersistRequest {
47 /// Write a crash-recovery checkpoint (in-flight turn state) to the
48 /// session's own file (`checkpoints/<session_id>.json`).
49 SaveCheckpoint { session: SavedSession },
50 /// Write a full session snapshot (completed turn, durable save).
51 SessionSnapshot(SavedSession),
52 /// Write queued/draft offline input for crash recovery.
53 OfflineQueue {
54 state: OfflineQueueState,
55 session_id: Option<String>,
56 },
57 /// Remove the queued/draft offline input file.
58 ClearOfflineQueue,
59 /// Remove one session's crash-recovery checkpoint file. Scoped: cannot
60 /// remove another session's checkpoint.
61 ClearCheckpoint { session_id: String },
62 /// Flush all pending work now and report durability results through
63 /// `reply`. The report aggregates every write/removal result since the
64 /// previous report (including background write cycles) — errors are
65 /// collected and surfaced, never discarded.
66 FlushAndReport { reply: oneshot::Sender<FlushReport> },
67 /// Graceful shutdown — flush pending writes, then exit the actor loop.
68 Shutdown,
69 }
70
71 /// Aggregated durability results: how many writes/removals completed and
72 /// which failed (labelled by what was being persisted, with the I/O error
73 /// kind).
74 #[derive(Debug, Default)]
75 pub struct FlushReport {
76 pub completed: usize,
77 pub failures: Vec<(String, std::io::ErrorKind)>,
78 }
79
80 impl FlushReport {
81 /// Upper bound on retained failure entries when accumulating across
82 /// write cycles. Every failure is logged at the cycle it happened, so
83 /// dropping older-than-bound entries from the reply loses no evidence.
84 const MAX_ACCUMULATED_FAILURES: usize = 256;
85
86 fn merge(&mut self, other: FlushReport) {
87 self.completed += other.completed;
88 self.failures.extend(other.failures);
89 if self.failures.len() > Self::MAX_ACCUMULATED_FAILURES {
90 let excess = self.failures.len() - Self::MAX_ACCUMULATED_FAILURES;
91 self.failures.drain(..excess);
92 }
93 }
94 }
95
96 #[derive(Debug)]
97 enum PendingOfflineQueue {
98 Save {
99 state: Box<OfflineQueueState>,
100 session_id: Option<String>,
101 },
102 Clear,
103 }
104
105 // ---------------------------------------------------------------------------
106 // Handle (held by the TUI)
107 // ---------------------------------------------------------------------------
108
109 type PersistRequestSender = mpsc::UnboundedSender<PersistRequest>;
110 type PersistRequestReceiver = mpsc::UnboundedReceiver<PersistRequest>;
111
112 /// Single construction seam for the production persistence request channel.
113 ///
114 /// The ignored backlog measurement uses this same factory with the receiver
115 /// deliberately paused, so a later bounded-channel change cannot leave the
116 /// baseline measuring an obsolete representation.
117 fn persistence_request_channel() -> (PersistRequestSender, PersistRequestReceiver) {
118 mpsc::unbounded_channel()
119 }
120
121 /// Lightweight handle that the UI holds to queue persistence work.
122 #[derive(Debug, Clone)]
123 pub struct PersistActorHandle {
124 tx: PersistRequestSender,
125 }
126
127 impl PersistActorHandle {
128 /// Queue a persistence request without blocking. If the actor's channel is
129 /// closed (shutdown has already happened), return `false`.
130 pub fn try_send(&self, request: PersistRequest) -> bool {
131 self.tx.send(request).is_ok()
132 }
133 }
134
135 // ---------------------------------------------------------------------------
136 // Global singleton (avoid threading through App)
137 // ---------------------------------------------------------------------------
138
139 static ACTOR_TX: OnceLock<PersistActorHandle> = OnceLock::new();
140
141 /// Initialise the global persistence actor handle. Must be called once at
142 /// startup, before the event loop starts.
143 pub fn init_actor(handle: PersistActorHandle) {
144 let _ = ACTOR_TX.set(handle);
145 }
146
147 /// Queue a persistence request through the global handle. When the request
148 /// cannot be queued — actor not initialised yet (tests, early startup) or
149 /// already shut down — the drop is logged instead of discarded silently, so
150 /// lost session/work-graph state is diagnosable after the fact.
151 pub fn persist(request: PersistRequest) {
152 let label = request_label(&request);
153 if try_persist(request) {
154 return;
155 }
156 if ACTOR_TX.get().is_some() {
157 tracing::warn!(
158 request = label,
159 "persistence request dropped: actor channel is closed (shutdown already happened)"
160 );
161 } else {
162 tracing::debug!(
163 request = label,
164 "persistence request dropped: actor not initialised yet"
165 );
166 }
167 }
168
169 fn request_label(request: &PersistRequest) -> &'static str {
170 match request {
171 PersistRequest::SaveCheckpoint { .. } => "SaveCheckpoint",
172 PersistRequest::SessionSnapshot(_) => "SessionSnapshot",
173 PersistRequest::OfflineQueue { .. } => "OfflineQueue",
174 PersistRequest::ClearOfflineQueue => "ClearOfflineQueue",
175 PersistRequest::ClearCheckpoint { .. } => "ClearCheckpoint",
176 PersistRequest::FlushAndReport { .. } => "FlushAndReport",
177 PersistRequest::Shutdown => "Shutdown",
178 }
179 }
180
181 /// Queue persistence and report whether the actor accepted ownership. Work
182 /// Graph projections use this acknowledgement as their publish boundary.
183 pub fn try_persist(request: PersistRequest) -> bool {
184 ACTOR_TX
185 .get()
186 .is_some_and(|handle| handle.try_send(request))
187 }
188
189 // ---------------------------------------------------------------------------
190 // Actor spawn
191 // ---------------------------------------------------------------------------
192
193 /// Spawn the persistence actor task and return a handle for the caller to
194 /// store and initialise.
195 ///
196 /// The returned handle should be passed to [`init_actor`] so that the
197 /// `persist()` free function can reach it from anywhere in the TUI.
198 pub fn spawn_persistence_actor(
199 manager: SessionManager,
200 ) -> (PersistActorHandle, tokio::task::JoinHandle<()>) {
201 let (tx, mut rx) = persistence_request_channel();
202 let handle = PersistActorHandle { tx };
203
204 let task = spawn_supervised(
205 "persistence-actor",
206 std::panic::Location::caller(),
207 async move {
208 let mut pending = PendingState::default();
209 // Durability results from write cycles that no caller has asked
210 // about yet; drained into the next `FlushAndReport` reply.
211 let mut unreported = FlushReport::default();
212
213 // Flush pending work, log new failures, and fold the cycle's
214 // results into the unreported accumulator.
215 fn flush_cycle(
216 manager: &SessionManager,
217 pending: &mut PendingState,
218 unreported: &mut FlushReport,
219 ) {
220 let cycle = flush_inner(manager, pending);
221 log_flush_failures(&cycle);
222 unreported.merge(cycle);
223 }
224
225 loop {
226 // Drain everything waiting, keeping only the latest of each kind.
227 while let Ok(req) = rx.try_recv() {
228 match pending.absorb(req) {
229 Control::Continue => {}
230 Control::Flush(reply) => {
231 flush_cycle(&manager, &mut pending, &mut unreported);
232 let _ = reply.send(std::mem::take(&mut unreported));
233 }
234 Control::Shutdown => {
235 flush_cycle(&manager, &mut pending, &mut unreported);
236 return;
237 }
238 }
239 }
240
241 // Write coalesced work.
242 flush_cycle(&manager, &mut pending, &mut unreported);
243
244 // Block until the next request arrives.
245 match rx.recv().await {
246 Some(req) => match pending.absorb(req) {
247 Control::Continue => {}
248 Control::Flush(reply) => {
249 flush_cycle(&manager, &mut pending, &mut unreported);
250 let _ = reply.send(std::mem::take(&mut unreported));
251 }
252 Control::Shutdown => {
253 flush_cycle(&manager, &mut pending, &mut unreported);
254 return;
255 }
256 },
257 None => {
258 // Channel closed — final flush and exit.
259 flush_cycle(&manager, &mut pending, &mut unreported);
260 return;
261 }
262 }
263 }
264 },
265 );
266
267 (handle, task)
268 }
269
270 /// Coalesced work waiting for the next write cycle.
271 #[derive(Debug, Default)]
272 struct PendingState {
273 /// Latest-wins per session id. Crash checkpoints are keyed per session
274 /// (mirroring `sessions` below) so concurrent sessions can interleave
275 /// saves and clears without clobbering each other.
276 checkpoints: BTreeMap<String, SavedSession>,
277 /// Session ids whose checkpoint file should be removed.
278 checkpoint_clears: BTreeSet<String>,
279 /// Latest-wins per session id. Coalescing into one global slot can
280 /// drop session A when an immediate `/new` queues session B before
281 /// the actor drains.
282 sessions: BTreeMap<String, SavedSession>,
283 offline_queue: Option<PendingOfflineQueue>,
284 }
285
286 /// What the actor loop should do after absorbing a request.
287 enum Control {
288 Continue,
289 Flush(oneshot::Sender<FlushReport>),
290 Shutdown,
291 }
292
293 impl PendingState {
294 fn absorb(&mut self, req: PersistRequest) -> Control {
295 match req {
296 PersistRequest::SaveCheckpoint { session } => {
297 // Last-writer-wins per session: a fresh checkpoint supersedes
298 // a pending clear for the same session so the two never both
299 // apply in one drain (which previously cleared then re-wrote
300 // the stale checkpoint, undoing the clear).
301 let id = session.metadata.id.clone();
302 self.checkpoint_clears.remove(&id);
303 self.checkpoints.insert(id, session);
304 }
305 PersistRequest::SessionSnapshot(session) => {
306 self.sessions.insert(session.metadata.id.clone(), session);
307 }
308 PersistRequest::OfflineQueue { state, session_id } => {
309 self.offline_queue = Some(PendingOfflineQueue::Save {
310 state: Box::new(state),
311 session_id,
312 });
313 }
314 PersistRequest::ClearOfflineQueue => {
315 self.offline_queue = Some(PendingOfflineQueue::Clear);
316 }
317 PersistRequest::ClearCheckpoint { session_id } => {
318 // A clear supersedes a pending checkpoint write for the same
319 // session only — other sessions' pending work is untouched.
320 self.checkpoints.remove(&session_id);
321 self.checkpoint_clears.insert(session_id);
322 }
323 PersistRequest::FlushAndReport { reply } => return Control::Flush(reply),
324 PersistRequest::Shutdown => return Control::Shutdown,
325 }
326 Control::Continue
327 }
328 }
329
330 /// Write all pending work to disk, draining `pending`. Every write and
331 /// removal result is collected into the returned [`FlushReport`] — failures
332 /// are reported, never silently discarded.
333 fn flush_inner(manager: &SessionManager, pending: &mut PendingState) -> FlushReport {
334 let mut report = FlushReport::default();
335 let mut record = |what: String, result: std::io::Result<()>| match result {
336 Ok(()) => report.completed += 1,
337 Err(err) => report.failures.push((what, err.kind())),
338 };
339
340 for session_id in std::mem::take(&mut pending.checkpoint_clears) {
341 record(
342 format!("clear-checkpoint:{session_id}"),
343 manager.clear_session_checkpoint(&session_id),
344 );
345 }
346 for (session_id, session) in std::mem::take(&mut pending.checkpoints) {
347 record(
348 format!("checkpoint:{session_id}"),
349 manager.save_checkpoint(&session).map(|_| ()),
350 );
351 }
352 for (session_id, session) in std::mem::take(&mut pending.sessions) {
353 record(
354 format!("session:{session_id}"),
355 manager.save_session(&session).map(|_| ()),
356 );
357 }
358 if let Some(request) = pending.offline_queue.take() {
359 match request {
360 PendingOfflineQueue::Save { state, session_id } => record(
361 "offline-queue".to_string(),
362 manager
363 .save_offline_queue_state(&state, session_id.as_deref())
364 .map(|_| ()),
365 ),
366 PendingOfflineQueue::Clear => record(
367 "clear-offline-queue".to_string(),
368 manager.clear_offline_queue_state(),
369 ),
370 }
371 }
372 report
373 }
374
375 /// Surface flush failures in the log for write cycles that have no caller
376 /// waiting on a [`FlushReport`].
377 fn log_flush_failures(report: &FlushReport) {
378 for (what, kind) in &report.failures {
379 tracing::warn!(
380 target: "persistence",
381 what = %what,
382 error_kind = ?kind,
383 "persistence write failed",
384 );
385 }
386 }
387
388 #[cfg(test)]
389 #[path = "persistence_actor/tests.rs"]
390 mod backlog_measurement_tests;
391
392 #[cfg(test)]
393 mod tests {
394 use super::*;
395 use std::time::Duration;
396
397 use crate::session_manager::{OfflineQueueState, QueuedSessionMessage};
398
399 async fn wait_until(mut predicate: impl FnMut() -> bool) {
400 let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
401 loop {
402 if predicate() {
403 return;
404 }
405 assert!(
406 tokio::time::Instant::now() < deadline,
407 "timed out waiting for persistence actor"
408 );
409 tokio::time::sleep(Duration::from_millis(10)).await;
410 }
411 }
412
413 #[tokio::test]
414 async fn actor_persists_and_clears_offline_queue_requests() {
415 let tmp = tempfile::tempdir().expect("tempdir");
416 let sessions_dir = tmp.path().join("sessions");
417 let manager = SessionManager::new(sessions_dir.clone()).expect("manager");
418 let queue_path = sessions_dir.join("checkpoints").join("offline_queue.json");
419 let (handle, task) = spawn_persistence_actor(manager);
420
421 let state = OfflineQueueState {
422 messages: vec![QueuedSessionMessage {
423 display: "queued from enter".to_string(),
424 skill_instruction: None,
425 skill_provenance: None,
426 }],
427 ..OfflineQueueState::default()
428 };
429
430 handle.try_send(PersistRequest::OfflineQueue {
431 state,
432 session_id: Some("session-A".to_string()),
433 });
434 wait_until(|| {
435 std::fs::read_to_string(&queue_path)
436 .is_ok_and(|body| body.contains("queued from enter"))
437 })
438 .await;
439
440 handle.try_send(PersistRequest::ClearOfflineQueue);
441 wait_until(|| !queue_path.exists()).await;
442 handle.try_send(PersistRequest::Shutdown);
443 task.await.expect("persistence actor join");
444 }
445
446 #[tokio::test]
447 async fn shutdown_wait_flushes_queued_session_before_returning() {
448 let tmp = tempfile::tempdir().expect("tempdir");
449 let sessions_dir = tmp.path().join("sessions");
450 let manager = SessionManager::new(sessions_dir.clone()).expect("manager");
451 let verification_manager = SessionManager::new(sessions_dir).expect("verification manager");
452 let session = crate::session_manager::create_saved_session_with_mode(
453 &[],
454 "deepseek-v4-pro",
455 tmp.path(),
456 0,
457 None,
458 Some("agent"),
459 );
460 let session_id = session.metadata.id.clone();
461 let (handle, task) = spawn_persistence_actor(manager);
462
463 handle.try_send(PersistRequest::SessionSnapshot(session));
464 handle.try_send(PersistRequest::Shutdown);
465 task.await.expect("persistence actor join");
466
467 let loaded = verification_manager
468 .load_session(&session_id)
469 .expect("shutdown must flush queued session");
470 assert_eq!(loaded.metadata.id, session_id);
471 }
472
473 #[tokio::test]
474 async fn shutdown_flushes_latest_snapshot_for_each_session_id() {
475 let tmp = tempfile::tempdir().expect("tempdir");
476 let sessions_dir = tmp.path().join("sessions");
477 let manager = SessionManager::new(sessions_dir.clone()).expect("manager");
478 let verification_manager = SessionManager::new(sessions_dir).expect("verification manager");
479 let mut first = crate::session_manager::create_saved_session_with_mode(
480 &[],
481 "deepseek-v4-pro",
482 tmp.path(),
483 0,
484 None,
485 Some("agent"),
486 );
487 first.metadata.title = "Session A".to_string();
488 let mut second = crate::session_manager::create_saved_session_with_mode(
489 &[],
490 "deepseek-v4-pro",
491 tmp.path(),
492 0,
493 None,
494 Some("agent"),
495 );
496 second.metadata.title = "Session B".to_string();
497 let first_id = first.metadata.id.clone();
498 let second_id = second.metadata.id.clone();
499 let (handle, task) = spawn_persistence_actor(manager);
500
501 handle.try_send(PersistRequest::SessionSnapshot(first));
502 handle.try_send(PersistRequest::SessionSnapshot(second));
503 handle.try_send(PersistRequest::Shutdown);
504 task.await.expect("persistence actor join");
505
506 assert_eq!(
507 verification_manager
508 .load_session(&first_id)
509 .expect("session A flushed")
510 .metadata
511 .title,
512 "Session A"
513 );
514 assert_eq!(
515 verification_manager
516 .load_session(&second_id)
517 .expect("session B flushed")
518 .metadata
519 .title,
520 "Session B"
521 );
522 }
523
524 #[tokio::test]
525 async fn interleaved_checkpoint_saves_and_clears_stay_per_session() {
526 let tmp = tempfile::tempdir().expect("tempdir");
527 let sessions_dir = tmp.path().join("sessions");
528 let manager = SessionManager::new(sessions_dir.clone()).expect("manager");
529 let verification_manager = SessionManager::new(sessions_dir).expect("verification manager");
530 let first = crate::session_manager::create_saved_session_with_mode(
531 &[],
532 "deepseek-v4-pro",
533 tmp.path(),
534 0,
535 None,
536 Some("agent"),
537 );
538 let second = crate::session_manager::create_saved_session_with_mode(
539 &[],
540 "deepseek-v4-pro",
541 tmp.path(),
542 0,
543 None,
544 Some("agent"),
545 );
546 let first_id = first.metadata.id.clone();
547 let second_id = second.metadata.id.clone();
548 let (handle, task) = spawn_persistence_actor(manager);
549
550 // Interleave: save A, save B, clear A — all coalesced into one drain.
551 handle.try_send(PersistRequest::SaveCheckpoint { session: first });
552 handle.try_send(PersistRequest::SaveCheckpoint { session: second });
553 handle.try_send(PersistRequest::ClearCheckpoint {
554 session_id: first_id.clone(),
555 });
556 handle.try_send(PersistRequest::Shutdown);
557 task.await.expect("persistence actor join");
558
559 assert!(
560 verification_manager
561 .load_session_checkpoint(&first_id)
562 .expect("load first checkpoint")
563 .is_none(),
564 "cleared session must have no checkpoint file"
565 );
566 let survivor = verification_manager
567 .load_session_checkpoint(&second_id)
568 .expect("load second checkpoint")
569 .expect("second session's checkpoint must survive an unrelated clear");
570 assert_eq!(survivor.metadata.id, second_id);
571 }
572
573 #[tokio::test]
574 async fn flush_and_report_returns_completed_counts() {
575 let tmp = tempfile::tempdir().expect("tempdir");
576 let sessions_dir = tmp.path().join("sessions");
577 let manager = SessionManager::new(sessions_dir).expect("manager");
578 let session = crate::session_manager::create_saved_session_with_mode(
579 &[],
580 "deepseek-v4-pro",
581 tmp.path(),
582 0,
583 None,
584 Some("agent"),
585 );
586 let (handle, task) = spawn_persistence_actor(manager);
587
588 handle.try_send(PersistRequest::SaveCheckpoint { session });
589 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
590 handle.try_send(PersistRequest::FlushAndReport { reply: reply_tx });
591 let report = reply_rx.await.expect("flush report reply");
592 // Whether the checkpoint was written by an earlier background cycle
593 // or by this flush, the accumulated report must count it and show no
594 // failures — and the actor keeps running afterwards.
595 assert!(report.completed >= 1, "checkpoint write must be counted");
596 assert!(report.failures.is_empty(), "no failures expected");
597 handle.try_send(PersistRequest::Shutdown);
598 task.await.expect("persistence actor join");
599 }
600
601 #[tokio::test]
602 async fn flush_and_report_propagates_write_failures() {
603 let tmp = tempfile::tempdir().expect("tempdir");
604 let sessions_dir = tmp.path().join("sessions");
605 let manager = SessionManager::new(sessions_dir.clone()).expect("manager");
606 // Occupy the checkpoints directory path with a regular file so every
607 // checkpoint write deterministically fails on all platforms.
608 std::fs::write(sessions_dir.join("checkpoints"), b"not a directory")
609 .expect("block checkpoints dir");
610 let session = crate::session_manager::create_saved_session_with_mode(
611 &[],
612 "deepseek-v4-pro",
613 tmp.path(),
614 0,
615 None,
616 Some("agent"),
617 );
618 let session_id = session.metadata.id.clone();
619 let (handle, task) = spawn_persistence_actor(manager);
620
621 handle.try_send(PersistRequest::SaveCheckpoint { session });
622 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
623 handle.try_send(PersistRequest::FlushAndReport { reply: reply_tx });
624 let report = reply_rx.await.expect("flush report reply");
625
626 assert!(
627 report
628 .failures
629 .iter()
630 .any(|(what, _)| what == &format!("checkpoint:{session_id}")),
631 "failed checkpoint write must be reported, got: {:?}",
632 report.failures
633 );
634 handle.try_send(PersistRequest::Shutdown);
635 task.await.expect("persistence actor join");
636 }
637 }
638
638 lines RUST