Bullfire.Cli 1.3.1

dotnet tool install --global Bullfire.Cli --version 1.3.1
                    
This package contains a .NET tool you can call from the shell/command line.
dotnet new tool-manifest
                    
if you are setting up this repo
dotnet tool install --local Bullfire.Cli --version 1.3.1
                    
This package contains a .NET tool you can call from the shell/command line.
#tool dotnet:?package=Bullfire.Cli&version=1.3.1
                    
nuke :add-package Bullfire.Cli --version 1.3.1
                    

<div align="center">

Bullfire

Fast, Redis-backed background job queue and scheduler for .NET.

License: MIT NuGet NuGet downloads

</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.Compat package — drop-in BackgroundJob.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 modeRateLimitMode.Sliding enforces 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.
   └────────────────────┘
  1. 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 an added event to the Redis Stream.
  2. 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 own DbContext, HttpClient, etc.).
  3. 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.
  4. When the handler succeeds, Bullfire writes the return value to the job hash, moves the id to the completed bucket, and emits a completed event. If it throws and attempts remain, it re-queues with exponential backoff; if attempts are exhausted, it moves to failed.
  5. 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.CompatBackgroundJob.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

License

MIT. See LICENSE.

Product 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 was computed.  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 was computed.  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.

This package has no dependencies.

Version Downloads Last Updated
1.3.1 100 5/1/2026
1.3.0 95 5/1/2026
1.2.0 102 4/30/2026