返回 DeepSeek-TUI-2026
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**: when multiple `Checkpoint` or
20 //! `SessionSnapshot` requests pile up before the actor's next write cycle,
21 //! only the most recent one is written. `ClearCheckpoint` requests
22 //! accumulate normally (they're cheap and commutative).
23 //! - **Unbounded channel** for `try_send` to always succeed; the actor
24 //! naturally backpressures via the spawn pool. A few outstanding
25 //! `SavedSession` values in the channel (< 1 MB) is negligible pressure.
26
27 use std::sync::OnceLock;
28
29 use tokio::sync::mpsc;
30
31 use crate::session_manager::{SavedSession, SessionManager};
32 use crate::utils::spawn_supervised;
33
34 // ---------------------------------------------------------------------------
35 // Request type
36 // ---------------------------------------------------------------------------
37
38 /// Persistence work item sent to the actor.
39 #[derive(Debug)]
40 pub enum PersistRequest {
41 /// Write a crash-recovery checkpoint (in-flight turn state).
42 Checkpoint(SavedSession),
43 /// Write a full session snapshot (completed turn, durable save).
44 SessionSnapshot(SavedSession),
45 /// Remove the crash-recovery checkpoint file.
46 ClearCheckpoint,
47 /// Graceful shutdown — flush pending writes, then exit the actor loop.
48 Shutdown,
49 }
50
51 // ---------------------------------------------------------------------------
52 // Handle (held by the TUI)
53 // ---------------------------------------------------------------------------
54
55 /// Lightweight handle that the UI holds to queue persistence work.
56 #[derive(Debug, Clone)]
57 pub struct PersistActorHandle {
58 tx: mpsc::UnboundedSender<PersistRequest>,
59 }
60
61 impl PersistActorHandle {
62 /// Queue a persistence request without blocking. If the actor's channel is
63 /// closed (shutdown has already happened) the request is silently dropped.
64 pub fn try_send(&self, request: PersistRequest) {
65 let _ = self.tx.send(request);
66 }
67 }
68
69 // ---------------------------------------------------------------------------
70 // Global singleton (avoid threading through App)
71 // ---------------------------------------------------------------------------
72
73 static ACTOR_TX: OnceLock<PersistActorHandle> = OnceLock::new();
74
75 /// Initialise the global persistence actor handle. Must be called once at
76 /// startup, before the event loop starts.
77 pub fn init_actor(handle: PersistActorHandle) {
78 let _ = ACTOR_TX.set(handle);
79 }
80
81 /// Queue a persistence request through the global handle. No-op (silently
82 /// ignored) when the actor hasn't been initialised yet — this can happen in
83 /// tests or early startup before the actor is ready.
84 pub fn persist(request: PersistRequest) {
85 if let Some(handle) = ACTOR_TX.get() {
86 handle.try_send(request);
87 }
88 }
89
90 // ---------------------------------------------------------------------------
91 // Actor spawn
92 // ---------------------------------------------------------------------------
93
94 /// Spawn the persistence actor task and return a handle for the caller to
95 /// store and initialise.
96 ///
97 /// The returned handle should be passed to [`init_actor`] so that the
98 /// `persist()` free function can reach it from anywhere in the TUI.
99 pub fn spawn_persistence_actor(manager: SessionManager) -> PersistActorHandle {
100 let (tx, mut rx) = mpsc::unbounded_channel::<PersistRequest>();
101 let handle = PersistActorHandle { tx };
102
103 spawn_supervised(
104 "persistence-actor",
105 std::panic::Location::caller(),
106 async move {
107 let mut latest_checkpoint: Option<SavedSession> = None;
108 let mut latest_session: Option<SavedSession> = None;
109 let mut should_clear: bool = false;
110
111 loop {
112 // Drain everything waiting, keeping only the latest of each kind.
113 while let Ok(req) = rx.try_recv() {
114 match req {
115 PersistRequest::Checkpoint(session) => {
116 latest_checkpoint = Some(session);
117 }
118 PersistRequest::SessionSnapshot(session) => {
119 latest_session = Some(session);
120 }
121 PersistRequest::ClearCheckpoint => {
122 should_clear = true;
123 }
124 PersistRequest::Shutdown => {
125 flush_inner(
126 &manager,
127 latest_checkpoint.as_ref(),
128 latest_session.as_ref(),
129 should_clear,
130 );
131 return;
132 }
133 }
134 }
135
136 // Write coalesced work.
137 if should_clear {
138 let _ = manager.clear_checkpoint();
139 should_clear = false;
140 }
141 if let Some(ref session) = latest_checkpoint.take() {
142 let _ = manager.save_checkpoint(session);
143 }
144 if let Some(ref session) = latest_session.take() {
145 let _ = manager.save_session(session);
146 }
147
148 // Block until the next request arrives.
149 match rx.recv().await {
150 Some(PersistRequest::Checkpoint(session)) => {
151 latest_checkpoint = Some(session);
152 }
153 Some(PersistRequest::SessionSnapshot(session)) => {
154 latest_session = Some(session);
155 }
156 Some(PersistRequest::ClearCheckpoint) => {
157 should_clear = true;
158 }
159 Some(PersistRequest::Shutdown) => {
160 flush_inner(
161 &manager,
162 latest_checkpoint.as_ref(),
163 latest_session.as_ref(),
164 should_clear,
165 );
166 return;
167 }
168 None => {
169 // Channel closed — final flush and exit.
170 flush_inner(
171 &manager,
172 latest_checkpoint.as_ref(),
173 latest_session.as_ref(),
174 should_clear,
175 );
176 return;
177 }
178 }
179 }
180 },
181 );
182
183 handle
184 }
185
186 /// Write any pending work to disk (used on shutdown).
187 fn flush_inner(
188 manager: &SessionManager,
189 checkpoint: Option<&SavedSession>,
190 session: Option<&SavedSession>,
191 should_clear: bool,
192 ) {
193 if should_clear {
194 let _ = manager.clear_checkpoint();
195 }
196 if let Some(s) = checkpoint {
197 let _ = manager.save_checkpoint(s);
198 }
199 if let Some(s) = session {
200 let _ = manager.save_session(s);
201 }
202 }
203
203 lines RUST