Interview question
Implement a stable EF Core keyset page
Implements descending keyset pagination in LINQ using a compound timestamp-and-id cursor.
TL;DR
Implements descending keyset pagination in LINQ using a compound timestamp-and-id cursor.
Compound cursors, descending comparisons, stable ordering, bounded pages, projection, and index alignment.
Practice the problem like a real interview: restate, reason, implement, and test.
Return OrderFeedItem rows ordered by CreatedAtUtc DESC, Id DESC, where Id is a long database key. The first page has no cursor. Later requests provide both values from the previous page's last row. Deleted orders are excluded.
After cursor (10:00, 42), include rows before 10:00 and rows at 10:00 whose id is lower than 42. A newer row inserted between requests does not shift the next page.
Order.Id and OrderCursor.OrderId are long in this exercise.I build the base query and add the compound cursor predicate only for later pages. Fetching pageSize + 1 avoids a separate count query. I return the requested rows and build the next cursor from the last returned item only when the extra row proves more data exists.
public sealed record OrderCursor(DateTimeOffset CreatedAtUtc, long OrderId);
public sealed record OrderFeedItem(long OrderId, DateTimeOffset CreatedAtUtc, string Status);
public static async Task<KeysetPage<OrderFeedItem>> LoadOrderPageAsync(
AppDbContext db,
OrderCursor? cursor,
int pageSize,
CancellationToken cancellationToken)
{
pageSize = Math.Clamp(pageSize, 1, 100);
IQueryable<Order> query = db.Orders
.AsNoTracking()
.Where(order => !order.IsDeleted);
if (cursor is not null)
{
query = query.Where(order =>
order.CreatedAtUtc < cursor.CreatedAtUtc ||
(order.CreatedAtUtc == cursor.CreatedAtUtc && order.Id < cursor.OrderId));
}
var rows = await query
.OrderByDescending(order => order.CreatedAtUtc)
.ThenByDescending(order => order.Id)
.Select(order => new OrderFeedItem(
order.Id,
order.CreatedAtUtc,
order.Status))
.Take(pageSize + 1)
.ToListAsync(cancellationToken);
var hasMore = rows.Count > pageSize;
var items = rows.Take(pageSize).ToList();
var last = items.LastOrDefault();
var next = hasMore && last is not null
? new OrderCursor(last.CreatedAtUtc, last.OrderId)
: null;
return new KeysetPage<OrderFeedItem>(items, next);
}
With an index such as (IsDeleted, CreatedAtUtc DESC, Id DESC) adapted to the provider and filter selectivity, the database seeks from the cursor and reads one page plus one row. The exercise uses a long id so the comparison is valid C# and translates directly. If a real model uses GUID ids, introduce a stable ordered tie-breaker or verify the provider-specific comparison with a relational integration test.
pageSize rows.hasMore without issuing a count query.Skip and still calling the result keyset pagination.Next in EF Core Labs: Tracked Split Query