ADR-010: Queue management — engine-owned, frontend-authoritative
ADR-010: Queue management — engine-owned, frontend-authoritative
Status
Accepted
Context
FR-CORE-2 requires queue management: add, remove, reorder items, persist across room switches. The queue is ordered work for the playback engine. Two architectural models exist:
- Engine-owned queue: The Playback Engine holds the authoritative queue state. The frontend mutates it via commands; the engine reports updates.
- Frontend-authoritative queue: The Svelte frontend holds the authoritative queue state. The engine holds a working copy (the currently playing item + next items). On engine restart, the frontend re-pushes the queue.
- Separate queue service: A dedicated Rust module (or actor) that coordinates between frontend and engine. Adds an extra consumer and synchronization boundary.
The queue must be persistent across room switches (FR-CORE-2). NFR-REL-3 (S5) marks queue durability across app restarts as Should, not Must — v1 can start with an in-memory queue.
Decision
The queue is co-located with the Playback Engine (same Rust module). Architecture:
Ownership model
The Svelte frontend holds the authoritative queue state. The Playback Engine holds a working copy (a window of the queue for auto-advance).
Frontend (Svelte) Playback Engine
queue (authoritative) queue (working copy)
│ │
│ queue_add(item) │
│ ──────────────invoke───────────>│ append to working copy
│ │
│ queue_remove(index) │
│ ──────────────invoke───────────>│ remove from working copy
│ │
│ queue_reorder(from, to) │
│ ──────────────invoke───────────>│ reorder working copy
│ │
│ queue_clear() │
│ ──────────────invoke───────────>│ clear working copy
│ │
│ queue_updated (full state) │
│ <─────────emit─────────────────│
│ │
│ (track ends, auto-advance) │
│ <─────────emit─────────────────│ next track started
Key rules:
- The frontend is the source of truth. If the engine restarts without the app restarting, the frontend re-pushes the queue via
queue_addcommands. - If the app restarts, the queue is empty (v1). No SQLite persistence. This is an explicit v1 limitation — NFR-REL-3 (S5) is Should, deferred to v2.
- The frontend queue survives room switches because Svelte state survives room transitions. This satisfies FR-CORE-2’s “persist across rooms” without engine-level persistence.
- The engine’s working copy is a
VecDeque<ContentId>— enough to auto-advance and report the current/next item.
Tauri command surface
#[tauri::command]
async fn queue_add(app: AppHandle, item: ContentId, position: Option<usize>) -> Result<(), String>
#[tauri::command]
async fn queue_remove(app: AppHandle, index: usize) -> Result<(), String>
#[tauri::command]
async fn queue_reorder(app: AppHandle, from: usize, to: usize) -> Result<(), String>
#[tauri::command]
async fn queue_clear(app: AppHandle) -> Result<(), String>
#[tauri::command]
async fn queue_get(app: AppHandle) -> Result<Vec<ContentId>, String>
Tauri events
// Emitted after every mutation with the full queue state (not a diff)
interface QueueUpdated {
items: ContentId[];
currentIndex: number | null;
}
// Emitted when the engine auto-advances (track ends, next starts)
interface TrackChanged {
previous: ContentId | null;
next: ContentId;
}
The full queue state is sent on every queue_updated event rather than diffs. At queue scale (10k items is extreme), the serialized state is a few hundred KB — not worth diff complexity.
Auto-advance flow
1. Track ends (phonic emits PlaybackStatusEvent::Stopped { exhausted: true })
2. Engine dequeues next item from working copy
3. Engine resolves content UUID via Library Service
4. Engine requests stream URL from Plugin Runtime
5. Engine starts playback via phonic
6. Engine emits TrackChanged event
7. Frontend receives TrackChanged, updates its authoritative queue (mark current index)
8. If working copy is near-empty, engine requests next batch from frontend
Alternatives considered
- Engine-owned queue: The engine persists the queue and the frontend is a thin display. Feeds into the later v2 SQLite-persistence model, but adds write-contention complexity in v1. Who wins when the engine wants to auto-advance and the frontend is mid-reorder? Engine-owned makes this a synchronization problem in the engine. Frontend-authoritative avoids it entirely: the frontend is the single writer; the engine is a reader + auto-advancer.
- Separate queue service: An actor or dedicated module that mediates between frontend and engine. Adds a coordination layer that buys nothing in v1 — the frontend and engine can talk directly via Tauri commands and events. The extra consumer introduces ordering guarantees that would need an ADR of their own.
- SQLite-persisted queue: Adds write amplification for every mutation and requires a recovery path on startup (was the previous queue interrupted by a crash?). NFR-REL-3 explicitly marks this as Should. Defer to v2 with its own ADR when the persistence semantics are designed.
Consequences
Positive:
- No synchronization contention — the frontend is the sole writer; the engine is a reader+advancer.
- Queue survives room switches naturally (Svelte frontend state).
- Engine restart without app restart is handled by frontend re-push.
- Full-state events simplify frontend logic — no diff reconciliation, no merge conflicts.
- v2 SQLite persistence has a clear model: the engine becomes the source of truth, reversing the ownership. That architectural reversal is significant enough to warrant its own ADR at the time.
Negative:
- Engine restart = queue loss unless the frontend re-pushes. Documented explicitly as a v1 limitation.
- Full-state events are less efficient than diffs for large queues (10k+ items). Acceptable for v1.
- Auto-advance requires the engine to request the next batch from the frontend when its working copy runs low. Adds a round-trip during track transitions. Mitigation: engine requests a generous window (20 items) to amortize round-trips.
References
- FR-CORE-2 (functional.md)
- NFR-REL-3 (non-functional.md) — S5 in backlog
- ADR-005 (Tauri events for inter-component communication)