Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Task Control Tower
# Task Turnstile

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 an air traffic control tower.** Every job that wants to run must request clearance first. The tower knows exactly what's in the air — only one flight can hold a given slot at a time, others are told to wait or come back later. When a flight lands, the slot is released and the next one can take off.
> **Think of it like a turnstile.** Every job that wants to run must push through first. Only one can hold the bar at a timeothers 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. `TaskControlTower` gives you a named gate:
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:

```csharp
if (!await _concurrencyManager.CanStartAsync("import-job"))
Expand All @@ -22,26 +22,26 @@ Unlike a simple lock, the state can survive app restarts (via Redis or SQL Serve
### In-memory (single instance, no persistence)

```csharp
builder.Services.AddTaskControlTower();
builder.Services.AddTaskTurnstile();
```

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

### Redis

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

This creates a **dedicated** Redis connection for `TaskControlTower`, 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.
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 `TaskControlTower` to share your app's **existing** `IDistributedCache` instead, use `AddDistributedStore()` (see below).
If you'd prefer `TaskTurnstile` to share your app's **existing** `IDistributedCache` instead, use `AddDistributedStore()` (see below).

### SQL Server

```csharp
builder.Services.AddTaskControlTower()
builder.Services.AddTaskTurnstile()
.AddSqlServerStore(o =>
{
o.ConnectionString = "Server=.;Database=MyApp;...";
Expand All @@ -57,10 +57,10 @@ builder.Services.AddTaskControlTower()

### Use the app's existing `IDistributedCache`

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

```csharp
builder.Services.AddTaskControlTower()
builder.Services.AddTaskTurnstile()
.AddDistributedStore();
```

Expand All @@ -71,7 +71,7 @@ Task keys are prefixed with `KeyPrefix` (default `"cm:"`) to avoid collisions wi
## Options

```csharp
builder.Services.AddTaskControlTower(o =>
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).
Expand Down Expand Up @@ -217,11 +217,11 @@ Register it:

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

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

Expand Down
7 changes: 0 additions & 7 deletions TaskControlTower.slnx

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
using TaskControlTower.DependencyInjection;
using TaskTurnstile.DependencyInjection;
using Microsoft.Extensions.Caching.StackExchangeRedis;

namespace TaskControlTower.Redis.DependencyInjection;
namespace TaskTurnstile.Redis.DependencyInjection;

public static class TaskControlTowerBuilderExtensions
public static class TaskTurnstileBuilderExtensions
{
/// <summary>
/// Uses a dedicated Redis cache as the backing store, isolated from any other Redis cache
/// the app may have registered.
/// </summary>
/// <example>
/// services.AddTaskControlTower()
/// services.AddTaskTurnstile()
/// .AddRedisStore(o => o.Configuration = "localhost:6379");
/// </example>
public static TaskControlTowerBuilder AddRedisStore(
this TaskControlTowerBuilder builder,
public static TaskTurnstileBuilder AddRedisStore(
this TaskTurnstileBuilder builder,
Action<RedisCacheOptions> configure)
{
return builder.AddDistributedStore(_ =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PackageId>TaskControlTower.Redis</PackageId>
<PackageId>TaskTurnstile.Redis</PackageId>
<Authors>Chris Hunt</Authors>
<Company>TappetyClick</Company>
<Description>
Redis backing store for TaskControlTower via StackExchange.Redis and IDistributedCache.
Redis backing store for TaskTurnstile via StackExchange.Redis and IDistributedCache.
Supports dedicated Redis instances independent of the app's own cache.
</Description>
<PackageTags>Concurrency, Background Tasks, Named Lock, Redis, Distributed Cache</PackageTags>
Expand All @@ -20,7 +20,7 @@
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\TaskControlTower\TaskControlTower.csproj" />
<ProjectReference Include="..\TaskTurnstile\TaskTurnstile.csproj" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
using TaskControlTower.DependencyInjection;
using TaskTurnstile.DependencyInjection;
using Microsoft.Extensions.Caching.SqlServer;

namespace TaskControlTower.SqlServer.DependencyInjection;
namespace TaskTurnstile.SqlServer.DependencyInjection;

public static class TaskControlTowerBuilderExtensions
public static class TaskTurnstileBuilderExtensions
{
/// <summary>
/// Uses a dedicated SQL Server distributed cache as the backing store, isolated from any
/// other distributed cache the app may have registered.
/// The cache table must exist — create it with: dotnet sql-cache create "connection" schema table
/// </summary>
/// <example>
/// services.AddTaskControlTower()
/// services.AddTaskTurnstile()
/// .AddSqlServerStore(o =>
/// {
/// o.ConnectionString = "Server=...";
/// o.SchemaName = "dbo";
/// o.TableName = "TaskControlTowerCache";
/// o.TableName = "TaskTurnstileCache";
/// });
/// </example>
public static TaskControlTowerBuilder AddSqlServerStore(
this TaskControlTowerBuilder builder,
public static TaskTurnstileBuilder AddSqlServerStore(
this TaskTurnstileBuilder builder,
Action<SqlServerCacheOptions> configure)
{
return builder.AddDistributedStore(_ =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PackageId>TaskControlTower.SqlServer</PackageId>
<PackageId>TaskTurnstile.SqlServer</PackageId>
<Authors>Chris Hunt</Authors>
<Company>TappetyClick</Company>
<Description>
SQL Server backing store for TaskControlTower via IDistributedCache.
SQL Server backing store for TaskTurnstile via IDistributedCache.
Supports dedicated SQL Server instances independent of the app's own cache.
Requires the cache table to be pre-created with the dotnet sql-cache CLI tool.
</Description>
Expand All @@ -21,7 +21,7 @@
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\TaskControlTower\TaskControlTower.csproj" />
<ProjectReference Include="..\TaskTurnstile\TaskTurnstile.csproj" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,46 +1,46 @@
using TaskControlTower.DependencyInjection;
using TaskTurnstile.DependencyInjection;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NSubstitute;

namespace TaskControlTower.Tests.DependencyInjection;
namespace TaskTurnstile.Tests.DependencyInjection;

public class ServiceCollectionExtensionsTests
{
// ── Registration ──────────────────────────────────────────────────────────

[Fact]
public void AddTaskControlTower_RegistersITaskStateManager()
public void AddTaskTurnstile_RegistersITaskStateManager()
{
var sp = new ServiceCollection().AddTaskControlTower().Services.BuildServiceProvider();
var sp = new ServiceCollection().AddTaskTurnstile().Services.BuildServiceProvider();
Assert.NotNull(sp.GetService<ITaskStateManager>());
}

[Fact]
public void AddTaskControlTower_RegistersITaskStateStore()
public void AddTaskTurnstile_RegistersITaskStateStore()
{
var sp = new ServiceCollection().AddTaskControlTower().Services.BuildServiceProvider();
var sp = new ServiceCollection().AddTaskTurnstile().Services.BuildServiceProvider();
Assert.NotNull(sp.GetService<ITaskStateStore>());
}

// ── CleanupOnStartup ──────────────────────────────────────────────────────

[Fact]
public void AddTaskControlTower_CleanupOnStartup_True_RegistersHostedService()
public void AddTaskTurnstile_CleanupOnStartup_True_RegistersHostedService()
{
var sp = new ServiceCollection()
.AddTaskControlTower(o => o.CleanupOnStartup = true)
.AddTaskTurnstile(o => o.CleanupOnStartup = true)
.Services.BuildServiceProvider();

Assert.Contains(sp.GetServices<IHostedService>(), s => s is CleanupOnStartupHostedService);
}

[Fact]
public void AddTaskControlTower_CleanupOnStartup_False_DoesNotRegisterHostedService()
public void AddTaskTurnstile_CleanupOnStartup_False_DoesNotRegisterHostedService()
{
var sp = new ServiceCollection()
.AddTaskControlTower(o => o.CleanupOnStartup = false)
.AddTaskTurnstile(o => o.CleanupOnStartup = false)
.Services.BuildServiceProvider();

Assert.DoesNotContain(sp.GetServices<IHostedService>(), s => s is CleanupOnStartupHostedService);
Expand Down Expand Up @@ -68,7 +68,7 @@ public async Task AddDistributedStore_UsesRegisteredDistributedCache()

var sp = new ServiceCollection()
.AddSingleton<IDistributedCache>(appCache)
.AddTaskControlTower().AddDistributedStore()
.AddTaskTurnstile().AddDistributedStore()
.Services.BuildServiceProvider();

var store = sp.GetRequiredService<ITaskStateStore>();
Expand All @@ -85,7 +85,7 @@ public void UseTaskStateStore_ReplacesDefaultStore()
var customStore = Substitute.For<ITaskStateStore>();

var sp = new ServiceCollection()
.AddTaskControlTower().UseTaskStateStore(_ => customStore)
.AddTaskTurnstile().UseTaskStateStore(_ => customStore)
.Services.BuildServiceProvider();

Assert.Same(customStore, sp.GetRequiredService<ITaskStateStore>());
Expand All @@ -98,7 +98,7 @@ public void UseTaskStateStore_CalledTwice_LastRegistrationWins()
var second = Substitute.For<ITaskStateStore>();

var sp = new ServiceCollection()
.AddTaskControlTower()
.AddTaskTurnstile()
.UseTaskStateStore(_ => first)
.UseTaskStateStore(_ => second)
.Services.BuildServiceProvider();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
using TaskControlTower.DependencyInjection;
using TaskControlTower.Stores;
using TaskTurnstile.DependencyInjection;
using TaskTurnstile.Stores;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;

namespace TaskControlTower.Tests.Manager;
namespace TaskTurnstile.Tests.Manager;

/// <summary>Unit tests for TaskStateManager using a mock ITaskStateStore.</summary>
public class TaskStateManagerTests
{
private static (TaskStateManager manager, ITaskStateStore store) BuildWithMockStore(
TaskControlTowerOptions? options = null)
TaskTurnstileOptions? options = null)
{
var store = Substitute.For<ITaskStateStore>();
var manager = new TaskStateManager(store, options ?? new TaskControlTowerOptions());
var manager = new TaskStateManager(store, options ?? new TaskTurnstileOptions());
return (manager, store);
}

private static TaskStateManager BuildWithRealStore()
{
var cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));
var store = new DistributedCacheTaskStateStore(cache);
return new TaskStateManager(store, new TaskControlTowerOptions());
return new TaskStateManager(store, new TaskTurnstileOptions());
}

// ── CanStartAsync ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -101,7 +101,7 @@ public async Task StartAsync_UsesDefaultMaxRuntime_WhenNotProvided()
var cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));
var manager = new TaskStateManager(
new DistributedCacheTaskStateStore(cache),
new TaskControlTowerOptions { DefaultMaxRuntime = TimeSpan.FromMilliseconds(50) });
new TaskTurnstileOptions { DefaultMaxRuntime = TimeSpan.FromMilliseconds(50) });

await manager.StartAsync("job");
Assert.True(await manager.IsRunningAsync("job"));
Expand Down Expand Up @@ -400,8 +400,8 @@ private static (TaskStateManager managerA, TaskStateManager managerB) BuildTwoMa
var cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));
var store = new DistributedCacheTaskStateStore(cache);
return (
new TaskStateManager(store, new TaskControlTowerOptions()),
new TaskStateManager(store, new TaskControlTowerOptions())
new TaskStateManager(store, new TaskTurnstileOptions()),
new TaskStateManager(store, new TaskTurnstileOptions())
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
using TaskControlTower.Stores;
using TaskTurnstile.Stores;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;

namespace TaskControlTower.Tests.Stores;
namespace TaskTurnstile.Tests.Stores;

public class DistributedCacheTaskStateStoreTests
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\TaskControlTower\TaskControlTower.csproj" />
<ProjectReference Include="..\TaskTurnstile\TaskTurnstile.csproj" />
</ItemGroup>

</Project>
7 changes: 7 additions & 0 deletions TaskTurnstile.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Solution>
<Project Path="TaskTurnstile.Redis/TaskTurnstile.Redis.csproj" />
<Project Path="TaskTurnstile.SqlServer/TaskTurnstile.SqlServer.csproj" />
<Project Path="TaskTurnstile.Tests/TaskTurnstile.Tests.csproj" />
<Project Path="TaskTurnstile/TaskTurnstile.csproj" />
<Project Path="samples/FileStore/FileStore.csproj" />
</Solution>
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Hosting;

namespace TaskControlTower.DependencyInjection;
namespace TaskTurnstile.DependencyInjection;

internal sealed class CleanupOnStartupHostedService(ITaskStateStore store) : IHostedService
{
Expand Down
Loading
Loading