A lightweight, persistent background task orchestrator for .NET applications with built-in retry logic, timeouts, and database persistence using Entity Framework Core.
$ dotnet add package OrchestratumA powerful and flexible command orchestration library for .NET applications with persistent storage, automatic retries, distributed execution support, and command chaining capabilities.
dotnet add package Orchestratum
Orchestratum is built around a command/handler pattern:
IOrchCommand): Define what needs to be executed, including input data, timeout, retry count, and target instanceIOrchCommandHandler<TCommand>): Implement the actual execution logic for commandsIOrchestratum): Manages command enqueueing and orchestrationusing Orchestratum.Contract;
// Command with input only
public class SendEmailCommand : OrchCommand<EmailData>
{
public override TimeSpan Timeout => TimeSpan.FromMinutes(2);
public override int RetryCount => 5;
}
// Command with input and output
[OrchCommand("generate_report")]
public class GenerateReportCommand : OrchCommand<ReportRequest, ReportResult>
{
public override TimeSpan Timeout => TimeSpan.FromMinutes(10);
// Chain another command on success
protected override IEnumerable<IOrchCommand> OnSuccess(ReportResult output)
{
yield return new SendEmailCommand
{
Input = new EmailData
{
To = "admin@example.com",
Subject = "Report Generated",
Body = $"Report {output.ReportId} was generated"
}
};
}
}
using Orchestratum.Contract;
public class SendEmailCommandHandler : IOrchCommandHandler<SendEmailCommand>
{
private readonly IEmailService _emailService;
public SendEmailCommandHandler(IEmailService emailService)
{
_emailService = emailService;
}
public async Task<IOrchResult<SendEmailCommand>> Execute(
SendEmailCommand command,
CancellationToken cancellationToken)
{
try
{
await _emailService.SendAsync(command.Input, cancellationToken);
return command.CreateResult(OrchResultStatus.Success);
}
catch (Exception)
{
return command.CreateResult(OrchResultStatus.Failed);
}
}
}
public class GenerateReportCommandHandler : IOrchCommandHandler<GenerateReportCommand>
{
private readonly IReportService _reportService;
public GenerateReportCommandHandler(IReportService reportService)
{
_reportService = reportService;
}
public async Task<IOrchResult<GenerateReportCommand>> Execute(
GenerateReportCommand command,
CancellationToken cancellationToken)
{
var result = await _reportService.GenerateAsync(command.Input, cancellationToken);
return command.CreateResult(result, OrchResultStatus.Success);
}
}
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
var builder = Host.CreateDefaultBuilder(args);
builder.ConfigureServices(services =>
{
// Register application services
services.AddSingleton<IEmailService, EmailService>();
services.AddSingleton<IReportService, ReportService>();
// Configure Orchestratum
services.AddOchestratum(opts =>
{
// Configure database
opts.ConfigureDbContext(db =>
db.UseNpgsql("Host=localhost;Database=myapp"));
// Register commands and handlers from assemblies
opts.RegisterCommands(typeof(Program).Assembly);
opts.RegisterHandlers(typeof(Program).Assembly);
// Configure options
opts.CommandPollingInterval = TimeSpan.FromSeconds(5);
opts.LockTimeoutBuffer = TimeSpan.FromSeconds(10);
opts.MaxCommandPull = 100;
opts.InstanceKey = "default"; // For distributed scenarios
opts.TablePrefix = "ORCH_"; // Database table prefix
});
});
builder.Build().Run();
public class ReportController : ControllerBase
{
private readonly IOrchestratum _orchestratum;
public ReportController(IOrchestratum orchestratum)
{
_orchestratum = orchestratum;
}
[HttpPost("generate")]
public async Task<IActionResult> GenerateReport(ReportRequest request)
{
var command = new GenerateReportCommand
{
Input = request
};
await _orchestratum.Push(command);
return Accepted(new { commandId = command.Id });
}
}
services.AddOchestratum(opts =>
{
// Database configuration (required)
opts.ConfigureDbContext(db => db.UseNpgsql(connectionString));
// Polling interval for checking new commands (default: 5 seconds)
opts.CommandPollingInterval = TimeSpan.FromSeconds(5);
// Buffer time added to command timeout for lock expiration (default: 10 seconds)
opts.LockTimeoutBuffer = TimeSpan.FromSeconds(10);
// Maximum number of commands to pull in one polling cycle (default: 100)
opts.MaxCommandPull = 100;
// Instance key for distributed scenarios (default: "default")
opts.InstanceKey = "worker-1";
// Database table prefix (default: "ORCH_")
opts.TablePrefix = "ORCHESTRATUM_";
});
Any database supported by Entity Framework Core:
Create a design-time factory in your project:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Orchestratum.Database;
namespace YourProject.Database;
public class OrchDbContextFactory : IDesignTimeDbContextFactory<OrchDbContext>
{
public OrchDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<OrchDbContext>();
optionsBuilder.UseNpgsql(
"Host=localhost;Database=myapp;Username=user;Password=pass",
opts => opts.MigrationsAssembly(typeof(OrchDbContextFactory).Assembly.GetName().Name));
return new OrchDbContext(optionsBuilder.Options, "ORCH_");
}
}
Run migration commands:
# Add migration
dotnet ef migrations add InitialOrchestratum --context OrchDbContext
# Apply migration
dotnet ef database update --context OrchDbContext
# Remove last migration (if needed)
dotnet ef migrations remove --context OrchDbContext
Commands are stored in the {prefix}commands table (default: ORCH_commands):
| Column | Type | Description |
|---|---|---|
id | GUID | Unique command identifier |
target | string | Target instance key for routing |
name | string | Command name (from attribute or convention) |
input | string | JSON-serialized input data |
output | string | JSON-serialized output data |
scheduled_at | DateTimeOffset | When command should execute |
timeout | TimeSpan | Maximum execution duration |
is_running | bool | Whether command is currently executing |
running_at | DateTimeOffset? | When execution started |
run_expires_at | DateTimeOffset? | When execution lock expires |
is_completed | bool | Whether command completed successfully |
completed_at | DateTimeOffset? | When command completed |
is_canceled | bool | Whether command was canceled |
canceled_at | DateTimeOffset? | When command was canceled |
retries_left | int | Remaining retry attempts |
is_failed | bool | Whether command failed permanently |
failed_at | DateTimeOffset? | When command failed |
Commands are automatically named based on class name:
// Automatic naming: "send_email"
public class SendEmailCommand : OrchCommand<EmailData> { }
// Explicit naming via attribute
[OrchCommand("email.send")]
public class SendEmailCommand : OrchCommand<EmailData> { }
Naming convention:
Chain commands based on execution result:
public class ProcessOrderCommand : OrchCommand<OrderData, OrderResult>
{
// Execute these commands on success
protected override IEnumerable<IOrchCommand> OnSuccess(OrderResult output)
{
yield return new SendConfirmationEmailCommand
{
Input = new EmailData { OrderId = output.OrderId }
};
yield return new UpdateInventoryCommand
{
Input = new InventoryUpdate { Items = output.Items }
};
}
// Execute these commands on failure
protected override IEnumerable<IOrchCommand> OnFailure()
{
yield return new NotifyAdminCommand
{
Input = new AdminNotification { OrderId = Id }
};
}
// Execute these commands on cancellation
protected override IEnumerable<IOrchCommand> OnCancellation()
{
yield return new RefundPaymentCommand
{
Input = new PaymentRefund { OrderId = Id }
};
}
}
Route commands to specific instances using the Target property:
// Configure instances
services.AddOchestratum(opts =>
{
opts.InstanceKey = "email-worker"; // This instance processes email commands
// ...
});
// Route command to specific instance
var command = new SendEmailCommand
{
Input = emailData,
Target = "email-worker" // Will only be processed by email-worker instance
};
await _orchestratum.Push(command);
Retries are automatic:
RetriesLeft decrementedRetriesLeft >= 0 → Command becomes available for retryRetriesLeft == -1 → Command marked as permanently failedOnFailure commands are enqueued only after final failureTimeouts are enforced automatically:
Commands return status via IOrchResult:
public enum OrchResultStatus
{
Success, // Command executed successfully
Cancelled, // Command was cancelled (timeout or explicit)
Failed, // Command failed (exception or explicit)
NotFound, // Handler not found
TimedOut // Command exceeded timeout
}
Register commands and handlers explicitly:
services.AddOchestratum(opts =>
{
// Register specific command
opts.RegisterCommand(typeof(SendEmailCommand));
// Register specific handler
opts.RegisterHandler<SendEmailCommandHandler>();
// Or register from assemblies
opts.RegisterCommands(Assembly.GetExecutingAssembly());
opts.RegisterHandlers(Assembly.GetExecutingAssembly());
});
MIT License
Contributions are welcome! Please feel free to submit a Pull Request.