Skip to content
Improve this page

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:

  • WithCustomerId and WithCreatedAtUnixMs are generated from the index annotations ([DataCacheForeignKey<Customer>] and [DataCacheIndex(Range)]) — IntelliSense lists exactly the indices you declared.
  • ExecutePooled() returns a disposable QueryResults<Order> backed by ArrayPool. Always using it. Execute() allocates and needs no disposal.
  • Index lanes are intersected; the first one that narrows to nothing stops the query.
  • A second AddOrUpdate of a structurally equal Order is a no-op (Same): nothing is swapped, no index is touched, and the out overload 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} and InnerJoinWith{Other} for cross-cache lookups.