Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions src/main/Hangfire.Storage.SQLite/SQLiteDistributedLock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DistributedLock>(), newExpiry);
}
}

Heartbeat?.Invoke(didUpdate);
Expand All @@ -237,6 +242,29 @@ private void StartHeartBeat()
}, null, timerInterval, timerInterval);
}

/// <summary>
/// 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.
/// </summary>
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<DistributedLock> tableQuery, DateTime expireAt)
{
var expireColumn = tableQuery.Table.FindColumnWithPropertyName(nameof(DistributedLock.ExpireAt)).Name;
Expand Down
33 changes: 33 additions & 0 deletions src/test/Hangfire.Storage.SQLite.Test/Issue79ReproFacts.cs
Original file line number Diff line number Diff line change
@@ -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",
});
}
}
}
}
Loading