Skip to content

Commit d68df74

Browse files
authored
Fixed concurrency issue in MetaDb chunk expansion (#9508)
1 parent 79d30b8 commit d68df74

2 files changed

Lines changed: 170 additions & 7 deletions

File tree

src/HotChocolate/Core/src/Types/Text/Json/ResultDocument.MetaDb.cs

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ internal struct MetaDb : IDisposable
1515
private static readonly ArrayPool<byte[]> s_arrayPool = ArrayPool<byte[]>.Shared;
1616

1717
private byte[][] _chunks;
18+
private byte[][]? _previousChunks;
1819
private Cursor _next;
20+
private volatile uint _nextValue;
1921
private bool _disposed;
2022

2123
internal static MetaDb CreateForEstimatedRows(int estimatedRows)
@@ -38,11 +40,20 @@ internal static MetaDb CreateForEstimatedRows(int estimatedRows)
3840
return new MetaDb
3941
{
4042
_chunks = chunks,
41-
_next = Cursor.Zero
43+
_next = Cursor.Zero,
44+
_nextValue = Cursor.Zero.Value
4245
};
4346
}
4447

45-
public Cursor NextCursor => _next;
48+
public readonly Cursor NextCursor
49+
{
50+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
51+
get
52+
{
53+
var value = _nextValue;
54+
return Unsafe.As<uint, Cursor>(ref value);
55+
}
56+
}
4657

4758
[MethodImpl(MethodImplOptions.AggressiveInlining)]
4859
internal Cursor Append(
@@ -87,9 +98,16 @@ internal Cursor Append(
8798
newChunks[i] = [];
8899
}
89100

90-
// clear and return old chunks buffer
91-
chunks.Clear();
92-
s_arrayPool.Return(_chunks);
101+
// Concurrent readers may still reference the current chunks array.
102+
// Return the previously retained one and keep the current one
103+
// alive until the next expansion or Dispose.
104+
if (_previousChunks is not null)
105+
{
106+
_previousChunks.AsSpan().Clear();
107+
s_arrayPool.Return(_previousChunks);
108+
}
109+
110+
_previousChunks = _chunks;
93111

94112
// assign new chunks buffer
95113
_chunks = newChunks;
@@ -119,7 +137,9 @@ internal Cursor Append(
119137
Unsafe.WriteUnaligned(ref Unsafe.Add(ref dest, byteOffset), row);
120138

121139
// Advance write head by one row
122-
_next = next + 1;
140+
var newNext = next + 1;
141+
_next = newNext;
142+
_nextValue = newNext.Value;
123143
return next;
124144
}
125145

@@ -331,7 +351,9 @@ private void AssertValidCursor(Cursor cursor)
331351
Debug.Assert(cursor.Chunk < _chunks.Length, "Chunk index out of bounds");
332352
Debug.Assert(_chunks[cursor.Chunk].Length > 0, "Accessing unallocated chunk");
333353

334-
var maxExclusive = _next.Chunk * Cursor.RowsPerChunk + _next.Row;
354+
var value = _nextValue;
355+
var maxCursor = Unsafe.As<uint, Cursor>(ref value);
356+
var maxExclusive = maxCursor.Chunk * Cursor.RowsPerChunk + maxCursor.Row;
335357
var absoluteIndex = (cursor.Chunk * Cursor.RowsPerChunk) + cursor.Row;
336358

337359
Debug.Assert(absoluteIndex >= 0 && absoluteIndex < maxExclusive,
@@ -348,6 +370,13 @@ public void Dispose()
348370
var chunks = _chunks.AsSpan(0, chunksLength);
349371
Log.MetaDbDisposed(2, chunksLength, cursor.Row);
350372

373+
if (_previousChunks is not null)
374+
{
375+
_previousChunks.AsSpan().Clear();
376+
s_arrayPool.Return(_previousChunks);
377+
_previousChunks = null;
378+
}
379+
351380
foreach (var chunk in chunks)
352381
{
353382
if (chunk.Length == 0)
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
using GreenDonut;
2+
using HotChocolate.Types;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using static HotChocolate.Tests.TestHelper;
5+
6+
namespace HotChocolate.Execution.Integration.DataLoader;
7+
8+
public class Issue9500Tests
9+
{
10+
[Fact]
11+
public async Task Composite_DataLoader_Result_Overflows_Selection_Buffer_When_Paging_Many_Nodes()
12+
{
13+
const int nodeCount = 100_000;
14+
15+
var executor = await CreateExecutorAsync(
16+
c => c
17+
.AddQueryType<Issue9500Query>()
18+
.AddDataLoader<INoteDataLoader, NoteDataLoader>()
19+
.ModifyRequestOptions(o => o.IncludeExceptionDetails = true));
20+
21+
var result = await executor.ExecuteAsync(
22+
OperationRequestBuilder.New()
23+
.SetDocument(
24+
$$"""
25+
{
26+
items(first: {{nodeCount}}) {
27+
edges {
28+
cursor
29+
node {
30+
id
31+
note {
32+
comment
33+
dueDate
34+
progress
35+
assignee
36+
status
37+
priority
38+
category
39+
createdBy
40+
updatedBy
41+
title
42+
summary
43+
kind
44+
owner
45+
reviewer
46+
milestone
47+
}
48+
}
49+
}
50+
}
51+
}
52+
""")
53+
.Build());
54+
55+
Assert.Empty(result.ExpectOperationResult().Errors);
56+
}
57+
58+
public class Issue9500Query
59+
{
60+
[UsePaging(DefaultPageSize = 100000, MaxPageSize = 100000)]
61+
public IEnumerable<Item> GetItems()
62+
=> Enumerable.Range(0, 100_000).Select(i => new Item(i));
63+
}
64+
65+
public class Item(int id)
66+
{
67+
public int Id { get; } = id;
68+
69+
public Task<Note?> GetNoteAsync(
70+
INoteDataLoader dataLoader,
71+
CancellationToken cancellationToken)
72+
=> dataLoader.LoadAsync(Id, cancellationToken);
73+
}
74+
75+
public interface INoteDataLoader
76+
: IDataLoader<int, Note>;
77+
78+
public class NoteDataLoader(
79+
IBatchScheduler batchScheduler,
80+
DataLoaderOptions options)
81+
: BatchDataLoader<int, Note>(batchScheduler, options), INoteDataLoader
82+
{
83+
protected override Task<IReadOnlyDictionary<int, Note>> LoadBatchAsync(
84+
IReadOnlyList<int> keys,
85+
CancellationToken cancellationToken)
86+
{
87+
return LoadAsync(keys, cancellationToken);
88+
}
89+
90+
private static async Task<IReadOnlyDictionary<int, Note>> LoadAsync(
91+
IReadOnlyList<int> keys,
92+
CancellationToken cancellationToken)
93+
{
94+
await Task.Delay(1, cancellationToken);
95+
cancellationToken.ThrowIfCancellationRequested();
96+
97+
return keys.ToDictionary(
98+
key => key,
99+
key => new Note(
100+
$"Comment {key}",
101+
$"2026-04-{(key % 28) + 1:00}",
102+
key % 100,
103+
$"Assignee {key}",
104+
key % 2 == 0 ? "Open" : "Closed",
105+
$"P{key % 5}",
106+
$"Category {key % 7}",
107+
$"Creator {key % 11}",
108+
$"Updater {key % 13}",
109+
$"Title {key}",
110+
$"Summary {key}",
111+
$"Kind {key % 3}",
112+
$"Owner {key % 17}",
113+
$"Reviewer {key % 19}",
114+
$"Milestone {key % 23}"));
115+
}
116+
}
117+
118+
public record Note(
119+
string Comment,
120+
string DueDate,
121+
int Progress,
122+
string Assignee,
123+
string Status,
124+
string Priority,
125+
string Category,
126+
string CreatedBy,
127+
string UpdatedBy,
128+
string Title,
129+
string Summary,
130+
string Kind,
131+
string Owner,
132+
string Reviewer,
133+
string Milestone);
134+
}

0 commit comments

Comments
 (0)