Skip to content

Commit e47f884

Browse files
Copiloteiriktsarpalisstephentoub
authored
Fix keyset pagination with monotonic UUIDv7-like task IDs (#1215)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Co-authored-by: Stephen Toub <stoub@microsoft.com>
1 parent 82bd980 commit e47f884

File tree

5 files changed

+247
-25
lines changed

5 files changed

+247
-25
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using System.Threading;
2+
3+
namespace System;
4+
5+
/// <summary>
6+
/// Provides helper methods for monotonic ID generation.
7+
/// </summary>
8+
internal static class IdHelpers
9+
{
10+
private static long s_counter;
11+
12+
/// <summary>
13+
/// Creates a strictly monotonically increasing identifier string using 64-bit timestamp ticks
14+
/// and a 64-bit counter, formatted as a 32-character hexadecimal string (GUID-like).
15+
/// </summary>
16+
/// <param name="timestamp">The timestamp to embed in the identifier.</param>
17+
/// <returns>A new strictly monotonically increasing identifier string.</returns>
18+
/// <remarks>
19+
/// <para>
20+
/// This method creates a 128-bit identifier composed of two 64-bit values:
21+
/// - High 64 bits: <see cref="DateTimeOffset.Ticks"/> from the timestamp
22+
/// - Low 64 bits: A globally monotonically increasing counter
23+
/// </para>
24+
/// <para>
25+
/// The resulting string is strictly monotonically increasing when compared lexicographically,
26+
/// which is required for keyset pagination to work correctly. Unlike <c>Guid.CreateVersion7</c>,
27+
/// which uses random bits for intra-millisecond uniqueness, this implementation guarantees
28+
/// strict ordering for all identifiers regardless of when they were created.
29+
/// </para>
30+
/// </remarks>
31+
public static string CreateMonotonicId(DateTimeOffset timestamp)
32+
{
33+
long ticks = timestamp.UtcTicks;
34+
long counter = Interlocked.Increment(ref s_counter);
35+
36+
// Format as 32-character hex string (16 bytes = 128 bits)
37+
// High 64 bits: timestamp ticks, Low 64 bits: counter
38+
return $"{ticks:x16}{counter:x16}";
39+
}
40+
}

src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
<PackageId>ModelContextProtocol.Core</PackageId>
88
<Description>Core .NET SDK for the Model Context Protocol (MCP)</Description>
99
<PackageReadmeFile>README.md</PackageReadmeFile>
10-
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
1110
<!-- Suppress the experimental tasks warning -->
1211
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
1312
</PropertyGroup>
@@ -19,6 +18,7 @@
1918
<PropertyGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
2019
<!-- CS0436: Allow ObsoleteAttribute to be redefined internally -->
2120
<NoWarn>$(NoWarn);CS0436</NoWarn>
21+
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
2222
</PropertyGroup>
2323

2424
<ItemGroup>

src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs

Lines changed: 55 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
using System.Diagnostics.CodeAnalysis;
44
using System.Text.Json;
55

6+
#if MCP_TEST_TIME_PROVIDER
7+
namespace ModelContextProtocol.Tests.Internal;
8+
#else
69
namespace ModelContextProtocol;
10+
#endif
711

812
/// <summary>
913
/// Provides an in-memory implementation of <see cref="IMcpTaskStore"/> for development and testing.
@@ -35,6 +39,9 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
3539
private readonly int _pageSize;
3640
private readonly int? _maxTasks;
3741
private readonly int? _maxTasksPerSession;
42+
#if MCP_TEST_TIME_PROVIDER
43+
private readonly TimeProvider _timeProvider;
44+
#endif
3845

3946
/// <summary>
4047
/// Initializes a new instance of the <see cref="InMemoryMcpTaskStore"/> class.
@@ -120,6 +127,9 @@ public InMemoryMcpTaskStore(
120127
_pageSize = pageSize;
121128
_maxTasks = maxTasks;
122129
_maxTasksPerSession = maxTasksPerSession;
130+
#if MCP_TEST_TIME_PROVIDER
131+
_timeProvider = TimeProvider.System;
132+
#endif
123133

124134
cleanupInterval ??= TimeSpan.FromMinutes(1);
125135
if (cleanupInterval.Value != Timeout.InfiniteTimeSpan)
@@ -128,6 +138,26 @@ public InMemoryMcpTaskStore(
128138
}
129139
}
130140

141+
#if MCP_TEST_TIME_PROVIDER
142+
/// <summary>
143+
/// Initializes a new instance of the <see cref="InMemoryMcpTaskStore"/> class with a custom time provider.
144+
/// This constructor is only available for testing purposes.
145+
/// </summary>
146+
internal InMemoryMcpTaskStore(
147+
TimeSpan? defaultTtl,
148+
TimeSpan? maxTtl,
149+
TimeSpan? pollInterval,
150+
TimeSpan? cleanupInterval,
151+
int pageSize,
152+
int? maxTasks,
153+
int? maxTasksPerSession,
154+
TimeProvider timeProvider)
155+
: this(defaultTtl, maxTtl, pollInterval, cleanupInterval, pageSize, maxTasks, maxTasksPerSession)
156+
{
157+
_timeProvider = timeProvider ?? TimeProvider.System;
158+
}
159+
#endif
160+
131161
/// <inheritdoc/>
132162
public Task<McpTask> CreateTaskAsync(
133163
McpTaskMetadata taskParams,
@@ -155,7 +185,7 @@ public Task<McpTask> CreateTaskAsync(
155185
}
156186

157187
var taskId = GenerateTaskId();
158-
var now = DateTimeOffset.UtcNow;
188+
var now = GetUtcNow();
159189

160190
// Determine TTL: use requested, fall back to default, respect max limit
161191
var ttl = taskParams.TimeToLive ?? _defaultTtl;
@@ -242,7 +272,7 @@ public Task<McpTask> StoreTaskResultAsync(
242272
var updatedEntry = new TaskEntry(entry)
243273
{
244274
Status = status,
245-
LastUpdatedAt = DateTimeOffset.UtcNow,
275+
LastUpdatedAt = GetUtcNow(),
246276
StoredResult = result
247277
};
248278

@@ -303,7 +333,7 @@ public Task<McpTask> UpdateTaskStatusAsync(
303333
{
304334
Status = status,
305335
StatusMessage = statusMessage,
306-
LastUpdatedAt = DateTimeOffset.UtcNow,
336+
LastUpdatedAt = GetUtcNow(),
307337
};
308338

309339
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
@@ -321,32 +351,22 @@ public Task<ListTasksResult> ListTasksAsync(
321351
string? sessionId = null,
322352
CancellationToken cancellationToken = default)
323353
{
324-
// Parse cursor: format is "CreatedAt|TaskId" for keyset pagination
325-
(DateTimeOffset, string)? parsedCursor = null;
326-
if (cursor != null)
327-
{
328-
var parts = cursor.Split('|');
329-
if (parts.Length == 2 &&
330-
DateTimeOffset.TryParse(parts[0], out var parsedDate))
331-
{
332-
parsedCursor = (parsedDate, parts[1]);
333-
}
334-
}
335-
336354
// Stream enumeration - filter by session, exclude expired, apply keyset pagination
337355
var query = _tasks.Values
338356
.Where(e => sessionId == null || e.SessionId == sessionId)
339357
.Where(e => !IsExpired(e));
340358

341-
// Apply keyset filter if cursor provided: (CreatedAt, TaskId) > cursor
342-
if (parsedCursor is { } parsedCursorValue)
359+
// Apply keyset filter if cursor provided: TaskId > cursor
360+
// UUID v7 task IDs are monotonically increasing and inherently time-ordered
361+
if (cursor != null)
343362
{
344-
query = query.Where(e => (e.CreatedAt, e.TaskId).CompareTo(parsedCursorValue) > 0);
363+
query = query.Where(e => string.CompareOrdinal(e.TaskId, cursor) > 0);
345364
}
346365

347-
// Order by (CreatedAt, TaskId) for stable, deterministic pagination
366+
// Order by TaskId for stable, deterministic pagination
367+
// UUID v7 task IDs sort chronologically due to embedded timestamp
348368
var page = query
349-
.OrderBy(e => (e.CreatedAt, e.TaskId))
369+
.OrderBy(e => e.TaskId, StringComparer.Ordinal)
350370
.Take(_pageSize + 1) // Take one extra to check if there's a next page
351371
.Select(e => e.ToMcpTask())
352372
.ToList();
@@ -356,7 +376,7 @@ public Task<ListTasksResult> ListTasksAsync(
356376
if (page.Count > _pageSize)
357377
{
358378
var lastItemInPage = page[_pageSize - 1]; // Last item we'll actually return
359-
nextCursor = $"{lastItemInPage.CreatedAt:O}|{lastItemInPage.TaskId}";
379+
nextCursor = lastItemInPage.TaskId;
360380
page.RemoveAt(_pageSize); // Remove the extra item
361381
}
362382
else
@@ -397,7 +417,7 @@ public Task<McpTask> CancelTaskAsync(string taskId, string? sessionId = null, Ca
397417
var updatedEntry = new TaskEntry(entry)
398418
{
399419
Status = McpTaskStatus.Cancelled,
400-
LastUpdatedAt = DateTimeOffset.UtcNow,
420+
LastUpdatedAt = GetUtcNow(),
401421
};
402422

403423
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
@@ -417,20 +437,31 @@ public void Dispose()
417437
_cleanupTimer?.Dispose();
418438
}
419439

420-
private static string GenerateTaskId() => Guid.NewGuid().ToString("N");
440+
private string GenerateTaskId() =>
441+
IdHelpers.CreateMonotonicId(GetUtcNow());
421442

422443
private static bool IsTerminalStatus(McpTaskStatus status) =>
423444
status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled;
424445

446+
#if MCP_TEST_TIME_PROVIDER
447+
private DateTimeOffset GetUtcNow() => _timeProvider.GetUtcNow();
448+
#else
449+
private static DateTimeOffset GetUtcNow() => DateTimeOffset.UtcNow;
450+
#endif
451+
452+
#if MCP_TEST_TIME_PROVIDER
453+
private bool IsExpired(TaskEntry entry)
454+
#else
425455
private static bool IsExpired(TaskEntry entry)
456+
#endif
426457
{
427458
if (entry.TimeToLive == null)
428459
{
429460
return false; // Unlimited lifetime
430461
}
431462

432463
var expirationTime = entry.CreatedAt + entry.TimeToLive.Value;
433-
return DateTimeOffset.UtcNow >= expirationTime;
464+
return GetUtcNow() >= expirationTime;
434465
}
435466

436467
private void CleanupExpiredTasks(object? state)

tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,15 @@
55
<TargetFrameworks>net10.0;net9.0;net8.0;net472</TargetFrameworks>
66
<ImplicitUsings>enable</ImplicitUsings>
77
<Nullable>enable</Nullable>
8+
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
89

910
<IsPackable>false</IsPackable>
1011
<IsTestProject>true</IsTestProject>
1112
<RootNamespace>ModelContextProtocol.Tests</RootNamespace>
1213
<!-- https://github.com/dotnet/sdk/issues/51060 -->
1314
<NoWarn>$(NoWarn);NU1903;NU1902</NoWarn>
15+
<!-- Define for conditional TimeProvider support in InMemoryMcpTaskStore -->
16+
<DefineConstants>$(DefineConstants);MCP_TEST_TIME_PROVIDER</DefineConstants>
1417
</PropertyGroup>
1518

1619
<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0'">
@@ -27,13 +30,21 @@
2730

2831
<ItemGroup>
2932
<Compile Include="..\Common\**\*.cs" />
33+
<!-- Link InMemoryMcpTaskStore.cs for testing with TimeProvider support -->
34+
<Compile Include="..\..\src\ModelContextProtocol.Core\Server\InMemoryMcpTaskStore.cs" Link="Server\InMemoryMcpTaskStore.cs" />
35+
<!-- Include dependencies for the linked InMemoryMcpTaskStore.cs -->
36+
<Compile Include="..\..\src\Common\Experimentals.cs" Link="Experimentals.cs" />
3037
</ItemGroup>
3138

3239
<ItemGroup Condition="'$(TargetFramework)' == 'net472'">
3340
<Compile Include="..\..\src\Common\Throw.cs" Link="Throw.cs" />
3441
<Compile Include="..\..\src\Common\Polyfills\**\*.cs" />
3542
</ItemGroup>
3643

44+
<ItemGroup Condition="'$(TargetFramework)' != 'net472'">
45+
<Compile Include="..\..\src\Common\Polyfills\System\IdHelpers.cs" Link="Polyfills\IdHelpers.cs" />
46+
</ItemGroup>
47+
3748
<ItemGroup>
3849
<PackageReference Include="coverlet.collector">
3950
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -43,6 +54,7 @@
4354
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
4455
<PackageReference Include="Microsoft.Extensions.Logging" />
4556
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
57+
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
4658
<PackageReference Include="Microsoft.NET.Test.Sdk" />
4759
<PackageReference Include="Moq" />
4860
<PackageReference Include="OpenTelemetry" />

0 commit comments

Comments
 (0)