Deep Dive

Bulk operations: atomic batches across caches and counters

One request, one commit, many mutations — how Aeron Cache's bulk endpoint collapses N round trips into a single atomic batch that can freely mix cache and counter operations.

Why it matters

Doing N things over a network usually means paying for N round trips, and the latency adds up whether or not any single call is fast. Worse, when those N things are related — write a record and bump a counter, refill a hundred rate limiters, hydrate a cache from a snapshot — issuing them one at a time means they can interleave, partially apply, or drift apart under failure. That is the double tax of chatty clients: round-trip amplification on the happy path, and non-atomic multi-key updates on the unhappy one. Bulk operations remove both. A batch is one framed message, one committed decision, applied in order — and it can mix regular-cache and counter operations freely in the same request.

One endpoint, one atomic batch

Bulk lives at a single HTTP route: POST /api/v1/cache/bulkops. The body is a top-level requestId plus an operations array, where each operation carries its own operationType, its own requestId, a cacheId, a key, and either a value (cache ops) or a counterValue (counter ops):

{
  "requestId": "batch-1",
  "operations": [
    { "operationType": "ADD_ITEM", "requestId": "op-1", "cacheId": "sample-cache", "key": "k1", "value": "v1" },
    { "operationType": "INCREMENT_COUNTER", "requestId": "op-2", "cacheId": "counter-cache", "key": "requests", "counterValue": 5 },
    { "operationType": "GET_ITEM", "requestId": "op-3", "cacheId": "sample-cache", "key": "k1" }
  ]
}

Notice that a single batch spans two different caches and two different kinds of cache — a string cache and a counter cache. There is no separate “bulk cache” and “bulk counter” surface to coordinate; the operation vocabulary — puts, gets, patches, removes, and counter arithmetic — is uniform inside one operations array.

Why it is atomic: it rides the log

The atomicity is not a lock the gateway holds — it falls out of the architecture. Aeron Cache is a deterministic replicated state machine on Aeron Cluster (RAFT) (see RAFT consensus). A bulk request becomes committed, ordered log events that every node replays identically. So a batch of counter increments, or a mixed batch of cache and counter ops, is applied consistently across the cluster — the same operations, in the same order, on every replica — rather than interleaved inconsistently per node. You are not racing N independent requests through the cluster; you are committing one unit of work whose ordering is already decided before any node acts on it.

Each mutation inside the batch is a normal mutation, so it also fans out to subscribers as its usual event — ADD_ITEM, REMOVE_ITEM, PATCH_ITEM, a counter update — through the same streaming path any single write would take (see streaming subscriptions). A bulk batch is invisible to subscribers as a batch; they simply see its constituent changes arrive, in committed order.

Correlating results: requestId in, requestId out

Every operation’s requestId is echoed on the matching per-operation response, and results come back in request order. That gives a client two independent ways to line each result up with the op that produced it — position and id — so a partial-interest consumer can pick out exactly the responses it cares about:

const response = await client.bulkOps({
  requestId: "batch-1",
  operations: [
    { operationType: "ADD_ITEM", requestId: "op-1", cacheId: "sample-cache", key: "k1", value: "v1" },
    { operationType: "INCREMENT_COUNTER", requestId: "op-2", cacheId: "counter-cache", key: "requests", counterValue: 5 },
    { operationType: "GET_ITEM", requestId: "op-3", cacheId: "sample-cache", key: "k1" },
  ],
});

Each per-operation response carries its own operationStatus business status — SUCCESS, UNKNOWN_CACHE, UNKNOWN_KEY, CACHE_EXISTS, and so on — so clients branch on the status field per op rather than catching a transport exception for the whole request (see API overview). bulkOps is available in all four embedded clients — Java, TypeScript, Python, and Rust (see embedded clients).

Use cases

Atomic metric and leaderboard batches

When many counters must move together, a batch commits them as one decision. Refill a fleet of rate limiters, or apply a tick of leaderboard scores, and every node sees the same post-batch totals — no window where one counter has advanced and another has not:

{
  "requestId": "refill-window-42",
  "operations": [
    { "operationType": "INCREMENT_COUNTER", "requestId": "r-1", "cacheId": "ratelimits", "key": "tenant:acme", "counterValue": 100 },
    { "operationType": "INCREMENT_COUNTER", "requestId": "r-2", "cacheId": "ratelimits", "key": "tenant:globex", "counterValue": 100 },
    { "operationType": "INCREMENT_COUNTER", "requestId": "r-3", "cacheId": "ratelimits", "key": "tenant:initech", "counterValue": 100 }
  ]
}

Seeding and hydrating a cache

Round-trip amplification is the enemy at startup. Loading a thousand keys with a thousand puts pays a thousand round trips; a single bulk batch of ADD_ITEM ops pays one framed message and one commit. This is the natural way to warm a cache on boot or rebuild it after a CLEAR_CACHE:

curl -X POST http://localhost:7070/api/v1/cache/bulkops \
  -H 'Content-Type: application/json' \
  -d '{"requestId":"seed-1","operations":[
        {"operationType":"ADD_ITEM","requestId":"s-1","cacheId":"quotes","key":"EURUSD","value":"{\"bid\":1.0871}"},
        {"operationType":"ADD_ITEM","requestId":"s-2","cacheId":"quotes","key":"GBPUSD","value":"{\"bid\":1.2640}"}
      ]}'

Mixed atomic update: write a record and move a counter together

The sharpest case for mixing op kinds is keeping two facts from drifting apart. Persist an order record and increment the order count in the same batch, and there is no interval in which the record exists but the count has not caught up — both land in one committed log unit:

{
  "requestId": "order-9f3",
  "operations": [
    { "operationType": "ADD_ITEM", "requestId": "o-1", "cacheId": "orders", "key": "order:9f3", "value": "{\"sku\":\"A1\",\"qty\":2}" },
    { "operationType": "INCREMENT_COUNTER", "requestId": "o-2", "cacheId": "stats", "key": "orders.total", "counterValue": 1 }
  ]
}

Batched multi-key reads

A batch of GET_ITEM ops fetches a known set of keys in one request/response instead of N gets. This matters most precisely when you are not holding a local mirror — if you run an embedded cache, reads are already local map lookups with no round trip at all (see streaming subscriptions). Bulk reads are the answer for the stateless client that needs a specific, known set of keys right now and does not want to subscribe.

On the wire and across transports

Bulk is not HTTP-only. The bidirectional WebSocket (/api/ws/v1/bidi, AeronBidiClient) carries bulk frames in and bulkResponse frames out over one JSON socket, correlated by the client-generated correlationId that multiplexes every request on that connection — available in all four languages. On the native Aeron SBE gateway (Java and Rust), the schema (schema id 7, little-endian) encodes a BulkOperationType as a single uint8 on the wire, mirroring the cluster’s operation model rather than inventing a parallel one (see Aeron + SBE transport). Because a batch is one framed message, the gateway decodes it and dispatches its operations without per-operation allocation on the hot path (see low-GC design) — the batch is handled in the same GC-light style as any single command.

Takeaway: a bulk request is one message, one commit, and one ordered unit of work that can mix cache and counter operations freely — so you trade N round trips and N chances to drift for a single atomic batch whose results come back correlated and in order, applied identically on every node. When related mutations must not drift apart, or when round-trip amplification is the bottleneck, reach for bulkops; see the use-case patterns for how it recombines with the rest of the primitives.