feat(processing): persist safe atomic operation history
This commit is contained in:
@@ -79,10 +79,11 @@ public sealed class StartupAndLockTests(ITestOutputHelper output)
|
||||
db.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
var migrations = (await db.Database.GetAppliedMigrationsAsync()).ToArray();
|
||||
Assert.Equal("20261026090000_AddCvImportReviewEvidence", migrations[^1]);
|
||||
Assert.Equal("20261028090000_AddUserOperationEvents", migrations[^1]);
|
||||
// Only this test's newly-created schema is downgraded; no existing database is selected.
|
||||
await db.GetService<IMigrator>().MigrateAsync(migrations[^3]);
|
||||
Assert.Equal(migrations[^2..], await db.Database.GetPendingMigrationsAsync());
|
||||
const string beforeRd21 = "20261021090000_AddDashboardLayoutPreference";
|
||||
await db.GetService<IMigrator>().MigrateAsync(beforeRd21);
|
||||
Assert.Equal(migrations.SkipWhile(migration => migration != beforeRd21).Skip(1), await db.Database.GetPendingMigrationsAsync());
|
||||
db.ChangeTracker.Clear();
|
||||
await database.App.InitializeJobTrackerAsync();
|
||||
await using var verify = database.Context();
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.MariaDb.Tests;
|
||||
|
||||
public sealed class UserOperationHistoryPersistenceTests
|
||||
{
|
||||
private static CreateUserOperation Request(string key) => new("cv.process", key, "authorized", "local_only");
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_claim_and_repeated_stage_have_one_durable_event_each()
|
||||
{
|
||||
await using var database = await MariaDbDatabase.CreateAsync();
|
||||
await using (var owner = database.Context())
|
||||
await new UserOperationStore(owner, TimeProvider.System).CreateAsync(Request("history-concurrency"), default);
|
||||
await using var first = database.Context(null!);
|
||||
await using var second = database.Context(null!);
|
||||
var claims = await Task.WhenAll(new UserOperationStore(first, TimeProvider.System).ClaimNextAsync(TimeSpan.FromSeconds(30), default),
|
||||
new UserOperationStore(second, TimeProvider.System).ClaimNextAsync(TimeSpan.FromSeconds(30), default));
|
||||
var lease = Assert.Single(claims.OfType<UserOperationLease>());
|
||||
await using var ownerOne = database.Context();
|
||||
await using var ownerTwo = database.Context();
|
||||
var stages = await Task.WhenAll(new UserOperationStore(ownerOne, TimeProvider.System).ReportStageAsync(lease.OperationId, lease.LeaseToken, "cv_extracting", default),
|
||||
new UserOperationStore(ownerTwo, TimeProvider.System).ReportStageAsync(lease.OperationId, lease.LeaseToken, "cv_extracting", default));
|
||||
Assert.All(stages, count => Assert.Equal(1, count));
|
||||
await using var readback = database.Context();
|
||||
var events = await readback.UserOperationEvents.OrderBy(item => item.Id).ToListAsync();
|
||||
Assert.Equal(new[] { "Created", "Claimed", "StageChanged" }, events.Select(item => item.Kind));
|
||||
Assert.All(events, item => Assert.Equal("rd21-owner", item.OwnerUserId));
|
||||
await using var other = database.Context("other");
|
||||
Assert.Empty(await other.UserOperationEvents.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Event_insert_failure_rolls_back_completion_and_notifications_in_MariaDB()
|
||||
{
|
||||
await using var database = await MariaDbDatabase.CreateAsync();
|
||||
await using var owner = database.Context();
|
||||
var store = new UserOperationStore(owner, TimeProvider.System);
|
||||
var id = (await store.CreateAsync(Request("history-rollback"), default)).Operation.Id;
|
||||
await using var worker = database.Context(null!);
|
||||
var lease = Assert.IsType<UserOperationLease>(await new UserOperationStore(worker, TimeProvider.System).ClaimNextAsync(TimeSpan.FromSeconds(30), default));
|
||||
await owner.Database.ExecuteSqlRawAsync("CREATE TRIGGER fail_history BEFORE INSERT ON UserOperationEvents FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'synthetic event insert failure';");
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => store.CompleteAsync(id, lease.LeaseToken, null, default));
|
||||
await using var readback = database.Context();
|
||||
Assert.Equal(OperationStatuses.Running, (await readback.UserOperations.SingleAsync()).Status);
|
||||
Assert.Equal(2, await readback.UserOperationEvents.CountAsync());
|
||||
Assert.Empty(await readback.UserNotifications.ToListAsync());
|
||||
}
|
||||
}
|
||||
@@ -362,9 +362,15 @@ public sealed class CvProcessingOperationTests
|
||||
|
||||
public static async Task<Fixture> CreateAsync()
|
||||
{
|
||||
var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), $"jobtracker-cv-operation-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
var connection = new SqliteConnection($"Data Source={Path.Combine(tempRoot, "operations.db")};Pooling=False;Default Timeout=5");
|
||||
await connection.OpenAsync();
|
||||
await using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = "PRAGMA journal_mode=WAL;";
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Data:Root"] = tempRoot,
|
||||
@@ -407,7 +413,8 @@ public sealed class CvProcessingOperationTests
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddScoped<CurrentUserService>();
|
||||
services.AddScoped<ICurrentUserService>(provider => provider.GetRequiredService<CurrentUserService>());
|
||||
services.AddDbContext<JobTrackerContext>((_, options) => options.UseSqlite(connection));
|
||||
// Separate connections observe committed state, unlike a shared in-memory connection.
|
||||
services.AddDbContext<JobTrackerContext>((_, options) => options.UseSqlite(connection.ConnectionString));
|
||||
services.AddIdentityCore<ApplicationUser>().AddRoles<IdentityRole>().AddEntityFrameworkStores<JobTrackerContext>();
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton(new AppPaths(configuration, environment.Object));
|
||||
|
||||
@@ -9,6 +9,171 @@ namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class UserOperationStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Retry_uses_current_durable_state_and_emits_one_event_without_a_tracked_stale_operation()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await using var owner = fixture.Context("user-1");
|
||||
var store = fixture.Store(owner);
|
||||
var id = (await store.CreateAsync(Request("retry-current"), default)).Operation.Id;
|
||||
await using var worker = fixture.Context(null);
|
||||
var lease = Assert.IsType<UserOperationLease>(await fixture.Store(worker).ClaimNextAsync(TimeSpan.FromSeconds(20), default));
|
||||
Assert.True(await store.FailAsync(id, lease.LeaseToken, false, "timeout", "Synthetic timeout", TimeSpan.Zero, default));
|
||||
Assert.True(await store.RetryAsync(id, default));
|
||||
Assert.False(await store.RetryAsync(id, default));
|
||||
await using var readback = fixture.Context("user-1");
|
||||
Assert.Equal(OperationStatuses.Queued, (await readback.UserOperations.SingleAsync()).Status);
|
||||
Assert.Single(await readback.UserOperationEvents.Where(item => item.Kind == "Retried").ToListAsync());
|
||||
Assert.Empty(await readback.UserNotifications.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stage_history_is_deduplicated_owner_scoped_and_redacts_unknown_values()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await using var owner = fixture.Context("user-1");
|
||||
var store = fixture.Store(owner);
|
||||
var id = (await store.CreateAsync(Request("stage-history"), default)).Operation.Id;
|
||||
await using var worker = fixture.Context(null);
|
||||
var lease = Assert.IsType<UserOperationLease>(await fixture.Store(worker).ClaimNextAsync(TimeSpan.FromSeconds(20), default));
|
||||
Assert.Equal(0, await store.ReportStageAsync(id, "wrong", "cv_extracting", default));
|
||||
Assert.Equal(1, await store.ReportStageAsync(id, lease.LeaseToken, "cv_extracting", default));
|
||||
Assert.Equal(1, await store.ReportStageAsync(id, lease.LeaseToken, "cv_extracting", default));
|
||||
Assert.Equal(1, await store.ReportStageAsync(id, lease.LeaseToken, "SECRET document and token", default));
|
||||
Assert.True(await store.FailAsync(id, lease.LeaseToken, false, "SECRET error", "SECRET document content", TimeSpan.Zero, default));
|
||||
var events = await owner.UserOperationEvents.OrderBy(item => item.Id).ToListAsync();
|
||||
Assert.Equal(new[] { "Created", "Claimed", "StageChanged", "StageChanged", "Failed" }, events.Select(item => item.Kind));
|
||||
Assert.Equal("cv_extracting", events[2].Stage);
|
||||
Assert.Null(events[3].Stage);
|
||||
Assert.Equal("processing_failed", events[4].FailureCode);
|
||||
Assert.DoesNotContain("SECRET", System.Text.Json.JsonSerializer.Serialize(events));
|
||||
Assert.Empty(await worker.UserOperationEvents.ToListAsync());
|
||||
await using var other = fixture.Context("user-2");
|
||||
Assert.Empty(await other.UserOperationEvents.ToListAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Claimed")]
|
||||
[InlineData("StageChanged")]
|
||||
[InlineData("Completed")]
|
||||
[InlineData("Retried")]
|
||||
public async Task Failed_event_insert_rolls_back_claim_stage_or_completion(string kind)
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await using var owner = fixture.Context("user-1");
|
||||
var store = fixture.Store(owner);
|
||||
var id = (await store.CreateAsync(Request("rollback-history"), default)).Operation.Id;
|
||||
await using var worker = fixture.Context(null);
|
||||
UserOperationLease? lease = null;
|
||||
if (kind != "Claimed") lease = await fixture.Store(worker).ClaimNextAsync(TimeSpan.FromSeconds(20), default);
|
||||
if (kind == "Retried") Assert.True(await store.FailAsync(id, lease!.LeaseToken, false, "timeout", "Synthetic timeout", TimeSpan.Zero, default));
|
||||
await owner.Database.ExecuteSqlRawAsync("CREATE TRIGGER fail_history BEFORE INSERT ON UserOperationEvents BEGIN SELECT RAISE(ABORT, 'synthetic event failure'); END;");
|
||||
await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
{
|
||||
if (kind == "Claimed") await fixture.Store(worker).ClaimNextAsync(TimeSpan.FromSeconds(20), default);
|
||||
else if (kind == "StageChanged") await store.ReportStageAsync(id, lease!.LeaseToken, "cv_extracting", default);
|
||||
else if (kind == "Retried") await store.RetryAsync(id, default);
|
||||
else await store.CompleteAsync(id, lease!.LeaseToken, null, default);
|
||||
});
|
||||
await using var readback = fixture.Context("user-1");
|
||||
var operation = await readback.UserOperations.SingleAsync();
|
||||
Assert.Equal(kind == "Claimed" ? OperationStatuses.Queued : kind == "Retried" ? OperationStatuses.Failed : OperationStatuses.Running, operation.Status);
|
||||
Assert.Equal(kind == "Claimed" ? 0 : 1, operation.AttemptCount);
|
||||
Assert.Null(operation.ProgressStage);
|
||||
Assert.Equal(kind == "Retried" ? 1 : 0, await readback.UserNotifications.CountAsync());
|
||||
Assert.Equal(kind == "Claimed" ? 1 : kind == "Retried" ? 3 : 2, await readback.UserOperationEvents.CountAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Expired_or_cancel_requested_lease_cannot_publish_a_stage(bool cancel)
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await using var owner = fixture.Context("user-1");
|
||||
var store = fixture.Store(owner);
|
||||
var id = (await store.CreateAsync(Request("inactive-stage"), default)).Operation.Id;
|
||||
await using var worker = fixture.Context(null);
|
||||
var lease = Assert.IsType<UserOperationLease>(await fixture.Store(worker).ClaimNextAsync(TimeSpan.FromSeconds(20), default));
|
||||
if (cancel) Assert.True(await store.RequestCancellationAsync(id, default));
|
||||
else fixture.Time.Advance(TimeSpan.FromSeconds(21));
|
||||
Assert.Equal(0, await store.ReportStageAsync(id, lease.LeaseToken, "cv_extracting", default));
|
||||
Assert.Empty(await owner.UserOperationEvents.Where(item => item.Kind == "StageChanged").ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_recovery_history_matches_actual_reclaims_and_final_expiry()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await using var owner = fixture.Context("user-1");
|
||||
await fixture.Store(owner).CreateAsync(Request("recover-history", maxAttempts: 2), default);
|
||||
await using var worker = fixture.Context(null);
|
||||
var store = fixture.Store(worker);
|
||||
Assert.NotNull(await store.ClaimNextAsync(TimeSpan.FromSeconds(5), default));
|
||||
fixture.Time.Advance(TimeSpan.FromSeconds(6));
|
||||
Assert.NotNull(await store.ClaimNextAsync(TimeSpan.FromSeconds(5), default));
|
||||
fixture.Time.Advance(TimeSpan.FromSeconds(6));
|
||||
Assert.Null(await store.ClaimNextAsync(TimeSpan.FromSeconds(5), default));
|
||||
var events = await owner.UserOperationEvents.OrderBy(item => item.Id).ToListAsync();
|
||||
Assert.Equal(new[] { "Created", "Claimed", "LeaseRecovered", "Claimed", "Expired" }, events.Select(item => item.Kind));
|
||||
Assert.Equal(new[] { 0, 1, 1, 2, 2 }, events.Select(item => item.Attempt));
|
||||
Assert.Equal(OperationStatuses.Failed, events[^1].Status);
|
||||
Assert.Equal("lease_expired", events[^1].FailureCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stage_event_does_not_flush_unrelated_tracked_user_changes()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await using var owner = fixture.Context("user-1");
|
||||
owner.Users.Add(new() { Id = "user-1", UserName = "synthetic", DisplayName = "Original" });
|
||||
var store = fixture.Store(owner);
|
||||
var id = (await store.CreateAsync(Request("no-flush"), default)).Operation.Id;
|
||||
await using var worker = fixture.Context(null);
|
||||
var lease = Assert.IsType<UserOperationLease>(await fixture.Store(worker).ClaimNextAsync(TimeSpan.FromSeconds(20), default));
|
||||
(await owner.Users.SingleAsync()).DisplayName = "Uncommitted";
|
||||
Assert.Equal(1, await store.ReportStageAsync(id, lease.LeaseToken, "cv_extracting", default));
|
||||
await using var readback = fixture.Context("user-1");
|
||||
Assert.Equal("Original", (await readback.Users.SingleAsync()).DisplayName);
|
||||
Assert.Equal(3, await readback.UserOperationEvents.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task History_retention_and_operation_deletion_preserve_other_owners()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await using var owner = fixture.Context("user-1");
|
||||
await fixture.Store(owner).CreateAsync(Request("old-history"), default);
|
||||
fixture.Time.Advance(TimeSpan.FromDays(31));
|
||||
await using var other = fixture.Context("user-2");
|
||||
await fixture.Store(other).CreateAsync(Request("fresh-history"), default);
|
||||
await using var neutral = fixture.Context(null);
|
||||
Assert.Equal(1, await OperationEventLog.PruneAsync(neutral, fixture.Time.GetUtcNow().UtcDateTime, default));
|
||||
Assert.Empty(await owner.UserOperationEvents.ToListAsync());
|
||||
Assert.Single(await other.UserOperationEvents.ToListAsync());
|
||||
Assert.Single(await owner.UserOperations.AsNoTracking().ToListAsync());
|
||||
await other.UserOperations.ExecuteDeleteAsync();
|
||||
Assert.Empty(await neutral.UserOperationEvents.IgnoreQueryFilters().ToListAsync());
|
||||
Assert.Single(await owner.UserOperations.AsNoTracking().ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Durable_history_records_only_successful_transitions_not_duplicate_creation_or_heartbeat()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await using var owner = fixture.Context("user-1");
|
||||
var store = fixture.Store(owner);
|
||||
var created = await store.CreateAsync(Request("history"), default);
|
||||
await store.CreateAsync(Request("history"), default);
|
||||
Assert.Equal(1, await owner.Database.SqlQueryRaw<int>("SELECT COUNT(*) AS Value FROM UserOperationEvents").SingleAsync());
|
||||
await using var worker = fixture.Context(null);
|
||||
var lease = Assert.IsType<UserOperationLease>(await fixture.Store(worker).ClaimNextAsync(TimeSpan.FromSeconds(20), default));
|
||||
Assert.Equal(1, await store.HeartbeatAsync(created.Operation.Id, lease.LeaseToken, TimeSpan.FromSeconds(20), null, null, default));
|
||||
Assert.Equal(1, await store.CompleteAsync(created.Operation.Id, lease.LeaseToken, null, default));
|
||||
Assert.Equal(0, await store.CompleteAsync(created.Operation.Id, lease.LeaseToken, null, default));
|
||||
Assert.Equal(3, await owner.Database.SqlQueryRaw<int>("SELECT COUNT(*) AS Value FROM UserOperationEvents").SingleAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_is_idempotent_within_owner_and_isolated_between_owners()
|
||||
{
|
||||
|
||||
@@ -23,8 +23,8 @@ public sealed partial class ProfileCvController
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (context is null) return;
|
||||
var lease = context.Lease;
|
||||
var updated = await _db.UserOperations.Where(o => o.Id == lease.OperationId && o.LeaseToken == lease.LeaseToken && o.Status == OperationStatuses.Running && o.CancellationRequestedAtUtc == null && o.LeaseExpiresAtUtc > DateTime.UtcNow)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(o => o.ProgressStage, stage), cancellationToken);
|
||||
var updated = await new UserOperationStore(_db, TimeProvider.System)
|
||||
.ReportStageAsync(lease.OperationId, lease.LeaseToken, stage, cancellationToken);
|
||||
if (updated != 1) throw new OperationCanceledException("The document processing lease is no longer active.", cancellationToken);
|
||||
}
|
||||
await Stage("cv_validating_file");
|
||||
|
||||
@@ -66,6 +66,7 @@ namespace JobTrackerApi.Data
|
||||
public DbSet<SubmittedPackageAttachment> SubmittedPackageAttachments => Set<SubmittedPackageAttachment>();
|
||||
public DbSet<InterviewPrepItem> InterviewPrepItems => Set<InterviewPrepItem>();
|
||||
public DbSet<UserOperation> UserOperations => Set<UserOperation>();
|
||||
public DbSet<UserOperationEvent> UserOperationEvents => Set<UserOperationEvent>();
|
||||
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
|
||||
public DbSet<EmailSendAttempt> EmailSendAttempts => Set<EmailSendAttempt>();
|
||||
public DbSet<EmailDraft> EmailDrafts => Set<EmailDraft>();
|
||||
@@ -321,6 +322,16 @@ namespace JobTrackerApi.Data
|
||||
modelBuilder.Entity<UserOperation>()
|
||||
.HasIndex(x => new { x.Status, x.AvailableAtUtc, x.Priority });
|
||||
|
||||
modelBuilder.Entity<UserOperationEvent>().HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||
modelBuilder.Entity<UserOperationEvent>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
||||
modelBuilder.Entity<UserOperationEvent>().Property(x => x.Kind).HasMaxLength(40);
|
||||
modelBuilder.Entity<UserOperationEvent>().Property(x => x.Status).HasMaxLength(32);
|
||||
modelBuilder.Entity<UserOperationEvent>().Property(x => x.Stage).HasMaxLength(64);
|
||||
modelBuilder.Entity<UserOperationEvent>().Property(x => x.FailureCode).HasMaxLength(64);
|
||||
modelBuilder.Entity<UserOperationEvent>().HasIndex(x => new { x.OperationId, x.Id });
|
||||
modelBuilder.Entity<UserOperationEvent>().HasIndex(x => x.AtUtc);
|
||||
modelBuilder.Entity<UserOperationEvent>().HasOne<UserOperation>().WithMany().HasForeignKey(x => x.OperationId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<UserNotification>()
|
||||
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||
modelBuilder.Entity<UserNotification>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using JobTrackerApi.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace JobTrackerApi.Migrations;
|
||||
|
||||
[DbContext(typeof(JobTrackerContext))]
|
||||
[Migration("20261028090000_AddUserOperationEvents")]
|
||||
public sealed class AddUserOperationEvents : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
if (ActiveProvider?.Contains("MySql", StringComparison.OrdinalIgnoreCase) == true)
|
||||
migrationBuilder.Sql("""
|
||||
CREATE TABLE `UserOperationEvents` (
|
||||
`Id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`OperationId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`AtUtc` datetime(6) NOT NULL,
|
||||
`Kind` varchar(40) NOT NULL,
|
||||
`Status` varchar(32) NOT NULL,
|
||||
`Stage` varchar(64) NULL,
|
||||
`Attempt` int NOT NULL,
|
||||
`FailureCode` varchar(64) NULL,
|
||||
CONSTRAINT `PK_UserOperationEvents` PRIMARY KEY (`Id`),
|
||||
CONSTRAINT `FK_UserOperationEvents_UserOperations_OperationId` FOREIGN KEY (`OperationId`) REFERENCES `UserOperations` (`Id`) ON DELETE CASCADE
|
||||
) CHARACTER SET=utf8mb4;
|
||||
""");
|
||||
else
|
||||
migrationBuilder.CreateTable("UserOperationEvents", table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "INTEGER", nullable: false).Annotation("Sqlite:Autoincrement", true),
|
||||
OperationId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
OwnerUserId = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
|
||||
AtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
Kind = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
Stage = table.Column<string>(type: "TEXT", maxLength: 64, nullable: true),
|
||||
Attempt = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
FailureCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: true),
|
||||
}, constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserOperationEvents", x => x.Id);
|
||||
table.ForeignKey("FK_UserOperationEvents_UserOperations_OperationId", x => x.OperationId, "UserOperations", "Id", onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
migrationBuilder.CreateIndex("IX_UserOperationEvents_OperationId_Id", "UserOperationEvents", new[] { "OperationId", "Id" });
|
||||
migrationBuilder.CreateIndex("IX_UserOperationEvents_AtUtc", "UserOperationEvents", "AtUtc");
|
||||
}
|
||||
protected override void Down(MigrationBuilder migrationBuilder) => migrationBuilder.DropTable("UserOperationEvents");
|
||||
}
|
||||
@@ -2265,6 +2265,24 @@ namespace JobTrackerApi.Migrations
|
||||
b.ToTable("TwoFactorRecoveryCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.UserOperationEvent", b =>
|
||||
{
|
||||
b.Property<long>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
|
||||
b.Property<Guid>("OperationId").HasColumnType("TEXT");
|
||||
b.Property<string>("OwnerUserId").IsRequired().HasMaxLength(255).HasColumnType("TEXT");
|
||||
b.Property<DateTime>("AtUtc").HasColumnType("TEXT");
|
||||
b.Property<string>("Kind").IsRequired().HasMaxLength(40).HasColumnType("TEXT");
|
||||
b.Property<string>("Status").IsRequired().HasMaxLength(32).HasColumnType("TEXT");
|
||||
b.Property<string>("Stage").HasMaxLength(64).HasColumnType("TEXT");
|
||||
b.Property<int>("Attempt").HasColumnType("INTEGER");
|
||||
b.Property<string>("FailureCode").HasMaxLength(64).HasColumnType("TEXT");
|
||||
b.HasKey("Id");
|
||||
b.HasIndex("OperationId", "Id");
|
||||
b.HasIndex("AtUtc");
|
||||
b.ToTable("UserOperationEvents");
|
||||
b.HasOne("JobTrackerApi.Models.UserOperation", null).WithMany().HasForeignKey("OperationId").OnDelete(DeleteBehavior.Cascade).IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.UserNotification", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
public enum OperationEventKind
|
||||
{
|
||||
Created, Claimed, StageChanged, Completed, RetryScheduled, Failed,
|
||||
CancellationRequested, Cancelled, Retried, LeaseRecovered, Expired
|
||||
}
|
||||
|
||||
public sealed class UserOperationEvent
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public Guid OperationId { get; set; }
|
||||
public string OwnerUserId { get; set; } = string.Empty;
|
||||
public DateTime AtUtc { get; set; }
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public string? Stage { get; set; }
|
||||
public int Attempt { get; set; }
|
||||
public string? FailureCode { get; set; }
|
||||
}
|
||||
@@ -248,6 +248,9 @@ public sealed class AccountDataExportService(
|
||||
}).ToListAsync(cancellationToken);
|
||||
var notifications = await db.UserNotifications.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.CreatedAtUtc).ToListAsync(cancellationToken);
|
||||
await AddJsonAsync("data/operations.json", new { Operations = operations, Notifications = notifications }, operations.Count + notifications.Count);
|
||||
var operationEvents = await db.UserOperationEvents.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId)
|
||||
.OrderBy(item => item.Id).ToListAsync(cancellationToken);
|
||||
await AddJsonAsync("data/operation-events.json", operationEvents, operationEvents.Count);
|
||||
|
||||
var gmail = await db.GmailConnections.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId)
|
||||
.Select(item => new { item.GmailAddress, item.Scope, item.ConnectedAt, item.LastSyncedAt, item.LastSyncAttemptedAt, item.LastSyncSucceededAt, item.LastSyncMode, item.LastSyncSource, item.LastSyncStatus, item.LastSyncError }).ToListAsync(cancellationToken);
|
||||
|
||||
@@ -264,6 +264,7 @@ public sealed class AccountDeletionService(
|
||||
deleted += await db.UserRuleSettings.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.UserNotifications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.UserOperations.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.UserOperationEvents.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.JobApplications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.Jobs.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.Companies.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed class CvImportRetentionHostedService(IServiceScopeFactory scopes,
|
||||
|
||||
public static async Task<int> SweepAsync(JobTrackerContext db, AppPaths paths, DateTimeOffset now, CancellationToken token)
|
||||
{
|
||||
await OperationEventLog.PruneAsync(db, now.UtcDateTime, token);
|
||||
var runs = await db.CvExtractionRuns.IgnoreQueryFilters().Include(r => r.Artifact)
|
||||
.Where(r => r.ParserVersion == "docling-v1" && r.Artifact != null && r.Artifact.StoragePath != "")
|
||||
.OrderBy(r => r.Id).Take(1000).ToListAsync(token);
|
||||
@@ -43,6 +44,7 @@ public sealed class CvImportRetentionHostedService(IServiceScopeFactory scopes,
|
||||
operation.Status = Models.OperationStatuses.Cancelled;
|
||||
operation.CancellationRequestedAtUtc = now.UtcDateTime;
|
||||
operation.CompletedAtUtc = now.UtcDateTime;
|
||||
db.UserOperationEvents.Add(OperationEventLog.Create(operation, Models.OperationEventKind.Cancelled, now.UtcDateTime));
|
||||
}
|
||||
await db.SaveChangesAsync(token);
|
||||
try
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public static class OperationEventLog
|
||||
{
|
||||
public const int RetentionDays = 30;
|
||||
public static string SafeStatus(string? value) => value is
|
||||
OperationStatuses.Queued or OperationStatuses.Running or OperationStatuses.WaitingForRetry or OperationStatuses.WaitingForExternalFallback
|
||||
or OperationStatuses.Succeeded or OperationStatuses.Failed or OperationStatuses.Cancelled ? value : "unknown";
|
||||
|
||||
public static string? SafeStage(string? value) => value is "cv_validating_file" or "cv_extracting" or "cv_identifying_sections"
|
||||
or "cv_analysing" or "cv_validating_details" or "cv_preparing_review" or "cv_review_ready" ? value : null;
|
||||
|
||||
public static string? SafeFailure(string? value) => value is null ? null : value is
|
||||
"timeout" or "lease_expired" or "deadline_exceeded" or "entitlement_changed" or "document_unavailable"
|
||||
or "sidecar_unavailable" or "upstream_unavailable" or "invalid_response" or "circuit_open" or "rate_limited"
|
||||
or "permission_denied" or "cancelled" or "upload_rejected" or "parser_failed" or "malformed_output" or "provider_unavailable"
|
||||
? value : "processing_failed";
|
||||
|
||||
public static UserOperationEvent Create(UserOperation operation, OperationEventKind kind, DateTime now,
|
||||
string? status = null, string? stage = null, string? failure = null, int? attempt = null) => new()
|
||||
{
|
||||
OperationId = operation.Id, OwnerUserId = operation.OwnerUserId, AtUtc = now, Kind = kind.ToString(),
|
||||
Status = SafeStatus(status ?? operation.Status), Stage = SafeStage(stage ?? operation.ProgressStage),
|
||||
Attempt = Math.Clamp(attempt ?? operation.AttemptCount, 0, 10), FailureCode = SafeFailure(failure),
|
||||
};
|
||||
|
||||
public static async Task AppendAsync(JobTrackerContext db, UserOperation operation, OperationEventKind kind, DateTime now,
|
||||
CancellationToken token, string? status = null, string? stage = null, string? failure = null, int? attempt = null)
|
||||
{
|
||||
var item = Create(operation, kind, now, status, stage, failure, attempt);
|
||||
if (!db.Database.IsRelational())
|
||||
{
|
||||
db.UserOperationEvents.Add(item);
|
||||
await db.SaveChangesAsync(token);
|
||||
return;
|
||||
}
|
||||
if (db.Database.CurrentTransaction is null) throw new InvalidOperationException("Operation history requires the state transition transaction.");
|
||||
// Parameterized insert avoids flushing unrelated entities tracked by a caller.
|
||||
await db.Database.ExecuteSqlInterpolatedAsync($"INSERT INTO `UserOperationEvents` (`OperationId`,`OwnerUserId`,`AtUtc`,`Kind`,`Status`,`Stage`,`Attempt`,`FailureCode`) VALUES ({item.OperationId},{item.OwnerUserId},{item.AtUtc},{item.Kind},{item.Status},{item.Stage},{item.Attempt},{item.FailureCode})", token);
|
||||
}
|
||||
|
||||
public static async Task<int> PruneAsync(JobTrackerContext db, DateTime now, CancellationToken token)
|
||||
{
|
||||
var cutoff = now.AddDays(-RetentionDays);
|
||||
var ids = await db.UserOperationEvents.IgnoreQueryFilters().Where(item => item.AtUtc < cutoff)
|
||||
.OrderBy(item => item.Id).Select(item => item.Id).Take(5000).ToListAsync(token);
|
||||
if (!db.Database.IsRelational())
|
||||
{
|
||||
var items = await db.UserOperationEvents.IgnoreQueryFilters().Where(item => ids.Contains(item.Id)).ToListAsync(token);
|
||||
db.UserOperationEvents.RemoveRange(items);
|
||||
return await db.SaveChangesAsync(token);
|
||||
}
|
||||
return await db.UserOperationEvents.IgnoreQueryFilters().Where(item => ids.Contains(item.Id)).ExecuteDeleteAsync(token);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ internal static class StartupSchemaOwnership
|
||||
"TwoFactorRecoveryCodes",
|
||||
"UserNotifications",
|
||||
"UserOperations",
|
||||
"UserOperationEvents",
|
||||
"UserRuleSettings",
|
||||
"UserSessions",
|
||||
};
|
||||
|
||||
@@ -75,6 +75,8 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
|
||||
DeadlineAtUtc = request.DeadlineAtUtc,
|
||||
};
|
||||
db.UserOperations.Add(operation);
|
||||
var createdEvent = OperationEventLog.Create(operation, OperationEventKind.Created, now);
|
||||
db.UserOperationEvents.Add(createdEvent);
|
||||
AiUsageRecord? usage = null;
|
||||
if (request.UsageReservedTokens > 0)
|
||||
{
|
||||
@@ -95,6 +97,7 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
db.Entry(operation).State = EntityState.Detached;
|
||||
db.Entry(createdEvent).State = EntityState.Detached;
|
||||
if (usage is not null) db.Entry(usage).State = EntityState.Detached;
|
||||
existing = await db.UserOperations.FirstOrDefaultAsync(
|
||||
item => item.TaskType == request.TaskType && item.IdempotencyKey == request.IdempotencyKey,
|
||||
@@ -140,6 +143,7 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
|
||||
foreach (var candidateId in candidateIds)
|
||||
{
|
||||
var leaseToken = Guid.NewGuid().ToString("N");
|
||||
await using var transaction = await BeginTransactionAsync(cancellationToken);
|
||||
var affected = await db.UserOperations.IgnoreQueryFilters()
|
||||
.Where(operation => operation.Id == candidateId &&
|
||||
(operation.Status == OperationStatuses.Queued || operation.Status == OperationStatuses.WaitingForRetry) &&
|
||||
@@ -158,20 +162,23 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
|
||||
|
||||
var claimed = await db.UserOperations.IgnoreQueryFilters().AsNoTracking()
|
||||
.SingleAsync(operation => operation.Id == candidateId && operation.LeaseToken == leaseToken, cancellationToken);
|
||||
await OperationEventLog.AppendAsync(db, claimed, OperationEventKind.Claimed, now, cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
return new UserOperationLease(claimed.Id, claimed.OwnerUserId, leaseToken, claimed.TaskType, claimed.PrivacyPolicy, claimed.SubjectType, claimed.SubjectId, claimed.AttemptCount, claimed.DeadlineAtUtc);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public Task<int> HeartbeatAsync(Guid operationId, string leaseToken, TimeSpan leaseDuration, string? progressStage, int? progressPercent, CancellationToken cancellationToken)
|
||||
public async Task<int> HeartbeatAsync(Guid operationId, string leaseToken, TimeSpan leaseDuration, string? progressStage, int? progressPercent, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureOwnerScope();
|
||||
ValidateLeaseDuration(leaseDuration);
|
||||
if (progressPercent is < 0 or > 100) throw new ArgumentOutOfRangeException(nameof(progressPercent));
|
||||
ValidateOptional(progressStage, 64, nameof(progressStage));
|
||||
if (progressStage is not null && await ReportStageAsync(operationId, leaseToken, progressStage, cancellationToken) != 1) return 0;
|
||||
var now = UtcNow;
|
||||
return db.UserOperations
|
||||
return await db.UserOperations
|
||||
.Where(operation => operation.Id == operationId && operation.Status == OperationStatuses.Running && operation.LeaseToken == leaseToken)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(operation => operation.LeaseExpiresAtUtc, now.Add(leaseDuration))
|
||||
@@ -181,6 +188,26 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<int> ReportStageAsync(Guid operationId, string leaseToken, string stage, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureOwnerScope();
|
||||
ValidateRequired(stage, 64, nameof(stage));
|
||||
var now = UtcNow;
|
||||
await using var transaction = await BeginTransactionAsync(cancellationToken);
|
||||
var active = db.UserOperations.Where(item => item.Id == operationId && item.LeaseToken == leaseToken &&
|
||||
item.Status == OperationStatuses.Running && item.CancellationRequestedAtUtc == null && item.LeaseExpiresAtUtc > now);
|
||||
var updated = await active.Where(item => item.ProgressStage != stage)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.ProgressStage, stage), cancellationToken);
|
||||
if (updated == 1)
|
||||
{
|
||||
var operation = await active.AsNoTracking().SingleAsync(cancellationToken);
|
||||
await OperationEventLog.AppendAsync(db, operation, OperationEventKind.StageChanged, now, cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
return 1;
|
||||
}
|
||||
return await active.AnyAsync(cancellationToken) ? 1 : 0;
|
||||
}
|
||||
|
||||
public Task<int> CompleteAsync(Guid operationId, string leaseToken, string? resultReference, CancellationToken cancellationToken)
|
||||
=> CompleteAsync(operationId, leaseToken, resultReference, null, null, null, cancellationToken);
|
||||
|
||||
@@ -240,6 +267,8 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
|
||||
.SetProperty(item => item.EstimatedTokenCount, estimatedTokens), cancellationToken);
|
||||
}
|
||||
db.UserNotifications.Add(CreateTerminalNotification(operation, OperationStatuses.Succeeded, now));
|
||||
await OperationEventLog.AppendAsync(db, operation, OperationEventKind.Completed, now, cancellationToken,
|
||||
status: OperationStatuses.Succeeded, stage: completionStage ?? "");
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
@@ -296,7 +325,12 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
|
||||
db.UserNotifications.Add(CreateTerminalNotification(operation, OperationStatuses.Failed, now));
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
if (affected == 1 && transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
if (affected == 1)
|
||||
{
|
||||
await OperationEventLog.AppendAsync(db, operation, canRetry ? OperationEventKind.RetryScheduled : OperationEventKind.Failed, now, cancellationToken,
|
||||
status: canRetry ? OperationStatuses.WaitingForRetry : OperationStatuses.Failed, stage: progressStage ?? "", failure: category);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
return affected == 1;
|
||||
}
|
||||
|
||||
@@ -318,6 +352,7 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
|
||||
{
|
||||
await SynchronizeCvRunAsync(operation, OperationStatuses.Cancelled, "CV processing was cancelled.", now, cancellationToken);
|
||||
db.UserNotifications.Add(CreateTerminalNotification(operation, OperationStatuses.Cancelled, now));
|
||||
await OperationEventLog.AppendAsync(db, operation, OperationEventKind.Cancelled, now, cancellationToken, status: OperationStatuses.Cancelled);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
return true;
|
||||
@@ -326,7 +361,11 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
|
||||
var requested = await db.UserOperations
|
||||
.Where(item => item.Id == operationId && item.Status == OperationStatuses.Running && item.CancellationRequestedAtUtc == null)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.CancellationRequestedAtUtc, now), cancellationToken);
|
||||
if (requested == 1 && transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
if (requested == 1)
|
||||
{
|
||||
await OperationEventLog.AppendAsync(db, operation, OperationEventKind.CancellationRequested, now, cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
return requested == 1;
|
||||
}
|
||||
|
||||
@@ -350,6 +389,7 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
|
||||
{
|
||||
await SynchronizeCvRunAsync(operation, OperationStatuses.Cancelled, "CV processing was cancelled.", now, cancellationToken);
|
||||
db.UserNotifications.Add(CreateTerminalNotification(operation, OperationStatuses.Cancelled, now));
|
||||
await OperationEventLog.AppendAsync(db, operation, OperationEventKind.Cancelled, now, cancellationToken, status: OperationStatuses.Cancelled);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
@@ -360,26 +400,30 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
|
||||
{
|
||||
EnsureOwnerScope();
|
||||
await using var transaction = await BeginTransactionAsync(cancellationToken);
|
||||
var operation = await db.UserOperations.FirstOrDefaultAsync(item => item.Id == operationId, cancellationToken);
|
||||
var operation = await db.UserOperations.AsNoTracking().FirstOrDefaultAsync(item => item.Id == operationId, cancellationToken);
|
||||
if (operation is null || operation.Status is not (OperationStatuses.Failed or OperationStatuses.Cancelled)) return false;
|
||||
if (operation.TaskType == CvProcessingQueue.TaskType && operation.SubjectType == CvProcessingQueue.SubjectType
|
||||
&& await SynchronizeCvRunAsync(operation, OperationStatuses.Queued, null, UtcNow, cancellationToken) != 1) return false;
|
||||
operation.Status = OperationStatuses.Queued;
|
||||
operation.AttemptCount = 0;
|
||||
operation.AvailableAtUtc = UtcNow;
|
||||
operation.StartedAtUtc = null;
|
||||
operation.CompletedAtUtc = null;
|
||||
operation.CancellationRequestedAtUtc = null;
|
||||
operation.FailureCategory = null;
|
||||
operation.FailureMessage = null;
|
||||
operation.ResultReference = null;
|
||||
operation.Provider = null;
|
||||
operation.Model = null;
|
||||
operation.ProgressStage = null;
|
||||
operation.ProgressPercent = null;
|
||||
var existingNotification = await db.UserNotifications.FirstOrDefaultAsync(item => item.OperationId == operationId, cancellationToken);
|
||||
if (existingNotification is not null) db.UserNotifications.Remove(existingNotification);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
var now = UtcNow;
|
||||
var affected = await db.UserOperations.Where(item => item.Id == operationId && item.Status == operation.Status && item.CompletedAtUtc == operation.CompletedAtUtc)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(item => item.Status, OperationStatuses.Queued)
|
||||
.SetProperty(item => item.AttemptCount, 0)
|
||||
.SetProperty(item => item.AvailableAtUtc, now)
|
||||
.SetProperty(item => item.StartedAtUtc, (DateTime?)null)
|
||||
.SetProperty(item => item.CompletedAtUtc, (DateTime?)null)
|
||||
.SetProperty(item => item.CancellationRequestedAtUtc, (DateTime?)null)
|
||||
.SetProperty(item => item.FailureCategory, (string?)null)
|
||||
.SetProperty(item => item.FailureMessage, (string?)null)
|
||||
.SetProperty(item => item.ResultReference, (string?)null)
|
||||
.SetProperty(item => item.Provider, (string?)null)
|
||||
.SetProperty(item => item.Model, (string?)null)
|
||||
.SetProperty(item => item.ProgressStage, (string?)null)
|
||||
.SetProperty(item => item.ProgressPercent, (int?)null), cancellationToken);
|
||||
if (affected != 1) return false;
|
||||
await db.UserNotifications.Where(item => item.OperationId == operationId).ExecuteDeleteAsync(cancellationToken);
|
||||
await OperationEventLog.AppendAsync(db, operation, OperationEventKind.Retried, now, cancellationToken,
|
||||
status: OperationStatuses.Queued, stage: "", attempt: 0);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
@@ -408,11 +452,18 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
|
||||
foreach (var operation in deadlineExpired)
|
||||
await FinalizeRecoveredAsync(operation, RecoveryTerminal.QueuedDeadline, OperationStatuses.Failed, "deadline_exceeded", "The operation deadline elapsed before work could complete.", now, cancellationToken);
|
||||
|
||||
await db.UserOperations.IgnoreQueryFilters()
|
||||
var recoverable = await db.UserOperations.IgnoreQueryFilters().AsNoTracking()
|
||||
.Where(operation => operation.Status == OperationStatuses.Running && operation.LeaseExpiresAtUtc <= now &&
|
||||
operation.CancellationRequestedAtUtc == null && operation.AttemptCount < operation.MaxAttempts &&
|
||||
(operation.DeadlineAtUtc == null || operation.DeadlineAtUtc > now))
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var operation in recoverable)
|
||||
{
|
||||
await using var transaction = await BeginTransactionAsync(cancellationToken);
|
||||
var affected = await db.UserOperations.IgnoreQueryFilters().Where(item => item.Id == operation.Id &&
|
||||
item.Status == OperationStatuses.Running && item.LeaseExpiresAtUtc <= now && item.CancellationRequestedAtUtc == null &&
|
||||
item.AttemptCount < item.MaxAttempts && (item.DeadlineAtUtc == null || item.DeadlineAtUtc > now))
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(operation => operation.Status, OperationStatuses.WaitingForRetry)
|
||||
.SetProperty(operation => operation.AvailableAtUtc, now)
|
||||
.SetProperty(operation => operation.FailureCategory, "lease_expired")
|
||||
@@ -420,6 +471,13 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
|
||||
.SetProperty(operation => operation.LeaseToken, (string?)null)
|
||||
.SetProperty(operation => operation.LeaseExpiresAtUtc, (DateTime?)null),
|
||||
cancellationToken);
|
||||
if (affected == 1)
|
||||
{
|
||||
await OperationEventLog.AppendAsync(db, operation, OperationEventKind.LeaseRecovered, now, cancellationToken,
|
||||
status: OperationStatuses.WaitingForRetry, failure: "lease_expired");
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FinalizeRecoveredAsync(UserOperation operation, RecoveryTerminal reason, string status, string category, string message, DateTime now, CancellationToken cancellationToken)
|
||||
@@ -443,6 +501,8 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
|
||||
{
|
||||
await SynchronizeCvRunAsync(operation, status, message, now, cancellationToken);
|
||||
db.UserNotifications.Add(CreateTerminalNotification(operation, status, now));
|
||||
await OperationEventLog.AppendAsync(db, operation, status == OperationStatuses.Cancelled ? OperationEventKind.Cancelled : OperationEventKind.Expired,
|
||||
now, cancellationToken, status: status, failure: category);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# D history foundation checkpoint
|
||||
|
||||
28 October2026. Cumulative branch `feature/admin-processing-monitor`, based on C0bd290e; architecture checkpoint5f26108 was pushed with remote equality verified. This report accompanies the event/persistence checkpoint. **This is not acceptance of the admin API/UI, which remains in progress.**
|
||||
|
||||
Implemented migration-owned typed operation history, strict safe stage/status/failure projection at insertion, event owner filtering and operation-cascade erasure. Queue creation, successful claims, actual stage changes, completion, retry/failure, cancellation and lease/deadline recovery append events within their state transition transaction. Periodic same-stage/empty heartbeats do not manufacture history. New relational event writes use parameterized insertion without flushing unrelated tracked objects. Existing terminal notification transactions remain intact.
|
||||
|
||||
Document stage reporting retains the original owner, lease, cancellation and expiry checks and now uses the same store. Retention uses the existing hourly owner, excludes no current document cleanup and removes up to5,000 events older than30 days per sweep. Account export adds only owner-scoped event metadata; operation cascade and explicit owner erasure remove history. No event contains raw CVs/prompts/results, free-form exceptions, lease tokens, paths or model/provider strings. Existing operation records retain their prior contract; future admin projections must independently redact their legacy free-text fields.
|
||||
|
||||
Qualification, independent lanes under `.artifacts/pre-phase10-ux/`:
|
||||
|
||||
- `d-events-red/d-events-red.trx`: missing-history regression failed as expected before implementation.
|
||||
- `d-retry-red/d-retry-red.trx`: confirmed an existing tracked-stale-operation retry failure. Retry now reads durable state and conditionally resets the observed terminal state/completion timestamp, atomically deleting its notification and adding one retry event.
|
||||
- `d-backend-01/d-backend.trx`: **1,413 passed, zero failed/skipped** full backend. Subsequent additional history cases are checked in the focused lane, not added to this total.
|
||||
- `d-events-04/d-events-04.trx`: **53 passed, zero failed/skipped**, including claim/stage/completion/retry insertion-failure rollback, stage deduplication/redaction/owner isolation, no unrelated tracked-user flush, inactive lease rejection, recovery ordering/attempts, retention/cascade, existing account rights, startup ownership, CV workers and avatar/document retention.
|
||||
- `d-mariadb-02/{mariadb.trx,run.json}`: **88 passed, zero failed/skipped** against real MariaDB12.2.2. New tests prove one winning claim/event, concurrent same-stage deduplication, owner isolation, and event-insertion failure rolling back completion and notifications. Migration/startup, account/import/revision tests also pass. All generated resources cleaned; no existing/production database selected.
|
||||
|
||||
Retained findings: the first MariaDB run had87 passes/one outdated latest-migration assertion. The test now names the pre-RD21 migration explicitly, retaining every original content/version preservation assertion and actually exercising revision backfill. A shared in-memory SQLite connection allowed a worker test to observe an uncommitted success before stopping the worker; its fixture now uses independent connections to an isolated temporary WAL database, preserving the requirement that success survives the worker stop. `d-events-01` retains that failure; later focused/full runs pass.
|
||||
|
||||
The additive migration and snapshot were updated together; no production schema or runtime was changed. Event-history acceptance is limited to the checks above. Admin-only APIs, exact string cursors, owner-verified associations, safe browser presentation, polling/recovery, correlation headers and integrated acceptance remain the next work. No retrospective history is invented for old operation rows.
|
||||
@@ -31,7 +31,7 @@ No main merge, production service/database/storage/model/cache/deployment operat
|
||||
- [x] B-01: versioned designs, actual samples, matching letters, secure optional photographs, queue repair and local runtime. Backend1394/frontend1014/MariaDB84/interactive browser6/real Docling2; revised CV36+letter18 per platform and original Linux24+4. Conditional development acceptance and retained large-name oracle failures: B_ACCEPTANCE.md. Source3f4db8c committed/pushed with remote equality verified; qualified source images activated only locally on28 October.
|
||||
- [x] C-00: branch from accepted B; pinned2.3.0/MIT, installed modern API/React19 pointer proof, measured+23,636 aggregate gzip bytes; schema2 and legacy RED/GREEN migration tests. Combined with C-01 source checkpoint.
|
||||
- [x] C-01: actual widgets drag/resize, compact catalogue and keyboard controls, bounded non-overlapping geometry, no automatic writes, stale-client/CAS protection. Backend1405/full frontend1019 plus final focused33/MariaDB86/browser12; C_ACCEPTANCE.md records conditional development acceptance and independent evidence. Commit/push/remote equality form the publication step of this checkpoint.
|
||||
- [ ] D-00: branch from accepted C; define exact safe diagnostic/event schema and admin identity/association policies; tests first.
|
||||
- [x] D-00: cumulative branch from C0bd290e, safe history/API/identity contract pushed5f26108. Missing history and stale tracked retry reproduced RED. Event foundation: backend1413/final focused53/MariaDB88 with atomic rollback/redaction/retention/account rights; D_HISTORY_CHECKPOINT.md. Admin API/UI remains in D-01.
|
||||
- [ ] D-01: atomic structured events/correlation/retention and account rights, safe admin list/stats/detail/events, polling filters/reconnect/pause, EN/Bokmål and responsive UI; security/provider/browser validation; commit/push/verify.
|
||||
- [ ] UX-INT-00: branch `test/pre-phase10-ux-acceptance` from accepted D. Fresh full backend/frontend/ordinary and added browsers/real MariaDB/RD21/Python/Docling/import/contracts/security/type/lint/build/Windows/Linux PDF/preservation; exact source/discovery/native exits, retain failures and fix regressions.
|
||||
- [ ] UX-INT-01: independent feature/overall/Phase10/production verdicts, all57/33 preservation carry-forward, accepted branch commit register, human/operator debt and cleanup-only handoff. Commit/push/remote equality/own-work clean; preserve unrelated primary checkout work; stop.
|
||||
|
||||
Reference in New Issue
Block a user