Skip to content

Commit 0b23d00

Browse files
committed
Add ContinuationToken<T> for stateless pagination
1 parent c761c63 commit 0b23d00

File tree

3 files changed

+1173
-3
lines changed

3 files changed

+1173
-3
lines changed

Directory.Packages.props

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
<NoWarn>$(NoWarn);NU1507</NoWarn>
66
</PropertyGroup>
77
<ItemGroup>
8-
<PackageVersion Include="Aspire.Hosting.AppHost" Version="13.0.0" />
8+
<PackageVersion Include="Aspire.Hosting.AppHost" Version="13.0.1" />
99
<PackageVersion Include="AspNetCore.SecurityKey" Version="4.0.0" />
1010
<PackageVersion Include="AssemblyMetadata.Generators" Version="2.1.0" />
1111
<PackageVersion Include="AutoMapper" Version="[14.0.0]" />
1212
<PackageVersion Include="AwesomeAssertions" Version="9.3.0" />
1313
<PackageVersion Include="Azure.Communication.Email" Version="1.1.0" />
1414
<PackageVersion Include="Azure.Communication.Sms" Version="1.0.2" />
15-
<PackageVersion Include="BenchmarkDotNet" Version="0.15.7" />
15+
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
1616
<PackageVersion Include="Blazilla" Version="2.0.1" />
1717
<PackageVersion Include="Bogus" Version="35.6.5" />
1818
<PackageVersion Include="CsvHelper" Version="33.1.0" />
@@ -70,7 +70,7 @@
7070
<PackageVersion Include="TestHost.Abstracts" Version="2.0.0" />
7171
<PackageVersion Include="Testcontainers.MongoDb" Version="4.9.0" />
7272
<PackageVersion Include="Testcontainers.MsSql" Version="4.9.0" />
73-
<PackageVersion Include="TUnit" Version="1.2.11" />
73+
<PackageVersion Include="TUnit" Version="1.3.9" />
7474
<PackageVersion Include="Twilio" Version="7.13.7" />
7575
<PackageVersion Include="Verify.TUnit" Version="31.7.3" />
7676
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
using System.Buffers;
2+
using System.Buffers.Binary;
3+
using System.Buffers.Text;
4+
using System.Diagnostics;
5+
using System.Diagnostics.CodeAnalysis;
6+
using System.Runtime.CompilerServices;
7+
using System.Runtime.InteropServices;
8+
9+
namespace Arbiter.CommandQuery.Services;
10+
11+
/// <summary>
12+
/// Represents a continuation token for pagination that combines an identifier and an optional timestamp.
13+
/// </summary>
14+
/// <typeparam name="T">The type of the identifier. Must be an unmanaged type.</typeparam>
15+
/// <remarks>
16+
/// <para>
17+
/// The <see cref="ContinuationToken{T}"/> provides a stateless pagination mechanism by encoding an identifier
18+
/// and optional timestamp into a compact, URL-safe Base64 string. This approach is more efficient than traditional
19+
/// page-based pagination for large datasets and avoids consistency issues when data changes between requests.
20+
/// </para>
21+
/// <para>
22+
/// The token uses little-endian encoding for cross-platform consistency and supports common unmanaged types including:
23+
/// <list type="bullet">
24+
/// <item><description>Numeric types: <see cref="int"/>, <see cref="long"/>, <see cref="short"/>, <see cref="byte"/>, and unsigned variants</description></item>
25+
/// <item><description>Floating-point types: <see cref="float"/>, <see cref="double"/></description></item>
26+
/// <item><description><see cref="Guid"/> for unique identifiers</description></item>
27+
/// <item><description>Other unmanaged value types</description></item>
28+
/// </list>
29+
/// </para>
30+
/// <para>
31+
/// The optional timestamp component stores UTC ticks and can be used for time-based pagination or audit tracking.
32+
/// When serialized, the token is encoded as a Base64URL string that is safe for use in URLs and HTTP headers.
33+
/// </para>
34+
/// </remarks>
35+
/// <example>
36+
/// <para><strong>Basic Usage with Entity Framework Core</strong></para>
37+
/// <para>
38+
/// The following example demonstrates keyset pagination using continuation tokens.
39+
/// This is more efficient than SKIP/TAKE because it uses indexed WHERE clauses:
40+
/// </para>
41+
/// <code>
42+
/// var query = dbContext.Products
43+
/// .AsNoTracking()
44+
/// .Where(p => p.IsActive);
45+
///
46+
/// // Parse continuation token to get the last seen ID
47+
/// if (ContinuationToken&lt;int&gt;.TryParse(continuationToken, out var token))
48+
/// {
49+
/// // Fetch items after the last seen ID (keyset pagination)
50+
/// query = query.Where(p => p.Id &gt; token.Id);
51+
/// }
52+
///
53+
/// // Take one more than requested to check if there are more items
54+
/// var pageSize = 20;
55+
/// var items = await query
56+
/// .OrderBy(p => p.Id)
57+
/// .Take(pageSize + 1)
58+
/// .ToListAsync();
59+
///
60+
/// // Check if there are more items and create continuation token
61+
/// string? nextToken = null;
62+
/// if (items.Count &gt; pageSize)
63+
/// {
64+
/// items.RemoveAt(items.Count - 1);
65+
/// var lastItem = items[^1];
66+
/// nextToken = new ContinuationToken&lt;int&gt;(lastItem.Id).ToString();
67+
/// }
68+
/// </code>
69+
///
70+
/// <para><strong>Time-Based Pagination</strong></para>
71+
/// <para>
72+
/// Using timestamp with ID as a tie-breaker for ordering by date:
73+
/// </para>
74+
/// <code>
75+
/// var query = dbContext.Events.AsNoTracking();
76+
///
77+
/// // Parse token to get last seen timestamp and ID
78+
/// if (ContinuationToken&lt;int&gt;.TryParse(continuationToken, out var token))
79+
/// {
80+
/// // Keyset pagination with composite key (timestamp + ID)
81+
/// query = query.Where(e =>
82+
/// e.CreatedDate &gt; token.Timestamp ||
83+
/// (e.CreatedDate == token.Timestamp &amp;&amp; e.Id &gt; token.Id));
84+
/// }
85+
///
86+
/// // Take one more than requested to check if there are more items
87+
/// var pageSize = 50;
88+
/// var events = await query
89+
/// .OrderBy(e => e.CreatedDate)
90+
/// .ThenBy(e => e.Id)
91+
/// .Take(pageSize + 1)
92+
/// .ToListAsync();
93+
///
94+
/// // Check if there are more items and create continuation token
95+
/// string? nextToken = null;
96+
/// if (events.Count &gt; pageSize)
97+
/// {
98+
/// events.RemoveAt(events.Count - 1);
99+
/// var last = events[^1];
100+
/// nextToken = new ContinuationToken&lt;int&gt;(last.Id, last.CreatedDate).ToString();
101+
/// }
102+
/// </code>
103+
/// </example>
104+
[DebuggerDisplay("Id = {Id}, Timestamp = {Timestamp}")]
105+
[StructLayout(LayoutKind.Sequential)]
106+
public readonly record struct ContinuationToken<T>
107+
where T : unmanaged
108+
{
109+
/// <summary>
110+
/// Initializes a new instance of the <see cref="ContinuationToken{T}"/> struct.
111+
/// </summary>
112+
/// <param name="id">The identifier value.</param>
113+
/// <param name="timestamp">The optional timestamp associated with the token.</param>
114+
public ContinuationToken(T id, DateTimeOffset? timestamp = null)
115+
{
116+
Id = id;
117+
Timestamp = timestamp;
118+
}
119+
120+
/// <summary>
121+
/// Gets the identifier value.
122+
/// </summary>
123+
public T Id { get; }
124+
125+
/// <summary>
126+
/// Gets the optional timestamp associated with the token.
127+
/// </summary>
128+
public DateTimeOffset? Timestamp { get; }
129+
130+
131+
/// <summary>
132+
/// Converts the continuation token to a Base64URL-encoded string representation.
133+
/// </summary>
134+
/// <returns>A Base64URL-encoded string containing the identifier and optional date.</returns>
135+
public override readonly string ToString()
136+
{
137+
int idSize = Unsafe.SizeOf<T>();
138+
139+
// Allocate: idSize for Id + 1 for hasDate flag + (optional: 8 for ticks)
140+
int totalSize = Timestamp.HasValue ? idSize + 9 : idSize + 1;
141+
Span<byte> buffer = stackalloc byte[totalSize];
142+
143+
// Write Id with explicit little-endian encoding
144+
WriteValueLittleEndian(Id, buffer);
145+
146+
// Write hasDate flag
147+
buffer[idSize] = Timestamp.HasValue ? (byte)1 : (byte)0;
148+
149+
// Write Date ticks if present
150+
if (Timestamp.HasValue)
151+
BinaryPrimitives.WriteInt64LittleEndian(buffer[(idSize + 1)..], Timestamp.Value.UtcTicks);
152+
153+
// Convert to Base64URL
154+
int encodedLength = Base64Url.GetEncodedLength(totalSize);
155+
Span<byte> encoded = stackalloc byte[encodedLength];
156+
Base64Url.EncodeToUtf8(buffer, encoded, out _, out _);
157+
158+
return System.Text.Encoding.UTF8.GetString(encoded);
159+
}
160+
161+
162+
/// <summary>
163+
/// Attempts to parse a Base64URL-encoded string into a continuation token.
164+
/// </summary>
165+
/// <param name="token">The Base64URL-encoded token string to parse.</param>
166+
/// <param name="result">When this method returns, contains the parsed continuation token if successful; otherwise, the default value.</param>
167+
/// <returns><c>true</c> if the token was successfully parsed; otherwise, <c>false</c>.</returns>
168+
[SuppressMessage("Design", "MA0018:Do not declare static members on generic types", Justification = "Safe")]
169+
public static bool TryParse(string? token, out ContinuationToken<T> result)
170+
{
171+
result = default;
172+
173+
if (string.IsNullOrEmpty(token))
174+
return false;
175+
176+
try
177+
{
178+
int idSize = Unsafe.SizeOf<T>();
179+
180+
// Decode from Base64URL
181+
var tokenByteCount = System.Text.Encoding.UTF8.GetByteCount(token);
182+
Span<byte> encodedBytes = stackalloc byte[tokenByteCount];
183+
System.Text.Encoding.UTF8.GetBytes(token, encodedBytes);
184+
185+
int maxDecodedLength = Base64Url.GetMaxDecodedLength(encodedBytes.Length);
186+
Span<byte> buffer = stackalloc byte[maxDecodedLength];
187+
188+
if (Base64Url.DecodeFromUtf8(encodedBytes, buffer, out _, out int bytesWritten) != OperationStatus.Done)
189+
return false;
190+
191+
buffer = buffer[..bytesWritten];
192+
193+
// Validate minimum size (idSize + 1 for hasDate flag)
194+
if (buffer.Length < idSize + 1)
195+
return false;
196+
197+
// Read hasDate flag first to determine expected size
198+
bool hasDate = buffer[idSize] == 1;
199+
200+
// Calculate expected size and validate exact match
201+
int expectedSize = hasDate ? idSize + 9 : idSize + 1;
202+
if (buffer.Length != expectedSize)
203+
return false;
204+
205+
// Read Id with explicit little-endian decoding
206+
T id = ReadValueLittleEndian<T>(buffer);
207+
208+
DateTimeOffset? date = null;
209+
if (hasDate)
210+
{
211+
long ticks = BinaryPrimitives.ReadInt64LittleEndian(buffer[(idSize + 1)..]);
212+
date = new DateTimeOffset(ticks, TimeSpan.Zero);
213+
}
214+
215+
result = new ContinuationToken<T>(id, date);
216+
return true;
217+
}
218+
catch
219+
{
220+
return false;
221+
}
222+
}
223+
224+
225+
/// <summary>
226+
/// Writes a value to a buffer using little-endian encoding.
227+
/// </summary>
228+
/// <typeparam name="TValue">The type of value to write. Must be an unmanaged type.</typeparam>
229+
/// <param name="value">The value to write.</param>
230+
/// <param name="buffer">The destination buffer.</param>
231+
private static void WriteValueLittleEndian<TValue>(TValue value, Span<byte> buffer)
232+
where TValue : unmanaged
233+
{
234+
// Handle common numeric types with explicit endianness
235+
if (typeof(TValue) == typeof(short))
236+
BinaryPrimitives.WriteInt16LittleEndian(buffer, Unsafe.As<TValue, short>(ref value));
237+
else if (typeof(TValue) == typeof(ushort))
238+
BinaryPrimitives.WriteUInt16LittleEndian(buffer, Unsafe.As<TValue, ushort>(ref value));
239+
else if (typeof(TValue) == typeof(int))
240+
BinaryPrimitives.WriteInt32LittleEndian(buffer, Unsafe.As<TValue, int>(ref value));
241+
else if (typeof(TValue) == typeof(uint))
242+
BinaryPrimitives.WriteUInt32LittleEndian(buffer, Unsafe.As<TValue, uint>(ref value));
243+
else if (typeof(TValue) == typeof(long))
244+
BinaryPrimitives.WriteInt64LittleEndian(buffer, Unsafe.As<TValue, long>(ref value));
245+
else if (typeof(TValue) == typeof(ulong))
246+
BinaryPrimitives.WriteUInt64LittleEndian(buffer, Unsafe.As<TValue, ulong>(ref value));
247+
else if (typeof(TValue) == typeof(float))
248+
BinaryPrimitives.WriteSingleLittleEndian(buffer, Unsafe.As<TValue, float>(ref value));
249+
else if (typeof(TValue) == typeof(double))
250+
BinaryPrimitives.WriteDoubleLittleEndian(buffer, Unsafe.As<TValue, double>(ref value));
251+
else if (typeof(TValue) == typeof(byte) || typeof(TValue) == typeof(sbyte))
252+
// Single byte types don't need endianness conversion
253+
Unsafe.WriteUnaligned(ref MemoryMarshal.GetReference(buffer), value);
254+
else if (typeof(TValue) == typeof(Guid))
255+
Unsafe.As<TValue, Guid>(ref value).TryWriteBytes(buffer);
256+
else
257+
// For other unmanaged types, use unaligned write
258+
Unsafe.WriteUnaligned(ref MemoryMarshal.GetReference(buffer), value);
259+
}
260+
261+
/// <summary>
262+
/// Reads a value from a buffer using little-endian encoding.
263+
/// </summary>
264+
/// <typeparam name="TValue">The type of value to read. Must be an unmanaged type.</typeparam>
265+
/// <param name="buffer">The source buffer.</param>
266+
/// <returns>The value read from the buffer.</returns>
267+
private static TValue ReadValueLittleEndian<TValue>(Span<byte> buffer)
268+
where TValue : unmanaged
269+
{
270+
// Handle common numeric types with explicit endianness
271+
if (typeof(TValue) == typeof(short))
272+
{
273+
short value = BinaryPrimitives.ReadInt16LittleEndian(buffer);
274+
return Unsafe.As<short, TValue>(ref value);
275+
}
276+
else if (typeof(TValue) == typeof(ushort))
277+
{
278+
ushort value = BinaryPrimitives.ReadUInt16LittleEndian(buffer);
279+
return Unsafe.As<ushort, TValue>(ref value);
280+
}
281+
else if (typeof(TValue) == typeof(int))
282+
{
283+
int value = BinaryPrimitives.ReadInt32LittleEndian(buffer);
284+
return Unsafe.As<int, TValue>(ref value);
285+
}
286+
else if (typeof(TValue) == typeof(uint))
287+
{
288+
uint value = BinaryPrimitives.ReadUInt32LittleEndian(buffer);
289+
return Unsafe.As<uint, TValue>(ref value);
290+
}
291+
else if (typeof(TValue) == typeof(long))
292+
{
293+
long value = BinaryPrimitives.ReadInt64LittleEndian(buffer);
294+
return Unsafe.As<long, TValue>(ref value);
295+
}
296+
else if (typeof(TValue) == typeof(ulong))
297+
{
298+
ulong value = BinaryPrimitives.ReadUInt64LittleEndian(buffer);
299+
return Unsafe.As<ulong, TValue>(ref value);
300+
}
301+
else if (typeof(TValue) == typeof(float))
302+
{
303+
float value = BinaryPrimitives.ReadSingleLittleEndian(buffer);
304+
return Unsafe.As<float, TValue>(ref value);
305+
}
306+
else if (typeof(TValue) == typeof(double))
307+
{
308+
double value = BinaryPrimitives.ReadDoubleLittleEndian(buffer);
309+
return Unsafe.As<double, TValue>(ref value);
310+
}
311+
else if (typeof(TValue) == typeof(byte) || typeof(TValue) == typeof(sbyte))
312+
{
313+
// Single byte types don't need endianness conversion
314+
return Unsafe.ReadUnaligned<TValue>(ref MemoryMarshal.GetReference(buffer));
315+
}
316+
else if (typeof(TValue) == typeof(Guid))
317+
{
318+
Guid value = new(buffer[..16]);
319+
return Unsafe.As<Guid, TValue>(ref value);
320+
}
321+
else
322+
{
323+
// For other unmanaged types, use unaligned read
324+
// Note: This may still have endianness issues for complex structs
325+
return Unsafe.ReadUnaligned<TValue>(ref MemoryMarshal.GetReference(buffer));
326+
}
327+
}
328+
}

0 commit comments

Comments
 (0)