22 KiB
id, title, type, created_date, tags
| id | title | type | created_date | tags | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| doc-26 | Audit Report: Concurrent State Change Safety (Phase 3) | other | 2026-05-19 07:28 |
|
Phase 3 Concurrent State Change Safety Audit
Date: 2026-05-19
Scope: All Oban workers that modify records matched against Records.notify_update/1 broadcast coverage. Form-edit vs background-update race traced. Double-update race evaluated.
Executive Summary
Result: MINOR FINDING — one medium-severity race condition identified. All 8 record-modifying workers correctly broadcast notify_update or broadcast_index_changed. Double-update between concurrent workers is safe due to Ecto changeset field-level updates + SQLite write serialization. However, the form edit + background update race (AC #3) can cause data loss: when a Show LiveView is in :edit mode and a worker broadcasts {:update, record}, the parent's @record assign is overwritten, and the user's next save can revert the worker's changes.
Acceptance Criterion #1: Record-Modifying Worker Broadcast Coverage
Status: ✅ ALL VERIFIED — every record-modifying worker broadcasts.
Workers that modify existing records (broadcast notify_update)
| Worker | File | Line | Fields modified | Broadcast call |
|---|---|---|---|---|
RefreshCover |
lib/music_library/worker/refresh_cover.ex |
14 | cover_hash |
Records.notify_update(updated_record) |
PopulateGenres |
lib/music_library/worker/populate_genres.ex |
15 | genres |
Records.notify_update(updated_record) |
GenerateRecordEmbedding |
lib/music_library/worker/generate_record_embedding.ex |
16 | embedding (via Similarity, not record column) | Records.notify_update(record) |
RecordRefreshMusicBrainzData |
lib/music_library/worker/record_refresh_music_brainz_data.ex |
14 | musicbrainz_data |
Records.notify_update(updated_record) |
Code verification:
# refresh_cover.ex:11-14
case Records.refresh_cover(record) do
{:ok, updated_record} -> Records.notify_update(updated_record) ✅
{:error, :cover_not_available} -> {:cancel, :cover_not_available}
other -> ErrorHandler.to_oban_result(other)
end
# populate_genres.ex:11-16
with {:ok, updated_record} <- Records.populate_genres(record),
{:ok, _worker} <- Records.Similarity.generate_embedding_async(updated_record) do
Records.notify_update(updated_record) ✅
else
other -> ErrorHandler.to_oban_result(other)
end
# generate_record_embedding.ex:14-16
case Similarity.generate_embedding(record) do
:noop -> :ok
{:ok, _} -> Records.notify_update(record) ✅
other -> ErrorHandler.to_oban_result(other)
end
# record_refresh_music_brainz_data.ex:12-15
case Records.refresh_musicbrainz_data(record) do
{:ok, updated_record} -> Records.notify_update(updated_record) ✅
other -> ErrorHandler.to_oban_result(other)
end
Workers that create new records (broadcast broadcast_index_changed)
| Worker | File | Line | Creates | Broadcast call |
|---|---|---|---|---|
ImportFromMusicbrainzRelease |
lib/music_library/worker/import_from_musicbrainz_release.ex |
24 | New record | Records.broadcast_index_changed() ✅ |
ImportFromMusicbrainzReleaseGroup |
lib/music_library/worker/import_from_musicbrainz_release_group.ex |
27 | New record | Records.broadcast_index_changed() ✅ |
These correctly use broadcast_index_changed() instead of notify_update because new records have no existing subscribers on "records:#{id}".
Batch workers (delegate to per-record async jobs)
| Worker | File | Delegates to | Individual worker broadcasts? |
|---|---|---|---|
RecordGenerateAllEmbeddings |
lib/music_library/worker/record_generate_all_embeddings.ex |
Records.Batch.generate_embeddings() → Similarity.generate_embedding_async(record) → enqueues GenerateRecordEmbedding |
✅ Yes |
RecordRefreshAllMusicBrainzData |
lib/music_library/worker/record_refresh_all_musicbrainz_data.ex |
Records.Batch.refresh_musicbrainz_data() → Records.refresh_musicbrainz_data_async(record) → enqueues RecordRefreshMusicBrainzData |
✅ Yes |
The batch workers (lines 8-10 of each file) don't call notify_update directly — they delegate to the individual workers which do. Each individual record gets its own broadcast. ✅
Acceptance Criterion #2: Non-Record-Modifying Workers
Status: ✅ ALL CONFIRMED — no spurious broadcasts.
Grep for notify_update and broadcast_index_changed across lib/music_library/worker/ returns exactly the 6 calls documented in AC #1. No other worker emits record PubSub messages.
| Worker | File | Modifies records? | Broadcasts? |
|---|---|---|---|
ApplyScrobbleRules |
lib/music_library/worker/apply_scrobble_rules.ex |
No — tracks only | ❌ No ✅ |
ArtistRefreshMusicBrainzData |
lib/music_library/worker/artist_refresh_music_brainz_data.ex |
No — artist info | ❌ No ✅ |
ArtistRefreshAllDiscogsData |
lib/music_library/worker/artist_refresh_all_discogs_data.ex |
No — artist info | ❌ No ✅ |
ArtistRefreshAllMusicBrainzData |
lib/music_library/worker/artist_refresh_all_musicbrainz_data.ex |
No — artist info | ❌ No ✅ |
ArtistRefreshAllWikipediaData |
lib/music_library/worker/artist_refresh_all_wikipedia_data.ex |
No — artist info | ❌ No ✅ |
ArtistRefreshDiscogsData |
lib/music_library/worker/artist_refresh_discogs_data.ex |
No — artist info | ❌ No ✅ |
ArtistRefreshWikipediaData |
lib/music_library/worker/artist_refresh_wikipedia_data.ex |
No — artist info | ❌ No ✅ |
BackfillScrobbledTracks |
lib/music_library/worker/backfill_scrobbled_tracks.ex |
No — tracks only | ❌ No ✅ |
FetchArtistImage |
lib/music_library/worker/fetch_artist_image.ex |
No — artist info | ❌ No ✅ |
FetchArtistInfo |
lib/music_library/worker/fetch_artist_info.ex |
No — artist info | ❌ No ✅ |
FetchArtistLastFmData |
lib/music_library/worker/fetch_artist_last_fm_data.ex |
No — artist info | ❌ No ✅ |
PruneArtistInfo |
lib/music_library/worker/prune_artist_info.ex |
No — cleanup | ❌ No ✅ |
PruneAssetCache |
lib/music_library/worker/prune_asset_cache.ex |
No — cleanup | ❌ No ✅ |
PruneAssets |
lib/music_library/worker/prune_assets.ex |
No — cleanup | ❌ No ✅ |
RefreshScrobbles |
lib/music_library/worker/refresh_scrobbles.ex |
No — tracks only | ❌ No ✅ |
RepoOptimize |
lib/music_library/worker/repo_optimize.ex |
No — DB maintenance | ❌ No ✅ |
RepoVacuum |
lib/music_library/worker/repo_vacuum.ex |
No — DB maintenance | ❌ No ✅ |
SendRecordsOnThisDayEmail |
lib/music_library/worker/send_records_on_this_day_email.ex |
No — email | ❌ No ✅ |
Acceptance Criterion #3: Form Edit + Background Update Race
Status: ⚠️ MEDIUM SEVERITY — potential data loss.
Race scenario trace
1. User opens RecordForm modal (live_action = :edit on CollectionLive.Show or WishlistLive.Show)
RecordForm rendered with @record = {genres: [], cover_hash: "abc"}
RecordForm's update/2 stores form via assign_new(:form, ...)
2. Background worker (e.g., PopulateGenres) completes
Worker calls Records.notify_update(updated_record) with {genres: ["rock"], cover_hash: "abc"}
3. PubSub broadcasts {:update, record} on "records:#{record.id}"
4. Show LiveView's handle_info({:update, record}, socket) fires:
└─ RecordActions.handle_record_updated(record)
└─ assign(:record, record) ← overwrites @record!
└─ put_toast(:info, "Record updated in the background") ← user sees toast
5. RecordForm's update/2 re-invoked with new @record
assign(assigns) ← updates @record to worker version
assign_new(:form, ...) ← skips: form already exists
6. User edits title and clicks Save
handle_event("save", %{"record" => %{"title" => "New", "genres" => [], ...}})
7. save_record/3 calls:
Records.update_record(socket.assigns.record, params)
where params = %{"title" => "New", "genres" => [], "format" => "LP", ...}
params.genres == [] ← stale! worker's genres: ["rock"] is LOST
Root cause
The Show LiveView's handle_info({:update, record}) does not check live_action:
# collection_live/show.ex:474-483
def handle_info({:update, record}, socket) do
if record.id == socket.assigns.record.id do ← guards against wrong record
{:noreply,
socket
|> RecordActions.handle_record_updated(record) ← ALWAYS updates, even during :edit
|> assign_similar_records()}
else
{:noreply, socket}
end
end
# wishlist_live/show.ex:367-373 — identical pattern
When live_action == :edit, the updated @record propagates to the RecordForm component via update/2. While assign_new(:form, ...) preserves the user's form fields, the underlying @record is now stale relative to the form data. On save, the stale params overwrite the worker's changes.
Impact assessment
| Factor | Assessment |
|---|---|
| Probability | LOW — workers are triggered by explicit user action, not periodic cron. User is unlikely to edit AND trigger a worker simultaneously. |
| Impact | MEDIUM — worker changes silently reverted without error. User might not notice. |
| Detectability | HIGH — toast "Record updated in the background" alerts user something changed. |
| Affected fields | genres (PopulateGenres), musicbrainz_data (RecordRefreshMusicBrainzData), cover_hash (RefreshCover) |
Recommended fix
Add a live_action guard to skip @record assignment when the user is editing:
File: lib/music_library_web/live/collection_live/show.ex:474-483
def handle_info({:update, record}, socket) do
if record.id == socket.assigns.record.id do
if socket.assigns.live_action in [:show] do
{:noreply,
socket
|> RecordActions.handle_record_updated(record)
|> assign_similar_records()}
else
# live_action == :edit: skip update to avoid overwriting form data.
# The worker's changes are already persisted. When the user saves or
# navigates back to show, handle_params will re-fetch the fresh record.
{:noreply,
put_toast(socket, :info, gettext("Record updated in the background. Your edits may be stale."))}
end
else
{:noreply, socket}
end
end
Same change needed in: lib/music_library_web/live/wishlist_live/show.ex:367-373
Acceptance Criterion #4: Double-Update Race Between Concurrent Workers
Status: ✅ SAFE — Ecto changeset field-level updates + SQLite serialization prevent data corruption.
Why it's safe
1. Ecto changesets only UPDATE changed fields
The enrichment functions construct changesets that touch only specific fields:
# enrichment.ex:41-43 — PopulateGenres
record
|> Record.add_genres(response["genres"]) # changes: %{genres: [...]}
|> Repo.update() # UPDATE records SET genres = ... WHERE id = ...
# enrichment.ex:51-53 — RefreshCover
record
|> Record.set_cover_hash(asset.hash) # changes: %{cover_hash: "xyz"}
|> Repo.update() # UPDATE records SET cover_hash = ... WHERE id = ...
# enrichment.ex:115-118 — RefreshMusicBrainzData
record
|> Record.add_musicbrainz_data(data) # changes: %{musicbrainz_data: ...}
|> Repo.update() # UPDATE records SET musicbrainz_data = ... WHERE id = ...
Ecto's Repo.update/1 with a changeset generates SQL that only includes columns in the changeset's changes map, not all cast fields. Two workers touching different columns produce non-overlapping UPDATEs.
2. SQLite serializes writes
SQLite uses a per-database write lock. Two concurrent writes are serialized (one waits for the other). The second write always sees the first write's committed state.
3. Both broadcasts arrive
Both workers broadcast notify_update after their respective commits. The Show LiveView processes both messages in order:
Worker A: commit → broadcast(record_A)
Worker B: commit → broadcast(record_B)
Show LV: handle_info(record_A) → assign record_A
Show LV: handle_info(record_B) → assign record_B ← last wins, but DB has both changes
The final socket.assigns.record reflects the last broadcast (record_B), which was fetched by Worker B before Worker A committed. So the UI may momentarily lack Worker A's changes until the next full page load or PubSub refresh. This is a minor UI staleness issue, not data corruption.
4. Verifying no full-struct UPDATE
# record.ex:134-152
def changeset(record, attrs) do
record
|> cast(attrs, [ # cast: user-supplied params
:type, :format, :title, :musicbrainz_id, :musicbrainz_data,
:release_date, :genres, :release_ids, :selected_release_id,
:included_release_group_ids, :cover_url, :cover_hash,
:dominant_colors, :purchased_at
])
...
end
cast/3 only puts fields present in attrs into changes. The enrichment functions use change/2 (not cast/3) with explicit field lists, so only targeted fields end up in the UPDATE.
Sequence diagram: Two workers, different fields
Worker A (PopulateGenres) Worker B (RefreshCover) SQLite DB
│ │ │
│ get_record!(id) → v0 │ get_record!(id) → v0 │ {genres:[], cover:"abc"}
│ populate_genres(v0) │ download new cover │
│ add_genres(["rock"]) │ set_cover_hash("xyz") │
│ Repo.update(chg: genres) ──────────── wait for lock ──────────────► │ {genres:["rock"], cover:"abc"}
│ │ Repo.update(chg: cover) ──► │ {genres:["rock"], cover:"xyz"}
│ notify_update(record_A) │ notify_update(record_B) │
│ record_A: {genres:["rock"]} │ record_B: {cover:"xyz"} │
│ (no cover_hash field) │ (no genres field) │
Result: DB has both changes. UI gets record_B (missing genres), but next handle_params reload corrects it. No data loss. ✅
Acceptance Criterion #5: ArtistLive.Show and {:update, record}
Status: ✅ CONFIRMED CORRECT — ArtistLive.Show intentionally does not handle {:update, record}.
Verification
1. No PubSub subscription for record topics
ArtistLive.Show's mount/3 (line 498-500):
def mount(_params, _session, socket) do
{:ok, socket}
end
No Records.subscribe/1 or subscribe_to_index/0 call. The LiveView never receives {:update, record} messages. ✅
2. handle_info clauses in ArtistLive.Show
# line 493, 639 — only two clauses:
def handle_info({MusicLibraryWeb.Components.Chat, :chats_changed}, socket)
def handle_info({MusicLibraryWeb.ArtistLive.Form, {:saved, artist_info}}, socket)
No {:update, record} clause. Any such message that somehow arrived would be silently dropped by Phoenix.LiveView (unmatched messages are ignored, not crashed). ✅
3. Artist update path
Artist updates use a distinct pub/sub path:
ArtistLive.Form (modal) → send(self(), {ArtistLive.Form, {:saved, artist_info}})
ArtistLive.Show → handle_info({:saved, artist_info}) → assign(:artist_info, ...)
Records displayed in ArtistLive.Show's grids are refreshed via assign_records/2 (which re-queries the DB) when the user triggers an action like add-to-collection or delete. No push-based record update. ✅
Complete Worker Broadcast Matrix
| # | Worker | File:Line | Broadcast | Fields Touched | Severity |
|---|---|---|---|---|---|
| 1 | RefreshCover |
refresh_cover.ex:14 |
notify_update(updated) |
cover_hash |
✅ OK |
| 2 | PopulateGenres |
populate_genres.ex:15 |
notify_update(updated) |
genres |
✅ OK |
| 3 | GenerateRecordEmbedding |
generate_record_embedding.ex:16 |
notify_update(record) |
embedding (not record column) | ✅ OK |
| 4 | RecordRefreshMusicBrainzData |
record_refresh_music_brainz_data.ex:14 |
notify_update(updated) |
musicbrainz_data |
✅ OK |
| 5 | ImportFromMusicbrainzRelease |
import_from_musicbrainz_release.ex:24 |
broadcast_index_changed() |
new record insert | ✅ OK |
| 6 | ImportFromMusicbrainzReleaseGroup |
import_from_musicbrainz_release_group.ex:27 |
broadcast_index_changed() |
new record insert | ✅ OK |
| 7 | RecordGenerateAllEmbeddings |
(delegates to GenerateRecordEmbedding) | per-record notify_update |
embedding | ✅ OK |
| 8 | RecordRefreshAllMusicBrainzData |
(delegates to RecordRefreshMusicBrainzData) | per-record notify_update |
musicbrainz_data |
✅ OK |
All 8 record-modifying workers verified. ✅
Recommendations
| # | Finding | Severity | Recommendation |
|---|---|---|---|
| 1 | Form edit + background update race — handle_info({:update, record}) overwrites @record during :edit, causing potential data loss on save |
MEDIUM | Add live_action == :show guard to handle_info({:update, record}) in both CollectionLive.Show (line 474) and WishlistLive.Show (line 367). Skip assign(:record, ...) during :edit. Show warning toast instead. The fresh record will be loaded on next handle_params when user navigates away from edit. |
| 2 | Double-update broadcasts cause brief UI staleness | ℹ️ INFO | Harmless — last broadcast wins on socket, but DB has all changes. Next handle_params reload fetches everything. No fix needed. |
| 3 | Batch workers (RecordGenerateAllEmbeddings, RecordRefreshAllMusicBrainzData) don't call notify_update directly |
ℹ️ INFO | By design — they delegate to individual workers which broadcast per record. No fix needed. |