Move away from inodes as direct db reference

This commit is contained in:
Andrew Mulbrook 2026-03-21 12:08:54 -05:00
parent f80f4d12a2
commit e16d67e2f8
4 changed files with 375 additions and 154 deletions

View file

@ -2,8 +2,8 @@ import os
import sqlite3 import sqlite3
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, List, Self from typing import Optional, Dict, List, Self, Iterable
from .trove import NODE_ROOT_ID, Note, Trove, TreeNote, BlobNote, Blob, Tree, BadNoteType from .trove import NODE_ROOT_ID, Note, Trove, TreeNote, BlobNote, Blob, Tree, BadNoteType, TreeEntry, NoteNotFound
class FSNote(Note): class FSNote(Note):
@ -27,7 +27,9 @@ class FSNote(Note):
@property @property
def _path(self) -> Path: def _path(self) -> Path:
if self._fs_path is not None: if self._fs_path is not None:
return self._fs_path if self._fs_path.exists():
return self._fs_path
self._fs_path = None
if self._inode is None: if self._inode is None:
raise ValueError("Note not yet saved to disk") raise ValueError("Note not yet saved to disk")
self._fs_path = self._trove.get_path_by_inode(self._inode) self._fs_path = self._trove.get_path_by_inode(self._inode)
@ -42,6 +44,8 @@ class FSNote(Note):
class FSBlobNote(FSNote, BlobNote): class FSBlobNote(FSNote, BlobNote):
def read(self) -> bytes: def read(self) -> bytes:
if self._inode is None:
return b""
return self._path.read_bytes() return self._path.read_bytes()
def write(self, data: bytes) -> None: def write(self, data: bytes) -> None:
@ -101,6 +105,17 @@ class FSTreeNote(FSNote, TreeNote):
self._trove._update_cache(inode, target_path) self._trove._update_cache(inode, target_path)
return FSTreeNote(self._trove, inode=inode, path=target_path) return FSTreeNote(self._trove, inode=inode, path=target_path)
def entries(self) -> Iterable[TreeEntry]:
try:
for item in self._path.iterdir():
if item.name == ".trove":
continue
inode = item.stat().st_ino
self._trove._update_cache(inode, item)
yield TreeEntry(name=item.name, object_id=inode)
except OSError:
pass
def list(self) -> dict[str, int]: def list(self) -> dict[str, int]:
res = {} res = {}
try: try:
@ -113,9 +128,16 @@ class FSTreeNote(FSNote, TreeNote):
pass pass
return res return res
def child(self, name: str) -> Note:
"""Retrieve a child not by name."""
target_path = self._path / name
return self._trove.get_raw_note_by_path(target_path)
class FSTrove(Trove): class FSTrove(Trove):
def __init__(self, root: str | Path): def __init__(self, root: str | Path):
self.root = Path(root).absolute() self.root = Path(root).absolute()
self._root_inode = self.root.stat().st_ino
self.dot_trove = self.root / ".trove" self.dot_trove = self.root / ".trove"
self.working = self.dot_trove / ".working" self.working = self.dot_trove / ".working"
@ -160,7 +182,7 @@ class FSTrove(Trove):
self.con.commit() self.con.commit()
def get_path_by_inode(self, inode: int) -> Optional[Path]: def get_path_by_inode(self, inode: int) -> Optional[Path]:
if inode == NODE_ROOT_ID: if inode == self._root_inode:
return self.root return self.root
row = self.con.execute("SELECT path FROM cache WHERE inode = ?", (inode,)).fetchone() row = self.con.execute("SELECT path FROM cache WHERE inode = ?", (inode,)).fetchone()
@ -189,14 +211,21 @@ class FSTrove(Trove):
continue continue
return None return None
def get_raw_note(self, note_id: int) -> Optional[Note]: def get_raw_note_by_path(self, target_path: Path) -> Note:
if not target_path.exists():
raise NoteNotFound(target_path.relative_to(self.root))
note_id = target_path.stat().st_ino
if target_path.is_dir():
return FSTreeNote(self, inode=note_id, path=target_path)
else:
return FSBlobNote(self, inode=note_id, path=target_path)
def get_raw_note(self, note_id: int) -> Note:
p = self.get_path_by_inode(note_id) p = self.get_path_by_inode(note_id)
if not p: if not p:
return None raise NoteNotFound(note_id)
if p.is_dir(): return self.get_raw_note_by_path(p)
return FSTreeNote(self, inode=note_id, path=p)
else:
return FSBlobNote(self, inode=note_id, path=p)
def create_blob(self, data: bytes | None = None) -> BlobNote: def create_blob(self, data: bytes | None = None) -> BlobNote:
fd, temp_path = tempfile.mkstemp(dir=self.working) fd, temp_path = tempfile.mkstemp(dir=self.working)
@ -211,7 +240,7 @@ class FSTrove(Trove):
return FSBlobNote(self, inode=inode, path=p) return FSBlobNote(self, inode=inode, path=p)
def get_root(self) -> TreeNote: def get_root(self) -> TreeNote:
return FSTreeNote(self, inode=NODE_ROOT_ID, path=self.root) return FSTreeNote(self, inode=self._root_inode, path=self.root)
def _get_metadata(self, inode: int, key: str) -> Optional[bytes]: def _get_metadata(self, inode: int, key: str) -> Optional[bytes]:
row = self.con.execute("SELECT value FROM metadata WHERE inode = ? AND key = ?", (inode, key)).fetchone() row = self.con.execute("SELECT value FROM metadata WHERE inode = ? AND key = ?", (inode, key)).fetchone()

View file

@ -13,34 +13,184 @@ import errno
import os import os
import stat import stat
import time import time
import logging
from typing import Sequence, Tuple, cast
import pyfuse3 import pyfuse3
import trio import trio
from pyfuse3 import InodeT, FileHandleT
from trovedb.trove import Trove, Note, Tree as TroveTree, TreeNote, Blob as TroveBlob from trovedb.trove import Trove, Note, Tree as TroveTree, TreeNote, Blob as TroveBlob, ObjectId, TreeExists
logger = logging.getLogger(__name__)
class _TroveEntry:
__slots__ = [ 'object_id', 'sys_inode', 'ref_count' ]
def __init__(self, sys_inode: InodeT, object_id: ObjectId | None):
self.object_id: ObjectId | None = object_id
self.sys_inode: InodeT = sys_inode
self.ref_count = 0
def ref(self) -> None:
self.ref_count += 1
def deref(self, count: int = 1) -> bool:
assert self.ref_count > 0
self.ref_count -= count
return self.ref_count <= 0
class _TroveHandle:
__slots__ = [ 'inode_id', 'ref_count', 'note', 'handle_id' ]
def __init__(self, inode_id: InodeT, handle_id: FileHandleT, note: Note):
self.inode_id = inode_id
self.handle_id = handle_id
self.note = note
class _TroveHandleTree(_TroveHandle):
@property
def tree(self) -> TreeNote:
return cast(TreeNote, self.note)
class TroveFuseOps(pyfuse3.Operations): class TroveFuseOps(pyfuse3.Operations):
enable_writeback_cache = False
def __init__(self, trove: Trove): def __init__(self, trove: Trove):
super().__init__() super().__init__()
self._trove = trove self._trove = trove
# Inode Cache
self._next_inode = 2
self._inode_cache: dict[InodeT, _TroveEntry] = {}
self._inode_reverse_cache: dict[ObjectId, InodeT] = {}
# Cache and Lock Root Inode
node_root = trove.get_root()
self._inode_cache[pyfuse3.ROOT_INODE] = _TroveEntry(pyfuse3.ROOT_INODE, node_root.object_id)
self._inode_reverse_cache[node_root.object_id] = pyfuse3.ROOT_INODE
self._inode_cache[pyfuse3.ROOT_INODE].ref()
self._inode_cache[pyfuse3.ROOT_INODE].ref()
# Handles
self._next_handle = 2
self._handles: dict[int, _TroveHandle] = {}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Helpers # Entry Management [entries relate inode to Note]
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _note_or_error(self, inode: int): def _get_ent_from_inode(self, inode: InodeT) -> _TroveEntry:
note = self._trove.get_raw_note(inode) """Get entry from predefined inode"""
if inode not in self._inode_cache:
logger.debug("inode not found in cache: %d", inode)
raise pyfuse3.FUSEError(errno.ENOENT)
value = self._inode_cache[inode]
return value
def _create_get_ent_from_note(self, note: Note) -> _TroveEntry:
"""Create entry from note. Inode is reserved but not saved in cache."""
if note.object_id in self._inode_reverse_cache:
sys_inode = self._inode_reverse_cache[note.object_id]
return self._inode_cache[sys_inode]
sys_inode = InodeT(self._next_inode)
self._next_inode += 1
return _TroveEntry(sys_inode=sys_inode, object_id=note.object_id)
def _ref_entry(self, ent: _TroveEntry) -> None:
"""Ref entry. If it is not in cache, it is added to cache."""
if ent.sys_inode not in self._inode_cache:
self._inode_cache[ent.sys_inode] = ent
self._inode_reverse_cache[ent.object_id] = ent.sys_inode
ent.ref()
def _deref_entry(self, ent: _TroveEntry, count: int = 1) -> None:
"""Deref entry. Remove from cache if count hits 0"""
if ent.deref(count):
if ent.sys_inode in self._inode_cache:
logger.debug("free inode: %d", ent.sys_inode)
del self._inode_cache[ent.sys_inode]
del self._inode_reverse_cache[ent.object_id]
def _get_inode_note(self, inode: InodeT) -> Note:
"""Get note from Inode, inode must be reserved"""
ent = self._get_ent_from_inode(inode)
return self._get_ent_note(ent)
def _get_ent_note(self, ent: _TroveEntry) -> Note:
"""Get note from entry."""
note = self._trove.get_raw_note(ent.object_id)
if note is None: if note is None:
logger.debug("note lookup failed: %s", ent.object_id)
raise pyfuse3.FUSEError(errno.ENOENT) raise pyfuse3.FUSEError(errno.ENOENT)
return note return note
def _make_attr(self, inode: int, is_tree: bool, size: int = 0) -> pyfuse3.EntryAttributes: def _lookup_update_object(self, object_id: ObjectId) -> _TroveEntry:
if object_id in self._inode_reverse_cache:
inode = self._inode_reverse_cache[object_id]
return self._lookup_existing(inode)
else:
inode_id = InodeT(self._next_inode)
self._next_inode += 1
inode = _TroveEntry(sys_inode=inode_id, object_id=object_id)
self._inode_cache[inode_id] = inode
self._inode_reverse_cache[object_id] = inode_id
return inode
def _lookup_child(self, parent_inode: InodeT, name: bytes) -> Tuple[_TroveEntry, Note]:
parent = self._get_inode_note(parent_inode)
if not isinstance(parent, TreeNote):
raise pyfuse3.FUSEError(errno.ENOTDIR)
try:
note = parent.child(name.decode())
except KeyError:
logger.debug("lookup failed: %d -> %s", parent_inode, name.decode())
raise pyfuse3.FUSEError(errno.ENOENT) from None
ent = self._create_get_ent_from_note(note)
return ent, note
def _get_sys_inode_id(self, object_id: ObjectId) -> InodeT:
if object_id in self._inode_reverse_cache:
return self._inode_reverse_cache[object_id]
else:
raise pyfuse3.FUSEError(errno.ENOENT)
# ------------------------------------------------------------------
# Handle Management
# ------------------------------------------------------------------
def _open_handle(self, inode: InodeT) -> _TroveHandle:
note = self._get_inode_note(inode)
handle_id = FileHandleT(self._next_handle)
self._next_handle += 1
handle: _TroveHandle
if isinstance(note, TreeNote):
handle = _TroveHandleTree(inode_id=inode, handle_id=handle_id, note=note)
else:
handle = _TroveHandle(inode_id=inode, handle_id=handle_id, note=note)
self._handles[handle_id] = handle
return handle
def _get_handle(self, handle_id: FileHandleT) -> _TroveHandle:
if not handle_id in self._handles:
raise pyfuse3.FUSEError(errno.EBADF)
return self._handles[handle_id]
def _close_handle(self, handle: _TroveHandle):
del self._handles[handle.handle_id]
def _get_attr(self, ent: _TroveEntry, note: Note) -> pyfuse3.EntryAttributes:
# Determine basic information
is_tree = True
size = 0
if isinstance(note, TroveBlob):
size = len(note.read())
is_tree = False
# Create and fill attr structure
attr = pyfuse3.EntryAttributes() attr = pyfuse3.EntryAttributes()
attr.st_ino = pyfuse3.InodeT(inode) attr.st_ino = ent.sys_inode
attr.st_nlink = 1 attr.st_nlink = 1
attr.st_uid = os.getuid() attr.st_uid = os.getuid()
attr.st_gid = os.getgid() attr.st_gid = os.getgid()
@ -63,116 +213,128 @@ class TroveFuseOps(pyfuse3.Operations):
attr.st_blocks = (size + 511) // 512 attr.st_blocks = (size + 511) // 512
return attr return attr
def _attr_for_note(self, note: Note) -> pyfuse3.EntryAttributes:
size = 0
is_tree = True
if isinstance(note, TroveBlob):
size = len(note.read())
is_tree = False
return self._make_attr(note.object_id, is_tree, size)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Stat / lookup # Stat / lookup
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def getattr(self, inode: int, ctx=None) -> pyfuse3.EntryAttributes: async def getattr(self, inode: InodeT, ctx=None) -> pyfuse3.EntryAttributes:
note = self._note_or_error(inode) logger.debug("getattr inode:%d", inode)
return self._attr_for_note(note) ent = self._get_ent_from_inode(inode)
note = self._get_ent_note(ent)
return self._get_attr(ent, note)
async def lookup(self, parent_inode: int, name: bytes, ctx=None) -> pyfuse3.EntryAttributes: async def lookup(self, parent_inode: InodeT, name: bytes, ctx=None) -> pyfuse3.EntryAttributes:
parent = self._note_or_error(parent_inode) logger.debug("lookup inode:%d name:%s", parent_inode, name)
if not isinstance(parent, TroveTree): ent, child = self._lookup_child(parent_inode, name)
raise pyfuse3.FUSEError(errno.ENOTDIR) self._ref_entry(ent)
entries = parent.list() return self._get_attr(ent, child)
name_str = name.decode()
if name_str not in entries:
raise pyfuse3.FUSEError(errno.ENOENT)
child = self._trove.get_raw_note(entries[name_str])
if child is None:
raise pyfuse3.FUSEError(errno.ENOENT)
return self._attr_for_note(child)
async def setattr(self, inode: int, attr, fields, fh, ctx) -> pyfuse3.EntryAttributes: async def setattr(self, inode: InodeT, attr, fields, fh: FileHandleT | None, ctx) -> pyfuse3.EntryAttributes:
note = self._note_or_error(inode) ent = self._get_ent_from_inode(inode)
if fields.update_size and not isinstance(note, TroveTree): note = self._get_ent_note(ent)
current = note.read() if fields.update_size:
new_size = attr.st_size if isinstance(note, TroveBlob):
if new_size < len(current): current = note.read()
note.write(current[:new_size]) new_size = attr.st_size
elif new_size > len(current): if new_size < len(current):
note.write(current + b"\x00" * (new_size - len(current))) note.write(current[:new_size])
return self._attr_for_note(note) elif new_size > len(current):
note.write(current + b"\x00" * (new_size - len(current)))
else:
raise pyfuse3.FUSEError(errno.EINVAL)
return self._get_attr(ent, note)
async def forget(self, inode_list) -> None: def forget(self, inode_list: Sequence[Tuple[InodeT, int]]) -> None:
pass for inode, nlookup in inode_list:
try:
logger.debug("deref inode:%d count:%d", inode, nlookup)
self._deref_entry(self._get_ent_from_inode(inode), nlookup)
except pyfuse3.FUSEError as e:
logger.warning("Failed to deref inode %d: %s", inode, str(e))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Directory ops # Directory ops
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def opendir(self, inode: int, ctx) -> pyfuse3.FileHandleT: async def opendir(self, inode: InodeT, ctx) -> FileHandleT:
note = self._note_or_error(inode) handle = self._open_handle(inode)
if not isinstance(note, TroveTree): if not isinstance(handle, _TroveHandleTree):
logger.debug("attempted opendir on %d not a tree", inode)
self._close_handle(handle)
raise pyfuse3.FUSEError(errno.ENOTDIR) raise pyfuse3.FUSEError(errno.ENOTDIR)
return pyfuse3.FileHandleT(inode) logger.debug("opened dir inode %d -> handle %d", inode, handle.handle_id)
return handle.handle_id
async def readdir(self, fh: int, start_id: int, token) -> None: async def readdir(self, fh: FileHandleT, start_id: int, token) -> None:
note = self._note_or_error(fh) logger.debug("readdir %d start_id %d", fh, start_id)
handle = self._get_handle(fh)
note = handle.note
if not isinstance(note, TroveTree): if not isinstance(note, TroveTree):
logger.debug("attempted readdir on %d not a tree", fh)
raise pyfuse3.FUSEError(errno.ENOTDIR) raise pyfuse3.FUSEError(errno.ENOTDIR)
entries = list(note.list().items()) # [(name, object_id), ...] entries = list(note.list().items()) # [(name, object_id), ...]
for idx, (name, child_id) in enumerate(entries): for idx, (name, child_id) in enumerate(entries):
if idx < start_id: if idx < start_id:
continue continue
child = self._trove.get_raw_note(child_id) child = self._trove.get_raw_note(child_id)
if child is None: if child is None:
continue continue
attr = self._attr_for_note(child)
child_ent = self._create_get_ent_from_note(child)
attr = self._get_attr(child_ent, child)
self._ref_entry(child_ent)
if not pyfuse3.readdir_reply(token, name.encode(), attr, idx + 1): if not pyfuse3.readdir_reply(token, name.encode(), attr, idx + 1):
break break
async def releasedir(self, fh: int) -> None: async def releasedir(self, fh: FileHandleT) -> None:
pass logger.debug("releasedir %d", fh)
handle = self._get_handle(fh)
self._close_handle(handle)
async def mkdir(self, parent_inode: int, name: bytes, mode: int, ctx) -> pyfuse3.EntryAttributes: async def mkdir(self, parent_inode: InodeT, name: bytes, mode: int, ctx) -> pyfuse3.EntryAttributes:
parent = self._note_or_error(parent_inode) logger.debug("mkdir inode:%d name:%s", parent_inode, name)
# Grab parent note, verify is tree
parent = self._get_inode_note(parent_inode)
if not isinstance(parent, TreeNote): if not isinstance(parent, TreeNote):
raise pyfuse3.FUSEError(errno.ENOTDIR) raise pyfuse3.FUSEError(errno.ENOTDIR)
name_str = name.decode() # Create new directory in note
if name_str in parent.list(): try:
raise pyfuse3.FUSEError(errno.EEXIST) new_tree: TreeNote = parent.mkdir(name.decode())
new_tree: TreeNote = parent.mkdir(name_str) except TreeExists:
return self._make_attr(new_tree.object_id, True, 0) raise pyfuse3.FUSEError(errno.EEXIST) from None
# Grab entity for kernel
ent = self._create_get_ent_from_note(new_tree)
self._ref_entry(ent)
return self._get_attr(ent, new_tree)
async def rmdir(self, parent_inode: int, name: bytes, ctx) -> None: async def rmdir(self, parent_inode: InodeT, name: bytes, ctx) -> None:
parent = self._note_or_error(parent_inode) logger.debug("rmdir inode:%d name:%s", parent_inode, name)
if not isinstance(parent, TroveTree): parent = self._get_inode_note(parent_inode)
if not isinstance(parent, TreeNote):
raise pyfuse3.FUSEError(errno.ENOTDIR) raise pyfuse3.FUSEError(errno.ENOTDIR)
name_str = name.decode() try:
entries = parent.list() parent.unlink(name.decode())
if name_str not in entries: except KeyError:
raise pyfuse3.FUSEError(errno.ENOENT) raise pyfuse3.FUSEError(errno.ENOENT) from None
child = self._trove.get_raw_note(entries[name_str])
if child is None:
raise pyfuse3.FUSEError(errno.ENOENT)
if not isinstance(child, TroveTree):
raise pyfuse3.FUSEError(errno.ENOTDIR)
if child.list():
raise pyfuse3.FUSEError(errno.ENOTEMPTY)
parent.unlink(name_str)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# File ops # File ops
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def open(self, inode: int, flags, ctx) -> pyfuse3.FileInfo: async def open(self, inode: InodeT, flags, ctx) -> pyfuse3.FileInfo:
note = self._note_or_error(inode) handle = self._open_handle(inode)
if isinstance(note, TroveTree): if isinstance(handle.note, TroveTree):
self._close_handle(handle)
raise pyfuse3.FUSEError(errno.EISDIR) raise pyfuse3.FUSEError(errno.EISDIR)
return pyfuse3.FileInfo(fh=pyfuse3.FileHandleT(inode)) return pyfuse3.FileInfo(fh=handle.handle_id)
async def create(self, parent_inode: int, name: bytes, mode: int, flags, ctx) -> tuple: async def create(self, parent_inode: InodeT, name: bytes, mode: int, flags, ctx) -> tuple:
parent = self._note_or_error(parent_inode) logger.debug("create inode:%d name:%s", parent_inode, name)
parent = self._get_inode_note(parent_inode)
if not isinstance(parent, TroveTree): if not isinstance(parent, TroveTree):
raise pyfuse3.FUSEError(errno.ENOTDIR) raise pyfuse3.FUSEError(errno.ENOTDIR)
name_str = name.decode() name_str = name.decode()
@ -180,67 +342,66 @@ class TroveFuseOps(pyfuse3.Operations):
raise pyfuse3.FUSEError(errno.EEXIST) raise pyfuse3.FUSEError(errno.EEXIST)
blob = self._trove.create_blob(b"") blob = self._trove.create_blob(b"")
parent.link(name_str, blob) parent.link(name_str, blob)
attr = self._make_attr(blob.object_id, False, 0)
return pyfuse3.FileInfo(fh=pyfuse3.FileHandleT(blob.object_id)), attr
async def read(self, fh: int, offset: int, length: int) -> bytes: ent = self._create_get_ent_from_note(blob)
note = self._note_or_error(fh) self._ref_entry(ent)
return note.read()[offset:offset + length]
async def write(self, fh: int, offset: int, data: bytes) -> int: handle = self._open_handle(ent.sys_inode)
note = self._note_or_error(fh) attr = self._get_attr(ent, blob)
existing = note.read() return pyfuse3.FileInfo(fh=handle.handle_id), attr
if offset > len(existing):
existing = existing + b"\x00" * (offset - len(existing)) async def read(self, fh: FileHandleT, offset: int, length: int) -> bytes:
note.write(existing[:offset] + data + existing[offset + len(data):]) logger.debug("read fh:%d offset:%d length:%d", fh, offset, length)
handle = self._get_handle(fh)
note = handle.note
if isinstance(note, TroveBlob):
return note.read()[offset:offset + length]
raise pyfuse3.FUSEError(errno.EBADF)
async def write(self, fh: FileHandleT, offset: int, data: bytes) -> int:
handle = self._get_handle(fh)
note = handle.note
if isinstance(note, TroveBlob):
existing = note.read()
if offset > len(existing):
existing = existing + b"\x00" * (offset - len(existing))
note.write(existing[:offset] + data + existing[offset + len(data):])
return len(data) return len(data)
async def release(self, fh: int) -> None: async def release(self, fh: FileHandleT) -> None:
pass handle = self._get_handle(fh)
self._close_handle(handle)
async def unlink(self, parent_inode: int, name: bytes, ctx) -> None: async def unlink(self, parent_inode: InodeT, name: bytes, ctx) -> None:
parent = self._note_or_error(parent_inode) parent_note = self._get_inode_note(parent_inode)
if not isinstance(parent, TroveTree): if not isinstance(parent_note, TroveTree):
raise pyfuse3.FUSEError(errno.ENOTDIR) raise pyfuse3.FUSEError(errno.ENOTDIR)
name_str = name.decode() name_str = name.decode()
entries = parent.list() if name_str not in parent_note.list():
if name_str not in entries:
raise pyfuse3.FUSEError(errno.ENOENT) raise pyfuse3.FUSEError(errno.ENOENT)
child = self._trove.get_raw_note(entries[name_str]) parent_note.unlink(name.decode())
if child is None:
raise pyfuse3.FUSEError(errno.ENOENT)
if isinstance(child, TroveTree):
raise pyfuse3.FUSEError(errno.EISDIR)
parent.unlink(name_str)
async def rename(self, parent_inode_old, name_old, parent_inode_new, name_new, flags, ctx): async def rename(self, parent_inode_old: InodeT, name_old: bytes, parent_inode_new: InodeT, name_new: bytes, flags, ctx):
old_parent = self._note_or_error(parent_inode_old) # Decode / validate names
new_parent = self._note_or_error(parent_inode_new) name_new_str = name_new.decode()
if not isinstance(old_parent, TroveTree) or not isinstance(new_parent, TroveTree): name_old_str = name_old.decode()
# Grab the parents
new_parent = self._get_inode_note(parent_inode_new)
if not isinstance(new_parent, TroveTree):
raise pyfuse3.FUSEError(errno.ENOTDIR)
old_parent = self._get_inode_note(parent_inode_old)
if not isinstance(old_parent, TroveTree):
raise pyfuse3.FUSEError(errno.ENOTDIR) raise pyfuse3.FUSEError(errno.ENOTDIR)
name_old_str = name_old.decode() # We want to maintain the inode - find the note via the internal entity
name_new_str = name_new.decode() ent, note = self._lookup_child(parent_inode_old, name_old)
old_entries = old_parent.list() # Remove existing target
if name_old_str not in old_entries: new_parent.unlink(name_new_str)
raise pyfuse3.FUSEError(errno.ENOENT)
child_id = old_entries[name_old_str] # Link to new parent, unlink from old
child = self._trove.get_raw_note(child_id) new_parent.link(name_new_str, note)
if child is None:
raise pyfuse3.FUSEError(errno.ENOENT)
# Remove existing target if present
new_entries = new_parent.list()
if name_new_str in new_entries:
target = self._trove.get_raw_note(new_entries[name_new_str])
if target is not None and isinstance(target, TroveTree):
if target.list():
raise pyfuse3.FUSEError(errno.ENOTEMPTY)
new_parent.unlink(name_new_str)
new_parent.link(name_new_str, child)
old_parent.unlink(name_old_str) old_parent.unlink(name_old_str)
@ -249,6 +410,8 @@ class TroveFuseOps(pyfuse3.Operations):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def _run(ops: TroveFuseOps, mountpoint: str) -> None: async def _run(ops: TroveFuseOps, mountpoint: str) -> None:
logging.basicConfig(level=logging.DEBUG)
options = set(pyfuse3.default_options) options = set(pyfuse3.default_options)
options.add("fsname=trove") options.add("fsname=trove")
pyfuse3.init(ops, mountpoint, options) pyfuse3.init(ops, mountpoint, options)

View file

@ -1,12 +1,20 @@
from typing import Protocol, runtime_checkable, Optional, Dict, List, Self from typing import Protocol, runtime_checkable, Optional, Dict, List, Self, NamedTuple, Iterable, MappingView
from uuid import UUID from uuid import UUID
from pathlib import PurePosixPath from pathlib import PurePosixPath
NODE_ROOT_ID = 1
type ObjectId = int
NODE_ROOT_ID: ObjectId = 1
class BadNoteType(TypeError): class BadNoteType(TypeError):
"""Raised when an invalid note type is encountered.""" """Raised when an invalid note type is encountered."""
class TreeExists(TypeError):
"""Raised when a label already exists."""
class NoteNotFound(KeyError):
"""Raised when a note is not found."""
@runtime_checkable @runtime_checkable
class Note(Protocol): class Note(Protocol):
@ -15,7 +23,7 @@ class Note(Protocol):
Represents access to an individual note's content and metadata. Represents access to an individual note's content and metadata.
""" """
@property @property
def object_id(self) -> int: def object_id(self) -> ObjectId:
"""The unique identifier for this note.""" """The unique identifier for this note."""
... ...
@ -37,6 +45,11 @@ class Blob(Protocol):
"""Write new content to the note.""" """Write new content to the note."""
... ...
class TreeEntry(NamedTuple):
name: str
object_id: ObjectId
@runtime_checkable @runtime_checkable
class Tree(Protocol): class Tree(Protocol):
def link(self, name: str, note: Note): def link(self, name: str, note: Note):
@ -56,7 +69,11 @@ class Tree(Protocol):
... ...
def child(self, name: str) -> Note: def child(self, name: str) -> Note:
"""Retrieve a child not by name.""" """Retrieve a child note by name."""
...
def entries(self) -> Iterable[TreeEntry]:
"""Return all entries in the directory"""
... ...
def list(self) -> dict[str, int]: def list(self) -> dict[str, int]:
@ -78,8 +95,8 @@ class Trove(Protocol):
Provides high-level access to notes and trees. Provides high-level access to notes and trees.
""" """
def get_raw_note(self, note: int) -> Optional[Note]: def get_raw_note(self, note: ObjectId) -> Note:
"""Retrieve a note by a UUID""" """Retrieve a note by a object id"""
... ...
def create_blob(self, data: bytes | None = None) -> BlobNote: def create_blob(self, data: bytes | None = None) -> BlobNote:

View file

@ -11,7 +11,7 @@ from pathlib import Path
from .db import Sqlite3Trove from .db import Sqlite3Trove
from .tree import Tree as TreeData from .tree import Tree as TreeData
from .trove import NODE_ROOT_ID, Note, Trove, TreeNote, BlobNote from .trove import NODE_ROOT_ID, Note, Trove, TreeNote, BlobNote, TreeEntry, NoteNotFound
class NoteImpl(Note): class NoteImpl(Note):
@ -65,9 +65,12 @@ class TreeNoteImpl(NoteImpl, TreeNote):
def unlink(self, name: str) -> None: def unlink(self, name: str) -> None:
"""Remove an entry by name. Raises KeyError if not found.""" """Remove an entry by name. Raises KeyError if not found."""
tree = self._read_tree() try:
tree.rm_entry(name) tree = self._read_tree()
self._flush_tree(tree) tree.rm_entry(name)
self._flush_tree(tree)
except KeyError:
pass
def mkdir(self, name: str) -> 'TreeNoteImpl': def mkdir(self, name: str) -> 'TreeNoteImpl':
"""Create a new empty tree, link it under name, and return it.""" """Create a new empty tree, link it under name, and return it."""
@ -91,7 +94,16 @@ class TreeNoteImpl(NoteImpl, TreeNote):
if name not in entries: if name not in entries:
raise KeyError(f"Entry '{name}' not found") raise KeyError(f"Entry '{name}' not found")
child_id = entries[name] child_id = entries[name]
return self._parent.get_raw_note(child_id) value = self._parent.get_raw_note(child_id)
if value is None:
raise KeyError(f"Entry '{name}' has no value")
return value
def entries(self):
"""Return all entries as an iterable of TreeEntry."""
tree = self._read_tree()
for name, object_id in tree.list().items():
yield TreeEntry(name, object_id)
def list(self) -> dict[str, int]: def list(self) -> dict[str, int]:
"""Return all entries as {name: object_id}.""" """Return all entries as {name: object_id}."""
@ -138,11 +150,11 @@ class TroveImpl:
self.close() self.close()
# Trove protocol # Trove protocol
def get_raw_note(self, note_id: int) -> Optional[Note]: def get_raw_note(self, note_id: int) -> Note:
"""Return a BlobNote or TreeNote for the given id, or None if not found.""" """Return a BlobNote or TreeNote for the given id, or None if not found."""
ot = self._db.get_object_type(note_id) ot = self._db.get_object_type(note_id)
if ot is None: if ot is None:
return None raise NoteNotFound(note_id)
if ot == "blob": if ot == "blob":
return BlobNoteImpl(self, note_id) return BlobNoteImpl(self, note_id)
if ot == "tree": if ot == "tree":