Skip to main content

agentmux_srv/backend/storage/filestore/
core.rs

1// Copyright 2025, Command Line Inc.
2// Copyright 2025-2026, AgentMux Corp.
3// SPDX-License-Identifier: Apache-2.0
4
5//! FileStore struct and CRUD operations.
6
7
8use std::collections::HashMap;
9use std::path::Path;
10use std::sync::{Arc, Mutex};
11
12use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior};
13
14use super::cache::CacheEntry;
15use super::counter::{new_gen, AppendMode, AppendPos, DROP_EPOCH_SQL};
16use super::lines::count_all;
17use super::types::{FileMeta, FileOpts, MuxFile};
18use crate::backend::storage::error::StoreError;
19use crate::backend::storage::migrations::{
20    check_schema_compat, run_filestore_migrations, stamp_version, FILESTORE_SCHEMA_VERSION,
21};
22
23/// Default part size: 64KB (matches Go's DefaultPartDataSize).
24pub(super) const PART_DATA_SIZE: usize = 64 * 1024;
25
26/// Default flush interval in seconds.
27#[allow(dead_code)]
28pub const DEFAULT_FLUSH_SECS: u64 = 5;
29
30/// Clean cache entries idle longer than this are evicted during flush.
31#[allow(dead_code)]
32pub const CACHE_TTL_SECS: u64 = 60;
33
34/// Hard cap on the total byte size held in the metadata cache (128 MB).
35/// When this is exceeded, LRU eviction removes the oldest entries first.
36pub const MAX_CACHE_BYTES: usize = 128 * 1024 * 1024;
37
38/// Waiting this long for the connection, or holding it this long, is logged
39/// with the call site (`FileStore::lock_conn`).
40const SLOW_CONN: std::time::Duration = std::time::Duration::from_millis(500);
41
42/// The store's connection, held: logs on drop when it was held for
43/// `SLOW_CONN` or longer, naming where it was taken. Every store operation in
44/// the process waits for this one lock — on the global transcript store, every
45/// agent's appends and every pane's history reads — so a long hold stalls
46/// them all (REPORT_AGENT_OPEN_STALL_RCA_2026_10_05.md).
47pub(crate) struct TimedConn<'a> {
48    guard: std::sync::MutexGuard<'a, Connection>,
49    site: &'static std::panic::Location<'static>,
50    got: std::time::Instant,
51}
52
53impl std::ops::Deref for TimedConn<'_> {
54    type Target = Connection;
55    fn deref(&self) -> &Connection {
56        &self.guard
57    }
58}
59
60impl std::ops::DerefMut for TimedConn<'_> {
61    fn deref_mut(&mut self) -> &mut Connection {
62        &mut self.guard
63    }
64}
65
66impl Drop for TimedConn<'_> {
67    fn drop(&mut self) {
68        let held = self.got.elapsed();
69        if held >= SLOW_CONN {
70            tracing::warn!(
71                site = %self.site,
72                held_ms = held.as_millis() as u64,
73                "filestore: connection held a long time"
74            );
75        }
76    }
77}
78
79/// Connections that only read, for a file-backed store. In WAL mode a reader
80/// sees every committed write and never waits for a writer, so reads taken
81/// through these don't queue behind the writer's mutex — which, on the global
82/// transcript store, every agent's appends share, and which a write holds while
83/// it waits up to `busy_timeout` for another srv instance's write lock
84/// (REPORT_AGENT_OPEN_STALL_RCA_2026_10_05.md). Opened on demand and kept for
85/// reuse, at most `READERS_KEPT` idle.
86pub(super) struct ReaderPool {
87    path: std::path::PathBuf,
88    idle: Mutex<Vec<Connection>>,
89}
90
91/// Idle reader connections kept for reuse; more may be open at a busy moment.
92const READERS_KEPT: usize = 4;
93
94impl ReaderPool {
95    fn new(path: &Path) -> Self {
96        Self { path: path.to_path_buf(), idle: Mutex::new(Vec::new()) }
97    }
98
99    fn take(&self) -> Result<Connection, StoreError> {
100        if let Some(conn) = self.idle.lock().unwrap().pop() {
101            return Ok(conn);
102        }
103        // Not SQLITE_OPEN_READ_ONLY: a read-only connection can't create the
104        // WAL index if it's missing. `query_only` refuses writes instead.
105        let conn = Connection::open_with_flags(
106            &self.path,
107            rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
108        )?;
109        conn.execute_batch("PRAGMA busy_timeout=5000; PRAGMA query_only=1;")?;
110        Ok(conn)
111    }
112
113    fn give_back(&self, conn: Connection) {
114        let mut idle = self.idle.lock().unwrap();
115        if idle.len() < READERS_KEPT {
116            idle.push(conn);
117        }
118    }
119}
120
121/// SQLite-backed file storage with write-through cache.
122pub struct FileStore {
123    pub(super) conn: Mutex<Connection>,
124    pub(super) cache: Mutex<HashMap<(String, String), CacheEntry>>,
125    /// Total bytes currently accounted for across all cache entries.
126    pub(super) cache_total_bytes: Mutex<usize>,
127    /// Maximum bytes the cache may hold before LRU eviction kicks in.
128    pub(super) cache_max_bytes: usize,
129    /// Background WAL checkpoints for a file-backed store (checkpointer.rs);
130    /// `None` in memory, or if it couldn't start (SQLite's automatic
131    /// checkpoints stay on then).
132    pub(super) _checkpointer: Option<super::checkpointer::Checkpointer>,
133    /// Read-only connections for a file-backed store (`ReaderPool`); `None` in
134    /// memory, where every connection is its own database.
135    pub(super) readers: Option<ReaderPool>,
136}
137
138impl FileStore {
139    /// Open a FileStore backed by a file on disk.
140    pub fn open(path: &Path) -> Result<Self, StoreError> {
141        let conn = Connection::open(path)?;
142        let mut store = Self::configure_and_migrate(conn)?;
143        // Checkpoints off the write path: a thread with its own connection
144        // runs them, and this connection stops running them inside whichever
145        // write crosses the threshold (checkpointer.rs). Only if the thread
146        // started — otherwise SQLite's automatic checkpoints stay.
147        if let Some(checkpointer) = super::checkpointer::Checkpointer::start(path) {
148            store.conn.lock().unwrap().execute_batch("PRAGMA wal_autocheckpoint=0;")?;
149            store._checkpointer = Some(checkpointer);
150        }
151        store.readers = Some(ReaderPool::new(path));
152        Ok(store)
153    }
154
155    /// Open an existing filestore **read-only** — the `Store::open_read_only`
156    /// twin: `SQLITE_OPEN_READ_ONLY`, no pragma that writes, no migrations, no
157    /// version stamp. For doctor checks that need `stat`/`read_file` against
158    /// a filestore they must not touch (codex P2 on #3070). Fails if the file
159    /// does not exist.
160    pub fn open_read_only(path: &Path) -> Result<Self, StoreError> {
161        let conn = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
162        conn.execute_batch("PRAGMA busy_timeout=5000;")?;
163        Ok(Self {
164            conn: Mutex::new(conn),
165            cache: Mutex::new(HashMap::new()),
166            cache_total_bytes: Mutex::new(0),
167            cache_max_bytes: MAX_CACHE_BYTES,
168            _checkpointer: None,
169            readers: None,
170        })
171    }
172
173    /// Open an in-memory FileStore for testing.
174    #[allow(dead_code)]
175    pub fn open_in_memory() -> Result<Self, StoreError> {
176        let conn = Connection::open_in_memory()?;
177        Self::configure_and_migrate(conn)
178    }
179
180    /// Open an in-memory FileStore with a custom LRU byte cap.  Used in tests.
181    #[allow(dead_code)]
182    pub fn open_in_memory_with_cap(max_bytes: usize) -> Result<Self, StoreError> {
183        let conn = Connection::open_in_memory()?;
184        let mut store = Self::configure_and_migrate(conn)?;
185        store.cache_max_bytes = max_bytes;
186        Ok(store)
187    }
188
189    /// The connection, logging a wait or a hold of `SLOW_CONN` or more with
190    /// the caller's location. `#[track_caller]` on the store methods that take
191    /// it carries the site out to the code that called the store.
192    #[track_caller]
193    pub(crate) fn lock_conn(&self) -> TimedConn<'_> {
194        let site = std::panic::Location::caller();
195        let asked = std::time::Instant::now();
196        let guard = self.conn.lock().unwrap();
197        let waited = asked.elapsed();
198        if waited >= SLOW_CONN {
199            tracing::warn!(site = %site, waited_ms = waited.as_millis() as u64, "filestore: slow to get the connection");
200        }
201        TimedConn { guard, site, got: std::time::Instant::now() }
202    }
203
204    /// Raw connection access for tests that need to force a specific
205    /// failure mode (e.g. dropping a table) — mirrors `Store::conn()`.
206    #[cfg(test)]
207    pub(crate) fn conn(&self) -> &Mutex<Connection> {
208        &self.conn
209    }
210
211    /// Run `PRAGMA wal_checkpoint(TRUNCATE)` on the filestore connection.
212    /// Same semantics as `Store::checkpoint` — 5s busy_timeout, partial
213    /// truncate on contention is safe.
214    #[track_caller]
215    pub fn checkpoint(&self) -> Result<(), StoreError> {
216        self.lock_conn().execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
217        Ok(())
218    }
219
220    fn configure_and_migrate(mut conn: Connection) -> Result<Self, StoreError> {
221        // The global transcript store is opened by every srv instance on the
222        // machine, possibly at the same moment. busy_timeout comes first so
223        // every lock below waits instead of failing.
224        conn.execute_batch("PRAGMA busy_timeout=5000;")?;
225        // Switching a rollback-journal database (new, or created by an old
226        // build) to WAL upgrades a read lock to an exclusive one. Two
227        // connections doing that at once deadlock, and SQLite fails one at
228        // once with SQLITE_BUSY rather than calling the busy handler, so
229        // retry. It can't run inside a transaction.
230        const WAL_ATTEMPTS: u32 = 100;
231        for attempt in 1..=WAL_ATTEMPTS {
232            match conn.execute_batch("PRAGMA journal_mode=WAL;") {
233                Ok(()) => break,
234                Err(rusqlite::Error::SqliteFailure(e, _))
235                    if e.code == rusqlite::ErrorCode::DatabaseBusy && attempt < WAL_ATTEMPTS =>
236                {
237                    std::thread::sleep(std::time::Duration::from_millis(20));
238                }
239                Err(e) => return Err(e.into()),
240            }
241        }
242        // WAL + NORMAL, as the object store and saga log already are: a commit
243        // no longer waits for an fsync (the WAL is synced at checkpoints), so a
244        // transcript event — which waits for its writes since 5a-3 — isn't held
245        // ~2.5 ms per commit on Windows. It cannot corrupt the database; an OS
246        // crash or power cut (not an app crash) can lose the last commits.
247        // Chosen by the user over full fsync durability (2026-09-24, measured
248        // in SPEC_AGENT_PANE_BOUNDED_LIVE_WINDOW_MIGRATION_2026_09_23.md §6.3.7).
249        conn.execute_batch("PRAGMA synchronous=NORMAL;")?;
250        // Safety lock BEFORE migrations — same discipline as mstore /
251        // sagas: refuse to touch a newer-schema DB on disk before any
252        // mutating step runs. See `check_schema_compat` doc. All three in
253        // one IMMEDIATE transaction: it takes the write lock up front (with
254        // the busy handler), so a concurrent opener's migration can't
255        // deadlock with this one on a lock upgrade.
256        {
257            let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
258            check_schema_compat(&tx, FILESTORE_SCHEMA_VERSION, "filestore.db")?;
259            run_filestore_migrations(&tx)?;
260            stamp_version(&tx, FILESTORE_SCHEMA_VERSION)?;
261            tx.commit()?;
262        }
263        Ok(Self {
264            conn: Mutex::new(conn),
265            cache: Mutex::new(HashMap::new()),
266            cache_total_bytes: Mutex::new(0),
267            cache_max_bytes: MAX_CACHE_BYTES,
268            _checkpointer: None,
269            readers: None,
270        })
271    }
272
273    /// Run `f` as one `BEGIN IMMEDIATE` transaction: committed if it returns
274    /// `Ok`, rolled back otherwise. Every mutation goes through here.
275    ///
276    /// The in-process mutex only serializes this process. The global
277    /// transcript store is one database file opened by every srv instance on
278    /// the machine, so a multi-statement write (read the size, write the parts,
279    /// update the size) outside a transaction can interleave with another
280    /// process's and overwrite its bytes, and a failure part-way leaves a torn
281    /// file. `IMMEDIATE` takes the write lock up front, so reads inside `f` see
282    /// the state `f` writes on top of; `busy_timeout` makes another process
283    /// wait rather than fail. Callers update the in-process cache only after
284    /// this returns `Ok`.
285    #[track_caller]
286    pub(super) fn write_txn<T>(
287        &self,
288        f: impl FnOnce(&Transaction<'_>) -> Result<T, StoreError>,
289    ) -> Result<T, StoreError> {
290        let mut conn = self.lock_conn();
291        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
292        let out = f(&tx)?;
293        tx.commit()?;
294        Ok(out)
295    }
296
297    /// Run `f` as one read transaction: in WAL mode every read inside it sees
298    /// one consistent snapshot of the database, however other connections
299    /// write meanwhile. For a decision that combines several reads (a size, a
300    /// header, a label) that must describe the same moment.
301    #[track_caller]
302    pub(super) fn read_txn<T>(
303        &self,
304        f: impl FnOnce(&Transaction<'_>) -> Result<T, StoreError>,
305    ) -> Result<T, StoreError> {
306        if let Some(pool) = &self.readers {
307            let mut conn = pool.take()?;
308            let out = {
309                let tx = conn.transaction_with_behavior(TransactionBehavior::Deferred)?;
310                let out = f(&tx)?;
311                tx.commit()?;
312                out
313            };
314            pool.give_back(conn);
315            return Ok(out);
316        }
317        let mut conn = self.lock_conn();
318        let tx = conn.transaction_with_behavior(TransactionBehavior::Deferred)?;
319        let out = f(&tx)?;
320        tx.commit()?;
321        Ok(out)
322    }
323
324    /// Run a read on its own connection: a pooled reader for a file-backed
325    /// store (`ReaderPool`), the store's connection otherwise. For a single
326    /// read that needs no snapshot across statements; several reads that must
327    /// agree go through [`Self::read_txn`].
328    #[track_caller]
329    pub(super) fn read_conn<T>(&self, f: impl FnOnce(&Connection) -> Result<T, StoreError>) -> Result<T, StoreError> {
330        if let Some(pool) = &self.readers {
331            let conn = pool.take()?;
332            let out = f(&conn)?;
333            pool.give_back(conn);
334            return Ok(out);
335        }
336        let conn = self.lock_conn();
337        f(&conn)
338    }
339
340    /// Evict the least-recently-used cache entries until `cache_total_bytes <= cache_max_bytes`.
341    /// Must be called with *neither* `cache` nor `cache_total_bytes` lock held.
342    pub(super) fn evict_to_cap(&self) {
343        // Fast path: check total without evicting.
344        let total = *self.cache_total_bytes.lock().unwrap();
345        if total <= self.cache_max_bytes {
346            return;
347        }
348
349        // Collect (last_access_ms, key, size) for all entries, sort oldest-first.
350        let candidates: Vec<(i64, (String, String), usize)> = {
351            let cache = self.cache.lock().unwrap();
352            cache
353                .iter()
354                .map(|(k, e)| (e.last_access_ms, k.clone(), e.cached_size_bytes))
355                .collect()
356        };
357
358        // Sort by last_access_ms ascending (oldest first).
359        let mut candidates = candidates;
360        candidates.sort_by_key(|(ts, _, _)| *ts);
361
362        let mut evicted_count = 0usize;
363        let mut evicted_bytes = 0usize;
364
365        for (_, key, size) in candidates {
366            {
367                let total = *self.cache_total_bytes.lock().unwrap();
368                if total <= self.cache_max_bytes {
369                    break;
370                }
371            }
372            {
373                let mut cache = self.cache.lock().unwrap();
374                if cache.remove(&key).is_some() {
375                    let mut total = self.cache_total_bytes.lock().unwrap();
376                    *total = total.saturating_sub(size);
377                    evicted_count += 1;
378                    evicted_bytes += size;
379                }
380            }
381        }
382
383        if evicted_count > 0 {
384            tracing::debug!(
385                "filestore lru: evicted {} entries, freed {} bytes (cap={})",
386                evicted_count,
387                evicted_bytes,
388                self.cache_max_bytes,
389            );
390        }
391    }
392
393    /// Create a new file. Fails if file already exists.
394    #[allow(dead_code)]
395    #[track_caller]
396    pub fn make_file(
397        &self,
398        zone_id: &str,
399        name: &str,
400        meta: FileMeta,
401        opts: FileOpts,
402    ) -> Result<(), StoreError> {
403        let now = agentmux_common::time::now_ms();
404        let file = MuxFile {
405            zoneid: zone_id.to_string(),
406            name: name.to_string(),
407            size: 0,
408            createdts: now,
409            modts: now,
410            opts,
411            meta,
412        };
413
414        let opts_json = serde_json::to_string(&file.opts)?;
415        let meta_json = serde_json::to_string(&file.meta)?;
416        // Check and insert in one transaction: two processes creating the same
417        // file must see one success and one AlreadyExists, not a raw
418        // primary-key error (which callers treat as fatal and skip the write).
419        self.write_txn(|tx| {
420            let exists = tx
421                .query_row(
422                    "SELECT 1 FROM db_wave_file WHERE zoneid = ?1 AND name = ?2",
423                    params![zone_id, name],
424                    |_| Ok(()),
425                )
426                .optional()?
427                .is_some();
428            if exists {
429                return Err(StoreError::AlreadyExists);
430            }
431            // A new, empty file starts a counted epoch (counter.rs).
432            tx.execute(
433                "INSERT INTO db_wave_file (zoneid, name, size, createdts, modts, opts, meta,
434                     gen, lines, lines_size, lines_tail, lines_modts, lines_rev)
435                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0, 0, 0, ?5, 0)",
436                params![file.zoneid, file.name, file.size, file.createdts, file.modts, opts_json, meta_json, new_gen()],
437            )?;
438            Ok(())
439        })?;
440
441        // Add to cache
442        let key = (zone_id.to_string(), name.to_string());
443        let entry = CacheEntry {
444            file: Some(file),
445            data_entries: HashMap::new(),
446            dirty: false,
447            last_access_ms: now,
448            cached_size_bytes: 64, // new file is size=0; charge minimum overhead
449        };
450        {
451            let mut cache = self.cache.lock().unwrap();
452            cache.insert(key, entry);
453            *self.cache_total_bytes.lock().unwrap() += 64;
454        }
455        self.evict_to_cap();
456
457        Ok(())
458    }
459
460    /// Delete a file and all its data parts.
461    #[allow(dead_code)]
462    #[track_caller]
463    pub fn delete_file(&self, zone_id: &str, name: &str) -> Result<(), StoreError> {
464        self.write_txn(|tx| {
465            tx.execute(
466                "DELETE FROM db_wave_file WHERE zoneid = ?1 AND name = ?2",
467                params![zone_id, name],
468            )?;
469            tx.execute(
470                "DELETE FROM db_file_data WHERE zoneid = ?1 AND name = ?2",
471                params![zone_id, name],
472            )?;
473            Ok(())
474        })?;
475
476        // Remove from cache
477        let key = (zone_id.to_string(), name.to_string());
478        let mut cache = self.cache.lock().unwrap();
479        if let Some(removed) = cache.remove(&key) {
480            let mut total = self.cache_total_bytes.lock().unwrap();
481            *total = total.saturating_sub(removed.cached_size_bytes);
482        }
483
484        Ok(())
485    }
486
487    /// Delete all files in a zone.
488    #[allow(dead_code)]
489    #[track_caller]
490    pub fn delete_zone(&self, zone_id: &str) -> Result<(), StoreError> {
491        // The names (for cache cleanup) and both deletes in one transaction,
492        // so a file another process creates meanwhile is either deleted and
493        // listed, or neither.
494        let names: Vec<String> = self.write_txn(|tx| {
495            let names = {
496                let mut stmt = tx.prepare("SELECT name FROM db_wave_file WHERE zoneid = ?1")?;
497                let rows = stmt.query_map(params![zone_id], |row| row.get(0))?;
498                rows.collect::<Result<Vec<String>, _>>()?
499            };
500            tx.execute(
501                "DELETE FROM db_wave_file WHERE zoneid = ?1",
502                params![zone_id],
503            )?;
504            tx.execute(
505                "DELETE FROM db_file_data WHERE zoneid = ?1",
506                params![zone_id],
507            )?;
508            Ok(names)
509        })?;
510
511        let mut cache = self.cache.lock().unwrap();
512        let mut freed = 0usize;
513        for name in names {
514            if let Some(removed) = cache.remove(&(zone_id.to_string(), name)) {
515                freed += removed.cached_size_bytes;
516            }
517        }
518        if freed > 0 {
519            let mut total = self.cache_total_bytes.lock().unwrap();
520            *total = total.saturating_sub(freed);
521        }
522
523        Ok(())
524    }
525
526    /// Get file metadata. Returns None if file doesn't exist.
527    #[track_caller]
528    pub fn stat(&self, zone_id: &str, name: &str) -> Result<Option<MuxFile>, StoreError> {
529        // Check cache first
530        let key = (zone_id.to_string(), name.to_string());
531        {
532            let mut cache = self.cache.lock().unwrap();
533            if let Some(entry) = cache.get_mut(&key) {
534                entry.last_access_ms = agentmux_common::time::now_ms();
535                return Ok(entry.file.clone());
536            }
537        }
538
539        // Load from DB
540        let conn = self.lock_conn();
541        let result = conn.query_row(
542            "SELECT zoneid, name, size, createdts, modts, opts, meta FROM db_wave_file WHERE zoneid = ?1 AND name = ?2",
543            params![zone_id, name],
544            |row| {
545                let opts_str: String = row.get(5)?;
546                let meta_str: String = row.get(6)?;
547                Ok(MuxFile {
548                    zoneid: row.get(0)?,
549                    name: row.get(1)?,
550                    size: row.get(2)?,
551                    createdts: row.get(3)?,
552                    modts: row.get(4)?,
553                    opts: serde_json::from_str(&opts_str).unwrap_or_default(),
554                    meta: serde_json::from_str(&meta_str).unwrap_or_default(),
555                })
556            },
557        );
558
559        match result {
560            Ok(file) => Ok(Some(file)),
561            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
562            Err(e) => Err(StoreError::Sqlite(e)),
563        }
564    }
565
566    /// Replace an existing row's content with `data` inside `tx`. Replaced
567    /// content starts a new counted epoch with a fresh `gen` (counter.rs).
568    pub(super) fn replace_content_in(
569        tx: &Transaction<'_>,
570        zone_id: &str,
571        name: &str,
572        data: &[u8],
573        now: i64,
574    ) -> Result<(), StoreError> {
575        tx.execute(
576            "DELETE FROM db_file_data WHERE zoneid = ?1 AND name = ?2",
577            params![zone_id, name],
578        )?;
579        for (idx, part_data) in Self::split_into_parts(data).iter().enumerate() {
580            tx.execute(
581                "INSERT INTO db_file_data (zoneid, name, partidx, data) VALUES (?1, ?2, ?3, ?4)",
582                params![zone_id, name, idx as i32, part_data],
583            )?;
584        }
585        // The new epoch is recorded at the `rev` the part writes above left
586        // (the delete trigger bumps it).
587        let count = count_all(data);
588        tx.execute(
589            "UPDATE db_wave_file SET size = ?1, modts = ?2,
590                 gen = ?3, lines = ?4, lines_size = ?1, lines_tail = ?5, lines_modts = ?2,
591                 lines_rev = COALESCE(rev, 0)
592             WHERE zoneid = ?6 AND name = ?7",
593            params![data.len() as i64, now, new_gen(), count.lines as i64, count.tail_start as i64, zone_id, name],
594        )?;
595        Ok(())
596    }
597
598    /// Drop this process's cached rows for `names` (after a write that the
599    /// cache can't be patched to follow).
600    pub(super) fn forget_cached(&self, zone_id: &str, names: &[&str]) {
601        let mut cache = self.cache.lock().unwrap();
602        let mut freed = 0usize;
603        for name in names {
604            if let Some(removed) = cache.remove(&(zone_id.to_string(), name.to_string())) {
605                freed += removed.cached_size_bytes;
606            }
607        }
608        if freed > 0 {
609            let mut total = self.cache_total_bytes.lock().unwrap();
610            *total = total.saturating_sub(freed);
611        }
612    }
613
614    /// Write (replace) entire file contents.
615    #[track_caller]
616    pub fn write_file(
617        &self,
618        zone_id: &str,
619        name: &str,
620        data: &[u8],
621    ) -> Result<(), StoreError> {
622        let key = (zone_id.to_string(), name.to_string());
623        let now = agentmux_common::time::now_ms();
624
625        // Write directly to DB (write-through for full writes, matching Go's
626        // WriteFile), in one transaction: a failure part-way keeps the old
627        // content instead of a new size over half-written parts.
628        self.write_txn(|tx| {
629            let exists = tx
630                .query_row(
631                    "SELECT 1 FROM db_wave_file WHERE zoneid = ?1 AND name = ?2",
632                    params![zone_id, name],
633                    |_| Ok(()),
634                )
635                .optional()?
636                .is_some();
637            if !exists {
638                return Err(StoreError::NotFound);
639            }
640            Self::replace_content_in(tx, zone_id, name, data, now)
641        })?;
642
643        // Update cache (metadata only — data parts are already in DB, read_file loads from DB)
644        {
645            let new_size = data.len().max(64);
646            let mut cache = self.cache.lock().unwrap();
647            if let Some(entry) = cache.get_mut(&key) {
648                let old_size = entry.cached_size_bytes;
649                if let Some(ref mut file) = entry.file {
650                    file.size = data.len() as i64;
651                    file.modts = now;
652                }
653                entry.last_access_ms = now;
654                entry.cached_size_bytes = new_size;
655                let delta = new_size as i64 - old_size as i64;
656                let mut total = self.cache_total_bytes.lock().unwrap();
657                if delta >= 0 {
658                    *total += delta as usize;
659                } else {
660                    *total = total.saturating_sub((-delta) as usize);
661                }
662            }
663        }
664        self.evict_to_cap();
665
666        Ok(())
667    }
668
669    /// Read entire file contents.
670    #[track_caller]
671    pub fn read_file(&self, zone_id: &str, name: &str) -> Result<Option<Vec<u8>>, StoreError> {
672        // Get file metadata
673        let file = match self.stat(zone_id, name)? {
674            Some(f) => f,
675            None => return Ok(None),
676        };
677
678        if file.size == 0 {
679            return Ok(Some(Vec::new()));
680        }
681
682        let data_len = file.data_length();
683        let start_idx = file.data_start_idx();
684        let num_parts = ((start_idx + data_len - 1) / PART_DATA_SIZE as i64 + 1) as i32;
685        let start_part = (start_idx / PART_DATA_SIZE as i64) as i32;
686
687        // Load parts from DB
688        let conn = self.lock_conn();
689        let mut stmt = conn.prepare(
690            "SELECT partidx, data FROM db_file_data WHERE zoneid = ?1 AND name = ?2 ORDER BY partidx",
691        )?;
692        let rows = stmt.query_map(params![zone_id, name], |row| {
693            Ok((row.get::<_, i32>(0)?, row.get::<_, Vec<u8>>(1)?))
694        })?;
695
696        let mut parts_map: HashMap<i32, Vec<u8>> = HashMap::new();
697        for row in rows {
698            let (idx, data) = row?;
699            parts_map.insert(idx, data);
700        }
701        drop(stmt);
702        drop(conn);
703
704        // Assemble data
705        let mut result = Vec::with_capacity(data_len as usize);
706        for part_idx in start_part..start_part + num_parts {
707            if let Some(part_data) = parts_map.get(&part_idx) {
708                let part_start = part_idx as i64 * PART_DATA_SIZE as i64;
709                let skip = if part_start < start_idx {
710                    (start_idx - part_start) as usize
711                } else {
712                    0
713                };
714                let remaining = data_len as usize - result.len();
715                let take = remaining.min(part_data.len() - skip);
716                result.extend_from_slice(&part_data[skip..skip + take]);
717            }
718        }
719
720        let _ = (num_parts, start_part); // used in loop above
721        Ok(Some(result))
722    }
723
724    /// Append data to the end of a file.
725    #[track_caller]
726    pub fn append_data(
727        &self,
728        zone_id: &str,
729        name: &str,
730        data: &[u8],
731    ) -> Result<(), StoreError> {
732        if data.is_empty() {
733            return Ok(());
734        }
735        self.append_data_at(zone_id, name, data).map(|_| ())
736    }
737
738    /// Append data to the end of a file and return the byte offset the
739    /// batch actually landed at. Unlike a caller-side stat-then-append,
740    /// the size read and the part writes happen in ONE transaction, so the
741    /// returned offset is exact even when concurrent appenders interleave —
742    /// codex P2 on PR #2508: the `output.tsidx` sidecar keys batch
743    /// receive-times by offset, and a racy pre-append stat could stamp a
744    /// batch with another batch's position.
745    #[track_caller]
746    pub fn append_data_at(
747        &self,
748        zone_id: &str,
749        name: &str,
750        data: &[u8],
751    ) -> Result<i64, StoreError> {
752        self.append_data_pos(zone_id, name, data).map(|pos| pos.offset)
753    }
754
755    /// [`Self::append_data_at`], also reporting the line counter's position
756    /// (`filestore/counter.rs`).
757    #[track_caller]
758    pub fn append_data_pos(
759        &self,
760        zone_id: &str,
761        name: &str,
762        data: &[u8],
763    ) -> Result<AppendPos, StoreError> {
764        let now = agentmux_common::time::now_ms();
765        let (pos, new_size) = self.append_inner(zone_id, name, data, AppendMode::Raw, now)?;
766        // Nothing written, nothing for the cache to follow (its modts must
767        // keep matching the database's).
768        if new_size > pos.offset {
769            self.note_appended(zone_id, name, new_size, now);
770        }
771        Ok(pos)
772    }
773
774    /// Bring this process's cached row up to an append that just committed
775    /// at `now` (the modts the append wrote, so the cache matches it).
776    pub(super) fn note_appended(&self, zone_id: &str, name: &str, new_size: i64, now: i64) {
777        let key = (zone_id.to_string(), name.to_string());
778        {
779            let new_size_bytes = (new_size as usize).max(64);
780            let mut cache = self.cache.lock().unwrap();
781            if let Some(entry) = cache.get_mut(&key) {
782                let old_size = entry.cached_size_bytes;
783                if let Some(ref mut f) = entry.file {
784                    f.size = new_size;
785                    f.modts = now;
786                }
787                entry.last_access_ms = now;
788                entry.cached_size_bytes = new_size_bytes;
789                let delta = new_size_bytes as i64 - old_size as i64;
790                let mut total = self.cache_total_bytes.lock().unwrap();
791                if delta >= 0 {
792                    *total += delta as usize;
793                } else {
794                    *total = total.saturating_sub((-delta) as usize);
795                }
796            }
797        }
798        self.evict_to_cap();
799    }
800
801    /// Write metadata. If `merge` is true, only specified keys are updated;
802    /// otherwise the entire metadata map is replaced.
803    #[track_caller]
804    pub fn write_meta(
805        &self,
806        zone_id: &str,
807        name: &str,
808        meta: FileMeta,
809        merge: bool,
810    ) -> Result<(), StoreError> {
811        let key = (zone_id.to_string(), name.to_string());
812        let now = agentmux_common::time::now_ms();
813
814        // Merge base read from the database in the same transaction as the
815        // update, not from `stat` — this process's cached row can predate
816        // another process's write, and merging onto it would drop that write.
817        let new_meta = self.write_txn(|tx| {
818            let current: String = tx
819                .query_row(
820                    "SELECT meta FROM db_wave_file WHERE zoneid = ?1 AND name = ?2",
821                    params![zone_id, name],
822                    |row| row.get(0),
823                )
824                .optional()?
825                .ok_or(StoreError::NotFound)?;
826            let new_meta = if merge {
827                let mut merged: FileMeta = serde_json::from_str(&current).unwrap_or_default();
828                for (k, v) in meta {
829                    if v.is_null() {
830                        merged.remove(&k);
831                    } else {
832                        merged.insert(k, v);
833                    }
834                }
835                merged
836            } else {
837                meta
838            };
839            let meta_json = serde_json::to_string(&new_meta)?;
840            // A metadata write doesn't change content: a valid epoch follows
841            // the new modts (the right-hand sides see the old row).
842            tx.execute(
843                "UPDATE db_wave_file SET meta = ?1, modts = ?2,
844                     lines_modts = CASE WHEN lines_modts = modts AND lines_size = size THEN ?2 ELSE lines_modts END
845                 WHERE zoneid = ?3 AND name = ?4",
846                params![meta_json, now, zone_id, name],
847            )?;
848            Ok(new_meta)
849        })?;
850
851        // Update cache (metadata write doesn't change file.size, so cached_size_bytes unchanged)
852        let mut cache = self.cache.lock().unwrap();
853        if let Some(entry) = cache.get_mut(&key) {
854            if let Some(ref mut f) = entry.file {
855                f.meta = new_meta;
856                f.modts = now;
857            }
858            entry.last_access_ms = now;
859        }
860
861        Ok(())
862    }
863
864    /// List all files in a zone.
865    #[allow(dead_code)]
866    #[track_caller]
867    pub fn list_files(&self, zone_id: &str) -> Result<Vec<MuxFile>, StoreError> {
868        let conn = self.lock_conn();
869        let mut stmt = conn.prepare(
870            "SELECT zoneid, name, size, createdts, modts, opts, meta FROM db_wave_file WHERE zoneid = ?1",
871        )?;
872        let rows = stmt.query_map(params![zone_id], |row| {
873            let opts_str: String = row.get(5)?;
874            let meta_str: String = row.get(6)?;
875            Ok(MuxFile {
876                zoneid: row.get(0)?,
877                name: row.get(1)?,
878                size: row.get(2)?,
879                createdts: row.get(3)?,
880                modts: row.get(4)?,
881                opts: serde_json::from_str(&opts_str).unwrap_or_default(),
882                meta: serde_json::from_str(&meta_str).unwrap_or_default(),
883            })
884        })?;
885
886        rows.collect::<Result<Vec<_>, _>>()
887            .map_err(StoreError::Sqlite)
888    }
889
890    /// Zone IDs that start with `prefix`, in no particular order. A range scan
891    /// on the primary key, so it costs the number of matches, not the size of
892    /// the store (`get_all_zone_ids` walks every file row).
893    #[track_caller]
894    pub fn zone_ids_with_prefix(&self, prefix: &str) -> Result<Vec<String>, StoreError> {
895        let conn = self.lock_conn();
896        let mut stmt = conn.prepare_cached(
897            "SELECT DISTINCT zoneid FROM db_wave_file \
898             WHERE zoneid >= ?1 AND zoneid < ?2",
899        )?;
900        // U+10FFFF sorts after every character a zone name can contain.
901        let upper = format!("{prefix}\u{10FFFF}");
902        let rows = stmt.query_map(params![prefix, upper], |row| row.get(0))?;
903        rows.collect::<Result<Vec<_>, _>>()
904            .map_err(StoreError::Sqlite)
905    }
906
907    /// Get all zone IDs that have files.
908    #[allow(dead_code)]
909    #[track_caller]
910    pub fn get_all_zone_ids(&self) -> Result<Vec<String>, StoreError> {
911        let conn = self.lock_conn();
912        let mut stmt = conn.prepare("SELECT DISTINCT zoneid FROM db_wave_file")?;
913        let rows = stmt.query_map([], |row| row.get(0))?;
914        rows.collect::<Result<Vec<_>, _>>()
915            .map_err(StoreError::Sqlite)
916    }
917
918    /// Flush dirty cache entries to the database and evict stale clean entries.
919    /// Returns (files_flushed, parts_flushed).
920    #[allow(dead_code)]
921    #[track_caller]
922    pub fn flush_cache(&self) -> Result<(usize, usize), StoreError> {
923        let ttl_ms = (CACHE_TTL_SECS * 1000) as i64;
924        let now = agentmux_common::time::now_ms();
925        let cutoff_ms = now - ttl_ms;
926
927        let (dirty_keys, stale_keys): (Vec<_>, Vec<_>) = {
928            let cache = self.cache.lock().unwrap();
929            let dirty = cache
930                .iter()
931                .filter(|(_, e)| e.dirty)
932                .map(|(k, _)| k.clone())
933                .collect();
934            let stale = cache
935                .iter()
936                .filter(|(_, e)| !e.dirty && e.last_access_ms < cutoff_ms)
937                .map(|(k, _)| k.clone())
938                .collect();
939            (dirty, stale)
940        };
941
942        // Evict stale clean entries — they're already persisted in DB.
943        if !stale_keys.is_empty() {
944            let mut freed = 0usize;
945            let mut cache = self.cache.lock().unwrap();
946            for key in &stale_keys {
947                if let Some(removed) = cache.remove(key) {
948                    freed += removed.cached_size_bytes;
949                }
950            }
951            if freed > 0 {
952                let mut total = self.cache_total_bytes.lock().unwrap();
953                *total = total.saturating_sub(freed);
954            }
955            tracing::debug!("filestore cache: evicted {} stale entries ({} bytes)", stale_keys.len(), freed);
956        }
957
958        let mut files_flushed = 0;
959        let mut parts_flushed = 0;
960
961        for key in dirty_keys {
962            let entry = {
963                let mut cache = self.cache.lock().unwrap();
964                let entry = cache.remove(&key);
965                if let Some(ref e) = entry {
966                    let mut total = self.cache_total_bytes.lock().unwrap();
967                    *total = total.saturating_sub(e.cached_size_bytes);
968                }
969                entry
970            };
971
972            if let Some(entry) = entry {
973                if let Some(ref file) = entry.file {
974                    let meta_json = serde_json::to_string(&file.meta)?;
975                    self.write_txn(|tx| {
976                        // Rewrites content from the cache: the counter can't
977                        // vouch for it, so the epoch is dropped (counter.rs).
978                        tx.execute(
979                            &format!(
980                                "UPDATE db_wave_file SET size = ?1, modts = ?2, meta = ?3, {DROP_EPOCH_SQL}
981                                 WHERE zoneid = ?4 AND name = ?5"
982                            ),
983                            params![file.size, file.modts, meta_json, file.zoneid, file.name],
984                        )?;
985                        for data_entry in entry.data_entries.values() {
986                            tx.execute(
987                                "REPLACE INTO db_file_data (zoneid, name, partidx, data) VALUES (?1, ?2, ?3, ?4)",
988                                params![file.zoneid, file.name, data_entry.part_idx, data_entry.data],
989                            )?;
990                        }
991                        Ok(())
992                    })?;
993                    parts_flushed += entry.data_entries.len();
994                    files_flushed += 1;
995                }
996            }
997        }
998
999        Ok((files_flushed, parts_flushed))
1000    }
1001
1002    /// Split data into PART_DATA_SIZE chunks.
1003    fn split_into_parts(data: &[u8]) -> Vec<Vec<u8>> {
1004        if data.is_empty() {
1005            return Vec::new();
1006        }
1007        data.chunks(PART_DATA_SIZE)
1008            .map(|chunk| chunk.to_vec())
1009            .collect()
1010    }
1011
1012    /// Start background flusher (call from async context).
1013    #[allow(dead_code)]
1014    pub fn start_flusher(self: &Arc<Self>) -> tokio::task::JoinHandle<()> {
1015        let store = Arc::clone(self);
1016        tokio::spawn(async move {
1017            let mut interval =
1018                tokio::time::interval(std::time::Duration::from_secs(DEFAULT_FLUSH_SECS));
1019            loop {
1020                interval.tick().await;
1021                match store.flush_cache() {
1022                    Ok((files, parts)) => {
1023                        if files > 0 {
1024                            tracing::debug!(
1025                                "filestore flush: {} files, {} parts",
1026                                files,
1027                                parts
1028                            );
1029                        }
1030                    }
1031                    Err(e) => {
1032                        tracing::error!("filestore flush error: {}", e);
1033                    }
1034                }
1035            }
1036        })
1037    }
1038}