diff --git a/CLAUDE.md b/CLAUDE.md index 8d0f91a..dbf6902 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,7 +104,9 @@ or with inline pages: } ``` -**`BuilderJob` entity** (PostgreSQL, snake_case naming): `Id`, `SourceUri`, `SourceDataJson`, `Status`, `Created`, `Started`, `Finished`, `TotalPages`, `PagesCompleted`, `TotalWordCount`, `TotalImageCount`, `Errors`, `HangfireJobId`, `Services` (bitmask), `Title`, `CustomTypesJson`. +**`BuilderJob` entity** (PostgreSQL, snake_case naming): `Id`, `SourceUri`, `SourceDataJson`, `Status`, `Created`, `Started`, `Finished`, `TotalPages`, `PagesCompleted`, `TotalWordCount`, `TotalImageCount`, `Errors`, `HangfireJobId`, `Services` (bitmask), `Title`, `CustomTypesJson`, `InvocationCount`. + +**`InvocationCount`**: tracks how many times the job processor has run for this job. Set to `1` on initial `POST`, incremented by 1 on every `PUT` (reprocess). Existing rows default to `1`. Returned on the `POST`/`PUT`/`GET` `JobResponse` and included in `JobCompletionNotification`, so callers can tell which run a completion notification belongs to. Server-managed only — not settable by the caller. **`TextBuildJob`** (Hangfire): fetches manifest/resources concurrently (bounded by `MaxConcurrentPageFetches`), feeds `TextBuilder`, persists all artefacts, records per-page warnings without aborting the job. diff --git a/src/TextServices.Builder.Api/Data/BuilderJob.cs b/src/TextServices.Builder.Api/Data/BuilderJob.cs index 11cfb2f..4a00787 100644 --- a/src/TextServices.Builder.Api/Data/BuilderJob.cs +++ b/src/TextServices.Builder.Api/Data/BuilderJob.cs @@ -58,4 +58,11 @@ public class BuilderJob /// A value of 0 means processing completed but no derivatives could be produced. /// public int? FulfilledServices { get; set; } + + /// + /// Number of times the job processor has been invoked for this job. Set to 1 on + /// initial creation (POST) and incremented by 1 on every reprocess (PUT), + /// so callers can tell which run a completion notification belongs to. + /// + public int InvocationCount { get; set; } = 1; } diff --git a/src/TextServices.Builder.Api/Features/Jobs/JobResponse.cs b/src/TextServices.Builder.Api/Features/Jobs/JobResponse.cs index de27682..abd56d6 100644 --- a/src/TextServices.Builder.Api/Features/Jobs/JobResponse.cs +++ b/src/TextServices.Builder.Api/Features/Jobs/JobResponse.cs @@ -25,6 +25,12 @@ public class JobResponse public int TotalImageCount { get; set; } public string? Errors { get; set; } + /// + /// Number of times the job processor has been invoked for this job. 1 on initial + /// creation, incremented on every reprocess (PUT). + /// + public int InvocationCount { get; set; } = 1; + /// /// The services that were actually produced during the most recent run. /// Null for jobs processed before this field was introduced. @@ -119,6 +125,7 @@ public static JobResponse From(BuilderJob job, TextServicesOptions options) TotalWordCount = job.TotalWordCount, TotalImageCount = job.TotalImageCount, Errors = job.Errors, + InvocationCount = job.InvocationCount, SearchV1 = searchV1, AutocompleteV1 = autocompleteV1, SearchV2 = searchV2, diff --git a/src/TextServices.Builder.Api/Features/Jobs/ReprocessJob.cs b/src/TextServices.Builder.Api/Features/Jobs/ReprocessJob.cs index 52372cb..8c2f2ce 100644 --- a/src/TextServices.Builder.Api/Features/Jobs/ReprocessJob.cs +++ b/src/TextServices.Builder.Api/Features/Jobs/ReprocessJob.cs @@ -61,6 +61,7 @@ public async Task Handle(ReprocessJobRequest request, Cancel job.TotalImageCount = 0; job.Errors = null; job.HangfireJobId = null; + job.InvocationCount++; await db.SaveChangesAsync(ct); diff --git a/src/TextServices.Builder.Api/Jobs/TextBuildJob.cs b/src/TextServices.Builder.Api/Jobs/TextBuildJob.cs index 95b4c2d..8332848 100644 --- a/src/TextServices.Builder.Api/Jobs/TextBuildJob.cs +++ b/src/TextServices.Builder.Api/Jobs/TextBuildJob.cs @@ -83,7 +83,7 @@ public async Task ExecuteAsync(string jobId, IJobCancellationToken cancellationT await jobNotifier.Notify( new JobCompletionNotification(job.Id, job.Status, job.Finished, - job.TotalPages, job.TotalWordCount, job.Errors), + job.TotalPages, job.TotalWordCount, job.Errors, job.InvocationCount), cancellationToken.ShutdownToken); } catch (Exception ex) @@ -97,7 +97,7 @@ await jobNotifier.Notify( await jobNotifier.Notify( new JobCompletionNotification(job.Id, job.Status, job.Finished, - job.TotalPages, job.TotalWordCount, job.Errors), + job.TotalPages, job.TotalWordCount, job.Errors, job.InvocationCount), cancellationToken.ShutdownToken); } } diff --git a/src/TextServices.Builder.Api/Migrations/20260706104802_AddInvocationCountToBuilderJob.Designer.cs b/src/TextServices.Builder.Api/Migrations/20260706104802_AddInvocationCountToBuilderJob.Designer.cs new file mode 100644 index 0000000..10023ef --- /dev/null +++ b/src/TextServices.Builder.Api/Migrations/20260706104802_AddInvocationCountToBuilderJob.Designer.cs @@ -0,0 +1,112 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TextServices.Builder.Api.Data; + +#nullable disable + +namespace TextServices.Builder.Api.Migrations +{ + [DbContext(typeof(BuilderDbContext))] + [Migration("20260706104802_AddInvocationCountToBuilderJob")] + partial class AddInvocationCountToBuilderJob + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TextServices.Builder.Api.Data.BuilderJob", b => + { + b.Property("Id") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("CustomTypesJson") + .HasColumnType("text") + .HasColumnName("custom_types_json"); + + b.Property("Errors") + .HasColumnType("text") + .HasColumnName("errors"); + + b.Property("Finished") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished"); + + b.Property("FulfilledServices") + .HasColumnType("integer") + .HasColumnName("fulfilled_services"); + + b.Property("HangfireJobId") + .HasColumnType("text") + .HasColumnName("hangfire_job_id"); + + b.Property("InvocationCount") + .HasColumnType("integer") + .HasColumnName("invocation_count"); + + b.Property("PagesCompleted") + .HasColumnType("integer") + .HasColumnName("pages_completed"); + + b.Property("Services") + .HasColumnType("integer") + .HasColumnName("services"); + + b.Property("SourceDataJson") + .HasColumnType("text") + .HasColumnName("source_data_json"); + + b.Property("SourceUri") + .HasColumnType("text") + .HasColumnName("source_uri"); + + b.Property("Started") + .HasColumnType("timestamp with time zone") + .HasColumnName("started"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("Title") + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("TotalImageCount") + .HasColumnType("integer") + .HasColumnName("total_image_count"); + + b.Property("TotalPages") + .HasColumnType("integer") + .HasColumnName("total_pages"); + + b.Property("TotalWordCount") + .HasColumnType("integer") + .HasColumnName("total_word_count"); + + b.HasKey("Id") + .HasName("pk_jobs"); + + b.ToTable("jobs", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/TextServices.Builder.Api/Migrations/20260706104802_AddInvocationCountToBuilderJob.cs b/src/TextServices.Builder.Api/Migrations/20260706104802_AddInvocationCountToBuilderJob.cs new file mode 100644 index 0000000..010c0d1 --- /dev/null +++ b/src/TextServices.Builder.Api/Migrations/20260706104802_AddInvocationCountToBuilderJob.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TextServices.Builder.Api.Migrations +{ + /// + public partial class AddInvocationCountToBuilderJob : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "invocation_count", + table: "jobs", + type: "integer", + nullable: false, + defaultValue: 1); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "invocation_count", + table: "jobs"); + } + } +} diff --git a/src/TextServices.Builder.Api/Migrations/BuilderDbContextModelSnapshot.cs b/src/TextServices.Builder.Api/Migrations/BuilderDbContextModelSnapshot.cs index 846902a..d548f36 100644 --- a/src/TextServices.Builder.Api/Migrations/BuilderDbContextModelSnapshot.cs +++ b/src/TextServices.Builder.Api/Migrations/BuilderDbContextModelSnapshot.cs @@ -53,6 +53,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("hangfire_job_id"); + b.Property("InvocationCount") + .HasColumnType("integer") + .HasColumnName("invocation_count"); + b.Property("PagesCompleted") .HasColumnType("integer") .HasColumnName("pages_completed"); diff --git a/src/TextServices.Builder.Api/Services/Notifications/JobCompletionNotification.cs b/src/TextServices.Builder.Api/Services/Notifications/JobCompletionNotification.cs index 45638c7..d4e59c9 100644 --- a/src/TextServices.Builder.Api/Services/Notifications/JobCompletionNotification.cs +++ b/src/TextServices.Builder.Api/Services/Notifications/JobCompletionNotification.cs @@ -8,4 +8,5 @@ public record JobCompletionNotification( DateTimeOffset? Finished, int TotalPages, int TotalWordCount, - string? Errors); + string? Errors, + int InvocationCount); diff --git a/src/TextServices.Demo/wwwroot/builder.html b/src/TextServices.Demo/wwwroot/builder.html index 33cf804..b73f002 100644 --- a/src/TextServices.Demo/wwwroot/builder.html +++ b/src/TextServices.Demo/wwwroot/builder.html @@ -60,6 +60,7 @@

Jobs

ID Source Status + Invocations Progress Words Actions diff --git a/src/TextServices.Demo/wwwroot/js/builder.js b/src/TextServices.Demo/wwwroot/js/builder.js index ab7c8bc..e182609 100644 --- a/src/TextServices.Demo/wwwroot/js/builder.js +++ b/src/TextServices.Demo/wwwroot/js/builder.js @@ -195,6 +195,7 @@ function renderTable(rows) { ${esc(id)} ${esc(truncate(sourceUri, 50))} ${esc(status)} + ${job?.invocationCount ?? '—'} ${renderProgress(job)} ${job?.totalWordCount != null ? job.totalWordCount.toLocaleString() : '—'} diff --git a/src/TextServices.Search.Api/TextServices.Search.Api.csproj b/src/TextServices.Search.Api/TextServices.Search.Api.csproj index 2fa1628..518c738 100644 --- a/src/TextServices.Search.Api/TextServices.Search.Api.csproj +++ b/src/TextServices.Search.Api/TextServices.Search.Api.csproj @@ -12,6 +12,7 @@ + diff --git a/src/TextServices.Tests/BuilderApi/JobResponseTests.cs b/src/TextServices.Tests/BuilderApi/JobResponseTests.cs index a4f3ff4..f4e8031 100644 --- a/src/TextServices.Tests/BuilderApi/JobResponseTests.cs +++ b/src/TextServices.Tests/BuilderApi/JobResponseTests.cs @@ -22,6 +22,28 @@ private static BuilderJob CompletedJob( Status = JobStatus.Completed, }; + // ------------------------------------------------------------------------- + // InvocationCount — reflects the job's current run count + // ------------------------------------------------------------------------- + + [Fact] + public void From_NewJob_InvocationCountIsOne() + { + var response = JobResponse.From(CompletedJob(), Options()); + response.InvocationCount.ShouldBe(1); + } + + [Fact] + public void From_ReprocessedJob_InvocationCountReflectsJobValue() + { + var job = CompletedJob(); + job.InvocationCount = 3; + + var response = JobResponse.From(job, Options()); + + response.InvocationCount.ShouldBe(3); + } + // ------------------------------------------------------------------------- // FulfilledServices field // ------------------------------------------------------------------------- diff --git a/src/TextServices.Tests/BuilderApi/SnsJobNotifierTests.cs b/src/TextServices.Tests/BuilderApi/SnsJobNotifierTests.cs index 18ce0e6..f8e0818 100644 --- a/src/TextServices.Tests/BuilderApi/SnsJobNotifierTests.cs +++ b/src/TextServices.Tests/BuilderApi/SnsJobNotifierTests.cs @@ -15,7 +15,7 @@ public sealed class SnsJobNotifierTests private static JobCompletionNotification MakeNotification( string jobId = "my/job", JobStatus status = JobStatus.Completed) => - new(jobId, status, DateTimeOffset.UtcNow, 10, 500, null); + new(jobId, status, DateTimeOffset.UtcNow, 10, 500, null, 1); // ------------------------------------------------------------------------- // No-op when TopicArn is absent diff --git a/src/TextServices.Tests/BuilderApi/TextBuildJobTests.cs b/src/TextServices.Tests/BuilderApi/TextBuildJobTests.cs index 6463f40..adb2dc7 100644 --- a/src/TextServices.Tests/BuilderApi/TextBuildJobTests.cs +++ b/src/TextServices.Tests/BuilderApi/TextBuildJobTests.cs @@ -598,6 +598,32 @@ public async Task ExecuteAsync_OnSuccess_NotifiesWithCompletedStatus() notifier.Captured[0].Status.ShouldBe(JobStatus.Completed); } + [Fact] + public async Task ExecuteAsync_OnSuccess_NotificationCarriesInvocationCount() + { + var pages = new List + { + new() { Id = "https://example.org/c/1", Width = 1000, Height = 1500, + TextUri = "https://example.org/alto/1.xml" }, + }; + + var job = await CreateJob("test/notify-invocation-count", + sourceDataJson: JsonSerializer.Serialize(pages), + invocationCount: 2); + + var altoFetcher = FakeAlto(new Dictionary + { + ["https://example.org/alto/1.xml"] = SampleAlto("hello world"), + }); + + var notifier = new CapturingJobNotifier(); + var sut = MakeJob(altoFetcher: altoFetcher, jobNotifier: notifier); + await sut.ExecuteAsync(job.Id, FakeCancellationToken.Instance); + + notifier.Captured.ShouldHaveSingleItem(); + notifier.Captured[0].InvocationCount.ShouldBe(2); + } + [Fact] public async Task ExecuteAsync_OnFailure_NotifiesWithFailedStatus() { @@ -622,7 +648,7 @@ public async Task ExecuteAsync_OnFailure_NotifiesWithFailedStatus() private async Task CreateJob(string id, string? sourceUri = null, string? sourceDataJson = null, - JobServices services = JobServices.All) + JobServices services = JobServices.All, int invocationCount = 1) { var job = new BuilderJob { @@ -630,6 +656,7 @@ private async Task CreateJob(string id, SourceUri = sourceUri, SourceDataJson = sourceDataJson, Services = (int)services, + InvocationCount = invocationCount, }; _db.Jobs.Add(job); await _db.SaveChangesAsync();