Acta. apache-2.0 · .net 10 · pg / mssql / sqlite

from tickerq · migration notes

From TickerQ to Acta.

TickerQ and Acta agree on more than they differ, so this page starts with what does not change. Use Acta when a background job stops being a task and becomes application state: work that must survive the process that started it, and answer questions afterwards.

Shared ground

The checkboxes both have.

Source generation. AOT readiness. Persistence. A dashboard. Cron schedules. Retries. Priorities. Concurrency controls. If your comparison stops at this list, either tool serves, and TickerQ is the one you already run. None of these are why Acta exists.

The difference

What only a ledger does.

Acta's unit of durability is not the schedule entry: it is the work itself. Every job, attempt, lease, step, signal, and event is a SQL row, which is what "kill the worker, keep the work" means in practice.

The code, side by side

The scheduled job barely changes. The ceiling does.

TickerQ · a scheduled function
public sealed class CleanupJobs
{
    [TickerFunction("cleanup-expired-sessions", "*/5 * * * *")]
    public Task CleanupExpiredSessions(CancellationToken ct)
    {
        return Task.CompletedTask;
    }
}
Acta · the same schedule
public sealed class CleanupJobs
{
    [Job("cleanup-expired-sessions")]
    [JobSchedule("every-5-minutes", "5m")]
    public Task CleanupExpiredSessions(CancellationToken ct)
    {
        return Task.CompletedTask;
    }
}

The scheduled-job shape migrates almost one to one: an attribute on a method, discovered by source generation. The difference starts when a job needs more than a schedule.

Acta · what survives a worker kill
public sealed record PublishRelease(string ReleaseId);

public sealed class ReleaseJobs
{
    [Job("publish-release")]
    public async Task Handle(PublishRelease input, JobContext ctx, CancellationToken ct)
    {
        // A named durable step: its outcome is recorded in SQL. Kill the worker after
        // it completes and re-entry returns the stored result; the body does not run again.
        var artifact = await ctx.RunStepAsync("build-artifacts",
            token => BuildArtifactsAsync(input.ReleaseId, token));

        // Suspends the job durably: no worker thread waits. Approve it in an hour or in
        // three days, from code, the CLI, or the dashboard; it resumes on any peer worker.
        var approved = await ctx.WaitSignalAsync<bool>("release-approval", ct);

        if (approved)
        {
            await ctx.RunStepAsync("publish", token => PublishAsync(artifact, token));
        }
    }
}

Everything the handler has done so far, the completed build step, the pending approval, the signal once it arrives, is a row. A worker kill between any two lines loses no completed work, and the job's state is visible in SQL the whole time.

What changes

The differences you will feel in week one.

A fair split

Keep TickerQ if a scheduler is what you need.

Keep TickerQwhen
You want a source-generated scheduler with persistence, dashboard tooling, chaining, priorities, and concurrency controls, and your jobs finish or fail whole: no mid-job state worth preserving.
Consider Actawhen
SQL must serve as the durable operational ledger for resumable work: checkpoints, signals, recovery evidence, and operator intervention. The scheduler features come along; the ledger is the point.

The migration

Migrate one job, coexist with the rest.

There is no importer for TickerQ's tables and no attribute-compatible shim, by design. Move the one job that needs a ledger, keep TickerQ running the rest, and let the next migration argue for itself.

Also: from Hangfire·from Quartz·how Acta compares