22 KiB
id, title, status, assignee, created_date, updated_date, labels, dependencies, references, priority
| id | title | status | assignee | created_date | updated_date | labels | dependencies | references | priority | ||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ML-169 | Comprehensive application audit: functional correctness and performance | Done | 2026-05-08 08:46 | 2026-05-19 07:46 |
|
|
medium |
Description
Large-scale audit of the application covering two dimensions:
1. Functional Issues: Unhandled async messages from LiveComponents, stale PubSub subscriptions, unexpected state changes from concurrent operations (background workers updating records while user is viewing them).
2. Performance Low-Hanging Fruit: Unnecessary server round trips, missing opportunities for optimistic updates, redundant data reloads, and synchronous blocking work in mount.
The audit is divided into 4 independent phases that can be tackled in parallel or sequentially. Each phase produces findings that feed into fix tasks. Full implementation plan in the task plan section.
Acceptance Criteria
- #1 Phase 1 (async messages): all LiveComponents that send messages to parent LiveViews have matching handle_info clauses documented; missing handlers identified with severity
- #2 Phase 2 (PubSub lifecycle): all subscribe/unsubscribe pairs verified correct; stale subscription risks identified; ML-168 gap separately accounted for
- #3 Phase 3 (state change safety): concurrent record-mutation patterns audited; {:update, record} guards and manage_subscription patterns verified across all navigation paths
- #4 Phase 4 (performance): unnecessary round trips, redundant full-stream reloads, and synchronous mount work identified with estimated impact and fix approach
- #5 Each phase produces a written finding report with file paths, line references, severity ratings, and recommended fixes
Implementation Plan
Audit Implementation Plan
Audit Scope
This audit covers all 14 LiveViews, all LiveComponents that send messages to parents, all PubSub subscription points, and all Oban worker-LiveView interaction patterns. The full architecture is documented in docs/architecture.md.
Phase Structure
Each phase is a standalone investigation that produces a findings document. Fixes are NOT implemented in these phases — only identification, severity rating, and fix recommendations.
Phase 1: Async Message Audit (LiveComponent → Parent)
Goal: Verify every LiveComponent message has a matching handle_info in its parent LiveView.
Known message producers and their parents:
| Component | Messages sent | Consumer LiveViews | Status |
|---|---|---|---|
RecordForm |
{:saved, record} |
CollectionLive.Index, WishlistLive.Index, CollectionLive.Show, WishlistLive.Show | ✅ All four have handlers |
AddRecord |
{:imported_single, record}, {:imported_async, count} |
CollectionLive.Index, WishlistLive.Index | ✅ Both have handlers |
Chat |
:chats_changed |
CollectionLive.Index, CollectionLive.Show, WishlistLive.Show, ArtistLive.Show | ✅ All four have handlers |
Release |
{tag, release} (dynamic tag) |
ScrobbleLive.ReleaseShow, CollectionLive.Show | ⚠️ CollectionLive.Show renders Release but has NO handle_info({:release_loaded, _}) — needs investigation |
ArtistLive.Form |
{:saved, artist_info} |
ArtistLive.Show | ✅ Has handler |
RecordSetLive.Form |
{:created, _}, {:updated, _} |
RecordSetLive.Index | ✅ Has handlers |
RecordSetLive.RecordPicker |
{:added, record_set} |
RecordSetLive.Index | ✅ Has handler |
ScrobbleRulesLive.Form |
{:created, _}, {:updated, _} |
ScrobbleRulesLive.Index | ✅ Has handlers |
ScrobbledTracksLive.Form |
{:saved, _track} |
ScrobbledTracksLive.Index | ✅ Has handler |
OnlineStoreTemplateLive.Form |
{:saved, template} |
OnlineStoreTemplateLive.Index | ✅ Has handler |
ScrobbleRulePicker |
{:rule_created, _rule} |
ScrobbledTracksLive.Index, StatsLive.Index | ✅ Both have handlers |
Investigation steps:
- Cross-reference every
send(self(), ...)call site (found viagrep -rn "send(self()") withhandle_infoclauses in the parent LiveView - Specifically trace the
Releasecomponent usage inCollectionLive.Show— it rendersReleasewithon_release_loaded={:release_loaded}, butCollectionLive.Showhas nohandle_info({:release_loaded, _})clause. Determine if this is a dead message or a missing handler - Verify no component sends messages when rendered in a context that doesn't handle them (e.g., modal closed, wrong live_action)
- Look for
send/2calls using patterns NOT matching{__MODULE__, _}
Files to check: All lib/music_library_web/components/*.ex, all lib/music_library_web/live/**/*.ex
Phase 2: PubSub Subscription Lifecycle Audit
Goal: Verify all PubSub subscriptions are properly paired with unsubscriptions; identify stale subscription risks.
Subscription inventory:
| Subscribe | Topic | Where | Unsubscribe | Risk |
|---|---|---|---|---|
Records.subscribe(id) |
"records:#{id}" |
CollectionLive.Show, WishlistLive.Show (via manage_subscription/2) |
✅ manage_subscription/2 unsubscribes old ID before subscribing new — called on every handle_params |
Low |
ListeningStats.subscribe() |
"listening_stats:update" |
StatsLive.Index (mount), ScrobbledTracksLive.Index (mount) |
⚠️ Never unsubscribed. Only called when connected. | Medium — PID monitoring handles cleanup but double-subscription on reconnect possible |
Broadcast inventory:
| Broadcast | Topic | Message | Called from |
|---|---|---|---|
Records.notify_update/1 |
"records:#{id}" |
{:update, record} |
Workers (needs audit — see Phase 3) |
ListeningStats.broadcast_update/1 |
"listening_stats:update" |
%{track_count: n} |
lib/music_library/listening_stats.ex:68 |
Investigation steps:
- Verify
manage_subscription/2is called on ALL navigation paths: browser back/forward, direct URL, push_navigate, push_patch - Check that
Records.subscribe/1is NEVER called outside ofmanage_subscription/2(only two Show LiveViews should call it) - Verify
ListeningStats.subscribe()doesn't double-subscribe on LiveView reconnect (check if Phoenix.LiveView re-mounts fully or reuses process) - Check that
Records.unsubscribe/1is called on terminate — currently it's only called on navigation, not process termination. Phoenix.PubSub auto-cleans when PID dies, but verify - Audit
Records.notify_update/1broadcast sites against intended behavior (see Phase 3)
Files to check: lib/music_library/records.ex, lib/music_library/listening_stats.ex, lib/music_library_web/live_helpers/record_actions.ex, all Show LiveViews
Phase 3: Concurrent State Change Safety Audit
Goal: Verify that background worker updates to records don't corrupt LiveView state.
Pattern: Oban worker modifies record → calls Records.notify_update/1 → PubSub broadcasts {:update, record} → Show LiveView's handle_info({:update, record}, socket) applies update via RecordActions.handle_record_updated/2.
Guard: record.id == socket.assigns.record.id in both CollectionLive.Show and WishlistLive.Show ✅
Worker-to-broadcast audit (every worker that modifies records):
| Worker | Modifies record? | Calls notify_update? | Check |
|---|---|---|---|
RefreshCover |
Yes — updates cover_url, cover_hash | Need to verify | Read worker source |
PopulateGenres |
Yes — updates genres | Need to verify | Read worker source |
GenerateRecordEmbedding |
Yes (indirect via Similarity) | Need to verify | Read worker + Similarity |
RecordRefreshMusicBrainzData |
Yes — updates musicbrainz_data | Need to verify | Read worker source |
RecordRefreshAllMusicBrainzData |
Yes (batch) | Need to verify | Read worker + Batch |
RecordGenerateAllEmbeddings |
Yes (batch) | Need to verify | Read worker + Batch |
ImportFromMusicbrainzRelease |
Creates new record | Need to verify | Read worker source |
ImportFromMusicbrainzReleaseGroup |
Creates new records | Need to verify | Read worker source |
PruneAssets |
No (assets only) | N/A | Skip |
PruneAssetCache |
No (cache only) | N/A | Skip |
ApplyScrobbleRules |
No (scrobble data) | N/A | Skip |
FetchArtistInfo |
No (artist info) | N/A | Skip |
All ArtistRefresh* workers |
No (artist info) | N/A | Skip |
RefreshScrobbles |
No (scrobble data) | N/A | Skip |
BackfillScrobbledTracks |
No (scrobble data) | N/A | Skip |
SendRecordsOnThisDayEmail |
No (email) | N/A | Skip |
RepoVacuum / RepoOptimize |
No (maintenance) | N/A | Skip |
Additional checks:
- Double-update race: If two workers update the same record simultaneously, does the LiveView handle both
handle_infocalls correctly? (Each re-assigns:record— second update is latest, which is correct since workers touch different fields) - Form edit + background update race: User editing in
RecordFormmodal while a background worker updates the record. The form holds a stale@recordassign. When the worker broadcasts{:update, record}, the Show page updatessocket.assigns.record, but the form LiveComponent has its OWN@recordassign set duringupdate_many. What happens? This is a potential functional bug. - ArtistLive.Show: Does NOT handle
{:update, record}— artist updates use a different mechanism (artist info is updated throughArtistLive.Formwhich sends{:saved, artist_info}directly). Verify this is intentional and complete.
Files to check: All lib/music_library/worker/*.ex, lib/music_library/records.ex (notify_update), lib/music_library/records/similarity.ex, all Show LiveViews, RecordForm component
Phase 4: Performance Low-Hanging Fruit Audit
Goal: Identify unnecessary work — DB queries, stream resets, server round trips — that could be eliminated or deferred.
4a. Unnecessary Full-Stream Reloads
| LiveView | Stream name | Reset trigger | Optimizable? | Impact |
|---|---|---|---|---|
| CollectionLive.Index | :records |
Every param change, form save, display toggle | Display toggle (grid↔list) could be CSS class only — same data, different render | Medium: 1 FTS query per toggle |
| WishlistLive.Index | :records |
Same as above | Same | Medium |
| ScrobbleRulesLive.Index | :scrobble_rules |
Form save: does stream_insert then load_and_assign_rules (reset) |
The stream_insert is immediately overwritten by full reload — redundant |
Low: cosmetic |
| OnlineStoreTemplateLive.Index | :templates |
Form save: same redundant stream_insert + full reload |
Same | Low |
| ScrobbledTracksLive.Index | :tracks |
Form save, PubSub scrobble update | Could incrementally stream_insert the edited track instead of full reload |
Medium: full FTS on every scrobble batch |
| RecordSetLive.Index | :record_sets |
Form create | Could stream_insert the new set without full reload |
Low |
| StatsLive.Index | :recent_tracks, :recent_albums |
PubSub scrobble update | Incremental stream_insert could replace full reset |
Medium: resets on every scrobble batch |
4b. Synchronous Mount Work → Async Candidates
| LiveView | Blocking queries | Recommendation | Impact |
|---|---|---|---|
| StatsLive.Index | count_records_by_artist(20), count_records_by_genre(20), count_records_by_release_year(20), get_records_on_this_day(), get_latest_record() |
Move to assign_async — these are below-the-fold charts |
High: 5 queries blocking TTFB |
| CollectionLive.Index | Chats.count_chats(:collection, ...) |
Small, acceptable | None |
4c. Server Round Trips → Client-Side Candidates
| Interaction | Current | Optimizable? | Impact |
|---|---|---|---|
| Display mode toggle (grid↔list) | Server handle_event("set_display") → full DB query + stream reset |
Switch to client-side JS hook that toggles CSS class | Medium: eliminates 1 round trip + 1 DB query |
| Search input | Every keystroke triggers phx-change → handle_event("search") → DB query |
Check if phx-debounce is set. If not, add 300ms debounce |
Medium: reduces query load |
| Sort order change | push_patch → handle_params → DB query |
Cannot be client-side (pagination depends on sort order) | N/A |
4d. Optimistic UI Opportunities
| Operation | Current behavior | Optimistic approach | Risk |
|---|---|---|---|
| Record delete | Repo.delete → stream_delete (confirmed) |
stream_delete immediately → Repo.delete in background; rollback on error |
Low: delete failures are rare |
| Form save in modal | push_patch to base index → full reload |
stream_insert updated record into parent stream without navigation |
Medium: need to handle re-sort |
| Scrobble rule enabled toggle | Full list reload via push_patch |
Toggle stream_insert item in-place |
Low |
| Online store template toggle | Full list reload via push_patch |
Toggle stream_insert item in-place |
Low |
4e. N+1 and Query Pattern Audit
Checklist:
CollectionLive.Show.handle_params/3— loads record, last_listened_track, play_count, record_sets, chat_count, embedding_text, similar_records. Check for N+1.ArtistLive.Show.handle_params/3— loads artist, collection_records, wishlist_records. Check for N+1.- Stream rendering — check
.record_grid,.record_list,.record_cardcomponents for per-item DB calls - Verify preloads cover all rendered fields (artists, formats, genres, dominant_colors)
Investigation method:
- Use QueryReporter skill to capture SQL for:
/collection,/collection/:id,/artists/:id,/stats - Review stream usage with
grep -rn "stream(" lib/music_library_web/live/ - Check for
Repocalls inside LiveComponents (should delegate to context) - Check for
Repocalls inside.heextemplates (should be inhandle_params)
Files to check: All lib/music_library_web/live/**/*.ex, all lib/music_library_web/components/*.ex
Definition of Done
- #1 Audit report for each phase written as a Backlog.md document with file paths, line references, severity ratings, and fix recommendations
- #2 At least one concrete finding identified per phase before considering the phase complete
- #3 Each finding includes a specific file:line reference (not just a general concern)
- #4 Findings are triaged by severity: HIGH (crashes/data loss), MEDIUM (wrong behavior/noticeable perf), LOW (cosmetic/redundant)
- #5 ML-168 is referenced where relevant but not duplicated — this audit identifies NEW issues only
- #6 No fixes implemented during audit — only identification and recommendation