From 9594ea83e6fd2a9a5146ab152eb0866f34d057e2 Mon Sep 17 00:00:00 2001 From: Thomas Buck Date: Wed, 24 Jun 2026 01:19:51 +0100 Subject: [PATCH] fix: cover the public Acquire path in the distributed-lock heartbeat fix (#79) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix only routed the heartbeat onto a dedicated connection for the internal (storage-aware) acquisition paths. The public SQLiteDistributedLock.Acquire(resource, timeout, database, options) overload — used by the exact repro in issue #79 — still ran the heartbeat on the caller's connection and crashed the process reliably. When no storage/pool is available, the heartbeat now opens its own dedicated FullMutex connection to the same database (honouring URI/shared in-memory paths) instead of touching the caller's connection. Wire up the issue's repro as a regression test (Issue79ReproFacts): it crashed 3/3 before and passes 3/3 after, with the full suite stable across repeated runs on macOS. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017xW2zhMvrJChpY58hfJBeZ --- .../SQLiteDistributedLock.cs | 32 ++++++++++++++++-- .../Issue79ReproFacts.cs | 33 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 src/test/Hangfire.Storage.SQLite.Test/Issue79ReproFacts.cs diff --git a/src/main/Hangfire.Storage.SQLite/SQLiteDistributedLock.cs b/src/main/Hangfire.Storage.SQLite/SQLiteDistributedLock.cs index fab1a94..0931c2c 100644 --- a/src/main/Hangfire.Storage.SQLite/SQLiteDistributedLock.cs +++ b/src/main/Hangfire.Storage.SQLite/SQLiteDistributedLock.cs @@ -214,8 +214,13 @@ private void StartHeartBeat() } else { - // Legacy path (no storage available): falls back to the shared connection. - didUpdate = UpdateExpiration(_dbContext.DistributedLockRepository, newExpiry); + // No storage available (e.g. the public Acquire overload): open a dedicated, + // thread-safe connection to the same database so the timer thread never + // touches the caller's connection. + using (var heartbeatConnection = OpenDedicatedConnection()) + { + didUpdate = UpdateExpiration(heartbeatConnection.Table(), newExpiry); + } } Heartbeat?.Invoke(didUpdate); @@ -237,6 +242,29 @@ private void StartHeartBeat() }, null, timerInterval, timerInterval); } + /// + /// Opens a dedicated, thread-safe (FullMutex) connection to the same database the caller's + /// context uses, for running the heartbeat off the caller's connection. Used when no storage + /// (and therefore no connection pool) is available. + /// + private SQLiteConnection OpenDedicatedConnection() + { + var databasePath = _dbContext.Database.DatabasePath; + + var flags = SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.FullMutex; + + // Honour URI-style paths (e.g. the shared in-memory databases used by tests). + if (databasePath.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + flags |= SQLiteOpenFlags.Uri; + } + + return new SQLiteConnection(databasePath, flags, storeDateTimeAsTicks: true) + { + BusyTimeout = TimeSpan.FromSeconds(10) + }; + } + private bool UpdateExpiration(TableQuery tableQuery, DateTime expireAt) { var expireColumn = tableQuery.Table.FindColumnWithPropertyName(nameof(DistributedLock.ExpireAt)).Name; diff --git a/src/test/Hangfire.Storage.SQLite.Test/Issue79ReproFacts.cs b/src/test/Hangfire.Storage.SQLite.Test/Issue79ReproFacts.cs new file mode 100644 index 0000000..1e8e65c --- /dev/null +++ b/src/test/Hangfire.Storage.SQLite.Test/Issue79ReproFacts.cs @@ -0,0 +1,33 @@ +using Hangfire.Storage.SQLite.Entities; +using Hangfire.Storage.SQLite.Test.Utils; +using System; +using Xunit; + +namespace Hangfire.Storage.SQLite.Test +{ + public class Issue79ReproFacts + { + [Fact] + // The exact scenario from upstream issue #79 (shortened): the public Acquire overload's + // heartbeat must not race the consumer connection. + public void Use_Connection_When_Heartbeat_Fires() + { + using var database = ConnectionUtils.CreateConnection(); + + using var slock = SQLiteDistributedLock.Acquire("resource1", TimeSpan.FromSeconds(10), database, + new SQLiteStorageOptions { DistributedLockLifetime = TimeSpan.FromSeconds(1) }); // heartbeat ~200ms + + var start = DateTime.UtcNow; + while (DateTime.UtcNow - start < TimeSpan.FromSeconds(8)) + { + database.Database.Insert(new JobParameter + { + ExpireAt = start.AddSeconds(15), + JobId = 13, + Name = "MyParameter", + Value = "MyValue", + }); + } + } + } +}