Cita.Core 1.0.2

dotnet add package Cita.Core --version 1.0.2
                    
NuGet\Install-Package Cita.Core -Version 1.0.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Cita.Core" Version="1.0.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Cita.Core" Version="1.0.2" />
                    
Directory.Packages.props
<PackageReference Include="Cita.Core" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Cita.Core --version 1.0.2
                    
#r "nuget: Cita.Core, 1.0.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Cita.Core@1.0.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Cita.Core&version=1.0.2
                    
Install as a Cake Addin
#tool nuget:?package=Cita.Core&version=1.0.2
                    
Install as a Cake Tool

Cita.NET

A 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.

Features

  • πŸš€ Strongly-typed handlers β€” IJobHandler<TJob> pattern with full DI support
  • πŸ“… Flexible scheduling β€” Cron expressions, human-readable intervals, one-time execution
  • πŸ”„ Automatic retries β€” Configurable backoff strategies (exponential, linear, fixed, conditional)
  • πŸ”’ Distributed locking β€” Safe for multiple workers/instances
  • πŸ“Š Progress tracking β€” Report job progress (0–100%) in real-time
  • 🎯 Job priorities β€” Lowest β†’ Low β†’ Normal β†’ High β†’ Highest
  • πŸ“ Persistent logging β€” TTL-managed job execution history
  • πŸ₯ Health checks β€” Built-in ASP.NET Core health check integration
  • 🌐 REST API β€” Built-in management endpoints (MapCitaApi())
  • πŸ”Œ Pluggable backends β€” MongoDB, Redis, & Entity Framework Core (PostgreSQL, SQL Server, SQLite), extensible via ICitaBackend
  • 🏷️ Attribute-based definitions β€” [JobDefinition], [Every], [Schedule] decorators
  • ⚑ Change Streams β€” Real-time notifications via MongoDB Change Streams (replica set)

Installation

# 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.EntityFramework ships with the SQLite provider built-in. For PostgreSQL or SQL Server, also add the respective EF Core provider package (e.g. Npgsql.EntityFrameworkCore.PostgreSQL or Microsoft.EntityFrameworkCore.SqlServer).

Quick Start

1. Define a Job Handler

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 payload
  • context.JobId β€” unique job identifier
  • context.JobName β€” the job name
  • context.Priority β€” job priority level
  • context.FailCount / context.RetryAttempt β€” retry info
  • context.TouchAsync(progress, message, ct) β€” extend lock & report progress

2. Register and Wire Up

using 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();

3. Schedule Jobs

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.

Job Registration Patterns

Cita.NET supports two registration patterns. You can mix and match them freely.

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)

Pattern 2: Attribute-Based Controllers

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>();

Available Attributes

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

Scheduling Options

Human-Readable Intervals

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");

Cron Expressions

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)

Fluent Job Configuration

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();

Retry & Backoff Strategies

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

Built-in Presets

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

Usage with Attributes

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".

Usage with 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);
});

Progress Tracking

// 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}");
};

Persistent Job Logging

Cita.NET includes a built-in persistent logging system that records job execution events (start, success, fail, complete, locked) to the configured backend.

Enabling Logging

Logging is enabled globally via CitaOptions.EnableLogging (default: true). Each backend stores logs with configurable TTL (default: 14 days).

Per-Definition Control

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

Log Events

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.

Querying Logs

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"

Cross-Backend Logging (External Logger)

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");

Standalone Logger Constructors

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);

Job Events

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}");

REST API

Map the built-in management API:

app.MapCitaApi();           // Default prefix: /api/cita
app.MapCitaApi("/my-jobs"); // Custom prefix

Endpoints

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)

Configuration

Cita Options

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)
});

MongoDB Options

.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")

Redis Options

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: PubSub mode uses Redis Pub/Sub for instant job notifications across workers. Polling mode periodically scans for updated jobs. None disables 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.

Entity Framework Core Options

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, set UseMigrations = true and use dotnet ef migrations with the included CitaDesignTimeDbContextFactory (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.

Health Checks

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.

Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                      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β”‚   β”‚ β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Creating Custom Backends

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(/* ... */));

Development

Prerequisites

Repository Structure

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

Building

cd packages/cita-dotnet

# Restore & build the entire solution
dotnet build

# Build a specific project
dotnet build src/Cita.Core

Running Tests

# 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.

Running the Sample App

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 SendNotificationJob
  • ProcessOrderHandler β€” handler with progress reporting (25% β†’ 50% β†’ 75% β†’ 100%)
  • EmailJobs β€” attribute-based controller with [Every] and [Schedule] definitions
1. Choose a Backend

Option 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"
2. Run the App
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)
3. Try the Demo Endpoints

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": "..." }
4. Explore the Management API
# 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
5. OpenAPI / Swagger

The sample also exposes an OpenAPI spec in development mode:

curl http://localhost:5114/openapi/v1.json

Testing Frameworks

Package Purpose
xUnit Test framework
FluentAssertions Assertion library
NSubstitute Mocking framework
Testcontainers Docker-based integration tests

Key Interfaces for Contributors

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

License

MIT License β€” see LICENSE for details.

Contributing

Contributions are welcome! Please read our Contributing Guide first.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (4)

Showing the top 4 NuGet packages that depend on Cita.Core:

Package Downloads
Cita.AspNetCore

ASP.NET Core integration for Cita job scheduler

Cita.MongoDB

MongoDB backend for Cita job scheduler

Cita.EntityFramework

Package Description

Cita.Redis

Redis backend for Cita job scheduler

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.2 122 2/11/2026
1.0.1 119 2/11/2026
1.0.0 125 2/11/2026