Skip to content

Background Scheduler

Chrison Simtian edited this page May 17, 2026 · 1 revision

Background Scheduler

Recurring / delayed / queued work runs in-process via TickerQ. One library, one operational store, one migrations history — see ADR-0019.

How it's wired

File Role
src/ERP/Infrastructure/Persistence/PlanDbContext.cs Hosts TickerQ's three entity configurations (TimeTickers, CronTickers, CronTickerOccurrences) under the ticker schema
src/ERP/Infrastructure/Persistence/PersistenceServiceCollectionExtensions.cs AddTickerQ(...).AddOperationalStore(...UseApplicationDbContext<PlanDbContext>(IgnoreModelCustomizer))
src/ApiService/Program.cs app.UseTickerQ() after migrations apply
src/ApiService/AutoIngestStartup.cs Reconciles the auto-ingest cron against AutoIngest:Enabled at startup
src/ERP/Infrastructure/AutoIngestJob.cs [TickerFunction("auto-ingest-sav-watcher")] job body
src/ERP/Infrastructure/AutoIngestOptions.cs Options bound from FactoryState:Satisfactory:AutoIngest

Why apply TickerQ configurations directly in OnModelCreating? ConfigurationType.UseModelCustomizer (the default) relies on host DI being active during model build, which the design-time IDesignTimeDbContextFactory<T> intentionally bypasses for dotnet ef migrations add. Direct ApplyConfiguration works at both runtime and design time so the model snapshot stays consistent.

Auto-ingest (#115)

FactoryState:Satisfactory:AutoIngest:Enabled = true registers a cron entry ticking once per minute (0 * * * * *). The job polls the configured SaveGames directory, compares LastWriteTimeUtc against the currently-loaded source, and dispatches IngestSaveCommand via Wolverine when a newer save is present.

Setting Enabled = false (the default) deletes the cron entry — the acceptance criterion is "no background activity" when disabled, so attribute- level cron registration would always tick and isn't a fit.

The job opens a service scope per tick (Wolverine bus is scoped). Cost is negligible at 1 tick/min.

Adding a new scheduled job

public sealed class MyJob(IServiceScopeFactory scopeFactory, ILogger<MyJob> log)
{
    [TickerFunction("my-job-name")]
    public async Task RunAsync(TickerFunctionContext ctx, CancellationToken ct)
    {
        await using var scope = scopeFactory.CreateAsyncScope();
        var bus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
        // …
    }
}

Register in AddErpInfrastructure and either:

  • Attribute-level cron — add [TickerFunction("name", cronExpression: "...")] for jobs that always run.
  • Imperative reconcile at startup — for jobs whose schedule depends on config (mirror AutoIngestStartup.EnsureCronRegistrationAsync).

Operations

Want to see what's queued? The TickerQ tables live alongside plans in the same SQLite/Postgres database:

-- SQLite
SELECT * FROM "ticker.TimeTickers";
SELECT * FROM "ticker.CronTickers";
SELECT * FROM "ticker.CronTickerOccurrences" ORDER BY "ExecutionTime" DESC LIMIT 20;

(Postgres uses the schema-qualified ticker.TimeTickers etc.)

Clone this wiki locally