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>
This commit is contained in:
parent
82bd98081d
commit
e47f88419c
5 changed files with 247 additions and 25 deletions
40
src/Common/Polyfills/System/IdHelpers.cs
Normal file
40
src/Common/Polyfills/System/IdHelpers.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace System;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides helper methods for monotonic ID generation.
|
||||||
|
/// </summary>
|
||||||
|
internal static class IdHelpers
|
||||||
|
{
|
||||||
|
private static long s_counter;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a strictly monotonically increasing identifier string using 64-bit timestamp ticks
|
||||||
|
/// and a 64-bit counter, formatted as a 32-character hexadecimal string (GUID-like).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="timestamp">The timestamp to embed in the identifier.</param>
|
||||||
|
/// <returns>A new strictly monotonically increasing identifier string.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This method creates a 128-bit identifier composed of two 64-bit values:
|
||||||
|
/// - High 64 bits: <see cref="DateTimeOffset.Ticks"/> from the timestamp
|
||||||
|
/// - Low 64 bits: A globally monotonically increasing counter
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The resulting string is strictly monotonically increasing when compared lexicographically,
|
||||||
|
/// which is required for keyset pagination to work correctly. Unlike <c>Guid.CreateVersion7</c>,
|
||||||
|
/// which uses random bits for intra-millisecond uniqueness, this implementation guarantees
|
||||||
|
/// strict ordering for all identifiers regardless of when they were created.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static string CreateMonotonicId(DateTimeOffset timestamp)
|
||||||
|
{
|
||||||
|
long ticks = timestamp.UtcTicks;
|
||||||
|
long counter = Interlocked.Increment(ref s_counter);
|
||||||
|
|
||||||
|
// Format as 32-character hex string (16 bytes = 128 bits)
|
||||||
|
// High 64 bits: timestamp ticks, Low 64 bits: counter
|
||||||
|
return $"{ticks:x16}{counter:x16}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
<PackageId>ModelContextProtocol.Core</PackageId>
|
<PackageId>ModelContextProtocol.Core</PackageId>
|
||||||
<Description>Core .NET SDK for the Model Context Protocol (MCP)</Description>
|
<Description>Core .NET SDK for the Model Context Protocol (MCP)</Description>
|
||||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
|
|
||||||
<!-- Suppress the experimental tasks warning -->
|
<!-- Suppress the experimental tasks warning -->
|
||||||
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
|
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
@ -19,6 +18,7 @@
|
||||||
<PropertyGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
|
<PropertyGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
|
||||||
<!-- CS0436: Allow ObsoleteAttribute to be redefined internally -->
|
<!-- CS0436: Allow ObsoleteAttribute to be redefined internally -->
|
||||||
<NoWarn>$(NoWarn);CS0436</NoWarn>
|
<NoWarn>$(NoWarn);CS0436</NoWarn>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@ using System.Collections.Concurrent;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
|
#if MCP_TEST_TIME_PROVIDER
|
||||||
|
namespace ModelContextProtocol.Tests.Internal;
|
||||||
|
#else
|
||||||
namespace ModelContextProtocol;
|
namespace ModelContextProtocol;
|
||||||
|
#endif
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides an in-memory implementation of <see cref="IMcpTaskStore"/> for development and testing.
|
/// Provides an in-memory implementation of <see cref="IMcpTaskStore"/> for development and testing.
|
||||||
|
|
@ -35,6 +39,9 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
|
||||||
private readonly int _pageSize;
|
private readonly int _pageSize;
|
||||||
private readonly int? _maxTasks;
|
private readonly int? _maxTasks;
|
||||||
private readonly int? _maxTasksPerSession;
|
private readonly int? _maxTasksPerSession;
|
||||||
|
#if MCP_TEST_TIME_PROVIDER
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
#endif
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="InMemoryMcpTaskStore"/> class.
|
/// Initializes a new instance of the <see cref="InMemoryMcpTaskStore"/> class.
|
||||||
|
|
@ -120,6 +127,9 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
|
||||||
_pageSize = pageSize;
|
_pageSize = pageSize;
|
||||||
_maxTasks = maxTasks;
|
_maxTasks = maxTasks;
|
||||||
_maxTasksPerSession = maxTasksPerSession;
|
_maxTasksPerSession = maxTasksPerSession;
|
||||||
|
#if MCP_TEST_TIME_PROVIDER
|
||||||
|
_timeProvider = TimeProvider.System;
|
||||||
|
#endif
|
||||||
|
|
||||||
cleanupInterval ??= TimeSpan.FromMinutes(1);
|
cleanupInterval ??= TimeSpan.FromMinutes(1);
|
||||||
if (cleanupInterval.Value != Timeout.InfiniteTimeSpan)
|
if (cleanupInterval.Value != Timeout.InfiniteTimeSpan)
|
||||||
|
|
@ -128,6 +138,26 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if MCP_TEST_TIME_PROVIDER
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="InMemoryMcpTaskStore"/> class with a custom time provider.
|
||||||
|
/// This constructor is only available for testing purposes.
|
||||||
|
/// </summary>
|
||||||
|
internal InMemoryMcpTaskStore(
|
||||||
|
TimeSpan? defaultTtl,
|
||||||
|
TimeSpan? maxTtl,
|
||||||
|
TimeSpan? pollInterval,
|
||||||
|
TimeSpan? cleanupInterval,
|
||||||
|
int pageSize,
|
||||||
|
int? maxTasks,
|
||||||
|
int? maxTasksPerSession,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
: this(defaultTtl, maxTtl, pollInterval, cleanupInterval, pageSize, maxTasks, maxTasksPerSession)
|
||||||
|
{
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public Task<McpTask> CreateTaskAsync(
|
public Task<McpTask> CreateTaskAsync(
|
||||||
McpTaskMetadata taskParams,
|
McpTaskMetadata taskParams,
|
||||||
|
|
@ -155,7 +185,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
var taskId = GenerateTaskId();
|
var taskId = GenerateTaskId();
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = GetUtcNow();
|
||||||
|
|
||||||
// Determine TTL: use requested, fall back to default, respect max limit
|
// Determine TTL: use requested, fall back to default, respect max limit
|
||||||
var ttl = taskParams.TimeToLive ?? _defaultTtl;
|
var ttl = taskParams.TimeToLive ?? _defaultTtl;
|
||||||
|
|
@ -242,7 +272,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
|
||||||
var updatedEntry = new TaskEntry(entry)
|
var updatedEntry = new TaskEntry(entry)
|
||||||
{
|
{
|
||||||
Status = status,
|
Status = status,
|
||||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
LastUpdatedAt = GetUtcNow(),
|
||||||
StoredResult = result
|
StoredResult = result
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -303,7 +333,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
|
||||||
{
|
{
|
||||||
Status = status,
|
Status = status,
|
||||||
StatusMessage = statusMessage,
|
StatusMessage = statusMessage,
|
||||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
LastUpdatedAt = GetUtcNow(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
|
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
|
||||||
|
|
@ -321,32 +351,22 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
|
||||||
string? sessionId = null,
|
string? sessionId = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Parse cursor: format is "CreatedAt|TaskId" for keyset pagination
|
|
||||||
(DateTimeOffset, string)? parsedCursor = null;
|
|
||||||
if (cursor != null)
|
|
||||||
{
|
|
||||||
var parts = cursor.Split('|');
|
|
||||||
if (parts.Length == 2 &&
|
|
||||||
DateTimeOffset.TryParse(parts[0], out var parsedDate))
|
|
||||||
{
|
|
||||||
parsedCursor = (parsedDate, parts[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream enumeration - filter by session, exclude expired, apply keyset pagination
|
// Stream enumeration - filter by session, exclude expired, apply keyset pagination
|
||||||
var query = _tasks.Values
|
var query = _tasks.Values
|
||||||
.Where(e => sessionId == null || e.SessionId == sessionId)
|
.Where(e => sessionId == null || e.SessionId == sessionId)
|
||||||
.Where(e => !IsExpired(e));
|
.Where(e => !IsExpired(e));
|
||||||
|
|
||||||
// Apply keyset filter if cursor provided: (CreatedAt, TaskId) > cursor
|
// Apply keyset filter if cursor provided: TaskId > cursor
|
||||||
if (parsedCursor is { } parsedCursorValue)
|
// UUID v7 task IDs are monotonically increasing and inherently time-ordered
|
||||||
|
if (cursor != null)
|
||||||
{
|
{
|
||||||
query = query.Where(e => (e.CreatedAt, e.TaskId).CompareTo(parsedCursorValue) > 0);
|
query = query.Where(e => string.CompareOrdinal(e.TaskId, cursor) > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order by (CreatedAt, TaskId) for stable, deterministic pagination
|
// Order by TaskId for stable, deterministic pagination
|
||||||
|
// UUID v7 task IDs sort chronologically due to embedded timestamp
|
||||||
var page = query
|
var page = query
|
||||||
.OrderBy(e => (e.CreatedAt, e.TaskId))
|
.OrderBy(e => e.TaskId, StringComparer.Ordinal)
|
||||||
.Take(_pageSize + 1) // Take one extra to check if there's a next page
|
.Take(_pageSize + 1) // Take one extra to check if there's a next page
|
||||||
.Select(e => e.ToMcpTask())
|
.Select(e => e.ToMcpTask())
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
@ -356,7 +376,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
|
||||||
if (page.Count > _pageSize)
|
if (page.Count > _pageSize)
|
||||||
{
|
{
|
||||||
var lastItemInPage = page[_pageSize - 1]; // Last item we'll actually return
|
var lastItemInPage = page[_pageSize - 1]; // Last item we'll actually return
|
||||||
nextCursor = $"{lastItemInPage.CreatedAt:O}|{lastItemInPage.TaskId}";
|
nextCursor = lastItemInPage.TaskId;
|
||||||
page.RemoveAt(_pageSize); // Remove the extra item
|
page.RemoveAt(_pageSize); // Remove the extra item
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -397,7 +417,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
|
||||||
var updatedEntry = new TaskEntry(entry)
|
var updatedEntry = new TaskEntry(entry)
|
||||||
{
|
{
|
||||||
Status = McpTaskStatus.Cancelled,
|
Status = McpTaskStatus.Cancelled,
|
||||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
LastUpdatedAt = GetUtcNow(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
|
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
|
||||||
|
|
@ -417,12 +437,23 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
|
||||||
_cleanupTimer?.Dispose();
|
_cleanupTimer?.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GenerateTaskId() => Guid.NewGuid().ToString("N");
|
private string GenerateTaskId() =>
|
||||||
|
IdHelpers.CreateMonotonicId(GetUtcNow());
|
||||||
|
|
||||||
private static bool IsTerminalStatus(McpTaskStatus status) =>
|
private static bool IsTerminalStatus(McpTaskStatus status) =>
|
||||||
status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled;
|
status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled;
|
||||||
|
|
||||||
|
#if MCP_TEST_TIME_PROVIDER
|
||||||
|
private DateTimeOffset GetUtcNow() => _timeProvider.GetUtcNow();
|
||||||
|
#else
|
||||||
|
private static DateTimeOffset GetUtcNow() => DateTimeOffset.UtcNow;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if MCP_TEST_TIME_PROVIDER
|
||||||
|
private bool IsExpired(TaskEntry entry)
|
||||||
|
#else
|
||||||
private static bool IsExpired(TaskEntry entry)
|
private static bool IsExpired(TaskEntry entry)
|
||||||
|
#endif
|
||||||
{
|
{
|
||||||
if (entry.TimeToLive == null)
|
if (entry.TimeToLive == null)
|
||||||
{
|
{
|
||||||
|
|
@ -430,7 +461,7 @@ public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
var expirationTime = entry.CreatedAt + entry.TimeToLive.Value;
|
var expirationTime = entry.CreatedAt + entry.TimeToLive.Value;
|
||||||
return DateTimeOffset.UtcNow >= expirationTime;
|
return GetUtcNow() >= expirationTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CleanupExpiredTasks(object? state)
|
private void CleanupExpiredTasks(object? state)
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,15 @@
|
||||||
<TargetFrameworks>net10.0;net9.0;net8.0;net472</TargetFrameworks>
|
<TargetFrameworks>net10.0;net9.0;net8.0;net472</TargetFrameworks>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
<RootNamespace>ModelContextProtocol.Tests</RootNamespace>
|
<RootNamespace>ModelContextProtocol.Tests</RootNamespace>
|
||||||
<!-- https://github.com/dotnet/sdk/issues/51060 -->
|
<!-- https://github.com/dotnet/sdk/issues/51060 -->
|
||||||
<NoWarn>$(NoWarn);NU1903;NU1902</NoWarn>
|
<NoWarn>$(NoWarn);NU1903;NU1902</NoWarn>
|
||||||
|
<!-- Define for conditional TimeProvider support in InMemoryMcpTaskStore -->
|
||||||
|
<DefineConstants>$(DefineConstants);MCP_TEST_TIME_PROVIDER</DefineConstants>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0'">
|
<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0'">
|
||||||
|
|
@ -27,6 +30,10 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="..\Common\**\*.cs" />
|
<Compile Include="..\Common\**\*.cs" />
|
||||||
|
<!-- Link InMemoryMcpTaskStore.cs for testing with TimeProvider support -->
|
||||||
|
<Compile Include="..\..\src\ModelContextProtocol.Core\Server\InMemoryMcpTaskStore.cs" Link="Server\InMemoryMcpTaskStore.cs" />
|
||||||
|
<!-- Include dependencies for the linked InMemoryMcpTaskStore.cs -->
|
||||||
|
<Compile Include="..\..\src\Common\Experimentals.cs" Link="Experimentals.cs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup Condition="'$(TargetFramework)' == 'net472'">
|
<ItemGroup Condition="'$(TargetFramework)' == 'net472'">
|
||||||
|
|
@ -34,6 +41,10 @@
|
||||||
<Compile Include="..\..\src\Common\Polyfills\**\*.cs" />
|
<Compile Include="..\..\src\Common\Polyfills\**\*.cs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup Condition="'$(TargetFramework)' != 'net472'">
|
||||||
|
<Compile Include="..\..\src\Common\Polyfills\System\IdHelpers.cs" Link="Polyfills\IdHelpers.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector">
|
<PackageReference Include="coverlet.collector">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|
@ -43,6 +54,7 @@
|
||||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
<PackageReference Include="Moq" />
|
<PackageReference Include="Moq" />
|
||||||
<PackageReference Include="OpenTelemetry" />
|
<PackageReference Include="OpenTelemetry" />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
using ModelContextProtocol.Protocol;
|
using ModelContextProtocol.Protocol;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using ModelContextProtocol.Tests.Utils;
|
using ModelContextProtocol.Tests.Utils;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using TestInMemoryMcpTaskStore = ModelContextProtocol.Tests.Internal.InMemoryMcpTaskStore;
|
||||||
|
|
||||||
namespace ModelContextProtocol.Tests.Server;
|
namespace ModelContextProtocol.Tests.Server;
|
||||||
|
|
||||||
|
|
@ -1031,4 +1033,141 @@ public class InMemoryMcpTaskStoreTests : LoggedTest
|
||||||
// Assert
|
// Assert
|
||||||
Assert.NotNull(task2);
|
Assert.NotNull(task2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ListTasksAsync_KeysetPaginationWorksWithIdenticalTimestamps()
|
||||||
|
{
|
||||||
|
// Arrange - Use a fake time provider to create tasks with identical timestamps
|
||||||
|
var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||||
|
using var store = new TestInMemoryMcpTaskStore(
|
||||||
|
defaultTtl: null,
|
||||||
|
maxTtl: null,
|
||||||
|
pollInterval: null,
|
||||||
|
cleanupInterval: Timeout.InfiniteTimeSpan,
|
||||||
|
pageSize: 5,
|
||||||
|
maxTasks: null,
|
||||||
|
maxTasksPerSession: null,
|
||||||
|
timeProvider: fakeTime);
|
||||||
|
|
||||||
|
// Create 10 tasks - all with the EXACT same timestamp
|
||||||
|
var createdTasks = new List<McpTask>();
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
var task = await store.CreateTaskAsync(
|
||||||
|
new McpTaskMetadata(),
|
||||||
|
new RequestId($"req-{i}"),
|
||||||
|
new JsonRpcRequest { Method = "test" },
|
||||||
|
null,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
createdTasks.Add(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all tasks have the same CreatedAt timestamp
|
||||||
|
var firstTimestamp = createdTasks[0].CreatedAt;
|
||||||
|
Assert.All(createdTasks, task => Assert.Equal(firstTimestamp, task.CreatedAt));
|
||||||
|
|
||||||
|
// Act - Get first page
|
||||||
|
var result1 = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Assert - First page should have 5 tasks
|
||||||
|
Assert.Equal(5, result1.Tasks.Length);
|
||||||
|
Assert.NotNull(result1.NextCursor);
|
||||||
|
|
||||||
|
// Get second page using cursor
|
||||||
|
var result2 = await store.ListTasksAsync(cursor: result1.NextCursor, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Assert - Second page should have 5 tasks
|
||||||
|
Assert.Equal(5, result2.Tasks.Length);
|
||||||
|
Assert.Null(result2.NextCursor); // No more pages
|
||||||
|
|
||||||
|
// Verify no overlap between pages
|
||||||
|
var page1Ids = result1.Tasks.Select(t => t.TaskId).ToHashSet();
|
||||||
|
var page2Ids = result2.Tasks.Select(t => t.TaskId).ToHashSet();
|
||||||
|
Assert.Empty(page1Ids.Intersect(page2Ids));
|
||||||
|
|
||||||
|
// Verify we got all 10 tasks exactly once
|
||||||
|
var allReturnedIds = page1Ids.Union(page2Ids).ToHashSet();
|
||||||
|
var allCreatedIds = createdTasks.Select(t => t.TaskId).ToHashSet();
|
||||||
|
Assert.Equal(allCreatedIds, allReturnedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ListTasksAsync_TasksCreatedAfterFirstPageWithSameTimestampAppearInSecondPage()
|
||||||
|
{
|
||||||
|
// Arrange - Use a fake time provider so we can control timestamps precisely
|
||||||
|
var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||||
|
using var store = new TestInMemoryMcpTaskStore(
|
||||||
|
defaultTtl: null,
|
||||||
|
maxTtl: null,
|
||||||
|
pollInterval: null,
|
||||||
|
cleanupInterval: Timeout.InfiniteTimeSpan,
|
||||||
|
pageSize: 5,
|
||||||
|
maxTasks: null,
|
||||||
|
maxTasksPerSession: null,
|
||||||
|
timeProvider: fakeTime);
|
||||||
|
|
||||||
|
// Create initial 6 tasks - all with the same timestamp
|
||||||
|
// (6 so that first page has 5 and cursor points to task 5)
|
||||||
|
var initialTasks = new List<McpTask>();
|
||||||
|
for (int i = 0; i < 6; i++)
|
||||||
|
{
|
||||||
|
var task = await store.CreateTaskAsync(
|
||||||
|
new McpTaskMetadata(),
|
||||||
|
new RequestId($"req-initial-{i}"),
|
||||||
|
new JsonRpcRequest { Method = "test" },
|
||||||
|
null,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
initialTasks.Add(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first page - should have 5 tasks with a cursor
|
||||||
|
var result1 = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(5, result1.Tasks.Length);
|
||||||
|
Assert.NotNull(result1.NextCursor);
|
||||||
|
|
||||||
|
// Now create 5 more tasks AFTER we got the first page cursor
|
||||||
|
// These tasks have the SAME timestamp as the cursor (time hasn't moved)
|
||||||
|
// Due to monotonic UUID v7 with counter, they should sort AFTER the cursor
|
||||||
|
var laterTasks = new List<McpTask>();
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
var task = await store.CreateTaskAsync(
|
||||||
|
new McpTaskMetadata(),
|
||||||
|
new RequestId($"req-later-{i}"),
|
||||||
|
new JsonRpcRequest { Method = "test" },
|
||||||
|
null,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
laterTasks.Add(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all tasks have the same timestamp
|
||||||
|
var allTasks = initialTasks.Concat(laterTasks).ToList();
|
||||||
|
var firstTimestamp = allTasks[0].CreatedAt;
|
||||||
|
Assert.All(allTasks, task => Assert.Equal(firstTimestamp, task.CreatedAt));
|
||||||
|
|
||||||
|
// Get ALL remaining pages
|
||||||
|
var allSubsequentTasks = new List<McpTask>();
|
||||||
|
string? cursor = result1.NextCursor;
|
||||||
|
while (cursor != null)
|
||||||
|
{
|
||||||
|
var result = await store.ListTasksAsync(cursor: cursor, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
allSubsequentTasks.AddRange(result.Tasks);
|
||||||
|
cursor = result.NextCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no overlap between first page and subsequent
|
||||||
|
var page1Ids = result1.Tasks.Select(t => t.TaskId).ToHashSet();
|
||||||
|
var subsequentIds = allSubsequentTasks.Select(t => t.TaskId).ToHashSet();
|
||||||
|
Assert.Empty(page1Ids.Intersect(subsequentIds));
|
||||||
|
|
||||||
|
// Verify we got all tasks
|
||||||
|
var allReturnedIds = page1Ids.Union(subsequentIds).ToHashSet();
|
||||||
|
var allCreatedIds = allTasks.Select(t => t.TaskId).ToHashSet();
|
||||||
|
Assert.Equal(allCreatedIds, allReturnedIds);
|
||||||
|
|
||||||
|
// Most importantly: verify ALL the later tasks (created after first page) are surfaced
|
||||||
|
// in the subsequent pages
|
||||||
|
var laterTaskIds = laterTasks.Select(t => t.TaskId).ToHashSet();
|
||||||
|
Assert.Superset(laterTaskIds, subsequentIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue