Table of Contents

Predicate Operations

This page is part of the documentation for Orleans.Lattice 9.9.0 (release line 9.9), built 2026-10-04. It is also published as markdown, with every table and list, at predicated-operations.md, and llms.txt lists every page.

Server-side predicate push-down lets a caller filter a typed operation with an ordinary C# Expression<Func<T, bool>> and have that filter evaluated on the leaf grain that owns each key, not on the client. Only the keys (or values) that match are shipped back across the wire; non-matching values are dropped at the source.

This page explains how push-down works, what expressions are supported, and gives a runnable sample for every predicate overload. For the bare method signatures see API Reference. For the all-or-nothing guarantees of the guarded atomic batch see Atomic Writes.

How it works

  1. Capability gate (client). Push-down requires the value serializer to implement ILatticePredicateSerializer so the server can project each value into a navigable JSON document. The default JsonLatticeSerializer<T> satisfies this. A serializer that cannot expose a JSON document throws NotSupportedException at the call site, before the lambda is translated and before any RPC.
  2. Translate (client). The caller's lambda is lowered to a small, serializable intermediate representation (IR) - LatticePredicateNode - by LatticePredicateTranslator. Translation happens once, on the client, before any RPC. A construct outside the allowlist throws NotSupportedException immediately, naming the offending construct; the server never sees an IR it cannot evaluate.
  3. Evaluate (server). The leaf grain parses each candidate value's bytes as a JSON document and evaluates the IR against it, independent of T. Keys whose value does not match are skipped; live values that match flow back.

Because evaluation is value-shape driven (JSON), each member the lambda names is looked up by its C# member name in the value's serialized document - an exact match first, then a case-insensitive one, so a camelCase naming policy still resolves - and a member the serializer renames (for example with [JsonPropertyName]) or omits resolves as missing. A value that is not well-formed JSON never matches. Missing or tombstoned keys are treated as non-matches.

Every predicate overload on this page has a sibling that also takes an explicit ILatticeSerializer<T>; the shorter form uses JsonLatticeSerializer<T>.Default.

Supported expressions

The translator allowlists exactly:

  • Member access on the lambda parameter - properties or fields - resolved by name (u => u.Age, including nested paths like o => o.Customer.Tier); a bare boolean member (u => u.IsActive) is a predicate on its own.
  • Constants, including captured locals (var min = 18; u => u.Age >= min). More generally, any sub-expression that does not reference the lambda parameter - a captured field, another object's property, even a method call - is evaluated once on the client at translation time and pushed down as a literal.
  • Comparison operators: ==, !=, <, <=, >, >=.
  • Boolean operators: &&, ||, !.
  • String methods: StartsWith, EndsWith, Contains, and Equals, in their ordinal forms only. An overload taking a StringComparison, ignoreCase, or culture argument is rejected, because the server compares strings ordinally.

Conversions (casts) around a member or constant are unwrapped. Anything else that involves the lambda parameter - a method call on it outside that set, an indexer, arithmetic, or any other operator - throws NotSupportedException at translation time on the client.

Structural predicate kinds

Four node kinds look at a value's shape rather than comparing a scalar. The translator never produces them, so a C# lambda cannot express them. They are built directly on LatticePredicateNode and reach the cluster wherever a node is supplied as is: a schema policy's structured rule (LatticeSchemaRule.Structured), a value transform's condition, a materialised view's filter (a predicate, fold or aggregation view takes one), and LatticePredicateEvaluation.Matches.

Kind Factory Meaning
TypeOf LatticePredicateNode.TypeOf(memberPath, kind) A test: the value at the member path is of the given LatticeValueKind. Unlike a comparison, it sees objects and arrays. A missing member is not of any kind.
Length LatticePredicateNode.LengthOf(memberPath) A numeric operand: the length of a string (in Unicode scalars), the item count of an array, or the member count of an object. Anything else resolves as missing, so a comparison with it fails.
Every LatticePredicateNode.Every(memberPath, item) A quantifier: the value at the member path is an array and item holds for every element, evaluated with that element as the current document. An empty array satisfies it; anything that is not an array does not.
Self LatticePredicateNode.Self() An operand naming the current document: the whole value, or the element an enclosing Every is visiting.

A null or empty member path means the current document. LatticeValueKind names the kinds a TypeOf tests for:

LatticeValueKind Matches
Present Any value other than JSON null
Null JSON null
Boolean true or false
Number Any JSON number
Integer A number with no fractional part, such as 3 or 3.0
String A JSON string
Object A JSON object
Array A JSON array

For example, "id is present, and every tag is text of 1 to 32 characters":

var rule = LatticePredicateNode.Bool(
    LatticeBooleanOperator.And,
    LatticePredicateNode.TypeOf("id", LatticeValueKind.Present),
    LatticePredicateNode.Every(
        "tags",
        LatticePredicateNode.Bool(
            LatticeBooleanOperator.And,
            LatticePredicateNode.TypeOf(null, LatticeValueKind.String),
            LatticePredicateNode.Compare(
                LatticeComparisonOperator.GreaterThanOrEqual,
                LatticePredicateNode.LengthOf(null),
                LatticePredicateNode.Const(LatticeConstant.Integer(1))),
            LatticePredicateNode.Compare(
                LatticeComparisonOperator.LessThanOrEqual,
                LatticePredicateNode.LengthOf(null),
                LatticePredicateNode.Const(LatticeConstant.Integer(32))))));

bool matches = LatticePredicateEvaluation.Matches(
    Encoding.UTF8.GetBytes("""{"id":"a1","tags":["red","blue"]}"""),
    rule);

Structural kinds always parse the whole document, so a predicate that contains one never takes the forward-only reader path. They are additive on the wire: the new kinds, and LatticePredicateNode.ValueKind, are appended values and members, and a view's projection version includes the value kind only for a type test, so an existing view keeps the version it always had.

Mixed-version clusters. A silo that predates these kinds evaluates a node of a kind it does not know as false, and resolves an operand of a kind it does not know - a Length or Self operand - to the boolean false. A type test and a quantifier are then false, as is a string method or an ordering comparison (<, <=, >, >=) on such an operand, so a rule built from them with and and or fails closed: during a rolling upgrade an older silo rejects a value a newer one would admit, never the reverse. Two shapes do not. Not over a structural kind turns that false into true, and an equality test on a Length or Self operand compares the boolean false, so != against anything but a boolean, or == against false, holds on an older silo whatever the value. The Explorer's schema rule builder combines checks only with and and or, but it names the whole value - a card with an empty member path - with a Self operand, so a whole-value required check that is not structural, or a whole-value boolean type check, is of the second shape. Avoid both shapes until every silo is upgraded.

Reading values by predicate - GetManyAsync

GetManyAsync<T> with a predicate returns only the entries whose live value matches. Keys that are missing, tombstoned, or non-matching are omitted from the result dictionary, so the caller never pays to deserialize values it would immediately discard.

var keys = new List<string> { "user:1", "user:2", "user:3" };
Dictionary<string, User> adults = await tree.GetManyAsync<User>(
    keys,
    u => u.Age >= 18,
    cancellationToken);

foreach (var (key, user) in adults)
{
    // Only adults are present; the rest were filtered on the owning leaf.
}

Conditional bulk write - SetManyAsync

SetManyAsync<T> with a predicate is a compare-then-set guard applied per key: each key is written only if its current value matches the predicate. The method returns the keys it actually wrote. A key with no live value is treated as a non-match and skipped.

This overload is not atomic - each key is decided independently, so a partial result is possible. Use SetManyAtomicAsync when you need all-or-nothing semantics.

Like the unguarded SetManyAsync, it checks every entry against the optional write-size bounds (LatticeOptions.MaxKeyLength and LatticeOptions.MaxValueSizeBytes) and the tree's admission caps (LatticeOptions.MaxLiveKeys and LatticeOptions.MaxEstimatedBytes) before any key is evaluated, so adding a predicate bypasses neither: an oversized key or value throws ArgumentException, and a tree at a configured cap throws LatticeQuotaExceededException. The guarded atomic batch below makes the same checks before its saga starts.

var entries = new List<KeyValuePair<string, User>>
{
    new("user:1", new User("Alice", 31)),
    new("user:2", new User("Bob", 26)),
};

// Only overwrite keys whose CURRENT stored value is still under 40.
IReadOnlyList<string> written = await tree.SetManyAsync(
    entries,
    current => current.Age < 40,
    cancellationToken);

// `written` lists exactly the keys whose guard passed.

Guarded atomic batch - SetManyAtomicAsync

SetManyAtomicAsync<T> with a predicate is an all-or-nothing batch guarded by a precondition. The predicate is evaluated once, against the pre-saga snapshot of every target key. If every key matches, the whole batch commits; if any key fails the guard, nothing is written. The result is a non-throwing AtomicWriteOutcome.

var entries = new List<KeyValuePair<string, Order>>
{
    new("order:1", new Order("order:1", 120m)),
    new("order:2", new Order("order:2", 80m)),
};

AtomicWriteOutcome outcome = await tree.SetManyAtomicAsync(
    entries,
    current => current.Total > 0m,
    cancellationToken);

if (outcome == AtomicWriteOutcome.Committed)
{
    // Every key matched; the batch is durable.
}
else if (outcome == AtomicWriteOutcome.PreconditionFailed)
{
    // At least one key failed the guard; no key was written.
}

Pass an idempotency key to make a retried call safe: a re-attempt with the same operation id re-attaches to the original saga and returns the memoized outcome without re-evaluating the predicate.

var entries = new List<KeyValuePair<string, Order>>
{
    new("order:1", new Order("order:1", 120m)),
};
string operationId = Guid.NewGuid().ToString();

AtomicWriteOutcome first = await tree.SetManyAtomicAsync(
    entries, current => current.Total > 0m, operationId, cancellationToken);

// A retry with the same operationId returns `first` without re-running the guard.
AtomicWriteOutcome retry = await tree.SetManyAtomicAsync(
    entries, current => current.Total > 0m, operationId, cancellationToken);

Streaming scans - ScanKeysAsync, ScanEntriesAsync, ScanValuesAsync

The streaming scans accept a predicate as their first argument. Matching is done on each owning leaf, so a key-only scan never ships values across the wire at all, and an entry/value scan only ships the values that match. The resilient overloads recover transparently from an EnumerationAbortedException (raised when the remote enumerator is reclaimed mid-scan, for example by a silo failover or idle expiry) with the predicate intact. The low-level KeysAsync<T>, EntriesAsync<T>, and ValuesAsync<T> accept the same predicate but run a single enumeration without that recovery; prefer the Scan* forms.

// Keys only - no values cross the wire.
await foreach (string key in tree.ScanKeysAsync<User>(
    u => u.Age >= 21, cancellationToken: cancellationToken))
{
}

// Entries - only matching key/value pairs are materialized.
await foreach (KeyValuePair<string, User> entry in tree.ScanEntriesAsync<User>(
    u => u.Name.StartsWith("A"), cancellationToken: cancellationToken))
{
    User user = entry.Value;
}

// Values - only matching values are deserialized client-side.
await foreach (User user in tree.ScanValuesAsync<User>(
    u => u.Age < 65, cancellationToken: cancellationToken))
{
}

A predicate composes with the existing bounds, direction, and prefetch arguments:

await foreach (string key in tree.ScanKeysAsync<Order>(
    o => o.Total >= 100m,
    startInclusive: "order:",
    endExclusive: "order:~",
    reverse: true,
    cancellationToken: cancellationToken))
{
}

Durable cursors with a predicate

Every cursor opener has a predicate overload. The compiled IR is persisted on the cursor spec, so after a silo failover or client restart the cursor re-applies the same filter when it resumes - the caller does not need to resend the lambda. Predicate cursors compose with point-in-time and snapshot isolation.

var cursorId = await tree.OpenEntryCursorAsync<User>(
    u => u.Age >= 18, cancellationToken: cancellationToken);
while (true)
{
    var page = await tree.NextEntriesAsync(cursorId, pageSize: 500);
    foreach (var (key, value) in page.Entries)
    {
        // Only matching entries are paged back.
    }
    if (!page.HasMore) break;
}
await tree.CloseCursorAsync(cursorId);

The snapshot variants (OpenSnapshotKeyCursorAsync<T> and OpenSnapshotEntryCursorAsync<T>) apply the predicate against a zero-observable-writes view:

var snapCursor = await tree.OpenSnapshotKeyCursorAsync<Order>(
    o => o.Total >= 100m, cancellationToken: cancellationToken);
var page = await tree.NextKeysAsync(snapCursor, pageSize: 256);
await tree.CloseCursorAsync(snapCursor);

Conditional range delete - DeleteRangeAsync

DeleteRangeAsync<T> with a predicate tombstones only the keys in [startInclusive, endExclusive) whose value matches. The matched key set is persisted to the WAL and shipped to replicating clusters, so peers reproduce the exact same deletion without re-evaluating the predicate against their own (possibly divergent) values. The call returns the number of keys tombstoned.

var deleted = await tree.DeleteRangeAsync<Order>(
    o => o.Total == 0m,
    startInclusive: "order:",
    endExclusive: "order:~",
    cancellationToken);

For large ranges, the resumable cursor variant drains the work in bounded steps and survives failovers; each step re-applies the persisted IR and records its matched set in the WAL.

var cursorId = await tree.OpenDeleteRangeCursorAsync<Order>(
    o => o.Total == 0m,
    startInclusive: "order:",
    endExclusive: "order:~",
    cancellationToken: cancellationToken);
int total = 0;
while (true)
{
    var progress = await tree.DeleteRangeStepAsync(cursorId, maxToDelete: 1000);
    total = progress.DeletedTotal;
    if (progress.IsComplete) break;
}
await tree.CloseCursorAsync(cursorId);

Error surface

Condition Exception
The serializer does not implement ILatticePredicateSerializer NotSupportedException (thrown client-side, before any RPC)
The expression contains a construct outside the allowlist NotSupportedException (thrown client-side at translation time)

All other per-operation error semantics (cursor kind mismatches, closed cursors, range-delete bound validation) are unchanged by predicate push-down - see API Reference and Durable Cursors.