1use 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
23pub(super) const PART_DATA_SIZE: usize = 64 * 1024;
25
26#[allow(dead_code)]
28pub const DEFAULT_FLUSH_SECS: u64 = 5;
29
30#[allow(dead_code)]
32pub const CACHE_TTL_SECS: u64 = 60;
33
34pub const MAX_CACHE_BYTES: usize = 128 * 1024 * 1024;
37
38const SLOW_CONN: std::time::Duration = std::time::Duration::from_millis(500);
41
42pub(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
79pub(super) struct ReaderPool {
87 path: std::path::PathBuf,
88 idle: Mutex<Vec<Connection>>,
89}
90
91const 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 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
121pub struct FileStore {
123 pub(super) conn: Mutex<Connection>,
124 pub(super) cache: Mutex<HashMap<(String, String), CacheEntry>>,
125 pub(super) cache_total_bytes: Mutex<usize>,
127 pub(super) cache_max_bytes: usize,
129 pub(super) _checkpointer: Option<super::checkpointer::Checkpointer>,
133 pub(super) readers: Option<ReaderPool>,
136}
137
138impl FileStore {
139 pub fn open(path: &Path) -> Result<Self, StoreError> {
141 let conn = Connection::open(path)?;
142 let mut store = Self::configure_and_migrate(conn)?;
143 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 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 #[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 #[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 #[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 #[cfg(test)]
207 pub(crate) fn conn(&self) -> &Mutex<Connection> {
208 &self.conn
209 }
210
211 #[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 conn.execute_batch("PRAGMA busy_timeout=5000;")?;
225 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 conn.execute_batch("PRAGMA synchronous=NORMAL;")?;
250 {
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 #[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 #[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 #[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 pub(super) fn evict_to_cap(&self) {
343 let total = *self.cache_total_bytes.lock().unwrap();
345 if total <= self.cache_max_bytes {
346 return;
347 }
348
349 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 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 #[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 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 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 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, };
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 #[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 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 #[allow(dead_code)]
489 #[track_caller]
490 pub fn delete_zone(&self, zone_id: &str) -> Result<(), StoreError> {
491 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 #[track_caller]
528 pub fn stat(&self, zone_id: &str, name: &str) -> Result<Option<MuxFile>, StoreError> {
529 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 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 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 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 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 #[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 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 {
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 #[track_caller]
671 pub fn read_file(&self, zone_id: &str, name: &str) -> Result<Option<Vec<u8>>, StoreError> {
672 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 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 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); Ok(Some(result))
722 }
723
724 #[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 #[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 #[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 if new_size > pos.offset {
769 self.note_appended(zone_id, name, new_size, now);
770 }
771 Ok(pos)
772 }
773
774 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 #[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 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(¤t).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 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 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 #[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 #[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 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 #[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 #[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 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 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 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 #[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}