ITB.HangfirePostgreSql.ValkeyQueue 1.1.0

dotnet add package ITB.HangfirePostgreSql.ValkeyQueue --version 1.1.0
                    
NuGet\Install-Package ITB.HangfirePostgreSql.ValkeyQueue -Version 1.1.0
                    
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="ITB.HangfirePostgreSql.ValkeyQueue" Version="1.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ITB.HangfirePostgreSql.ValkeyQueue" Version="1.1.0" />
                    
Directory.Packages.props
<PackageReference Include="ITB.HangfirePostgreSql.ValkeyQueue" />
                    
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 ITB.HangfirePostgreSql.ValkeyQueue --version 1.1.0
                    
#r "nuget: ITB.HangfirePostgreSql.ValkeyQueue, 1.1.0"
                    
#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 ITB.HangfirePostgreSql.ValkeyQueue@1.1.0
                    
#: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=ITB.HangfirePostgreSql.ValkeyQueue&version=1.1.0
                    
Install as a Cake Addin
#tool nuget:?package=ITB.HangfirePostgreSql.ValkeyQueue&version=1.1.0
                    
Install as a Cake Tool

ITB.HangfirePostgreSql.ValkeyQueue

A Valkey/Redis-backed queue provider for Hangfire.PostgreSql. PostgreSQL stays the durable source of truth (job data, state history, scheduled/recurring jobs, dashboard); only the hot queue path (job IDs) moves to Valkey — LPUSH to enqueue, blocking BLMOVE to dequeue — so idle worker polling load on PostgreSQL drops toward zero.

Measured effect

PostgreSQL-side load counted from pg_stat_statements — the statements the server actually executed. Two arms in separate schemas: stock Hangfire.PostgreSql, and the same setup with this provider.

Metric PostgreSQL only With Valkey Change
Idle Hangfire queries (120 s) 164 4 −97.6 %
Idle queries / second 1.37 0.03 −97.6 %
Idle transaction-control calls 486 6 −98.8 %
Queries per job (2 000 jobs) 32.00 28.00 −12.5 %
PostgreSQL exec time during drain 1 576–1 598 ms 1 318–1 362 ms −15 %
Throughput 290–292 jobs/s 296–310 jobs/s +2 to +6 %

The idle row is the result that matters. An idle Hangfire server drops from ~1.37 queries/s to ~0.03; the remaining 4 queries are the maintenance pass, not polling. Per-job cost falls only 12.5 % because PostgreSQL still stores the job row, its parameters and every state transition — only the queue hop moves. Throughput is unchanged: the +2–6 % is run-to-run noise, and should be read as "no regression" rather than a speedup.

Measured on an Apple M2 Pro with PostgreSQL 17 and Valkey 8 in local containers, 20 workers, QueuePollInterval at the 15 s default. Absolute figures are a property of that machine; the relative query counts were identical across runs. Methodology, the reproducible harness and what the test does not cover: docs/LOAD-TEST.md.

Where the win is. On Hangfire.PostgreSql 1.21.1 the baseline already wakes idle workers via PostgreSQL LISTEN/NOTIFY, so the pickup-latency win is marginal and the reduction under sustained load is modest. The win is idle database load — measure against your own workload before adopting it, and keep pure-PostgreSQL as the fallback.

Installation

dotnet add package ITB.HangfirePostgreSql.ValkeyQueue

Targets .NET 10. Requires Hangfire.PostgreSql 1.21.x and a Valkey (or Redis) 7+ server.

Usage

using Hangfire;
using Hangfire.PostgreSql;
using Hangfire.PostgreSql.Factories;
using Hangfire.PostgreSql.ValkeyQueue;
using Hangfire.Server;
using StackExchange.Redis;

var storageOptions = new PostgreSqlStorageOptions { UseSlidingInvisibilityTimeout = true };
var storage = new PostgreSqlStorage(
    new NpgsqlConnectionFactory(pgConnectionString, storageOptions), storageOptions);

var redisOptions = ConfigurationOptions.Parse(valkeyConnectionString);
var mux = ConnectionMultiplexer.Connect(redisOptions);

var valkey = new ValkeyQueueOptions
{
    InvisibilityTimeout = TimeSpan.FromMinutes(30),   // how long a worker may go quiet
    HeartbeatInterval = TimeSpan.FromMinutes(1),      // keep at most 1/3 of the timeout
    BlockingConnectionConfig = redisOptions,          // required on managed Valkey (TLS + AUTH)
};

storage.UseValkeyQueues(mux, valkey);                 // point the "default" queue at Valkey

services.AddHangfire(c => c.UseStorage(storage));
services.AddHangfireServer();

// Safety nets — recover from Valkey data loss / worker death. Register as a singleton
// IBackgroundProcess so AddHangfireServer picks it up.
services.AddSingleton<IBackgroundProcess>(
    new ValkeyQueueMaintenance(mux, valkey, pgConnectionString));

A complete, runnable worked example — including the ValkeyQueueMaintenance registration that is easy to forget — is in docs/EXAMPLE.md.

Configuration

ValkeyQueueOptions:

Option Default Notes
KeyPrefix hangfire: Prefix for every Valkey key this provider owns.
BlockTimeout 2 s How long a single BLMOVE blocks before the worker loops.
InvisibilityTimeout 30 min A job whose worker has not sent a heartbeat for this long is treated as abandoned and requeued. A liveness window, not a runtime budget. Must be at least 3x HeartbeatInterval.
HeartbeatInterval 1 min How often a worker refreshes the timestamp of the job it is holding.
MaintenanceInterval 15 s How often the reconciler and orphan sweep run.
ReconcileGrace 30 s The reconciler ignores jobs enqueued more recently than this, so it never races an in-flight LPUSH.
Schema hangfire The schema Hangfire.PostgreSql created its tables in.
Queues ["default"] Queues served by Valkey. Anything not listed stays on PostgreSQL.
BlockingConnectionConfig null ConfigurationOptions for the dedicated blocking connections. Set this whenever AUTH/TLS is in play — see below.

Correctness

The enqueue is no longer transactional with job creation (it pushes to Valkey, not PostgreSQL). Two things keep that safe.

Ordering. The LPUSH is deferred to the enqueueing transaction's TransactionCompleted, so an id becomes visible to workers only after PostgreSQL has committed statename = 'Enqueued', and a rolled-back transaction publishes nothing. Pushing inline would race: a worker parked in BLMOVE wakes in microseconds, reads a job that is not Enqueued yet, and Hangfire's Worker.Execute discards it from the queue — recoverable, but only by the reconciler, one ReconcileGrace + MaintenanceInterval later.

Recovery. Two safety nets close what is left:

  • Reconciler re-enqueues jobs PostgreSQL reports as Enqueued but missing from Valkey (Valkey data loss / failover, or a push that failed after the commit).
  • Orphan sweep requeues jobs stuck in a processing list past InvisibilityTimeout (worker died mid-job).

Both live in ValkeyQueueMaintenance. Without it registered, those failures are silent job loss — this is the one registration step you cannot skip.

Upgrading to 1.1.0

InvisibilityTimeout changed meaning: it is now "no heartbeat for this long", not "longer than your longest job". The 30 minute default is unchanged, so an upgrade needs no config change.

Lowering it is a two-step rollout. A host still running 1.0.x sends no heartbeats, so a short timeout would have the sweep requeue its live jobs underneath it — running them twice. Deploy 1.1.0 everywhere first, then lower the timeout.

Duplicates are execution-safe: only one worker wins the Enqueued → Processing state transition in PostgreSQL; the loser is discarded.

Notes

  • One dedicated blocking connection per worker thread (BLMOVE monopolises its connection) — fine for tens of workers, revisit for hundreds.
  • On managed Valkey (ElastiCache with TLS + AUTH) set BlockingConnectionConfig; otherwise the blocking connections are derived from the shared multiplexer's connection string, which masks the password and every blocking connection fails to authenticate.
  • InvisibilityTimeout is about worker liveness, not job duration: a job running for hours is safe while its process keeps beating. Size it against how long a dead worker may go unnoticed.
  • Keys are wrapped in a {hash tag} so all three keys for a queue land in the same cluster hash slot; without it, cluster mode rejects the BLMOVE with CROSSSLOT.

License

MIT — see LICENSE.

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

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.1.0 149 9/28/2026
1.0.2 186 9/22/2026
1.0.1 100 9/21/2026
1.0.0 86 9/21/2026
0.1.2-alpha 85 9/21/2026