Bullfire 1.3.1
dotnet add package Bullfire --version 1.3.1
NuGet\Install-Package Bullfire -Version 1.3.1
<PackageReference Include="Bullfire" Version="1.3.1" />
<PackageVersion Include="Bullfire" Version="1.3.1" />
<PackageReference Include="Bullfire" />
paket add Bullfire --version 1.3.1
#r "nuget: Bullfire, 1.3.1"
#:package Bullfire@1.3.1
#addin nuget:?package=Bullfire&version=1.3.1
#tool nuget:?package=Bullfire&version=1.3.1
<div align="center">

Fast, Redis-backed background job queue and scheduler for .NET.
</div>
What is Bullfire?
Bullfire is a production-ready background job processing library for .NET. You push work into a queue from anywhere in your app; workers running in the same process β or in a separate service, on a different server β pick up those jobs and execute them reliably.
Every core capability you expect from a mature job-queue system ships in the free, MIT-licensed package: retries with backoff, priorities, delayed jobs, cron schedulers, parent/child job trees, rate limiting (fixed-window, sliding-window, and per-group), stalled-job recovery, OpenTelemetry tracing/metrics, a web dashboard, a command-line tool for ops, and a Hangfire migration package for teams moving off Hangfire. No paid tier, no "Enterprise Edition".
What's new in 1.3.0
- π
Bullfire.Hangfire.Compatpackage β drop-inBackgroundJob.Enqueue<T>(...)/RecurringJob.AddOrUpdate<T>(...)/Cron.*for teams migrating off Hangfire. One using statement and your existing job code keeps working. Jump to example. - π Sliding-window rate limit mode β
RateLimitMode.Slidingenforces a strict "β€ Max in any trailing Duration" bound at every fetch, instead of the fixed-window reset-on-boundary behavior. Jump to example. - π Per-group rate limiting β tag a job with
JobOptions.Group = "tenant-42"and the limiter scopes per group. Saturating one tenant can no longer starve the others. Jump to example. - π BenchmarkDotNet suite β 8 benchmarks covering enqueue shapes, bulk batches, pipelined counts, and single-worker drain throughput. Replaces ad-hoc perf claims with runnable numbers.
When to use it
- Send emails / notifications outside the web request/response cycle
- Process file uploads, images, videos, or any long-running work asynchronously
- Run scheduled reports, cleanups, syncs on a cron schedule
- Build multi-step workflows where a parent job waits for many children to finish
- Enforce rate limits when calling paid or rate-limited third-party APIs
- Anything you would normally call a "job queue", "task queue", or "work queue"
How it works
ββββββββββββββββββββββ ββββββββββββββββββββββ
β Your application β β Redis β
β β queue.AddAsync(...) β β
β (producer) β ββββββββββββββββββββΆ β Atomic Lua writesβ
β β β state buckets + β
β β β event stream β
ββββββββββββββββββββββ ββββββββββββ²ββββββββββ
β
β blocking fetch
ββββββββββββββββββββββ β
β Worker service β β
β β Worker<TData> main loop βββββββββ
β IJobHandler<T> β
β (runs with scoped β Lock auto-renewed on timer.
β DI per job) β Stalled jobs recovered.
β β Events emitted.
ββββββββββββββββββββββ
- Producer calls
queue.AddAsync("my-job", data, options). Bullfire runs a single Lua script that atomically stores the job's data, puts its id in the right state bucket (waiting / delayed / prioritized), and writes anaddedevent to the Redis Stream. - Worker runs an infinite loop that atomically pulls the next eligible job from Redis,
takes out a lock, and invokes your
IJobHandler<TData>.HandleAsync(context, ct)inside a fresh DI scope (so each invocation gets its ownDbContext,HttpClient, etc.). - While the handler runs, Bullfire renews the lock every 15 seconds on a dedicated timer. If the worker process dies mid-job, the stalled-detection tick recovers the job and re-queues it automatically.
- When the handler succeeds, Bullfire writes the return value to the job hash, moves
the id to the
completedbucket, and emits acompletedevent. If it throws and attempts remain, it re-queues with exponential backoff; if attempts are exhausted, it moves tofailed. - Every state transition emits an event to the queue's Redis Stream, so dashboards and monitoring can observe the system in real time.
All of this is powered by 11 embedded Lua scripts that ensure atomic state transitions under concurrent workers. You never see a job in two places at once, and you never lose a job because a worker crashed at the wrong moment.
Performance
Bullfire is built for throughput. Every redis call in the hot path has been profiled and tuned:
| Optimization | Impact |
|---|---|
| Cached writable-server lookup on every connection | Saves a per-call endpoint scan; ~5% throughput recovered on hot worker loops. |
Process-wide Lua script registry (one cache per BullfireRedisConnection) |
Skips repeated LuaScript.Prepare + per-server SHA1 cache lookups when you create many Queue / Worker instances. |
Pipelined AddBulkAsync |
1k-job bulk enqueue dropped from ~1.4s β ~120ms on localhost Redis. |
Pipelined GetCountsAsync |
7 state counts in a single round-trip instead of 7 sequential calls. |
Pipelined DrainAsync / RetryAllFailedAsync |
Bulk admin actions ship in a single batch. |
Stable Lua hashes via EmbeddedResource + StackExchange.Redis |
EVALSHA on every call after the first; falls back to EVAL only on NOSCRIPT. |
| Dedicated multiplexer for blocking worker reads | BZPOPMIN / XREAD BLOCK never starves the producer multiplexer. |
On localhost Redis (Memurai 8.2 / Windows 11), single-process Bullfire sustains roughly 10,000+ enqueues/sec from one producer and 1,000+ jobs/sec of throughput per worker on trivial handlers.
Features
| Capability | In MIT core | Notes |
|---|---|---|
| Enqueue (standard / delayed / prioritized / bulk) | β | Atomic via Lua, pipelined for bulk |
| Worker loop + lock renewal + stalled detection | β | 30s lock, 15s renew, 30s stalled tick |
| Retries with backoff | β | Fixed, exponential-with-jitter, or custom IBackoffStrategy |
| Rate limiting | β | Fixed-window, sliding-window, and per-group (e.g. per-tenant) |
RemoveOnComplete / KeepLast(N) truncation |
β | |
| Cron schedulers | β | Standard 5-field cron |
Parent/child job flows (FlowProducer) |
β | Arbitrary depth; parent runs only after all children succeed |
Event stream (QueueEvents) |
β | Real-time state-transition notifications |
| Pause / resume, drain, retry-all-failed | β | Admin operations as first-class API |
| DI / HostedService / ILogger integration | β | Standard ASP.NET Core patterns |
OpenTelemetry ActivitySource + Meter |
β | Source/meter name: "Bullfire" |
IHealthCheck |
β | Redis reachability + backlog threshold |
| Web UI dashboard | β | Separate package Bullfire.Dashboard |
| Command-line tool | β | Separate package Bullfire.Cli (dotnet tool install -g Bullfire.Cli) |
| Hangfire-compat layer | β | Separate package Bullfire.Hangfire.Compat β BackgroundJob.Enqueue, RecurringJob.AddOrUpdate, Cron.*. Drop-in for teams migrating off Hangfire. |
| BenchmarkDotNet suite | β | dotnet run -c Release --project benchmarks/Bullfire.Benchmarks β enqueue, bulk, counts, worker throughput. |
Install
dotnet add package Bullfire
Target frameworks: net8.0, net9.0, net10.0.
Runtime dependency: StackExchange.Redis
(MIT) plus Microsoft's standard .Extensions.* abstractions (all MIT, tiny interface-only
packages). That's it. No hidden deps, no commercial packages.
Quick start
Producer β enqueue jobs from your app
using Bullfire;
using StackExchange.Redis;
using var mux = await ConnectionMultiplexer.ConnectAsync("localhost:6379");
var connection = new BullfireRedisConnection(mux);
var queue = new Queue("emails", connection);
// Simple job
await queue.AddAsync("send-welcome", new WelcomeEmail(userId: 42));
// Delayed, prioritized, retried
await queue.AddAsync("send-reminder", new Reminder(), new JobOptions
{
DelayMilliseconds = 60_000,
Priority = 5,
Attempts = 3,
Backoff = new BackoffOptions { Type = "exponential", DelayMilliseconds = 1_000 },
});
// Bulk β pipelined under the hood; 1000 jobs ship in ~120ms on localhost Redis
await queue.AddBulkAsync(orders.Select(o =>
new BulkJob("process-order", o, new JobOptions { Attempts = 5 })));
Worker β process jobs in the background
using Bullfire;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddBullfire("localhost:6379");
builder.Services.AddBullfireWorker<WelcomeEmailHandler, WelcomeEmail>("emails");
builder.Build().Run();
// One class per job type.
public sealed record WelcomeEmail(int UserId);
public sealed class WelcomeEmailHandler : IJobHandler<WelcomeEmail>
{
private readonly IEmailGateway _gateway;
// Scoped DI β new instance per job invocation. Inject DbContext,
// HttpClient, etc. just like an ASP.NET Core controller action.
public WelcomeEmailHandler(IEmailGateway gateway) => _gateway = gateway;
public async Task HandleAsync(JobContext<WelcomeEmail> ctx, CancellationToken ct)
{
await _gateway.SendWelcomeAsync(ctx.Data.UserId, ct);
}
}
Admin operations
// One round-trip; 7 state counts.
QueueCounts c = await queue.GetCountsAsync();
Console.WriteLine($"wait={c.Wait} delayed={c.Delayed} failed={c.Failed} total={c.Total}");
// Pause workers during a deploy, then resume.
await queue.PauseAsync();
// ... cut over the worker fleet ...
await queue.ResumeAsync();
// Recover after a downstream outage.
long retried = await queue.RetryAllFailedAsync();
// Promote a delayed job to wait immediately (skip the schedule).
bool promoted = await queue.PromoteAsync("job-id-42");
// Wipe pre-deploy state (defaults to wait + delayed + prioritized).
long drained = await queue.DrainAsync();
// Or page through a bucket.
string[] failedIds = await queue.ListJobIdsAsync(JobState.Failed, 0, 49);
Cron schedulers
await queue.UpsertJobSchedulerAsync(
schedulerId: "nightly-cleanup",
cronPattern: "0 3 * * *", // every day at 3 AM
new JobSchedulerTemplate("cleanup", new { Scope = "all" }));
Parent/child flows
var producer = new FlowProducer(connection);
// Parent waits until ALL children complete.
var result = await producer.AddAsync("batch-report", new FlowJobNode(
Name: "aggregate-results",
Children: new[]
{
new FlowJobNode("process-region", new { Region = "us-east" }),
new FlowJobNode("process-region", new { Region = "us-west" }),
new FlowJobNode("process-region", new { Region = "eu" }),
}));
Hangfire migration β Bullfire.Hangfire.Compat
Migrating off Hangfire? Install one extra package, change one using statement, and most of your existing job code keeps working. No rewriting controllers, no rewriting services.
dotnet add package Bullfire.Hangfire.Compat
// Program.cs β wire it up alongside AddBullfire.
builder.Services.AddBullfire("localhost:6379");
builder.Services.AddBullfireHangfireCompat();
// Anywhere in your app β same shape as Hangfire's API.
using Bullfire.Hangfire.Compat;
// Fire-and-forget
BackgroundJob.Enqueue<IEmailService>(svc => svc.SendWelcome(userId));
// Delayed (relative or absolute)
BackgroundJob.Schedule<IEmailService>(svc => svc.SendReminder(userId), TimeSpan.FromHours(2));
// Cron-scheduled β Cron.* helpers mirror Hangfire's
RecurringJob.AddOrUpdate<IReportService>("daily-digest", svc => svc.RunDigest(), Cron.Daily());
RecurringJob.AddOrUpdate<ICleanupService>("hourly-purge", svc => svc.Purge(), Cron.Hourly());
RecurringJob.AddOrUpdate<ITenantBilling>("end-of-month", svc => svc.Charge(), Cron.Monthly());
How it works under the hood: the expression is captured into a MethodInvocation
payload, persisted as the Bullfire job data, and replayed via reflection by a generic handler
that resolves the target service from a fresh DI scope per invocation. Same execution
semantics as a native IJobHandler<T> β retries, backoff, OpenTelemetry tracing/metrics,
dashboard, admin operations, all of it.
Rate limiting β three modes
Three modes: fixed-window (default, BullMQ-compat), sliding-window, and per-group.
// Fixed window β at most 10 jobs in any 1-second window starting at the counter reset.
// Cheapest, BullMQ-compatible. May allow up to 2Γ burst across boundaries.
builder.Services.AddBullfireWorker<PaidApiHandler, ApiJob>("paid-api", opts =>
{
opts.RateLimit = new RateLimitOptions
{
Max = 10,
Duration = TimeSpan.FromSeconds(1),
};
});
// Sliding window β at most 10 jobs in the trailing 1 second at every fetch decision.
// Strictly bounds the rate; slightly more expensive (sorted set per limiter key).
opts.RateLimit = new RateLimitOptions
{
Max = 10,
Duration = TimeSpan.FromSeconds(1),
Mode = RateLimitMode.Sliding,
};
// Per-group β same rate but applied PER tenant / cohort / API key.
// Tag the job at enqueue time and the limiter key is scoped automatically.
await queue.AddAsync("call-vendor", payload, new JobOptions
{
Group = $"tenant-{tenantId}",
});
Observability
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddSource("Bullfire"))
.WithMetrics(m => m.AddMeter("Bullfire"));
builder.Services.AddHealthChecks()
.AddCheck<Bullfire.HealthChecks.BullfireHealthCheck>("bullfire");
Bullfire CLI
Bullfire.Cli is a dotnet tool that talks to any Bullfire-backed Redis instance β no
application restart, no source changes. It's the tool DevOps folks reach for during an
incident, and the tool developers reach for when they want to see what's actually in the
queue.
Install
dotnet tool install -g Bullfire.Cli
After install, bullfire <command> [...] is on your PATH. Configure the Redis connection
once via the env var:
# bash / zsh
export BULLFIRE_REDIS=localhost:6379
# PowerShell
$env:BULLFIRE_REDIS = "localhost:6379"
β¦or pass --redis "<connection-string>" per command. Every command also accepts --prefix
(defaults to bull) and --json for machine-readable output.
Commands
| Command | What it does |
|---|---|
bullfire ping |
Round-trips a PING and prints the latency. Use it as a health probe. |
bullfire status --queue <name> |
Snapshot of every job-state counter (wait, paused, active, prioritized, delayed, completed, failed). |
bullfire watch --queue <name> |
Tail the live event stream β added / waiting / active / completed / failed. Ctrl-C to stop. |
bullfire jobs --queue <name> --state <s> [--limit n] |
List job ids in a state. |
bullfire inspect --queue <name> --id <id> |
Print one job's full hash (data, opts, attempts, timestamps, return value). |
bullfire retry --queue <name> --id <id> |
Re-enqueue a single failed job. |
bullfire retry --queue <name> --all |
Re-enqueue every failed job. |
bullfire promote --queue <name> --id <id> |
Move a delayed job to :wait immediately. |
bullfire remove --queue <name> --id <id> |
Delete a job from every state plus its hash, lock, and logs. |
bullfire drain --queue <name> [--states ...] |
Empty the queue. Defaults to Wait,Delayed,Prioritized; pass --states All to also clear completed/failed. |
bullfire pause --queue <name> |
Workers stop dequeueing. |
bullfire resume --queue <name> |
Workers resume. |
bullfire cron list --queue <name> |
List active cron schedulers for a queue. |
bullfire cron remove --queue <name> --id <sid> |
Delete a cron scheduler. |
bullfire version |
Print library + CLI versions. |
Real examples
1. Quick health probe.
bullfire ping
# β PONG (0.6 ms)
bullfire status --queue emails
# Queue: emails
# --------------------------------
# wait 12
# paused 0
# active 2
# prioritized 0
# delayed 5
# completed 1043
# failed 7
# --------------------------------
# total 1069
2. "Why isn't my email job sending?" β tail events live.
bullfire watch --queue emails
# Watching queue "emails" β Ctrl-C to stop.
# 1735728000000-0 added job=42 name=send-welcome
# 1735728000005-0 waiting job=42
# 1735728000061-0 active job=42 prev=waiting
# 1735728001023-0 completed job=42
If a job is stuck β say its handler is throwing β you'll see failed events with reason
fields, and you can drill into the bad job:
bullfire inspect --queue emails --id 42
# id 42
# name send-welcome
# data {"userId":7}
# opts {"attempts":3}
# attempts made 3
# enqueued at 2026-04-30 09:13:20Z
# started at 2026-04-30 09:13:21Z
# finished at 2026-04-30 09:13:21Z
# failed reason SmtpException: 5.7.1 Mailbox blocked
3. Recover from a third-party outage. The downstream API was down for an hour, every
job sent during that window has now retried-out and ended up in :failed. Once the API is
back:
bullfire retry --queue emails --all
# Retried 247 failed job(s) in "emails".
4. Pre-deploy: pause the queue, deploy, resume.
bullfire pause --queue emails
# Paused queue "emails".
# ... rolling deploy of the worker fleet ...
bullfire resume --queue emails
# Resumed queue "emails".
5. Wipe a test environment.
bullfire drain --queue emails --states All
# Drained 1069 job(s) from "emails" (Wait, Paused, Active, Prioritized, Delayed, Completed, Failed).
6. Scripted on-call playbook (JSON output). Everything supports --json so you can
pipe through jq:
# Alert if more than 100 failed jobs are sitting in the queue
failed=$(bullfire status --queue emails --json | jq '.counts.failed')
if [ "$failed" -gt 100 ]; then
echo "TOO MANY FAILED JOBS: $failed" >&2
exit 1
fi
7. Pause everything during database migration.
for q in emails reports webhooks paid-api; do
bullfire pause --queue "$q"
done
# run migration ...
for q in emails reports webhooks paid-api; do
bullfire resume --queue "$q"
done
Exit codes
| Exit code | Meaning |
|---|---|
0 |
Success. |
1 |
Generic error (network, parse, exception). The reason is on stderr. |
2 |
The target (job id / scheduler id) was not found. |
64 |
Usage error β missing required option, bad combination of flags. |
Docs
- Getting started β step-by-step setup for any .NET app
- Production checklist β Redis tuning, TLS, shutdown timing, observability
- Sample app β a runnable MVC demo you can clone and try in 2 minutes
License
MIT. See LICENSE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. 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. |
-
net10.0
- Bullfire.Abstractions (>= 1.3.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Options (>= 9.0.0)
- NCrontab (>= 3.3.3)
- StackExchange.Redis (>= 2.8.22)
-
net8.0
- Bullfire.Abstractions (>= 1.3.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Options (>= 9.0.0)
- NCrontab (>= 3.3.3)
- StackExchange.Redis (>= 2.8.22)
-
net9.0
- Bullfire.Abstractions (>= 1.3.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Options (>= 9.0.0)
- NCrontab (>= 3.3.3)
- StackExchange.Redis (>= 2.8.22)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Bullfire:
| Package | Downloads |
|---|---|
|
Bullfire.Dashboard
Web UI for Bullfire queues. Plug into any ASP.NET Core app with one line: app.MapBullfireDashboard("/bullfire"). Shows queue stats and job lists per state. |
|
|
Bullfire.Hangfire.Compat
Hangfire-style BackgroundJob.Enqueue / RecurringJob.AddOrUpdate facade over Bullfire. Drop-in for teams migrating off Hangfire (free SQL backend) to Bullfire (Redis, MIT, no paywall). |
GitHub repositories
This package is not used by any popular GitHub repositories.