Skip to content

Commit 21d35f7

Browse files
authored
Merge pull request #43 from raisedapp/develop
Develop To Master
2 parents 7d0960b + ea18e88 commit 21d35f7

File tree

5 files changed

+81
-38
lines changed

5 files changed

+81
-38
lines changed

src/main/Hangfire.Storage.SQLite/Entities/DistributedLock.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ public class DistributedLock
2323
[Unique]
2424
public string Resource { get; set; }
2525

26+
/// <summary>
27+
/// The owner key for this resource.
28+
/// Prevents race conditions and changes to locks that are owned by other entities.
29+
/// </summary>
30+
public string ResourceKey { get; set; }
31+
2632
/// <summary>
2733
/// The timestamp for when the lock expires.
2834
/// This is used if the lock is not maintained or

src/main/Hangfire.Storage.SQLite/HangfireSQLiteConnection.cs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Linq;
4-
using System.Text;
54
using System.Threading;
65
using Hangfire.Common;
76
using Hangfire.Server;
@@ -330,10 +329,15 @@ public override void Heartbeat(string serverId)
330329

331330
var server = DbContext.HangfireServerRepository.FirstOrDefault(_ => _.Id == serverId);
332331
if (server == null)
333-
return;
332+
throw new BackgroundServerGoneException();
334333

335334
server.LastHeartbeat = DateTime.UtcNow;
336-
DbContext.Database.Update(server);
335+
var affected = DbContext.Database.Update(server);
336+
337+
if (affected == 0)
338+
{
339+
throw new BackgroundServerGoneException();
340+
}
337341
}
338342

339343
public override void RemoveServer(string serverId)

src/main/Hangfire.Storage.SQLite/SQLiteDistributedLock.cs

Lines changed: 37 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ private static readonly ThreadLocal<Dictionary<string, int>> AcquiredLocks
1919
= new ThreadLocal<Dictionary<string, int>>(() => new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase));
2020

2121
private readonly string _resource;
22+
private readonly string _resourceKey;
2223

2324
private readonly HangfireDbContext _dbContext;
2425

@@ -44,6 +45,7 @@ public SQLiteDistributedLock(string resource, TimeSpan timeout, HangfireDbContex
4445
_resource = resource ?? throw new ArgumentNullException(nameof(resource));
4546
_dbContext = database ?? throw new ArgumentNullException(nameof(database));
4647
_storageOptions = storageOptions ?? throw new ArgumentNullException(nameof(storageOptions));
48+
_resourceKey = Guid.NewGuid().ToString();
4749

4850
if (string.IsNullOrEmpty(resource))
4951
{
@@ -92,7 +94,7 @@ public void Dispose()
9294
}
9395

9496
// Timer callback may be invoked after the Dispose method call,
95-
// so we are using lock to avoid unsynchronized calls.
97+
// but since we use the resource key, we will not disturb other owners.
9698
AcquiredLocks.Value.Remove(_resource);
9799

98100
if (_heartbeatTimer != null)
@@ -110,32 +112,33 @@ private void Acquire(TimeSpan timeout)
110112
{
111113
try
112114
{
113-
// If result is null, then it means we acquired the lock
114115
var isLockAcquired = false;
115116
var now = DateTime.UtcNow;
116117
var lockTimeoutTime = now.Add(timeout);
117118

118-
while (!isLockAcquired && (lockTimeoutTime >= now))
119+
while (lockTimeoutTime >= now)
119120
{
120-
DistributedLock result;
121+
Cleanup();
121122

122123
lock (EventWaitHandleName)
123124
{
124-
result = _dbContext.DistributedLockRepository.FirstOrDefault(_ => _.Resource == _resource);
125-
var distributedLock = result ?? new DistributedLock();
125+
var result = _dbContext.DistributedLockRepository.FirstOrDefault(_ => _.Resource == _resource);
126126

127-
if (string.IsNullOrWhiteSpace(distributedLock.Id))
128-
distributedLock.Id = Guid.NewGuid().ToString();
129-
130-
distributedLock.Resource = _resource;
131-
distributedLock.ExpireAt = DateTime.UtcNow.Add(_storageOptions.DistributedLockLifetime);
132-
133-
var rowsAffected = _dbContext.Database.Update(distributedLock);
134-
if (rowsAffected == 0)
127+
if (result == null)
135128
{
136129
try
137130
{
131+
var distributedLock = new DistributedLock();
132+
distributedLock.Id = Guid.NewGuid().ToString();
133+
distributedLock.Resource = _resource;
134+
distributedLock.ResourceKey = _resourceKey;
135+
distributedLock.ExpireAt = DateTime.UtcNow.Add(_storageOptions.DistributedLockLifetime);
136+
138137
_dbContext.Database.Insert(distributedLock);
138+
139+
// we were able to acquire the lock - break the loop
140+
isLockAcquired = true;
141+
break;
139142
}
140143
catch (SQLiteException e) when (e.Result == SQLite3.Result.Constraint)
141144
{
@@ -145,24 +148,17 @@ private void Acquire(TimeSpan timeout)
145148
}
146149
}
147150

148-
// If result is null, then it means we acquired the lock
149-
if (result == null)
150-
{
151-
isLockAcquired = true;
152-
}
153-
else
154-
{
155-
var waitTime = (int)timeout.TotalMilliseconds / 10;
156-
lock (EventWaitHandleName)
157-
Monitor.Wait(EventWaitHandleName, waitTime);
151+
// we couldn't acquire the lock - wait a bit and try again
152+
var waitTime = (int)timeout.TotalMilliseconds / 10;
153+
lock (EventWaitHandleName)
154+
Monitor.Wait(EventWaitHandleName, waitTime);
158155

159-
now = DateTime.UtcNow;
160-
}
156+
now = DateTime.UtcNow;
161157
}
162158

163159
if (!isLockAcquired)
164160
{
165-
throw new DistributedLockTimeoutException($"Could not place a lock on the resource \'{_resource}\': The lock request timed out.");
161+
throw new DistributedLockTimeoutException(_resource);
166162
}
167163
}
168164
catch (DistributedLockTimeoutException ex)
@@ -183,8 +179,8 @@ private void Release()
183179
{
184180
try
185181
{
186-
// Remove resource lock
187-
_dbContext.DistributedLockRepository.Delete(_ => _.Resource == _resource);
182+
// Remove resource lock (if it's still ours)
183+
_dbContext.DistributedLockRepository.Delete(_ => _.Resource == _resource && _.ResourceKey == _resourceKey);
188184
lock (EventWaitHandleName)
189185
Monitor.Pulse(EventWaitHandleName);
190186
}
@@ -198,7 +194,7 @@ private void Cleanup()
198194
{
199195
try
200196
{
201-
// Delete expired locks
197+
// Delete expired locks (of any owner)
202198
_dbContext.DistributedLockRepository.
203199
Delete(x => x.Resource == _resource && x.ExpireAt < DateTime.UtcNow);
204200
}
@@ -218,13 +214,20 @@ private void StartHeartBeat()
218214
_heartbeatTimer = new Timer(state =>
219215
{
220216
// Timer callback may be invoked after the Dispose method call,
221-
// so we are using lock to avoid unsynchronized calls.
217+
// but since we use the resource key, we will not disturb other owners.
222218
try
223219
{
224-
var distributedLock = _dbContext.DistributedLockRepository.FirstOrDefault(x => x.Resource == _resource);
225-
distributedLock.ExpireAt = DateTime.UtcNow.Add(_storageOptions.DistributedLockLifetime);
220+
var distributedLock = _dbContext.DistributedLockRepository.FirstOrDefault(x => x.Resource == _resource && x.ResourceKey == _resourceKey);
221+
if (distributedLock != null)
222+
{
223+
distributedLock.ExpireAt = DateTime.UtcNow.Add(_storageOptions.DistributedLockLifetime);
226224

227-
_dbContext.Database.Update(distributedLock);
225+
_dbContext.Database.Update(distributedLock);
226+
}
227+
else
228+
{
229+
Logger.ErrorFormat("Unable to update heartbeat on the resource '{0}'. The resource is not locked or is locked by another owner.", _resource);
230+
}
228231
}
229232
catch (Exception ex)
230233
{

src/test/Hangfire.Storage.SQLite.Test/SQLiteConnectionFacts.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
using System.Collections.Generic;
99
using System.Diagnostics;
1010
using System.Linq;
11-
using System.Text;
1211
using System.Threading;
1312
using Xunit;
1413

@@ -627,6 +626,13 @@ public void RemoveServer_RemovesAServerRecord()
627626
});
628627
}
629628

629+
[Fact, CleanDatabase]
630+
public void Heartbeat_ThrowsBackgroundServerGoneException_WhenGivenServerDoesNotExist()
631+
{
632+
UseConnection((database, connection) => Assert.Throws<BackgroundServerGoneException>(
633+
() => connection.Heartbeat(Guid.NewGuid().ToString())));
634+
}
635+
630636
[Fact, CleanDatabase]
631637
public void Heartbeat_ThrowsAnException_WhenServerIdIsNull()
632638
{

src/test/Hangfire.Storage.SQLite.Test/SQLiteDistributedLockFacts.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,30 @@ public void Ctor_SetLockExpireAtWorks_WhenResourceIsNotLocked()
201201
});
202202
}
203203

204+
// see https://github.com/raisedapp/Hangfire.Storage.SQLite/issues/38
205+
[Fact, CleanDatabase]
206+
public void Ctor_SetLockExpireAtWorks_WhenResourceIsLockedAndExpiring()
207+
{
208+
UseConnection(database =>
209+
{
210+
// add a lock (taken by another process who is now killed) which will expire in 3 seconds from now
211+
database.Database.Insert(new DistributedLock
212+
{
213+
Id = Guid.NewGuid().ToString(),
214+
Resource = "resource1",
215+
ExpireAt = DateTime.UtcNow.Add(TimeSpan.FromSeconds(3))
216+
});
217+
218+
// try to get the lock in the next 10 seconds
219+
// ideally, after ~3 seconds, the constructor should succeed
220+
using (new SQLiteDistributedLock("resource1", TimeSpan.FromSeconds(10), database, new SQLiteStorageOptions() { DistributedLockLifetime = TimeSpan.FromSeconds(3) }))
221+
{
222+
DistributedLock lockEntry = database.DistributedLockRepository.FirstOrDefault(_ => _.Resource == "resource1");
223+
Assert.NotNull(lockEntry);
224+
}
225+
});
226+
}
227+
204228
private static void UseConnection(Action<HangfireDbContext> action)
205229
{
206230
var connection = ConnectionUtils.CreateConnection();

0 commit comments

Comments
 (0)