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.
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 tickUnlike a simple lock, the state can survive app restarts (via Redis or SQL Server) and be shared across multiple instances of your app.
builder.Services.AddTaskTurnstile();The default store is a private in-memory cache — isolated from your app's own IMemoryCache and requiring zero configuration.
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).
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.
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
maxRuntimeexpires.
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:";
});Inject ITaskStateManager into your class:
public class ImportJob(ITaskStateManager manager)bool canStart = await manager.CanStartAsync("import-job");Returns true if the task is not currently running (or its maxRuntime has expired).
// 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");// Waits until "import-job" is free, then starts and runs the work.
await manager.RunAsync("import-job", async ct =>
{
await DoImportAsync(ct);
});if (!await manager.StartAsync("import-job"))
return; // already running
try
{
await DoImportAsync(cancellationToken);
}
finally
{
await manager.TryStopAsync("import-job");
}All methods accept an optional maxRuntime to override the global default:
await manager.TryRunAsync("long-job", DoWorkAsync, maxRuntime: TimeSpan.FromHours(4));public class ImportInvocable(ITaskStateManager manager) : IInvocable
{
public async Task Invoke()
{
await manager.TryRunAsync("import", async ct =>
{
await DoImportAsync(ct);
});
}
}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);
}
}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.