• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jeanlrnt / MiniCron.Core / 20606941126

30 Dec 2025 10:08PM UTC coverage: 89.22%. First build
20606941126

Pull #26

github

web-flow
Merge 0fc5bc2cb into f77233cf2
Pull Request #26: Develop

61 of 67 branches covered (91.04%)

Branch coverage included in aggregate %.

177 of 210 new or added lines in 8 files covered. (84.29%)

328 of 369 relevant lines covered (88.89%)

27.37 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.35
/src/MiniCron.Core/Services/InMemoryJobLockProvider.cs
1
using System.Collections.Concurrent;
2

3
namespace MiniCron.Core.Services;
4

5
/// <summary>
6
/// Simple in-memory job lock provider. Suitable for single-node scenarios or tests.
7
/// Not suitable for multi-process distributed locking.
8
/// </summary>
9
/// <remarks>
10
/// Once <see cref="Dispose"/> is called, this provider should not be used for any operations.
11
/// Attempting to call <see cref="TryAcquireAsync"/> or <see cref="ReleaseAsync"/> after disposal
12
/// will throw <see cref="ObjectDisposedException"/>.
13
/// </remarks>
14
public class InMemoryJobLockProvider : IJobLockProvider, IDisposable
15
{
16
    private readonly ConcurrentDictionary<Guid, DateTimeOffset> _locks = new();
25✔
17
    private volatile bool _disposed;
18
    private readonly SemaphoreSlim _lockReleasedSignal = new(0, 1);
25✔
19

20
    /// <summary>
21
    /// Attempts to acquire a lock for the specified job with a time-to-live (TTL).
22
    /// Uses a combination of event-based signaling and progressive backoff for efficiency.
23
    /// </summary>
24
    /// <param name="jobId">The unique identifier of the job to lock.</param>
25
    /// <param name="ttl">The time-to-live for the lock.</param>
26
    /// <param name="cancellationToken">Token to cancel the acquisition attempt.</param>
27
    /// <returns>
28
    /// True if the lock was successfully acquired; false if the operation was cancelled
29
    /// or the lock could not be acquired within the cancellation period.
30
    /// When cancelled, the method returns false rather than throwing an exception.
31
    /// </returns>
32
    public async Task<bool> TryAcquireAsync(Guid jobId, TimeSpan ttl, CancellationToken cancellationToken)
33
    {
34
        if (_disposed)
29✔
35
        {
36
            throw new ObjectDisposedException(nameof(InMemoryJobLockProvider));
2✔
37
        }
38
        var backoffDelay = 10; // Start with 10ms
27✔
39
        const int maxBackoffDelay = 500; // Cap at 500ms
40
        const double backoffMultiplier = 1.5; // Exponential growth factor
41

42
        while (!cancellationToken.IsCancellationRequested)
34✔
43
        {
44
            var now = DateTimeOffset.UtcNow;
34✔
45
            var expiry = now.Add(ttl);
34✔
46

47
            // Try add new lock
48
            if (_locks.TryAdd(jobId, expiry))
34✔
49
            {
50
                return true;
24✔
51
            }
52

53
            // If existing lock expired, try to replace it
54
            if (_locks.TryGetValue(jobId, out var existingExpiry))
10✔
55
            {
56
                var refreshedExpiry = DateTimeOffset.UtcNow.Add(ttl);
10✔
57
                if (existingExpiry <= DateTimeOffset.UtcNow
10✔
58
                    && _locks.TryUpdate(jobId, refreshedExpiry, existingExpiry))
10✔
59
                {
60
                    return true;
2✔
61
                }
62
            }
63

64
            // Wait for either a lock release signal or backoff delay
65
            // This combines event-based signaling with progressive backoff
66
            try
67
            {
68
                await _lockReleasedSignal.WaitAsync(backoffDelay, cancellationToken);
8✔
69
            }
7✔
70
            catch (OperationCanceledException)
1✔
71
            {
72
                // Cancellation requested, return false as documented
73
                return false;
1✔
74
            }
75

76
            // Increase backoff delay exponentially, capped at maxBackoffDelay
77
            backoffDelay = Math.Min((int)(backoffDelay * backoffMultiplier), maxBackoffDelay);
7✔
78
        }
79

NEW
80
        return false;
×
81
    }
27✔
82

83
    /// <summary>
84
    /// Releases the lock for the specified job ID.
85
    /// </summary>
86
    /// <param name="jobId">The unique identifier of the job.</param>
87
    /// <returns>A completed task.</returns>
88
    /// <exception cref="ObjectDisposedException">Thrown if this provider has been disposed.</exception>
89
    public Task ReleaseAsync(Guid jobId)
90
    {
91
        if (_disposed)
22✔
92
        {
93
            throw new ObjectDisposedException(nameof(InMemoryJobLockProvider));
1✔
94
        }
95

96
        _locks.TryRemove(jobId, out _);
21✔
97
        
98
        // Signal waiting threads that a lock has been released
99
        // Use try-catch to handle the case where count is already at max
100
        try
101
        {
102
            _lockReleasedSignal.Release();
21✔
103
        }
18✔
104
        catch (SemaphoreFullException)
3✔
105
        {
106
            // Already at max count, no action needed
107
        }
3✔
108
        
109
        return Task.CompletedTask;
21✔
110
    }
111

112
    /// <summary>
113
    /// Disposes the provider and clears all locks. This method is idempotent.
114
    /// After disposal, all operations on this provider will throw <see cref="ObjectDisposedException"/>.
115
    /// </summary>
116
    public void Dispose()
117
    {
118
        if (_disposed)
2✔
119
        {
NEW
120
            return;
×
121
        }
122
        _disposed = true;
2✔
123
        _locks.Clear();
2✔
124
        _lockReleasedSignal.Dispose();
2✔
125
    }
2✔
126
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc