12 KiB
The orphans provider — a store garbage-collection view
Context and problem statement
The content-addressed store (architecture/crabidy-store.md) never shrinks on
its own. Capturing writes audio + a <name>.cbd-store.toml sidecar into
~/.local/share/crabidy/; deleting a save (or the current queue rolling over,
or a scan --capture toml being removed) only ever removes the toml that
pointed at a store entry — never the entry itself (store D7). That is
deliberate: a store entry may be shared by many tomls, so no single deletion can
know it is safe to reclaim. The consequence, called out as future work in store
D10, is that store entries accumulate that no toml references any more.
There is today no way to see them or reclaim their disk.
This design adds a read-mostly management provider, /orphans, that surfaces
exactly those unreferenced store entries and lets the user rename or delete them,
or queue them for a listen before deciding. It is the store's garbage-collection
UI, expressed as an ordinary library subtree so it needs no new client concepts.
This realizes store D10 (orphan reclamation). It changes nothing about how
captures are written or de-duplicated; it only reads the store's residue and
offers targeted rename/delete.
Assumptions
- Confirmed by the request.
/orphanslists every store item, walks all local file providers, crosses off referenced items, and presents the rest; entries are renamable (audio file and sidecar), deletable (files on disk), and queueable. - "Referenced" means reachable through a mounted local file provider. The
reference scan walks the disk roots of the running file providers — the
/crabidytoml tree (~/.local/state/crabidy/, which holdscurrent, every save, every capture) and the/fsroot (which can holdPlayable::Storetomls written byscan --capture/--move). A.cbd-track.tomlthat lives outside every mounted provider root (e.g.scan --capturerun on a folder that is not under/fs) is invisible to the walk, so its target counts as an orphan. This is the only sound definition available without a global reference index, and it matches the request's wording ("walks all local file providers"). It is a documented boundary, not a bug (see Risks). - Single writer. As with the rest of the store, one process owns both roots and serializes mutations under the store's index mutex; there is no concurrent external writer.
- Orphan-ness is recomputed on every listing. There is no persisted orphan
list — consistent with
fsdy's "read the tree fresh every visit" philosophy. A capture that adds a reference makes an entry stop being an orphan on the next listing.
What is a "store item" and when is it an orphan
- A store item is a pair in
store_root: an audio file<name>and its sidecar<name>.cbd-store.toml. The sidecar is the source of truth (storeD2); the set of items is the set of sidecars that have a readable audio file beside them. (A sidecar without audio, or audio without a sidecar, is malformed residue — reported so it can be reclaimed too; see D4.) - A store item
<name>is referenced iff some.cbd-track.tomlunder a mounted file-provider root validates toPlayable::Store(<name>). - Orphans = all store items − referenced store items.
Because /orphans only ever exposes unreferenced entries, renaming or deleting
one cannot break any toml reference — that is what makes the destructive
operations safe by construction (subject to the narrow race in Risks).
Options considered
Presentation: tracks vs. child nodes
Each orphan must be renamable, deletable, and queueable. The wire has two carriers with capability flags:
Track— hasis_captured/is_skippedbut nois_editable. Tracks can be deleted (tracks_deletable) and queued, but the library has no rename-a-track gesture anywhere; adding one means new proto surface plus TUI and web changes.LibraryNodeChild— already carriesis_editable,is_deletable,is_queable,is_captured. The node-editing feature already bindse→rename_lib_node(child_path, new_title)andd→delete_lib_node(child_path)for children that advertise the flags (that is how/tidal/searchterms and/crabidysaves are renamed/deleted today), and queueing a queueable child resolves its tracks.
Decision: present each orphan as an editable + deletable + queueable child
node of /orphans, titled by its store file <name> (the thing a rename
edits). Entering the node lists its single track (the store audio, with metadata
from the sidecar); queueing the node — or the whole /orphans root — resolves
that track. This reuses rename_lib_node/delete_lib_node/resolve_tracks_into
and every client gesture with zero proto, TUI, or web changes. The only
minor wart — each orphan is a one-track "folder" — is acceptable and is exactly
how a single-track save already presents. The track-carrier option was rejected
purely on cost: it buys nothing the node model lacks and forces a wire change
just to gain a rename gesture.
Home of the logic: new crate vs. server module
The orphan computation needs the store root and index (to enumerate items and
mutate them) and the file-provider disk roots (to find references). A standalone
orphandy crate would have to duplicate CrabidyStore internals it does not own.
Decision: keep it in crabidy-server. Orphan enumeration, rename, and delete
become methods on CrabidyStore (it already owns store_root and the index). A
thin new OrphansProvider (crabidy-server/src/orphans.rs) implements
ProviderClient, holding Arc<CrabidyStore> plus the list of reference roots to
walk, and delegates to those store methods. ProviderOrchestrator mounts and
routes /orphans exactly like the other providers.
Boundaries and interfaces
direction: right
tui: TUI / web / cbd-cli { shape: person }
orchestrator: ProviderOrchestrator {
routes: "routes /orphans/*"
}
orphans: OrphansProvider {
refroots: "ref_roots: Vec<PathBuf>"
}
store: CrabidyStore {
index: "StoreIndex (by_hash / by_provider_id)"
ops: "list_orphans / rename_orphan / delete_orphan"
}
data: content store\n~/.local/share/crabidy {
shape: cylinder
items: "<name> + <name>.cbd-store.toml"
}
crabidytree: /crabidy tree\n~/.local/state/crabidy { shape: cylinder }
fstree: /fs root { shape: cylinder }
tui -> orchestrator: "get / rename / delete / queue /orphans/*"
orchestrator -> orphans: delegate
orphans -> store: "enumerate + mutate (by name)"
store -> data: "read sidecars, rename/delete files"
orphans -> crabidytree: "walk for Playable::Store refs"
orphans -> fstree: "walk for Playable::Store refs"
The orphan diff (what a listing computes)
direction: down
allitems: "all store items\n(scan *.cbd-store.toml in store_root)"
refs: "referenced set\n(walk ref_roots for\nPlayable::Store(name))"
diff: "orphans = all − referenced" { shape: diamond }
node: "/orphans node:\none editable/deletable/queueable\nchild per orphan"
allitems -> diff
refs -> diff
diff -> node
Provider surface (OrphansProvider: ProviderClient)
Mounted at /orphans only when crabidy_store is present (it is the store's
view). Paths: the root /orphans, and one child per orphan at
/orphans/<encode_segment(name)>. There are no deeper levels.
get_lib_root/get_lib_node("/orphans")— a queueable, non-creatable node whose children are the current orphans (recomputed by the diff above). Each child:title = <name>,is_queable = true,is_editable = true,is_deletable = true,is_downloadable = false,is_captured = true.get_lib_node("/orphans/<seg>")— a queueable, childless node carrying the singleTrackfor that store entry (metadata from the sidecar's first provider entry;is_captured = true). Unknown/renamed-away segment →MalformedPath.is_track_path— alwaysfalse: orphans are addressed as nodes, and the one track is reached by resolving the node (so the defaultresolve_tracks_intowalk queues it).get_metadata_for_tracktherefore is not the entry point;get_urls_for_track("/orphans/<seg>")returnsstore_root/<name>(a local file path, exactly like a resolvedPlayable::Store) so the resolved track still plays.rename_lib_node("/orphans/<seg>", new)— validatesnewas a bare store file name (validate_folder_name(new, &[]): non-empty, no separators/NUL, no leading dot), refuses a name already taken by another store entry (InvalidInput), then renames both<old>→<new>audio and<old>.cbd-store.toml→<new>.cbd-store.toml, and updates the in-memory index (drop the old name's mappings, re-insert under the new name; hash and provider ids are unchanged). Returns the renamed node at/orphans/<encode(new)>.delete_lib_node("/orphans/<seg>")— removes the audio file and the sidecar fromstore_rootand drops the entry from the index; returns the refreshed/orphansroot. Idempotent (an already-gone entry succeeds).create_lib_node—NotSupported(the root is not creatable).
Store methods added to CrabidyStore
list_orphans(&self, ref_roots: &[PathBuf]) -> Result<Vec<OrphanEntry>, StoreError>— scanstore_rootfor*.cbd-store.toml; build the referenced set by walking eachref_rootrecursively for*.cbd-track.tomland collectingPlayable::Store(name); return the difference asOrphanEntry { name, title, artist, duration, album }(metadata from the sidecar's first provider entry).orphan_track(&self, name) -> Result<Track, StoreError>/orphan_url(&self, name) -> Result<String, StoreError>— build the wire track / resolve the store audio path for a single entry.rename_orphan(&self, old, new)/delete_orphan(&self, name)— the mutations above, under the index mutex, with the index kept in sync.StoreIndex::remove(&mut self, name, sidecar)— the inverse ofinsert, so rename/delete can update the derived index without a full rescan.
Reference roots wiring
OrphansProvider is constructed in ProviderOrchestrator::init with
ref_roots = the disk roots of the mounted file providers: the /crabidy tree
(store.tree_dir()) and, when enabled, the /fs root. A new
fsdy::Client::disk_root(&self) -> &Path accessor exposes the /fs root (the
/crabidy tree root is already available via CrabidyStore::tree_dir). If more
fsdy instances are ever mounted, they are added to this list — the definition
of "local file provider" is "an fsdy instance whose root can hold store
references."
Risks and open questions
- Capture-then-delete race (TOCTOU). Between a
/orphanslisting and a delete, a concurrentWcapture could hash-hit the very entry the user is about to delete and write a freshPlayable::Storereference to it; deleting then leaves that new toml dangling. The window is small (store mutations serialize under the index mutex and orphan-ness is recomputed every listing), and the failure is benign: a dangling store reference already resolves toMalformedPathat play time and is skipped, not a crash. Accepted; noted here rather than engineered away. - References outside mounted roots are not counted. As stated in Assumptions,
a
scan --capturetoml under a folder that is not mounted under/fswill not be seen, so its target shows as an orphan. Deleting it would orphan that toml's audio. The mitigation is scope discipline (scan under/fs); the alternative — a persisted global reference index — is out of scope and would fight the "read fresh" design. - Cost. A
/orphanslisting scans the whole store plus both trees on every visit (no cache), i.e. O(store entries + tomls under the roots). This matchesfsdy's existing per-visit read cost and is fine for personal-library sizes; if it ever bites, memoizing behind the index's mutation counter is the escape hatch. Not premature-optimized here. - Malformed residue. A sidecar with no audio (or vice versa) is itself
reclaimable junk.
list_orphansreports such half-entries as orphans (titled by whatever is present) so a delete cleans them up; it never treats a half-entry as "referenced." - Open question: should the
/orphansroot also expose a single "delete all" affordance? Deferred — per-item delete covers the request; bulk reclaim can be a later addition (a client could multi-select and delete, once marks exist there).