Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -521,12 +521,14 @@ public async Task<IReadOnlyList<LlmRequest>> GetStuckProcessingNonCaptureAsync(
throw new ArgumentOutOfRangeException(nameof(limit), limit, "Limit must be at least 1.");
}

var staleBeforeUtc = staleBefore.ToUniversalTime();

if (_context.Database.IsSqlite())
{
// SQLite's EF provider cannot translate WHERE/ORDER BY on a DateTimeOffset column, so the
// staleness comparison + order + LIMIT live in raw SQL. The TEXT comparison is chronological
// because every UpdatedAt writer (Entity ctor/Touch and the raw claim UPDATEs) and staleBefore
// are all DateTimeOffset.UtcNow, so they share a fixed-width "+00:00" offset and lexical order
// because every UpdatedAt writer (Entity ctor/Touch and the raw claim UPDATEs) and the
// normalized cutoff are all fixed at a "+00:00" offset, so lexical order
// equals chronological order -- the same shape the shipped
// OutboundWebhookDeliveryRepository.GetStuckProcessingAsync relies on.
// FromSqlInterpolated + Include wraps this in a subquery that does not guarantee the raw
Expand All @@ -536,7 +538,7 @@ public async Task<IReadOnlyList<LlmRequest>> GetStuckProcessingNonCaptureAsync(
// UpdatedAt) and never reads the User/Board navigations, so loading them is wasted work.
var rows = await _context.LlmRequests
.FromSqlInterpolated(
$"SELECT * FROM LlmRequests WHERE Status = {(int)RequestStatus.Processing} AND RequestType NOT LIKE {CaptureRequestTypeLike} AND UpdatedAt <= {staleBefore} ORDER BY UpdatedAt ASC LIMIT {limit}")
$"SELECT * FROM LlmRequests WHERE Status = {(int)RequestStatus.Processing} AND RequestType NOT LIKE {CaptureRequestTypeLike} AND UpdatedAt <= {staleBeforeUtc} ORDER BY UpdatedAt ASC LIMIT {limit}")
.ToListAsync(cancellationToken);
return rows.OrderBy(lr => lr.UpdatedAt).ToList();
}
Expand All @@ -546,7 +548,7 @@ public async Task<IReadOnlyList<LlmRequest>> GetStuckProcessingNonCaptureAsync(
return await _context.LlmRequests
.Where(lr => lr.Status == RequestStatus.Processing
&& !EF.Functions.Like(lr.RequestType, CaptureRequestTypeLike)
&& lr.UpdatedAt <= staleBefore)
&& lr.UpdatedAt <= staleBeforeUtc)
.OrderBy(lr => lr.UpdatedAt)
.Take(limit)
.ToListAsync(cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,33 @@ await WithSqliteRepoAsync(async (db, repo) =>
});
}

[Fact]
public async Task GetStuckProcessingNonCaptureAsync_WithNonUtcThreshold_ComparesByInstant()
{
await WithSqliteRepoAsync(async (db, repo) =>
{
var user = new User("stuck-offset", "stuck-offset@example.com", "hash");
db.Users.Add(user);

var stale = new LlmRequest(user.Id, "instruction", "{}");
stale.MarkAsProcessing();
var fresh = new LlmRequest(user.Id, "instruction", "{}");
fresh.MarkAsProcessing();

var cutoffUtc = new DateTimeOffset(2026, 1, 1, 22, 0, 0, TimeSpan.Zero);
db.Entry(stale).Property(nameof(Entity.UpdatedAt)).CurrentValue = cutoffUtc.AddHours(-1);
db.Entry(fresh).Property(nameof(Entity.UpdatedAt)).CurrentValue = cutoffUtc.AddHours(1);
db.LlmRequests.AddRange(stale, fresh);
await db.SaveChangesAsync();
db.ChangeTracker.Clear();

var nonUtcCutoff = cutoffUtc.ToOffset(TimeSpan.FromHours(2));
var stuck = await repo.GetStuckProcessingNonCaptureAsync(nonUtcCutoff, 50);

stuck.Select(request => request.Id).Should().Equal(stale.Id);
});
}

[Fact]
public async Task GetStuckProcessingNonCaptureAsync_ReturnsOldestFirstAndRespectsLimit()
{
Expand Down
Loading