ASP.NET Core integration for Cita job scheduler
$ dotnet add package Cita.AspNetCoreA lightweight, robust job scheduling library for .NET 9 applications. Inspired by the popular Cita job scheduler for Node.js, rebuilt natively for the .NET ecosystem with strongly-typed handlers, attribute-based definitions, and a pluggable backend architecture.
IJobHandler<TJob> pattern with full DI supportMapCitaApi())ICitaBackend[JobDefinition], [Every], [Schedule] decorators# Core package
dotnet add package Cita.Core
# ASP.NET Core integration (optional)
dotnet add package Cita.AspNetCore
# Backend — pick one:
dotnet add package Cita.MongoDB # MongoDB
dotnet add package Cita.Redis # Redis (StackExchange.Redis)
dotnet add package Cita.EntityFramework # EF Core (PostgreSQL, SQL Server, SQLite)
Tip:
Cita.EntityFrameworkships with the SQLite provider built-in. For PostgreSQL or SQL Server, also add the respective EF Core provider package (e.g.Npgsql.EntityFrameworkCore.PostgreSQLorMicrosoft.EntityFrameworkCore.SqlServer).
Implement IJobHandler<TJob> to create a strongly-typed job handler with full dependency injection support:
public record SendNotificationJob(string Message);
public sealed class SendNotificationHandler(ILogger<SendNotificationHandler> logger)
: IJobHandler<SendNotificationJob>
{
public async Task HandleAsync(IJobContext<SendNotificationJob> context, CancellationToken ct)
{
logger.LogInformation("Sending: {Message}", context.Data.Message);
// Do work...
await context.TouchAsync(100, "Done", ct); // Report progress
}
}
The IJobContext<TJob> gives you access to:
context.Data — your deserialized job payloadcontext.JobId — unique job identifiercontext.JobName — the job namecontext.Priority — job priority levelcontext.FailCount / context.RetryAttempt — retry infocontext.TouchAsync(progress, message, ct) — extend lock & report progressusing Cita.AspNetCore;
using Cita.MongoDB;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCita(options =>
{
options.Name = "MyWorker";
options.ProcessEvery = TimeSpan.FromSeconds(5);
options.MaxConcurrency = 20;
})
.UseMongoDB(options =>
{
options.ConnectionString = "mongodb://localhost:27017";
options.DatabaseName = "myapp";
})
.AddJobHandler<SendNotificationJob, SendNotificationHandler>()
.AddHealthChecks();
var app = builder.Build();
app.MapCitaApi(); // REST management API at /api/cita
app.MapHealthChecks("/health");
app.Run();
Use the type-safe scheduling extensions — the job name is auto-derived from the type name via kebab-case (e.g. SendNotificationJob → "send-notification-job"):
var cita = app.Services.GetRequiredService<ICita>();
// Run immediately
await cita.EnqueueAsync(new SendNotificationJob("Hello!"));
// Schedule for later (human-readable)
await cita.ScheduleAsync(new SendNotificationJob("Later!"), "in 5 minutes");
// Schedule at a specific time
await cita.ScheduleAsync(new SendNotificationJob("Timed!"), DateTimeOffset.UtcNow.AddHours(1));
// Create a recurring job
await cita.RecurringAsync("0 9 * * *", new SendNotificationJob("Daily report"));
await cita.RecurringAsync("every 30 minutes", new SendNotificationJob("Heartbeat"));
Tip: Use the
[JobName("custom-name")]attribute on a job record to override the auto-derived name.
Cita.NET supports two registration patterns. You can mix and match them freely.
IJobHandler<TJob> (Recommended)Register strongly-typed handlers via the builder. Handlers participate in full DI (constructor injection):
builder.Services.AddCita(options => { ... })
.AddJobHandler<SendNotificationJob, SendNotificationHandler>()
.AddJobHandler<OrderData, ProcessOrderHandler>(options =>
{
options.Concurrency = 5;
options.Priority = JobPriority.High;
options.MaxRetries = 3;
options.RemoveOnComplete = true;
});
Handler registration options (JobHandlerOptions):
| Option | Type | Default | Description |
|---|---|---|---|
Name | string? | auto-derived | Explicit job name (overrides kebab-case derivation) |
Concurrency | int? | from CitaOptions | Max concurrent executions for this job type |
LockLimit | int? | 0 (unlimited) | Max jobs locked at once |
LockLifetime | TimeSpan? | 10 minutes | Lock validity duration |
Priority | JobPriority? | Normal | Default priority |
MaxRetries | int? | null | Max retry attempts |
RemoveOnComplete | bool | false | Remove after successful completion |
Logging | bool? | null | Log execution events (null = use global LoggingDefault) |
Use [JobsController] and [JobDefinition] attributes for a declarative style. Recurring schedules can be specified directly via [Every] and [Schedule] attributes:
[JobsController(Namespace = "email")]
public class EmailJobs(ILogger<EmailJobs> logger)
{
[JobDefinition(Concurrency = 5, Priority = JobPriority.High)]
public async Task SendWelcomeEmail(Job<WelcomeEmailData> job, CancellationToken ct)
{
logger.LogInformation("Welcome email to {Email}", job.Data.Email);
// ...
}
[JobDefinition(Concurrency = 1)]
[Every("1 hour", Name = "cleanup-logs")]
public async Task CleanupOldLogs(Job job, CancellationToken ct)
{
// Runs every hour automatically
}
[JobDefinition]
[Schedule("0 9 * * *", Name = "daily-digest", Timezone = "America/New_York")]
public async Task SendDailyDigest(Job job, CancellationToken ct)
{
// Runs daily at 9am Eastern
}
}
public record WelcomeEmailData(string UserId, string Email, string Name);
// Register in Program.cs
builder.Services.AddCita(options => { ... })
.UseMongoDB(options => { ... })
.AddJobsController<EmailJobs>();
| Attribute | Target | Description |
|---|---|---|
[JobDefinition] | Method | Marks a method as a job handler. Properties: Concurrency, Priority, LockLifetime, MaxRetries, RemoveOnComplete, Logging, Backoff |
[JobsController] | Class | Groups job definitions. Property: Namespace (prefix for job names) |
[Every] | Method | Repeating schedule. Properties: Interval, Name, Timezone, Data (JSON) |
[Schedule] | Method | Cron schedule. Properties: Cron, Name, Timezone, Data (JSON) |
[JobName] | Class/Struct | Overrides auto-derived kebab-case name for a job data type |
await cita.EveryAsync("5 minutes", "task");
await cita.EveryAsync("1 hour and 30 minutes", "task");
await cita.EveryAsync("2 days", "task");
await cita.ScheduleAsync("in 10 minutes", "task");
await cita.ScheduleAsync("tomorrow", "task");
await cita.EveryAsync("*/5 * * * *", "task"); // Every 5 minutes
await cita.EveryAsync("0 9 * * 1-5", "task"); // 9am weekdays
await cita.EveryAsync("0 0 1 * *", "task"); // First of month
await cita.EveryAsync("0 */2 * * * *", "task"); // Every 2 hours (6-field with seconds)
var job = cita.Create<MyData>("process-data", new MyData { Id = 123 });
job.SetPriority(JobPriority.High)
.Schedule(DateTime.UtcNow.AddHours(1))
.SetMaxRetries(3)
.SetRemoveOnComplete(true)
.Unique(new { dataId = 123 }); // Prevent duplicates
await job.SaveAsync();
Cita.NET includes a rich backoff strategy system:
| Strategy | Description |
|---|---|
ExponentialBackoff | Exponential delay with configurable factor, jitter, max delay, and max retries |
FixedBackoff | Constant delay between retries |
LinearBackoff | Linearly increasing delay |
CompositeBackoff | Chain multiple strategies (first match wins) |
ConditionalBackoff | Only retry when a predicate is satisfied |
| Preset | Strategy | Details |
|---|---|---|
BackoffPresets.Normal | Exponential | 1s → 2s → 4s → 8s → 16s (max 5 retries, 30s cap) |
BackoffPresets.Aggressive | Exponential | 5s → 25s → 125s → 625s (max 4 retries, 10m cap) |
BackoffPresets.Patient | Exponential | 1m → 5m → 25m → 2h → 10h (max 5 retries, 12h cap) |
BackoffPresets.Quick | Fixed | 1s delay, max 3 retries |
BackoffPresets.None | Fixed | No retry at all |
BackoffPresets.Forever | Exponential | 1s initial, 2× factor, 1h max — retries indefinitely |
Use the Backoff property on [JobDefinition] to select a preset by name:
[JobsController(Namespace = "tasks")]
public class TaskJobs
{
[JobDefinition(Backoff = "aggressive")]
public async Task CriticalTask(Job job, CancellationToken ct) { /* ... */ }
[JobDefinition(Backoff = "patient")]
public async Task ApiSync(Job job, CancellationToken ct) { /* ... */ }
[JobDefinition(Backoff = "none")]
public async Task FireAndForget(Job job, CancellationToken ct) { /* ... */ }
}
Available preset names: "normal", "aggressive", "patient", "quick", "none", "infinite".
Define() (Advanced)For dynamic or custom backoff strategies, use the lower-level Define() API:
var cita = app.Services.GetRequiredService<ICita>();
// Built-in preset
cita.Define("critical-task", async (job, ct) => { /* ... */ }, def =>
{
def.Backoff = BackoffPresets.Aggressive;
});
// Custom exponential backoff
cita.Define("my-job", async (job, ct) => { /* ... */ }, def =>
{
def.Backoff = new ExponentialBackoff(
initialDelay: TimeSpan.FromSeconds(5),
factor: 2.0,
maxDelay: TimeSpan.FromMinutes(30),
maxRetries: 5,
jitter: 0.1 // Add randomness to prevent thundering herd
);
});
// Conditional retry — only retry on transient exceptions
cita.Define("selective-retry", async (job, ct) => { /* ... */ }, def =>
{
def.Backoff = BackoffPresets.OnlyOn<TransientException>(BackoffPresets.Normal);
});
// Inside a handler
public async Task HandleAsync(IJobContext<MyJob> context, CancellationToken ct)
{
for (int i = 0; i < 100; i++)
{
// Do work...
await context.TouchAsync(i, $"Processing item {i}/100", ct);
}
}
// Subscribe to progress events
cita.JobProgress += (sender, e) =>
{
Console.WriteLine($"Job {e.Job.Name}: {e.Progress}% - {e.Message}");
};
Cita.NET includes a built-in persistent logging system that records job execution events (start, success, fail, complete, locked) to the configured backend.
Logging is enabled globally via CitaOptions.EnableLogging (default: true). Each backend stores logs with configurable TTL (default: 14 days).
Individual job definitions can override the global default. When Logging is null (the default), the global LoggingDefault is used:
builder.Services.AddCita(options =>
{
options.EnableLogging = true; // Master switch
options.LoggingDefault = true; // Default for definitions with Logging = null
})
.AddJobHandler<NoisyJob, NoisyJobHandler>(o => o.Logging = false) // Disable for this job
.AddJobHandler<ImportantJob, ImportantJobHandler>(o => o.Logging = true); // Always log
| Event | Level | When |
|---|---|---|
Locked | Debug | Job acquired by a worker |
Start | Information | Handler execution begins |
Success | Information | Handler completed without error |
Fail | Error | Handler threw an exception |
Complete | Information | After success or fail (always emitted) |
Each log entry includes metadata: workerName, processedAt, citaName, optional retryDelay, and error details on failure.
var cita = app.Services.GetRequiredService<ICita>();
// Query logs with filters
var result = await cita.GetLogsAsync(new JobLogQuery
{
JobName = "send-notification-job",
Level = JobLogLevel.Error,
From = DateTimeOffset.UtcNow.AddDays(-7),
Limit = 50
});
foreach (var entry in result.Entries)
Console.WriteLine($"[{entry.Level}] {entry.Event} — {entry.Message}");
// Clear old logs
var deleted = await cita.ClearLogsAsync(new JobLogQuery
{
To = DateTimeOffset.UtcNow.AddDays(-30)
});
The REST API also exposes log endpoints:
# Query logs
curl "http://localhost:5114/api/cita/logs?jobName=send-notification-job&level=Error&limit=50"
# Clear logs
curl -X DELETE "http://localhost:5114/api/cita/logs?jobName=send-notification-job"
Use ExternalLogger to send logs to a different backend than the one storing jobs — for example, store jobs in Redis but logs in MongoDB:
var mongoLogger = new MongoJobLogger(mongoDatabase, "jobLogs", TimeSpan.FromDays(30));
builder.Services.AddCita(options =>
{
options.EnableLogging = true;
options.ExternalLogger = mongoLogger;
})
.UseRedis("localhost:6379");
Each backend provides a public constructor for standalone use:
// MongoDB
var mongoLogger = new MongoJobLogger(database, "jobLogs", TimeSpan.FromDays(14));
// Redis
var redisLogger = new RedisJobLogger(connectionMultiplexer, keyPrefix: "cita:", database: 0);
// EF Core
var efLogger = new EfJobLogger(dbContextFactory, logsTableName: "cita_logs", schemaName: null);
cita.Ready += (s, e) => Console.WriteLine("Cita is ready!");
cita.Error += (s, e) => Console.WriteLine($"Error: {e.Exception.Message}");
cita.JobStarted += (s, e) => Console.WriteLine($"Started: {e.Job.Name}");
cita.JobCompleted += (s, e) => Console.WriteLine($"Completed: {e.Job.Name}");
cita.JobSucceeded += (s, e) => Console.WriteLine($"Success: {e.Job.Name}");
cita.JobFailed += (s, e) => Console.WriteLine($"Failed: {e.Job.Name} - {e.Exception.Message}");
cita.JobProgress += (s, e) => Console.WriteLine($"Progress: {e.Job.Name} {e.Progress}%");
cita.JobRetry += (s, e) => Console.WriteLine($"Retrying: {e.Job.Name} (attempt {e.Attempt})");
cita.JobRetryExhausted += (s, e) => Console.WriteLine($"Gave up: {e.Job.Name}");
Map the built-in management API:
app.MapCitaApi(); // Default prefix: /api/cita
app.MapCitaApi("/my-jobs"); // Custom prefix
| Method | Endpoint | Description |
|---|---|---|
GET | /api/cita/jobs | List jobs (query params: name, skip, limit, sort) |
GET | /api/cita/jobs/{id} | Get a specific job by ID |
POST | /api/cita/jobs/{name}/now | Run a job immediately (optional JSON body for data) |
POST | /api/cita/jobs/{name}/schedule | Schedule a job (body: { "when": "...", "data": {} }) |
POST | /api/cita/jobs/{name}/every | Create a recurring job (body: { "interval": "...", "data": {} }) |
POST | /api/cita/jobs/{id}/run | Trigger a specific job now |
POST | /api/cita/jobs/{id}/disable | Disable a job |
POST | /api/cita/jobs/{id}/enable | Enable a job |
DELETE | /api/cita/jobs/{id} | Delete a job |
DELETE | /api/cita/jobs/{name} | Cancel/delete jobs by name |
GET | /api/cita/definitions | List all defined job names |
GET | /api/cita/status | Get worker status (name, isRunning, definition count) |
GET | /api/cita/logs | Query job logs (params: jobId, jobName, level, event, from, to, skip, limit) |
DELETE | /api/cita/logs | Clear job logs (params: jobId, jobName, from, to) |
builder.Services.AddCita(options =>
{
// Worker identification
options.Name = "Worker-1"; // Default: "cita-{guid}"
// Polling interval
options.ProcessEvery = TimeSpan.FromSeconds(5); // Default: 5 seconds
// Concurrency limits
options.MaxConcurrency = 20; // Global max concurrent jobs (default: 20)
options.DefaultConcurrency = 5; // Per job-type default (default: 5)
// Lock settings
options.LockLimit = 0; // Global max locked (0 = unlimited)
options.DefaultLockLimit = 0; // Per job-type max locked (0 = unlimited)
options.DefaultLockLifetime = TimeSpan.FromMinutes(10); // Default: 10 minutes
// Behavior
options.RemoveOnComplete = false; // Keep completed jobs (default: false)
options.EnableLogging = true; // Persistent job logs (default: true)
options.LoggingDefault = true; // Per-definition default when Logging is null (default: true)
options.ExternalLogger = null; // Cross-backend IJobLogger (default: null)
options.AutoStart = true; // Start processing on app start (default: true)
});
.UseMongoDB(options =>
{
options.ConnectionString = "mongodb://localhost:27017"; // Default
options.DatabaseName = "cita"; // Default
options.JobsCollectionName = "jobs"; // Default
options.LogsCollectionName = "jobLogs"; // Default
options.EnsureIndexes = true; // Auto-create indexes (default: true)
options.EnableChangeStreams = false; // Requires replica set (default: false)
options.LogRetention = TimeSpan.FromDays(14); // TTL for log entries (default: 14 days)
})
// Or use the shorthand:
.UseMongoDB("mongodb://localhost:27017", "myapp")
The Cita.Redis backend stores jobs as Redis Hashes with Sets and Sorted Sets for indexing. It supports real-time notifications via Pub/Sub or a polling fallback.
using Cita.Redis;
// Full options
.UseRedis(options =>
{
options.ConnectionString = "localhost:6379"; // Default
options.KeyPrefix = "cita:"; // Default: key namespace
options.Database = 0; // Default: Redis database index
options.NotificationMode = RedisNotificationMode.PubSub; // PubSub | Polling | None
options.ChannelName = "cita:notifications"; // Default: Pub/Sub channel
options.PollInterval = TimeSpan.FromSeconds(1); // Default: polling interval
options.LogRetention = TimeSpan.FromDays(14); // Default: 14 days
options.LastModifiedBy = "worker-1"; // Default: "cita"
})
// Or use the shorthand:
.UseRedis("localhost:6379")
// Or provide an external IConnectionMultiplexer:
var mux = ConnectionMultiplexer.Connect("localhost:6379");
.UseRedis(options => { options.ConnectionMultiplexer = mux; })
| Option | Type | Default | Description |
|---|---|---|---|
ConnectionString | string | "localhost:6379" | Redis connection string |
Configuration | ConfigurationOptions? | null | Advanced StackExchange.Redis config (overrides ConnectionString) |
ConnectionMultiplexer | IConnectionMultiplexer? | null | External connection (backend won't own it) |
KeyPrefix | string | "cita:" | Prefix for all Redis keys |
Database | int | 0 | Redis database index |
ChannelName | string | "cita:notifications" | Pub/Sub channel name |
NotificationMode | RedisNotificationMode | PubSub | PubSub, Polling, or None |
PollInterval | TimeSpan | 1 second | Polling interval (when mode is Polling) |
LogRetention | TimeSpan | 14 days | TTL for log entries |
LastModifiedBy | string | "cita" | Worker name stamped on locked jobs |
Notifications:
PubSubmode uses Redis Pub/Sub for instant job notifications across workers.Pollingmode periodically scans for updated jobs.Nonedisables notifications (rely on polling loop only).
Atomicity: The find-and-lock operation uses a Lua script for atomic job acquisition, preventing race conditions across distributed workers.
The Cita.EntityFramework backend supports PostgreSQL, SQL Server, and SQLite via the same EF Core abstraction.
using Cita.EntityFramework;
// Full options
.UseEntityFramework(options =>
{
options.ConfigureDbContext = db => db.UseNpgsql("Host=localhost;Database=cita");
options.JobsTableName = "cita_jobs"; // Default
options.LogsTableName = "cita_logs"; // Default
options.SchemaName = "public"; // Default: provider default
options.UseMigrations = false; // Default: uses EnsureCreated
options.LogRetention = TimeSpan.FromDays(14); // Default: 14 days
options.EnableNotifications = false; // Polling-based notifications
options.NotificationPollInterval = TimeSpan.FromSeconds(1);
options.LastModifiedBy = "worker-1"; // Default: "cita"
})
// Or use the shorthand:
.UseEntityFramework(db => db.UseNpgsql("Host=localhost;Database=cita"))
// SQLite (great for development/testing)
.UseEntityFramework(db => db.UseSqlite("Data Source=cita.db"))
// SQL Server
.UseEntityFramework(db => db.UseSqlServer("Server=.;Database=Cita;Trusted_Connection=True"))
| Option | Type | Default | Description |
|---|---|---|---|
ConfigureDbContext | Action<DbContextOptionsBuilder> | — | EF provider configuration (UseNpgsql, UseSqlServer, UseSqlite) |
JobsTableName | string | "cita_jobs" | Name of the jobs table |
LogsTableName | string | "cita_logs" | Name of the logs table |
SchemaName | string? | null | Database schema (e.g. "public", "dbo") |
UseMigrations | bool | false | Use MigrateAsync() instead of EnsureCreatedAsync() |
LogRetention | TimeSpan | 14 days | TTL for log entries |
EnableNotifications | bool | false | Enable polling-based notification channel |
NotificationPollInterval | TimeSpan | 1 second | Polling interval (when notifications enabled) |
LastModifiedBy | string? | "cita" | Worker name stamped on locked jobs |
Schema creation: By default,
EnsureCreatedAsync()creates tables on first connect. For production, setUseMigrations = trueand usedotnet ef migrationswith the includedCitaDesignTimeDbContextFactory(uses SQLite by default — create your own for your target provider).
Provider detection: The dialect (PostgreSQL, SQL Server, SQLite) is auto-detected from the EF provider name. No manual configuration needed.
builder.Services.AddCita(options => { ... })
.UseMongoDB(options => { ... }) // or .UseEntityFramework(...)
.AddHealthChecks(); // Registers IHealthCheck
app.MapHealthChecks("/health");
The health check reports Healthy when the cita is running, Unhealthy otherwise.
┌─────────────────────────────────────────────────────────────┐
│ Your Application │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ ICita │ │ REST API │ │ Health Checks │ │
│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
├─────────┼────────────────┼────────────────────┼─────────────┤
│ │ Cita.AspNetCore │ │
├─────────┼─────────────────────────────────────┼─────────────┤
│ ┌──────┴──────┐ ┌─────────────┐ ┌──────────┴─────────┐ │
│ │CitaService │──│JobProcessor │──│ Channel<Job> │ │
│ └──────┬──────┘ └──────┬──────┘ └────────────────────┘ │
│ │ │ │
│ ┌──────┴────────────────┴────────────────────────────────┐ │
│ │ Cita.Core │ │
│ │ Models · Interfaces · Backoff · Scheduling │ │
│ │ Attributes · Registration · Services │ │
│ └──────────────────────────┬─────────────────────────────┘ │
├─────────────────────────────┼───────────────────────────────┤
│ ┌──────────────────────────┴────────────────────────────┐ │
│ │ ICitaBackend (Pluggable) │ │
│ │ ┌───────────────┐ ┌─────────────┐ ┌──────────────┐ │ │
│ │ │ Cita.MongoDB │ │ Cita.Redis. │ │Cita.EF Core│ │ │ │
│ │ │ │ │ │ │ ┌────┐┌────┐ │ │ │
│ │ │ │ │ Pub/Sub │ │ │ PG ││ SQL│ │ │ │
│ │ │ │ │ Lua Scripts│ │ └────┘└────┘ │ │ │
│ │ │ │ │ │ │ ┌──────┐ │ │ │
│ │ │ │ │ │ │ │SQLite│ │ │ │
│ │ └───────────────┘ └─────────────┘ └──────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Implement ICitaBackend, IJobRepository, and optionally IJobLogger / INotificationChannel:
public class MyCustomBackend : ICitaBackend
{
public string Name => "MyBackend";
public IJobRepository Repository { get; }
public INotificationChannel? NotificationChannel { get; }
public IJobLogger? Logger { get; }
public bool OwnsConnection => true;
public Task ConnectAsync(CancellationToken ct) { /* ... */ }
public Task DisconnectAsync(CancellationToken ct) { /* ... */ }
}
Register your backend:
builder.Services.AddCita(options => { ... })
.UseBackend(sp => new MyCustomBackend(/* ... */));
Cita.sln
├── src/
│ ├── Cita.Core/ # Core abstractions, models, scheduling, backoff, attributes
│ ├── Cita.AspNetCore/ # ASP.NET Core integration (DI, REST API, health checks)
│ ├── Cita.MongoDB/ # MongoDB backend implementation
│ ├── Cita.Redis/ # Redis backend (StackExchange.Redis, Lua scripts, Pub/Sub)
│ └── Cita.EntityFramework/ # EF Core backend (PostgreSQL, SQL Server, SQLite)
├── tests/
│ ├── Cita.Core.Tests/ # Unit tests for core logic (~324 tests)
│ ├── Cita.MongoDB.Tests/ # Integration tests with Testcontainers (~130 tests)
│ ├── Cita.Redis.Tests/ # Redis integration tests with Testcontainers (~56 tests)
│ └── Cita.EntityFramework.Tests/ # EF Core integration tests (~50 tests, SQLite in-memory)
└── example/
└── Cita.Sample/ # Sample ASP.NET Core application
cd packages/cita-dotnet
# Restore & build the entire solution
dotnet build
# Build a specific project
dotnet build src/Cita.Core
# Run all tests
dotnet test
# Run only core unit tests (fast, no Docker required)
dotnet test tests/Cita.Core.Tests
# Run MongoDB integration tests (requires Docker)
dotnet test tests/Cita.MongoDB.Tests
# Run Redis integration tests (requires Docker)
dotnet test tests/Cita.Redis.Tests
# Run EF Core integration tests (SQLite in-memory, no Docker required)
dotnet test tests/Cita.EntityFramework.Tests
# Detailed per-test output
dotnet test --logger "console;verbosity=detailed"
# Run a specific test class
dotnet test --filter "FullyQualifiedName~MongoJobRepositoryTests"
dotnet test --filter "FullyQualifiedName~EfJobRepositoryTests"
# Run a specific test method
dotnet test --filter "FullyQualifiedName~SaveJobAsync_NewJob_AssignsId"
Note: The MongoDB and Redis integration tests use Testcontainers to automatically spin up containers (MongoDB 7.0 / Redis 7 Alpine). Make sure Docker Desktop is running before executing them. The EF Core tests use SQLite in-memory databases and require no external dependencies.
The example/Cita.Sample project is a complete ASP.NET Core application that demonstrates both IJobHandler<TJob> registrations and attribute-based controllers. It supports multiple backends via a --backend argument.
It includes:
SendNotificationHandler — a simple handler for SendNotificationJobProcessOrderHandler — handler with progress reporting (25% → 50% → 75% → 100%)EmailJobs — attribute-based controller with [Every] and [Schedule] definitionsOption A: MongoDB (default)
docker run -d --name cita-mongo -p 27017:27017 mongo:7.0
Or set a custom connection string:
export ConnectionStrings__MongoDB="mongodb://localhost:27017"
Option B: SQLite (no external dependencies)
cd example/Cita.Sample
dotnet run -- --backend sqlite
This creates an cita.db file in the project directory — perfect for local development.
Option C: PostgreSQL
docker run -d --name cita-pg -p 5432:5432 -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=cita postgres:16
cd example/Cita.Sample
dotnet run -- --backend postgres
Or set a custom connection string:
export ConnectionStrings__PostgreSQL="Host=localhost;Database=cita;Username=postgres;Password=postgres"
Option D: SQL Server
cd example/Cita.Sample
dotnet run -- --backend sqlserver
Set the connection string:
export ConnectionStrings__SqlServer="Server=localhost;Database=Cita;Trusted_Connection=True;TrustServerCertificate=True"
Option E: Redis
docker run -d --name cita-redis -p 6379:6379 redis:7-alpine
cd example/Cita.Sample
dotnet run -- --backend redis
Or set a custom connection string:
export ConnectionStrings__Redis="localhost:6379"
cd example/Cita.Sample
# MongoDB (default)
dotnet run
# SQLite (zero-config)
dotnet run -- --backend sqlite
# PostgreSQL
dotnet run -- --backend postgres
# Redis
dotnet run -- --backend redis
The app starts at http://localhost:5114. You'll see event logs in the console as jobs are processed:
[EVENT] Job started: send-notification-job (abc123)
[EVENT] Job succeeded: send-notification-job (abc123)
Enqueue a job immediately:
curl -X POST "http://localhost:5114/demo/notification?message=Hello"
# → { "jobId": "...", "message": "Notification job scheduled" }
Process an order (with progress tracking):
curl -X POST http://localhost:5114/demo/order \
-H "Content-Type: application/json" \
-d '{"orderId": "ORD-001", "amount": 99.99, "customerId": "CUST-1"}'
# → { "jobId": "...", "message": "Order processing job scheduled" }
Schedule for later (human-readable):
curl -X POST "http://localhost:5114/demo/schedule?when=in+5+minutes&message=Later"
# → { "jobId": "...", "scheduledFor": "2026-02-06T12:05:00Z" }
Create a recurring job:
curl -X POST "http://localhost:5114/demo/recurring?interval=every+30+seconds&name=heartbeat"
# → { "jobId": "...", "interval": "every 30 seconds", "nextRun": "..." }
# Worker status
curl http://localhost:5114/api/cita/status
# → { "name": "SampleWorker", "isRunning": true, ... }
# List all jobs
curl http://localhost:5114/api/cita/jobs
# List registered job definitions
curl http://localhost:5114/api/cita/definitions
# Disable a job
curl -X POST http://localhost:5114/api/cita/jobs/{id}/disable
# Health check
curl http://localhost:5114/health
# → Healthy
The sample also exposes an OpenAPI spec in development mode:
curl http://localhost:5114/openapi/v1.json
| Package | Purpose |
|---|---|
| xUnit | Test framework |
| FluentAssertions | Assertion library |
| NSubstitute | Mocking framework |
| Testcontainers | Docker-based integration tests |
| Interface | Package | Purpose |
|---|---|---|
ICita | Core | Main scheduling API |
ICitaBackend | Core | Backend abstraction (connect, disconnect, expose repo/logger/channel) |
IJobRepository | Core | CRUD + locking operations for jobs |
IJobLogger | Core | Persistent job execution log |
INotificationChannel | Core | Real-time job change notifications |
IJobHandler<TJob> | Core | Strongly-typed job handler |
EfCitaBackend | EntityFramework | EF Core backend (Postgres, SQL Server, SQLite) |
IDatabaseDialect | EntityFramework | Provider-specific SQL abstraction (internal) |
RedisCitaBackend | Redis | Redis backend (Hashes, Sorted Sets, Lua scripts) |
RedisJobRepository | Redis | IJobRepository via Redis Hashes + Sets + Sorted Sets |
RedisPubSubNotificationChannel | Redis | Real-time notifications via Redis Pub/Sub |
MIT License — see LICENSE for details.
Contributions are welcome! Please read our Contributing Guide first.