Fix Surah seed tracking conflict on API startup retries.
build-and-deploy / deploy (push) Has been cancelled

Use idempotent seed, fresh DbContext per retry, and explicit Surah/Tafsir keys.
This commit is contained in:
2026-10-09 22:57:27 +03:30
parent ae746d65ba
commit 92a2fdf6bc
6 changed files with 472 additions and 31 deletions
+4 -3
View File
@@ -40,14 +40,15 @@ builder.Services.AddOpenApi();
var app = builder.Build(); var app = builder.Build();
using (var scope = app.Services.CreateScope())
{ {
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var audioRoot = app.Configuration["AudioRoot"] ?? "/data/audio";
for (var attempt = 1; ; attempt++) for (var attempt = 1; ; attempt++)
{ {
try try
{ {
var audioRoot = app.Configuration["AudioRoot"] ?? "/data/audio"; // Fresh scope each attempt so a failed seed cannot leave tracked entities.
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await DbSeeder.SeedAsync(db, audioRoot); await DbSeeder.SeedAsync(db, audioRoot);
break; break;
} }
@@ -27,6 +27,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
modelBuilder.Entity<Tafsir>(e => modelBuilder.Entity<Tafsir>(e =>
{ {
e.Property(x => x.Id).ValueGeneratedNever();
e.HasIndex(x => x.Slug).IsUnique(); e.HasIndex(x => x.Slug).IsUnique();
e.Property(x => x.Title).HasMaxLength(200); e.Property(x => x.Title).HasMaxLength(200);
e.Property(x => x.Author).HasMaxLength(120); e.Property(x => x.Author).HasMaxLength(120);
@@ -35,6 +36,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
modelBuilder.Entity<Surah>(e => modelBuilder.Entity<Surah>(e =>
{ {
e.Property(x => x.Id).ValueGeneratedNever();
e.HasIndex(x => x.Number).IsUnique(); e.HasIndex(x => x.Number).IsUnique();
e.Property(x => x.NameAr).HasMaxLength(80); e.Property(x => x.NameAr).HasMaxLength(80);
e.Property(x => x.NameFa).HasMaxLength(80); e.Property(x => x.NameFa).HasMaxLength(80);
@@ -43,12 +43,13 @@ public static class DbSeeder
public static async Task SeedAsync(AppDbContext db, string? audioRoot = null, CancellationToken ct = default) public static async Task SeedAsync(AppDbContext db, string? audioRoot = null, CancellationToken ct = default)
{ {
await db.Database.MigrateAsync(ct); await db.Database.MigrateAsync(ct);
db.ChangeTracker.Clear();
if (!await db.Surahs.AnyAsync(ct)) var existingSurahIds = (await db.Surahs.AsNoTracking().Select(s => s.Id).ToListAsync(ct)).ToHashSet();
{
for (var i = 0; i < SurahNames.Length; i++) for (var i = 0; i < SurahNames.Length; i++)
{ {
var n = i + 1; var n = i + 1;
if (existingSurahIds.Contains(n)) continue;
db.Surahs.Add(new Surah db.Surahs.Add(new Surah
{ {
Id = n, Id = n,
@@ -57,10 +58,12 @@ public static class DbSeeder
NameFa = SurahNames[i].Fa NameFa = SurahNames[i].Fa
}); });
} }
if (db.ChangeTracker.HasChanges())
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
}
if (!await db.Tafsirs.AnyAsync(ct)) db.ChangeTracker.Clear();
if (!await db.Tafsirs.AsNoTracking().AnyAsync(t => t.Id == GhatrehChekaniId, ct))
{ {
db.Tafsirs.Add(new Tafsir db.Tafsirs.Add(new Tafsir
{ {
@@ -73,6 +76,7 @@ public static class DbSeeder
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
db.ChangeTracker.Clear();
await SyncEpisodesFromAudioAsync(db, audioRoot, ct); await SyncEpisodesFromAudioAsync(db, audioRoot, ct);
} }
@@ -81,14 +85,14 @@ public static class DbSeeder
if (string.IsNullOrWhiteSpace(audioRoot) || !Directory.Exists(audioRoot)) if (string.IsNullOrWhiteSpace(audioRoot) || !Directory.Exists(audioRoot))
return; return;
var tafsir = await db.Tafsirs.FirstOrDefaultAsync(t => t.Id == GhatrehChekaniId, ct); if (!await db.Tafsirs.AsNoTracking().AnyAsync(t => t.Id == GhatrehChekaniId, ct))
if (tafsir is null) return; return;
var existing = await db.Episodes var existingSet = (await db.Episodes.AsNoTracking()
.Where(e => e.TafsirId == GhatrehChekaniId) .Where(e => e.TafsirId == GhatrehChekaniId)
.Select(e => e.AudioUrl) .Select(e => e.AudioUrl)
.ToListAsync(ct); .ToListAsync(ct))
var existingSet = existing.ToHashSet(StringComparer.OrdinalIgnoreCase); .ToHashSet(StringComparer.OrdinalIgnoreCase);
var audioFiles = Directory.EnumerateFiles(audioRoot, "*.*", SearchOption.AllDirectories) var audioFiles = Directory.EnumerateFiles(audioRoot, "*.*", SearchOption.AllDirectories)
.Where(f => f.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase) .Where(f => f.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase)
@@ -100,7 +104,7 @@ public static class DbSeeder
if (audioFiles.Count == 0) return; if (audioFiles.Count == 0) return;
var sort = existing.Count; var sort = existingSet.Count;
var added = 0; var added = 0;
foreach (var file in audioFiles) foreach (var file in audioFiles)
{ {
@@ -122,10 +126,18 @@ public static class DbSeeder
SortOrder = sort, SortOrder = sort,
Summary = null Summary = null
}); });
existingSet.Add(relative);
added++; added++;
// batch inserts to avoid huge change tracker
if (added % 200 == 0)
{
await db.SaveChangesAsync(ct);
db.ChangeTracker.Clear();
}
} }
if (added > 0) if (db.ChangeTracker.HasChanges())
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
@@ -149,10 +161,8 @@ public static class DbSeeder
private static int GuessAyahNumber(string fileName) private static int GuessAyahNumber(string fileName)
{ {
// patterns: ..._001, -1, .1, ayah01
var matches = System.Text.RegularExpressions.Regex.Matches(fileName, @"(\d+)"); var matches = System.Text.RegularExpressions.Regex.Matches(fileName, @"(\d+)");
if (matches.Count == 0) return 1; if (matches.Count == 0) return 1;
// prefer last number group as ayah when multiple
if (int.TryParse(matches[^1].Value, out var n) && n > 0) if (int.TryParse(matches[^1].Value, out var n) && n > 0)
return n; return n;
return 1; return 1;
@@ -0,0 +1,396 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using TafsirQuran.Infrastructure.Persistence;
#nullable disable
namespace TafsirQuran.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20261009192702_SurahExplicitIds")]
partial class SurahExplicitIds
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("TafsirQuran.Domain.Entities.AppUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.Ayah", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("Number")
.HasColumnType("int");
b.Property<int>("SurahId")
.HasColumnType("int");
b.Property<string>("TextAr")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("TextFa")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("SurahId", "Number")
.IsUnique();
b.ToTable("Ayahs");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.Bookmark", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<Guid>("EpisodeId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<Guid?>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("UserId");
b.HasIndex("DeviceId", "UserId", "EpisodeId")
.IsUnique()
.HasFilter("[UserId] IS NOT NULL");
b.ToTable("Bookmarks");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.Favorite", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<Guid>("EpisodeId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("UserId");
b.HasIndex("DeviceId", "UserId", "EpisodeId")
.IsUnique()
.HasFilter("[UserId] IS NOT NULL");
b.ToTable("Favorites");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.ListeningProgress", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<Guid>("EpisodeId")
.HasColumnType("uniqueidentifier");
b.Property<double>("PositionSeconds")
.HasColumnType("float");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("UserId");
b.HasIndex("DeviceId", "UserId");
b.ToTable("ListeningProgresses");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.Surah", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<string>("NameAr")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("NameFa")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<int>("Number")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Number")
.IsUnique();
b.ToTable("Surahs");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<string>("Author")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("CoverUrl")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsPublished")
.HasColumnType("bit");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Tafsirs");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirEpisode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AudioUrl")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("AyahNumber")
.HasColumnType("int");
b.Property<int>("DurationSeconds")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<string>("Summary")
.HasColumnType("nvarchar(max)");
b.Property<int>("SurahId")
.HasColumnType("int");
b.Property<Guid>("TafsirId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.HasKey("Id");
b.HasIndex("SurahId");
b.HasIndex("TafsirId", "SortOrder");
b.ToTable("Episodes");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.Ayah", b =>
{
b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah")
.WithMany("Ayahs")
.HasForeignKey("SurahId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Surah");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.Bookmark", b =>
{
b.HasOne("TafsirQuran.Domain.Entities.TafsirEpisode", "Episode")
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User")
.WithMany("Bookmarks")
.HasForeignKey("UserId");
b.Navigation("Episode");
b.Navigation("User");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.Favorite", b =>
{
b.HasOne("TafsirQuran.Domain.Entities.TafsirEpisode", "Episode")
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User")
.WithMany("Favorites")
.HasForeignKey("UserId");
b.Navigation("Episode");
b.Navigation("User");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.ListeningProgress", b =>
{
b.HasOne("TafsirQuran.Domain.Entities.TafsirEpisode", "Episode")
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User")
.WithMany("ListeningProgresses")
.HasForeignKey("UserId");
b.Navigation("Episode");
b.Navigation("User");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirEpisode", b =>
{
b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah")
.WithMany("Episodes")
.HasForeignKey("SurahId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir")
.WithMany("Episodes")
.HasForeignKey("TafsirId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Surah");
b.Navigation("Tafsir");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.AppUser", b =>
{
b.Navigation("Bookmarks");
b.Navigation("Favorites");
b.Navigation("ListeningProgresses");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.Surah", b =>
{
b.Navigation("Ayahs");
b.Navigation("Episodes");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b =>
{
b.Navigation("Episodes");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TafsirQuran.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class SurahExplicitIds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "Id",
table: "Surahs",
type: "int",
nullable: false,
oldClrType: typeof(int),
oldType: "int")
.OldAnnotation("SqlServer:Identity", "1, 1");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "Id",
table: "Surahs",
type: "int",
nullable: false,
oldClrType: typeof(int),
oldType: "int")
.Annotation("SqlServer:Identity", "1, 1");
}
}
}
@@ -187,11 +187,8 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
modelBuilder.Entity("TafsirQuran.Domain.Entities.Surah", b => modelBuilder.Entity("TafsirQuran.Domain.Entities.Surah", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int"); .HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("NameAr") b.Property<string>("NameAr")
.IsRequired() .IsRequired()
.HasMaxLength(80) .HasMaxLength(80)
@@ -216,7 +213,6 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b => modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
b.Property<string>("Author") b.Property<string>("Author")