crabidy/architecture/orphans.md

12 KiB
Raw Blame History

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. /orphans lists 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 /crabidy toml tree (~/.local/state/crabidy/, which holds current, every save, every capture) and the /fs root (which can hold Playable::Store tomls written by scan --capture/--move). A .cbd-track.toml that lives outside every mounted provider root (e.g. scan --capture run 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 (store D2); 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.toml under a mounted file-provider root validates to Playable::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 — has is_captured/is_skipped but no is_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 carries is_editable, is_deletable, is_queable, is_captured. The node-editing feature already binds erename_lib_node(child_path, new_title) and ddelete_lib_node(child_path) for children that advertise the flags (that is how /tidal/search terms and /crabidy saves 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 single Track for that store entry (metadata from the sidecar's first provider entry; is_captured = true). Unknown/renamed-away segment → MalformedPath.
  • is_track_path — always false: orphans are addressed as nodes, and the one track is reached by resolving the node (so the default resolve_tracks_into walk queues it). get_metadata_for_track therefore is not the entry point; get_urls_for_track("/orphans/<seg>") returns store_root/<name> (a local file path, exactly like a resolved Playable::Store) so the resolved track still plays.
  • rename_lib_node("/orphans/<seg>", new) — validates new as 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 from store_root and drops the entry from the index; returns the refreshed /orphans root. Idempotent (an already-gone entry succeeds).
  • create_lib_nodeNotSupported (the root is not creatable).

Store methods added to CrabidyStore

  • list_orphans(&self, ref_roots: &[PathBuf]) -> Result<Vec<OrphanEntry>, StoreError> — scan store_root for *.cbd-store.toml; build the referenced set by walking each ref_root recursively for *.cbd-track.toml and collecting Playable::Store(name); return the difference as OrphanEntry { 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 of insert, 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 /orphans listing and a delete, a concurrent W capture could hash-hit the very entry the user is about to delete and write a fresh Playable::Store reference 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 to MalformedPath at 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 --capture toml under a folder that is not mounted under /fs will 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 /orphans listing scans the whole store plus both trees on every visit (no cache), i.e. O(store entries + tomls under the roots). This matches fsdy'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_orphans reports 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 /orphans root 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).