diff --git a/docs/architecture.md b/docs/architecture.md index 743b672b..7352bdb0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,6 +105,7 @@ Last.fm schemas (separate, not Ecto-persisted to main DB): | `Search` | (cross-context) | Universal search across collection, wishlist, artists, record sets | | `Secrets` | Secret | Encrypted key-value storage | | `BarcodeScan` | (Result struct) | Barcode → MusicBrainz lookup workflow | +| `Maintenance` | (Oban.Job) | Background job monitoring, database vacuum/optimize | --- diff --git a/lib/music_library/maintenance.ex b/lib/music_library/maintenance.ex new file mode 100644 index 00000000..02535940 --- /dev/null +++ b/lib/music_library/maintenance.ex @@ -0,0 +1,39 @@ +defmodule MusicLibrary.Maintenance do + @moduledoc """ + Context for database maintenance operations and background job monitoring. + """ + + import Ecto.Query + + alias MusicLibrary.BackgroundRepo + alias MusicLibrary.Repo + + @doc """ + Counts active Oban jobs for the given worker module name. + + Active jobs are those in "available", "scheduled", "executing", or "retryable" states. + """ + def count_active_jobs(worker) do + query = + from j in Oban.Job, + where: j.worker == ^worker, + where: j.state in ["available", "scheduled", "executing", "retryable"], + select: count(j.id) + + BackgroundRepo.one(query) + end + + @doc """ + Runs VACUUM on the main database. + """ + def vacuum do + Repo.vacuum() + end + + @doc """ + Runs PRAGMA optimize on the main database. + """ + def optimize do + Repo.optimize() + end +end diff --git a/lib/music_library_web/live/maintenance_live/index.ex b/lib/music_library_web/live/maintenance_live/index.ex index 55344263..247b5183 100644 --- a/lib/music_library_web/live/maintenance_live/index.ex +++ b/lib/music_library_web/live/maintenance_live/index.ex @@ -1,14 +1,11 @@ defmodule MusicLibraryWeb.MaintenanceLive.Index do use MusicLibraryWeb, :live_view - import Ecto.Query - require Logger alias MusicLibrary.Artists - alias MusicLibrary.BackgroundRepo + alias MusicLibrary.Maintenance alias MusicLibrary.Records - alias MusicLibrary.Repo @poll_interval 2_000 @@ -186,40 +183,30 @@ defmodule MusicLibraryWeb.MaintenanceLive.Index do socket |> assign( :refresh_records_musicbrainz_jobs, - count_jobs("MusicLibrary.Worker.RecordRefreshMusicBrainzData") + Maintenance.count_active_jobs("MusicLibrary.Worker.RecordRefreshMusicBrainzData") ) |> assign( :generate_record_embeddings_jobs, - count_jobs("MusicLibrary.Worker.GenerateRecordEmbedding") + Maintenance.count_active_jobs("MusicLibrary.Worker.GenerateRecordEmbedding") ) |> assign( :refresh_artists_musicbrainz_jobs, - count_jobs("MusicLibrary.Worker.ArtistRefreshMusicBrainzData") + Maintenance.count_active_jobs("MusicLibrary.Worker.ArtistRefreshMusicBrainzData") ) |> assign( :refresh_artists_discogs_jobs, - count_jobs("MusicLibrary.Worker.ArtistRefreshDiscogsData") + Maintenance.count_active_jobs("MusicLibrary.Worker.ArtistRefreshDiscogsData") ) |> assign( :refresh_artists_wikipedia_jobs, - count_jobs("MusicLibrary.Worker.ArtistRefreshWikipediaData") + Maintenance.count_active_jobs("MusicLibrary.Worker.ArtistRefreshWikipediaData") ) |> assign( :refresh_artists_lastfm_jobs, - count_jobs("MusicLibrary.Worker.FetchArtistLastFmData") + Maintenance.count_active_jobs("MusicLibrary.Worker.FetchArtistLastFmData") ) end - defp count_jobs(worker) do - query = - from j in Oban.Job, - where: j.worker == ^worker, - where: j.state in ["available", "scheduled", "executing", "retryable"], - select: count(j.id) - - BackgroundRepo.one(query) - end - @impl true def handle_event("refresh_records_musicbrainz_data", _params, socket) do Records.Batch.refresh_musicbrainz_data() @@ -270,7 +257,7 @@ defmodule MusicLibraryWeb.MaintenanceLive.Index do end def handle_event("db_vacuum", _params, socket) do - case Repo.vacuum() do + case Maintenance.vacuum() do {:ok, _result} -> {:noreply, socket @@ -286,7 +273,7 @@ defmodule MusicLibraryWeb.MaintenanceLive.Index do end def handle_event("db_optimize", _params, socket) do - case Repo.optimize() do + case Maintenance.optimize() do {:ok, _result} -> {:noreply, socket diff --git a/test/music_library/maintenance_test.exs b/test/music_library/maintenance_test.exs new file mode 100644 index 00000000..754d6e61 --- /dev/null +++ b/test/music_library/maintenance_test.exs @@ -0,0 +1,26 @@ +defmodule MusicLibrary.MaintenanceTest do + use MusicLibrary.DataCase + + alias MusicLibrary.Maintenance + + describe "vacuum/0" do + test "delegates to Repo.vacuum/0" do + # VACUUM cannot run inside the Ecto sandbox transaction, + # so we verify it attempts the operation and returns the expected tuple shape. + assert {:error, %Exqlite.Error{message: "cannot VACUUM from within a transaction"}} = + Maintenance.vacuum() + end + end + + describe "optimize/0" do + test "returns {:ok, _}" do + assert {:ok, _} = Maintenance.optimize() + end + end + + describe "count_active_jobs/1" do + test "returns 0 for a worker with no jobs" do + assert Maintenance.count_active_jobs("MusicLibrary.Worker.NonExistent") == 0 + end + end +end