Skip to content

perf(cache): write a set's value before its event, and an opt-in background L2 write - #266

Merged
cosmin-staicu merged 1 commit into
mainfrom
perf/background-write
Oct 22, 2026
Merged

cosmin-staicu merged 1 commit into
mainfrom
perf/background-write

Conversation

@cosmin-staicu

Copy link
Copy Markdown
Member

Two changes to the write path of MultilayerCache and MultilayerHashCache.

Write before publishing (default)

A single-key set published its event (XADD) before writing the value. A peer that woke on the event, dropped its local entry and read L2 in that window cached the old value again, for its whole local lifetime. Single-key sets, hash sets and the rehydrate writes now write first and publish second, as the batch set already did. A batch set now publishes every key the inner tier holds, even when only the local write failed.

write event returns L2 peers
ok ok true new notified
ok refused or throws false new stale until their entry expires
refused or throws not sent false old unaffected
cancelled before the write lands not sent false old unaffected
ok, local write fails ok false new notified

Opt-in background L2 write

CachePolicy.BackgroundDistributedWrite takes the L2 write of a single-key Set* and of a GetOrAdd* fill off the caller's path, together with the set's event after it, on both caches. Awaited round trips: a set goes from 2 to 0, a fill's write from 1 to 0.

What it keeps:

  • The value stays local at the disconnected cap (LocalExpirationDisconnected) until its L2 write lands, then gets the normal local lifetime. The promotion skips an entry a newer write, a remove or an invalidation replaced.
  • A failed background write sends no event and leaves the value local only until the cap; it is logged and counted as cache.background_write.failed.
  • Writes to one key reach L2 in issue order, also with a retrying resilience pipeline.
  • Awaited sets, removes, refreshes and batches of a key wait for its pending background write, so they cannot be overtaken by an older value.
  • InMemoryRedisCacheOptions.MaxBackgroundWrites (default 1024) bounds the writes in flight; past it a write runs awaited instead of being dropped.
  • The work runs on the cache's lifetime token, never the caller's. Dispose and DisposeAsync drain it for BackgroundWriteDrainTimeout (default 5 s).
  • The value is serialized before the call returns on the uncontended path.

What stays awaited: removes, refresh, batch operations, a fill under the distributed lock (the lock is released when the run ends, so a peer must find the value), and the IDistributedCache adapter, whose buffer writes use the caller's memory. The adapter forces the flag off for any policy it is given.

What a caller gives up with the flag on: after SetAsync returns, other nodes can still read the old value until the write lands (about one round trip), and a true result means the local write succeeded, not the L2 one.

Tests

  • Unit: the ordering and failure combinations above, for the single-key, hash, rehydrate and batch writes; the background path on both caches (promotion, failure, per-key order, waits, the limit, the token, dispose drain, fill under the lock, adapter stays foreground, policy merge).
  • Redis integration, two nodes with a delayed write: a peer never keeps the replaced value; background set and fill return before the write and land after; per-key order; dispose drain; the adapter through DI with the flag on by default.

@github-actions

Copy link
Copy Markdown

🔎 Maintainer heads-up: automated triage flagged this PR as potentially material, so it may need a signed CLA in addition to the DCO sign-off.

Strong signals

  • adds public API surface (PublicAPI.Unshipped.txt in src/UiPath.Caching.Abstractions, src/UiPath.Caching)

Other signals

  • large production change (+536 lines under src/)

This is advisory only — the bot does not decide. Please judge against the CLA criteria (material, product-critical, patent-sensitive, corporate contributor, broad commercial use). Note that thresholds can be gamed by splitting PRs, so use your judgement.

  • If a CLA is needed → add the cla-required label (a contributor comment with signing steps is posted automatically).
  • If it is not needed → replace needs-cla-review with cla-not-required so later pushes don't re-flag it.

@github-actions github-actions Bot added the needs-cla-review A maintainer should assess whether a signed CLA is required (see CONTRIBUTING.md) label Oct 21, 2026
@cosmin-staicu cosmin-staicu added cla-not-required Maintainer reviewed: no CLA required for this contribution and removed needs-cla-review A maintainer should assess whether a signed CLA is required (see CONTRIBUTING.md) labels Oct 21, 2026
@cosmin-staicu
cosmin-staicu force-pushed the perf/background-write branch 5 times, most recently from 972d033 to ec1666d Compare October 22, 2026 04:09
…ground L2 write

A single-key set published its event before writing the value, so a peer that
woke on the event and read L2 in between kept the old value for its whole local
lifetime. Single-key, hash and rehydrate writes now write first and publish
second; a batch set publishes every key the inner tier holds.

CachePolicy.BackgroundDistributedWrite runs the L2 write of a single-key set and
of a GetOrAdd fill, and a set's event after it, off the caller's path, on both
the single-key and the hash cache. The value stays local at the disconnected cap
until its write lands. Writes of one key reach L2 in issue order: awaited
writes, removes, TryAdd and refresh wait for the key's background writes, and
a background write waits for every earlier write of its key. A local write that
a newer one overtook, and a fill from an L2 read begun before a write of the
key or while one is in flight, leave the newer local value in place.
MaxBackgroundWrites bounds them, falling back to an awaited write; Dispose and
DisposeAsync drain them once, for BackgroundWriteDrainTimeout (infinite waits
for all), and count the ones cancelled. Batches, fills under the distributed
lock and the IDistributedCache adapter stay awaited.

The notify filter of a forced reconnect now keys on the connector's current
multiplexer, so a reconnect during a subscribe neither keeps a retired
connection's failures nor ignores the new one's.

Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
@sonarqubecloud

Copy link
Copy Markdown

@cosmin-staicu
cosmin-staicu merged commit 11db007 into main Oct 22, 2026
11 checks passed
@cosmin-staicu
cosmin-staicu deleted the perf/background-write branch October 22, 2026 08:05
cosmin-staicu added a commit that referenced this pull request Oct 23, 2026
…h cache fixes (#267)

## RemoveManyAsync and SetManyAsync

On `ICache`, `ICache<T>`, `IHashCache` and `IHashCache<T>`, served by
`ISpanKeyCache*` and `ISpanKeyHashCache*`, which gain these members and
`RemoveFieldsAsync` with default bodies, so an implementation built
against 3.0.0 still loads. Any other implementation, a mock included,
falls back to chunked single-key calls. They take an
`IEnumerable<CacheKey>` and return the keys that failed; an empty list
means all succeeded.

- **Any slots.** One command per key, so a cluster never answers
`CROSSSLOT`.
- **Chunks.** `InMemoryRedisCacheOptions.BatchChunkSize` (500) keys per
chunk, `BatchConcurrentChunks` (2) in flight. A chunk's writes or
removes start together, then that chunk's events, so a chunk costs about
two round trips and memory stays with the chunk. A failure never stops
the rest. The caller's cancellation throws: no command is sent after it,
in the chunk or a later one, and what ran stays done.
- **Order per key.** Remove drops the local entry, unlinks, then
announces, even if the unlink failed (as `RemoveAsync` does). Set
writes, then announces; a failed write gets no event. Both take their
place in the key's write order from #266, so a background write of the
key is never overtaken, and every local write is tracked so an earlier
read cannot put an old value back.
- **A key named twice.** Within a chunk the key ends as its writes in
order would leave it: the last write wins, and hash merges combine in
order. Across chunks, which run two at a time, the order is not set.
- **Topic-wide event.** Above the opt-in
`BatchTopicInvalidationThreshold`, one `TopicInvalidated` event replaces
the per-key events. It drops the local tier of every cache sharing the
topic, since events name no cache. A node on an older version ignores
it, so enable it only once every node runs this version. The size is
known through `Cache<T>` and its key strategy too.
- **Background writes.** The calls stay awaited under
`BackgroundDistributedWrite` and wait only for keys with a pending
background write.

The notify doorbell now keeps one `PUBLISH` in flight and coalesces the
rings after it into one more. One `PUBLISH` per event overflowed a
subscriber's `client-output-buffer-limit pubsub` at 100k events, and
Redis closed the subscription. A `PUBLISH` stalled on Redis holds the
rings behind it for at most one second.

`RemoveAsync(CacheKey[])` and the batch `SetAsync` are unchanged: the
batch set is one atomic transaction, which the new path would give up.

## Hash cache

- **`HashReplace` keeps the local hash whole.** A `HashReplace` write
left only the written fields in the local tier, so the writing node lost
the others until the entry expired. It now merges into the held local
hash, as one locked step so two concurrent merges keep both sets of
fields; with none held, it stores nothing and the next read fetches the
whole hash.
- **`GetCacheEntryAsync` in one round trip.** An `EXISTS` before the
read is gone, as the docs already said.
- **`RemoveFieldsAsync`** removes single fields (`HDEL`). Peers drop the
whole local entry through an ordinary remove event, so older nodes need
no new format. The event goes out whenever the delete was sent, since
some chunks may have landed even if it failed.
- **Large hashes.** A write of more than
`RedisCacheOptions.HashFieldBatchSize` (1000) fields goes in bounded
`HSET` chunks. A `KeyReplace` write stays atomic and keeps its TTL
through a same-slot staging key and `RENAME`; a hash with no expiry is
renamed and persisted in one transaction, so it never lands with the
staging key's lifetime; a `HashReplace` merge is not atomic across
chunks (documented). Field reads are split too, and a whole-hash read
pages by `HSCAN` above ten batches on Redis 7+.

## Measured (local Docker)

| | N=1 | N=10 | N=225 |
|---|---|---|---|
| `RemoveManyAsync`, single node | 0.9 ms | 1.1 ms | 10.2 ms |
| `RemoveManyAsync`, cluster | 0.9 ms | 1.35 ms | 10.8 ms |
| `SetManyAsync`, cluster | 0.9 ms | 1.3 ms | 11.3 ms |
| `RemoveAsync` in a loop | 0.9 ms | 8.9 ms | 217 ms |

- 100k keys: about 23–30k keys/s for both calls on single node and
cluster, no failures.
- A 1M-field hash on a single node: writes at 122k fields/s; the longest
server command in `SLOWLOG` was about 3.5 ms.

## Tests

- Unit: chunk boundaries, a failing middle chunk, cancellation between
and within chunks, the in-flight bound, pending background writes, the
topic-wide switch and its failure, the size through `Cache<T>`'s key
strategy, a key named twice, an older node's decode, the fallback
without the capability; the `HashReplace` merge, two merges at once;
`RemoveFieldsAsync`; large hashes; the doorbell coalescing and its
one-second bound.
- Redis integration on single node and cluster: 130 keys across slots,
the topic-wide drop on a peer, hash set and remove, about two round
trips for 300 keys under delayed writes, 20k keys in chunks, the merge,
`RemoveFieldsAsync`, 5000-field hashes, a chunked replace of a hash with
no expiry.
- Three cluster-only integration failures also happen on `main` against
a cluster; they are standalone-only tests.

## Follow-up

- A chunked hash write starts every chunk at once, each a copy of its
fields: a 1M-field hash at `HashFieldBatchSize` 1000 puts about 1000
commands on the wire before any is awaited and doubles its payload in
memory. A window of chunks in flight, as `BatchConcurrentChunks` gives
the batch calls, would bound both; it needs its own benchmark, so it is
left for a separate change.

Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-not-required Maintainer reviewed: no CLA required for this contribution

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants