Merge branch 'claudedo/5c80a0aba5d9468a96cf809fc7a6e7a1'

This commit is contained in:
mika kuns
2026-08-10 15:15:26 +02:00
20 changed files with 1768 additions and 49 deletions
+29 -1
View File
@@ -36,7 +36,10 @@ public sealed class QueuePicker : IQueuePicker
{
// Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races.
// Raw SQL because EF cannot express UPDATE...RETURNING.
// Eligible task must be Queued, unblocked, not manual, and due (or unscheduled).
// Eligible task must be Queued, unblocked (chain and depends-on), not manual, and due
// (or unscheduled). depends_on_task_id only gates on the dependency's Status='done' --
// unlike blocked_by_task_id there is no cascade on the dependency failing, so a task
// with a Failed dependency simply stays here, skipped, until someone intervenes.
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — same format used here for comparison.
var rows = await ctx.Tasks.FromSqlRaw("""
UPDATE tasks SET status = 'running', started_at = {1}
@@ -46,6 +49,11 @@ public sealed class QueuePicker : IQueuePicker
AND t.blocked_by_task_id IS NULL
AND t.is_manual = 0
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
AND (t.depends_on_task_id IS NULL
OR EXISTS (
SELECT 1 FROM tasks d
WHERE d.id = t.depends_on_task_id AND d.status = 'done'
))
ORDER BY t.sort_order ASC, t.created_at ASC
LIMIT 1
)
@@ -72,8 +80,28 @@ public sealed class QueuePicker : IQueuePicker
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct);
// The same depends-on gate ClaimTopEligibleAsync applies in SQL: a declared dependency must
// be Done. Resolved as one extra query over just the referenced ids (rather than repeating
// the correlated subquery) so both claim paths agree -- without it, opting a list into
// scope serialization would silently stop enforcing dependencies.
var dependencyIds = candidates
.Where(t => t.DependsOnTaskId != null)
.Select(t => t.DependsOnTaskId!)
.Distinct()
.ToList();
var doneDependencyIds = dependencyIds.Count == 0
? new HashSet<string>(StringComparer.Ordinal)
: (await ctx.Tasks.AsNoTracking()
.Where(d => dependencyIds.Contains(d.Id) && d.Status == TaskStatus.Done)
.Select(d => d.Id)
.ToListAsync(ct))
.ToHashSet(StringComparer.Ordinal);
foreach (var candidate in candidates)
{
if (candidate.DependsOnTaskId is { } dependsOn && !doneDependencyIds.Contains(dependsOn))
continue;
if (serializingListIds.Contains(candidate.ListId)
&& await ScopeOverlap.FindBlockingSiblingAsync(ctx, candidate, ct) is not null)
continue;