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", + }); + } + } + } +}