Skip to content

chunty/TaskTurnstile

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

37 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Task Turnstile

NuGet NuGet Downloads

A thread-safe named task lifecycle manager for .NET. Prevents duplicate background job execution across threads and — optionally — across multiple application instances via a distributed backing store.

Think of it like a turnstile. Every job that wants to run must push through first. Only one can hold the bar at a time — others wait their turn or are sent away. When the job is done, the bar rotates and the next one can step through.

Why?

Scheduled jobs (Coravel, Hangfire, Quartz) fire on a timer. If the previous run hasn't finished, you don't want a second one to start. TaskTurnstile gives you a named gate:

if (!await _concurrencyManager.CanStartAsync("import-job"))
    return; // already running, skip this tick

Unlike a simple lock, the state can survive app restarts (via Redis or SQL Server) and be shared across multiple instances of your app.


Setup

In-memory (single instance, no persistence)

builder.Services.AddTaskTurnstile();

The default store is a private in-memory cache — isolated from your app's own IMemoryCache and requiring zero configuration.

Redis

builder.Services.AddTaskTurnstile()
                .AddRedisStore(o => o.Configuration = "localhost:6379");

This creates a dedicated Redis connection for TaskTurnstile, independent of any other Redis cache your app may be using. Configure it with any connection string — it can point at the same Redis instance as your app or a completely separate one.

If you'd prefer TaskTurnstile to share your app's existing IDistributedCache instead, use AddDistributedStore() (see below).

SQL Server

builder.Services.AddTaskTurnstile()
                .AddSqlServerStore(o =>
                {
                    o.ConnectionString = "Server=.;Database=MyApp;...";
                    o.TableName = "ActiveTasks";
                    o.SchemaName = "dbo";
                });

The table is created automatically on first startup — no manual setup required.

Use the app's existing IDistributedCache

If you've already registered a distributed cache (e.g. AddStackExchangeRedisCache) and want TaskTurnstile to share it:

builder.Services.AddTaskTurnstile()
                .AddDistributedStore();

Task keys are prefixed with KeyPrefix (default "cm:") to avoid collisions with your own cache entries. Override it in options if needed.


Performance: Regardless of which backing store you choose, all state checks are short-circuited by a local in-process memory cache. Once this instance marks a task as running, subsequent checks skip the backing store entirely until the task stops or its maxRuntime expires.


Options

builder.Services.AddTaskTurnstile(o =>
{
    // Maximum time a task can run before it's considered stale.
    // Prevents tasks from being stuck forever if TryStopAsync is never called (e.g. app crash).
    o.DefaultMaxRuntime = TimeSpan.FromHours(2);

    // Remove all running task records on startup.
    // Useful for clearing state left behind by a previous crashed process.
    o.CleanupOnStartup = true;

    // Prefix applied to all keys in the backing store.
    // Change this if "cm:" collides with your own cache keys (only relevant when using AddDistributedStore()).
    o.KeyPrefix = "myapp:tasks:";
});

API

Inject ITaskStateManager into your class:

public class ImportJob(ITaskStateManager manager)

Check if a task can start

bool canStart = await manager.CanStartAsync("import-job");

Returns true if the task is not currently running (or its maxRuntime has expired).

Run with automatic start/stop (recommended)

// Returns false immediately if already running; true after work completes.
bool ran = await manager.TryRunAsync("import-job", async ct =>
{
    await DoImportAsync(ct);
});
// With a return value:
var result = await manager.TryRunAsync("import-job", async ct =>
{
    return await DoImportAsync(ct);
});

if (result.Started)
    Console.WriteLine($"Imported {result.Value} records");

Wait for a task to be free, then run

// Waits until "import-job" is free, then starts and runs the work.
await manager.RunAsync("import-job", async ct =>
{
    await DoImportAsync(ct);
});

Manual start/stop

if (!await manager.StartAsync("import-job"))
    return; // already running

try
{
    await DoImportAsync(cancellationToken);
}
finally
{
    await manager.TryStopAsync("import-job");
}

Per-task runtime override

All methods accept an optional maxRuntime to override the global default:

await manager.TryRunAsync("long-job", DoWorkAsync, maxRuntime: TimeSpan.FromHours(4));

Real-world patterns

Coravel invocable (skip if already running)

public class ImportInvocable(ITaskStateManager manager) : IInvocable
{
    public async Task Invoke()
    {
        await manager.TryRunAsync("import", async ct =>
        {
            await DoImportAsync(ct);
        });
    }
}

BackgroundService (wait for previous run to finish)

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        await manager.RunAsync("sync", async ct =>
        {
            await DoSyncAsync(ct);
        }, cancellationToken: stoppingToken);

        await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
    }
}

Custom store

Implement ITaskStateStore to back the manager with anything — a file, a database, an API:

public class MyCustomStore : ITaskStateStore
{
    public Task<bool> IsRunningAsync(string taskName, CancellationToken ct = default) { ... }
    public Task<bool> IsExpiredAsync(string taskName, CancellationToken ct = default) { ... }
    public Task SetRunningAsync(string taskName, TimeSpan? maxRuntime = null, CancellationToken ct = default) { ... }
    public Task SetStoppedAsync(string taskName, CancellationToken ct = default) { ... }
    public Task CleanupAsync(CancellationToken ct = default) { ... }
}

Register it:

// Let DI create it:
builder.Services.AddTaskTurnstile()
                .UseTaskStateStore<MyCustomStore>();

// Or use a factory:
builder.Services.AddTaskTurnstile()
                .UseTaskStateStore(sp => new MyCustomStore("/var/run/locks"));

See samples/FileStore/FileTaskStateStore.cs for a complete file-based example.

About

A thread-safe named task lifecycle manager for .NET. Prevents duplicate background job execution across threads and across multiple app instances via Redis or SQL Server.

Topics

Resources

License

MIT, MIT licenses found

Licenses found

MIT
LICENSE
MIT
LICENSE.txt

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages