243 lines
12 KiB
Markdown
243 lines
12 KiB
Markdown
# 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 `e` →
|
||
`rename_lib_node(child_path, new_title)` and `d` → `delete_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
|
||
|
||
```d2
|
||
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)
|
||
|
||
```d2
|
||
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_node` — `NotSupported` (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).
|