Quick Start
A working cache in three steps, and a fourth that reads its indices directly.
1. Define a POCO
Annotate any data class with [DataCache]. Mark its primary key with [DataCacheKey] and declare any indices you want to query by. The Order below is the entity every page of these docs shares, so it carries a few annotations a first cache does not need; the comments say what each one is for. Everything lives in using Prague.Core;.
public enum OrderStatus {
Pending,
Paid,
Shipped,
Cancelled,
}
[DataCache] // emits OrderCache; [DataCache("OrdersCache")] renames it
[DataCacheTopic("orders.[v:env]")] // Kafka topic; [v:env] resolves from KafkaCachesOptions.Vars
[DataCacheSort("ByCreatedAt", "CreatedAtUnixMs:-1", "OrderId:1")] // emits OrderCache.ByCreatedAtComparer (newest first)
public partial class Order { // must be partial
[DataCacheKey]
public required string OrderId { get; init; }
[DataCacheForeignKey<Customer>(DataCacheJoinType.ManyToOne)] // many orders → one customer; also indexes CustomerId
[DataCacheGlobalLastUpdateIndex<CustomerActivityIndex>] // every order write stamps its customer's activity
public required int CustomerId { get; init; }
[DataCacheIndex(DataCacheIndexType.Range)] // sorted — Gte / Lt / … queries
public required long CreatedAtUnixMs { get; init; }
[DataCacheIndex(DataCacheIndexType.Many)] // 1:N — many orders share a status
public required OrderStatus Status { get; init; }
[DataCacheHasValueIndex] // key set: WithSettledAtUnixMs() = settled (non-null)
public long? SettledAtUnixMs { get; init; }
public required decimal Total { get; init; }
}
The class must be partial. The source generator emits OrderCache : IDataCache<OrderCache, string, Order> alongside it — index storage, AddOrUpdate, a fluent Query() builder and a Prepare() builder — and completes Order with CacheEquals / CacheGetHashCode (deep structural equality) and Clone(). Do not implement any interface by hand; [DataCacheKey] is enough.
A [DataCacheForeignKey<T>] also indexes the property it sits on, which is why Order gets WithCustomerId(...) without a separate [DataCacheIndex]; the join it declares is covered in Joins.
2. Create the cache
Stand-alone, for tests and in-process views:
var cache = new OrderCache();
Through the registry, which also wires joins and global indices between caches (registering OrderCache pulls in the caches its foreign keys point at):
var registry = new DataCacheRegistryBuilder()
.Register<OrderCache>()
.Build();
var cache = registry.GetCache<OrderCache>();
From Kafka through DI (see Kafka integration); the cache resolves as soon as the container is built and fills once the hosted consumer has replayed the topic:
services.AddKafkaCaches(o => o.BootstrapServers = "kafka:9092",
builder => builder.AddCache<OrderCache, string, Order>());
var serviceProvider = services.BuildServiceProvider(); // in an app: the host's provider
var cache = serviceProvider.GetRequiredService<OrderCache>();
3. Write and query
cache.AddOrUpdate(new Order { // timestamp = now; AddOrUpdate(order, timestampMs) to set it
OrderId = "ORD-001",
CustomerId = 42,
CreatedAtUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
Status = OrderStatus.Pending,
Total = 99.99m,
});
var yesterdayUnixMs = DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeMilliseconds();
using var orders = cache.Query()
.WithCustomerId(42)
.WithCreatedAtUnixMs(q => q.Gte(yesterdayUnixMs))
.ExecutePooled();
foreach (var order in orders)
Console.WriteLine($"{order.OrderId} {order.Total}");
Notes:
WithCustomerIdandWithCreatedAtUnixMsare generated from the index annotations ([DataCacheForeignKey<Customer>]and[DataCacheIndex(Range)]) — IntelliSense lists exactly the indices you declared.ExecutePooled()returns a disposableQueryResults<Order>backed byArrayPool. Alwaysusingit.Execute()allocates and needs no disposal.- Index lanes are intersected; the first one that narrows to nothing stops the query.
- A second
AddOrUpdateof a structurally equalOrderis a no-op (Same): nothing is swapped, no index is touched, and theoutoverload reports it:
var same = new Order { // equal field for field to what is stored
OrderId = "ORD-001",
CustomerId = 42,
CreatedAtUnixMs = createdAt,
Status = OrderStatus.Pending,
Total = 99.99m,
};
var changed = cache.AddOrUpdate(same, out var previous); // false: nothing swapped, previous is null
4. Direct index access
The indices are public fields on the cache, so a lookup that needs no query can read them directly. A Many or Range index hands back keys; cache.Cache is the raw InMemoryDataCache<string, Order> that dereferences them.
var customerOrderIds = cache.CustomerIdIndex.GetValues(42); // Many → keys
var recentOrderIds = cache.CreatedAtUnixMsIndex.GetValuesGte(yesterdayUnixMs); // Range → keys
if (cache.Cache.TryGet("ORD-001", out var order)) // by primary key
Console.WriteLine(order.Total);
Every block on this page is compiled and run as a test in samples/Prague.Samples; dotnet test samples/Prague.Samples reproduces it.
Next
- Index Types — the full menu of
[DataCacheIndex]variants plus[DataCacheValueIndex],[DataCacheHasValueIndex], and friends. - Query Engine — pooled vs allocating execution,
UseIndex,Or,Where, sort, and intersection rules. - Prepared and Frozen Queries — build a query once, execute it at 0 B.
- Joins —
JoinWith{Other}andInnerJoinWith{Other}for cross-cache lookups.