Skip to content

Commit eccd8dc

Browse files
alexyakuninclaude
andcommitted
feat(Core): add TaskCoalescer
Coalesces concurrent runs of the same task factory: at most one run in flight, at most one queued behind it; requests accepted while the queued run awaits its turn share its task, so a burst is served by at most two runs. Extracted from NpgsqlDbLogWatcher NOTIFY coalescing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDuxW7dLs7KPpbTzDtyDUV
1 parent 95e51c1 commit eccd8dc

2 files changed

Lines changed: 168 additions & 0 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
namespace ActualLab.Async;
2+
3+
/// <summary>
4+
/// Coalesces concurrent runs of the same task factory: at most one run is in flight and at most
5+
/// one more is queued behind it. Requests accepted while the queued run awaits its turn share
6+
/// its task, so any burst of requests is served by at most two runs.
7+
/// </summary>
8+
public sealed class TaskCoalescer(Func<Task> taskFactory)
9+
{
10+
private readonly Lock _lock = new();
11+
private Task? _activeTask;
12+
private Task? _queuedTask;
13+
14+
public ILogger? Log { get; init; }
15+
16+
// The task covering every accepted request - e.g. to await on graceful shutdown
17+
public Task? LastTask {
18+
get {
19+
lock (_lock)
20+
return _queuedTask ?? _activeTask;
21+
}
22+
}
23+
24+
public Task Run()
25+
{
26+
lock (_lock) {
27+
// The queued task must be checked first: the active one may be already completed
28+
// while the queued one hasn't promoted itself yet, and starting a new run
29+
// in this state would bypass the queued one
30+
if (_queuedTask is { } queuedTask)
31+
return queuedTask;
32+
if (_activeTask is { IsCompleted: false } activeTask)
33+
return _queuedTask = QueuedRun(activeTask);
34+
35+
return _activeTask = taskFactory.Invoke();
36+
}
37+
}
38+
39+
// Private methods
40+
41+
private async Task QueuedRun(Task activeTask)
42+
{
43+
await activeTask.SilentAwait(false);
44+
// The yield forces an asynchronous continuation, which can't enter the promotion block
45+
// below before Run (still holding _lock) assigns _queuedTask = this task
46+
await Task.Yield();
47+
lock (_lock) {
48+
if (!ReferenceEquals(_activeTask, activeTask)) {
49+
// Must be unreachable; skipping the run is the recovery here
50+
Log?.LogCritical(
51+
$"{nameof(TaskCoalescer)}.{nameof(QueuedRun)}: the task it chained itself behind isn't the active one");
52+
_queuedTask = null;
53+
return;
54+
}
55+
56+
_activeTask = _queuedTask;
57+
_queuedTask = null;
58+
}
59+
await taskFactory.Invoke().ConfigureAwait(false);
60+
}
61+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
namespace ActualLab.Tests.Async;
2+
3+
public class TaskCoalescerTest(ITestOutputHelper @out) : TestBase(@out)
4+
{
5+
[Fact]
6+
public async Task SequentialRunsTest()
7+
{
8+
var runCount = 0;
9+
var coalescer = new TaskCoalescer(async () => {
10+
Interlocked.Increment(ref runCount);
11+
await Task.Yield();
12+
});
13+
14+
await coalescer.Run();
15+
await coalescer.Run();
16+
await coalescer.Run();
17+
runCount.Should().Be(3); // No concurrency = no coalescing
18+
}
19+
20+
[Fact]
21+
public async Task CoalescingTest()
22+
{
23+
var runCount = 0;
24+
var gate = TaskCompletionSourceExt.New<Unit>();
25+
var coalescer = new TaskCoalescer(async () => {
26+
Interlocked.Increment(ref runCount);
27+
await gate.Task.ConfigureAwait(false);
28+
});
29+
30+
var t1 = coalescer.Run();
31+
var t2 = coalescer.Run();
32+
var t3 = coalescer.Run();
33+
runCount.Should().Be(1);
34+
t2.Should().BeSameAs(t3); // Requests behind an in-flight run share the queued one
35+
t2.Should().NotBeSameAs(t1);
36+
coalescer.LastTask.Should().BeSameAs(t2);
37+
38+
gate.SetResult(default);
39+
await t3;
40+
await t1;
41+
runCount.Should().Be(2); // The whole burst is served by two runs
42+
}
43+
44+
[Fact]
45+
public async Task ConcurrentBurstTest()
46+
{
47+
var runCount = 0;
48+
var gate = TaskCompletionSourceExt.New<Unit>();
49+
var coalescer = new TaskCoalescer(async () => {
50+
Interlocked.Increment(ref runCount);
51+
await gate.Task.ConfigureAwait(false);
52+
});
53+
54+
var t1 = coalescer.Run();
55+
var tasks = Enumerable.Range(0, 100)
56+
.AsParallel()
57+
.Select(_ => coalescer.Run())
58+
.ToArray();
59+
runCount.Should().Be(1);
60+
tasks.Should().AllSatisfy(t => t.Should().BeSameAs(tasks[0]));
61+
62+
gate.SetResult(default);
63+
await Task.WhenAll(tasks);
64+
await t1;
65+
runCount.Should().Be(2);
66+
}
67+
68+
[Fact]
69+
public async Task ErrorPropagationTest()
70+
{
71+
var runCount = 0;
72+
var mustFail = true;
73+
var coalescer = new TaskCoalescer(async () => {
74+
Interlocked.Increment(ref runCount);
75+
await Task.Yield();
76+
if (mustFail)
77+
throw new InvalidOperationException("Simulated");
78+
});
79+
80+
await Assert.ThrowsAsync<InvalidOperationException>(() => coalescer.Run());
81+
mustFail = false;
82+
await coalescer.Run(); // A faulted active task must not block new runs
83+
runCount.Should().Be(2);
84+
}
85+
86+
[Fact]
87+
public async Task QueuedRunErrorPropagationTest()
88+
{
89+
var runCount = 0;
90+
var gate = TaskCompletionSourceExt.New<Unit>();
91+
var coalescer = new TaskCoalescer(async () => {
92+
var runIndex = Interlocked.Increment(ref runCount);
93+
await gate.Task.ConfigureAwait(false);
94+
if (runIndex == 2)
95+
throw new InvalidOperationException("Simulated");
96+
});
97+
98+
var t1 = coalescer.Run();
99+
var t2 = coalescer.Run();
100+
gate.SetResult(default);
101+
await t1;
102+
await Assert.ThrowsAsync<InvalidOperationException>(() => t2);
103+
104+
await coalescer.Run(); // And a faulted queued task must not block new runs either
105+
runCount.Should().Be(3);
106+
}
107+
}

0 commit comments

Comments
 (0)