Skip to main content

agentos_vfs/local/
sqlite_metadata_store.rs

1use async_trait::async_trait;
2use rusqlite::{params, Connection, OptionalExtension};
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::Path;
5use std::sync::Mutex;
6use vfs::engine::error::{VfsError, VfsResult};
7use vfs::engine::mem::metadata_store::MetadataDump;
8use vfs::engine::mem::InMemoryMetadataStore;
9use vfs::engine::metadata::MetadataStore;
10use vfs::engine::types::{
11    BlockKey, ChunkEdit, ChunkRange, ChunkRef, CreateInodeAttrs, DentryStat, InodeMeta, InodePatch,
12    InodeType, SnapshotId, Storage, Timespec, DEFAULT_CHUNK_SIZE,
13};
14
15const LOCAL_FS_SCHEMA_VERSION_TABLE: &str = "agentos_fs_schema_version";
16
17struct LocalFsMigration {
18    version: i64,
19    statements: &'static str,
20}
21
22// This ladder belongs to the standalone rusqlite metadata database opened by
23// `SqliteMetadataStore`. It is not interchangeable with the filesystem ladder
24// installed in the per-VM descriptor database by `chunked_actor_sqlite`.
25const LOCAL_FS_MIGRATIONS: &[LocalFsMigration] = &[
26    LocalFsMigration {
27        version: 1,
28        statements: r#"
29        CREATE TABLE agentos_fs_inodes (
30          ino INTEGER PRIMARY KEY CHECK (ino > 0),
31          kind INTEGER NOT NULL CHECK (kind IN (0, 1, 2)),
32          mode INTEGER NOT NULL CHECK (mode BETWEEN 0 AND 4294967295),
33          uid INTEGER NOT NULL CHECK (uid BETWEEN 0 AND 4294967295),
34          gid INTEGER NOT NULL CHECK (gid BETWEEN 0 AND 4294967295),
35          size INTEGER NOT NULL CHECK (size >= 0),
36          nlink INTEGER NOT NULL CHECK (nlink >= 0),
37          atime_ns INTEGER NOT NULL,
38          mtime_ns INTEGER NOT NULL,
39          ctime_ns INTEGER NOT NULL,
40          birthtime_ns INTEGER NOT NULL,
41          storage_mode INTEGER NOT NULL CHECK (storage_mode IN (0, 1, 2)),
42          storage_chunk_size INTEGER CHECK (
43            storage_chunk_size IS NULL OR
44            storage_chunk_size BETWEEN 1 AND 4294967295
45          ),
46          inline_content BLOB,
47          symlink_target TEXT,
48          CHECK (
49            (storage_mode = 0 AND storage_chunk_size IS NULL AND inline_content IS NULL) OR
50            (storage_mode = 1 AND storage_chunk_size IS NULL AND inline_content IS NOT NULL) OR
51            (storage_mode = 2 AND storage_chunk_size IS NOT NULL AND inline_content IS NULL)
52          ),
53          CHECK (
54            (kind = 2 AND symlink_target IS NOT NULL) OR
55            (kind <> 2 AND symlink_target IS NULL)
56          )
57        ) STRICT;
58        CREATE TABLE agentos_fs_dentries (
59          parent_ino INTEGER NOT NULL CHECK (parent_ino > 0),
60          name TEXT NOT NULL CHECK (length(name) > 0),
61          child_ino INTEGER NOT NULL CHECK (child_ino > 0),
62          kind INTEGER NOT NULL CHECK (kind IN (0, 1, 2)),
63          PRIMARY KEY (parent_ino, name)
64        ) STRICT;
65        CREATE INDEX agentos_fs_dentries_parent
66          ON agentos_fs_dentries(parent_ino);
67        CREATE TABLE agentos_fs_chunks (
68          ino INTEGER NOT NULL CHECK (ino > 0),
69          chunk_index INTEGER NOT NULL CHECK (chunk_index >= 0),
70          block_key TEXT NOT NULL CHECK (length(block_key) > 0),
71          len INTEGER NOT NULL CHECK (len BETWEEN 0 AND 4294967295),
72          PRIMARY KEY (ino, chunk_index)
73        ) STRICT;
74        CREATE TABLE agentos_fs_block_refs (
75          block_key TEXT PRIMARY KEY CHECK (length(block_key) > 0),
76          refcount INTEGER NOT NULL CHECK (refcount > 0)
77        ) STRICT;
78        CREATE TABLE agentos_fs_snapshots (
79          snapshot_id INTEGER PRIMARY KEY CHECK (snapshot_id > 0),
80          root_ino INTEGER NOT NULL CHECK (root_ino > 0),
81          created_ns INTEGER NOT NULL
82        ) STRICT;
83        "#,
84    },
85    LocalFsMigration {
86        version: 2,
87        statements: r#"
88            ALTER TABLE agentos_fs_inodes RENAME TO agentos_fs_inodes_v1;
89            CREATE TABLE agentos_fs_inodes (
90              ino INTEGER PRIMARY KEY CHECK (ino > 0),
91              kind INTEGER NOT NULL CHECK (kind IN (0, 1, 2, 3, 4, 5)),
92              mode INTEGER NOT NULL CHECK (mode BETWEEN 0 AND 4294967295),
93              uid INTEGER NOT NULL CHECK (uid BETWEEN 0 AND 4294967295),
94              gid INTEGER NOT NULL CHECK (gid BETWEEN 0 AND 4294967295),
95              size INTEGER NOT NULL CHECK (size >= 0),
96              nlink INTEGER NOT NULL CHECK (nlink >= 0),
97              atime_ns INTEGER NOT NULL,
98              mtime_ns INTEGER NOT NULL,
99              ctime_ns INTEGER NOT NULL,
100              birthtime_ns INTEGER NOT NULL,
101              storage_mode INTEGER NOT NULL CHECK (storage_mode IN (0, 1, 2)),
102              storage_chunk_size INTEGER CHECK (
103                storage_chunk_size IS NULL OR
104                storage_chunk_size BETWEEN 1 AND 4294967295
105              ),
106              inline_content BLOB,
107              symlink_target TEXT,
108              xattrs_json BLOB NOT NULL DEFAULT X'7B7D',
109              allocated_extents_json BLOB NOT NULL DEFAULT X'5B5D',
110              CHECK (
111                (storage_mode = 0 AND storage_chunk_size IS NULL AND inline_content IS NULL) OR
112                (storage_mode = 1 AND storage_chunk_size IS NULL AND inline_content IS NOT NULL) OR
113                (storage_mode = 2 AND storage_chunk_size IS NOT NULL AND inline_content IS NULL)
114              ),
115              CHECK (
116                (kind = 2 AND symlink_target IS NOT NULL) OR
117                (kind <> 2 AND symlink_target IS NULL)
118              )
119            ) STRICT;
120            INSERT INTO agentos_fs_inodes
121              (ino, kind, mode, uid, gid, size, nlink, atime_ns, mtime_ns, ctime_ns,
122               birthtime_ns, storage_mode, storage_chunk_size, inline_content, symlink_target,
123               xattrs_json, allocated_extents_json)
124            SELECT ino, kind, mode, uid, gid, size, nlink, atime_ns, mtime_ns, ctime_ns,
125                   birthtime_ns, storage_mode, storage_chunk_size, inline_content, symlink_target,
126                   X'7B7D',
127                   CASE WHEN kind = 0 AND size > 0
128                     THEN CAST(printf('[[0,%d]]', (size + 511) / 512) AS BLOB)
129                     ELSE X'5B5D'
130                   END
131              FROM agentos_fs_inodes_v1;
132            DROP TABLE agentos_fs_inodes_v1;
133        "#,
134    },
135];
136
137pub struct SqliteMetadataStore {
138    connection: Mutex<Connection>,
139    pending_write_count: Mutex<usize>,
140    inner: InMemoryMetadataStore,
141}
142
143const MAX_PENDING_WRITE_COMMITS: usize = 256;
144
145impl SqliteMetadataStore {
146    pub fn open(path: impl AsRef<Path>) -> VfsResult<Self> {
147        let connection = Connection::open(path)
148            .map_err(|err| VfsError::eio(format!("open SQLite metadata store: {err}")))?;
149        Self::from_connection(connection)
150    }
151
152    pub fn in_memory() -> VfsResult<Self> {
153        let connection = Connection::open_in_memory()
154            .map_err(|err| VfsError::eio(format!("open in-memory SQLite metadata store: {err}")))?;
155        Self::from_connection(connection)
156    }
157
158    fn from_connection(mut connection: Connection) -> VfsResult<Self> {
159        connection
160            .pragma_update(None, "journal_mode", "WAL")
161            .map_err(|err| VfsError::eio(format!("enable SQLite WAL mode: {err}")))?;
162        connection
163            .pragma_update(None, "synchronous", "NORMAL")
164            .map_err(|err| VfsError::eio(format!("configure SQLite synchronous mode: {err}")))?;
165        install_schema(&mut connection)?;
166        let dump = load_dump(&connection)?;
167        let is_new = dump.is_none();
168        let inner = dump
169            .map(InMemoryMetadataStore::from_dump)
170            .unwrap_or_default();
171        if is_new {
172            persist_dump(&mut connection, &inner.dump())?;
173        }
174        Ok(Self {
175            connection: Mutex::new(connection),
176            pending_write_count: Mutex::new(0),
177            inner,
178        })
179    }
180
181    pub fn has_schema(&self) -> VfsResult<bool> {
182        let connection = self.connection.lock().expect("sqlite mutex poisoned");
183        let count: i64 = connection
184            .query_row(
185                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('agentos_fs_inodes', 'agentos_fs_dentries', 'agentos_fs_chunks', 'agentos_fs_block_refs', 'agentos_fs_snapshots')",
186                [],
187                |row| row.get(0),
188            )
189            .map_err(|err| VfsError::eio(format!("inspect SQLite schema: {err}")))?;
190        Ok(count == 5)
191    }
192
193    fn persist(&self) -> VfsResult<()> {
194        self.flush_pending_writes()?;
195        let dump = self.inner.dump();
196        let mut connection = self.connection.lock().expect("sqlite mutex poisoned");
197        persist_dump(&mut connection, &dump)
198    }
199
200    fn persist_create(&self, parent: u64, name: &str, meta: &InodeMeta) -> VfsResult<()> {
201        self.flush_pending_writes()?;
202        let parent_meta = self.inner.inode_meta(parent)?;
203        let mut connection = self.connection.lock().expect("sqlite mutex poisoned");
204        let tx = connection
205            .transaction()
206            .map_err(|err| VfsError::eio(format!("begin SQLite create transaction: {err}")))?;
207        upsert_inode(&tx, &parent_meta)?;
208        upsert_inode(&tx, meta)?;
209        tx.execute(
210            "INSERT INTO agentos_fs_dentries (parent_ino, name, child_ino, kind) VALUES (?, ?, ?, ?)",
211            params![parent, name, meta.ino, kind_id(meta.kind)],
212        )
213        .map_err(|err| VfsError::eio(format!("persist SQLite dentry {name}: {err}")))?;
214        tx.commit()
215            .map_err(|err| VfsError::eio(format!("commit SQLite create transaction: {err}")))
216    }
217
218    fn persist_set_attr(&self, ino: u64, storage_changed: bool) -> VfsResult<()> {
219        let meta = self.inner.inode_meta(ino)?;
220        let mut pending = self
221            .pending_write_count
222            .lock()
223            .expect("sqlite pending-write mutex poisoned");
224        let mut connection = self.connection.lock().expect("sqlite mutex poisoned");
225        if *pending > 0 {
226            if let Err(error) = self.persist_set_attr_rows(&connection, &meta, storage_changed) {
227                let rollback_result = connection.execute_batch("ROLLBACK");
228                *pending = 0;
229                if let Err(rollback_error) = rollback_result {
230                    return Err(VfsError::eio(format!(
231                        "{error}; rollback batched SQLite setattr failed: {rollback_error}"
232                    )));
233                }
234                return Err(error);
235            }
236            return Ok(());
237        }
238
239        let tx = connection
240            .transaction()
241            .map_err(|err| VfsError::eio(format!("begin SQLite setattr transaction: {err}")))?;
242        self.persist_set_attr_rows(&tx, &meta, storage_changed)?;
243        tx.commit()
244            .map_err(|err| VfsError::eio(format!("commit SQLite setattr transaction: {err}")))
245    }
246
247    fn persist_set_attr_rows(
248        &self,
249        connection: &Connection,
250        meta: &InodeMeta,
251        storage_changed: bool,
252    ) -> VfsResult<()> {
253        let mut affected_keys = BTreeSet::new();
254        if storage_changed {
255            let mut statement = connection
256                .prepare_cached("SELECT block_key FROM agentos_fs_chunks WHERE ino = ?")
257                .map_err(|err| VfsError::eio(format!("prepare setattr chunk lookup: {err}")))?;
258            let rows = statement
259                .query_map(params![meta.ino], |row| row.get::<_, String>(0))
260                .map_err(|err| VfsError::eio(format!("query setattr chunks: {err}")))?;
261            for row in rows {
262                affected_keys.insert(BlockKey(
263                    row.map_err(|err| VfsError::eio(format!("read setattr chunk key: {err}")))?,
264                ));
265            }
266        }
267        upsert_inode(connection, meta)?;
268        if storage_changed {
269            connection
270                .execute(
271                    "DELETE FROM agentos_fs_chunks WHERE ino = ?",
272                    params![meta.ino],
273                )
274                .map_err(|err| VfsError::eio(format!("delete setattr chunks: {err}")))?;
275            for key in affected_keys {
276                let refcount = self.inner.refcount(&key);
277                if refcount == 0 {
278                    connection
279                        .execute(
280                            "DELETE FROM agentos_fs_block_refs WHERE block_key = ?",
281                            params![key.0],
282                        )
283                        .map_err(|err| {
284                            VfsError::eio(format!("delete setattr block ref {}: {err}", key.0))
285                        })?;
286                } else {
287                    connection
288                        .execute(
289                            "INSERT INTO agentos_fs_block_refs (block_key, refcount) VALUES (?, ?)
290                             ON CONFLICT(block_key) DO UPDATE SET refcount=excluded.refcount",
291                            params![key.0, refcount],
292                        )
293                        .map_err(|err| {
294                            VfsError::eio(format!("persist setattr block ref {}: {err}", key.0))
295                        })?;
296                }
297            }
298        }
299        Ok(())
300    }
301
302    fn flush_pending_writes(&self) -> VfsResult<()> {
303        let mut pending = self
304            .pending_write_count
305            .lock()
306            .expect("sqlite pending-write mutex poisoned");
307        if *pending == 0 {
308            return Ok(());
309        }
310        let connection = self.connection.lock().expect("sqlite mutex poisoned");
311        connection
312            .execute_batch("COMMIT")
313            .map_err(|err| VfsError::eio(format!("commit pending SQLite writes: {err}")))?;
314        *pending = 0;
315        Ok(())
316    }
317
318    fn flush_durable(&self) -> VfsResult<()> {
319        self.flush_pending_writes()?;
320        let connection = self.connection.lock().expect("sqlite mutex poisoned");
321        connection
322            .execute_batch("PRAGMA wal_checkpoint(FULL)")
323            .map_err(|err| VfsError::eio(format!("checkpoint SQLite metadata WAL: {err}")))
324    }
325
326    fn persist_commit_write(
327        &self,
328        ino: u64,
329        edits: &[ChunkEdit],
330        old_size: u64,
331        new_size: u64,
332        chunk_size: u64,
333    ) -> VfsResult<()> {
334        let meta = self.inner.inode_meta(ino)?;
335        let keep_chunks = if new_size == 0 {
336            0
337        } else {
338            new_size.div_ceil(chunk_size)
339        };
340        let old_chunks = if old_size == 0 {
341            0
342        } else {
343            old_size.div_ceil(chunk_size)
344        };
345        let mut pending = self
346            .pending_write_count
347            .lock()
348            .expect("sqlite pending-write mutex poisoned");
349        let connection = self.connection.lock().expect("sqlite mutex poisoned");
350        if *pending == 0 {
351            connection.execute_batch("BEGIN IMMEDIATE").map_err(|err| {
352                VfsError::eio(format!("begin batched SQLite write transaction: {err}"))
353            })?;
354        }
355
356        let write_result = (|| -> VfsResult<()> {
357            let mut affected_keys = BTreeSet::new();
358            upsert_inode(&connection, &meta)?;
359            if new_size < old_size {
360                let mut statement = connection
361                    .prepare_cached(
362                        "SELECT block_key FROM agentos_fs_chunks WHERE ino = ? AND chunk_index >= ?",
363                    )
364                    .map_err(|err| {
365                        VfsError::eio(format!("prepare truncated chunk lookup: {err}"))
366                    })?;
367                let rows = statement
368                    .query_map(params![ino, keep_chunks], |row| row.get::<_, String>(0))
369                    .map_err(|err| VfsError::eio(format!("query truncated chunks: {err}")))?;
370                for row in rows {
371                    affected_keys.insert(BlockKey(row.map_err(|err| {
372                        VfsError::eio(format!("read truncated chunk key: {err}"))
373                    })?));
374                }
375            }
376
377            for edit in edits.iter().filter(|edit| edit.index < keep_chunks) {
378                let previous = if edit.index >= old_chunks {
379                    None
380                } else {
381                    connection
382                        .prepare_cached(
383                            "SELECT block_key FROM agentos_fs_chunks WHERE ino = ? AND chunk_index = ?",
384                        )
385                        .map_err(|err| {
386                            VfsError::eio(format!(
387                                "prepare previous SQLite chunk lookup {ino}/{}: {err}",
388                                edit.index
389                            ))
390                        })?
391                        .query_row(params![ino, edit.index], |row| row.get::<_, String>(0))
392                        .optional()
393                        .map_err(|err| {
394                            VfsError::eio(format!(
395                                "query previous SQLite chunk {ino}/{}: {err}",
396                                edit.index
397                            ))
398                        })?
399                };
400                if let Some(key) = previous {
401                    affected_keys.insert(BlockKey(key));
402                }
403                affected_keys.insert(edit.key.clone());
404            }
405
406            if new_size < old_size {
407                connection
408                    .prepare_cached(
409                        "DELETE FROM agentos_fs_chunks WHERE ino = ? AND chunk_index >= ?",
410                    )
411                    .map_err(|err| VfsError::eio(format!("prepare truncated chunk delete: {err}")))?
412                    .execute(params![ino, keep_chunks])
413                    .map_err(|err| {
414                        VfsError::eio(format!("delete truncated SQLite chunks: {err}"))
415                    })?;
416            }
417            let mut insert_chunk = connection
418                .prepare_cached(
419                    "INSERT INTO agentos_fs_chunks (ino, chunk_index, block_key, len) VALUES (?, ?, ?, ?)
420                     ON CONFLICT(ino, chunk_index) DO UPDATE SET
421                       block_key=excluded.block_key, len=excluded.len",
422                )
423                .map_err(|err| VfsError::eio(format!("prepare SQLite chunk upsert: {err}")))?;
424            for edit in edits.iter().filter(|edit| edit.index < keep_chunks) {
425                insert_chunk
426                    .execute(params![ino, edit.index, edit.key.0, edit.len])
427                    .map_err(|err| {
428                        VfsError::eio(format!("persist SQLite chunk {ino}/{}: {err}", edit.index))
429                    })?;
430            }
431
432            for key in affected_keys {
433                let refcount = self.inner.refcount(&key);
434                if refcount == 0 {
435                    connection
436                        .execute(
437                            "DELETE FROM agentos_fs_block_refs WHERE block_key = ?",
438                            params![key.0],
439                        )
440                        .map_err(|err| {
441                            VfsError::eio(format!("delete SQLite block ref {}: {err}", key.0))
442                        })?;
443                } else {
444                    connection
445                        .execute(
446                            "INSERT INTO agentos_fs_block_refs (block_key, refcount) VALUES (?, ?)
447                             ON CONFLICT(block_key) DO UPDATE SET refcount=excluded.refcount",
448                            params![key.0, refcount],
449                        )
450                        .map_err(|err| {
451                            VfsError::eio(format!("persist SQLite block ref {}: {err}", key.0))
452                        })?;
453                }
454            }
455            Ok(())
456        })();
457
458        if let Err(error) = write_result {
459            let rollback_result = connection.execute_batch("ROLLBACK");
460            *pending = 0;
461            if let Err(rollback_error) = rollback_result {
462                return Err(VfsError::eio(format!(
463                    "{error}; rollback batched SQLite writes failed: {rollback_error}"
464                )));
465            }
466            return Err(error);
467        }
468
469        *pending += 1;
470        if *pending >= MAX_PENDING_WRITE_COMMITS {
471            connection.execute_batch("COMMIT").map_err(|err| {
472                VfsError::eio(format!("commit bounded SQLite write batch: {err}"))
473            })?;
474            *pending = 0;
475        }
476        Ok(())
477    }
478}
479
480impl Drop for SqliteMetadataStore {
481    fn drop(&mut self) {
482        if let Err(error) = self.flush_pending_writes() {
483            eprintln!("failed to flush pending SQLite metadata writes during drop: {error}");
484        }
485    }
486}
487
488fn upsert_inode(connection: &Connection, meta: &InodeMeta) -> VfsResult<()> {
489    let (storage_mode, storage_chunk_size, inline_content) = match &meta.storage {
490        Storage::None => (0, None, None),
491        Storage::Inline(data) => (1, None, Some(data.as_slice())),
492        Storage::Chunked { chunk_size } => (2, Some(*chunk_size), None),
493    };
494    let xattrs_json = serde_json::to_vec(&meta.xattrs)
495        .map_err(|err| VfsError::eio(format!("serialize inode {} xattrs: {err}", meta.ino)))?;
496    let allocated_extents_json = serde_json::to_vec(&meta.allocated_extents).map_err(|err| {
497        VfsError::eio(format!(
498            "serialize inode {} allocation extents: {err}",
499            meta.ino
500        ))
501    })?;
502    connection
503        .execute(
504            "INSERT INTO agentos_fs_inodes
505             (ino, kind, mode, uid, gid, size, nlink, atime_ns, mtime_ns, ctime_ns, birthtime_ns,
506              storage_mode, storage_chunk_size, inline_content, symlink_target, xattrs_json,
507              allocated_extents_json)
508             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
509             ON CONFLICT(ino) DO UPDATE SET
510              kind=excluded.kind, mode=excluded.mode, uid=excluded.uid, gid=excluded.gid,
511              size=excluded.size, nlink=excluded.nlink, atime_ns=excluded.atime_ns,
512              mtime_ns=excluded.mtime_ns, ctime_ns=excluded.ctime_ns,
513              birthtime_ns=excluded.birthtime_ns, storage_mode=excluded.storage_mode,
514              storage_chunk_size=excluded.storage_chunk_size,
515              inline_content=excluded.inline_content, symlink_target=excluded.symlink_target,
516              xattrs_json=excluded.xattrs_json,
517              allocated_extents_json=excluded.allocated_extents_json",
518            params![
519                meta.ino,
520                kind_id(meta.kind),
521                meta.mode,
522                meta.uid,
523                meta.gid,
524                meta.size,
525                meta.nlink,
526                timespec_to_ns(meta.atime),
527                timespec_to_ns(meta.mtime),
528                timespec_to_ns(meta.ctime),
529                timespec_to_ns(meta.birthtime),
530                storage_mode,
531                storage_chunk_size,
532                inline_content,
533                meta.symlink_target,
534                xattrs_json,
535                allocated_extents_json,
536            ],
537        )
538        .map_err(|err| VfsError::eio(format!("persist SQLite inode {}: {err}", meta.ino)))?;
539    Ok(())
540}
541
542fn install_schema(connection: &mut Connection) -> VfsResult<()> {
543    install_schema_migrations(connection, LOCAL_FS_MIGRATIONS)
544}
545
546fn install_schema_migrations(
547    connection: &mut Connection,
548    migrations: &[LocalFsMigration],
549) -> VfsResult<()> {
550    validate_migration_ladder(migrations)?;
551    let latest_version = migrations.last().map_or(0, |migration| migration.version);
552    let tx = connection
553        .transaction()
554        .map_err(|err| VfsError::eio(format!("begin SQLite schema migration: {err}")))?;
555    tx.execute_batch(
556        "CREATE TABLE IF NOT EXISTS agentos_fs_schema_version (
557           singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
558           schema_version INTEGER NOT NULL CHECK (schema_version >= 0)
559         ) STRICT;",
560    )
561    .map_err(|err| VfsError::eio(format!("install SQLite schema version table: {err}")))?;
562
563    let row_count: i64 = tx
564        .query_row(
565            "SELECT COUNT(*) FROM agentos_fs_schema_version",
566            [],
567            |row| row.get(0),
568        )
569        .map_err(|err| VfsError::eio(format!("inspect SQLite schema version rows: {err}")))?;
570    let current_version = match row_count {
571        0 => 0,
572        1 => tx
573            .query_row(
574                "SELECT schema_version FROM agentos_fs_schema_version WHERE singleton = 1",
575                [],
576                |row| row.get::<_, i64>(0),
577            )
578            .map_err(|err| VfsError::eio(format!("read SQLite schema version: {err}")))?,
579        count => {
580            return Err(VfsError::eio(format!(
581                "{LOCAL_FS_SCHEMA_VERSION_TABLE} must contain at most one row; found {count}"
582            )))
583        }
584    };
585    if !(0..=latest_version).contains(&current_version) {
586        return Err(VfsError::eio(format!(
587            "unsupported {LOCAL_FS_SCHEMA_VERSION_TABLE} version {current_version}; latest supported version is {latest_version}"
588        )));
589    }
590
591    for migration in migrations
592        .iter()
593        .filter(|migration| migration.version > current_version)
594    {
595        tx.execute_batch(migration.statements).map_err(|err| {
596            VfsError::eio(format!(
597                "apply SQLite filesystem migration {}: {err}",
598                migration.version
599            ))
600        })?;
601        tx.execute(
602            "INSERT INTO agentos_fs_schema_version (singleton, schema_version)
603             VALUES (1, ?1)
604             ON CONFLICT(singleton) DO UPDATE SET schema_version = excluded.schema_version",
605            [migration.version],
606        )
607        .map_err(|err| {
608            VfsError::eio(format!(
609                "record SQLite filesystem migration {}: {err}",
610                migration.version
611            ))
612        })?;
613    }
614
615    tx.commit()
616        .map_err(|err| VfsError::eio(format!("commit SQLite schema migration: {err}")))
617}
618
619fn validate_migration_ladder(migrations: &[LocalFsMigration]) -> VfsResult<()> {
620    for (index, migration) in migrations.iter().enumerate() {
621        let expected = i64::try_from(index + 1)
622            .map_err(|_| VfsError::eio("SQLite filesystem migration version overflow"))?;
623        if migration.version != expected {
624            return Err(VfsError::eio(format!(
625                "malformed SQLite filesystem migration ladder: expected version {expected}, found {}",
626                migration.version
627            )));
628        }
629        if migration.statements.trim().is_empty() {
630            return Err(VfsError::eio(format!(
631                "malformed SQLite filesystem migration ladder: version {expected} has no statements"
632            )));
633        }
634    }
635    Ok(())
636}
637
638fn load_dump(connection: &Connection) -> VfsResult<Option<MetadataDump>> {
639    let inode_count: i64 = connection
640        .query_row("SELECT COUNT(*) FROM agentos_fs_inodes", [], |row| {
641            row.get(0)
642        })
643        .map_err(|err| VfsError::eio(format!("count SQLite inodes: {err}")))?;
644    if inode_count == 0 {
645        return Ok(None);
646    }
647
648    let mut inodes = BTreeMap::new();
649    let mut next_ino = 1;
650    let mut statement = connection
651        .prepare(
652            "SELECT ino, kind, mode, uid, gid, size, nlink, atime_ns, mtime_ns, ctime_ns,
653                    birthtime_ns, storage_mode, storage_chunk_size, inline_content, symlink_target,
654                    xattrs_json, allocated_extents_json
655             FROM agentos_fs_inodes",
656        )
657        .map_err(|err| VfsError::eio(format!("prepare inode load: {err}")))?;
658    let rows = statement
659        .query_map([], |row| {
660            let ino: u64 = row.get(0)?;
661            let kind_id: i64 = row.get(1)?;
662            let storage_id: i64 = row.get(11)?;
663            let chunk_size: Option<u32> = row.get(12)?;
664            let inline_content: Option<Vec<u8>> = row.get(13)?;
665            let symlink_target: Option<String> = row.get(14)?;
666            let xattrs_json: Vec<u8> = row.get(15)?;
667            let allocated_extents_json: Vec<u8> = row.get(16)?;
668            let kind = match kind_id {
669                0 => InodeType::File,
670                1 => InodeType::Directory,
671                2 => InodeType::Symlink,
672                3 => InodeType::CharacterDevice,
673                4 => InodeType::BlockDevice,
674                _ => InodeType::Fifo,
675            };
676            let storage = match storage_id {
677                1 => Storage::Inline(inline_content.unwrap_or_default()),
678                2 => Storage::Chunked {
679                    chunk_size: chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE),
680                },
681                _ => Storage::None,
682            };
683            Ok(InodeMeta {
684                ino,
685                kind,
686                mode: row.get(2)?,
687                uid: row.get(3)?,
688                gid: row.get(4)?,
689                size: row.get(5)?,
690                nlink: row.get(6)?,
691                atime: ns_to_timespec(row.get(7)?),
692                mtime: ns_to_timespec(row.get(8)?),
693                ctime: ns_to_timespec(row.get(9)?),
694                birthtime: ns_to_timespec(row.get(10)?),
695                storage,
696                symlink_target,
697                allocated_extents: serde_json::from_slice(&allocated_extents_json).map_err(
698                    |error| {
699                        rusqlite::Error::FromSqlConversionFailure(
700                            allocated_extents_json.len(),
701                            rusqlite::types::Type::Blob,
702                            Box::new(error),
703                        )
704                    },
705                )?,
706                xattrs: serde_json::from_slice(&xattrs_json).map_err(|error| {
707                    rusqlite::Error::FromSqlConversionFailure(
708                        xattrs_json.len(),
709                        rusqlite::types::Type::Blob,
710                        Box::new(error),
711                    )
712                })?,
713            })
714        })
715        .map_err(|err| VfsError::eio(format!("load SQLite inodes: {err}")))?;
716    for row in rows {
717        let meta = row.map_err(|err| VfsError::eio(format!("load SQLite inode row: {err}")))?;
718        next_ino = next_ino.max(meta.ino + 1);
719        inodes.insert(meta.ino, meta);
720    }
721
722    let mut dentries = BTreeMap::new();
723    let mut statement = connection
724        .prepare("SELECT parent_ino, name, child_ino FROM agentos_fs_dentries")
725        .map_err(|err| VfsError::eio(format!("prepare dentry load: {err}")))?;
726    let rows = statement
727        .query_map([], |row| {
728            Ok((
729                (row.get::<_, u64>(0)?, row.get::<_, String>(1)?),
730                row.get::<_, u64>(2)?,
731            ))
732        })
733        .map_err(|err| VfsError::eio(format!("load SQLite dentries: {err}")))?;
734    for row in rows {
735        let (key, value) =
736            row.map_err(|err| VfsError::eio(format!("load SQLite dentry row: {err}")))?;
737        dentries.insert(key, value);
738    }
739
740    let mut chunks = BTreeMap::new();
741    let mut statement = connection
742        .prepare("SELECT ino, chunk_index, block_key, len FROM agentos_fs_chunks")
743        .map_err(|err| VfsError::eio(format!("prepare chunk load: {err}")))?;
744    let rows = statement
745        .query_map([], |row| {
746            let index = row.get::<_, u64>(1)?;
747            Ok((
748                (row.get::<_, u64>(0)?, index),
749                ChunkRef {
750                    index,
751                    key: BlockKey(row.get(2)?),
752                    len: row.get(3)?,
753                },
754            ))
755        })
756        .map_err(|err| VfsError::eio(format!("load SQLite chunks: {err}")))?;
757    for row in rows {
758        let (key, value) =
759            row.map_err(|err| VfsError::eio(format!("load SQLite chunk row: {err}")))?;
760        chunks.insert(key, value);
761    }
762
763    let mut block_refs = BTreeMap::new();
764    let mut statement = connection
765        .prepare("SELECT block_key, refcount FROM agentos_fs_block_refs")
766        .map_err(|err| VfsError::eio(format!("prepare block ref load: {err}")))?;
767    let rows = statement
768        .query_map([], |row| Ok((BlockKey(row.get(0)?), row.get::<_, u64>(1)?)))
769        .map_err(|err| VfsError::eio(format!("load SQLite block refs: {err}")))?;
770    for row in rows {
771        let (key, value) =
772            row.map_err(|err| VfsError::eio(format!("load SQLite block ref row: {err}")))?;
773        block_refs.insert(key, value);
774    }
775
776    Ok(Some(MetadataDump {
777        next_ino,
778        inodes,
779        dentries,
780        chunks,
781        block_refs,
782    }))
783}
784
785fn persist_dump(connection: &mut Connection, dump: &MetadataDump) -> VfsResult<()> {
786    let tx = connection
787        .transaction()
788        .map_err(|err| VfsError::eio(format!("begin SQLite metadata transaction: {err}")))?;
789    tx.execute_batch(
790        "
791        DELETE FROM agentos_fs_snapshots;
792        DELETE FROM agentos_fs_block_refs;
793        DELETE FROM agentos_fs_chunks;
794        DELETE FROM agentos_fs_dentries;
795        DELETE FROM agentos_fs_inodes;
796        ",
797    )
798    .map_err(|err| VfsError::eio(format!("clear SQLite metadata tables: {err}")))?;
799
800    for meta in dump.inodes.values() {
801        upsert_inode(&tx, meta)?;
802    }
803
804    for ((parent, name), child) in &dump.dentries {
805        let kind = dump
806            .inodes
807            .get(child)
808            .map(|meta| meta.kind)
809            .ok_or_else(|| VfsError::eio(format!("dentry points to missing inode {child}")))?;
810        tx.execute(
811            "INSERT INTO agentos_fs_dentries (parent_ino, name, child_ino, kind) VALUES (?, ?, ?, ?)",
812            params![parent, name, child, kind_id(kind)],
813        )
814        .map_err(|err| VfsError::eio(format!("persist SQLite dentry {name}: {err}")))?;
815    }
816
817    for ((ino, index), chunk) in &dump.chunks {
818        tx.execute(
819            "INSERT INTO agentos_fs_chunks (ino, chunk_index, block_key, len) VALUES (?, ?, ?, ?)",
820            params![ino, index, chunk.key.0, chunk.len],
821        )
822        .map_err(|err| VfsError::eio(format!("persist SQLite chunk {ino}/{index}: {err}")))?;
823    }
824
825    for (key, refcount) in &dump.block_refs {
826        tx.execute(
827            "INSERT INTO agentos_fs_block_refs (block_key, refcount) VALUES (?, ?)",
828            params![key.0, refcount],
829        )
830        .map_err(|err| VfsError::eio(format!("persist SQLite block ref {}: {err}", key.0)))?;
831    }
832
833    tx.commit()
834        .map_err(|err| VfsError::eio(format!("commit SQLite metadata transaction: {err}")))
835}
836
837fn kind_id(kind: InodeType) -> i64 {
838    match kind {
839        InodeType::File => 0,
840        InodeType::Directory => 1,
841        InodeType::Symlink => 2,
842        InodeType::CharacterDevice => 3,
843        InodeType::BlockDevice => 4,
844        InodeType::Fifo => 5,
845    }
846}
847
848fn timespec_to_ns(time: Timespec) -> i64 {
849    time.sec.saturating_mul(1_000_000_000) + i64::from(time.nsec)
850}
851
852fn ns_to_timespec(ns: i64) -> Timespec {
853    Timespec {
854        sec: ns / 1_000_000_000,
855        nsec: ns.rem_euclid(1_000_000_000) as u32,
856    }
857}
858
859#[async_trait]
860impl MetadataStore for SqliteMetadataStore {
861    async fn resolve(&self, path: &str) -> VfsResult<InodeMeta> {
862        self.inner.resolve(path).await
863    }
864
865    async fn resolve_parent(&self, path: &str) -> VfsResult<(InodeMeta, String)> {
866        self.inner.resolve_parent(path).await
867    }
868
869    async fn lstat(&self, path: &str) -> VfsResult<InodeMeta> {
870        self.inner.lstat(path).await
871    }
872
873    async fn list_dir(&self, ino: u64) -> VfsResult<Vec<DentryStat>> {
874        self.inner.list_dir(ino).await
875    }
876
877    async fn create(
878        &self,
879        parent: u64,
880        name: &str,
881        attrs: CreateInodeAttrs,
882    ) -> VfsResult<InodeMeta> {
883        let result = self.inner.create(parent, name, attrs).await;
884        if let Ok(meta) = &result {
885            self.persist_create(parent, name, meta)?;
886        }
887        result
888    }
889
890    async fn link(&self, parent: u64, name: &str, target: u64) -> VfsResult<()> {
891        let result = self.inner.link(parent, name, target).await;
892        if result.is_ok() {
893            self.persist()?;
894        }
895        result
896    }
897
898    async fn remove(&self, parent: u64, name: &str) -> VfsResult<Vec<BlockKey>> {
899        let result = self.inner.remove(parent, name).await;
900        if result.is_ok() {
901            self.persist()?;
902        }
903        result
904    }
905
906    async fn rename(
907        &self,
908        src_parent: u64,
909        src: &str,
910        dst_parent: u64,
911        dst: &str,
912    ) -> VfsResult<Vec<BlockKey>> {
913        let result = self.inner.rename(src_parent, src, dst_parent, dst).await;
914        if result.is_ok() {
915            self.persist()?;
916        }
917        result
918    }
919
920    async fn set_attr(&self, ino: u64, patch: InodePatch) -> VfsResult<Vec<BlockKey>> {
921        let storage_changed = patch.storage.is_some();
922        let result = self.inner.set_attr(ino, patch).await;
923        if result.is_ok() {
924            self.persist_set_attr(ino, storage_changed)?;
925        }
926        result
927    }
928
929    async fn commit_write(
930        &self,
931        ino: u64,
932        edits: Vec<ChunkEdit>,
933        new_size: u64,
934        allocated_extents: Vec<(u64, u64)>,
935    ) -> VfsResult<Vec<BlockKey>> {
936        let chunk_size = match self.inner.inode_meta(ino)?.storage {
937            Storage::Chunked { chunk_size } => u64::from(chunk_size),
938            Storage::Inline(_) | Storage::None => u64::from(DEFAULT_CHUNK_SIZE),
939        };
940        let old_size = self.inner.inode_meta(ino)?.size;
941        let persisted_edits = edits.clone();
942        let result = self
943            .inner
944            .commit_write(ino, edits, new_size, allocated_extents)
945            .await;
946        if result.is_ok() {
947            self.persist_commit_write(ino, &persisted_edits, old_size, new_size, chunk_size)?;
948        }
949        result
950    }
951
952    async fn get_chunks(&self, ino: u64, range: ChunkRange) -> VfsResult<Vec<ChunkRef>> {
953        self.inner.get_chunks(ino, range).await
954    }
955
956    async fn snapshot(&self, root: u64) -> VfsResult<SnapshotId> {
957        self.inner.snapshot(root).await
958    }
959
960    async fn fork(&self, snap: SnapshotId) -> VfsResult<u64> {
961        let result = self.inner.fork(snap).await;
962        if result.is_ok() {
963            self.persist()?;
964        }
965        result
966    }
967
968    async fn gc(&self) -> VfsResult<Vec<BlockKey>> {
969        self.inner.gc().await
970    }
971
972    async fn flush(&self) -> VfsResult<()> {
973        self.flush_durable()
974    }
975}
976
977#[cfg(test)]
978mod writeback_tests {
979    use super::*;
980
981    #[test]
982    fn file_store_uses_writeback_sqlite_settings() {
983        let temp = tempfile::tempdir().unwrap();
984        let store = SqliteMetadataStore::open(temp.path().join("metadata.sqlite")).unwrap();
985        let connection = store.connection.lock().expect("sqlite mutex poisoned");
986        let journal_mode: String = connection
987            .query_row("PRAGMA journal_mode", [], |row| row.get(0))
988            .unwrap();
989        let synchronous: i64 = connection
990            .query_row("PRAGMA synchronous", [], |row| row.get(0))
991            .unwrap();
992
993        assert_eq!(journal_mode, "wal");
994        assert_eq!(synchronous, 1);
995    }
996}
997
998#[cfg(test)]
999mod tests {
1000    use super::*;
1001
1002    #[test]
1003    fn rejects_malformed_ladder_before_touching_database() {
1004        const MALFORMED: &[LocalFsMigration] = &[LocalFsMigration {
1005            version: 2,
1006            statements: "CREATE TABLE agentos_fs_probe (value INTEGER) STRICT;",
1007        }];
1008        let mut connection = Connection::open_in_memory().expect("open database");
1009
1010        let error = install_schema_migrations(&mut connection, MALFORMED)
1011            .expect_err("malformed ladder must fail");
1012
1013        assert!(error.message().contains("expected version 1, found 2"));
1014        let table_count: i64 = connection
1015            .query_row(
1016                "SELECT COUNT(*) FROM sqlite_schema WHERE type = 'table' AND name LIKE 'agentos_fs_%'",
1017                [],
1018                |row| row.get(0),
1019            )
1020            .expect("inspect database");
1021        assert_eq!(table_count, 0);
1022    }
1023
1024    #[test]
1025    fn rolls_back_schema_and_version_when_migration_fails() {
1026        const FAILING: &[LocalFsMigration] = &[LocalFsMigration {
1027            version: 1,
1028            statements: "CREATE TABLE agentos_fs_probe (value INTEGER CHECK (value > 0)) STRICT;
1029                         INSERT INTO agentos_fs_probe (value) VALUES (0);",
1030        }];
1031        let mut connection = Connection::open_in_memory().expect("open database");
1032
1033        install_schema_migrations(&mut connection, FAILING)
1034            .expect_err("failing migration must roll back");
1035
1036        let table_count: i64 = connection
1037            .query_row(
1038                "SELECT COUNT(*) FROM sqlite_schema WHERE type = 'table' AND name IN ('agentos_fs_schema_version', 'agentos_fs_probe')",
1039                [],
1040                |row| row.get(0),
1041            )
1042            .expect("inspect database");
1043        assert_eq!(table_count, 0);
1044    }
1045}