diff --git a/.github/docker/Dockerfile.container-scan b/.github/docker/Dockerfile.container-scan
index 9a490b3be..fb00ca45b 100644
--- a/.github/docker/Dockerfile.container-scan
+++ b/.github/docker/Dockerfile.container-scan
@@ -1,4 +1,5 @@
-FROM mcr.microsoft.com/dotnet/aspnet:10.0.4
+ARG DOTNET_ASPNET_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0
+FROM ${DOTNET_ASPNET_IMAGE}
WORKDIR /app
diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index 086c677e5..c19f767e0 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -74,7 +74,8 @@ jobs:
Jwt__Audience: "test"
security__secretKey: "testsecretkeytestsecretkeytestsecretkey"
QuartzDisabled: "true"
- run: dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage" --settings coverage.runsettings --results-directory coverage/results
+ # Parallel solution-level VSTest with coverage can return a non-zero exit after successful test summaries.
+ run: dotnet test --no-build -m:1 --verbosity normal --collect:"XPlat Code Coverage" --settings coverage.runsettings --results-directory coverage/results
- name: Generate coverage report
run: |
dotnet tool install -g dotnet-reportgenerator-globaltool
diff --git a/.github/workflows/sca-container-scan.yml b/.github/workflows/sca-container-scan.yml
index 7ff0a4d2c..aab47bf01 100644
--- a/.github/workflows/sca-container-scan.yml
+++ b/.github/workflows/sca-container-scan.yml
@@ -45,6 +45,8 @@ jobs:
permissions:
contents: read
security-events: write
+ env:
+ SCAN_BASE_IMAGE: mcr.microsoft.com/dotnet/aspnet:10.0
steps:
- name: Checkout repository
@@ -58,18 +60,47 @@ jobs:
- name: Publish application for scanning
run: dotnet publish src/Melodee.Blazor/Melodee.Blazor.csproj -c Release -o scan/publish --self-contained false -p:PublishTrimmed=false
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ - name: Pull Docker base image for scanning
+ run: |
+ set -euo pipefail
+
+ for attempt in 1 2 3 4 5; do
+ if docker pull "$SCAN_BASE_IMAGE"; then
+ exit 0
+ fi
+
+ delay=$((attempt * 20))
+ echo "Base image pull failed on attempt ${attempt}; retrying in ${delay}s..."
+ sleep "$delay"
+ done
+
+ docker pull "$SCAN_BASE_IMAGE"
- name: Build Docker image for scanning
- uses: docker/build-push-action@v5
- with:
- context: .
- file: .github/docker/Dockerfile.container-scan
- load: true
- tags: melodee:latest
- cache-from: type=gha
- cache-to: type=gha,mode=max
+ run: |
+ set -euo pipefail
+
+ for attempt in 1 2 3; do
+ if docker build \
+ --pull=false \
+ --build-arg DOTNET_ASPNET_IMAGE="$SCAN_BASE_IMAGE" \
+ -f .github/docker/Dockerfile.container-scan \
+ -t melodee:latest \
+ .; then
+ exit 0
+ fi
+
+ delay=$((attempt * 20))
+ echo "Docker image build failed on attempt ${attempt}; retrying in ${delay}s..."
+ sleep "$delay"
+ done
+
+ docker build \
+ --pull=false \
+ --build-arg DOTNET_ASPNET_IMAGE="$SCAN_BASE_IMAGE" \
+ -f .github/docker/Dockerfile.container-scan \
+ -t melodee:latest \
+ .
- name: Run Trivy Vulnerability Scanner
uses: aquasecurity/trivy-action@master
diff --git a/Directory.Build.props b/Directory.Build.props
index 64259da25..9949ef02c 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,9 +1,5 @@
true
-
- $(NoWarn);NU1902
diff --git a/Directory.Packages.props b/Directory.Packages.props
index f0ca61a7a..28cdbdc01 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -7,7 +7,6 @@
-
@@ -18,106 +17,106 @@
-
-
+
+
+
-
+
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ allruntime; build; native; contentfiles; analyzers; buildtransitive
-
+ allruntime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
-
+
+
-
+
-
+
-
+
-
+
-
+
-
-
+
+
-
-
+
+
-
+ allruntime; build; native; contentfiles; analyzers; buildtransitive
-
+
-
\ No newline at end of file
+
diff --git a/benchmarks/Melodee.Benchmarks/Melodee.Benchmarks.csproj b/benchmarks/Melodee.Benchmarks/Melodee.Benchmarks.csproj
index 96cfe685e..2ef396185 100644
--- a/benchmarks/Melodee.Benchmarks/Melodee.Benchmarks.csproj
+++ b/benchmarks/Melodee.Benchmarks/Melodee.Benchmarks.csproj
@@ -15,7 +15,11 @@
+
+
+ all
+
diff --git a/benchmarks/Melodee.Benchmarks/MusicBrainzImportBenchmarks.cs b/benchmarks/Melodee.Benchmarks/MusicBrainzImportBenchmarks.cs
index 28fd5be05..3b50983b1 100644
--- a/benchmarks/Melodee.Benchmarks/MusicBrainzImportBenchmarks.cs
+++ b/benchmarks/Melodee.Benchmarks/MusicBrainzImportBenchmarks.cs
@@ -81,9 +81,7 @@ public async Task ImportWithDefaultSettings()
await context.Database.EnsureCreatedAsync();
var importer = new DecentDBStreamingMusicBrainzImporter(_logger);
- await importer.ImportAsync(context, _testDataPath, null, CancellationToken.None);
-
- var count = await context.Artists.CountAsync();
+ var summary = await importer.ImportAsync(context, _testDataPath, null, CancellationToken.None);
try
{
@@ -91,7 +89,7 @@ public async Task ImportWithDefaultSettings()
}
catch { }
- return count;
+ return summary.Artists;
}
[Benchmark]
@@ -107,9 +105,7 @@ public async Task ImportWithLargerDataset()
await context.Database.EnsureCreatedAsync();
var importer = new DecentDBStreamingMusicBrainzImporter(_logger);
- await importer.ImportAsync(context, _testDataPath, null, CancellationToken.None);
-
- var count = await context.Artists.CountAsync();
+ var summary = await importer.ImportAsync(context, _testDataPath, null, CancellationToken.None);
try
{
@@ -117,7 +113,7 @@ public async Task ImportWithLargerDataset()
}
catch { }
- return count;
+ return summary.Artists;
}
[Benchmark]
@@ -133,18 +129,16 @@ public async Task ImportWithProgressTracking()
await context.Database.EnsureCreatedAsync();
var importer = new DecentDBStreamingMusicBrainzImporter(_logger);
- await importer.ImportAsync(context, _testDataPath,
+ var summary = await importer.ImportAsync(context, _testDataPath,
(phase, current, total, msg) => { }, CancellationToken.None);
- var count = await context.Artists.CountAsync();
-
try
{
File.Delete(dbFile);
}
catch { }
- return count;
+ return summary.Artists;
}
}
diff --git a/benchmarks/Melodee.Benchmarks/MusicBrainzImportProbe.cs b/benchmarks/Melodee.Benchmarks/MusicBrainzImportProbe.cs
new file mode 100644
index 000000000..a00781c06
--- /dev/null
+++ b/benchmarks/Melodee.Benchmarks/MusicBrainzImportProbe.cs
@@ -0,0 +1,353 @@
+using System.Diagnostics;
+using System.Text.Json;
+using Melodee.Common.Plugins.SearchEngine.MusicBrainz.Data;
+using Microsoft.EntityFrameworkCore;
+using Serilog;
+using Serilog.Events;
+
+namespace Melodee.Benchmarks;
+
+internal static class MusicBrainzImportProbe
+{
+ public static async Task RunAsync(string[] args)
+ {
+ var options = MusicBrainzProbeOptions.Parse(args);
+ try
+ {
+ var storagePath = options.RequireString("storage");
+ var databasePath = options.RequireString("db");
+ var outputPath = options.GetString("output");
+ var cleanTarget = options.GetBool("clean");
+ var sampleIntervalMs = Math.Max(250, options.GetInt32("sample-interval-ms", 5000));
+
+ if (!Directory.Exists(Path.Combine(storagePath, "staging", "mbdump")))
+ {
+ throw new DirectoryNotFoundException(
+ $"MusicBrainz staging dump was not found under {Path.Combine(storagePath, "staging", "mbdump")}.");
+ }
+
+ if (cleanTarget)
+ {
+ DeleteDatabaseFiles(databasePath);
+ }
+
+ var report = await RunProbeAsync(
+ storagePath,
+ databasePath,
+ sampleIntervalMs,
+ CancellationToken.None)
+ .ConfigureAwait(false);
+ await WriteReportAsync(report, outputPath, CancellationToken.None).ConfigureAwait(false);
+ return report.Success ? 0 : 1;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine(ex.Message);
+ return 1;
+ }
+ }
+
+ private static async Task RunProbeAsync(
+ string storagePath,
+ string databasePath,
+ int sampleIntervalMs,
+ CancellationToken cancellationToken)
+ {
+ var startedAt = DateTimeOffset.UtcNow;
+ var stopwatch = Stopwatch.StartNew();
+ var samples = new List();
+ var phaseTracker = new ImportPhaseTracker();
+ DecentDBMusicBrainzImportSummary? summary = null;
+ string? errorMessage = null;
+ var success = false;
+
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ ConsoleCancelEventHandler cancelHandler = (_, eventArgs) =>
+ {
+ eventArgs.Cancel = true;
+ linkedCts.Cancel();
+ };
+ Console.CancelKeyPress += cancelHandler;
+
+ var sampler = Task.Run(
+ () => SampleProcessAsync(databasePath, sampleIntervalMs, samples, linkedCts.Token),
+ linkedCts.Token);
+
+ try
+ {
+ var logger = new LoggerConfiguration()
+ .MinimumLevel.Is(LogEventLevel.Information)
+ .WriteTo.Console()
+ .CreateLogger();
+
+ var dbOptions = new DbContextOptionsBuilder()
+ .UseDecentDB($"Data Source={databasePath}")
+ .Options;
+
+ await using var context = new MusicBrainzDbContext(dbOptions);
+ await context.Database.EnsureCreatedAsync(linkedCts.Token).ConfigureAwait(false);
+
+ var importer = new DecentDBStreamingMusicBrainzImporter(logger);
+ summary = await importer.ImportAsync(
+ context,
+ storagePath,
+ phaseTracker.Progress,
+ linkedCts.Token)
+ .ConfigureAwait(false);
+ success = true;
+ }
+ catch (OperationCanceledException)
+ {
+ errorMessage = "Import probe was cancelled.";
+ }
+ catch (Exception ex)
+ {
+ errorMessage = ex.ToString();
+ }
+ finally
+ {
+ stopwatch.Stop();
+ phaseTracker.Complete();
+ linkedCts.Cancel();
+ Console.CancelKeyPress -= cancelHandler;
+
+ try
+ {
+ await sampler.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ }
+
+ samples.Add(CaptureSample(databasePath));
+
+ return new MusicBrainzImportProbeReport(
+ "musicbrainz-import-probe",
+ storagePath,
+ databasePath,
+ startedAt,
+ DateTimeOffset.UtcNow,
+ stopwatch.Elapsed.TotalMilliseconds,
+ success,
+ errorMessage,
+ summary,
+ phaseTracker.Phases,
+ samples);
+ }
+
+ private static async Task SampleProcessAsync(
+ string databasePath,
+ int sampleIntervalMs,
+ List samples,
+ CancellationToken cancellationToken)
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ lock (samples)
+ {
+ samples.Add(CaptureSample(databasePath));
+ }
+
+ await Task.Delay(sampleIntervalMs, cancellationToken)
+ .ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
+ }
+ }
+
+ private static ImportProbeSample CaptureSample(string databasePath)
+ {
+ var process = Process.GetCurrentProcess();
+ var linuxStatus = LinuxProcessStatus.ReadCurrent();
+ return new ImportProbeSample(
+ DateTimeOffset.UtcNow,
+ process.WorkingSet64,
+ process.PeakWorkingSet64,
+ process.TotalProcessorTime.TotalMilliseconds,
+ linuxStatus.VmRssBytes,
+ linuxStatus.VmHwmBytes,
+ GetFileLength(databasePath),
+ GetFileLength($"{databasePath}.wal"),
+ GetFileLength($"{databasePath}-wal"));
+ }
+
+ private static long GetFileLength(string path) =>
+ File.Exists(path) ? new FileInfo(path).Length : 0;
+
+ private static void DeleteDatabaseFiles(string databasePath)
+ {
+ foreach (var path in new[]
+ {
+ databasePath,
+ $"{databasePath}.wal",
+ $"{databasePath}.shm",
+ $"{databasePath}-wal",
+ $"{databasePath}-shm"
+ })
+ {
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ }
+ }
+
+ private static async Task WriteReportAsync(
+ MusicBrainzImportProbeReport report,
+ string? outputPath,
+ CancellationToken cancellationToken)
+ {
+ var json = JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true });
+ if (string.IsNullOrWhiteSpace(outputPath))
+ {
+ Console.WriteLine(json);
+ return;
+ }
+
+ var outputDirectory = Path.GetDirectoryName(outputPath);
+ if (!string.IsNullOrWhiteSpace(outputDirectory))
+ {
+ Directory.CreateDirectory(outputDirectory);
+ }
+
+ await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false);
+ Console.WriteLine($"Wrote MusicBrainz import probe report to {outputPath}");
+ }
+
+ private sealed class ImportPhaseTracker
+ {
+ private readonly object gate = new();
+ private readonly List phases = [];
+ private string? currentPhase;
+ private DateTimeOffset currentStartedAt;
+ private string? currentMessage;
+ private int current;
+ private int total;
+
+ public IReadOnlyList Phases
+ {
+ get
+ {
+ lock (gate)
+ {
+ return phases.ToArray();
+ }
+ }
+ }
+
+ public void Progress(string phase, int currentValue, int totalValue, string? message)
+ {
+ lock (gate)
+ {
+ if (!string.Equals(currentPhase, phase, StringComparison.Ordinal))
+ {
+ FinishCurrentPhase();
+ currentPhase = phase;
+ currentStartedAt = DateTimeOffset.UtcNow;
+ }
+
+ current = currentValue;
+ total = totalValue;
+ currentMessage = message;
+ }
+ }
+
+ public void Complete()
+ {
+ lock (gate)
+ {
+ FinishCurrentPhase();
+ }
+ }
+
+ private void FinishCurrentPhase()
+ {
+ if (currentPhase is null)
+ {
+ return;
+ }
+
+ var finishedAt = DateTimeOffset.UtcNow;
+ phases.Add(new ImportProbePhase(
+ currentPhase,
+ currentStartedAt,
+ finishedAt,
+ (finishedAt - currentStartedAt).TotalMilliseconds,
+ current,
+ total,
+ currentMessage));
+ currentPhase = null;
+ currentMessage = null;
+ current = 0;
+ total = 0;
+ }
+ }
+
+ private readonly record struct LinuxProcessStatus(long? VmRssBytes, long? VmHwmBytes)
+ {
+ public static LinuxProcessStatus ReadCurrent()
+ {
+ const string statusPath = "/proc/self/status";
+ if (!File.Exists(statusPath))
+ {
+ return new LinuxProcessStatus(null, null);
+ }
+
+ long? vmRss = null;
+ long? vmHwm = null;
+ foreach (var line in File.ReadLines(statusPath))
+ {
+ if (line.StartsWith("VmRSS:", StringComparison.Ordinal))
+ {
+ vmRss = ParseKilobyteLine(line);
+ }
+ else if (line.StartsWith("VmHWM:", StringComparison.Ordinal))
+ {
+ vmHwm = ParseKilobyteLine(line);
+ }
+ }
+
+ return new LinuxProcessStatus(vmRss, vmHwm);
+ }
+
+ private static long? ParseKilobyteLine(string line)
+ {
+ var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ return parts.Length >= 2 && long.TryParse(parts[1], out var kilobytes)
+ ? kilobytes * 1024
+ : null;
+ }
+ }
+
+ private sealed record MusicBrainzImportProbeReport(
+ string ProbeName,
+ string StoragePath,
+ string DatabasePath,
+ DateTimeOffset StartedAtUtc,
+ DateTimeOffset FinishedAtUtc,
+ double DurationMilliseconds,
+ bool Success,
+ string? ErrorMessage,
+ DecentDBMusicBrainzImportSummary? ImportSummary,
+ IReadOnlyList PhaseTimings,
+ IReadOnlyList Samples);
+
+ private sealed record ImportProbePhase(
+ string Name,
+ DateTimeOffset StartedAtUtc,
+ DateTimeOffset FinishedAtUtc,
+ double DurationMilliseconds,
+ int Current,
+ int Total,
+ string? LastMessage);
+
+ private sealed record ImportProbeSample(
+ DateTimeOffset TimestampUtc,
+ long WorkingSetBytes,
+ long PeakWorkingSetBytes,
+ double TotalProcessorMilliseconds,
+ long? VmRssBytes,
+ long? VmHwmBytes,
+ long DatabaseBytes,
+ long WalBytes,
+ long DashWalBytes);
+}
diff --git a/benchmarks/Melodee.Benchmarks/MusicBrainzProbeOptions.cs b/benchmarks/Melodee.Benchmarks/MusicBrainzProbeOptions.cs
new file mode 100644
index 000000000..c855db5e6
--- /dev/null
+++ b/benchmarks/Melodee.Benchmarks/MusicBrainzProbeOptions.cs
@@ -0,0 +1,61 @@
+namespace Melodee.Benchmarks;
+
+internal sealed class MusicBrainzProbeOptions
+{
+ private readonly Dictionary values;
+
+ private MusicBrainzProbeOptions(Dictionary values)
+ {
+ this.values = values;
+ }
+
+ public static MusicBrainzProbeOptions Parse(string[] args)
+ {
+ var values = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (var i = 0; i < args.Length; i++)
+ {
+ var arg = args[i];
+ if (!arg.StartsWith("--", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var key = arg[2..];
+ if (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal))
+ {
+ values[key] = args[i + 1];
+ i++;
+ }
+ else
+ {
+ values[key] = "true";
+ }
+ }
+
+ return new MusicBrainzProbeOptions(values);
+ }
+
+ public string? GetString(string key) =>
+ values.TryGetValue(key, out var value) ? value : null;
+
+ public string RequireString(string key)
+ {
+ var value = GetString(key);
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new ArgumentException($"Missing required --{key} argument.");
+ }
+
+ return value;
+ }
+
+ public bool GetBool(string key) =>
+ values.TryGetValue(key, out var value) &&
+ (value is null || bool.TryParse(value, out var result) && result);
+
+ public int GetInt32(string key, int defaultValue)
+ {
+ var value = GetString(key);
+ return int.TryParse(value, out var parsed) ? parsed : defaultValue;
+ }
+}
diff --git a/benchmarks/Melodee.Benchmarks/MusicBrainzQueryProbe.cs b/benchmarks/Melodee.Benchmarks/MusicBrainzQueryProbe.cs
new file mode 100644
index 000000000..1ce16fb9e
--- /dev/null
+++ b/benchmarks/Melodee.Benchmarks/MusicBrainzQueryProbe.cs
@@ -0,0 +1,459 @@
+using System.Diagnostics;
+using System.Text.Json;
+using DecentDB.AdoNet;
+using Melodee.Common.Plugins.SearchEngine.MusicBrainz.Data;
+using Melodee.Common.Plugins.SearchEngine.MusicBrainz.Data.Models.Materialized;
+using Microsoft.EntityFrameworkCore;
+
+namespace Melodee.Benchmarks;
+
+internal static class MusicBrainzQueryProbe
+{
+ public static async Task RunAsync(string[] args)
+ {
+ try
+ {
+ var options = MusicBrainzProbeOptions.Parse(args);
+ var databasePath = options.RequireString("db");
+ var outputPath = options.GetString("output");
+
+ if (!File.Exists(databasePath))
+ {
+ throw new FileNotFoundException("MusicBrainz DecentDB database was not found.", databasePath);
+ }
+
+ var report = await RunProbeAsync(databasePath, options, CancellationToken.None)
+ .ConfigureAwait(false);
+ await WriteReportAsync(report, outputPath, CancellationToken.None).ConfigureAwait(false);
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine(ex.Message);
+ return 1;
+ }
+ }
+
+ private static async Task RunProbeAsync(
+ string databasePath,
+ MusicBrainzProbeOptions options,
+ CancellationToken cancellationToken)
+ {
+ var startedAt = DateTimeOffset.UtcNow;
+ await using var sampleContext = CreateContext(databasePath);
+ var sampleValues = await ResolveSampleAsync(sampleContext, options, cancellationToken)
+ .ConfigureAwait(false);
+ var sample = QueryProbeSample.FromValues(sampleValues);
+ var indexes = GetIndexDiagnostics(sampleContext);
+ var includeRowExistenceProbe = options.GetBool("include-row-existence");
+
+ var measurements = new List();
+ foreach (var pass in new[] { "cold", "warm" })
+ {
+ await using var context = CreateContext(databasePath);
+ measurements.Add(await MeasureCanConnectAsync(context, pass, cancellationToken)
+ .ConfigureAwait(false));
+ if (includeRowExistenceProbe)
+ {
+ measurements.Add(await MeasureQueryAsync(
+ context,
+ pass,
+ "ordered-first-row-existence",
+ sampleValues.FirstArtistId.ToString(),
+ context.Artists
+ .AsNoTracking()
+ .OrderBy(a => a.Id)
+ .Select(a => a.Id)
+ .Take(1),
+ databasePath,
+ """
+ SELECT "Id"
+ FROM "Artist"
+ ORDER BY "Id"
+ LIMIT 1
+ """,
+ cancellationToken)
+ .ConfigureAwait(false));
+ }
+ measurements.Add(await MeasureQueryAsync(
+ context,
+ pass,
+ "exact-normalized-name",
+ sample.NameNormalized,
+ context.Artists
+ .AsNoTracking()
+ .Where(a => a.NameNormalized == sampleValues.NameNormalized)
+ .OrderBy(a => a.SortName)
+ .Take(10),
+ databasePath,
+ $"""
+ SELECT *
+ FROM "Artist"
+ WHERE "NameNormalized" = {ToSqlStringLiteral(sampleValues.NameNormalized)}
+ ORDER BY "SortName"
+ LIMIT 10
+ """,
+ cancellationToken)
+ .ConfigureAwait(false));
+
+ if (!string.IsNullOrWhiteSpace(sampleValues.AliasNormalized))
+ {
+ measurements.Add(await MeasureQueryAsync(
+ context,
+ pass,
+ "exact-normalized-alias",
+ sampleValues.AliasNormalized,
+ context.ArtistAliases
+ .AsNoTracking()
+ .Where(a => a.NameNormalized == sampleValues.AliasNormalized)
+ .OrderBy(a => a.MusicBrainzArtistId)
+ .Take(10),
+ databasePath,
+ $"""
+ SELECT *
+ FROM "ArtistAlias"
+ WHERE "NameNormalized" = {ToSqlStringLiteral(sampleValues.AliasNormalized)}
+ ORDER BY "MusicBrainzArtistId"
+ LIMIT 10
+ """,
+ cancellationToken)
+ .ConfigureAwait(false));
+ }
+
+ measurements.Add(await MeasureQueryAsync(
+ context,
+ pass,
+ "exact-musicbrainz-id-raw",
+ sampleValues.MusicBrainzIdRaw,
+ context.Artists
+ .AsNoTracking()
+ .Where(a => a.MusicBrainzIdRaw == sampleValues.MusicBrainzIdRaw)
+ .OrderBy(a => a.Id)
+ .Take(1),
+ databasePath,
+ $"""
+ SELECT *
+ FROM "Artist"
+ WHERE "MusicBrainzIdRaw" = {ToSqlStringLiteral(sampleValues.MusicBrainzIdRaw)}
+ ORDER BY "Id"
+ LIMIT 1
+ """,
+ cancellationToken)
+ .ConfigureAwait(false));
+ }
+
+ return new MusicBrainzQueryProbeReport(
+ "musicbrainz-query-probe",
+ databasePath,
+ sampleContext.Database.ProviderName ?? "unknown",
+ startedAt,
+ DateTimeOffset.UtcNow,
+ sample,
+ indexes,
+ measurements);
+ }
+
+ private static MusicBrainzDbContext CreateContext(string databasePath)
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseDecentDB($"Data Source={databasePath}")
+ .Options;
+
+ return new MusicBrainzDbContext(options);
+ }
+
+ private static async Task ResolveSampleAsync(
+ MusicBrainzDbContext context,
+ MusicBrainzProbeOptions options,
+ CancellationToken cancellationToken)
+ {
+ var requestedName = options.GetString("name");
+ var requestedAlias = options.GetString("alias");
+ var requestedMbid = options.GetString("mbid");
+
+ var artist = !string.IsNullOrWhiteSpace(requestedMbid)
+ ? await context.Artists
+ .AsNoTracking()
+ .Where(a => a.MusicBrainzIdRaw == requestedMbid)
+ .OrderBy(a => a.Id)
+ .FirstOrDefaultAsync(cancellationToken)
+ .ConfigureAwait(false)
+ : null;
+
+ artist ??= !string.IsNullOrWhiteSpace(requestedName)
+ ? await context.Artists
+ .AsNoTracking()
+ .Where(a => a.NameNormalized == requestedName)
+ .OrderBy(a => a.Id)
+ .FirstOrDefaultAsync(cancellationToken)
+ .ConfigureAwait(false)
+ : null;
+
+ artist ??= await context.Artists
+ .AsNoTracking()
+ .OrderBy(a => a.Id)
+ .FirstOrDefaultAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ if (artist is null)
+ {
+ throw new InvalidOperationException("The MusicBrainz database has no materialized artists.");
+ }
+
+ var alias = requestedAlias;
+ if (string.IsNullOrWhiteSpace(alias))
+ {
+ alias = await context.ArtistAliases
+ .AsNoTracking()
+ .Where(a => a.MusicBrainzArtistId == artist.MusicBrainzArtistId)
+ .OrderBy(a => a.NameNormalized)
+ .Select(a => a.NameNormalized)
+ .FirstOrDefaultAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ if (string.IsNullOrWhiteSpace(alias))
+ {
+ alias = await context.ArtistAliases
+ .AsNoTracking()
+ .OrderBy(a => a.MusicBrainzArtistId)
+ .ThenBy(a => a.NameNormalized)
+ .Select(a => a.NameNormalized)
+ .FirstOrDefaultAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ return new QueryProbeSampleValues(
+ artist.Id,
+ artist.NameNormalized,
+ alias,
+ artist.MusicBrainzIdRaw);
+ }
+
+ private static IReadOnlyList GetIndexDiagnostics(MusicBrainzDbContext context)
+ {
+ var entityTypes = new[]
+ {
+ typeof(Artist),
+ typeof(ArtistAliasLookup),
+ typeof(Album)
+ };
+
+ return entityTypes
+ .SelectMany(entityType => context.Model.FindEntityType(entityType)?.GetIndexes() ?? [])
+ .Select(index => new QueryProbeIndexDiagnostic(
+ index.DeclaringEntityType.GetTableName() ?? index.DeclaringEntityType.DisplayName(),
+ index.GetDatabaseName() ?? "(unnamed)",
+ index.IsUnique,
+ index.Properties.Select(property => property.Name).ToArray()))
+ .OrderBy(index => index.Table)
+ .ThenBy(index => index.Name)
+ .ToArray();
+ }
+
+ private static async Task MeasureCanConnectAsync(
+ MusicBrainzDbContext context,
+ string pass,
+ CancellationToken cancellationToken)
+ {
+ var started = Stopwatch.GetTimestamp();
+ var canConnect = await context.Database.CanConnectAsync(cancellationToken).ConfigureAwait(false);
+ var elapsed = Stopwatch.GetElapsedTime(started);
+
+ return new QueryProbeMeasurement(
+ "database-can-connect",
+ pass,
+ null,
+ canConnect ? 1 : 0,
+ elapsed.TotalMilliseconds,
+ null,
+ QueryProbePlanDiagnostic.NotApplicable("No SQL plan is available for Database.CanConnectAsync()."));
+ }
+
+ private static async Task MeasureQueryAsync(
+ MusicBrainzDbContext context,
+ string pass,
+ string name,
+ string? sampleValue,
+ IQueryable query,
+ string databasePath,
+ string planSql,
+ CancellationToken cancellationToken)
+ {
+ var sql = TryGetSql(query);
+ var started = Stopwatch.GetTimestamp();
+ var rows = await query.ToArrayAsync(cancellationToken).ConfigureAwait(false);
+ var elapsed = Stopwatch.GetElapsedTime(started);
+ var planDiagnostics = TryGetPlanDiagnostics(databasePath, planSql);
+
+ return new QueryProbeMeasurement(
+ name,
+ pass,
+ SanitizeSampleValue(sampleValue),
+ rows.Length,
+ elapsed.TotalMilliseconds,
+ sql,
+ planDiagnostics);
+ }
+
+ private static string? TryGetSql(IQueryable query)
+ {
+ try
+ {
+ return query.ToQueryString();
+ }
+ catch (Exception ex)
+ {
+ return $"SQL unavailable: {ex.Message}";
+ }
+ }
+
+ private static QueryProbePlanDiagnostic TryGetPlanDiagnostics(string databasePath, string planSql)
+ {
+ try
+ {
+ using var connection = new DecentDBConnection($"Data Source={databasePath}");
+ connection.Open();
+ var plan = connection.ExplainQuery(planSql);
+ return QueryProbePlanDiagnostic.Captured(
+ plan.Sql,
+ plan.ExplainSql,
+ plan.Duration.TotalMilliseconds,
+ plan.Lines);
+ }
+ catch (Exception ex)
+ {
+ return QueryProbePlanDiagnostic.Unavailable(planSql, ex.Message);
+ }
+ }
+
+ private static string ToSqlStringLiteral(string? value)
+ {
+ return value is null ? "NULL" : $"'{value.Replace("'", "''", StringComparison.Ordinal)}'";
+ }
+
+ private static string? SanitizeSampleValue(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return value;
+ }
+
+ return value.Length <= 160 ? value : string.Concat(value.AsSpan(0, 160), "...");
+ }
+
+ private static async Task WriteReportAsync(
+ MusicBrainzQueryProbeReport report,
+ string? outputPath,
+ CancellationToken cancellationToken)
+ {
+ var json = JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true });
+ if (string.IsNullOrWhiteSpace(outputPath))
+ {
+ Console.WriteLine(json);
+ return;
+ }
+
+ var outputDirectory = Path.GetDirectoryName(outputPath);
+ if (!string.IsNullOrWhiteSpace(outputDirectory))
+ {
+ Directory.CreateDirectory(outputDirectory);
+ }
+
+ await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false);
+ Console.WriteLine($"Wrote MusicBrainz query probe report to {outputPath}");
+ }
+
+ private sealed record MusicBrainzQueryProbeReport(
+ string ProbeName,
+ string DatabasePath,
+ string ProviderName,
+ DateTimeOffset StartedAtUtc,
+ DateTimeOffset FinishedAtUtc,
+ QueryProbeSample Sample,
+ IReadOnlyList Indexes,
+ IReadOnlyList Measurements);
+
+ private sealed record QueryProbeSample(
+ long FirstArtistId,
+ string NameNormalized,
+ string? AliasNormalized,
+ string MusicBrainzIdRaw)
+ {
+ public static QueryProbeSample FromValues(QueryProbeSampleValues values)
+ {
+ return new QueryProbeSample(
+ values.FirstArtistId,
+ SanitizeSampleValue(values.NameNormalized) ?? string.Empty,
+ SanitizeSampleValue(values.AliasNormalized),
+ SanitizeSampleValue(values.MusicBrainzIdRaw) ?? string.Empty);
+ }
+ }
+
+ private sealed record QueryProbeSampleValues(
+ long FirstArtistId,
+ string NameNormalized,
+ string? AliasNormalized,
+ string MusicBrainzIdRaw);
+
+ private sealed record QueryProbeIndexDiagnostic(
+ string Table,
+ string Name,
+ bool IsUnique,
+ IReadOnlyList Properties);
+
+ private sealed record QueryProbeMeasurement(
+ string Name,
+ string Pass,
+ string? SampleValue,
+ int RowCount,
+ double ElapsedMilliseconds,
+ string? Sql,
+ QueryProbePlanDiagnostic PlanDiagnostics);
+
+ private sealed record QueryProbePlanDiagnostic(
+ string Status,
+ string? PlanSql,
+ string? ExplainSql,
+ double? ElapsedMilliseconds,
+ IReadOnlyList Lines,
+ string? Error)
+ {
+ public static QueryProbePlanDiagnostic Captured(
+ string planSql,
+ string explainSql,
+ double elapsedMilliseconds,
+ IReadOnlyList lines)
+ {
+ return new QueryProbePlanDiagnostic(
+ "captured",
+ planSql,
+ explainSql,
+ elapsedMilliseconds,
+ lines,
+ null);
+ }
+
+ public static QueryProbePlanDiagnostic Unavailable(string planSql, string error)
+ {
+ return new QueryProbePlanDiagnostic(
+ "unavailable",
+ planSql,
+ null,
+ null,
+ [],
+ error);
+ }
+
+ public static QueryProbePlanDiagnostic NotApplicable(string reason)
+ {
+ return new QueryProbePlanDiagnostic(
+ "not-applicable",
+ null,
+ null,
+ null,
+ [],
+ reason);
+ }
+ }
+}
diff --git a/benchmarks/Melodee.Benchmarks/Program.cs b/benchmarks/Melodee.Benchmarks/Program.cs
index 6d25451dc..fc67d0f54 100644
--- a/benchmarks/Melodee.Benchmarks/Program.cs
+++ b/benchmarks/Melodee.Benchmarks/Program.cs
@@ -8,7 +8,7 @@ namespace Melodee.Benchmarks;
public class Program
{
- public static void Main(string[] args)
+ public static async Task Main(string[] args)
{
if (args.Length == 0)
{
@@ -21,18 +21,37 @@ public static void Main(string[] args)
Console.WriteLine(" cache - Cache performance benchmarks");
Console.WriteLine(" collection - Collection operation benchmarks");
Console.WriteLine(" musicbrainz - MusicBrainz import performance benchmarks");
+ Console.WriteLine(" musicbrainz-query-probe - Manual DecentDB MusicBrainz query probe");
+ Console.WriteLine(" musicbrainz-import-probe - Manual DecentDB MusicBrainz import probe");
Console.WriteLine(" all - Run all benchmarks");
Console.WriteLine();
Console.WriteLine("Usage: dotnet run -c Release --project benchmarks/Melodee.Benchmarks [category] [-- BenchmarkDotNet args]");
Console.WriteLine("Example: dotnet run -c Release --project benchmarks/Melodee.Benchmarks streaming");
Console.WriteLine("Example: dotnet run -c Release --project benchmarks/Melodee.Benchmarks all -- --exporters json,github,csv --artifacts benchmarks/artifacts");
- return;
+ Console.WriteLine("Example: dotnet run -c Release --project benchmarks/Melodee.Benchmarks musicbrainz-query-probe -- --db /path/musicbrainz.ddb --output query-probe.json [--include-row-existence]");
+ Console.WriteLine("Example: dotnet run -c Release --project benchmarks/Melodee.Benchmarks musicbrainz-import-probe -- --storage /path/musicbrainz --db /tmp/musicbrainz.ddb --output import-probe.json --clean");
+ return 0;
}
var category = args[0].ToLower();
// Parse BenchmarkDotNet arguments (everything after the first argument)
var benchmarkArgs = args.Skip(1).ToArray();
+ if (benchmarkArgs.Length > 0 && benchmarkArgs[0] == "--")
+ {
+ benchmarkArgs = benchmarkArgs.Skip(1).ToArray();
+ }
+
+ if (category == "musicbrainz-query-probe")
+ {
+ return await MusicBrainzQueryProbe.RunAsync(benchmarkArgs);
+ }
+
+ if (category == "musicbrainz-import-probe")
+ {
+ return await MusicBrainzImportProbe.RunAsync(benchmarkArgs);
+ }
+
var config = CreateConfig(benchmarkArgs);
switch (category)
@@ -61,9 +80,11 @@ public static void Main(string[] args)
break;
default:
Console.WriteLine($"Unknown benchmark category: {category}");
- Console.WriteLine("Available categories: streaming, database, cache, collection, musicbrainz, all");
+ Console.WriteLine("Available categories: streaming, database, cache, collection, musicbrainz, musicbrainz-query-probe, musicbrainz-import-probe, all");
break;
}
+
+ return 0;
}
private static IConfig CreateConfig(string[] args)
diff --git a/design/2026-05-25.REVIEW.md b/design/2026-05-25.REVIEW.md
new file mode 100644
index 000000000..7b7540eb3
--- /dev/null
+++ b/design/2026-05-25.REVIEW.md
@@ -0,0 +1,914 @@
+## Melodee Large Collection Ingestion Review
+
+**Date**: 2026-05-25
+**Status**: Architecture review and phased implementation plan
+**Scope**: Ingestion, staging, storage promotion, database insertion, and
+large-collection runtime concerns
+
+## Executive Summary
+
+Melodee has the right high-level ingestion model for a serious media manager:
+raw releases enter an inbound area, normalized releases move to staging, valid
+staged releases move to canonical storage, and storage metadata is inserted into
+the database for serving through the UI and APIs.
+
+The current implementation is not yet shaped for the stated target scale of
+millions of songs and hundreds of thousands of releases. The main issue is not a
+single bad loop or one missing index. The broader issue is that ingestion is
+still mostly a filesystem sweep with in-memory progress, rather than a durable,
+idempotent work pipeline.
+
+The system should evolve toward:
+
+- a persistent release work item model
+- streaming discovery rather than full-tree materialization
+- idempotent file moves and database inserts
+- a clear separation between structural media validity and metadata enrichment
+- batch-oriented database writes and aggregate maintenance
+- materialized browse/search indexes for large library API requests
+- durable messaging once the work boundaries need to cross processes
+
+On the queue question: yes, this is the right time to talk about a real queue
+system such as RabbitMQ. It should be planned now, but it should not be the first
+implementation move. A broker delivers work; it does not make filesystem moves,
+database writes, or metadata enrichment idempotent. Melodee should first make the
+database the source of truth for ingestion state. After that, RabbitMQ becomes a
+clean way to distribute the work across dedicated workers and survive process
+restarts.
+
+This aligns with the existing internal direction in
+`design/requirements/message-bus-strategy-and-requirements.md`, which already
+standardizes on Wolverine, RabbitMQ, and PostgreSQL-backed durable inbox/outbox.
+
+## Current Ingestion Model
+
+The documented model is accurate at the conceptual level:
+
+1. Inbound processing reads releases from an inbound library.
+2. Media and metadata are normalized into the staging library.
+3. Staging revalidation can re-check albums that failed artist enrichment.
+4. Valid staged albums move to storage.
+5. Storage albums are inserted into the database.
+6. API clients then serve from the database and canonical storage paths.
+
+The CLI `mcli library scan` currently runs four steps in sequence:
+
+1. `LibraryInboundProcessJob`
+2. `StagingAlbumRevalidationJob`
+3. `StagingAutoMoveJob`
+4. `LibraryInsertJob`
+
+The scheduled job chain is slightly different: inbound processing chains to
+staging auto-move, and staging auto-move chains to library insert. Staging
+revalidation runs on its own schedule, but the CLI scan includes it as part of
+the full manual workflow.
+
+That model is understandable for users and operators. The implementation needs
+to become more durable and less sweep-oriented before it can be trusted for very
+large collections.
+
+## Primary Findings
+
+### Finding 1: Ingestion Is Sweep-Based Instead Of Work-Item-Based
+
+Inbound processing discovers candidate directories, materializes them, and then
+processes the list. Staging promotion and database insertion also scan broad
+directory trees to find `melodee.json` files.
+
+Relevant examples:
+
+- `DirectoryProcessorToStagingService.ProcessDirectoryAsync` calls
+ `GetFileSystemDirectoryInfosToProcess(...).ToList()`.
+- `LibraryService.MoveAlbumsFromLibraryToLibrary` calls
+ `Directory.GetFiles(..., SearchOption.AllDirectories)`.
+- `LibraryInsertJob.GetMelodeeFilesToProcess` recursively enumerates all
+ `melodee.json` files and returns a `List`.
+- `LibraryService.Rebuild` turns the directory enumerable into an array.
+
+For small libraries this is reasonable. For hundreds of thousands of releases,
+even no-op scans become expensive because the application must keep asking the
+filesystem the same questions.
+
+The durable representation should be:
+
+- one row per discovered release or release directory
+- one stable source path
+- one normalized destination path
+- one manifest hash
+- one current state
+- attempt count and last error
+- lease fields for safe concurrent workers
+- timestamps for discovered, staged, stored, indexed, and failed transitions
+
+The filesystem should be used to discover and verify work, not to be the only
+source of truth about what work exists.
+
+### Finding 2: Checkpoints Can Advance Without A Full Successful Outcome
+
+The current jobs are written as procedural workflows. If one stage reports
+errors but still returns a result object, the caller can still record scan
+history or update status values.
+
+The high-risk pattern is:
+
+- scan starts
+- some releases fail
+- job records a scan timestamp
+- the next run assumes the library is up to date
+- the failed releases do not get retried unless a force scan or a separate
+ reprocess path catches them
+
+For large collections, this is dangerous because the operator cannot reasonably
+inspect every missed release. Each release needs its own durable state and retry
+history. Job-level timestamps are still useful for reporting, but they should not
+be the only gate controlling whether a release is eligible for processing.
+
+### Finding 3: Structural Validity And Metadata Enrichment Are Coupled Too Tightly
+
+Today an album can be invalid because the artist cannot be resolved to a Melodee
+database id, MusicBrainz id, or Spotify id. That makes ingestion success depend
+on external or local search-engine coverage.
+
+That is too strict for large personal collections. Large libraries contain:
+
+- obscure artists
+- private recordings
+- live sets
+- purchased releases not yet known to metadata providers
+- internal or self-produced albums
+- legacy releases with imperfect tags
+
+Melodee should distinguish:
+
+- **structurally valid**: playable media, sane tags, track numbers, durations,
+ album title, album artist text, and no missing files
+- **enriched**: matched to MusicBrainz, Spotify, Discogs, or another metadata
+ authority
+- **curated**: manually reviewed or explicitly accepted by the operator
+
+Only structural validity should be required for safe staging and storage. Search
+engine enrichment should improve metadata quality, artwork, relations, and
+confidence, but it should not always block a clean local release from entering
+the canonical library.
+
+This point matters directly for Blackbeard provenance. Blackbeard can provide
+useful release-local facts. It should help Melodee trust a release as a
+well-formed local release, while enrichment can remain asynchronous.
+
+### Finding 4: Database Insert Is Batched, But Still Too Per-Album Oriented
+
+`LibraryInsertJob` has moved in a better direction by using batches and
+short-lived contexts. However, the hot path still does repeated per-album
+operations:
+
+- find or create artist
+- find existing album
+- calculate song file hash
+- build song entities
+- save albums
+- derive contributors
+- save contributors
+
+This should be pushed toward bulk lookup and set-based persistence:
+
+- read a batch of manifests
+- collect all artist keys, normalized names, MusicBrainz ids, and Spotify ids
+- issue one lookup per key shape or one consolidated lookup strategy
+- collect all album keys and normalized identifiers
+- issue one album lookup
+- insert missing artists and albums in a transaction
+- insert songs with already-known file metadata
+- insert contributors with bulk dedupe
+
+File hashes should be computed during normalization and stored in the manifest.
+The insert phase should not reread every media file just to compute a CRC unless
+the manifest is missing or explicitly stale.
+
+### Finding 5: Aggregate Refresh Is O(Total Library)
+
+`LibraryService.UpdateAggregatesAsync` loads all artists in a library and issues
+count queries per artist. It then updates library counts and clears the entire
+cache.
+
+That is not suitable after every insert batch in a very large collection.
+
+The target shape should be one of:
+
+- incremental counter updates inside the insert transaction
+- periodic aggregate maintenance as a separate background job
+- set-based SQL update statements
+- materialized aggregate tables refreshed by changed artist/library ids
+
+The cache should also be invalidated by region and entity scope. A full cache
+clear after aggregate work is a blunt tool that will cause avoidable cold-cache
+latency in large deployments.
+
+### Finding 6: The Final Storage Layout Is Better Than The Transient Layout
+
+Final storage already shards artist directories by prefix through
+`ArtistExtensions.ToDirectoryName`. That is a good direction. It avoids putting
+all artists directly under one storage root.
+
+The transient layout is weaker:
+
+- inbound may contain many top-level releases
+- staging can accumulate many release folders
+- staging promotion scans all `melodee.json` files recursively
+- invalid releases can remain in staging indefinitely
+
+For very large ingestion runs, staging should also be sharded. A common shape is:
+
+```text
+staging/
+ 2026/
+ 05/
+ run-20260525-001/
+ ok/
+ review/
+ failed/
+```
+
+or:
+
+```text
+staging/
+ ab/
+ abc123-release-id/
+```
+
+The exact structure is less important than the guarantee that no single
+directory becomes a massive operational hot spot.
+
+### Finding 7: Runtime API Paths Need Large-Library Browse Strategies
+
+The ingestion pipeline is the immediate concern, but a million-song library also
+changes API expectations.
+
+Two examples need attention:
+
+- `GetIndexesAsync` loads all artists, then groups them in memory.
+- `GetRandomSongsAsync` can load all matching songs, then shuffle in memory.
+
+Those patterns will become visible latency and memory problems. Large libraries
+need materialized browse indexes, keyset pagination, indexed random sampling, and
+precomputed counts for common OpenSubsonic views.
+
+### Finding 8: CLI Scans Do Not Get Scheduler Concurrency Guarantees
+
+Quartz job attributes such as `DisallowConcurrentExecution` help when Quartz is
+actually scheduling the job. The CLI scan command directly instantiates and runs
+job classes.
+
+That means a manual CLI scan can race the server scheduler unless there is a
+separate database-backed library lock or run lease.
+
+This matters more once the library is large, because long-running scans become
+normal. The system should assume that two processes can exist:
+
+- the web/server process
+- a CLI or worker process
+
+Both must coordinate through durable state, not in-process objects.
+
+## Phase Map
+
+| Phase | Name | Current status | Target status |
+|-------|------|----------------|---------------|
+| 0 | Baseline and guardrails | Partial | Measured baseline with scale tests and operational budgets |
+| 1 | Durable ingestion state | Not started | Database-backed release work items with leases and retries |
+| 2 | Streaming discovery | Partial | No full-tree materialization in normal ingestion paths |
+| 3 | Validation/enrichment split | Partial | Local structural validity can pass without external ids |
+| 4 | Idempotent file operations and layout | Partial | Atomic or resumable moves with sharded staging |
+| 5 | Bulk database insertion | Partial | Batch lookup/upsert with transactional checkpointing |
+| 6 | Aggregates and API scale | Partial | Incremental aggregates and materialized browse paths |
+| 7 | Durable distributed messaging | Design documented | Wolverine + RabbitMQ + durable inbox/outbox in runtime |
+| 8 | Operations, replay, and observability | Partial | DLQ/replay/run dashboards and clear operator controls |
+
+Status definitions:
+
+- **Not started**: no meaningful implementation exists in the current shape.
+- **Partial**: some supporting pieces exist, but the phase goal is not satisfied.
+- **Design documented**: architectural direction exists, but runtime behavior is
+ not yet implemented.
+- **Complete**: implementation and validation are both in place.
+
+No phase in this map should be marked complete until there are tests or measured
+evidence proving the claim at representative scale.
+
+## Phase 0: Baseline And Guardrails
+
+### Goal
+
+Create enough measurement and regression coverage that future ingestion work can
+be evaluated objectively.
+
+### Current Status
+
+Partial.
+
+Melodee has useful logs and several recent performance-oriented changes, but the
+normal test suite does not yet prove behavior at the intended collection size.
+
+### Work Items
+
+1. Add a synthetic filesystem generator for release trees.
+2. Add a synthetic manifest generator for `melodee.json` files.
+3. Add a database seed path for:
+ - 100,000 artists
+ - 500,000 albums
+ - 5,000,000 songs
+4. Add no-op scan benchmarks.
+5. Add staged-to-storage promotion benchmarks.
+6. Add storage-to-database insert benchmarks.
+7. Add OpenSubsonic browse benchmarks for:
+ - indexes
+ - random songs
+ - album lists
+ - search
+8. Track wall-clock time, peak memory, database query count, files enumerated,
+ and bytes read/written.
+
+### Exit Criteria
+
+Phase 0 is complete when a developer can run one command that produces a
+repeatable ingestion performance report. The report should show both a small
+developer dataset and a large synthetic dataset.
+
+The large synthetic dataset does not need real audio. It can use placeholder
+files or fake filesystem abstractions for most tests. Full media tests should be
+reserved for smaller representative samples.
+
+## Phase 1: Durable Ingestion State
+
+### Goal
+
+Make ingestion state explicit, durable, resumable, and visible.
+
+### Current Status
+
+Not started.
+
+The code currently uses library scan history, filesystem state, logs, and
+manifest files. That is not enough to safely coordinate large ingestion.
+
+### Proposed Data Model
+
+Add a table such as `IngestionItems` or `ReleaseWorkItems`.
+
+Suggested fields:
+
+| Field | Purpose |
+|-------|---------|
+| `Id` | Stable database identity |
+| `ApiKey` | Public/stable id for operator views and APIs |
+| `SourceLibraryId` | Inbound or staging library source |
+| `TargetLibraryId` | Staging or storage target when known |
+| `SourcePath` | Original release directory |
+| `CurrentPath` | Current known directory |
+| `TargetPath` | Planned destination |
+| `ManifestPath` | Current `melodee.json` path, if created |
+| `ManifestHash` | Hash of normalized manifest content |
+| `SourceFingerprint` | Hash or summary of source file names, sizes, mtimes |
+| `State` | Current state enum |
+| `StateReason` | Machine-readable reason for review/failure |
+| `AttemptCount` | Retry count |
+| `LastError` | Truncated operator-safe error |
+| `LockedBy` | Worker/process lease owner |
+| `LockedUntil` | Lease expiration |
+| `DiscoveredAt` | First discovery timestamp |
+| `LastAttemptAt` | Last work attempt timestamp |
+| `CompletedAt` | Terminal success timestamp |
+| `CorrelationId` | Run or message correlation |
+
+Suggested states:
+
+```text
+Discovered
+Normalizing
+Normalized
+Staged
+StructurallyValid
+NeedsReview
+EnrichmentPending
+Enriched
+MovingToStorage
+Stored
+Indexing
+Indexed
+Failed
+Quarantined
+Ignored
+```
+
+### Required Behavior
+
+- Work is claimed by lease, not by in-process assumptions.
+- A worker can crash and another worker can resume after lease expiration.
+- A release is retried based on item state, not based on directory timestamps.
+- A scan history row reports what happened, but does not decide what still needs
+ to happen.
+- State transitions are monotonic unless an explicit reprocess command is issued.
+- Every transition records enough context for an operator to understand why the
+ release is where it is.
+
+### Exit Criteria
+
+Phase 1 is complete when inbound processing can be interrupted and restarted
+without losing track of every discovered release.
+
+## Phase 2: Streaming Discovery
+
+### Goal
+
+Remove full-tree materialization from normal ingestion paths.
+
+### Current Status
+
+Partial.
+
+Some helpers use enumeration APIs, but multiple hot paths immediately convert
+the result to `List`, `Array`, or full `Directory.GetFiles` output.
+
+### Work Items
+
+1. Replace `ToList()` and `ToArray()` discovery points with paged work item
+ creation.
+2. Store discovered candidates in the ingestion table in batches.
+3. Use keyset paging over ingestion items when workers process releases.
+4. Keep progress reporting approximate when exact totals require a full scan.
+5. Avoid repeated file enumeration inside the same release directory.
+6. Change `GetFileSystemDirectoryInfosToProcess` so it does not enumerate files
+ twice for each directory.
+7. Review the `*.*` search patterns. Some directory names without dots may be
+ skipped depending on platform behavior and API call shape.
+8. Avoid root-directory last-write time as the primary scan gate.
+
+### Design Notes
+
+Streaming discovery should not mean "do one release and forget everything." It
+should mean:
+
+- discover a bounded page
+- persist candidates
+- process candidates by durable state
+- continue until the page source is exhausted
+
+That gives the system resumability without requiring a perfect filesystem
+watcher.
+
+### Exit Criteria
+
+Phase 2 is complete when a no-op scan of a large already-processed library does
+not recursively deserialize or inspect every release unless explicitly requested.
+
+## Phase 3: Split Structural Validation From Enrichment
+
+### Goal
+
+Allow well-formed local releases to flow through ingestion even when external
+metadata providers cannot identify the artist or album.
+
+### Current Status
+
+Partial.
+
+Melodee already has validation and enrichment concepts, but current album
+validity is still strongly tied to artist identity from Melodee, MusicBrainz, or
+Spotify.
+
+### Proposed Validation Categories
+
+#### Structural validation
+
+This should answer: can Melodee safely store and play this release?
+
+Required checks:
+
+- media files exist
+- media files are readable
+- song count is coherent
+- track numbers are coherent
+- duration values are present
+- album title is present
+- album artist text is present
+- storage path can be generated
+- required artwork policy is satisfied, if enabled
+
+#### Enrichment validation
+
+This should answer: how confident is Melodee that this release is matched to
+external metadata?
+
+Signals:
+
+- MusicBrainz artist id
+- MusicBrainz release id
+- Spotify artist id
+- Spotify album id
+- Discogs id
+- local artist cache match
+- Blackbeard provenance
+- NFO/SFV/CUE/M3U metadata confidence
+
+#### Curation validation
+
+This should answer: did a trusted operator explicitly accept this release?
+
+Signals:
+
+- manual approval
+- trusted source rule
+- trusted provenance source
+- ignored warning rule
+
+### Blackbeard Role
+
+Blackbeard provenance should be treated as a first-class directory metadata
+source. It should help establish local structural facts, such as:
+
+- source tool version
+- normalized tags
+- final validation outcome from the upstream tool
+- track list
+- file names
+- release-level artist/title/year/genre
+
+It should not automatically import secrets or unstable internal paths. The
+provenance file can contain source URLs, commands, absolute paths, and internal
+details. Melodee should retain only safe fields or store sensitive fields in a
+redacted operator-only diagnostic area.
+
+### Exit Criteria
+
+Phase 3 is complete when a local release with good tags, media, and artwork can
+be marked structurally valid even if MusicBrainz and Spotify return no match.
+
+## Phase 4: Idempotent File Operations And Scalable Layout
+
+### Goal
+
+Make file movement safe, resumable, and suitable for large directories and
+network-mounted storage.
+
+### Current Status
+
+Partial.
+
+The code has improved copy helpers and final storage sharding by artist prefix,
+but staging and movement workflows are not yet modeled as durable, resumable
+operations.
+
+### Work Items
+
+1. Add operation ids for normalization, staging move, storage move, and insert.
+2. Use temporary destination directories during copy/move.
+3. Write a move manifest before starting a multi-file move.
+4. Verify destination file size and hash when required.
+5. Mark the operation complete only after manifest verification.
+6. Delete source files only after successful destination verification.
+7. Make repeated execution safe.
+8. Add staging sharding.
+9. Add collision strategy for same artist/year/title.
+10. Keep invalid releases in review or failed state, not in an unbounded flat
+ staging root.
+
+### Suggested Move Shape
+
+```text
+target/
+ .melodee-op-123.tmp/
+ files...
+ move-manifest.json
+ Final Directory/
+ files...
+ melodee.json
+```
+
+The operation should only rename the temporary directory to the final directory
+after all files are present and verified.
+
+### Exit Criteria
+
+Phase 4 is complete when killing the process during a file move never leaves a
+release in an ambiguous state. On restart, Melodee should either resume, roll
+forward, or mark the release failed with a specific reason.
+
+## Phase 5: Bulk Database Insertion
+
+### Goal
+
+Turn storage-to-database insertion into a batch lookup/upsert pipeline with
+transactional checkpointing.
+
+### Current Status
+
+Partial.
+
+`LibraryInsertJob` uses batches, but still does too much per album and per song.
+
+### Work Items
+
+1. Read a bounded page of stored work items that are ready for indexing.
+2. Deserialize manifests in parallel with bounded concurrency.
+3. Collect artist lookup keys for the whole batch.
+4. Query existing artists in bulk.
+5. Insert missing artists in bulk.
+6. Collect album lookup keys for the whole batch.
+7. Query existing albums in bulk.
+8. Insert missing albums and songs in one transaction per batch.
+9. Insert contributors after song ids are known.
+10. Mark each ingestion item indexed only after the transaction commits.
+11. Do not compute media file hashes in the insert phase when a trusted manifest
+ already has them.
+
+### Transaction Rule
+
+The database transaction should own:
+
+- artist insert
+- album insert
+- song insert
+- contributor insert
+- ingestion item state transition to `Indexed`
+- outbox message creation for downstream consumers
+
+If the transaction fails, the ingestion item should remain eligible for retry.
+
+### Exit Criteria
+
+Phase 5 is complete when inserting a large batch has bounded query counts and
+does not reread every media file.
+
+## Phase 6: Aggregates, Cache, And Runtime API Scale
+
+### Goal
+
+Keep the application responsive after the library is large.
+
+### Current Status
+
+Partial.
+
+The database has many useful indexes, and services use `AsNoTracking` in many
+read paths. However, some high-level API methods still load too much data into
+memory, and aggregate refreshes are too broad.
+
+### Work Items
+
+1. Replace full aggregate refresh after inserts with incremental aggregate
+ changes.
+2. Add a periodic aggregate repair job for consistency.
+3. Invalidate caches by affected entity/library/region, not globally.
+4. Materialize OpenSubsonic artist indexes.
+5. Add keyset pagination for large browse views.
+6. Replace in-memory random song shuffling with indexed random sampling.
+7. Normalize genres into queryable tables if genre filtering becomes common.
+8. Add query plans or benchmark assertions for common API calls.
+
+### Candidate Materialized Tables
+
+```text
+ArtistBrowseIndex
+AlbumBrowseIndex
+SongSearchIndex
+GenreIndex
+LibraryAggregateSnapshot
+```
+
+These do not need to replace normalized source tables. They can be denormalized
+read models maintained by ingestion and repair jobs.
+
+### Exit Criteria
+
+Phase 6 is complete when common API operations do not need to load all artists,
+albums, or matching songs into memory.
+
+## Phase 7: Durable Distributed Messaging
+
+### Goal
+
+Introduce a real queue/bus when Melodee is ready to distribute ingestion work
+across processes.
+
+### Current Status
+
+Design documented.
+
+The existing design direction is Wolverine plus RabbitMQ plus PostgreSQL durable
+inbox/outbox. The current runtime still needs to be brought into that shape.
+
+### Should RabbitMQ Be Discussed Now?
+
+Yes.
+
+RabbitMQ should be part of the architecture now because Melodee's target scale
+implies long-running background work, retries, multiple worker processes, and
+clear operational boundaries. A single web process with in-memory local
+messaging is not a strong long-term fit for large ingestion.
+
+However, RabbitMQ should not be treated as the first fix. Adding a broker before
+the ingestion operations are idempotent can make failures harder to reason about.
+The database-backed work item model should come first.
+
+The right sequence is:
+
+1. Make release state durable in PostgreSQL.
+2. Make operations idempotent and lease-based.
+3. Make database writes transactional with work item state changes.
+4. Add durable outbox messages from those transactions.
+5. Use RabbitMQ to distribute commands/events to workers.
+6. Add durable inbox and idempotent consumers.
+7. Add dead-letter handling and replay tools.
+
+### Recommended Message Types
+
+Commands:
+
+- `DiscoverInboundLibrary`
+- `NormalizeRelease`
+- `ValidateRelease`
+- `EnrichReleaseMetadata`
+- `MoveReleaseToStorage`
+- `IndexStoredRelease`
+- `RepairLibraryAggregates`
+
+Events:
+
+- `ReleaseDiscovered`
+- `ReleaseNormalized`
+- `ReleaseMarkedStructurallyValid`
+- `ReleaseNeedsReview`
+- `ReleaseMovedToStorage`
+- `ReleaseIndexed`
+- `ReleaseFailed`
+- `ArtistCreated`
+- `AlbumCreated`
+- `SongCreated`
+
+### Queue Boundary Rule
+
+RabbitMQ should carry commands and events. It should not be the source of truth
+for release state.
+
+The source of truth should be PostgreSQL:
+
+- current item state
+- leases
+- attempts
+- errors
+- idempotency keys
+- outbox messages
+- inbox dedupe
+
+This lets Melodee survive:
+
+- web process restarts
+- worker process restarts
+- RabbitMQ restarts
+- temporary storage outages
+- retry storms
+- duplicate message delivery
+
+### Exit Criteria
+
+Phase 7 is complete when ingestion workers can run outside the web host, process
+work through RabbitMQ, survive restarts, and safely replay failed messages.
+
+## Phase 8: Operations, Replay, And Observability
+
+### Goal
+
+Give operators enough visibility and control to run Melodee against very large
+libraries without guessing what happened.
+
+### Current Status
+
+Partial.
+
+Logs exist, and the CLI reports useful summaries. The missing piece is a
+release-level operational view.
+
+### Work Items
+
+1. Add an ingestion run table.
+2. Add an ingestion item detail view.
+3. Add state transition history.
+4. Add retry controls.
+5. Add replay controls.
+6. Add quarantine/review controls.
+7. Add DLQ browser once RabbitMQ is introduced.
+8. Add per-phase metrics:
+ - releases discovered
+ - releases normalized
+ - releases moved
+ - releases indexed
+ - bytes copied
+ - files copied
+ - average duration per release
+ - failure count by reason
+9. Add operator-safe error messages that do not expose secrets from provenance
+ files or source metadata.
+
+### Exit Criteria
+
+Phase 8 is complete when an operator can answer these questions from the UI or
+CLI:
+
+- What is currently being processed?
+- What failed?
+- Why did it fail?
+- Is it safe to retry?
+- What will retry do?
+- Which worker owns this item?
+- How many releases are waiting in each phase?
+- How long will the backlog likely take?
+
+## Suggested Implementation Order
+
+### First Implementation Slice
+
+The first slice should be small but structural:
+
+1. Add `IngestionItem` and `IngestionRun` tables.
+2. Add state enums and transition helpers.
+3. Add a discovery path that records inbound release directories as work items.
+4. Keep existing processing logic, but have it claim one item at a time.
+5. Mark items as staged, failed, or needing review.
+6. Add CLI reporting for item counts by state.
+
+This slice creates the durable backbone without forcing a full rewrite.
+
+### Second Implementation Slice
+
+Convert staging auto-move to work from durable state:
+
+1. Select staged items where structural status is valid.
+2. Lease items before moving.
+3. Move with a temporary destination and operation manifest.
+4. Mark moved items as stored.
+5. Keep a compatibility fallback for existing staging directories during the
+ migration period.
+
+### Third Implementation Slice
+
+Convert library insert to work from stored items:
+
+1. Select stored items waiting for indexing.
+2. Batch lookup artists and albums.
+3. Insert in transactions.
+4. Mark indexed only after commit.
+5. Emit outbox events if the message infrastructure is ready.
+
+### Fourth Implementation Slice
+
+Introduce Wolverine and RabbitMQ for one non-critical command path first.
+
+Good candidates:
+
+- enrichment retry
+- aggregate repair
+- non-blocking image enrichment
+
+Avoid starting with the file move path. File moves are the least forgiving part
+of the workflow and should first become idempotent under direct execution.
+
+## Risk Register
+
+| Risk | Impact | Mitigation |
+|------|--------|------------|
+| Broker introduced before idempotency | Duplicate or partial side effects become harder to debug | Implement durable work items and operation ids first |
+| Full scans remain in no-op paths | Large libraries spend most time proving nothing changed | Store item state and use paged work queues |
+| External enrichment blocks storage | Obscure valid releases remain stuck | Split structural validity from enrichment |
+| Per-song hashing during DB insert | Insert throughput limited by media reads | Compute hashes during normalization and store them |
+| Full aggregate rebuild after insert | Insert jobs slow down as library grows | Use incremental counters and repair jobs |
+| Global cache clear after maintenance | Large deployments see repeated cold-cache latency | Invalidate by region/entity |
+| Staging grows flat and unbounded | Filesystem operations degrade | Shard staging and use item states |
+| CLI and scheduler race | Duplicate processing or file conflicts | Use DB leases for library and item work |
+| Provenance imports secrets | Sensitive source data leaks into logs or manifests | Redact by default and retain only safe fields |
+
+## Non-Goals
+
+The following should not be attempted in the first pass:
+
+- replacing the entire ingestion pipeline at once
+- making RabbitMQ mandatory before local ingestion is durable
+- turning `melodee.json` into the only operational state store
+- removing manual review workflows
+- requiring MusicBrainz or Spotify matches for all stored releases
+- building a perfect filesystem watcher
+
+## Recommended Decision
+
+Adopt the following direction:
+
+1. **Approve a durable ingestion state model.**
+2. **Treat PostgreSQL as the source of truth for release work.**
+3. **Keep `melodee.json` as the portable release manifest, not the workflow
+ database.**
+4. **Split structural validation from enrichment.**
+5. **Plan RabbitMQ now, but phase it in after idempotency.**
+6. **Use Wolverine + RabbitMQ + durable inbox/outbox when the worker boundary is
+ introduced.**
+
+This path preserves the current mental model while changing the execution model
+to one that can handle very large collections.
diff --git a/design/DECENTDB_IMPROVEMENTS.md b/design/DECENTDB_IMPROVEMENTS.md
index 644427f1a..4a206f0e5 100644
--- a/design/DECENTDB_IMPROVEMENTS.md
+++ b/design/DECENTDB_IMPROVEMENTS.md
@@ -1,1009 +1,782 @@
-# DecentDB EntityFramework Improvements for Melodee
+## DecentDB EntityFramework Improvements for Melodee
**Date**: 2026-03-21
-**Status**: Targeted Melodee and DecentDB fixes delivered; synthetic validation complete;
-final clean real-file metrics still outstanding
-
-## Summary
-
-Melodee is using `DecentDB.EntityFrameworkCore` for two very different workloads:
-
-- `MusicBrainzConnection` backs the large, read-mostly MusicBrainz materialized database.
-- `ArtistSearchEngineConnection` backs the much smaller local artist search cache used by
- Melodee's search-engine enrichment workflow.
-
-Those two workloads should not be treated as if they have the same performance envelope.
-
-The large MusicBrainz `.ddb` is viable for Melodee today, but only if Melodee stays on
-index-friendly query shapes and avoids scan-heavy existence or substring patterns on the
-request path. The evidence from the real 2.31 GB file is very clear:
-
-- opening the database is fast
-- `CanConnectAsync()` is fast
-- first-row projection is fast
-- exact normalized-name lookup is fast
-- `AnyAsync()` and `CountAsync()` existence probes are pathologically slow
-- substring searches over `AlternateNames` and tokenized `Contains(...)` fallbacks are slow
-- exact `MusicBrainzIdRaw` lookup is unexpectedly slow and needs explicit investigation
-
-For the local artist search cache database, the current Melodee usage is acceptable today
-because that database is expected to stay much smaller, and the code has multiple caching
-layers in front of it. Even so, a few query/index choices will become pain points if that
-local cache is allowed to grow substantially.
-
-The short version is:
-
-- Melodee's doctor path is now using the correct large-file pattern:
- `CanConnectAsync()` plus projected first-row fetches instead of `AnyAsync()` or `CountAsync()`.
-- Melodee now materializes exact-match MusicBrainz aliases into a dedicated normalized lookup
- structure instead of relying on substring scans over the `AlternateNames` blob.
-- Melodee removed the tokenized `Contains(...)` fallback from the default large-file request path
- and prefers the fast exact-name path before touching the slower exact-MBID path when both values
- are supplied.
-- The first real rebuild run proved the large MusicBrainz `.ddb` file was not the startup problem.
- The real problem was Melodee's own import materialization strategy.
-- The first import fix addressed memory pressure by keeping album writes batch-bounded and by
- collapsing duplicate valid `ReleaseCountryStaging` rows so one logical release does not fan out
- into duplicate albums.
-- Phase-level profiling then showed that the next bottleneck was not album inserts at all. It was
- the album materialization query shape itself.
-- Melodee now precomputes helper staging tables for resolved release-country rows and primary
- artist-credit rows so the hot album query joins simple keyed tables instead of repeatedly paying
- for the old aggregate/derived-table shape.
-- Melodee now writes large staging/materialization batches through prepared ADO commands where that
- shape is materially better than EF `AddRange(...)` plus `SaveChanges()`.
-- The DecentDB .NET bindings were also corrected at the source:
- - local-source builds now load the correct native library instead of stale packaged native
- assets
- - EF `DateTime` / `DateTimeOffset` mapping is back on true DecentDB `TIMESTAMP`
- - `DecentDBDataReader.GetDateTime()` now decodes DecentDB timestamp microseconds correctly
- - UUID-backed values can be read as canonical strings more safely
- - repeated single-statement `ExecuteNonQuery()` calls now reuse prepared statements
-- The current synthetic importer validation is green again:
- `StreamingMusicBrainzImporterTests` passed `11/11` in `26s`.
-- The main outstanding work is no longer "find the bottleneck." The remaining work is to finish a
- clean monitored real-file rerun and capture the final wall-clock, RSS, file-growth, and
- post-import probe numbers.
-- `MusicBrainzIdRaw` exact lookup still deserves explicit validation on the rebuilt database before
- it is trusted as a first-class fast path.
-- The local artist cache is in a better place than MusicBrainz, but its wide OR search and admin
- listing patterns are still the most obvious future scale concerns.
-
-## Completed implementation and validation in this session
-
-### Melodee-side changes delivered
-
-The following Melodee changes are no longer theoretical recommendations. They were implemented and
-validated during this session:
-
-1. **Doctor/request-path fixes**
- - `DoctorService` now uses `Database.CanConnectAsync()` and projected first-row fetches instead
- of expensive large-table existence patterns.
- - Regression tests were added so the large-file doctor path does not silently regress back to
- `AnyAsync()`/`CountAsync()` behavior.
-
-2. **MusicBrainz lookup hardening**
- - Exact alias lookup moved onto a dedicated normalized alias structure rather than a substring
- scan over `AlternateNames`.
- - The tokenized `Contains(...)` fallback was removed from the normal large-file request path.
- - The repository now prefers exact-name success before paying the suspicious MBID path when both
- values are provided.
-
-3. **MusicBrainz import pipeline fixes**
- - Album materialization was changed from a giant in-memory accumulation model to a batch-bounded
- pipeline.
- - Duplicate valid release-country rows are collapsed before album materialization so one logical
- release does not create duplicate albums.
- - Artist alternate-name materialization was moved off the per-row update loop and into bulk SQL
- aggregation.
- - Album insert persistence moved onto raw prepared ADO inserts with lightweight row buffering
- rather than EF tracking-heavy bulk adds.
- - The album materialization query now uses precomputed helper tables:
- - `ReleaseCountryResolvedStaging`
- - `ArtistCreditPrimaryArtistStaging`
-
-4. **Local cache maintenance**
- - Melodee now ensures the local artist-cache housekeeping index even on already-created DecentDB
- files, rather than assuming a fresh database creation path is the only place the index can be
- established.
-
-### DecentDB provider / binding changes delivered
-
-The following DecentDB .NET changes were implemented in the DecentDB repository and then wired into
-Melodee through local project references:
-
-1. **Native asset selection for local/source builds**
- - The .NET build/runtime asset flow was corrected so local-source validation does not quietly
- load stale packaged native binaries.
-
-2. **TIMESTAMP correctness**
- - EF mapping was restored to DecentDB `TIMESTAMP` instead of the temporary integer workaround.
- - `GetDateTime()` decoding was corrected to use microseconds since Unix epoch UTC, matching the
- DecentDB engine's native representation.
-
-3. **Query/execution improvements**
- - `Any()` / existence translation was optimized in the provider.
- - repeated single-statement `ExecuteNonQuery()` now benefits from prepared statement reuse
- - `Prepare()` now actually primes that reusable prepared statement path instead of being a no-op
-
-4. **Data-reader compatibility**
- - UUID-backed values read as strings now come back as canonical GUID strings, which is safer for
- mixed-schema or legacy-read scenarios.
-
-### Validation completed
-
-The fixes above were not left at the "looks right" stage. Validation completed includes:
-
-- full DecentDB .NET solution tests passing after the provider changes (`526/526`)
-- targeted ADO validation passing for the prepared statement reuse work (`43/43`)
-- direct ADO coverage added for prepared statement reuse
-- EF type-mapping and timestamp regression coverage added/updated
-- Melodee importer regression coverage for duplicate release-country rows
-- latest `StreamingMusicBrainzImporterTests` run: `11/11` passing in `26s`
-
-### Outstanding work
-
-The remaining work is much narrower now:
-
-1. **Finish the clean monitored real-file rerun**
- - the rerun was started from a compiled `Release` probe harness and then intentionally stopped
- at user request to wrap the session
- - final full-run metrics are still needed
-
-2. **Capture final full-file metrics**
- - total wall time
- - peak RSS / `VmHWM`
- - final `.ddb` and `.wal` sizes
- - post-import probe timings
-
-3. **Validate exact `MusicBrainzIdRaw` lookup on the rebuilt database**
- - this remains the most suspicious exact-match path based on the earlier probe
-
-4. **Separate environment issues**
- - the true CLI job path is still blocked in this shell by PostgreSQL-backed configuration access
- - the `Melodee.Tests.Blazor` local-reference test-host crash remains a separate issue from the
- DecentDB work itself
-
-## Scope of this analysis
-
-This analysis focused on the Melodee surfaces that currently use `DecentDB.EntityFrameworkCore`
-and that are relevant to real-world user workflows:
-
-- `src/Melodee.Blazor/Program.cs`
-- `src/Melodee.Cli/Command/CommandBase.cs`
-- `src/Melodee.Blazor/Services/DoctorService.cs`
-- `src/Melodee.Cli/Command/DoctorCommand.cs`
-- `src/Melodee.Common/Plugins/SearchEngine/MusicBrainz/Data/MusicBrainzDbContext.cs`
-- `src/Melodee.Common/Plugins/SearchEngine/MusicBrainz/Data/DecentDBMusicBrainzRepository.cs`
-- `src/Melodee.Common/Plugins/SearchEngine/MusicBrainz/MusicBrainzArtistSearchEnginePlugin.cs`
-- `src/Melodee.Common/Plugins/SearchEngine/MusicBrainz/MusicBrainzCoverArtArchiveSearchEngine.cs`
-- `src/Melodee.Common/Services/SearchEngines/ArtistSearchEngineService.cs`
-- `src/Melodee.Common/Models/SearchEngines/ArtistSearchEngineServiceData/*.cs`
-- `src/Melodee.Common/Jobs/ArtistSearchEngineRepositoryHousekeepingJob.cs`
-- `src/Melodee.Common/Jobs/MusicBrainzUpdateDatabaseJob.cs`
-
-It also used a temporary probe against the real MusicBrainz DecentDB file:
-
-- `/mnt/incoming/melodee_test/search-engine-storage/musicbrainz/musicbrainz.ddb`
-
-## Where Melodee uses DecentDB.EntityFramework today
-
-### DecentDB database registrations
-
-Melodee registers two DecentDB-backed EF Core contexts in both the Blazor app and the CLI:
-
-- `ArtistSearchEngineServiceDbContext` via `ArtistSearchEngineConnection`
-- `MusicBrainzDbContext` via `MusicBrainzConnection`
-
-That wiring exists in:
-
-- `src/Melodee.Blazor/Program.cs`
-- `src/Melodee.Cli/Command/CommandBase.cs`
-
-This is the correct high-level split. The important point is that these are not just two
-copies of the same use case:
-
-- the MusicBrainz database is a large, read-mostly lookup store
-- the artist search cache database is a smaller, mutable working set
-
-The provider and Melodee code should optimize differently for each one.
-
-## Workload 1: MusicBrainz lookup database
-
-The MusicBrainz DecentDB file is used by the following Melodee paths:
-
-- Blazor doctor checks
-- CLI doctor checks
-- artist lookup through `MusicBrainzArtistSearchEnginePlugin`
-- cover-art lookup through `MusicBrainzCoverArtArchiveSearchEngine`
-- MusicBrainz update/import job
-- optional global search when `SearchInclude.MusicBrainz` is enabled
-
-The main repository is `DecentDBMusicBrainzRepository`.
-
-Its search behavior matters the most, because that is where user-facing latency appears.
-
-### Current MusicBrainz query strategy
-
-`DecentDBMusicBrainzRepository.SearchArtist(...)` does a bounded search and caps the
-working result size to `10` items internally. That is a good decision for a large
-file-backed store.
-
-The search path is currently centered on exact-match phases:
-
-1. exact `NameNormalized` match
-2. exact reversed-name match
-3. exact alias lookup through the normalized alias structure
-4. exact `MusicBrainzIdRaw` match when an MBID is provided and the earlier exact phases did not
- already return results
-5. album fan-out query by the found `MusicBrainzArtistId` values
-
-This is a much better mix of shapes for a large store than what Melodee started with:
-
-- exact normalized-name match is good
-- exact reversed-name match is good
-- exact alias lookup is the correct structure for alternate names on a large file
-- exact `MusicBrainzIdRaw` is still suspicious enough to deserve targeted validation
-- album fan-out by `MusicBrainzArtistId` is good as long as the artist set is small
-- the old substring `AlternateNames.Contains(...)` path was not kept on the default large-file
- request path
-- the old tokenized `Contains(...)` fallback was intentionally removed from that path
-
-## Workload 2: local artist search cache / "media artists"
-
-The local search-engine cache is backed by `ArtistSearchEngineServiceDbContext`.
-
-This database stores artist and album data discovered from external providers and is used by:
-
-- `ArtistSearchEngineService`
-- the housekeeping refresh job
-- CRUD/list operations for cached artist records
-- doctor connectivity checks
-
-This database is not the same scale as the MusicBrainz materialized database. That matters.
-
-Melodee also has multiple protections in front of it:
-
-- `ArtistSearchCache` caches positive and negative lookup results
-- `DirectoryRunContext.ArtistSearchCache` adds per-run single-flight caching
-- the search engine plugin order checks local Melodee data before MusicBrainz
-
-That means the local cache database is already acting as a pressure-relief valve that keeps
-many requests away from the large MusicBrainz file.
-
-This is good architecture and should be preserved.
-
-## Probe results against the real 2.31 GB MusicBrainz file
-
-I ran a focused probe against the real database file and measured both generic EF access and
-query shapes that closely resemble Melodee's actual MusicBrainz repository logic.
-
-### Historical real-file timings that drove the request-path fixes
-
-| Operation | Cold | Warm | Assessment |
+**Last updated**: 2026-06-15
+**Status**: Complete; DecentDB `2.13.1` package validation resolved DDB-002 and DDB-003, and Melodee follow-up hardening is implemented
+
+## Purpose
+
+This document is now an active implementation backlog for the remaining
+DecentDB and Melodee scale work. Historical fixes that have already landed were
+removed so coding agents can treat the table below as the source of truth for
+remaining validation and provider follow-up. The current package baseline is
+`DecentDB.AdoNet` `2.13.1`, `DecentDB.EntityFrameworkCore` `2.13.1`, and
+`DecentDB.EntityFrameworkCore.NodaTime` `2.13.1`. The real-file measurements
+below remain labeled with the DecentDB version or local worktree that produced
+them.
+
+Melodee uses `DecentDB.EntityFrameworkCore` for two different workloads:
+
+- `MusicBrainzConnection` backs the large, read-mostly MusicBrainz materialized
+ database.
+- `ArtistSearchEngineConnection` backs the smaller local artist search cache
+ used by the search-engine enrichment workflow.
+
+The large MusicBrainz database can support Melodee when query shapes stay
+index-friendly, bounded, and explicit. The remaining work is focused on
+validation, regression probes, and the smaller number of query shapes that can
+still become expensive at very large scale.
+
+## Status Values
+
+| Status | Meaning |
+| --- | --- |
+| TODO | Implementation or documentation work is still required. |
+| TODO/VERIFY | The code path may exist, but real-data proof is still missing. |
+| TODO/CONDITIONAL | Implement after the dependency row proves the work is needed, or while already touching the same area. |
+| PARTIAL | Some implementation or instrumentation exists, but the acceptance target is not satisfied. |
+| DONE | Melodee implementation and validation work is complete. |
+| UPSTREAM-FIXED | DecentDB upstream has code and regression coverage, but Melodee has not yet validated an available package against the required real-file scenario. |
+| PROVIDER-FOLLOWUP | Melodee has a repro or mitigation; remaining work belongs in DecentDB provider or tooling. |
+
+## Active Work Table
+
+| ID | Status | Priority | Owner | Area | Acceptance target |
+| --- | --- | --- | --- | --- | --- |
+| DDB-001 | DONE | High | Melodee | Real-file MusicBrainz rebuild | Clean monitored rebuild completed against DecentDB `2.8.0`; final wall-clock, memory, `.ddb`, `.wal`, and post-import probe timings are recorded below. |
+| DDB-002 | DONE | High | Melodee / DecentDB | Exact `MusicBrainzIdRaw` lookup | DecentDB `2.13.1` NuGet package validation reproduced the checkpointed real-file MBID fix: cold measured query `11.04 ms`, warm `0.56 ms`, both with `IndexSeek`. |
+| DDB-003 | DONE | High | DecentDB provider | Indexed string equality | DecentDB `2.13.1` NuGet package validation reproduced checkpointed real-file indexed string equality: cold `Artist.NameNormalized` first-use hydration `9,996 ms`, warm `0.66 ms`; alias cold `780 ms`, warm `0.80 ms`; all with `IndexSeek`. |
+| DDB-004 | DONE | Medium | Melodee | Local artist cache search | Local cache search now uses staged exact provider-ID, normalized-name, and normalized-alias phases; normal lookup no longer uses mixed `OR` or substring alias matching. |
+| DDB-005 | DONE | Medium | Melodee | Local cache listing | Normal page requests now page in the database and avoid mandatory full count and full materialization; count-only requests still return exact counts. |
+| DDB-006 | DONE | Low | Melodee | Import completion counts | Streaming import now returns materialization counts; normal completion logging no longer performs final full-table `CountAsync()` probes. |
+| DDB-007 | DONE | Medium | Melodee | MusicBrainz query perf probes | `musicbrainz-query-probe` emits JSON cold/warm timings, SQL shape, DecentDB `EXPLAIN` output, sample values, row counts, and index metadata. |
+| DDB-008 | DONE | Medium | Melodee | MusicBrainz import perf probes | `musicbrainz-import-probe` emits JSON phase timings, memory samples, database/WAL growth, CPU time, and importer-reported row counts. |
+| DDB-009 | DONE | Medium | DecentDB provider / Melodee docs | Large-text search strategy | Melodee strategy is documented in `design/docs/decentdb-large-text-search-strategy.md`; provider candidates are listed separately. |
+| DDB-010 | DONE | Medium | DecentDB provider / Melodee | Large-file diagnostics | Manual probes now expose practical app-level SQL, timing, row-count, index, memory, and file-growth diagnostics. |
+| DDB-011 | DONE | Medium | Melodee | MusicBrainz DecentDB warm-up | `MusicBrainzDecentDbWarmupService` warms request-path indexes after Blazor startup and after a successful MusicBrainz database promotion. |
+| DDB-012 | DONE | Medium | Melodee | Avoid broad row-existence probes | Normal query validation excludes the broad `ordered-first-row-existence` measurement by default; it is now available only through `--include-row-existence`. |
+| DDB-013 | DONE | Medium | Melodee | Package-upgrade validation gate | `scripts/run-decentdb-package-upgrade-gate.sh` and `design/docs/decentdb-package-upgrade-validation-runbook.md` provide a repeatable DDB-002/DDB-003 validation path. |
+| DDB-014 | DONE | Low | Melodee | Checkpoint and WAL operating guidance | MusicBrainz imports checkpoint with native .NET bindings before promotion, and the package-upgrade runbook documents checkpoint/WAL acceptance notes. |
+| DDB-015 | DONE | Low | Melodee docs | Future fuzzy/full-text guidance | Exact-match and lookup-table request paths remain the accepted strategy; fuzzy/full-text search is documented as explicit future non-hot-path work. |
+
+## DecentDB 2.13.0 Binding Update
+
+Melodee consumed the DecentDB `2.13.0` .NET packages during the historical
+validation run. The MusicBrainz import checkpoint path uses
+`DecentDBMaintenance.CheckpointAsync(...)`, so Melodee no longer opens the
+database manually for this maintenance step and does not invoke an external
+DecentDB process anywhere in the repository.
+
+The current binding baseline covers the Melodee-requested .NET maintenance,
+diagnostic, and planner surface:
+
+- file-path maintenance helpers for WAL status, checkpoint, compact, vacuum,
+ and index rebuild
+- checkpoint result snapshots with before/after WAL sizes
+- ADO.NET query-plan diagnostics for provider-level investigation
+- clearer ADO.NET open failure diagnostics for unsupported database formats
+- provider regression coverage for indexed string equality translation
+- native ordered indexed equality projection support for provider-generated
+ `ORDER BY`, `LIMIT`, and `OFFSET` query shapes
+
+## DecentDB Local Worktree Validation
+
+Melodee was temporarily switched to direct local DecentDB binding binaries from
+`/home/steven/src/github/decentdb/bindings/dotnet` while the DecentDB engine was
+fixed in-place. The local native library hash used by Melodee was:
+
+```text
+3d91afb34ed0d13d82095b61c8b535072f66770d96c69fc91c9b4f6d571ce9db
+```
+
+The direct ADO.NET probe against
+`.tmp/decentdb-2.13-validation/musicbrainz.ddb` proved the rejected
+open-time-hydration behavior was gone:
+
+| Direct probe step | Time |
+| --- | ---: |
+| `DecentDBConnection.Open()` | `1.83 ms` |
+| First `Artist.NameNormalized` indexed equality | `9.19 s` |
+| Same `Artist.NameNormalized` prepared parameterized equality | `3.87 ms` |
+| First `Artist.MusicBrainzIdRaw` indexed equality | `8.65 s` |
+| Same `Artist.MusicBrainzIdRaw` prepared parameterized equality | `0.32 ms` |
+
+The committed Melodee `musicbrainz-query-probe` then completed against the same
+checkpointed real MusicBrainz file and wrote:
+`.tmp/decentdb-local-validation/musicbrainz-query-probe-targeted.json`.
+
+| Query | Cold | Warm | Plan |
| --- | ---: | ---: | --- |
-| `DbContextOptions` build | ~22 ms | n/a | Fine |
-| `new MusicBrainzDbContext(...)` | ~1 ms | ~0 ms | Fine |
-| `context.Model` access | ~12 ms | ~0.4 ms | Fine |
-| `Database.CanConnectAsync()` | ~13 ms | ~3 ms | Fine |
-| `Artists.AnyAsync()` | ~13.3 s | ~13.0 s | Bad |
-| `Artists.Take(1).CountAsync()` | ~13.3 s | ~13.0 s | Bad |
-| `Artists.Select(x => x.Id).FirstOrDefaultAsync()` | ~15 ms | ~7 ms | Good |
-| MusicBrainz exact normalized-name flow | ~40 ms | ~13 ms | Good |
-| MusicBrainz alternate-name substring search | ~15.3 s | ~15.1 s | Historical red flag |
-| MusicBrainz tokenized `Contains(...)` fallback | ~22.3 s | ~22.4 s | Historical red flag |
-| MusicBrainz exact-MBID flow | ~34.6 s | ~34.2 s | Red flag |
-
-### What these timings mean
-
-These numbers show that the large `.ddb` file is not inherently too slow for Melodee.
-
-The store can absolutely support request-path usage when the query shape is compatible with:
-
-- existing indexes
-- first-row short-circuiting
-- small bounded result sets
-
-The store becomes a problem when the query shape implies:
-
-- full or near-full scans
-- non-short-circuiting existence checks
-- substring searches on large string columns
-- query plans that do not make effective use of equality indexes
-
-That distinction is the core lesson for Melodee and for the DecentDB EF provider work.
-
-These measurements also matter historically even though some of those paths are no longer the
-current design. They explain why the request path was changed:
-
-- the alternate-name substring path was replaced structurally rather than micro-optimized
-- the tokenized fallback was removed from the normal large-file path
-- the exact MBID path was left under explicit suspicion until rebuilt-db validation can confirm it
-
-## MusicBrainz use cases: what is safe and what is not
-
-### 1. Dashboard doctor and health checks
-
-### Current verdict
-
-**Healthy now, after the Melodee-side fix.**
-
-### Why
-
-`DoctorService` now checks MusicBrainz health with:
-
-- `Database.CanConnectAsync()`
-- `Select(x => x.Id).FirstOrDefaultAsync()`
-
-That is exactly the right pattern for a large file-backed DecentDB table.
-
-This should remain the rule for all request-path existence checks against large DecentDB
-tables:
-
-- do not use `AnyAsync()` for "does at least one row exist?"
-- do not use `CountAsync()` for "does at least one row exist?"
-- do use a projected first-row fetch such as `Select(Id).FirstOrDefaultAsync()`
-
-Even if the provider continues to improve, Melodee should keep this pattern because it is
-the most explicit and least surprising shape for large stores.
-
-### 2. Exact normalized-name artist lookup
-
-### Current verdict
-
-**Good.**
-
-### Why
-
-The `Artist` materialized model has an index on `NameNormalized`, and the probe showed the
-exact normalized-name flow completing in tens of milliseconds, even against the 2.31 GB file.
-
-That means Melodee's first and most important MusicBrainz search phase is already aligned
-with the large-file reality.
-
-The reversed-name phase reuses the same exact-match pattern, so it should have the same
-general performance characteristics.
-
-### 3. Album fan-out after artist match
-
-### Current verdict
-
-**Good, if kept bounded.**
-
-### Why
-
-After finding a small artist set, Melodee loads albums by:
-
-- collecting `MusicBrainzArtistId` values
-- issuing a bounded `Contains(...)` query on `Albums`
-- relying on the `MusicBrainzArtistId` index
-
-This is fine because:
-
-- the repository intentionally limits the search result set
-- the album lookup is downstream of a selective artist match
-- the `Album` materialized entity has an index on `MusicBrainzArtistId`
-
-This is a good pattern for DecentDB: narrow first, then fan out.
-
-### 4. Alternate-name lookup
-
-### Current verdict
-
-**Now on the correct structural path, but rebuilt databases are required to benefit fully and the
-final post-rebuild real-file timing is still outstanding.**
-
-### Why
-
-The old query shape was:
-
-- `AlternateNames != null && AlternateNames.Contains(query.NameNormalized)`
-
-On the large file, that substring scan took about fifteen seconds. That was not acceptable for
-interactive lookup.
-
-Melodee no longer has to rely on that design for the normal large-file request path.
-
-Instead, Melodee now materializes normalized aliases into a dedicated lookup structure and resolves
-exact alias matches there. That is the right large-file shape because it turns alternate-name lookup
-back into an equality lookup rather than a blob substring scan.
-
-### Root cause
-
-The original root cause was structural:
-
-- `AlternateNames` was stored as a single string blob
-- the lookup was a substring scan over that blob
-- there was no realistic indexing strategy that made that shape attractive on a huge file
-
-The delivered fix was also structural:
-
-- materialize one normalized alias per lookup row
-- index the alias lookup column
-- keep the request path on exact-match semantics
+| `Artist.NameNormalized == value` | `9,120 ms` | `0.65 ms` | `IndexSeek(table=Artist, index=IX_Artist_NameNormalized)` |
+| `ArtistAlias.NameNormalized == value` | `694 ms` | `0.75 ms` | `IndexSeek(table=ArtistAlias, index=IX_ArtistAlias_NameNormalized)` |
+| `Artist.MusicBrainzIdRaw == value` | `3.10 ms` | `0.55 ms` | `IndexSeek(table=Artist, index=IX_Artist_MusicBrainzIdRaw)` |
-### Recommendation
+This validated the DecentDB worktree fix for DDB-002 and DDB-003 before package
+publication. The DecentDB `2.13.1` package validation below reproduced these
+results without local binary references and completed both items.
-Keep substring scanning over `AlternateNames` off the normal request path for the large
-MusicBrainz database.
+## DecentDB 2.13.1 NuGet Package Validation
-Preferred options:
+Melodee was switched back from direct local DecentDB binding references to the
+published `2.13.1` NuGet packages on 2026-06-15. The Release solution was
+cleaned, restored, and rebuilt against package references, and
+`project.assets.json` resolved:
-1. keep the new normalized alias lookup structure as the canonical online fallback
-2. rebuild existing databases so the alias lookup structure is actually populated
-3. if fuzzy alias search is needed later, give it a separate opt-in structure and cost model
-4. do not fall back to blob substring scans silently on the large-file request path
+- `DecentDB.AdoNet` `2.13.1`
+- `DecentDB.EntityFrameworkCore` `2.13.1`
+- `DecentDB.EntityFrameworkCore.NodaTime` `2.13.1`
-### 5. Tokenized `Contains(...)` fallback
+The same checkpointed real MusicBrainz file was validated with:
+`.tmp/decentdb-2.13.1-validation/musicbrainz-query-probe-targeted.json`.
-### Current verdict
-
-**Intentionally removed from the default large-file request path.**
-
-### Why
-
-The old tokenized fallback issued substring searches like:
-
-- `NameNormalized.Contains(word)`
-- `AlternateNames.Contains(word)`
-
-The probe showed roughly twenty-two seconds for this shape on the large file. That was worse than
-the already-bad alternate-name substring path and clearly outside acceptable interactive latency.
-
-This is exactly the kind of search phase that looks user-friendly in code review but quietly
-guarantees scan-heavy behavior on a very large file-backed store.
-
-### Recommendation
-
-Keep the tokenized `Contains(...)` phase out of the normal request path for the large MusicBrainz
-DecentDB file.
-
-If Melodee wants fuzzy fallback behavior here, it should come from:
-
-- a separate alias/token table
-- a precomputed search index
-- an explicit "slow fallback" mode that is opt-in and off the main UI path
-
-### 6. Exact MusicBrainz ID lookup
-
-### Current verdict
-
-**Unexpectedly slow; treat as a red-flag concern until explicitly verified.**
-
-### Why
-
-This path should have been one of the best-performing paths because:
-
-- it is an equality lookup
-- `MusicBrainzIdRaw` is indexed
-- the result set is tiny
-
-Instead, the probe showed the exact-MBID flow taking roughly thirty-four seconds.
-
-That is not a normal result and should be investigated as a provider/schema/data issue rather
-than accepted as normal workload behavior.
-
-### Additional concern
-
-While probing, a sampled `MusicBrainzIdRaw` value did not round-trip as a printable GUID string
-in console output. I have not yet proven whether the cause is:
-
-- provider string decoding
-- imported data representation
-- console rendering of returned bytes
-
-However, when that observation is combined with the thirty-four-second exact-MBID lookup time,
-`MusicBrainzIdRaw` becomes a high-priority verification area.
-
-### Recommendation
-
-Before Melodee leans harder on MBID-exact lookup, validate all of the following:
-
-1. the stored DecentDB column type for `MusicBrainzIdRaw`
-2. the data produced by the importer for that column
-3. provider string round-tripping for that column
-4. index usage/effectiveness for equality on that column
-5. whether the current `.ddb` file was created before an importer/provider fix and needs
- regeneration
-
-Until that is resolved, exact-name lookup is more trustworthy than exact-MBID lookup in the
-observed file.
-
-### 7. Cover-art lookup through MusicBrainz
-
-### Current verdict
-
-**Mostly acceptable, but it inherits the strengths and weaknesses of `SearchArtist(...)`.**
-
-### Why
-
-`MusicBrainzCoverArtArchiveSearchEngine` uses `SearchArtist(...)` with artist and album context
-and then extracts the release-group ID for Cover Art Archive.
-
-That means:
-
-- if the query resolves through exact normalized-name match, it is fine
-- if it resolves through the new exact alias lookup path, it should also be fine once the rebuilt
- database has populated that structure
-- the old tokenized `Contains(...)` fallback is no longer supposed to be part of the default path
-- if it relies on the exact-MBID path that is currently suspicious, it can also become slow
-
-### Recommendation
-
-Keep cover-art lookup dependent on the indexed search phases as much as possible. Do not reintroduce
-slow fallback search behavior here silently.
-
-## MusicBrainz import/update path
-
-### Current verdict
-
-**Viable as an offline workflow after multiple Melodee-side and provider-side fixes. Synthetic
-validation is green again, but final full-file completion metrics are still outstanding.**
-
-### What is already good
-
-The importer is not trying to use high-level EF for every row-level operation. It uses:
-
-- staging tables
-- batched streaming
-- raw SQL for materialization
-- direct command execution for alias updates
-- helper-table precomputation for the hot album join path
-- prepared ADO inserts for the write-heavy materialization phases
-
-That is the right general shape for a large import pipeline.
-
-### Real rebuild findings
-
-I ran the import path against the real staged MusicBrainz dump already present under:
-
-- `/mnt/incoming/melodee_test/search-engine-storage/musicbrainz/staging`
-
-That staging footprint was already substantial:
-
-- `mbdump.tar.bz2`: ~6.43 GB
-- `mbdump-derived.tar.bz2`: ~455.62 MB
-- extracted `mbdump/`: ~23.94 GB
-- total staging footprint: ~30.81 GB
-
-The first live rebuild attempt did **not** fail because DecentDB could not open or write the
-large `.ddb` file. Instead, it exposed a Melodee importer algorithm problem during album
-materialization.
-
-Observed timeline from the first live run:
-
-- `11:49:05` - streaming artist file began
-- `11:57:02` - streamed `808,816` artist links to staging
-- `12:08:28` - materialized `2,792,908` artists
-- `12:09:04` - materialized `7` artist relations
-- `12:12:25` - artist staging cleared and album-side staging began
-- `13:04:51` - streamed `5,265,580` releases and started album materialization
-
-At that point the process became the bottleneck:
-
-- RSS climbed to roughly `20 GB`
-- the `.ddb` size plateaued at about `7.12 GB`
-- sampled `/proc//io` counters stopped showing meaningful additional file I/O
-- CPU remained heavily utilized
-
-That signature is important. It means the importer was mostly burning CPU and memory inside the
-application process rather than being blocked on DecentDB file open or write throughput.
-
-That first run identified the original failure mode, but it was not the end of the investigation.
-After the first streaming/batching fixes were applied, the importer became functionally correct
-again and memory behavior improved, yet the performance tests were still failing badly. That led to
-phase-level profiling of the importer itself.
-
-Phase probe results from the temporary diagnostic harness showed:
-
-- synthetic dataset: `1000` artists with `5` albums each
-- total import time: about `69.57s`
-- `Loading Artists`: about `0.30s`
-- `Materializing Artists`: about `0.35s`
-- `Loading Albums`: about `2.36s`
-- `Materializing Albums`: about `63.54s`
-
-That result narrowed the remaining bottleneck to the album phase.
-
-The next probe split album materialization into query time versus insert time:
-
-- album query time: about `65.75s`
-- album insert time: about `0.34s`
-
-That was the decisive result. Once album inserts were made cheap, the remaining hotspot was the
-album materialization query shape itself, not the write loop.
-
-### Root cause
-
-The root cause turned out to be layered, not singular.
-
-1. **Initial failure mode**
- - `DecentDBStreamingMusicBrainzImporter.MaterializeAlbumsAsync(...)` was reading the entire
- album join result into one huge `List` before writing that batch to the database.
- - For a real dump containing millions of releases, that creates avoidable allocation pressure,
- delays persistence too long, and magnifies correctness issues such as duplicate valid
- `release_country` rows.
-
-2. **Second-order bottleneck after the first memory fix**
- - Once writes were made bounded and cheap, the hot album query still performed very poorly.
- - The expensive parts were the grouped/derived release-country resolution and the primary-artist
- lookup shape inside the album materialization query.
- - In other words, the importer no longer had just a write-path problem. It also had a
- planner-unfriendly query-shape problem.
-
-3. **Provider-side write overhead that was worth fixing, but was not the main bottleneck**
- - repeated single-statement `ExecuteNonQuery()` calls were reparsing and re-preparing every time
- before the provider change
- - fixing that was the right DecentDB ADO improvement, but the phase probe proved it was not the
- dominant remaining import cost once the album query was isolated
-
-### Fix delivered
-
-The importer and provider now include the following delivered fixes:
-
-1. **Correct raw staging parameter binding**
- - while converting staging writes to raw commands, a regression was found:
- `$p0`, `$p1`, ... placeholders were not recognized by the provider's parameter rewriter
- - the raw staging helper was corrected to use `@p0`, `@p1`, ... placeholders instead
-
-2. **Prepared raw staging and album inserts**
- - raw staging commands now call `Prepare()`
- - album persistence moved to prepared ADO inserts with lightweight row buffering
-
-3. **Provider prepared statement reuse**
- - `DecentDBCommand.ExecuteNonQuery()` now reuses prepared statements for repeated
- single-statement non-query execution
- - `Prepare()` now primes that path instead of being a no-op
-
-4. **Artist alternate-name materialization**
- - the importer no longer performs a per-artist C# update loop to build alternate names
- - bulk SQL aggregation is used instead
-
-5. **Album materialization batching**
- - keyset pagination on `ReleaseStaging.Id`
- - bounded `LIMIT`-based reads per batch
- - release-country selection collapsed to the first valid row per release before album creation
- - progress messages emitted after each persisted batch
-
-6. **Album query simplification**
- - precompute `ReleaseCountryResolvedStaging`
- - precompute `ArtistCreditPrimaryArtistStaging`
- - join those keyed helper tables from the album materialization query instead of repeatedly
- resolving those relationships in the hot query itself
-
-This does two things:
-
-1. it keeps memory bounded during the album phase
-2. it removes a substantial amount of unnecessary work from the dominant album query path
-
-Focused validation was added or rerun to lock the important behavior in place:
-
-- `ImportAsync_WithDuplicateReleaseCountries_MaterializesSingleAlbumPerRelease`
-- direct DecentDB ADO tests for prepared statement reuse
-- importer suite validation, with the latest `StreamingMusicBrainzImporterTests` run passing
- `11/11` in `26s`
-
-### Latest clean monitored rerun status
+| Query | Cold | Warm | Plan |
+| --- | ---: | ---: | --- |
+| `Artist.NameNormalized == value` | `9,996 ms` | `0.66 ms` | `IndexSeek(table=Artist, index=IX_Artist_NameNormalized)` |
+| `ArtistAlias.NameNormalized == value` | `780 ms` | `0.80 ms` | `IndexSeek(table=ArtistAlias, index=IX_ArtistAlias_NameNormalized)` |
+| `Artist.MusicBrainzIdRaw == value` | `11.04 ms` | `0.56 ms` | `IndexSeek(table=Artist, index=IX_Artist_MusicBrainzIdRaw)` |
+
+This completes DDB-002 and DDB-003. The first cold query for a deferred B-tree
+index still pays bounded first-use hydration cost, but warm and repeated
+short-lived connection paths are request-safe and use the expected indexes.
+
+## Melodee Follow-Up Hardening
+
+After DecentDB `2.13.1` resolved the remaining provider behavior, Melodee
+completed the application-side follow-up items:
+
+- Blazor startup now runs an opportunistic background
+ `MusicBrainzDecentDbWarmupService` pass against the configured MusicBrainz
+ database.
+- `MusicBrainzUpdateDatabaseJob` runs the same warm-up against the newly
+ promoted checkpointed database before the search engine is re-enabled.
+- The warm-up targets the same bounded indexed shapes used by request paths:
+ exact normalized artist name, exact raw MusicBrainz ID, exact alias lookup by
+ name, and albums by artist ID. The sample-selection step also reads one alias
+ for the selected artist.
+- The broad first-row existence probe is no longer part of normal
+ `musicbrainz-query-probe` output; use `--include-row-existence` only for
+ explicit investigation.
+- `scripts/run-decentdb-package-upgrade-gate.sh` can run package-upgrade gates
+ with explicit sample values from prior probe output and does not use the
+ DecentDB CLI.
+
+## DecentDB 2.13.0 Real-File Validation
+
+The DecentDB `2.13.0` package was validated on 2026-06-15 with the committed
+Melodee probes against the real MusicBrainz dump. Melodee restored, cleaned,
+and rebuilt against the published packages without code changes beyond the
+central package version update. Validation used a fresh real-file rebuild so
+any DecentDB file or index layout changes could participate.
+
+Validation artifacts:
+
+- Import probe:
+ `.tmp/decentdb-2.13-validation/musicbrainz-import-probe.json`
+- ADO.NET plan capture:
+ `.tmp/decentdb-2.13-validation/musicbrainz-explain-plans.txt`
+- Checkpointed query probes were attempted with explicit sample values, but
+ both targeted runs timed out before a JSON report was written.
+
+Real-file import and maintenance results:
+
+| Metric | Value | Notes |
+| --- | ---: | --- |
+| Import duration | `46.62 min` | `2,797.22 s`; build time excluded. |
+| Imported artists / aliases / relations / albums | `2,887,383` / `492,790` / `834,044` / `3,705,849` | Counts reported by the streaming importer summary. |
+| Peak working set | `20.33 GiB` | From the import probe process `PeakWorkingSetBytes`. |
+| Post-import `.ddb` / `.wal` before checkpoint | `8 KB` / `5.0 GiB` | Import probe leaves the WAL uncheckpointed. |
+| Native checkpoint duration | `635.93 s` | Run through DecentDB `2.13.0` ADO.NET `DecentDBMaintenance.CheckpointAsync(...)`. |
+| Post-checkpoint `.ddb` / `.wal` | `2.4 GiB` / `32 B` | Checkpoint completed without DecentDB CLI usage. |
+
+ADO.NET `EXPLAIN` plan results:
+
+| Query shape | Plan capture | Plan |
+| --- | ---: | --- |
+| Exact normalized-name lookup | `17.28 ms` | `IndexSeek(table=Artist, index=IX_Artist_NameNormalized)`. |
+| Exact alias lookup | `0.27 ms` | `IndexSeek(table=ArtistAlias, index=IX_ArtistAlias_NameNormalized)`. |
+| Exact `MusicBrainzIdRaw` lookup | `0.16 ms` | `IndexSeek(table=Artist, index=IX_Artist_MusicBrainzIdRaw)`. |
+
+Checkpointed execution validation:
+
+| Targeted probe | Result |
+| --- | --- |
+| Explicit `MusicBrainzIdRaw` sample, with name and alias supplied | Timed out after `180 s` before the probe could write a report. |
+| Explicit normalized-name sample, with alias supplied | Timed out after `180 s` before the probe could write a report. |
+
+At the DecentDB `2.13.0` package baseline, DDB-002 and DDB-003 therefore
+remained `PROVIDER-FOLLOWUP`. A later local DecentDB worktree fix is documented
+above, and DecentDB `2.13.1` package validation completed the items. No Melodee
+CLI fallback or ad hoc DecentDB process invocation should be added.
+
+## DecentDB 2.12.0 Real-File Validation
+
+The DecentDB `2.12.0` package was validated on 2026-06-13 with the committed
+Melodee probes against the real MusicBrainz dump. Melodee restored and built
+against the published packages without code changes beyond the central package
+version update. The existing production MusicBrainz database could not be
+opened because it was an older DecentDB file format, so validation used a fresh
+real-file rebuild.
+
+Validation artifacts:
+
+- Import probe:
+ `.tmp/decentdb-2.12-validation/musicbrainz-import-probe.json`
+- WAL-backed query probe:
+ `.tmp/decentdb-2.12-validation/musicbrainz-query-probe-wal-backed.json`
+- Checkpointed query probe:
+ `.tmp/decentdb-2.12-validation/musicbrainz-query-probe-checkpointed.json`
+
+Real-file import and maintenance results:
+
+| Metric | Value | Notes |
+| --- | ---: | --- |
+| Import duration | `48.72 min` | `2,922.97 s`; build time excluded. |
+| Imported artists / aliases / relations / albums | `2,887,383` / `492,790` / `834,044` / `3,705,849` | Counts reported by the streaming importer summary. |
+| Peak working set | `22.70 GiB` | From the import probe process `PeakWorkingSetBytes`. |
+| Post-import `.ddb` / `.wal` before checkpoint | `8 KB` / `5.0 GiB` | Import probe leaves the WAL uncheckpointed. |
+| Native checkpoint duration | `573.60 s` | Run through DecentDB `2.12.0` ADO.NET `DecentDBMaintenance.CheckpointAsync(...)`. |
+| Post-checkpoint `.ddb` / `.wal` | `2.4 GiB` / `32 B` | Checkpoint completed without DecentDB CLI usage. |
+
+Real-file query results:
+
+| Query shape | WAL-backed warm | Checkpointed warm | Plan |
+| --- | ---: | ---: | --- |
+| `Database.CanConnectAsync()` | `52.15 ms` | `52.68 ms` | Not applicable. |
+| Ordered first-row projection | `7,869.50 ms` | `2,918.39 ms` | `OrderedRowIdScan(table=Artist, column=Id)`. |
+| Exact normalized-name lookup | `16,907.93 ms` | `10,293.83 ms` | `IndexSeek(table=Artist, index=IX_Artist_NameNormalized)`. |
+| Exact alias lookup | `7,210.72 ms` | `855.80 ms` | `IndexSeek(table=ArtistAlias, index=IX_ArtistAlias_NameNormalized)`. |
+| Exact `MusicBrainzIdRaw` lookup | `22,463.17 ms` | `9,398.26 ms` | `IndexSeek(table=Artist, index=IX_Artist_MusicBrainzIdRaw)`. |
+
+DDB-002 and DDB-003 therefore remain `PROVIDER-FOLLOWUP`. DecentDB `2.12.0`
+still chooses the expected indexes, but checkpointed real-file indexed
+equality on the large `Artist` table remains far outside the request-safe
+acceptance target. No Melodee CLI fallback or ad hoc DecentDB process
+invocation should be added.
+
+## DecentDB 2.11.0 Real-File Validation
+
+The DecentDB `2.11.0` package was validated on 2026-06-12 with the committed
+Melodee probes against the real MusicBrainz dump. Melodee restored and built
+against the published packages without code changes beyond the central package
+version update. The package still captures `IndexSeek` plans for the tested
+equality shapes, but the runtime acceptance target is not met for the large
+`Artist` table.
+
+Validation artifacts:
+
+- Import probe:
+ `.tmp/decentdb-2.11-validation/musicbrainz-import-probe.json`
+- WAL-backed query probe:
+ `.tmp/decentdb-2.11-validation/musicbrainz-query-probe-wal-backed.json`
+- Checkpointed query probe:
+ `.tmp/decentdb-2.11-validation/musicbrainz-query-probe-checkpointed.json`
+
+Real-file import and maintenance results:
+
+| Metric | Value | Notes |
+| --- | ---: | --- |
+| Import duration | `45.68 min` | `2,740.87 s`; build time excluded. |
+| Imported artists / aliases / relations / albums | `2,887,383` / `492,790` / `834,044` / `3,705,849` | Counts reported by the streaming importer summary. |
+| Peak working set | `22.73 GiB` | From the import probe process `PeakWorkingSetBytes`. |
+| Post-import `.ddb` / `.wal` before checkpoint | `8 KB` / `5.0 GiB` | Import probe leaves the WAL uncheckpointed. |
+| Native checkpoint duration | `560.91 s` | Run through DecentDB `2.11.0` ADO.NET `DecentDBMaintenance.CheckpointAsync(...)`. |
+| Post-checkpoint `.ddb` / `.wal` | `2.4 GiB` / `32 B` | Checkpoint completed without DecentDB CLI usage. |
+
+Real-file query results:
+
+| Query shape | WAL-backed warm | Checkpointed warm | Plan |
+| --- | ---: | ---: | --- |
+| `Database.CanConnectAsync()` | `65.94 ms` | `54.65 ms` | Not applicable. |
+| Ordered first-row projection | `7,842.93 ms` | `3,199.05 ms` | `TableScan(table=Artist)`. |
+| Exact normalized-name lookup | `7,896.98 ms` | `3,373.36 ms` | `IndexSeek(table=Artist, index=IX_Artist_NameNormalized)`. |
+| Exact alias lookup | `5,867.64 ms` | `256.62 ms` | `IndexSeek(table=ArtistAlias, index=IX_ArtistAlias_NameNormalized)`. |
+| Exact `MusicBrainzIdRaw` lookup | `7,758.18 ms` | `3,417.25 ms` | `IndexSeek(table=Artist, index=IX_Artist_MusicBrainzIdRaw)`. |
+
+A scratch ADO.NET probe on the same checkpointed file also measured unordered
+raw SQL below EF Core:
+
+| Raw SQL shape | Warm timing | Plan |
+| --- | ---: | --- |
+| `SELECT "Id" FROM "Artist" WHERE "MusicBrainzIdRaw" = value LIMIT 1` | `3,356.3 ms` | `IndexSeek(table=Artist, index=IX_Artist_MusicBrainzIdRaw)`. |
+| `SELECT * FROM "Artist" WHERE "NameNormalized" = value LIMIT 10` | `3,354.1 ms` | `IndexSeek(table=Artist, index=IX_Artist_NameNormalized)`. |
+| `SELECT * FROM "ArtistAlias" WHERE "NameNormalized" = value LIMIT 10` | `242.8 ms` | `IndexSeek(table=ArtistAlias, index=IX_ArtistAlias_NameNormalized)`. |
+
+DDB-002 and DDB-003 therefore remain `PROVIDER-FOLLOWUP`. The remaining
+DecentDB issue is below Melodee's EF translation and ordering choices: even
+unordered raw ADO.NET indexed equality against the checkpointed large `Artist`
+table still requires multi-second execution. No Melodee CLI fallback or ad hoc
+DecentDB process invocation should be added.
+
+## DecentDB 2.10.0 Real-File Validation
+
+The DecentDB `2.10.0` package was validated on 2026-06-10 with the committed
+Melodee probes against the real MusicBrainz dump. The planner/provider fix is
+present: exact normalized-name, exact alias, and exact `MusicBrainzIdRaw`
+queries all capture DecentDB `EXPLAIN` output with `IndexSeek`. The runtime
+acceptance target is still not met for the large `Artist` table.
+
+Validation artifacts:
+
+- Import probe:
+ `.tmp/decentdb-2.10-validation/musicbrainz-import-probe.json`
+- WAL-backed query probe:
+ `.tmp/decentdb-2.10-validation/musicbrainz-query-probe-wal-backed.json`
+- Checkpointed query probe:
+ `.tmp/decentdb-2.10-validation/musicbrainz-query-probe-checkpointed.json`
+
+Real-file import and maintenance results:
+
+| Metric | Value | Notes |
+| --- | ---: | --- |
+| Import duration | `46.72 min` | `2,802.96 s`; build time excluded. |
+| Imported artists / aliases / relations / albums | `2,887,383` / `492,790` / `834,044` / `3,705,849` | Counts reported by the streaming importer summary. |
+| Peak working set | `21.04 GiB` | From the import probe process `PeakWorkingSetBytes`. |
+| Post-import `.ddb` / `.wal` before checkpoint | `8 KB` / `5.1 GB` | Import probe leaves the WAL uncheckpointed. |
+| Native checkpoint duration | `557.47 s` | Run through DecentDB `2.10.0` ADO.NET `DecentDBMaintenance.CheckpointAsync(...)`. |
+| Post-checkpoint `.ddb` / `.wal` | `2.4 GB` / `32 B` | Checkpoint completed without DecentDB CLI usage. |
+
+Real-file query results:
+
+| Query shape | WAL-backed warm | Checkpointed warm | Plan |
+| --- | ---: | ---: | --- |
+| `Database.CanConnectAsync()` | `52.86 ms` | `55.59 ms` | Not applicable. |
+| Ordered first-row projection | `7,763.99 ms` | `3,372.08 ms` | `TableScan(table=Artist)`. |
+| Exact normalized-name lookup | `8,094.76 ms` | `3,495.39 ms` | `IndexSeek(table=Artist, index=IX_Artist_NameNormalized)`. |
+| Exact alias lookup | `6,119.85 ms` | `260.08 ms` | `IndexSeek(table=ArtistAlias, index=IX_ArtistAlias_NameNormalized)`. |
+| Exact `MusicBrainzIdRaw` lookup | `8,173.73 ms` | `3,626.46 ms` | `IndexSeek(table=Artist, index=IX_Artist_MusicBrainzIdRaw)`. |
-After the synthetic validation went green again, a clean real-file rerun was started from a
-compiled `Release` probe harness so the monitor data would represent importer work rather than
-`dotnet run` build time. That rerun was intentionally stopped at user request before completion so
-the session could be wrapped up.
+DDB-002 and DDB-003 therefore remain `PROVIDER-FOLLOWUP`. The remaining
+DecentDB issue is no longer EF translation or parameter binding. It is a
+provider/runtime/storage issue where `IndexSeek` plans on the checkpointed
+large `Artist` table still require multi-second execution. No Melodee CLI
+fallback or ad hoc DecentDB process invocation should be added.
-What was completed before stopping:
+## Historical Measurements That Still Drive The Work
-- the existing `musicbrainz.ddb` and `musicbrainz.ddb-wal` were deleted before the rerun
-- the rerun was launched from the compiled harness DLL
-- the monitored root PID was `1908758`
-- the importer had cleanly started and was still in the artist-file staging phase when stopped
+The following measurements came from a focused probe against a real
+MusicBrainz DecentDB file of roughly `2.31 GB`. They remain useful because they
+explain why the open items exist.
-Partial metrics captured before stopping:
+| Query shape | Prior timing | Current instruction |
+| --- | ---: | --- |
+| `Database.CanConnectAsync()` | ~3-13 ms | Safe connectivity probe. |
+| Ordered first-row projection, for example `OrderBy(x => x.Id).Select(x => x.Id).FirstOrDefaultAsync()` | ~7-15 ms | DecentDB `2.13.1` still measured this around `2.9 s` in the checkpointed probe; keep off hot paths until a bounded row-id/limit fast path is validated. |
+| `Artists.AnyAsync()` / `Artists.Take(1).CountAsync()` | ~13 s | Keep off request paths and avoid in large-table probes. |
+| Exact normalized-name MusicBrainz artist lookup | ~13-40 ms | Intended primary large-file search path. DecentDB `2.13.1` validates this as complete for warm request-path use. |
+| `AlternateNames.Contains(...)` substring lookup | ~15 s | Do not use blob substring scans on the large request path. |
+| Tokenized `Contains(...)` fallback | ~22 s | Do not reintroduce as an automatic large-file fallback. |
+| Exact `MusicBrainzIdRaw` lookup | ~34 s | DecentDB `2.13.1` validates this as complete for warm request-path use. |
-- last sampled elapsed time: `00:08:00`
-- last sampled total RSS: `180,948 KB`
-- sampled peak `VmHWM`: `186,964 KB`
-- sampled total CPU time: `478.61s`
-- last sampled `.ddb` size: `591,761,408` bytes
-- last sampled `.wal` size: `42,372,368` bytes
-- on-disk sizes immediately after stopping:
- - `.ddb`: `615,456,768` bytes
- - `.wal`: `84,744,736` bytes
+## DDB-001: Clean Monitored Real-File Rebuild
-That partial rerun is not a substitute for final completion metrics, but it is still useful:
+**Status**: DONE
-- it shows the cleaned-up rerun starts correctly
-- it shows healthy early-stage `.ddb`/`.wal` growth
-- it shows sampled memory remaining dramatically below the earlier pre-fix high-memory plateau
+The MusicBrainz import was run against the real dump to completion and the
+final metrics were recorded below. The proof used DecentDB `2.8.0`.
-### Concerns
+Relevant entry points:
-There are still a few areas to watch:
+- `src/Melodee.Common/Jobs/MusicBrainzUpdateDatabaseJob.cs`
+- `src/Melodee.Common/Plugins/SearchEngine/MusicBrainz/Data/DecentDBMusicBrainzRepository.cs`
+- `src/Melodee.Common/Plugins/SearchEngine/MusicBrainz/Data/DecentDBStreamingMusicBrainzImporter.cs`
+- `tests/Melodee.Tests.Common/Plugins/SearchEngine/MusicBrainz/MusicBrainzImportBenchmark.cs`
-1. `ImportData(...)` finishes by calling `CountAsync()` on `Artists` and `Albums`. That is fine
- for an offline job, but it is still expensive work on huge tables and does not belong on a
- request path.
-2. the fully representative CLI job path is still harder to validate end-to-end than the direct
- repository harness path in this shell environment because the PostgreSQL-backed configuration
- store was unreachable from the test shell.
-3. the clean monitored rerun was intentionally paused at user request after about eight minutes of
- artist-file staging, so the final full-run metrics are still missing.
-4. `MusicBrainzIdRaw` exact lookup is still suspicious enough that it should be re-measured on the
- rebuilt database before it is treated as a strong fast path.
+Implementation guide:
-### Recommendation
+1. Use a compiled `Release` harness or a production-equivalent CLI job so build
+ time is not counted as import time.
+2. Start from a clean target by deleting the target `musicbrainz.ddb` and
+ `musicbrainz.ddb-wal`.
+3. Monitor the root worker PID and any child processes until import finishes.
+4. Capture wall-clock time, total RSS, peak `VmHWM`, total CPU time, final
+ `.ddb` size, final `.wal` size, and post-import probe timings.
+5. Record the final numbers in this document under "Final Real-File Metrics".
-For the import pipeline, prefer:
+Acceptance target:
-- offline-only counts or logged inserted-row counts already known from materialization steps
-- batch-limited materialization for every very-large table fan-out phase, not just album writes
-- helper-table precomputation when a hot join repeatedly has to rediscover the same small keyed
- relationships
-- direct perf logging around major import phases so memory and I/O regressions are obvious early
-- compiled-harness monitoring for real rebuild validation so build time and worker time are not
- conflated
+- The real-file import finishes without manual interruption.
+- The final metrics table is filled in.
+- Post-import probes include at least exact name, exact alias, first-row
+ existence, and exact MBID lookup.
-This is not the top priority compared with request-path search behavior, but it is worth
-tracking.
+## DDB-002: Exact MusicBrainzIdRaw Lookup Validation
-## Local artist search cache ("media artists") use cases
+**Status**: DONE
-### 1. Why this database is in a better place than MusicBrainz
+Exact MBID lookup should be one of the fastest MusicBrainz paths because it is
+an equality search on an indexed column. The prior probe showed roughly
+`34s`, which is not acceptable for request-path use. DecentDB `2.13.0`
+captures `IndexSeek(table=Artist, index=IX_Artist_MusicBrainzIdRaw)` through
+ADO.NET `EXPLAIN`, but the checkpointed targeted MBID probe timed out after
+`180 s`.
-The local artist search cache is not being asked to behave like the 2.31 GB MusicBrainz file.
-That matters more than any single query detail.
+The DecentDB local worktree fix validated this path before package publication,
+and DecentDB `2.13.1` NuGet package validation reproduced the result without
+local binary references. The committed Melodee query probe measured the
+checkpointed MBID query at `11.04 ms` cold and `0.56 ms` warm, both with
+`IndexSeek(table=Artist, index=IX_Artist_MusicBrainzIdRaw)`.
-Melodee already reduces pressure on this database through:
+Relevant code:
-- in-memory `ArtistSearchCache`
-- per-run `SingleFlightCache` in `DirectoryRunContext`
-- plugin ordering that checks Melodee/local data before MusicBrainz
+- `src/Melodee.Common/Plugins/SearchEngine/MusicBrainz/Data/DecentDBMusicBrainzRepository.cs`
+- `src/Melodee.Common/Plugins/SearchEngine/MusicBrainz/Data/MusicBrainzDbContext.cs`
+- `src/Melodee.Common/Plugins/SearchEngine/MusicBrainz/Data/DecentDBStreamingMusicBrainzImporter.cs`
+
+Validation guide:
+
+1. Use the rebuilt database from DDB-001.
+2. Sample known artist MBIDs directly from the materialized `Artist` table.
+3. Verify that `MusicBrainzIdRaw` values are canonical strings and round-trip
+ through the provider without binary or formatting artifacts.
+4. Time direct equality lookup:
+ `context.Artists.Where(a => a.MusicBrainzIdRaw == mbid).OrderBy(a => a.Id).Take(1)`.
+5. Time repository lookup through `SearchArtist(...)` when only an MBID is
+ supplied.
+6. Time repository lookup when both exact name and MBID are supplied.
+7. Compare cold and warm timings to exact normalized-name lookup.
+8. Re-run the targeted query probe after future DecentDB package updates to
+ catch regressions in checkpointed indexed equality.
+
+Acceptance target:
+
+- Warm MBID lookup is in the same broad request-safe class as exact
+ normalized-name lookup.
+- Stored values are readable canonical strings.
+- DecentDB `2.13.1` reproduces the local worktree result.
+
+## DDB-003: Indexed String Equality Provider Follow-Up
+
+**Status**: DONE
+
+DDB-002 proved that rebuilt-file indexed string equality was still slow enough
+to keep off Melodee request paths under the DecentDB `2.8.0` baseline. Exact
+normalized-name and exact `MusicBrainzIdRaw` lookups both remained around
+`7.6 s` warm against the clean rebuild, with exact alias lookup around `5.7 s`
+warm.
+
+DecentDB `2.10.0` includes the concrete provider/planner follow-up from this
+document:
+
+- simple indexed equality projections accept simple ordering, limits, and
+ offsets instead of falling through to slower projection paths
+- the native executor has a regression that fails if ordered indexed equality
+ does not stay on the fast path
+- the EF Core provider has a large indexed string regression for
+ `Where(...).OrderBy(...).Take(1)`
+- ADO.NET has binding-native index rebuild helpers for maintenance workflows
+
+The `2.13.0` real-file validation proves those planner fixes are still present
+through ADO.NET `EXPLAIN`, but the runtime target was still not satisfied for
+the checkpointed large `Artist` table. Targeted checkpointed probes for both
+`Artist.NameNormalized == value` and `Artist.MusicBrainzIdRaw == value` timed
+out after `180 s`, even though both plans show `IndexSeek`.
+
+The DecentDB local worktree fix validated the runtime path before package
+publication, and DecentDB `2.13.1` NuGet package validation reproduced the
+fix without local binary references. The checkpointed real-file probe measured
+`Artist.NameNormalized` at `9,996 ms` cold first-use hydration and `0.66 ms`
+warm, `ArtistAlias` at `780 ms` cold and `0.80 ms` warm, and
+`Artist.MusicBrainzIdRaw` at `11.04 ms` cold and `0.56 ms` warm. All three
+plans use `IndexSeek`.
+
+Provider enhancement candidates are tracked in
+`design/docs/decentdb-provider-enhancements.md`.
+
+Release validation guide:
+
+1. Keep the `2.13.0` import, plan, timeout, local-worktree, and `2.13.1`
+ package-validation artifacts attached to any future regression issue.
+2. Rerun the same targeted checkpointed query probe after future DecentDB
+ package updates.
+3. Check indexed row lookup/materialization cost for checkpointed large tables,
+ not only planner index selection.
+4. Re-run `musicbrainz-query-probe` after the next provider/runtime fix.
+
+Acceptance target:
+
+- Equality lookup on a representative indexed string column is consistently
+ request-safe.
+- Provider tests fail before the fix and pass after it.
+
+## DecentDB Provider Enhancement Candidates
+
+The current Melodee work exposed several DecentDB provider or .NET binding
+enhancement candidates:
+
+- native .NET checkpoint, vacuum, compact, and WAL-status APIs published in
+ DecentDB `2.9.0`
+- clearer WAL checkpoint status published in DecentDB `2.9.0`; documented
+ `WalAutoCheckpoint` guidance remains useful provider documentation
+- regression coverage for equality on indexed string columns published in
+ DecentDB `2.9.0`; ordered indexed equality fast-path coverage and binding
+ index rebuild helpers published in DecentDB `2.10.0`
+- opt-in query plan and index usage diagnostics through ADO.NET, published in
+ DecentDB `2.9.0`
+- real-file DecentDB `2.13.0` validation showed `IndexSeek` plans but
+ checkpointed targeted probes timed out after `180 s`; DecentDB `2.13.1`
+ package validation completes the runtime path
+- documented large-text or full-text search strategy
+- clearer unsupported file-format diagnostics published in DecentDB `2.9.0`;
+ explicit upgrade guidance remains a provider documentation candidate
+
+See `design/docs/decentdb-provider-enhancements.md` for the detailed list and
+the exact observations behind each item.
+
+## DDB-004: Local Artist Cache Search Refactor
+
+**Status**: DONE
+
+The local artist cache is smaller than MusicBrainz and has caching in front of
+it, but it still contains query shapes that will not age well if the cache grows
+to hundreds of thousands or millions of rows.
+
+Current risk areas:
+
+- `ArtistSearchEngineService.DoSearchAsync(...)` performs a wide mixed `OR`
+ query across normalized name, MusicBrainz ID, Spotify ID, and tagged
+ `AlternateNames.Contains(...)`.
+- The second lookup after provider results repeats a similar mixed `OR` query
+ across several external IDs and alternate names.
+- `Include(x => x.Albums)` is applied before the candidate set is known to be
+ small.
+
+Relevant code:
-That means Melodee usually pays the large-file MusicBrainz cost only when the faster sources do
-not already have the answer.
+- `src/Melodee.Common/Services/SearchEngines/ArtistSearchEngineService.cs`
+- `src/Melodee.Common/Models/SearchEngines/ArtistSearchEngineServiceData`
-Architecturally, that is the correct direction.
+Implementation guide:
-### 2. Local cache search path
+1. Split local cache lookup into ordered phases:
+ exact provider IDs, exact `NameNormalized`, exact normalized alias, then any
+ intentionally slower fallback.
+2. Avoid `AlternateNames.Contains(...)` for the normal fast path. If alternate
+ names must be searchable, add or reuse a normalized alias structure.
+3. Load albums only after a bounded candidate artist set has been selected.
+4. Preserve current caching behavior and provider ordering.
+5. Add tests proving an exact local hit does not call external providers and
+ that duplicate provider results do not create duplicate artists.
-### Current verdict
+Acceptance target:
-**Acceptable today, but should be hardened before this database grows much larger.**
+- The normal local-cache path no longer relies on one wide mixed `OR` query.
+- The normal local-cache path does not use substring alternate-name matching.
+- Album loading happens after candidate narrowing.
-### Why
+## DDB-005: Local Cache Listing Scale Hardening
-The local cache search uses multiple indexed equality lookups:
+**Status**: DONE
-- `NameNormalized`
-- `MusicBrainzId`
-- `SpotifyId`
-- various provider IDs with unique indexes
+`ArtistSearchEngineService.ListAsync(...)` currently performs a total count and
+then materializes the filtered artist query before applying `Skip` and `Take`
+in memory. That is acceptable only while the local cache stays small.
-Those are fine.
+Relevant code:
-However, the query also mixes those indexed predicates with:
+- `src/Melodee.Common/Services/SearchEngines/ArtistSearchEngineService.cs`
-- `AlternateNames.Contains(...)`
-- a wide `OR` chain
-- eager `Include(x => x.Albums)`
+Implementation guide:
-That combination is survivable for a small working set but is not something I would want to
-trust blindly once the local cache becomes large.
+1. Make total count optional for UI paths that do not strictly require it.
+2. Keep pagination on the database side when the provider can translate the
+ shape reliably.
+3. If provider limitations require a workaround, prefer keyset pagination or a
+ two-step ID page query over full table materialization.
+4. Keep album counts as a second grouped query over the page's artist IDs only.
+5. Add tests for count-only requests, normal page requests, and filtered page
+ requests.
-### Recommendation
+Acceptance target:
-For future-proofing, split the local cache search into staged phases the same way MusicBrainz
-search should be split:
+- A normal page request does not materialize all matching artists before paging.
+- Total count can be skipped by callers that do not need it.
+- Album counts remain limited to the returned page.
-1. exact ID lookups
-2. exact normalized-name lookup
-3. exact alternate-name token lookup via a normalized alias structure
-4. only then consider slower fallbacks
+## DDB-006: Import Completion Counts
-That keeps the fast path obviously index-driven.
+**Status**: DONE
-### 3. Local cache admin listing
+The import path still uses large-table `CountAsync()` calls for completion
+logging and some progress calculations. These are offline operations, so they
+are lower risk than request-path queries, but they still add avoidable work on
+very large databases.
-### Current verdict
+Relevant code:
-**Fine for a small cache, but contains scale risks.**
+- `src/Melodee.Common/Plugins/SearchEngine/MusicBrainz/Data/DecentDBMusicBrainzRepository.cs`
+- `src/Melodee.Common/Plugins/SearchEngine/MusicBrainz/Data/DecentDBStreamingMusicBrainzImporter.cs`
-### Why
+Implementation guide:
-`ArtistSearchEngineService.ListAsync(...)` always does:
+1. Track inserted artist, alias, album, and relation counts during
+ materialization.
+2. Return an import summary from the streaming importer instead of requiring the
+ repository to count materialized tables at the end.
+3. Use known staging/materialization counts for progress when they are already
+ available.
+4. Keep any full-table verification counts behind explicit diagnostic or
+ validation settings.
-- `CountAsync()` for total rows
-- pagination with `Skip/Take`
-- a correlated album count per projected artist row
+Acceptance target:
-That is okay for a modest working set and likely only exercised in admin tooling, but it is not
-the shape I would choose if this database becomes large.
+- Normal import completion logging does not need final full-table `CountAsync()`
+ probes.
+- Diagnostic full-table counts remain available when explicitly requested.
-### Recommendation
+## DDB-007: MusicBrainz Query Perf Probe
-If the local cache grows materially, revisit this method:
+**Status**: DONE
-- make total count optional when the UI does not strictly need it
-- replace per-row correlated album counts with a precomputed counter or grouped query
-- keep ordering aligned with indexed columns whenever possible
+Add a committed probe so future agents can verify the query shapes that matter
+without rebuilding ad hoc scripts each time.
-This is not urgent in the same way the large MusicBrainz fallback scans are urgent, but it is
-the most obvious local-cache scale concern.
+Probe coverage:
-### 4. Housekeeping job
+- database open and `CanConnectAsync()`
+- ordered first-row projection existence check
+- exact normalized-name lookup
+- exact alias lookup
+- exact `MusicBrainzIdRaw` lookup
+- any intentionally slow fallback, if one is ever reintroduced
-### Current verdict
+Implementation guide:
-**Improved and in a reasonable place today.**
+1. Put the probe somewhere explicit and non-flaky, such as a benchmark project,
+ a manual integration test category, or a documented CLI diagnostic.
+2. Require an explicit database path so CI does not need the full MusicBrainz
+ file.
+3. Emit machine-readable output, preferably JSON, with cold and warm timings.
+4. Record sanitized sample query values and row counts.
-### Why
+Acceptance target:
-`ArtistSearchEngineRepositoryHousekeepingJob` filters on:
+- A developer can run one documented command against a target `.ddb` file and
+ get comparable timings for all critical query shapes.
+- The probe output is suitable for attaching to future performance issues.
-- `IsLocked`
-- `LastRefreshed`
+## DDB-008: MusicBrainz Import Perf Probe
-and orders by `LastRefreshed`.
+**Status**: DONE
-Earlier in the session, this was one of the obvious scale mismatches in the local cache workflow.
-That gap has now been addressed by ensuring the housekeeping-supporting index exists even on
-already-created DecentDB files.
+Extend the committed import perf harnesses so they separate import work from
+build time and record the data needed to catch regressions in large MusicBrainz
+imports.
-### Recommendation
+Current code state:
-Keep the housekeeping index in place and only revisit the exact shape if the local cache workload
-changes materially. If the cache grows much larger, a composite `(IsLocked, LastRefreshed)` shape
-may still be worth measuring explicitly.
+- `benchmarks/Melodee.Benchmarks/MusicBrainzImportBenchmarks.cs` provides
+ BenchmarkDotNet coverage for synthetic MusicBrainz import data.
+- `tests/Melodee.Tests.Common/Plugins/SearchEngine/MusicBrainz/MusicBrainzImportBenchmark.cs`
+ provides opt-in synthetic perf tests behind `MELODEE_RUN_PERF_TESTS=true`.
+- `benchmarks/Melodee.Benchmarks/MusicBrainzImportProbe.cs` provides the
+ manual real-file import mode, OS-level RSS and `VmHWM` samples on Linux,
+ periodic `.ddb` and `.wal` growth samples, phase timings, and final counts
+ from importer counters.
-### 5. EnsureCreated and doctor checks on the local cache DB
+Probe coverage:
-### Current verdict
+- artist staging time
+- artist materialization time
+- album staging time
+- album materialization time
+- relation materialization time
+- peak RSS / `VmHWM`
+- `.ddb` and `.wal` size growth over time
+- final imported row counts from known import counters
-**Healthy.**
+Implementation guide:
-### Why
+1. Run from compiled `Release` output.
+2. Emit periodic metric samples to JSON or CSV.
+3. Support cancellation without corrupting the metric file.
+4. Keep the real-file probe manual or opt-in; retain synthetic coverage for
+ regular CI or local smoke checks.
-The local cache DB is only checked for connectivity in doctor paths and is initialized with
-`EnsureCreatedAsync()` during service startup. Those are reasonable operations for this smaller
-database.
+Acceptance target:
-No major concern here.
+- Real-file import runs can be compared across commits without relying on
+ shell-only monitoring scripts.
+- Phase timing and memory regressions are visible from the probe output.
-## What Melodee is already doing right
+## DDB-009: Large-Text Search Strategy
-Melodee is already doing several things that should be kept:
+**Status**: DONE
-1. **Separate databases for separate workloads**
- - MusicBrainz is isolated from the local cache.
+The large MusicBrainz request path should not silently rely on substring scans
+over blob-like text fields. Decide whether this belongs in DecentDB provider
+features, Melodee schema design, or explicit documentation.
-2. **Bounded MusicBrainz result sets**
- - `SearchArtist(...)` internally caps search result size.
+Implementation guide:
-3. **Caching around search**
- - repeated searches are not forced back through the same path every time.
+1. Decide whether DecentDB should support a full-text or token index strategy
+ for this class of workload.
+2. If provider support is not planned, document that large substring workloads
+ require schema-level lookup tables.
+3. Keep MusicBrainz alternate-name lookup based on normalized exact-match rows.
+4. Do not reintroduce automatic substring fallback on the large request path.
-4. **Local-first search ordering**
- - Melodee's own data is consulted before the large MusicBrainz database.
+Acceptance target:
-5. **Doctor fast-path fix**
- - first-render health checks now use an appropriate query shape.
+- The intended approach is documented in the provider and/or Melodee docs.
+- Any future fuzzy search work has an explicit schema and cost model.
-6. **Offline-oriented import pipeline**
- - bulk import is using staging + SQL materialization instead of naive row-by-row EF inserts.
+## DDB-010: Large-File Diagnostics
-These are the correct building blocks. The remaining work is mostly about making the query
-shapes match the scale of the MusicBrainz file.
+**Status**: DONE
-## Recommended improvements by owner
+Large-file plan problems should be visible before they turn into production
+latency surprises.
-### Melodee-side improvements
+Current code state:
-| Status | Priority | Area | Recommendation |
-| --- | --- | --- | --- |
-| Done | High | MusicBrainz request path | Keep `Select(Id).FirstOrDefaultAsync()` as the standard existence probe for large tables. |
-| Done | High | MusicBrainz search | Keep substring/tokenized fallback phases off the normal large-file request path. |
-| Done | High | MusicBrainz schema/search design | Keep the normalized alias lookup structure as the online alternate-name path; rebuilt databases are required to populate it. |
-| Done | High | MusicBrainz import materialization | Keep album materialization batch-bounded and keep the helper-table query simplification in place. |
-| Outstanding | High | MBID exact search | Treat `MusicBrainzIdRaw` lookup as suspect until verified against the rebuilt database and provider/data file. |
-| Outstanding | Medium | Local cache search | Split local cache search into staged exact-match phases instead of one wide OR query. |
-| Done | Medium | Local cache housekeeping | Maintain the new housekeeping index and revisit the exact composite shape only if the workload grows materially. |
-| Outstanding | Medium | Local cache listing | Revisit `CountAsync()` and correlated album-count projections if the local cache grows substantially. |
-| Outstanding | Low | Import completion | Prefer pre-known inserted counts over end-of-job `CountAsync()` when possible. |
+- `DecentDBMusicBrainzRepository` logs phase timing for MusicBrainz artist
+ searches.
+- `MusicBrainzUpdateDatabaseJob` logs WAL size before and after the DecentDB
+ checkpoint step through `DecentDBMaintenance.CheckpointAsync(...)`.
+- The query and import probes expose opt-in sanitized SQL, row counts, index
+ metadata, query-plan output from DecentDB's ADO.NET `ExplainQuery` helper,
+ memory samples, file growth, and phase timings.
-### DecentDB provider / binding improvements
+Implementation guide:
-| Status | Priority | Area | Recommendation |
-| --- | --- | --- | --- |
-| Done | High | Native loading / TIMESTAMP correctness | Keep the local-source native asset fix, true `TIMESTAMP` mapping, and microsecond timestamp decoding in place. |
-| Done | High | Provider guidance / existence semantics | Keep the `Any()` optimization work and continue documenting that large-table request paths should still prefer explicit first-row projection. |
-| Done | Medium | UUID string compatibility | Preserve canonical GUID string reads for UUID-backed values read as strings. |
-| Done | Medium | Repeated non-query execution | Keep prepared statement reuse for repeated single-statement `ExecuteNonQuery()` and the now-real `Prepare()` path. |
-| Outstanding | High | Equality on indexed strings | Verify index usage and string round-tripping for large indexed string columns such as `MusicBrainzIdRaw`. |
-| Outstanding | Medium | Large-text search | Decide whether the provider should support additional indexing/search strategies for substring-heavy workloads, or clearly document that these require a different schema. |
-| Outstanding | Medium | Diagnostics | Add easy SQL/perf diagnostics so large-file plan problems are visible sooner in app-level probes. |
+1. Add or expose sanitized SQL logging for DecentDB-backed probes.
+2. Include elapsed time, row count, database path or logical connection name,
+ and query phase name.
+3. Add plan/index diagnostics if DecentDB exposes enough information.
+4. Keep diagnostics opt-in so normal production logs do not become noisy.
+5. Wire the diagnostics into DDB-007 and DDB-008 where practical.
-## Will Melodee's current DecentDB use cases be successful and performant?
+Acceptance target:
-### Short answer
+- A slow query report includes enough detail to distinguish a bad query shape
+ from provider/index behavior.
+- Diagnostics can be enabled without editing source code.
-**Yes for the key implemented paths, with two important caveats: the rebuilt real-file rerun still
-needs final completion metrics, and exact `MusicBrainzIdRaw` lookup still needs direct validation.**
+## Historical DecentDB 2.8.0 Real-File Metrics
-### Use-case verdict matrix
+This table records the completed DDB-001 and DDB-002 run against DecentDB `2.8.0`.
-| Use case | Verdict | Notes |
+| Metric | Value | Notes |
| --- | --- | --- |
-| Blazor dashboard doctor | **Success / performant** | Now uses a fast first-row projection. |
-| CLI doctor DecentDB connectivity | **Success / performant** | Uses `CanConnectAsync()` only. |
-| MusicBrainz exact normalized-name search | **Success / performant** | Real-file probe shows low-millisecond to tens-of-milliseconds performance. |
-| MusicBrainz reversed-name exact search | **Likely success / performant** | Same query family as exact normalized-name. |
-| MusicBrainz alternate-name lookup | **Success / structurally improved** | Now uses the normalized alias lookup path; rebuilt DBs are required and final post-rebuild timing is still outstanding. |
-| MusicBrainz tokenized `Contains(...)` fallback | **Not on the default path** | Intentionally removed from the large-file request path. |
-| MusicBrainz exact MBID lookup | **Questionable** | Probe showed ~34 seconds and suspicious raw-value behavior. Needs explicit validation. |
-| MusicBrainz cover-art lookup | **Conditionally performant** | Good if exact name/alias phases hit; still questionable if it falls to the suspicious exact-MBID path. |
-| Local artist cache search | **Success / acceptable today** | Smaller DB, cached, local-first. Still has scale concerns. |
-| Local artist cache admin listing | **Success / acceptable today** | Count and correlated album count should be watched as the cache grows. |
-| Local cache housekeeping | **Success / improved** | Housekeeping index is now ensured; revisit only if workload changes materially. |
-| MusicBrainz import/update job | **Success / synthetic validation green** | Batch/query fixes are in place and importer tests now pass; final full-file completion metrics are still outstanding. |
-
-## Recommended next steps
-
-### Immediate next steps
-
-1. Resume the clean monitored real-file rerun and let it finish so this document can carry final
- wall-clock, RSS, file-growth, and post-import probe metrics instead of only partial numbers.
-2. Re-measure exact `MusicBrainzIdRaw` lookup on the rebuilt database before treating MBID-exact
- search as a strong fast path.
-3. Keep the doctor-query, normalized alias, and tokenized-fallback removals in place. Those are
- now part of the intended design, not temporary experiments.
-4. Record the final monitored rerun metrics back into this document and compare them directly with
- the earlier bad run.
-
-### Near-term implementation candidates
-
-1. Refactor local cache search into exact-match phases instead of one mixed OR query.
-2. Add a committed perf regression probe for the query shapes that matter:
- - exact normalized-name search
- - exact MBID search
- - first-row projection existence check
- - exact alias lookup
- - any explicit slow fallback mode, if one is reintroduced later
-3. Add a committed import perf regression probe for:
- - artist materialization
- - album materialization
- - peak RSS
- - final `.ddb` size growth over time
-4. Add easier provider/app diagnostics for large-file query shape investigation.
-
-## Final assessment
-
-Melodee does **not** need to abandon DecentDB.EntityFrameworkCore for the large MusicBrainz file.
-The work completed in this session actually strengthens that conclusion. Once the query shapes and
-provider behavior are corrected, the large file is usable and performant for the paths that are
-supposed to be interactive.
-
-The core issue is not "DecentDB cannot handle a large MusicBrainz file." The core issue is:
-
-> Melodee and the .NET bindings both needed targeted, scale-aware fixes. Once those were applied,
-> the remaining problem narrowed to finishing real-file validation rather than searching for the
-> root cause.
-
-What is completed now:
-
-- doctor checks are on the right path
-- exact-name and exact-alias lookup design is on the right path
-- tokenized substring fallback is off the normal request path
-- TIMESTAMP support in the provider is fixed properly
-- prepared non-query execution is better
-- the importer is no longer designed around giant in-memory album buffering
-- the hot album query has been simplified structurally
-- synthetic importer validation is green again
-
-What is still outstanding is much smaller and better defined:
-
-- finish one clean monitored real-file rerun to completion
-- capture and record the final full-file metrics
-- explicitly validate `MusicBrainzIdRaw` exact lookup on the rebuilt database
-
-That is a much better place to end a session than where this started. The remaining risk is no
-longer "we do not know what is wrong." The remaining risk is "we still need the last completion
-numbers and one suspicious exact-match path verified."
+| Rebuild command or harness | `dotnet benchmarks/Melodee.Benchmarks/bin/Release/net10.0/Melodee.Benchmarks.dll musicbrainz-import-probe --storage /mnt/fileserver_db_storage/melodee/search-engine-storage/musicbrainz --db /tmp/melodee-decentdb-probes/musicbrainz.ddb --output /tmp/melodee-decentdb-probes/musicbrainz-import-probe.json --clean --sample-interval-ms 10000` | Compiled `Release` output. |
+| Source dump path | `/mnt/fileserver_db_storage/melodee/search-engine-storage/musicbrainz/staging/mbdump` | Existing extracted real MusicBrainz dump. |
+| Target database path | `/tmp/melodee-decentdb-probes/musicbrainz.ddb` | Clean target. Existing files were deleted first. |
+| Wall-clock time | `45.49 min` | `2,729.35 s`; build time excluded. |
+| Imported artists / aliases / relations / albums | `2,887,383` / `492,790` / `834,044` / `3,705,849` | Counts reported by the streaming importer summary, not final `CountAsync()` probes. |
+| Peak RSS / `VmHWM` | `20.68 GiB` peak process working set; manual `/proc` check observed `VmHWM` around `21 GiB` | Probe report also contains periodic memory samples. |
+| Final `.ddb` size | `8 KB` | Main file stayed small because checkpointing was unavailable. |
+| Final `.wal` size | `5.1 GB` | This run happened before DecentDB `2.9.0` binding maintenance helpers were consumed; Melodee now checkpoints imported databases through `DecentDBMaintenance.CheckpointAsync(...)`. |
+| First-row existence probe | cold `7,419 ms`; warm `7,509 ms` | Final query probe: `/tmp/melodee-decentdb-probes/musicbrainz-query-probe-final.json`. |
+| Exact normalized-name lookup | cold `7,748 ms`; warm `7,620 ms` | Indexed `Artist.NameNormalized` equality. |
+| Exact alias lookup | cold `5,659 ms`; warm `5,665 ms` | Indexed `ArtistAlias.NameNormalized` equality. |
+| Exact `MusicBrainzIdRaw` lookup | cold `7,718 ms`; warm `7,597 ms` | Indexed `Artist.MusicBrainzIdRaw` equality. |
+
+## Current Working Assumption
+
+Melodee does not need to abandon `DecentDB.EntityFrameworkCore` for the large
+MusicBrainz file. The Melodee-side query-shape, import-count, local-cache, and
+diagnostic work is implemented. DecentDB `2.13.1` provides the binding-native
+checkpoint, WAL-status, query-plan, compatibility diagnostic, ordered indexed
+equality, index rebuild helpers, and checkpointed deferred-index runtime fix
+requested by Melodee. Melodee consumes the package references directly and the
+checkpointed real-file query probe validates DDB-002 and DDB-003 as complete.
diff --git a/design/DECENTDB_ISSUE_UNABLE_TO_FIX_PROMPT.md b/design/DECENTDB_ISSUE_UNABLE_TO_FIX_PROMPT.md
new file mode 100644
index 000000000..4223fcbce
--- /dev/null
+++ b/design/DECENTDB_ISSUE_UNABLE_TO_FIX_PROMPT.md
@@ -0,0 +1,489 @@
+## DecentDB Indexed Equality Performance Issue Handoff Prompt
+
+**Status**: Historical handoff prompt. The DecentDB local worktree fix was
+validated on 2026-06-15 by wiring Melodee directly to
+`/home/steven/src/github/decentdb/bindings/dotnet`, and DecentDB `2.13.1`
+NuGet package validation reproduced the fix. See
+`design/DECENTDB_IMPROVEMENTS.md` for the current `DONE` status.
+
+Use this prompt for a DecentDB coding agent working in the local DecentDB
+repository at `/home/steven/src/github/decentdb`.
+
+## Situation
+
+Melodee has tried to resolve DDB-002 and DDB-003 across multiple DecentDB
+package releases. The current Melodee package baseline is:
+
+- `DecentDB.AdoNet` `2.13.0`
+- `DecentDB.EntityFrameworkCore` `2.13.0`
+- `DecentDB.EntityFrameworkCore.NodaTime` `2.13.0`
+
+The `2.13.0` package still does not satisfy Melodee's real-file acceptance
+target for DDB-002 or DDB-003.
+
+The important distinction is that DecentDB now chooses the expected query
+plans, but the real execution remains far too slow:
+
+- `Artist.NameNormalized == value` reports `IndexSeek`.
+- `Artist.MusicBrainzIdRaw == value` reports `IndexSeek`.
+- Checkpointed execution against the real MusicBrainz-sized `Artist` table
+ still does not complete in a request-safe window; targeted `2.13.0` probes
+ timed out after `180 s`.
+
+This means the remaining problem is likely below EF Core translation and below
+simple planner index selection. The issue appears to be in DecentDB runtime,
+storage, index lookup, row materialization, sort/limit execution after an
+index seek, checkpointed-file access, or a closely related path.
+
+Do not mark this issue fixed until the DecentDB repository has tests that fail
+before the fix and pass after it, and Melodee's real-file probe proves the
+checkpointed query timings are request-safe.
+
+## Why This Matters
+
+Melodee uses DecentDB for a large, read-mostly MusicBrainz materialized
+database. This database is used by ingestion and artist lookup workflows.
+MusicBrainz artist data is large enough that full scans or inefficient
+post-index work become user-visible delays.
+
+DDB-002 is the exact `MusicBrainzIdRaw` lookup path. This should be one of the
+fastest possible lookups because it is an equality search over an indexed
+canonical identifier column.
+
+DDB-003 is the more general indexed string equality path. Melodee depends on
+indexed normalized strings such as `NameNormalized` and normalized alias lookup
+rows to avoid substring scans over large text columns.
+
+If exact indexed equality on a checkpointed multi-million-row table takes
+multiple seconds, Melodee cannot safely put these lookups on request paths.
+The application can avoid bad query shapes, but it cannot compensate for a
+multi-second indexed equality execution path inside the database engine.
+
+## Current Melodee Evidence
+
+The latest Melodee validation used DecentDB `2.13.0` on 2026-06-15.
+
+The existing production MusicBrainz database could not be reused because it was
+an older unsupported DecentDB file format. Validation therefore used a fresh
+real-file rebuild from the MusicBrainz dump.
+
+Artifacts from the Melodee validation run:
+
+- Import probe:
+ `.tmp/decentdb-2.13-validation/musicbrainz-import-probe.json`
+- ADO.NET plan capture:
+ `.tmp/decentdb-2.13-validation/musicbrainz-explain-plans.txt`
+- Checkpointed targeted query probes were attempted, but timed out before a
+ JSON report was written.
+
+Import and checkpoint results:
+
+| Metric | Value |
+| --- | ---: |
+| Import duration in minutes | `46.62 min` |
+| Import duration in seconds | `2,797.22 s` |
+| Artists | `2,887,383` |
+| Artist aliases | `492,790` |
+| Artist relations | `834,044` |
+| Albums | `3,705,849` |
+| Peak working set | `20.33 GiB` |
+| Post-import main `.ddb` before checkpoint | `8 KB` |
+| Post-import `.wal` before checkpoint | `5.0 GiB` |
+| Native checkpoint duration | `635.93 s` |
+| Post-checkpoint main `.ddb` | `2.4 GiB` |
+| Post-checkpoint `.wal` | `32 B` |
+
+The checkpoint was run through the DecentDB .NET binding:
+
+```csharp
+await DecentDBMaintenance.CheckpointAsync(databasePath, cancellationToken);
+```
+
+No DecentDB CLI was used for the Melodee validation.
+
+## Current Failing Validation
+
+DecentDB `2.13.0` still reports the expected index plans:
+
+| Query shape | Plan capture | Plan |
+| --- | ---: | --- |
+| Exact normalized-name lookup | `17.28 ms` | `IndexSeek(table=Artist, index=IX_Artist_NameNormalized)` |
+| Exact alias lookup | `0.27 ms` | `IndexSeek(table=ArtistAlias, index=IX_ArtistAlias_NameNormalized)` |
+| Exact `MusicBrainzIdRaw` lookup | `0.16 ms` | `IndexSeek(table=Artist, index=IX_Artist_MusicBrainzIdRaw)` |
+
+The actual checkpointed execution still fails:
+
+| Targeted probe | Result |
+| --- | --- |
+| Explicit `MusicBrainzIdRaw` sample, with name and alias supplied | Timed out after `180 s`. |
+| Explicit normalized-name sample, with alias supplied | Timed out after `180 s`. |
+
+The checkpointed values are the relevant acceptance case for Melodee because
+the MusicBrainz update job checkpoints the imported database before promoting
+it.
+
+These timeouts are not acceptable. An indexed equality lookup on one row, or a
+very small candidate set, should not exceed a `180 s` validation timeout after
+the file is checkpointed and reopened.
+
+## Important Sample Values
+
+The `2.13.0` query probe used this sample:
+
+```text
+FirstArtistId: 1
+NameNormalized: 0JTQvtC60YLQvtGAINCh0LDRgtCw0L3QsA
+AliasNormalized: 0KDQsNC30L3QuCDQmNC30LLQtdC00YPQstCw0YfQuA
+MusicBrainzIdRaw: fadeb38c-833f-40bc-9d8c-a6383b38b1be
+```
+
+The exact `MusicBrainzIdRaw` value should be highly selective. If the engine
+is doing significant work after the index seek, that work needs to be
+explained and reduced.
+
+## Melodee Query Shapes
+
+Melodee's benchmark probe measures these EF Core query shapes.
+
+Ordered first-row existence:
+
+```csharp
+context.Artists
+ .AsNoTracking()
+ .OrderBy(a => a.Id)
+ .Select(a => a.Id)
+ .Take(1)
+```
+
+Exact normalized artist name:
+
+```csharp
+context.Artists
+ .AsNoTracking()
+ .Where(a => a.NameNormalized == sampleValues.NameNormalized)
+ .OrderBy(a => a.SortName)
+ .Take(10)
+```
+
+Exact normalized alias:
+
+```csharp
+context.ArtistAliases
+ .AsNoTracking()
+ .Where(a => a.NameNormalized == sampleValues.AliasNormalized)
+ .OrderBy(a => a.MusicBrainzArtistId)
+ .Take(10)
+```
+
+Exact MusicBrainz ID:
+
+```csharp
+context.Artists
+ .AsNoTracking()
+ .Where(a => a.MusicBrainzIdRaw == sampleValues.MusicBrainzIdRaw)
+ .OrderBy(a => a.Id)
+ .Take(1)
+```
+
+The probe also sends equivalent SQL to DecentDB's ADO.NET `ExplainQuery`
+helper so the report includes plan diagnostics.
+
+Exact normalized artist name SQL shape:
+
+```sql
+SELECT *
+FROM "Artist"
+WHERE "NameNormalized" = '0JTQvtC60YLQvtGAINCh0LDRgtCw0L3QsA'
+ORDER BY "SortName"
+LIMIT 10
+```
+
+Exact MusicBrainz ID SQL shape:
+
+```sql
+SELECT *
+FROM "Artist"
+WHERE "MusicBrainzIdRaw" = 'fadeb38c-833f-40bc-9d8c-a6383b38b1be'
+ORDER BY "Id"
+LIMIT 1
+```
+
+## Captured Plans
+
+Checkpointed exact normalized-name plan:
+
+```text
+Limit(limit=10, offset=none)
+ Sort(SortName)
+ Project(*)
+ IndexSeek(table=Artist, index=IX_Artist_NameNormalized, predicate=(NameNormalized = '0JTQvtC60YLQvtGAINCh0LDRgtCw0L3QsA'))
+```
+
+Checkpointed exact MusicBrainz ID plan:
+
+```text
+Limit(limit=1, offset=none)
+ Sort(Id)
+ Project(*)
+ IndexSeek(table=Artist, index=IX_Artist_MusicBrainzIdRaw, predicate=(MusicBrainzIdRaw = 'fadeb38c-833f-40bc-9d8c-a6383b38b1be'))
+```
+
+The `EXPLAIN` helper itself is fast. The expensive part is query execution.
+For example, in the checkpointed `2.13.0` probe, the plan capture time was
+about `0.16 ms` for the exact `MusicBrainzIdRaw` plan, but the actual targeted
+query probe timed out after `180 s`.
+
+## Historical Attempts And Why They Were Not Enough
+
+Melodee previously validated DecentDB `2.10.0` and `2.11.0`.
+
+DecentDB `2.10.0` proved the planner/provider could select `IndexSeek`, but
+runtime execution was still slow on the real checkpointed `Artist` table:
+
+| Query shape | Checkpointed warm timing | Plan |
+| --- | ---: | --- |
+| Ordered first-row projection | `3,372.08 ms` | `TableScan(table=Artist)` |
+| Exact normalized-name lookup | `3,495.39 ms` | `IndexSeek(table=Artist, index=IX_Artist_NameNormalized)` |
+| Exact alias lookup | `260.08 ms` | `IndexSeek(table=ArtistAlias, index=IX_ArtistAlias_NameNormalized)` |
+| Exact `MusicBrainzIdRaw` lookup | `3,626.46 ms` | `IndexSeek(table=Artist, index=IX_Artist_MusicBrainzIdRaw)` |
+
+DecentDB `2.11.0` still did not meet the acceptance target:
+
+| Query shape | Checkpointed warm timing | Plan |
+| --- | ---: | --- |
+| Ordered first-row projection | `3,199.05 ms` | `TableScan(table=Artist)` |
+| Exact normalized-name lookup | `3,373.36 ms` | `IndexSeek(table=Artist, index=IX_Artist_NameNormalized)` |
+| Exact alias lookup | `256.62 ms` | `IndexSeek(table=ArtistAlias, index=IX_ArtistAlias_NameNormalized)` |
+| Exact `MusicBrainzIdRaw` lookup | `3,417.25 ms` | `IndexSeek(table=Artist, index=IX_Artist_MusicBrainzIdRaw)` |
+
+DecentDB `2.12.0` still does not meet the target and appears worse for the two
+large `Artist` equality paths:
+
+| Query shape | Checkpointed warm timing | Plan |
+| --- | ---: | --- |
+| Ordered first-row projection | `2,918.39 ms` | `OrderedRowIdScan(table=Artist, column=Id)` |
+| Exact normalized-name lookup | `10,293.83 ms` | `IndexSeek(table=Artist, index=IX_Artist_NameNormalized)` |
+| Exact alias lookup | `855.80 ms` | `IndexSeek(table=ArtistAlias, index=IX_ArtistAlias_NameNormalized)` |
+| Exact `MusicBrainzIdRaw` lookup | `9,398.26 ms` | `IndexSeek(table=Artist, index=IX_Artist_MusicBrainzIdRaw)` |
+
+DecentDB `2.13.0` still does not meet the target:
+
+| Query shape | Checkpointed execution result | Plan |
+| --- | --- | --- |
+| Exact normalized-name lookup | Timed out after `180 s` | `IndexSeek(table=Artist, index=IX_Artist_NameNormalized)` |
+| Exact `MusicBrainzIdRaw` lookup | Timed out after `180 s` | `IndexSeek(table=Artist, index=IX_Artist_MusicBrainzIdRaw)` |
+
+The previous fixes were therefore insufficient. Selecting `IndexSeek` is
+necessary, but it is not enough.
+
+## Constraints
+
+Do not solve this in Melodee by shelling out to the DecentDB CLI.
+
+Melodee must use native .NET bindings for DecentDB access and maintenance:
+
+- `DecentDB.AdoNet`
+- `DecentDB.EntityFrameworkCore`
+- `DecentDB.EntityFrameworkCore.NodaTime`
+- `DecentDBMaintenance`
+
+Do not claim the issue is fixed only because `EXPLAIN` reports `IndexSeek`.
+That has already been proven and is still too slow.
+
+Do not accept a DecentDB change that causes broad performance regressions.
+A previous DecentDB attempt degraded benchmark performance by orders of
+magnitude. Any proposed fix must include targeted regression tests and must
+pass the DecentDB quality and benchmark checks listed below.
+
+## Required DecentDB Work
+
+Work in `/home/steven/src/github/decentdb`.
+
+Investigate why checkpointed large-table indexed equality remains
+multi-second even when `EXPLAIN` reports `IndexSeek`.
+
+Create DecentDB-side tests that reproduce the behavior without requiring the
+full Melodee repository. The tests should cover at least these cases:
+
+1. A large table with an indexed string column and millions of rows or a
+ scaled-down fixture that preserves the same execution bug.
+2. A highly selective exact string equality lookup that returns one row.
+3. A lookup with `ORDER BY` and `LIMIT`.
+4. A lookup without `ORDER BY` to isolate sort cost from index and row fetch
+ cost.
+5. A checkpoint and reopen cycle before timing the query.
+6. Verification that the selected plan is `IndexSeek`.
+7. Verification that execution time is request-safe and does not regress.
+
+If a full millions-of-rows unit test is too expensive for normal CI, create:
+
+- a small deterministic regression test that fails before the fix; and
+- a larger ignored/manual performance test or benchmark that can be run before
+ release.
+
+The large/manual test is still required before publishing a package that
+Melodee will treat as a candidate fix.
+
+## Investigation Areas
+
+Start with these hypotheses. They may be wrong, but the final answer must
+explain which were ruled out and why.
+
+- `IndexSeek` may find matching keys quickly but perform expensive row
+ materialization for each candidate.
+- `IndexSeek` may scan a much larger portion of the index than the predicate
+ implies.
+- Equality on string keys may be doing extra decode, collation, comparison,
+ allocation, or normalization work per row.
+- The `Sort(...)` above `IndexSeek` may force materialization of many rows
+ before `LIMIT`, even when the equality predicate is highly selective.
+- `Project(*)` may fetch large row payloads inefficiently from checkpointed
+ files.
+- Row lookup by row id from an index entry may be slow after checkpoint or
+ reopen.
+- WAL-backed and checkpointed files may use different access paths or caching
+ behavior.
+- The `Artist` table may expose a different problem than `ArtistAlias` because
+ of row count, row width, index layout, page locality, or payload size.
+- The `.coord` or checkpoint metadata path may add overhead when resolving
+ index entries to rows.
+- The query executor may not stop early enough for `LIMIT 1` after an
+ equality predicate.
+
+The final DecentDB fix should identify the actual root cause, not only adjust
+one Melodee-specific query.
+
+## Reproduction From Melodee
+
+Use this only as the final integration proof. The DecentDB repository should
+also get its own focused tests.
+
+From `/home/steven/src/github/melodee`, with DecentDB packages updated to the
+candidate package:
+
+```bash
+dotnet restore Melodee.sln
+dotnet build Melodee.sln --no-restore
+```
+
+Run a clean real-file import probe:
+
+```bash
+dotnet run -c Release --project benchmarks/Melodee.Benchmarks --no-restore -- \
+ musicbrainz-import-probe \
+ --storage /mnt/fileserver_db_storage/melodee/search-engine-storage/musicbrainz \
+ --db .tmp/decentdb-validation/musicbrainz.ddb \
+ --output .tmp/decentdb-validation/musicbrainz-import-probe.json \
+ --clean \
+ --sample-interval-ms 10000
+```
+
+Run a query probe before checkpoint:
+
+```bash
+dotnet run -c Release --project benchmarks/Melodee.Benchmarks --no-restore -- \
+ musicbrainz-query-probe \
+ --db .tmp/decentdb-validation/musicbrainz.ddb \
+ --output .tmp/decentdb-validation/musicbrainz-query-probe-wal-backed.json
+```
+
+Checkpoint through the native .NET binding. Do not use the DecentDB CLI for
+Melodee validation:
+
+```csharp
+await DecentDBMaintenance.CheckpointAsync(databasePath, cancellationToken);
+```
+
+Run the checkpointed query probe:
+
+```bash
+dotnet run -c Release --project benchmarks/Melodee.Benchmarks --no-restore -- \
+ musicbrainz-query-probe \
+ --db .tmp/decentdb-validation/musicbrainz.ddb \
+ --output .tmp/decentdb-validation/musicbrainz-query-probe-checkpointed.json
+```
+
+Extract timings:
+
+```bash
+jq -r '.Measurements[] |
+ [.Pass, .Name, (.ElapsedMilliseconds | tostring), (.RowCount | tostring),
+ (.PlanDiagnostics.Lines | join(" | "))] | @tsv' \
+ .tmp/decentdb-validation/musicbrainz-query-probe-checkpointed.json
+```
+
+## DecentDB Quality Gate
+
+Before claiming the DecentDB issue is fixed, run DecentDB's normal quality
+checks from `/home/steven/src/github/decentdb`:
+
+```bash
+python ./scripts/do-pre-commit-checks.py
+```
+
+Also run the relevant DecentDB test suites and targeted benchmarks for the
+changed runtime/index/storage path.
+
+Because an earlier DecentDB attempt caused severe performance regressions,
+also run the DecentDB benchmark coverage that can catch broad execution
+slowdowns, including the Rust baseline benchmarks when applicable:
+
+```bash
+cd /home/steven/src/github/decentdb/benchmarks/rust-baseline
+```
+
+Use the repository's benchmark instructions from there. Record the before and
+after results or explain why a benchmark could not be run.
+
+## Acceptance Criteria
+
+The DecentDB work is not complete until all of these are true:
+
+- DecentDB has a focused regression test that fails before the fix and passes
+ after the fix.
+- DecentDB has coverage for checkpoint and reopen behavior.
+- DecentDB proves `IndexSeek` is still selected for the relevant queries.
+- DecentDB proves the actual execution time is request-safe, not merely that
+ plan generation is fast.
+- DecentDB quality checks pass, including
+ `python ./scripts/do-pre-commit-checks.py`.
+- DecentDB benchmarks do not show broad performance regression.
+- Melodee consumes the candidate DecentDB package through NuGet.
+- Melodee restore and build pass.
+- Melodee's fresh real-file import, native checkpoint, and checkpointed query
+ probe show DDB-002 and DDB-003 are request-safe.
+- `design/DECENTDB_IMPROVEMENTS.md` is updated with the new package version,
+ exact timings, plan output, and the final status.
+- `docs/pages/changelog.md` is updated after Melodee validation.
+
+## What Not To Do
+
+Do not close this as fixed based only on a small in-memory or uncheckpointed
+database test.
+
+Do not close this as fixed based only on `EXPLAIN`.
+
+Do not close this as fixed based only on planner changes.
+
+Do not add a Melodee workaround that shells out to the DecentDB CLI.
+
+Do not add an unbounded fallback query in Melodee.
+
+Do not trade correctness or broad DecentDB performance for this one query
+shape.
+
+## Desired Final Report
+
+When the DecentDB agent finishes, provide a report with:
+
+- root cause;
+- files changed;
+- tests added;
+- benchmarks run;
+- before and after DecentDB timings;
+- before and after Melodee real-file timings;
+- any remaining risks;
+- exact DecentDB NuGet package version that Melodee should consume.
+
+If the root cause cannot be fixed in one pass, document the blocker precisely
+and keep DDB-002 and DDB-003 open.
diff --git a/design/docs/decentdb-large-text-search-strategy.md b/design/docs/decentdb-large-text-search-strategy.md
new file mode 100644
index 000000000..a495f661f
--- /dev/null
+++ b/design/docs/decentdb-large-text-search-strategy.md
@@ -0,0 +1,100 @@
+## DecentDB Large-Text Search Strategy
+
+**Date**: 2026-06-15
+**Status**: Needed and adopted for Melodee DecentDB-backed request paths
+
+## Decision
+
+Melodee will not use substring scans over large text or blob-like columns on
+normal DecentDB-backed request paths. Large text search must use one of these
+bounded shapes:
+
+- exact equality against indexed normalized columns
+- exact equality against dedicated normalized lookup tables
+- small, explicitly bounded fallback queries that are kept off hot request
+ paths
+- a separate full-text or token index if fuzzy search becomes a product
+ requirement
+
+This is a Melodee schema and query-shape decision. DecentDB provider full-text
+support may be useful in the future, but current Melodee scale work must not
+depend on provider substring optimization for request safety.
+
+This document is still needed because `design/DECENTDB_IMPROVEMENTS.md` uses it
+as the adopted guidance for DDB-009.
+
+## Rationale
+
+Real-file MusicBrainz probes showed that substring scans over alternate-name
+text are not viable on large request paths. The MusicBrainz search path follows
+the safer schema shape by using normalized artist names and the `ArtistAlias`
+lookup table instead of scanning an `AlternateNames` string.
+
+The published DecentDB `2.13.0` real-file equality timings were still too slow
+for hot request paths on the large `Artist` table. DDB-002 and DDB-003 consumed
+the published planner/provider fixes, and DecentDB `EXPLAIN` reported
+`IndexSeek`, but checkpointed warm `Artist.NameNormalized` and
+`Artist.MusicBrainzIdRaw` targeted probes timed out after `180 s`.
+
+DecentDB `2.13.1` has since validated the indexed equality paths through the
+published NuGet packages. This strategy document still defines the Melodee
+query shape that should be preserved for future package updates and
+regression checks.
+
+## Query Rules
+
+- Prefer normalized exact-match columns such as `NameNormalized` and
+ `MusicBrainzIdRaw`.
+- Store alternate names as one normalized row per searchable value when the
+ dataset can become large.
+- Apply `OrderBy` before `Take`, `Skip`, `First`, or `FirstOrDefault` when the
+ result shape is not naturally unique.
+- Load related data only after a bounded candidate set is known.
+- Do not add automatic `Contains(...)` fallback over large text fields.
+- If a fuzzy fallback is required, make it opt-in, bounded, instrumented, and
+ absent from normal ingestion or page-render paths.
+
+## Diagnostics
+
+Use the manual MusicBrainz query probe for DecentDB-backed query validation:
+
+```bash
+dotnet run -c Release --project benchmarks/Melodee.Benchmarks \
+ -- musicbrainz-query-probe \
+ --db /path/to/musicbrainz.ddb \
+ --output /tmp/musicbrainz-query-probe.json
+```
+
+The report includes cold and warm timings, row counts, generated SQL, DecentDB
+`EXPLAIN` output for the fixed probe shapes, configured index metadata, and the
+sample values used for the exact-name, exact-alias, and exact-MBID probes.
+The broad `ordered-first-row-existence` measurement is excluded by default; use
+`--include-row-existence` only when investigating broad table access outside
+normal request-path validation.
+
+Use the package-upgrade gate when validating a new DecentDB NuGet version:
+
+```bash
+./scripts/run-decentdb-package-upgrade-gate.sh \
+ --db /path/to/musicbrainz.ddb \
+ --label 2.13.1 \
+ --output-dir .tmp/decentdb-upgrade \
+ --name "$MUSICBRAINZ_SAMPLE_NAME" \
+ --alias "$MUSICBRAINZ_SAMPLE_ALIAS" \
+ --mbid "$MUSICBRAINZ_SAMPLE_MBID"
+```
+
+Use the real-file import probe when validating import-scale behavior:
+
+```bash
+dotnet run -c Release --project benchmarks/Melodee.Benchmarks \
+ -- musicbrainz-import-probe \
+ --storage /path/to/musicbrainz-storage \
+ --db /tmp/musicbrainz.ddb \
+ --output /tmp/musicbrainz-import-probe.json \
+ --clean
+```
+
+The import report captures phase timings, imported row counts reported by the
+streaming importer, process memory, Linux `VmRSS` and `VmHWM` when available,
+CPU time, and DecentDB file/WAL growth samples.
diff --git a/design/docs/decentdb-package-upgrade-validation-runbook.md b/design/docs/decentdb-package-upgrade-validation-runbook.md
new file mode 100644
index 000000000..f67a68067
--- /dev/null
+++ b/design/docs/decentdb-package-upgrade-validation-runbook.md
@@ -0,0 +1,147 @@
+## Melodee DecentDB Package-Upgrade Validation Runbook
+
+The runbook is Melodee-only and exists to validate candidate DecentDB
+package upgrades against real MusicBrainz data without modifying service code.
+
+## Scope
+
+This runbook supports:
+
+- Revalidating DDB-002 and DDB-003 on an existing checkpointed MusicBrainz `.ddb`.
+- Rebuilding a fresh checkpointed file and collecting import/query probes when
+ package changes require it.
+- Producing artifacts that can be attached to upstream or downstream package
+ regression issues.
+
+Do **not** use the DecentDB CLI or local DecentDB repository changes in this
+phase of validation.
+
+## Required inputs
+
+- `MUSICBRAINZ_DDB_PATH`: path to an existing checkpointed MusicBrainz file,
+ for example `.tmp/decentdb-validation/musicbrainz.ddb`.
+- `OUTPUT_DIR`: writable folder for probe artifacts.
+- `PACKAGE_LABEL`: short identifier for the DecentDB package version being tested.
+
+Optional inputs:
+
+- `MUSICBRAINZ_STAGING_PATH`: path to a staging MusicBrainz dump for fresh
+ import rebuild runs.
+- `MUSICBRAINZ_SAMPLE_NAME`: normalized artist name from a prior probe.
+- `MUSICBRAINZ_SAMPLE_ALIAS`: normalized artist alias from a prior probe.
+- `MUSICBRAINZ_SAMPLE_MBID`: raw MusicBrainz artist ID from a prior probe.
+- `MUSICBRAINZ_QUERY_JSON`: custom path for query probe output.
+- `MUSICBRAINZ_IMPORT_JSON`: custom path for import probe output.
+
+## Scripted execution
+
+Use this helper:
+
+```bash
+./scripts/run-decentdb-package-upgrade-gate.sh \
+ --db /path/to/musicbrainz.ddb \
+ --label 2.13.1 \
+ --output-dir .tmp/decentdb-upgrade \
+ --name "$MUSICBRAINZ_SAMPLE_NAME" \
+ --alias "$MUSICBRAINZ_SAMPLE_ALIAS" \
+ --mbid "$MUSICBRAINZ_SAMPLE_MBID"
+```
+
+Optional fresh rebuild path:
+
+```bash
+./scripts/run-decentdb-package-upgrade-gate.sh \
+ --db /tmp/musicbrainz.ddb \
+ --storage /mnt/fileserver_db_storage/melodee/search-engine-storage/musicbrainz \
+ --label 2.13.1-candidate \
+ --output-dir .tmp/decentdb-upgrade
+```
+
+## Command sequence (manual equivalent)
+
+1. Build once before repeated probes:
+
+```bash
+dotnet restore Melodee.sln
+dotnet build Melodee.sln --no-restore
+```
+
+2. Run optional import probe for a fresh file (skip when using an existing
+ checkpointed `.ddb`):
+
+```bash
+dotnet run -c Release --project benchmarks/Melodee.Benchmarks -- \
+ musicbrainz-import-probe \
+ --storage /path/to/musicbrainz \
+ --db /tmp/musicbrainz.ddb \
+ --output /tmp/musicbrainz-import-probe.json \
+ --clean \
+ --sample-interval-ms 10000
+```
+
+3. Run query probe on the checkpointed target:
+
+```bash
+dotnet run -c Release --project benchmarks/Melodee.Benchmarks -- \
+ musicbrainz-query-probe \
+ --db /tmp/musicbrainz.ddb \
+ --output /tmp/musicbrainz-query-probe-checkpointed.json \
+ --name "$MUSICBRAINZ_SAMPLE_NAME" \
+ --alias "$MUSICBRAINZ_SAMPLE_ALIAS" \
+ --mbid "$MUSICBRAINZ_SAMPLE_MBID"
+```
+
+Use `--include-row-existence` only while investigating broad table access. The
+DDB-002/DDB-003 gate intentionally excludes that measurement by default because
+it is not a request-safe search shape.
+
+## Gate acceptance criteria
+
+Gate output must satisfy all of these for DDB-002 and DDB-003:
+
+| Item | Target |
+| --- | --- |
+| Targeted warm `Artist.NameNormalized` lookup | `IndexSeek` plan and non-timeout execution |
+| Targeted warm `Artist.MusicBrainzIdRaw` lookup | `IndexSeek` plan and non-timeout execution |
+| Warm execution time (both rows above) | Request-safe target (<= 1000 ms) |
+| `Exact alias` behavior | Captured and materially faster than prior checkpointed regressions |
+| Database open / probe report | Probe report is written and includes row counts |
+
+This runbook intentionally keeps DDB-002/DDB-003 acceptance focused on
+request-safe behavior against **checkpointed real-file** data rather than
+WAL-backed-only outputs.
+
+### Planned follow-up for non-hot-path search
+
+- Do not use automatic fuzzy or substring matches on the large `Artist` table in hot
+ request paths.
+- Keep fuzzy/full-text behavior as explicit future work:
+ bounded candidate extraction, separate indexed schema, and explicit cost
+ guardrails.
+
+## Checkpoint/WAL scheduling notes
+
+- Perform checkpointing as a native maintenance step after import completion in
+ Melodee; checkpointing is required before asserting DDB-002/DDB-003 request
+ safety for production-scale data.
+- Checkpointed artifacts should show expected WAL reduction and stable query timing
+ before declaring a package-level PASS.
+- If `.wal` remains large, run the query probe anyway for investigation, but
+ require checkpointed/reopened artifacts for acceptance.
+- Keep maintenance native to Melodee and DecentDB .NET bindings (`DecentDBMaintenance`
+ helpers); avoid external CLI or repository-level changes.
+
+Example maintenance contract:
+
+```csharp
+await DecentDBMaintenance.CheckpointAsync(databasePath, cancellationToken);
+```
+
+## Failure handling
+
+If the gate fails:
+
+- Keep existing DecentDB package versions unchanged in Melodee.
+- Attach the produced import/query JSON, command output, and `dotnet` logs to the
+ upstream ticket.
+- Re-run the same named outputs on the next candidate package.
diff --git a/design/docs/decentdb-provider-enhancements.md b/design/docs/decentdb-provider-enhancements.md
new file mode 100644
index 000000000..aa0cd7064
--- /dev/null
+++ b/design/docs/decentdb-provider-enhancements.md
@@ -0,0 +1,215 @@
+## DecentDB Provider Enhancement Candidates
+
+**Date**: 2026-06-15
+**Status**: Updated after DecentDB 2.13.1 validation; DDB-002 and DDB-003
+completed
+
+## Context
+
+Melodee now references `DecentDB.AdoNet` `2.13.1`,
+`DecentDB.EntityFrameworkCore` `2.13.1`, and
+`DecentDB.EntityFrameworkCore.NodaTime` `2.13.1`. The large real-file import
+and query timings in `design/DECENTDB_IMPROVEMENTS.md` were rerun against
+DecentDB `2.13.0` and then revalidated against DecentDB `2.13.1` on
+2026-06-15.
+
+The current DecentDB packages include the post-`2.9.0` fixes for Melodee's
+original provider-follow-up items: ordered indexed string equality remains on
+the native executor fast path, EF Core has regression coverage for Melodee's
+`Where(...).OrderBy(...).Take(...)` query shape, and the ADO.NET bindings
+expose native index rebuild helpers. The published `2.13.0` package still did
+not satisfy DDB-002 or DDB-003 because checkpointed `Artist` table indexed
+equality timed out even with captured `IndexSeek` plans.
+
+The DecentDB `2.13.1` package removes open-time eager all-index hydration and
+adds a bounded process cache for deferred runtime B-tree indexes plus paged row
+locators. Melodee validation against the real checkpointed MusicBrainz file now
+shows warm `Artist.NameNormalized`, `ArtistAlias.NameNormalized`, and
+`Artist.MusicBrainzIdRaw` equality paths under `1 ms` with `IndexSeek`.
+
+This document is still needed as provider context for future DecentDB package
+regression checks. The Melodee side now has repeatable manual probes:
+
+```bash
+dotnet run -c Release --project benchmarks/Melodee.Benchmarks \
+ -- musicbrainz-import-probe \
+ --storage /path/to/musicbrainz-storage \
+ --db /tmp/musicbrainz.ddb \
+ --output /tmp/musicbrainz-import-probe.json \
+ --clean
+```
+
+```bash
+dotnet run -c Release --project benchmarks/Melodee.Benchmarks \
+ -- musicbrainz-query-probe \
+ --db /tmp/musicbrainz.ddb \
+ --output /tmp/musicbrainz-query-probe.json
+```
+
+## Melodee Package-Upgrade Validation Gate
+
+Use the new internal runbook and helper for package-upgrade checks:
+
+- `design/docs/decentdb-package-upgrade-validation-runbook.md`
+- `scripts/run-decentdb-package-upgrade-gate.sh`
+
+The helper performs:
+
+- optional fresh import probe capture (from staging data)
+- checkpointed query probe capture against an existing `.ddb`
+- DDB-002/DDB-003 warm-query gate checks for `IndexSeek` + request-safe timings
+- optional explicit sample reuse (`--name`, `--alias`, `--mbid`) so package
+ validation can avoid broad sampling work
+- consistent output layout for attachment to future provider regressions
+
+This is a Melodee-only gating helper; it does not alter production workloads
+or depend on DecentDB CLI usage.
+
+These probes give reproducible inputs for provider issues without requiring
+ad hoc scripts.
+
+## Melodee Warm-Up And Request-Path Guardrails
+
+Melodee now warms the large MusicBrainz DecentDB indexes through native .NET
+queries after Blazor startup and after a successful MusicBrainz database
+promotion. The warm-up is opportunistic and non-fatal; if it cannot complete,
+search remains available and the same indexes warm on demand.
+
+The warmed query shapes intentionally match request-safe repository behavior:
+
+- exact `Artist.NameNormalized` equality
+- exact `Artist.MusicBrainzIdRaw` equality
+- exact `ArtistAlias.NameNormalized` equality
+- bounded albums by `MusicBrainzArtistId`
+
+The broad `ordered-first-row-existence` measurement remains available for
+investigation with `musicbrainz-query-probe --include-row-existence`, but it is
+not part of normal package gate acceptance.
+
+## Enhancement List
+
+Items marked published below are available to Melodee through the DecentDB
+`2.13.1` .NET bindings.
+
+### Native .NET Checkpoint And Maintenance APIs
+
+Status:
+
+- Published in DecentDB `2.9.0`: checkpoint, vacuum, compact, and WAL-status
+ helpers.
+- Published in DecentDB `2.10.0`: binding-native index rebuild helpers.
+- Melodee now checkpoints imported MusicBrainz databases through
+ `DecentDBMaintenance.CheckpointAsync(...)` and does not execute an external
+ DecentDB process.
+
+Useful API shapes added upstream:
+
+```csharp
+await DecentDBMaintenance.CheckpointAsync(databasePath, cancellationToken);
+await DecentDBMaintenance.VacuumAsync(databasePath, cancellationToken);
+await DecentDBMaintenance.CompactAsync(sourcePath, targetPath, cancellationToken);
+await DecentDBMaintenance.RebuildIndexAsync(databasePath, indexName, cancellationToken);
+await DecentDBMaintenance.RebuildIndexesAsync(databasePath, cancellationToken);
+```
+
+The rebuild helpers give Melodee a native maintenance surface if future
+validation proves that a targeted rebuild workflow is useful, but they did not
+make DDB-002 or DDB-003 complete by themselves.
+
+### WAL Checkpoint Visibility
+
+Current observation:
+
+- The DecentDB `2.13.0` real-file import finished with the main `.ddb` file at
+ `8 KB` and the WAL at roughly `5.0 GiB`.
+- Native ADO.NET checkpoint reduced the WAL to `32 B` and grew the main `.ddb`
+ file to roughly `2.4 GiB`, but the checkpoint took about `636 s`.
+
+Requested enhancement:
+
+- Implemented in DecentDB `2.9.0`: `DecentDBMaintenance.GetWalStatus(...)`
+ and checkpoint results expose before/after WAL sizes through ADO.NET.
+- Document `WalAutoCheckpoint` behavior and the connection string settings that
+ affect large import workloads.
+
+### Indexed String Equality Performance
+
+Current observation from the published DecentDB `2.13.0` rebuilt and
+checkpointed MusicBrainz file:
+
+- `Artist.NameNormalized == value` targeted probe: timed out after `180 s`.
+- `Artist.MusicBrainzIdRaw == value` targeted probe: timed out after `180 s`.
+- The affected columns have EF model indexes, generated SQL is captured by the
+ query probe, and DecentDB `EXPLAIN` reports `IndexSeek` for the tested
+ equality shapes.
+- The remaining package issue is not index selection: the probe captures
+ `IndexSeek` plans while execution does not complete in a request-safe window
+ on the checkpointed large `Artist` table.
+
+DecentDB `2.13.1` package observation:
+
+- `Database.CanConnectAsync()`: `16.57 ms` cold.
+- `Artist.NameNormalized`: `9,996 ms` cold first-use hydration and `0.66 ms`
+ warm.
+- `ArtistAlias.NameNormalized`: `780 ms` cold and `0.80 ms` warm.
+- `Artist.MusicBrainzIdRaw`: `11.04 ms` cold and `0.56 ms` warm.
+- All three indexed equality plans use `IndexSeek`.
+
+Requested enhancement:
+
+- Published in DecentDB `2.9.0`: .NET provider regression coverage for indexed
+ string equality translation.
+- Published in DecentDB `2.10.0`: the native executor accepts simple
+ `ORDER BY`, `LIMIT`, and `OFFSET` clauses for indexed equality projections,
+ and EF Core regression coverage executes Melodee's
+ `Where(...).OrderBy(...).Take(1)` shape.
+- Published in DecentDB `2.13.1`: DecentDB keeps deferred-table open lazy,
+ hydrates only the requested secondary index on first use, and reuses that
+ runtime index plus paged row locators across short-lived connections in the
+ same process.
+
+### Query Plan And Index Diagnostics
+
+Current observation:
+
+- `ToQueryString()` gives useful SQL shape for EF queries.
+- Melodee's manual query probe now records both EF-generated SQL and DecentDB
+ `EXPLAIN` output for the fixed probe shapes.
+
+Requested enhancement:
+
+- Implemented in DecentDB `2.9.0`: ADO.NET exposes an opt-in query-plan helper.
+- Implemented in Melodee: `musicbrainz-query-probe` records generated SQL,
+ elapsed time, row counts, sample values, EF model index metadata, and
+ structured plan diagnostics from the DecentDB ADO.NET query-plan helper.
+- Keep plan diagnostics opt-in and safe for app-level probe output.
+
+### Large Text Search Guidance Or Full-Text Support
+
+Current observation:
+
+- Melodee removed substring scans from large request paths and uses normalized
+ lookup tables instead.
+- This is safe, but it pushes fuzzy search responsibility to application
+ schema design.
+
+Requested enhancement:
+
+- Document DecentDB's recommended pattern for large substring or fuzzy text
+ workloads.
+- If provider support is planned, expose token/full-text indexes with clear
+ query-shape guidance.
+
+### Database Format Compatibility Diagnostics
+
+Current observation:
+
+- An existing MusicBrainz database failed to open with
+ `unsupported database format version: 11` after the DecentDB `2.8.0` upgrade.
+
+Requested enhancement:
+
+- DecentDB `2.9.0` improves the ADO.NET open failure message for unsupported
+ file format versions.
+- Provide clearer upgrade guidance for old file format versions.
+- Expose a supported upgrade or migration path when possible.
diff --git a/docs/VERSION b/docs/VERSION
index 7ec1d6db4..ac2cdeba0 100644
--- a/docs/VERSION
+++ b/docs/VERSION
@@ -1 +1 @@
-2.1.0
+2.1.3
diff --git a/docs/_config.yml b/docs/_config.yml
index 7df690ef8..1a32f82bf 100644
--- a/docs/_config.yml
+++ b/docs/_config.yml
@@ -47,11 +47,13 @@ version_params:
# Allow these versions to be searched
search_versions:
+ - 2.1.0
- 2.0.0
- 1.8.0
- 1.7.0
- latest: 2.0.0
+ latest: 2.1.0
versions:
+ - 2.1.0
- 2.0.0
- 1.8.0
- 1.7.0
diff --git a/docs/_data/navigation.yml b/docs/_data/navigation.yml
index 14cca752e..796955b14 100644
--- a/docs/_data/navigation.yml
+++ b/docs/_data/navigation.yml
@@ -1,6 +1,6 @@
- title: About
url: about
-- title: Documentation
- url: docs
- title: Homelab
url: homelab
+- title: News
+ url: news
diff --git a/docs/_data/toc.yml b/docs/_data/toc.yml
index 76a3270f4..585854a3f 100644
--- a/docs/_data/toc.yml
+++ b/docs/_data/toc.yml
@@ -20,6 +20,8 @@
url: "hardware"
- title: "Backup & Recovery"
url: "backup"
+ - title: "DecentDB Usage & Migration"
+ url: "decentdb"
- title: Core Concepts
links:
diff --git a/docs/_posts/2026-05-24-melodee-2-1-0-released.md b/docs/_posts/2026-05-24-melodee-2-1-0-released.md
index 0b32079e6..478278340 100644
--- a/docs/_posts/2026-05-24-melodee-2-1-0-released.md
+++ b/docs/_posts/2026-05-24-melodee-2-1-0-released.md
@@ -13,7 +13,7 @@ Melodee 2.1.0 is here! This release focuses on performance, developer experience
## What's New in 2.1.0
### SkiaSharp Image Processing
-All image processing has migrated from `SixLabors.ImageSharp` to `SkiaSharp`, replacing the legacy library with a more performant and actively maintained alternative.
+All image processing has migrated to `SkiaSharp`, replacing the legacy library with a more performant and actively maintained alternative.
- New `IImageProcessor` abstraction for decode, encode, resize, format detection, and hash generation
- Clean dependency injection throughout services, Blazor components, CLI commands, and tests
diff --git a/docs/pages/changelog.md b/docs/pages/changelog.md
index 4c52e6612..61ef6e834 100644
--- a/docs/pages/changelog.md
+++ b/docs/pages/changelog.md
@@ -21,20 +21,167 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
+## [Unreleased]
+
+### Added
+
+- Added public DecentDB usage and migration documentation covering Melodee's
+ generated search databases and rebuild steps for unsupported file-format
+ errors.
+
+### Fixed
+
+- Admin dashboard and login health warnings now open-check MusicBrainz and
+ ArtistSearch DecentDB files and link to the migration guide when unsupported
+ DecentDB file-format versions are detected.
+
+## [2.1.3] - 2026-06-15
+
+### Added
+
+- Added MusicBrainz DecentDB index warm-up after Blazor startup and after
+ successful MusicBrainz database promotion, using native .NET queries against
+ the request-path indexed lookup shapes.
+- Added an internal DecentDB package-upgrade validation gate and runbook for
+ repeatable DDB-002/DDB-003 checks against checkpointed MusicBrainz data.
+
+### Changed
+
+- Upgraded `DecentDB.AdoNet`, `DecentDB.EntityFrameworkCore`, and
+ `DecentDB.EntityFrameworkCore.NodaTime` to `2.13.1`.
+- MusicBrainz query probes now keep the broad ordered first-row existence
+ measurement opt-in via `--include-row-existence` so default validation stays
+ focused on request-safe indexed lookups.
+- Replaced the legacy IdSharp metadata fallback with Melodee's native ID3 tag
+ reader, removing the obsolete transitive image-processing dependency path.
+- Added explicit private `MessagePack` references for NBomber consumers so the
+ test and benchmark graphs resolve the non-vulnerable package version.
+
+### Fixed
+
+- Validated DecentDB `2.13.1` NuGet packages against the checkpointed real
+ MusicBrainz query probe, completing DDB-002 and DDB-003 with `IndexSeek`
+ plans and sub-millisecond warm indexed equality timings.
+- MusicBrainz DecentDB startup warm-up no longer runs the alias-by-artist
+ bounded query shape that DecentDB `2.13.1` rejects with a missing-parameter
+ error.
+
+## [2.1.2] - 2026-06-10
+
+### Added
+
+- Added DecentDB MusicBrainz import and query probes for real-file performance diagnostics,
+ including JSON phase timings, row counts, memory samples, WAL growth, SQL shape,
+ DecentDB `EXPLAIN` output, and cold/warm lookup timings.
+- Added internal DecentDB search strategy and provider enhancement notes covering ADO.NET
+ maintenance APIs, WAL visibility, query diagnostics, large indexed string equality, and
+ large-text search guidance.
+
+### Changed
+
+- Upgraded `DecentDB.AdoNet`, `DecentDB.EntityFrameworkCore`, and
+ `DecentDB.EntityFrameworkCore.NodaTime` to `2.13.0`.
+- Recorded DecentDB `2.13.0` real-file MusicBrainz validation. The package
+ provides indexed equality `EXPLAIN` plans for the tested query shapes, but
+ DDB-002 and DDB-003 remain provider follow-up because checkpointed large
+ `Artist` table equality probes still time out.
+- Validated a local DecentDB worktree fix for DDB-002 and DDB-003 through
+ direct local binding binaries; the items remain pending until a published
+ DecentDB NuGet package reproduces the real-file probe results.
+- MusicBrainz DecentDB imports now return materialized row counts and keep final full-table
+ verification counts opt-in, avoiding redundant full-table counts during normal imports.
+- Local artist cache lookups now use staged exact identifier, normalized name, and normalized
+ alias queries, with database-side paging for artist list requests.
+- Local artist aliases now use a normalized lookup table that is backfilled on startup for
+ existing cache data and synchronized when cached artists change.
+
+### Fixed
+
+- Release editing no longer triggers EasyMDE's Font Awesome CDN stylesheet load,
+ preventing Content Security Policy violations in the browser console.
+- The Blazor shell now loads the EasyMDE script only once.
+- Admin dashboard doctor checks no longer emit Entity Framework warnings for
+ unordered row-limiting probes.
+- Exact MusicBrainz ID lookups now apply deterministic ordering before row limiting.
+- DecentDB improvement tracking now separates completed Melodee changes from provider
+ enhancement candidates.
+- DecentDB improvement tracking now distinguishes the DecentDB `2.13.0`
+ planner/provider fix from the remaining large-file runtime/storage follow-up.
+- MusicBrainz database imports now checkpoint through the DecentDB
+ `DecentDBMaintenance.CheckpointAsync(...)` API instead of an external process.
+
+## [2.1.1] - 2026-05-25
+
+### Changed
+
+- Updated the docs release dropdown so the latest documentation track points to `2.1.0`, while patch releases continue to use the `2.1.x` application version line.
+- Replaced the top-navbar `Documentation` link with `News` so release posts are easier to find from the docs site.
+- Expanded `mcli library scan` storage-transfer reporting to separate ready albums, newly moved albums, albums merged with existing storage, duplicate-prefixed staging directories, failed metadata loads, and albums left in staging with their validation reason counts.
+- Added `mcli library scan` performance reporting for artist lookup cache behavior, conversion time, copy time, revalidation skips, and DecentDB artist-search persistence retry counts.
+- `mcli library scan` now shows live progress messages and item counts for inbound processing, staging revalidation, storage transfer, and database insert work instead of leaving active steps at an apparent 0%.
+- `mcli library scan` now suppresses ATL library stack traces during progress rendering and reports non-fatal inbound processing errors as scan warnings instead of letting raw exception text corrupt the TUI.
+- Artist search database read/open errors, including non-retryable DecentDB provider failures, are now counted in full-scan performance output and reported as scan warnings.
+- `mcli doctor` now validates DecentDB files by checking file presence, opening the configured database, inspecting expected schema tables, and running read queries instead of relying on shallow connection checks.
+- Bounded concurrent media conversions during inbound processing to reduce CPU and disk saturation on large batch scans.
+- Staging artist revalidation now uses a staging-local `.melodee-revalidation.ddb` retry state database so repeated scans defer recently failed albums instead of re-querying every invalid staged release on every run; the state database is recreated automatically if missing or corrupt.
+- Staging artist revalidation now logs the retry state database path and row counts, and `mcli library scan` reports artist lookup attempts and no-match counts for revalidation work.
+- MusicBrainz DecentDB artist searches now report phase timings and use a compact release/alias loading path for one-result ingestion lookups.
+
+### Fixed
+
+- Prevent inbound processing from deleting release directories through directory event scripts; releases now remain available for conversion, normalization, validation, and staging according to the documented ingestion pipeline.
+- Preserve copied cover images in staged album metadata after inbound processing renames images to Melodee's normalized `i-##-Type.jpg` filenames.
+- Revalidation during the full scan workflow now bypasses same-run negative artist lookup cache entries, allowing albums to become valid when artist metadata is discovered later in the ingestion pass.
+- iTunes artist matches now count as trusted artist identities alongside Spotify and MusicBrainz matches, allowing iTunes-only artist lookups to validate staged albums and participate in storage insert matching.
+- Staging revalidation now clears stale invalid-artist statuses when an album already contains a trusted artist identity, and iTunes-only artist IDs can be used for storage directory naming.
+- Dashboard loading now updates the layout spinner through the layout notification event and clears the global spinner when leaving the page.
+- Inbound move-mode processing now removes source sidecar metadata files such as `.sfv`, `.nfo`, `.m3u`, `.cue`, and Blackbeard provenance after albums are staged, including metadata-only directories left by earlier runs.
+- Inbound staging now tracks media files converted during processing, so NFO-derived albums that start as FLAC are staged from the converted MP3 files instead of leaving converted songs behind in `inbound`.
+- Storage-transfer chaining now treats albums merged into existing storage directories as handled work, so the next ingestion step can continue after merge-only batches.
+- Full-scan artist lookup work now shares one run-scoped cache across inbound processing and staging revalidation, including forced revalidation lookups.
+- Artist search persistence now retries transient DecentDB transaction conflicts while surfacing non-retryable DecentDB provider errors without retrying them.
+- Compound release artists such as `Artist One feat. Artist Two` now get conservative fallback artist lookups when the fallback candidate has trusted identity data and matching release evidence.
+- Staging revalidation now skips albums whose artist metadata is blank or obviously unsearchable instead of repeatedly calling external artist providers.
+- Forced staging revalidation no longer expands compound artist names into multiple fallback provider searches, preventing `mcli library scan` from appearing hung on batches of invalid collaboration artists.
+- Move-mode inbound cleanup now removes source residue files such as release artwork and `.txt` notes only after media files are gone, allowing empty inbound release directories to be removed without deleting unprocessed media.
+- Inbound processing now defers directories whose files are still changing instead of partially staging releases while the source copy is still in progress.
+- Media conversion now accepts a valid generated MP3 when ffmpeg produced usable output but ATL reports an unexpected format label, preventing converted tracks from being stranded in inbound.
+- External artist provider searches now honor bounded requested result limits instead of requesting unbounded provider result sets during forced lookups.
+- iTunes artist searches now deserialize large Apple artist, collection, AMG, and genre identifiers without failing, and artist image searches correctly send the requested result limit.
+- NFO parsing now ignores malformed track lines and missing artist metadata without emitting parser stack traces.
+- Inbound staging now reports missing staged files once per album when skipping tag updates instead of generating repeated per-song update warnings.
+
## [2.1.0] - 2026-05-24
### Changed
-- **Replaced `SixLabors.ImageSharp` with `SkiaSharp`** for all image processing operations. A new `IImageProcessor` abstraction centralizes decode, encode, resize, format identification, and average-hash computation. `ImageHasher`, `ImageConvertor`, and `ImageValidator` now receive `IImageProcessor` via dependency injection rather than using static library calls. All services, Blazor components, CLI commands, and test constructors were updated consistently. SkiaSharp native assets are included conditionally (`SkiaSharp.NativeAssets.Linux` on Linux) so builds work across platforms without extra runtime dependencies.
+- **Replaced the legacy image processing library with `SkiaSharp`** for all image processing operations.
+ A new `IImageProcessor` abstraction centralizes decode, encode, resize, format
+ identification, and average-hash computation. `ImageHasher`, `ImageConvertor`, and
+ `ImageValidator` now receive `IImageProcessor` via dependency injection rather than
+ using static library calls. All services, Blazor components, CLI commands, and test
+ constructors were updated consistently. SkiaSharp native assets are included
+ conditionally (`SkiaSharp.NativeAssets.Linux` on Linux) so builds work across
+ platforms without extra runtime dependencies.
- Set min-width on album detail action column for layout stability
- Remove unnecessary EnsureArtistAliasTableAsync call in MusicBrainz repository
- Dashboard data loading moved from `OnInitializedAsync` to `OnAfterRenderAsync` so skeleton placeholders render immediately instead of blocking the initial page render.
- Bulk delete operations in `SongService`, `AlbumService`, and `ArtistService` now batch-load entities in a single query instead of executing N+1 queries per item, significantly improving performance for large deletions.
- Quartz job scheduling extracted into `QuartzSchedulerExtensions.ScheduleJobIfConfigured` helper method, reducing `Program.cs` from 1,046 to ~860 lines and eliminating ~150 lines of repetitive scheduling code.
-- **Squashed 55 EF Core migrations into a single `InitialBaseline` migration.** The migration history (107 files spanning Feb 2025 – Jan 2026) has been consolidated into one baseline file that generates the complete current schema. This reduces repository size, speeds up CI builds, and eliminates fragile migration chains. Existing databases that have already applied the latest migration are unaffected; new setups will apply only the single baseline.
+- **Squashed 55 EF Core migrations into a single `InitialBaseline` migration.** The
+ migration history (107 files spanning Feb 2025 – Jan 2026) has been consolidated into
+ one baseline file that generates the complete current schema. This reduces repository
+ size, speeds up CI builds, and eliminates fragile migration chains. Existing databases
+ that have already applied the latest migration are unaffected; new setups will apply
+ only the single baseline.
- Added `.kilo/` project configuration with slash commands (`/build`, `/test`, `/test-mql`, `/lint`, `/migrate`, `/coverage`) and a project-aware `melodee-developer` agent for consistent developer workflows.
- Dropped JavaScript/TypeScript from CodeQL analysis — the repository contains only minimal JS files (jQuery, lunr.js in docs site), and scanning them wasted ~5–10 minutes per CI run with no security value.
-- **Refactored `PartyModeService` to call domain services directly instead of making HTTP requests to the same application.** Replaced `HttpClient` with `PartySessionService`, `PartyQueueService`, `PartyPlaybackService`, and `PartySessionEndpointRegistryService` via dependency injection. User identity resolved through `IAuthService.CurrentUser` rather than cookie auth. Eliminates ~20 HTTP round-trips per user interaction (create, join, leave, queue, playback, endpoints) in party mode Blazor components.
+- **Refactored `PartyModeService` to call domain services directly instead of making HTTP
+ requests to the same application.** Replaced `HttpClient` with `PartySessionService`,
+ `PartyQueueService`, `PartyPlaybackService`, and `PartySessionEndpointRegistryService`
+ via dependency injection. User identity resolved through `IAuthService.CurrentUser`
+ rather than cookie auth. Eliminates ~20 HTTP round-trips per user interaction (create,
+ join, leave, queue, playback, endpoints) in party mode Blazor components.
### Security
diff --git a/docs/pages/cli/doctor.md b/docs/pages/cli/doctor.md
index 3ff005992..6e2132e50 100644
--- a/docs/pages/cli/doctor.md
+++ b/docs/pages/cli/doctor.md
@@ -29,8 +29,8 @@ mcli doctor [OPTIONS]
- Validates required connection strings exist
2. **Database connectivity**
- Postgres (`DefaultConnection`)
- - MusicBrainz SQLite (`MusicBrainzConnection`)
- - ArtistSearchEngine SQLite (`ArtistSearchEngineConnection`)
+ - MusicBrainz DecentDB (`MusicBrainzConnection`)
+ - ArtistSearchEngine DecentDB (`ArtistSearchEngineConnection`)
3. **Library paths**
- Ensures each configured library path exists
- Optionally validates write access with `--write-test`
@@ -60,8 +60,8 @@ mcli doctor [OPTIONS]
```
✓ Configuration
✓ Database: Postgres
-✓ Database: MusicBrainz (SQLite)
-✓ Database: ArtistSearchEngine (SQLite)
+✓ Database: MusicBrainz (DecentDB)
+✓ Database: ArtistSearchEngine (DecentDB)
✓ Libraries
All checks passed.
diff --git a/docs/pages/decentdb.md b/docs/pages/decentdb.md
new file mode 100644
index 000000000..1080d3311
--- /dev/null
+++ b/docs/pages/decentdb.md
@@ -0,0 +1,218 @@
+---
+title: DecentDB Usage & Migration
+description: How Melodee uses DecentDB, how to diagnose compatibility issues, and how to rebuild generated DecentDB search databases.
+permalink: /decentdb/
+tags:
+ - decentdb
+ - migration
+ - troubleshooting
+---
+
+## Overview
+
+Melodee uses DecentDB for local, generated search databases. These files are
+separate from the primary Melodee PostgreSQL database and can be rebuilt from
+source data when necessary.
+
+DecentDB is currently used for:
+
+- **MusicBrainz search data**: the local MusicBrainz artist, alias, relation,
+ and album lookup database.
+- **Artist search cache**: the local artist search repository used to speed up
+ artist matching and enrichment.
+
+Melodee does not store user accounts, playlists, play history, ratings, or
+library metadata in these DecentDB files. Those records live in the primary
+PostgreSQL database.
+
+## Configuration
+
+The DecentDB databases are configured with these connection strings:
+
+| Connection string | Purpose |
+|-------------------|---------|
+| `MusicBrainzConnection` | Local MusicBrainz lookup database |
+| `ArtistSearchEngineConnection` | Local artist search cache database |
+
+In container deployments, the same values can be supplied with environment
+variables:
+
+```bash
+ConnectionStrings__MusicBrainzConnection="Data Source=/app/storage/_search-engines/musicbrainz/musicbrainz.ddb"
+ConnectionStrings__ArtistSearchEngineConnection="Data Source=/app/storage/_search-engines/artistSearchEngine.ddb"
+```
+
+Use your configured paths as the source of truth. Older installations may use
+`.db` filenames while newer examples use `.ddb`; the connection string path is
+what matters.
+
+## Doctor Compatibility Checks
+
+Doctor opens both DecentDB files with the current Melodee DecentDB provider. If
+the file was created by a newer or incompatible DecentDB engine, Doctor reports
+an issue similar to:
+
+```text
+MusicBrainz DecentDB database uses a file format that is not supported by the current DecentDB provider.
+Provider error: unsupported DecentDB file format version 11
+```
+
+This means the current Melodee process cannot safely read that generated search
+database. Search and enrichment may continue in degraded mode, but the affected
+database should be rebuilt or the Melodee/DecentDB package version should be
+updated.
+
+## Migration Strategy
+
+For Melodee's generated DecentDB files, migration usually means rebuilding the
+affected generated database with the current Melodee version. This is safer than
+trying to manually edit a DecentDB file created by an incompatible provider.
+
+Use this order:
+
+1. Upgrade Melodee to the intended version.
+2. Stop Melodee so no process is writing to the DecentDB files.
+3. Back up the affected `.ddb` file and any companion WAL or shared-memory
+ files.
+4. Move the incompatible generated database out of the active path.
+5. Start Melodee.
+6. Rebuild the affected database from Melodee.
+7. Run Doctor again and confirm the DecentDB checks pass.
+
+## Backup Before Rebuild
+
+Back up the main database and generated DecentDB files before changing anything.
+The example below preserves common DecentDB companion file names.
+
+```bash
+#!/usr/bin/env bash
+set -euo pipefail
+
+backup_root="$HOME/melodee-decentdb-backup-$(date +%Y%m%d-%H%M%S)"
+mkdir -p "$backup_root"
+
+musicbrainz_db="/path/to/search-engine-storage/musicbrainz/musicbrainz.ddb"
+artist_search_db="/path/to/search-engine-storage/artistSearchEngine.ddb"
+
+backup_decentdb_file() {
+ local db_path="$1"
+ local db_name
+ db_name="$(basename "$db_path")"
+
+ for suffix in "" ".wal" "-wal" ".shm" "-shm"; do
+ if [ -f "${db_path}${suffix}" ]; then
+ cp -a "${db_path}${suffix}" "$backup_root/${db_name}${suffix}"
+ fi
+ done
+}
+
+backup_decentdb_file "$musicbrainz_db"
+backup_decentdb_file "$artist_search_db"
+
+echo "Backed up DecentDB files to $backup_root"
+```
+
+Also back up PostgreSQL before a Melodee upgrade. See
+[Backup & Recovery](/backup/) for full backup guidance.
+
+## Example: Rebuild Incompatible DecentDB Files
+
+This example renames incompatible generated DecentDB files so Melodee can create
+fresh files with the current provider.
+
+```bash
+#!/usr/bin/env bash
+set -euo pipefail
+
+stamp="$(date +%Y%m%d-%H%M%S)"
+
+musicbrainz_db="/path/to/search-engine-storage/musicbrainz/musicbrainz.ddb"
+artist_search_db="/path/to/search-engine-storage/artistSearchEngine.ddb"
+
+move_decentdb_file_aside() {
+ local db_path="$1"
+
+ for suffix in "" ".wal" "-wal" ".shm" "-shm"; do
+ if [ -f "${db_path}${suffix}" ]; then
+ mv "${db_path}${suffix}" "${db_path}${suffix}.unsupported-$stamp"
+ fi
+ done
+}
+
+# Stop Melodee before moving active database files.
+# podman compose down
+# docker compose down
+
+move_decentdb_file_aside "$musicbrainz_db"
+move_decentdb_file_aside "$artist_search_db"
+
+# Start Melodee again.
+# podman compose up -d
+# docker compose up -d
+```
+
+After the files are moved aside, rebuild the generated databases.
+
+### MusicBrainz
+
+Use one of these options:
+
+- In the web UI, go to **Admin > Doctor** and use **Generate MusicBrainz
+ Database**.
+- In the web UI, go to **Admin > Jobs** and run
+ `MusicBrainzUpdateDatabaseJob`.
+- From the CLI, run:
+
+```bash
+./mcli job musicbrainz-update
+```
+
+MusicBrainz rebuilds can take a long time because Melodee downloads and imports
+the MusicBrainz dump into a local DecentDB file.
+
+### Artist Search Cache
+
+Use one of these options:
+
+- In the web UI, go to **Admin > Jobs** and run
+ `ArtistSearchEngineRepositoryHousekeepingJob`.
+- From the CLI, run:
+
+```bash
+./mcli job artistsearchengine-refresh
+```
+
+The artist search cache is generated from Melodee's library data and configured
+artist search providers. It can be rebuilt after the incompatible file is moved
+aside.
+
+## Verify The Migration
+
+Run Doctor after rebuilding:
+
+```bash
+./mcli doctor --verbose
+```
+
+Or open **Admin > Doctor** in the web UI.
+
+The following checks should pass:
+
+- `MusicBrainzDatabase`
+- `ArtistSearchEngineDatabase`
+
+If Doctor still reports an unsupported DecentDB file format after rebuilding,
+confirm that:
+
+- Melodee is running the version you expect.
+- The active connection strings point to the rebuilt files.
+- No old `.wal`, `-wal`, `.shm`, or `-shm` companion files remain beside the
+ rebuilt database.
+- The app container or service was restarted after the rebuild.
+
+## What Not To Delete
+
+Do not delete the primary PostgreSQL database when resolving DecentDB search
+cache compatibility issues. The unsupported DecentDB file-format warning applies
+to generated local search databases, not the primary Melodee database.
+
diff --git a/docs/pages/scripting.md b/docs/pages/scripting.md
index 31d265723..842578713 100644
--- a/docs/pages/scripting.md
+++ b/docs/pages/scripting.md
@@ -79,7 +79,7 @@ is displayed to the user explaining why the action was denied.
| `settingKey` | string | The settings key, e.g. `script.userLoginStart` |
| `timeoutMs` | number | The configured timeout in milliseconds |
| `maxStatements` | number | The configured statement limit |
-| `onDeny` | string | Host action when result is `false` (`skip`, `delete`, `quarantine`) |
+| `onDeny` | string | Host action when result is `false` (`skip` or `quarantine` for ingestion events) |
| `isOverride` | boolean | Whether an override (library/path) matched |
| `libraryId` | number\|null | The matched override library ID (directory events only) |
| `pathPrefix` | string\|null | The matched override path prefix (directory events only) |
@@ -109,7 +109,7 @@ Each `script.` setting value is a JSON document. The conceptual schem
"enabled": true,
"libraryId": 1,
"pathPrefix": "Incoming/",
- "onDeny": "delete",
+ "onDeny": "skip",
"body": "function check(ctx, scriptConfig) { return ctx.mediaFilesCount >= 3; }"
}
]
@@ -144,11 +144,12 @@ Melodee treats scripts as untrusted code (defense in depth):
- Statement limit (`maxStatements`)
- Failures default to allow and are logged using the settings key and script hash (not the full script body).
-### Directory deletion and dry-run
+### Directory actions and dry-run
-Directory deletion is constrained to safe roots. You can also enable dry-run mode:
+Inbound ingestion does not physically delete release directories through event scripting. Directory scripts can skip a
+candidate directory, and quarantine handlers are still subject to dry-run mode:
-- `script.dryRun.enabled = true` prevents deletion/quarantine from actually modifying the filesystem.
+- `script.dryRun.enabled = true` prevents quarantine actions from modifying the filesystem.
## Event reference
@@ -156,8 +157,8 @@ This section lists the supported events and the `ctx` fields available to script
### `directoryProcessingStart`
-Runs before processing each candidate directory. If it returns `false`, Melodee applies `onDeny` (`skip`, `delete`, or
-`quarantine`).
+Runs before processing each candidate directory. If it returns `false`, Melodee applies `onDeny`. For ingestion safety,
+`delete` is treated as `skip`.
Context: `DirectoryProcessingContext`
@@ -176,8 +177,8 @@ Context: `DirectoryProcessingContext`
### `directoryProcessingDelete`
-Runs when `directoryProcessingStart` returns `false` and `onDeny` is `delete`. If this script returns `true`, deletion
-proceeds; if `false`, deletion is skipped (directory is not processed but also not deleted).
+Legacy event name retained for existing settings compatibility. Inbound ingestion no longer evaluates this event and does
+not delete release directories through event scripting.
Context: `DirectoryProcessingContext` (same as above).
@@ -303,20 +304,19 @@ Recommended override config example:
"enabled": true,
"libraryId": 1,
"pathPrefix": "Incoming/",
- "onDeny": "delete",
+ "onDeny": "skip",
"body": "function check(ctx, scriptConfig) { return ctx.mediaFilesCount >= 3; }"
}
]
}
```
-### Example: add an extra safety check before deletion
+### Example: skip directories with track number gaps
-Event: `directoryProcessingDelete`
+Event: `directoryProcessingStart`
```javascript
function check(ctx, scriptConfig) {
- // Only allow deletion if there are no track number gaps.
return ctx.hasTrackNumberGaps === false;
}
```
diff --git a/scripts/run-decentdb-package-upgrade-gate.sh b/scripts/run-decentdb-package-upgrade-gate.sh
new file mode 100755
index 000000000..ddbe6cc5b
--- /dev/null
+++ b/scripts/run-decentdb-package-upgrade-gate.sh
@@ -0,0 +1,258 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'USAGE'
+Usage:
+ ./scripts/run-decentdb-package-upgrade-gate.sh [--options]
+
+This helper runs Melodee's existing MusicBrainz import/query probes and evaluates
+DDB-002/DDB-003 acceptance for an existing checkpointed database.
+
+Required:
+ --db PATH Path to checkpointed MusicBrainz .ddb file.
+
+Options:
+ --storage PATH Path to MusicBrainz staging dump for optional import rebuild.
+ --label TEXT Identifier for output subdirectory (default: candidate).
+ --output-dir PATH Output root (default: .tmp/decentdb-upgrade-validation).
+ --max-warm-name-ms N Max warm Artist.NameNormalized query time in ms (default: 1000).
+ --max-warm-mbid-ms N Max warm Artist.MusicBrainzIdRaw query time in ms (default: 1000).
+ --max-warm-alias-ms N Max warm ArtistAlias.NameNormalized query time in ms (default: 2000).
+ --name TEXT Explicit normalized artist sample for the query probe.
+ --alias TEXT Explicit normalized alias sample for the query probe.
+ --mbid TEXT Explicit raw MusicBrainz ID sample for the query probe.
+ --include-row-existence
+ Include the broad first-row existence probe for investigation.
+ --skip-build Skip restore/build step and run probes directly.
+ --help Show this help text.
+
+Examples:
+ ./scripts/run-decentdb-package-upgrade-gate.sh \
+ --db /tmp/musicbrainz.ddb \
+ --label 2.13.1 \
+ --output-dir .tmp/decentdb-upgrade
+
+ ./scripts/run-decentdb-package-upgrade-gate.sh \
+ --db /tmp/musicbrainz.ddb \
+ --storage /mnt/fileserver_db_storage/melodee/search-engine-storage/musicbrainz \
+ --label 2.13.1-candidate \
+ --output-dir .tmp/decentdb-upgrade
+USAGE
+}
+
+if [[ $# -eq 0 ]]; then
+ usage
+ exit 1
+fi
+
+DB_PATH=""
+STORAGE_PATH=""
+LABEL="candidate"
+OUTPUT_DIR=".tmp/decentdb-upgrade-validation"
+MAX_NAME_MS=1000
+MAX_MBID_MS=1000
+MAX_ALIAS_MS=2000
+SAMPLE_NAME=""
+SAMPLE_ALIAS=""
+SAMPLE_MBID=""
+INCLUDE_ROW_EXISTENCE=false
+SKIP_BUILD=false
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --db)
+ DB_PATH="$2"
+ shift 2
+ ;;
+ --storage)
+ STORAGE_PATH="$2"
+ shift 2
+ ;;
+ --label)
+ LABEL="$2"
+ shift 2
+ ;;
+ --output-dir)
+ OUTPUT_DIR="$2"
+ shift 2
+ ;;
+ --max-warm-name-ms)
+ MAX_NAME_MS="$2"
+ shift 2
+ ;;
+ --max-warm-mbid-ms)
+ MAX_MBID_MS="$2"
+ shift 2
+ ;;
+ --max-warm-alias-ms)
+ MAX_ALIAS_MS="$2"
+ shift 2
+ ;;
+ --name)
+ SAMPLE_NAME="$2"
+ shift 2
+ ;;
+ --alias)
+ SAMPLE_ALIAS="$2"
+ shift 2
+ ;;
+ --mbid)
+ SAMPLE_MBID="$2"
+ shift 2
+ ;;
+ --include-row-existence)
+ INCLUDE_ROW_EXISTENCE=true
+ shift
+ ;;
+ --skip-build)
+ SKIP_BUILD=true
+ shift
+ ;;
+ --help|-h)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $1" >&2
+ usage
+ exit 1
+ ;;
+ esac
+done
+
+if [[ -z "$DB_PATH" ]]; then
+ echo "Error: --db is required." >&2
+ usage
+ exit 1
+fi
+
+if ! command -v jq >/dev/null 2>&1; then
+ echo "Error: jq is required for acceptance checks." >&2
+ exit 1
+fi
+
+if ! command -v dotnet >/dev/null 2>&1; then
+ echo "Error: dotnet is required to run benchmark probes." >&2
+ exit 1
+fi
+
+RUN_DIR="${OUTPUT_DIR%/}/${LABEL}"
+IMPORT_REPORT="${RUN_DIR}/musicbrainz-import-probe.json"
+CHECKPOINTED_REPORT="${RUN_DIR}/musicbrainz-query-probe-checkpointed.json"
+mkdir -p "$RUN_DIR"
+
+if [[ "$SKIP_BUILD" != true ]]; then
+ echo "Restoring and building benchmark project..."
+ dotnet restore Melodee.sln
+ dotnet build Melodee.sln --no-restore
+fi
+
+if [[ -n "$STORAGE_PATH" ]]; then
+ if [[ ! -d "$STORAGE_PATH" ]]; then
+ echo "Error: --storage path does not exist: $STORAGE_PATH" >&2
+ exit 1
+ fi
+
+ if [[ ! -d "$STORAGE_PATH/staging/mbdump" ]]; then
+ echo "Error: expected MusicBrainz dump under $STORAGE_PATH/staging/mbdump." >&2
+ exit 1
+ fi
+
+ echo "Running import probe for fresh real-file rebuild..."
+ dotnet run -c Release --project benchmarks/Melodee.Benchmarks -- \
+ musicbrainz-import-probe \
+ --storage "$STORAGE_PATH" \
+ --db "$DB_PATH" \
+ --output "$IMPORT_REPORT" \
+ --clean \
+ --sample-interval-ms 10000
+
+ echo "Import probe complete -> $IMPORT_REPORT"
+fi
+
+echo "Running checkpointed query probe..."
+QUERY_PROBE_ARGS=(
+ musicbrainz-query-probe
+ --db "$DB_PATH"
+ --output "$CHECKPOINTED_REPORT"
+)
+
+if [[ -n "$SAMPLE_NAME" ]]; then
+ QUERY_PROBE_ARGS+=(--name "$SAMPLE_NAME")
+fi
+
+if [[ -n "$SAMPLE_ALIAS" ]]; then
+ QUERY_PROBE_ARGS+=(--alias "$SAMPLE_ALIAS")
+fi
+
+if [[ -n "$SAMPLE_MBID" ]]; then
+ QUERY_PROBE_ARGS+=(--mbid "$SAMPLE_MBID")
+fi
+
+if [[ "$INCLUDE_ROW_EXISTENCE" == true ]]; then
+ QUERY_PROBE_ARGS+=(--include-row-existence)
+fi
+
+dotnet run -c Release --project benchmarks/Melodee.Benchmarks -- \
+ "${QUERY_PROBE_ARGS[@]}"
+
+if [[ ! -f "$CHECKPOINTED_REPORT" ]]; then
+ echo "Error: query probe report not created: $CHECKPOINTED_REPORT" >&2
+ exit 1
+fi
+
+check_query_ms() {
+ local query_name="$1"
+ local max_ms="$2"
+ local report_path="$3"
+
+ local warm_ms
+ warm_ms=$(jq -r --arg name "$query_name" --arg pass "warm" \
+ '.Measurements[] | select(.Name == $name and .Pass == $pass) | .ElapsedMilliseconds' "$report_path" | head -n 1)
+
+ if [[ -z "$warm_ms" || "$warm_ms" == "null" ]]; then
+ echo "$query_name: missing warm timing"
+ return 1
+ fi
+
+ local plan
+ plan=$(jq -r --arg name "$query_name" --arg pass "warm" \
+ '.Measurements[] | select(.Name == $name and .Pass == $pass) | .PlanDiagnostics.Lines | join(" ")' "$report_path")
+
+ if ! echo "$plan" | grep -q "IndexSeek"; then
+ echo "$query_name: warm $warm_ms ms -> FAIL (plan missing IndexSeek)"
+ return 1
+ fi
+
+ if awk -v ms="$warm_ms" -v limit="$max_ms" 'BEGIN { exit !(ms <= limit) }'; then
+ echo "$query_name: warm $warm_ms ms (PASS <= ${max_ms}ms, plan captured)"
+ return 0
+ fi
+
+ echo "$query_name: warm $warm_ms ms (FAIL > ${max_ms}ms)"
+ return 1
+}
+
+FAILURES=0
+
+if ! check_query_ms "exact-normalized-name" "$MAX_NAME_MS" "$CHECKPOINTED_REPORT"; then
+ FAILURES=1
+fi
+
+if ! check_query_ms "exact-musicbrainz-id-raw" "$MAX_MBID_MS" "$CHECKPOINTED_REPORT"; then
+ FAILURES=1
+fi
+
+if ! check_query_ms "exact-normalized-alias" "$MAX_ALIAS_MS" "$CHECKPOINTED_REPORT"; then
+ echo "Alias shape check failed but does not block DDB-002/DDB-003." >&2
+fi
+
+echo "Query probe report: $CHECKPOINTED_REPORT"
+
+if [[ "$FAILURES" -ne 0 ]]; then
+ echo "DDB-002 / DDB-003 gate failed. See report output for details." >&2
+ exit 1
+fi
+
+echo "DDB-002 / DDB-003 gate passed."
diff --git a/src/Melodee.Blazor/Components/App.razor b/src/Melodee.Blazor/Components/App.razor
index c6aad6e7b..69118eec9 100644
--- a/src/Melodee.Blazor/Components/App.razor
+++ b/src/Melodee.Blazor/Components/App.razor
@@ -71,7 +71,6 @@
-
diff --git a/src/Melodee.Blazor/Components/Components/ImageSearchUpload.razor b/src/Melodee.Blazor/Components/Components/ImageSearchUpload.razor
index 68f6fa04b..096e3a6f6 100644
--- a/src/Melodee.Blazor/Components/Components/ImageSearchUpload.razor
+++ b/src/Melodee.Blazor/Components/Components/ImageSearchUpload.razor
@@ -7,6 +7,7 @@
@inject ArtistImageSearchEngineService ArtistImageSearchEngineService
@inject MainLayoutProxyService MainLayoutProxyService
@inject ILogger Logger
+@implements IDisposable
Image Search
@@ -90,10 +91,13 @@
bool _isLoading;
int _maxResults = 10;
bool _doDeleteExistingImages = true;
+ int _searchRequestId;
+ bool _disposed;
private ImageSearchResult[] _results = [];
private ImageSearchResult? _selectedResult;
+ private CancellationTokenSource? _searchCancellationTokenSource;
[Parameter] public SearchValue[] SearchValues { get; set; } = [];
@@ -127,12 +131,13 @@
private async Task SelectOrCancelClicked(bool clickedSelected)
{
- if (!clickedSelected)
+ if (!clickedSelected || _selectedResult is null)
{
await OnUpdateCallback.InvokeAsync(null);
+ return;
}
- if (OnUpdateCallback.HasDelegate && _selectedResult != null)
+ if (OnUpdateCallback.HasDelegate)
{
await OnUpdateCallback.InvokeAsync(_selectedResult);
}
@@ -148,53 +153,104 @@
private async Task DoSearchAsync()
{
- if (_isLoading)
+ if (_disposed || SearchType == ImageSearchType.Profile)
{
return;
}
+ var searchValues = SearchValues.OrderBy(x => x.SortOrder).ToArray();
+ if (searchValues.Length == 0)
+ {
+ _results = [];
+ return;
+ }
+
+ var previousSearch = _searchCancellationTokenSource;
+ var searchTokenSource = new CancellationTokenSource();
+ _searchCancellationTokenSource = searchTokenSource;
+ var searchRequestId = Interlocked.Increment(ref _searchRequestId);
+ previousSearch?.Cancel();
+
_isLoading = true;
+ _selectedResult = null;
+ _results = [];
+ await InvokeAsync(StateHasChanged);
+
try
{
if (SearchType == ImageSearchType.Artist)
{
- _results = (await ArtistImageSearchEngineService.DoSearchAsync(new ArtistQuery
+ var results = (await ArtistImageSearchEngineService.DoSearchAsync(new ArtistQuery
{
- Name = SearchValues.First().Value
- }, _maxResults))
+ Name = searchValues.First().Value
+ }, _maxResults, searchTokenSource.Token))
.Data
.Where(x => x.ThumbnailUrlValue.Nullify() != null)
.Where(x => x.Width >= _minimumImageSize && x.Height >= _minimumImageSize)
.OrderByDescending(x => x.Width * x.Height)
.Select(x => new ImageSearchResult(x.ThumbnailUrlValue, x.MediaUrl, x.Title ?? string.Empty, _doDeleteExistingImages, x.Width, x.Height))
.ToArray();
+
+ if (searchRequestId == _searchRequestId && !searchTokenSource.IsCancellationRequested)
+ {
+ _results = results;
+ }
}
- else if (SearchType != ImageSearchType.Profile)
+ else if (searchValues.Length >= 3)
{
- _results = (await AlbumImageSearchEngineService.DoSearchAsync(new AlbumQuery
+ var results = (await AlbumImageSearchEngineService.DoSearchAsync(new AlbumQuery
{
- Artist = SearchValues.OrderBy(x => x.SortOrder).First().Value,
- Year = SafeParser.ToNumber(SearchValues.OrderBy(x => x.SortOrder).Skip(1).First().Value),
- Name = SearchValues.OrderBy(x => x.SortOrder).Skip(2).First().Value
- }, _maxResults))
+ Artist = searchValues[0].Value,
+ Year = SafeParser.ToNumber(searchValues[1].Value),
+ Name = searchValues[2].Value
+ }, _maxResults, searchTokenSource.Token))
.Data
.Where(x => x.Width >= _minimumImageSize && x.Height >= _minimumImageSize)
.OrderByDescending(x => x.Width * x.Height)
.Select(x => new ImageSearchResult(x.ThumbnailUrlValue, x.MediaUrl, x.Title ?? string.Empty, _doDeleteExistingImages, x.Width, x.Height))
.ToArray();
+
+ if (searchRequestId == _searchRequestId && !searchTokenSource.IsCancellationRequested)
+ {
+ _results = results;
+ }
}
}
+ catch (OperationCanceledException) when (searchTokenSource.IsCancellationRequested)
+ {
+ // A newer search or dialog close superseded this request.
+ }
catch (Exception e)
{
- Logger.Error(e, "For search [{Value}]", SearchValues.FirstOrDefault()?.Value);
+ Logger.Error(e, "For search [{Value}]", searchValues.FirstOrDefault()?.Value);
}
finally
{
- _isLoading = false;
- await InvokeAsync(StateHasChanged);
+ if (searchRequestId == _searchRequestId)
+ {
+ _isLoading = false;
+ if (!_disposed)
+ {
+ await InvokeAsync(StateHasChanged);
+ }
+ }
+
+ if (ReferenceEquals(_searchCancellationTokenSource, searchTokenSource))
+ {
+ _searchCancellationTokenSource = null;
+ }
+
+ searchTokenSource.Dispose();
}
}
+ public void Dispose()
+ {
+ _disposed = true;
+ Interlocked.Increment(ref _searchRequestId);
+ _searchCancellationTokenSource?.Cancel();
+ }
+
private Task CancelClicked()
{
diff --git a/src/Melodee.Blazor/Components/Layout/MainLayout.razor b/src/Melodee.Blazor/Components/Layout/MainLayout.razor
index cb4eff229..a25717982 100644
--- a/src/Melodee.Blazor/Components/Layout/MainLayout.razor
+++ b/src/Melodee.Blazor/Components/Layout/MainLayout.razor
@@ -228,7 +228,7 @@
.Select(x => new MediaLibraryInfo(x.Name, $"/media/library/{x.ApiKey}"))
.ToArray();
- MainLayoutProxyService.ShowSpinner = false;
+ MainLayoutProxyService.SetSpinnerVisible(false);
await CheckActivityAsync();
_activityTimer = new Timer(async _ => await CheckActivityAsync(), null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
diff --git a/src/Melodee.Blazor/Components/Pages/Admin/Dashboard.razor b/src/Melodee.Blazor/Components/Pages/Admin/Dashboard.razor
index 40846327d..61e34aaab 100644
--- a/src/Melodee.Blazor/Components/Pages/Admin/Dashboard.razor
+++ b/src/Melodee.Blazor/Components/Pages/Admin/Dashboard.razor
@@ -54,6 +54,35 @@
+ @if (!_isCheckingHealth && _systemHealthIssues.Length > 0)
+ {
+
+
+ @L("Dashboard.IssuesDetected")
+ @foreach (var issue in _systemHealthIssues)
+ {
+
+ @FormatHealthIssue(issue)
+
+ }
+ @if (DashboardHealthWarningEvaluator.HasUnsupportedDecentDbIssue(_systemHealthIssues))
+ {
+
+ @L("Dashboard.DecentDbMigrationGuide")
+
+ }
+
+
+
+ }
+
@* Quick Links *@
@@ -502,6 +531,7 @@
private string _melodeeVersion = "---";
private string _apiVersion = "---";
private string _osDescription = "---";
+ private Melodee.Common.Services.Doctor.DoctorCheckResult[] _systemHealthIssues = [];
private bool _systemHealthy = true;
private bool _isCheckingHealth = true;
@@ -648,10 +678,19 @@
try
{
var results = await DoctorService.RunAllChecksAsync();
- _systemHealthy = results.Checks.All(c => c.Success);
+ _systemHealthIssues = results.Checks.Where(c => !c.Success).Take(5).ToArray();
+ _systemHealthy = _systemHealthIssues.Length == 0;
}
catch
{
+ _systemHealthIssues =
+ [
+ new Melodee.Common.Services.Doctor.DoctorCheckResult(
+ "Doctor",
+ false,
+ "Doctor checks failed before returning results",
+ TimeSpan.Zero)
+ ];
_systemHealthy = false;
}
finally
@@ -661,6 +700,43 @@
}
}
+ private string FormatHealthIssue(Melodee.Common.Services.Doctor.DoctorCheckResult issue)
+ {
+ var name = LocalizeHealthIssueName(issue.Name);
+ return string.IsNullOrWhiteSpace(issue.Details)
+ ? name
+ : $"{name}: {issue.Details}";
+ }
+
+ private string LocalizeHealthIssueName(string checkName)
+ {
+ return checkName switch
+ {
+ "Configuration" => L("AdminDoctor.Check.Configuration"),
+ "PostgresDatabase" => L("AdminDoctor.Check.PostgresDatabase"),
+ "MusicBrainzDatabase" => L("AdminDoctor.Check.MusicBrainzDatabase"),
+ "ArtistSearchEngineDatabase" => L("AdminDoctor.Check.ArtistSearchEngineDatabase"),
+ "LibraryPaths" => L("AdminDoctor.Check.LibraryPaths"),
+ "SerilogLogging" => L("AdminDoctor.Check.SerilogLogging"),
+ "ConfigurableServices" => L("AdminDoctor.Check.ConfigurableServices"),
+ "DiskSpace" => L("AdminDoctor.Check.DiskSpace"),
+ "LibraryPathOverlap" => L("AdminDoctor.Check.LibraryPathOverlap"),
+ "SearchEngineApiKeys" => L("AdminDoctor.Check.SearchEngineApiKeys"),
+ "SmtpConfiguration" => L("AdminDoctor.Check.SmtpConfiguration"),
+ "JwtTokenStrength" => L("AdminDoctor.Check.JwtTokenStrength"),
+ "HttpsSecurity" => L("AdminDoctor.Check.HttpsSecurity"),
+ "AdminPassword" => L("AdminDoctor.Check.AdminPassword"),
+ "Scheduler" => L("AdminDoctor.Check.Scheduler"),
+ "FFmpeg" => L("AdminDoctor.Check.FFmpeg"),
+ "Memory" => L("AdminDoctor.Check.Memory"),
+ "TempDirectory" => L("AdminDoctor.Check.TempDirectory"),
+ "DatabaseLatency" => L("AdminDoctor.Check.DatabaseLatency"),
+ "JukeboxConfiguration" => L("AdminDoctor.Check.JukeboxConfiguration"),
+ "PodcastConfiguration" => L("AdminDoctor.Check.PodcastConfiguration"),
+ _ => checkName
+ };
+ }
+
private void ClearCacheClicked()
{
CacheManager.Clear();
diff --git a/src/Melodee.Blazor/Components/Pages/Dashboard.razor b/src/Melodee.Blazor/Components/Pages/Dashboard.razor
index 29d687f55..adcefc0ce 100644
--- a/src/Melodee.Blazor/Components/Pages/Dashboard.razor
+++ b/src/Melodee.Blazor/Components/Pages/Dashboard.razor
@@ -44,10 +44,26 @@
NavigationManager.NavigateTo("/admin/doctor"))">
-
+
@L("Dashboard.IssuesDetected")
+ @foreach (var issue in _healthIssues.Take(3))
+ {
+
+ @FormatHealthIssue(issue)
+
+ }
+ @if (DashboardHealthWarningEvaluator.HasUnsupportedDecentDbIssue(_healthIssues))
+ {
+
+ @L("Dashboard.DecentDbMigrationGuide")
+
+ }
@@ -350,6 +366,7 @@
private TopItemStat[] _recentlyPlayed = [];
private SongDataInfo[] _recentlyPlayedSongs = [];
private Request[] _unreadRequests = [];
+ private Melodee.Common.Services.Doctor.DoctorCheckResult[] _healthIssues = [];
private bool _showHealthWarning;
private bool _isAdmin;
@@ -363,6 +380,7 @@
private bool _artistsLoading = true;
private bool _albumsLoading = true;
private bool _isLoading => _healthLoading || _pinsLoading || _kpisLoading || _playsLoading || _topSongsLoading || _recentlyPlayedLoading || _requestsLoading || _artistsLoading || _albumsLoading;
+ private bool _isDisposed;
private short _latestPageSize;
private LocalDate _rangeStart;
@@ -431,7 +449,7 @@
var userApiKey = CurrentUser?.ToApiGuid() ?? Guid.Empty;
var userId = CurrentUser?.UserId() ?? 0;
- MainLayoutProxyService.ShowSpinner = true;
+ MainLayoutProxyService.SetSpinnerVisible(true);
_ = LoadHealthAsync();
_ = LoadPinsAsync(userId);
@@ -446,25 +464,43 @@
private void UpdateSpinner()
{
+ if (_isDisposed)
+ {
+ MainLayoutProxyService.SetSpinnerVisible(false);
+ return;
+ }
+
var anyLoading = _healthLoading || _pinsLoading || _kpisLoading || _playsLoading || _topSongsLoading || _recentlyPlayedLoading || _requestsLoading || _artistsLoading || _albumsLoading;
- MainLayoutProxyService.ShowSpinner = anyLoading;
+ MainLayoutProxyService.SetSpinnerVisible(anyLoading);
+ }
+
+ private async Task NotifyDashboardChangedAsync()
+ {
+ if (_isDisposed)
+ {
+ return;
+ }
+
+ await InvokeAsync(StateHasChanged);
}
private async Task LoadHealthAsync()
{
try
{
- _showHealthWarning = await DashboardHealthWarningEvaluator.ShouldShowAsync(CurrentUser, DoctorService);
+ _healthIssues = (await DashboardHealthWarningEvaluator.GetIssuesAsync(CurrentUser, DoctorService)).ToArray();
+ _showHealthWarning = _healthIssues.Length > 0;
}
catch
{
+ _healthIssues = [];
_showHealthWarning = false;
}
finally
{
_healthLoading = false;
UpdateSpinner();
- await InvokeAsync(StateHasChanged);
+ await NotifyDashboardChangedAsync();
}
}
@@ -486,7 +522,7 @@
{
_pinsLoading = false;
UpdateSpinner();
- await InvokeAsync(StateHasChanged);
+ await NotifyDashboardChangedAsync();
}
}
@@ -505,7 +541,7 @@
{
_kpisLoading = false;
UpdateSpinner();
- await InvokeAsync(StateHasChanged);
+ await NotifyDashboardChangedAsync();
}
}
@@ -524,7 +560,7 @@
{
_playsLoading = false;
UpdateSpinner();
- await InvokeAsync(StateHasChanged);
+ await NotifyDashboardChangedAsync();
}
}
@@ -543,7 +579,7 @@
{
_topSongsLoading = false;
UpdateSpinner();
- await InvokeAsync(StateHasChanged);
+ await NotifyDashboardChangedAsync();
}
}
@@ -599,7 +635,7 @@
{
_recentlyPlayedLoading = false;
UpdateSpinner();
- await InvokeAsync(StateHasChanged);
+ await NotifyDashboardChangedAsync();
}
}
@@ -618,7 +654,7 @@
{
_requestsLoading = false;
UpdateSpinner();
- await InvokeAsync(StateHasChanged);
+ await NotifyDashboardChangedAsync();
}
}
@@ -644,7 +680,7 @@
{
_artistsLoading = false;
UpdateSpinner();
- await InvokeAsync(StateHasChanged);
+ await NotifyDashboardChangedAsync();
}
}
@@ -670,7 +706,7 @@
{
_albumsLoading = false;
UpdateSpinner();
- await InvokeAsync(StateHasChanged);
+ await NotifyDashboardChangedAsync();
}
}
@@ -679,15 +715,43 @@
_ = InvokeAsync(async () =>
{
var authenticationState = await authenticationStateTask;
+ if (_isDisposed)
+ {
+ return;
+ }
+
_isAdmin = authenticationState.User.IsAdmin();
- _showHealthWarning = await DashboardHealthWarningEvaluator.ShouldShowAsync(authenticationState.User, DoctorService);
+ _healthIssues = (await DashboardHealthWarningEvaluator.GetIssuesAsync(authenticationState.User, DoctorService)).ToArray();
+ _showHealthWarning = _healthIssues.Length > 0;
StateHasChanged();
});
}
+ private string FormatHealthIssue(Melodee.Common.Services.Doctor.DoctorCheckResult issue)
+ {
+ var name = LocalizeHealthIssueName(issue.Name);
+ return string.IsNullOrWhiteSpace(issue.Details)
+ ? name
+ : $"{name}: {issue.Details}";
+ }
+
+ private string LocalizeHealthIssueName(string checkName)
+ {
+ return checkName switch
+ {
+ "Configuration" => L("AdminDoctor.Check.Configuration"),
+ "PostgresDatabase" => L("AdminDoctor.Check.PostgresDatabase"),
+ "MusicBrainzDatabase" => L("AdminDoctor.Check.MusicBrainzDatabase"),
+ "ArtistSearchEngineDatabase" => L("AdminDoctor.Check.ArtistSearchEngineDatabase"),
+ _ => checkName
+ };
+ }
+
public void Dispose()
{
+ _isDisposed = true;
DashboardAuthenticationStateProvider.AuthenticationStateChanged -= OnAuthenticationStateChanged;
+ MainLayoutProxyService.SetSpinnerVisible(false);
}
}
diff --git a/src/Melodee.Blazor/Components/Pages/DashboardHealthWarningEvaluator.cs b/src/Melodee.Blazor/Components/Pages/DashboardHealthWarningEvaluator.cs
index 4b478d48c..c44f6632f 100644
--- a/src/Melodee.Blazor/Components/Pages/DashboardHealthWarningEvaluator.cs
+++ b/src/Melodee.Blazor/Components/Pages/DashboardHealthWarningEvaluator.cs
@@ -1,12 +1,15 @@
using System.Security.Claims;
using Melodee.Blazor.Security.Extensions;
-using Melodee.Blazor.Services;
+using BlazorDoctorService = Melodee.Blazor.Services.IDoctorService;
+using DoctorCheckResult = Melodee.Common.Services.Doctor.DoctorCheckResult;
namespace Melodee.Blazor.Components.Pages;
internal static class DashboardHealthWarningEvaluator
{
- public static Task ShouldShowAsync(ClaimsPrincipal? user, IDoctorService doctorService, CancellationToken cancellationToken = default)
+ public const string DecentDbMigrationGuideUrl = "https://melodee.org/decentdb/";
+
+ public static Task ShouldShowAsync(ClaimsPrincipal? user, BlazorDoctorService doctorService, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(doctorService);
@@ -14,4 +17,32 @@ public static Task ShouldShowAsync(ClaimsPrincipal? user, IDoctorService d
? doctorService.NeedsAttentionAsync(cancellationToken)
: Task.FromResult(false);
}
+
+ public static async Task> GetIssuesAsync(
+ ClaimsPrincipal? user,
+ BlazorDoctorService doctorService,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(doctorService);
+
+ return user?.IsAdmin() == true
+ ? await doctorService.GetAttentionChecksAsync(cancellationToken)
+ : [];
+ }
+
+ public static bool HasUnsupportedDecentDbIssue(IEnumerable issues)
+ {
+ ArgumentNullException.ThrowIfNull(issues);
+
+ return issues.Any(issue =>
+ IsDecentDbCheck(issue.Name) &&
+ issue.Details.Contains("DecentDB", StringComparison.OrdinalIgnoreCase) &&
+ issue.Details.Contains("file format", StringComparison.OrdinalIgnoreCase) &&
+ issue.Details.Contains("not supported", StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static bool IsDecentDbCheck(string checkName)
+ {
+ return checkName is "MusicBrainzDatabase" or "ArtistSearchEngineDatabase";
+ }
}
diff --git a/src/Melodee.Blazor/Components/Pages/Data/AlbumDetail.razor b/src/Melodee.Blazor/Components/Pages/Data/AlbumDetail.razor
index 7f6a19a5d..1b8d2da6f 100644
--- a/src/Melodee.Blazor/Components/Pages/Data/AlbumDetail.razor
+++ b/src/Melodee.Blazor/Components/Pages/Data/AlbumDetail.razor
@@ -1026,13 +1026,13 @@
{
Key = "Year",
Value = _album.ReleaseDate.Year.ToString(),
- SortOrder = 1
+ SortOrder = 2
},
new()
{
Key = "Title",
Value = _album.Name,
- SortOrder = 1
+ SortOrder = 3
}
];
diff --git a/src/Melodee.Blazor/Components/Pages/Data/AlbumEdit.razor b/src/Melodee.Blazor/Components/Pages/Data/AlbumEdit.razor
index b49c637c5..75fe408fd 100644
--- a/src/Melodee.Blazor/Components/Pages/Data/AlbumEdit.razor
+++ b/src/Melodee.Blazor/Components/Pages/Data/AlbumEdit.razor
@@ -133,7 +133,7 @@
-
+
@@ -209,7 +209,7 @@
-
+
@@ -403,4 +403,3 @@
}
-
diff --git a/src/Melodee.Blazor/Components/Pages/Media/AlbumDetail.razor b/src/Melodee.Blazor/Components/Pages/Media/AlbumDetail.razor
index 02e17008d..5d15fb97b 100644
--- a/src/Melodee.Blazor/Components/Pages/Media/AlbumDetail.razor
+++ b/src/Melodee.Blazor/Components/Pages/Media/AlbumDetail.razor
@@ -793,13 +793,13 @@
{
Key = "Year",
Value = _album.AlbumYear()?.ToString() ?? string.Empty,
- SortOrder = 1
+ SortOrder = 2
},
new()
{
Key = "Title",
Value = _album.AlbumTitle() ?? string.Empty,
- SortOrder = 1
+ SortOrder = 3
}
];
diff --git a/src/Melodee.Blazor/Components/Pages/Media/AlbumEdit.razor b/src/Melodee.Blazor/Components/Pages/Media/AlbumEdit.razor
index f12a4625a..9ab25465f 100644
--- a/src/Melodee.Blazor/Components/Pages/Media/AlbumEdit.razor
+++ b/src/Melodee.Blazor/Components/Pages/Media/AlbumEdit.razor
@@ -372,13 +372,13 @@
{
Key = "Year",
Value = _album.Year.ToString(),
- SortOrder = 1
+ SortOrder = 2
},
new()
{
Key = "Title",
Value = _album.Title ?? string.Empty,
- SortOrder = 1
+ SortOrder = 3
}
];
@@ -482,4 +482,3 @@
}
-
diff --git a/src/Melodee.Blazor/Components/Pages/Shared.razor b/src/Melodee.Blazor/Components/Pages/Shared.razor
index 514c51bd5..22eea1d15 100644
--- a/src/Melodee.Blazor/Components/Pages/Shared.razor
+++ b/src/Melodee.Blazor/Components/Pages/Shared.razor
@@ -1,5 +1,4 @@
@page "/share/{ShareUniqueId}"
-@using AuralizeBlazor
@using Melodee.Common.Data.Models.Extensions
@using Melodee.Common.Enums
@@ -13,15 +12,32 @@