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
117 changes: 73 additions & 44 deletions src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.Acquire.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ private void AcquireQueueInExistingOrder(
List<JobRecord> acquired
)
{
var eligibility = new Dictionary<string, bool>(StringComparer.Ordinal);
foreach (var candidate in _jobs.Values
.Where(job => string.Equals(GetQueueName(job.JobName), queueName, StringComparison.Ordinal) &&
jobCapacities.ContainsKey(job.JobName) &&
Expand All @@ -130,10 +131,11 @@ job.State is JobState.Pending or JobState.Scheduled &&
{
if (queueCapacity == 0)
break;
if (jobCapacities[candidate.JobName] <= 0 || !CanAcquire(candidate.JobName, now))
if (jobCapacities[candidate.JobName] <= 0 || !CanAcquire(candidate.JobName, now, eligibility))
continue;

acquired.Add(Acquire(candidate, request, now));
eligibility.Remove(candidate.JobName);
jobCapacities[candidate.JobName]--;
queueCapacity--;
}
Expand Down Expand Up @@ -161,62 +163,89 @@ List<JobRecord> acquired
)
{
var policy = request.FairQueues!;
// The gate and acquisition timestamp stay fixed for the entire batch. Build ordered
// backlogs once, then advance past definitions whose capacity or limits are exhausted.
var groups = _jobs.Values
.Where(job => string.Equals(GetQueueName(job.JobName), queueName, StringComparison.Ordinal) &&
jobCapacities.TryGetValue(job.JobName, out var capacity) && capacity > 0 &&
job.State is JobState.Pending or JobState.Scheduled && job.DueAt <= now)
.OrderBy(static job => job.DueAt)
.ThenBy(static job => job.CreatedAt)
.ThenBy(static job => job.JobHandle.Value, StringComparer.Ordinal)
.GroupBy(static job => job.GroupId, StringComparer.Ordinal)
.Select(static group => new Queue<JobRecord>(group))
.ToList();
var eligibility = new Dictionary<string, bool>(StringComparer.Ordinal);
var activeCounts = new Dictionary<string, int>(StringComparer.Ordinal);
var totalActive = 0;
foreach (var job in _jobs.Values)
{
if (!string.Equals(GetQueueName(job.JobName), queueName, StringComparison.Ordinal) ||
job.State != JobState.Active || job.LeaseExpiresAt is null || job.LeaseExpiresAt <= now)
{
continue;
}

totalActive++;
if (job.GroupId is { } groupId)
activeCounts[groupId] = activeCounts.GetValueOrDefault(groupId) + 1;
}

var nextSequence = policy.GroupRoundRobin ? GetNextSequence(queueName) : 0;
var candidates = new List<Queue<JobRecord>>(groups.Count);
while (queueCapacity > 0)
{
var eligible = _jobs.Values
.Where(job => string.Equals(GetQueueName(job.JobName), queueName, StringComparison.Ordinal) &&
jobCapacities.TryGetValue(job.JobName, out var capacity) &&
capacity > 0 &&
job.State is JobState.Pending or JobState.Scheduled &&
job.DueAt <= now && CanAcquire(job.JobName, now))
.ToList();
if (eligible.Count == 0)
candidates.Clear();
foreach (var group in groups)
{
while (group.TryPeek(out var head))
{
if (jobCapacities[head.JobName] > 0 && CanAcquire(head.JobName, now, eligibility))
{
candidates.Add(group);
break;
}

group.Dequeue();
}
}

if (candidates.Count == 0)
break;

var activeCounts = _jobs.Values
.Where(job => string.Equals(GetQueueName(job.JobName), queueName, StringComparison.Ordinal) &&
job.GroupId is not null &&
job.State == JobState.Active &&
job.LeaseExpiresAt > now)
.GroupBy(static job => job.GroupId!, StringComparer.Ordinal)
.ToDictionary(static group => group.Key, static group => group.Count(), StringComparer.Ordinal);
var totalActive = _jobs.Values.Count(job => string.Equals(GetQueueName(job.JobName), queueName, StringComparison.Ordinal) &&
job.State == JobState.Active &&
job.LeaseExpiresAt > now);
var groupedHeads = eligible
.Where(static job => job.GroupId is not null)
.GroupBy(static job => job.GroupId!, StringComparer.Ordinal)
.Select(static group => group
.OrderBy(static job => job.DueAt)
.ThenBy(static job => job.CreatedAt)
.ThenBy(static job => job.JobHandle.Value, StringComparer.Ordinal)
.First());
var ungroupedHead = eligible
.Where(static job => job.GroupId is null)
.OrderBy(static job => job.DueAt)
.ThenBy(static job => job.CreatedAt)
.ThenBy(static job => job.JobHandle.Value, StringComparer.Ordinal)
.Take(1);
var candidates = groupedHeads.Concat(ungroupedHead);

var candidate = candidates
.OrderBy(job => IsNoisy(job.GroupId, activeCounts, totalActive, policy))
.ThenBy(job => GetNoisyInflight(job.GroupId, activeCounts, totalActive, policy))
.ThenBy(job => policy.GroupRoundRobin ? GetLastServed(queueName, job.GroupId) : 0)
.ThenBy(static job => job.DueAt)
.ThenBy(static job => job.CreatedAt)
.ThenBy(static job => job.JobHandle.Value, StringComparer.Ordinal)
var selected = candidates
.OrderBy(group => IsNoisy(group.Peek().GroupId, activeCounts, totalActive, policy))
.ThenBy(group => GetNoisyInflight(group.Peek().GroupId, activeCounts, totalActive, policy))
.ThenBy(group => policy.GroupRoundRobin ? GetLastServed(queueName, group.Peek().GroupId) : 0)
.ThenBy(static group => group.Peek().DueAt)
.ThenBy(static group => group.Peek().CreatedAt)
.ThenBy(static group => group.Peek().JobHandle.Value, StringComparer.Ordinal)
.First();

var job = Acquire(candidate, request, now);
var job = Acquire(selected.Dequeue(), request, now);
acquired.Add(job);
eligibility.Remove(job.JobName);
jobCapacities[job.JobName]--;
queueCapacity--;
if (job.LeaseExpiresAt > now)
{
totalActive++;
if (job.GroupId is { } activeGroup)
activeCounts[activeGroup] = activeCounts.GetValueOrDefault(activeGroup) + 1;
}

if (policy.GroupRoundRobin && job.GroupId is { } groupId)
_fairQueueLastServed[(queueName, groupId)] = GetNextSequence(queueName);
_fairQueueLastServed[(queueName, groupId)] = nextSequence++;
}
}

private bool CanAcquire(string jobName, DateTimeOffset now, Dictionary<string, bool> eligibility)
{
if (!eligibility.TryGetValue(jobName, out var ready))
eligibility.Add(jobName, ready = CanAcquire(jobName, now));
return ready;
}

private static bool IsNoisy(
string? groupId,
Dictionary<string, int> activeCounts,
Expand Down
15 changes: 9 additions & 6 deletions src/Immediate.Jobs.Shared/Storage/InMemoryJobStorage.Query.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ public async ValueTask<JobMonitoringSnapshot> GetMonitoringSnapshotAsync(Cancell

lock (_gate)
{
var counts = Enum.GetValues<JobState>().ToDictionary(state => state, state => _jobs.Values.LongCount(x => x.State == state));
var counts = Enum.GetValues<JobState>().ToDictionary(static state => state, static _ => 0L);
foreach (var job in _jobs.Values)
counts[job.State]++;
var now = timeProvider.GetUtcNow();
var servers = GetLiveServersSnapshot();
return new JobMonitoringSnapshot
Expand All @@ -74,13 +76,13 @@ public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(JobQuery query,

lock (_gate)
{
var jobs = _jobs.Values.Select(ReadRecord);
IEnumerable<JobRecord> jobs = _jobs.Values;
if (query.JobHandle is { } id)
jobs = jobs.Where(x => x.JobHandle == query.JobHandle);
jobs = _jobs.TryGetValue(id, out var job) ? [job] : [];
if (query.State is { } state)
jobs = jobs.Where(x => x.State == state);
if (!string.IsNullOrWhiteSpace(query.QueueName))
jobs = jobs.Where(x => string.Equals(x.QueueName, query.QueueName, StringComparison.Ordinal));
jobs = jobs.Where(x => string.Equals(GetQueueName(x.JobName), query.QueueName, StringComparison.Ordinal));
if (!string.IsNullOrWhiteSpace(query.JobName))
jobs = jobs.Where(x => string.Equals(x.JobName, query.JobName, StringComparison.OrdinalIgnoreCase));
if (query.CreatedBefore is { } createdBefore)
Expand All @@ -94,7 +96,8 @@ public async ValueTask<IReadOnlyList<JobRecord>> QueryJobsAsync(JobQuery query,
.. jobs.OrderByDescending(x => x.CreatedAt)
.ThenBy(x => x.JobHandle.Value, StringComparer.Ordinal)
.Skip(Math.Max(0, query.Skip))
.Take(Math.Clamp(query.Take, 1, 1000)),
.Take(Math.Clamp(query.Take, 1, 1000))
.Select(ReadRecord),
];
}
}
Expand All @@ -109,7 +112,6 @@ public async ValueTask<IReadOnlyList<JobRecord>> QueryNonCompletedJobsAsync(stri
lock (_gate)
{
return _jobs.Values
.Select(ReadRecord)
.Where(j => string.Equals(j.JobName, jobName, StringComparison.OrdinalIgnoreCase))
.Where(
j => j.State is
Expand All @@ -119,6 +121,7 @@ or JobState.Scheduled
or JobState.Pending
or JobState.Active
)
.Select(ReadRecord)
.ToList();
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
using System.Globalization;
using Immediate.Jobs.Shared.Apis;
using Immediate.Jobs.Shared.Storage;
using Microsoft.Extensions.Time.Testing;

namespace Immediate.Jobs.StorageTests;

public sealed class InMemoryStoragePerformanceRegressionTests
{
[Theory]
[InlineData(1, 0, 0, 10)]
[InlineData(0, 1, 0, 10)]
[InlineData(0, 0, 1, 10)]
[InlineData(0, 0, 0, 1)]
public async Task FairAcquisitionAdvancesPastExhaustedDefinitionsWithinEachGroup(
int maxConcurrency, int slidingMax, int fixedMax, int capacity
)
{
var token = TestContext.Current.CancellationToken;
var clock = new FakeTimeProvider(DateTimeOffset.UnixEpoch);
await using var storage = new InMemoryJobStorage(clock);
await storage.MergeJobDefinitionsListAsync(new()
{
Definitions =
[
new()
{
Name = "limited",
MaxConcurrency = maxConcurrency,
SlidingWindowMax = slidingMax,
SlidingWindowPeriod = slidingMax > 0 ? TimeSpan.FromMinutes(1) : null,
FixedWindowMax = fixedMax,
FixedWindowPeriod = fixedMax > 0 ? TimeSpan.FromMinutes(1) : null,
},
new() { Name = "free" },
],
}, token);
(string Id, string Name, string? Group)[] jobs =
[
("limited-a", "limited", "a"),
("limited-a-next", "limited", "a"),
("free-a", "free", "a"),
("limited-b", "limited", "b"),
("free-b", "free", "b"),
("limited-ungrouped", "limited", null),
("free-ungrouped", "free", null),
];
for (var index = 0; index < jobs.Length; index++)
{
var (id, name, group) = jobs[index];
await storage.EnqueueAsync(Job(id, name, group, clock.GetUtcNow().AddTicks(index)), token);
}

var acquired = await storage.AcquireDueJobsAsync(Request(capacity, roundRobin: true), token);

Assert.Equal(["limited-a", "free-b", "free-ungrouped", "free-a"], acquired.Select(static job => job.JobHandle.Value));
}

[Fact]
public async Task FairAcquisitionReclassifiesNoisyGroupsAfterEachClaim()
{
var token = TestContext.Current.CancellationToken;
var clock = new FakeTimeProvider(DateTimeOffset.UnixEpoch);
await using var storage = new InMemoryJobStorage(clock);
await storage.MergeJobDefinitionsListAsync(new() { Definitions = [new() { Name = "free" }] }, token);
for (var index = 0; index < 6; index++)
{
var group = index < 3 ? "a" : "b";
var id = string.Create(CultureInfo.InvariantCulture, $"{group}-{index % 3}");
await storage.EnqueueAsync(Job(id, "free", group, clock.GetUtcNow().AddTicks(index)), token);
}

var acquired = await storage.AcquireDueJobsAsync(Request(10, roundRobin: false), token);

Assert.Equal(["a-0", "b-0", "a-1", "b-1", "a-2", "b-2"], acquired.Select(static job => job.JobHandle.Value));
}

[Fact]
public async Task ExactJobQueryComposesFiltersAndUsesCurrentQueue()
{
var token = TestContext.Current.CancellationToken;
var clock = new FakeTimeProvider(DateTimeOffset.UnixEpoch);
await using var storage = new InMemoryJobStorage(clock);
await storage.MergeJobDefinitionsListAsync(new() { Definitions = [new() { Name = "free", QueueName = "old" }] }, token);
await storage.EnqueueAsync(Job("target", "free", group: null, clock.GetUtcNow()), token);
await storage.MergeJobDefinitionsListAsync(new() { Definitions = [new() { Name = "free", QueueName = "current" }] }, token);
var query = new JobQuery
{
JobHandle = JobHandle.FromString("target"),
State = JobState.Pending,
JobName = "FREE",
Search = "REE",
QueueName = "current",
CreatedBefore = clock.GetUtcNow().AddTicks(1),
};

Assert.Equal("current", Assert.Single(await storage.QueryJobsAsync(query, token)).QueueName);
Assert.Equal("current", Assert.Single(await storage.QueryNonCompletedJobsAsync("FREE", token)).QueueName);
JobQuery[] excluded =
[
query with { JobHandle = JobHandle.FromString("missing") },
query with { State = JobState.Scheduled },
query with { JobName = "other" },
query with { Search = "other" },
query with { QueueName = "old" },
query with { CreatedBefore = clock.GetUtcNow() },
query with { Skip = 1 },
];
foreach (var filter in excluded)
Assert.Empty(await storage.QueryJobsAsync(filter, token));
}

private static JobSubmission Job(string id, string name, string? group, DateTimeOffset createdAt) => new()
{
JobHandle = JobHandle.FromString(id),
JobName = name,
Payload = "{}",
State = JobState.Pending,
GroupId = group,
CreatedAt = createdAt,
DueAt = DateTimeOffset.UnixEpoch,
};

private static JobAcquisitionRequest Request(int limitedCapacity, bool roundRobin) => new()
{
WorkerId = "worker",
Lease = TimeSpan.FromMinutes(1),
BatchSize = 10,
FairQueues = new() { GroupRoundRobin = roundRobin, ConcurrencyShareThreshold = 0.5, MinInflightForNoisy = roundRobin ? 30 : 1 },
Queues =
[
new()
{
QueueName = "default",
Capacity = 10,
JobCapacities = new Dictionary<string, int>(StringComparer.Ordinal) { ["LIMITED"] = limitedCapacity, ["free"] = 10 },
},
],
};
}
Loading