Both schedulers follow one rule: persist first, then queue. Background tasks handle one-off heavy work (embeddings, graph, Wiki, board execution); scheduled tasks handle recurring work (skills, AI tasks, graph rebuild, vector rebuild). The former de-duplicates with a database unique index plus an in-memory semaphore; the latter decides timing from NextRunAt and cron expressions.
Both paths share the same pattern: persist the intent, run it, then write the outcome back to the same row.
IX_TaskItems_ActiveUniqueness filtered to "queued or running and not deleted", so one (TaskType, EntityId) can only have one active task
Indexes
TaskType · Status · IsDeleted
Enqueue and dedupe
A SemaphoreSlim(1, 1) serialises "check, insert, enqueue" so concurrent enqueues cannot duplicate.
Batch enqueues dedupe (TaskType, EntityId) pairs inside the batch first.
Targets that already have an active record (queued or running) are skipped.
The record is saved with SaveChangesAsync before QueueAsync puts it in the in-memory queue.
The consumer claims the record, marking it running, and writes that back to the database (ClaimAsync).
The queue itself is in memory and the database is the source of truth: after a restart, startup recovery plus the periodic sweep pick up what the queue lost instead of persisting the queue.
Consumption, sweep and recovery
Item
Behaviour
Consumer
One background service loops over the queue; there is no multi-threaded consumption
Startup recovery
Recovers leftover tasks (queued or stuck records) before entering the consumption loop
Sweep interval
SweepInterval = 2 minutes
Staleness
Only records untouched for more than StaleThreshold = 5 minutes are recovered, so long-running work is not mistaken for a hang
Shutdown
An in-flight task is reset to queued with a cleared start time and the message "service stopped, task requeued"
Explicit cancel
Marked failed with "task cancelled"
Execution error
Marked failed with the exception message
Wrap-up
The finally block writes completion and update timestamps regardless of outcome
Interval (minutes) or Cron (5 fields: minute hour day month weekday)
Execution record
ScheduledTaskExecution: Status (Running / Success / Failed) · ErrorMessage · Result · RetryAttempt · IsManual · start/end times
Endpoints
GET /api/scheduled-tasks · GET /api/scheduled-tasks/{id} · GET /api/scheduled-tasks/target-options · POST · PUT · DELETE · POST {id}/toggle · POST {id}/run · GET {id}/executions
Next run time
Mode
How it is computed
Interval
Base is LastRunAt ?? now; next = base + IntervalMinutes. If that is already in the past it rolls to "now + interval", so a long backlog of windows is not replayed
Cron
Cronos parses the 5-field expression and computes the next occurrence
Due check
Tasks where IsEnabled && NextRunAt <= now
Run now
POST {id}/run sets NextRunAt to now for the runner to pick up and returns a placeholder execution record (status queued, IsManual = true)
Update semantics
Saving the configuration resets RetryCount = 0 and recomputes NextRunAt
MaxRetries is clamped to 0 or more; a failure while computing the next run is logged as a warning and returns nothing (no schedule for that pass).
Database side: Name / TaskKind / ScheduleType are required and CronExpression / LastStatus / LastError have length limits; queries filter soft deletes.
Observability: status, error, next run and last status are on the list; history comes from /executions (duration and manual flag included).
Logs come from the background services themselves (sweep, recovery, failures); there is no unified scheduled-task log prefix.
Practice notes
"One active task per target" is deliberate: clicking vector rebuild twice will not enqueue ten jobs, it reuses the existing one.
Long tasks that stay silent for over 5 minutes look stale to the sweep; keep status moving, otherwise a restart can re-deliver them.
Interval never replays missed windows: after a day of sleep, scheduling restarts from now.
"Rebuild graph" and "rebuild vectors" are full-scale operations — confirm the embedding model and dimensions have not changed first (see Chunking & vector index).