Skip to content

feat(umbp): Redis/RESP master metadata store backend#468

Open
isytwu wants to merge 37 commits into
refactor/umbp-metadata-store-rebasefrom
feat/umbp-redis-metadata-backend-rebase
Open

feat(umbp): Redis/RESP master metadata store backend#468
isytwu wants to merge 37 commits into
refactor/umbp-metadata-store-rebasefrom
feat/umbp-redis-metadata-backend-rebase

Conversation

@isytwu

@isytwu isytwu commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a Redis/RESP-based master metadata store backend for UMBP, behind a client-agnostic seam, supporting single-node, Dragonfly, and Redis Cluster deployments, plus horizontal scaling via keyspace sharding and multi-endpoint fan-out.

Core backend

  • RESP/Redis master metadata store (Phase 1)
  • Fail-fast readiness probe + mode validation
  • Stop master crashing when the metadata store throws
  • Per-op store latency metric (mori_umbp_store_op_latency_seconds)

Horizontal scaling

  • Shard Redis block keyspace for RouteGet/Exists (opt-in, default 1)
  • Multi-endpoint Redis fan-out; concurrent read fan-out via worker pool
  • Tolerate a down shard instance on both read and write paths
  • Fold RouteGet lease/access bump into a single HSET

Redis Cluster mode

  • Extract IRespClient seam so the store is client-agnostic
  • redis-plus-plus cluster client behind the seam; wire Cluster mode into store + factory
  • Balanced per-master shard placement; auto-size block shards to ~2x master count
  • EVALSHA in the cluster client

Build / infra

  • Vendor redis-plus-plus (1.3.15)
  • Run RESP stores as independent containers (single/dragonfly/cluster)
  • apt-free local RESP launcher

Tests

  • Store conformance suite against Redis / Dragonfly / real Redis Cluster
  • Cluster slot math + balanced-placement conformance
  • Full-link proxy bench supports cluster / multi-endpoint backends
  • Multi-process external-master mode for perf verification

Misc

  • fix(rdma): capture errno immediately on ibverbs endpoint creation failure

Test plan

  • Conformance suite passes on single Redis
  • Conformance suite passes on Dragonfly
  • Conformance suite passes on a real Redis Cluster
  • Perf bench (multi-process external-master) sanity-checked

Perf

Two layers: store-direct microbench isolates the backend (cleanest); 8-process proxy is the full-link (master + Router/gRPC), ≈ SGLang's per-machine shape. batch=32, keys=50000, single 224-core host, loopback, glibc redis 7.2.5 / dragonfly v1.23.2. routeget=read hot path (BatchLookupBlockForRouteGet, incl. lease write), heartbeat=write hot path (ApplyHeartbeat). ops/s = batch32 calls/s (higher better). Best config per backend.

Store-direct microbench (bench_umbp_master_metadata_store, no gRPC/RDMA)

RouteGet (read)

backend best config server footprint ops/s p50 / p99 (µs)
in-memory index_shards=1 in-process 5,194,382 11 / 27
dragonfly, single instance proactor_threads=32, block_shards=64, pool=256 1 process / 32 threads 43,409 5,354 / 14,648
redis multi-endpoint 8 independent redis instances 8 processes 26,028 2,444 / 3,335
redis cluster 3 masters + 3 replicas, balanced 3 master processes 10,798 5,955 / 8,988

Heartbeat (write)

backend ops/s p50 / p99 (µs)
in-memory 142,866 329 / 1,677
redis multi-endpoint (8 instances) 18,035 3,302 / 7,454
redis cluster (3 masters + 3 replicas) 7,446 4,229 / 7,054
dragonfly, single instance 507 62,939 / 66,970
Reproduce (build + backends + microbench)
CTR=yutong-umbp-0706; DEV=/shared_inference/yutongwu/store/dev
docker exec $CTR bash -lc "cd $DEV/mori && USE_REDIS_BACKEND=ON BUILD_UMBP=ON BUILD_TESTS=ON pip3 install -e . --no-build-isolation -v"
docker exec $CTR bash -lc "cd $DEV && bash df_lua_opt/setup_cmp.sh && DF_THREADS=8 bash df_lua_opt/backends.sh up_opt"
M8=tcp://127.0.0.1:6390,tcp://127.0.0.1:6391,tcp://127.0.0.1:6392,tcp://127.0.0.1:6393,tcp://127.0.0.1:6394,tcp://127.0.0.1:6395,tcp://127.0.0.1:6396,tcp://127.0.0.1:6397
CL=tcp://127.0.0.1:7000,tcp://127.0.0.1:7001,tcp://127.0.0.1:7002
run() { docker exec $CTR bash -lc "cd $DEV/perf_cmp && bash bench1.sh $*"; }  # add WL=heartbeat for writes
run inmemory  64 UMBP_METADATA_BACKEND=inmemory UMBP_MASTER_INDEX_SHARDS=1
run multi-8   64 UMBP_METADATA_BACKEND=redis UMBP_REDIS_SHARD_URIS=$M8 UMBP_REDIS_POOL_SIZE=128
run cluster-3 64 UMBP_METADATA_BACKEND=redis UMBP_REDIS_CLUSTER=1 UMBP_REDIS_URI=$CL UMBP_REDIS_POOL_SIZE=128
docker exec $CTR bash -lc "cd $DEV/perf_cmp && bash df_sweep.sh"   # dragonfly, sweeps P8/P16/P32 -> take P32 peak

Full-link, 8-process proxy (umbp_master + Router/gRPC; GETMODE=exists, no RDMA)

8 client processes (each own conn pool + gRPC channel + heartbeat) hit one master. Best config per backend; cells = get QPS / BatchLookup p50 (ms):

backend best config 8 clients 32 clients 64 clients
in-memory index_shards=1 33,830 / 0.50 50,333 / 0.50 71,420 / 0.51
redis multi-endpoint 8 instances, pool=128 7,517 / 0.50 3,992 / 1.79 2,363 / 13.8
redis cluster 3 masters, balanced, pool=128 7,310 / 0.51 3,490 / 2.15 1,972 / 12.8
dragonfly, single instance proactor=32, block_shards=64, pool=256 732 / 3.04 668 / 17.0 393 / 66.7

The full-link gap is smaller than store-direct: each RPC adds a backend-independent ~0.5ms fixed cost (gRPC + single-master CPU + net). redis throughput peaks at low concurrency then the single master saturates (latency grows, QPS falls), while in-memory keeps scaling. dragonfly stays low regardless of proactor count — the mixed workload's heartbeat writes take a global store-wide lock. (route_get+RDMA, GETMODE=both, is RDMA-fetch-bound ~3–6k for all backends and flaky here, so BatchLookup is the stable read signal.)

Reproduce (full-link proxy)
# backends already up (see above); ensure dragonfly at proactor_threads=32 for its best config:
docker exec $CTR bash -lc "pkill -9 -f dragonfly-x86_64; sleep 2; cd /tmp/umbp_redis_bench && setsid ./dragonfly-x86_64 --port 6380 --proactor_threads=32 --dir /tmp/umbp_redis_bench >/tmp/df.log 2>&1 &"
docker exec $CTR bash -lc "cd $DEV/perf_cmp && H=127.0.0.1 GETMODE=exists CLIENTS_SWEEP='1 4 8' bash proxy_cmp.sh"

Takeaways

  • multi-endpoint = best throughput/core, solid read (26k) + write (18k), no HA. Good default.
  • dragonfly (1 inst) = single-port read champion (scales with proactor_threads, 43k) but write collapses to ~0.5k (global store-wide lock per write script; also drags the full-link path via heartbeats). Read-heavy / light-write only.
  • cluster (3 masters) = ~11k read / ~7k write, buys cross-host auto-failover HA.
  • in-memory = ceiling (5M read / 143k write), no stateless master / HA / persistence.

isytwu and others added 29 commits July 13, 2026 07:24
Adds a RESP-protocol-compatible IMasterMetadataStore backend so the master
can run stateless with all metadata in an external store (Redis / Dragonfly /
Valkey), selected at startup via UMBP_METADATA_BACKEND / UMBP_REDIS_URI. The
one production wiring change is MasterServer's store construction, which now
goes through MakeMasterMetadataStore(); router / eviction / reaper stay
backend-agnostic behind IMasterMetadataStore&.

Phase 1 vertical slice:
- RespClient seam over hiredis (connection pool, EVALSHA + NOSCRIPT fallback,
  owned RespValue) isolating the client library from the store.
- KeySchema (single deployment hash tag) + Lua scripts for the atomic hot-path
  mutations (register / heartbeat / route_get_batch / exists_batch / list_alive
  / unregister / expire).
- RedisMasterMetadataStore implementing the six hot-path methods with semantics
  matching the in-memory backend (seq-CAS heartbeat, lease/access bump,
  full-sync replace), plus the methods the running master needs; external-KV
  and eviction candidate enumeration are Phase 2 stubs.
- USE_REDIS_BACKEND CMake option (default OFF) wiring hiredis; setup.py
  passthrough. Default connection pool capped at 32 so a big host's CPU count
  cannot flood single-threaded Redis.
- Conformance test (skips without a live store; passes on Redis + Dragonfly),
  a store-level microbench, docker-compose + local launcher for the backends,
  and the Phase 1 go/no-go benchmark report.

Design: src/umbp/doc/design-redis-metadata-store.md
The RESP/Redis backend signals transport failures (and Phase-2-unimplemented
methods) by throwing. Those exceptions escaped the gRPC handlers and the
background loops and hit std::terminate, taking down the whole master on any
Redis hiccup — observed under load as "terminate called after throwing an
instance of mori::umbp::redis::RespError".

- Wrap every gRPC handler body in GuardStore(), which converts any exception
  escaping the metadata store into grpc::UNAVAILABLE (the "degrade, don't
  fabricate" contract in design-redis-metadata-store.md §7). The in-memory
  backend never throws, so this is a no-op there.
- Guard the reaper, hit-index GC, and eviction background loops so a transient
  store error is logged and retried on the next tick instead of killing the
  thread.

Verified: with Redis killed mid-run the master now logs the error and keeps
serving (returns UNAVAILABLE) instead of aborting; a normal run and the
conformance suite are unaffected.
…lure

CreateRdmaEndpoint read errno for logging/throwing only after intervening
calls (a mutex lock/unlock and the spdlog sink). spdlog's console sink calls
isatty(stderr), which sets errno=ENOTTY when stderr is not a terminal, so the
logged/thrown error masked the real failure code — e.g. an ibv_create_qp
rejection that is actually EINVAL (requested QP capacity, max_send_wr x the
per-WQE size derived from sge + inline, exceeding the device's per-QP
work-queue budget) was reported as "Inappropriate ioctl for device".

Capture errno into a local right after each failing ibv_create_* call
(comp channel, CQ, QP) and use it for both the log and the thrown message.
run_local_backends.sh: auto-fall-back for the Redis binary
(PATH -> prebuilt in RUN_DIR -> apt-get -> build the official source with
make) so the launcher works on images where the apt mirror is blocked but
github over HTTPS is reachable, and resolve redis-server/redis-cli by
absolute path instead of assuming they are on PATH.
route_get_batch already HGETALLs each block hash, so _acnt is in hand; write
_lease/_lacc/_acnt in a single HSET instead of HSET + a separate HINCRBY. The
single-slot Redis server is redis.call()-count bound, so dropping the per-
touched-key writes from 2 to 1 cuts serial script cost ~11% (t1 b32 routeget:
290->260us p50, 3296->3657 ops/s) with identical semantics (_acnt still ends at
old+1). Adds redis-backend-perf.md with the baseline + attribution runs (AOF is
negligible; the bottleneck is serial Lua op-count) and this commit's before/after.

Conformance: Redis + Dragonfly 10/10.
… verification

Add --external-master / --node-id-prefix / --node-address to
bench_kvevent_master_pressure so N OS processes (on one or many hosts) can
share a single standalone umbp_master instead of each spawning its own
in-process master. This reproduces the real "N-processes-per-machine +
multi-machine" load on the master metadata backend (Redis) that the
in-process kvevent bench (1 process, N clients) and the store microbench
(1 process, N threads) cannot model.

Add run_mp_redis_bench.sh (multi-process driver: starts one redis-backed
umbp_master, fans out PROCS x CLIENTS client processes over the full
Router/gRPC path, scrapes results) and parse_master_hist.py (per-RPC
p50/p95/p99 from the master Prometheus histogram). Together they are the
repeatable harness for judging Redis-backend optimizations: scale until the
single-slot ceiling shows, then compare master BatchRouteGet/RoutePut/
Heartbeat latency + redis evalsha usec/call before vs after.
…efault 1)

Spread block-location keys across N hash-tag shards ({umbp:<ns>:b<shard>})
chosen by a stable FNV-1a hash of the user key, and fan the batched read hot
path (BatchLookupBlockForRouteGet / BatchExistsBlock) out to one single-slot
EVAL per shard via a new RespClient::EvalPipeline (one round trip; NOSCRIPT
retries only the failed calls so a lease/access bump never double-applies).

Write path stays a single atomic script: heartbeat / unregister / expire now
receive the fully composed block key from C++ and store it as the reverse-index
member, so Lua needs no shard math. seq-CAS, SEQ_GAP, and full_sync-atomic
semantics are unchanged.

UMBP_REDIS_BLOCK_SHARDS (Config::block_shards) controls N; default 1 keeps the
legacy single-tag layout with byte-identical keys and whole-batch-atomic reads,
so existing behavior is unchanged (verified: 32 tests pass on Redis + Dragonfly
with shards={1,16}; A/B shows shards=1 == base performance). N>1 relaxes a batch
read to per-key atomicity (matching the in-memory backend; interface only
promises per-key semantics).

Note: on a single instance the split gives no throughput win (single-threaded
Redis just runs more scripts; regresses under saturation) - the benefit needs
genuinely parallel backends (multiple Redis instances / cluster / dedicated-core
Dragonfly). The cross-slot write scripts are single-instance only; splitting
them per-shard for Redis Cluster + multi-endpoint fan-out is the next step.
Add UMBP_REDIS_SHARD_URIS: a comma-separated list of Redis instances, one per
block shard, so block-lookup scripts run on independent server processes. This
is the only way past a single instance's single-thread throughput ceiling
(measured ~2.9x RouteGet at 4 instances on a dedicated host: M1 t16 ~5.2k -> M4
t16 ~15k ops/s; M1 stays flat at the single-slot ceiling at any concurrency).

clients_[0] is the control instance (client records, alive set, peer view,
extkv + block shard 0); shard s lives on clients_[s] with its own co-located
per-node reverse index. Since no Lua script spans instances, the cross-store
writes are split into a control step (seq-CAS + record + alive/peers) plus
per-shard block steps (apply_block_events / wipe_node_blocks). Block ADD/REMOVE,
full_sync clear+replay, and node-wipe are idempotent, so a partial failure is
retried and healed by the peer's next SEQ_GAP -> full_sync; per-key/per-shard
atomicity is preserved (same posture as the in-memory backend). Reads group keys
by shard and issue one EvalPipeline per instance.

Single-endpoint mode (no UMBP_REDIS_SHARD_URIS) is unchanged: the original
single atomic scripts run, M==1 stays byte-identical to before. Fan-out is
sequential per instance (throughput scales via concurrent RouteGets); a worker
pool to also cut single-request latency on high-RTT remote stores is a noted
follow-up (naive per-call std::async threads cost more churn than they save).

Tests: the store suite now also runs a 3-endpoint mode (all endpoints on one
physical Redis, disjoint tags) covering the split writes, per-shard reverse
index, cross-shard full_sync/unregister/expire, and fan-out reads — 46 tests
pass on Redis and Dragonfly.
Issue each Redis instance's read pipeline concurrently through a small persistent
worker pool (redis/thread_pool.h) instead of sequentially, so a multi-endpoint
BatchLookup/BatchExists costs one round trip rather than N. Measured ~3.3x
RouteGet throughput at 4 instances on a dedicated host (t8 5.2k -> 17.3k ops/s,
p50 1527us -> 462us) — better than sequential fan-out (13k) and far better than
per-call std::async (8k, which is dominated by thread-creation churn). The win
is larger on high-RTT remote stores where the N serial round trips dominate.

The pool is persistent (no per-call churn), deadlock-free for this use (workers
only run blocking Redis calls; only the caller waits on its futures), sized
min(64, endpoints*8), and created only in multi-endpoint mode. Single-endpoint
mode uses no pool and runs the one pipeline inline — byte-for-byte the prior
path. Replies are scattered on the calling thread so the per-key decode stays
lock-free. 46 store tests still pass on Redis and Dragonfly.
In multi-endpoint mode, a single Redis shard instance being down or erroring no
longer fails the whole batch read: RunShardedRead captures that instance's
transport/script failure, leaves its keys at the caller's default (a miss —
empty locations / exists=false), logs a WARN, and still serves keys on the
healthy shards. This keeps RouteGet/RoutePut-dedup working when one of several
block instances is unavailable, instead of erroring the entire RPC.

Single-endpoint mode is unchanged: with nothing to fall back to, a failure
propagates so a total outage surfaces rather than masquerading as all-misses.
The write path (heartbeat / unregister / expire) stays strict on purpose — a
down shard makes block-apply throw so the peer retries instead of silently
dropping locations; updates resume (gap heals via full_sync) when the shard
returns.

Test: RedisFaultToleranceTest points shard 1 at a closed port, seeds shard 0,
and asserts a batch spanning both resolves the live key and reads the dead
shard's key as a miss without throwing. 47 store tests pass.
…econds)

Add an optional observability hook so the Redis backend exports the latency of
each IMasterMetadataStore operation as a labeled histogram
mori_umbp_store_op_latency_seconds{op,backend="redis"}, making backend
round-trip cost (RTT + server-side script time) visible per store method on the
master's Prometheus endpoint — previously only inferable indirectly from redis
INFO commandstats.

- MetricsServer: add a labeled per-observation observe() overload (reuses the
  labeled-histogram storage that observeAggregated already uses).
- IMasterMetadataStore: add a default-no-op SetMetricsSink() hook (in-memory
  ignores it); RedisMasterMetadataStore stores the sink and a ScopedStoreOp RAII
  timer records latency on the hot methods (RegisterClient, ApplyHeartbeat,
  Unregister/Expire, LookupBlock, BatchLookup/BatchExists, ListAlive,
  GetAlivePeerView). master_server wires the sink after the metrics server
  starts. Null sink (e.g. the standalone microbench) = zero overhead.

Test: RedisStoreMetricsTest drives the store with a live MetricsServer and
scrapes /metrics, asserting the histogram appears with op/backend labels
(GPU-free; the kvevent bench that drives the master's RPC path needs a GPU).
48 store tests pass.
A down shard instance no longer aborts the write path. The control step (seq-CAS
+ record + nodes:alive/alive_peers) runs first, so the node is already marked
ALIVE and the reaper won't expire it. If a per-shard block step then fails,
ApplyHeartbeat returns SEQ_GAP instead of throwing — the master already converts
SEQ_GAP into a full_sync request, so the peer re-ships a full snapshot and the
node self-heals once the shard is back (block ADD/REMOVE and full_sync
clear+replay are idempotent, so the replay is safe). Previously the exception
propagated as a failed heartbeat RPC.

UnregisterClient / ExpireStaleClients now wipe best-effort per shard: a down
shard is skipped (its lingering locations point at a gone node and are filtered
out of reads by GetAlivePeerView) and cleaned when it returns, instead of
failing the whole cleanup.

Single-endpoint mode is unchanged (one atomic script; failures still surface).
Test: RedisWriteFaultToleranceTest points shard 1 at a closed port and asserts a
heartbeat spanning both shards returns SEQ_GAP (not an exception), keeps the node
ALIVE, and still applies the live shard's event. 49 store tests pass.
bench_umbp_kvevent_master_pressure set cfg.io_engine.host="0.0.0.0", so the
published EngineDesc resolved to loopback — fine when every peer is on one host,
but cross-machine peers then tried to reach the RDMA engine at 127.0.0.1 and got
"ProtocolError: Connection reset by peer", making multi-machine --external-master
runs crash. Advertise o.node_address instead (already the routable IP the peer
registers with the master; default 127.0.0.1 keeps single-host runs unchanged).

Verified: with this, 148+032 client machines drive a master+Redis on 039 over
RDMA with no crash. The RDMA fabric itself was fine (ib_write_bw 039<->148 =
28.6 Gb/s over RoCEv2); the blocker was purely the advertised engine address.
…ent base

Add sewenew/redis-plus-plus as a 3rdparty submodule and build it from source
(static, PIC, C++17, tests off) behind USE_REDIS_BACKEND, using the system
hiredis. This is the client library the Redis backend will standardize on for
Redis Cluster failover + Sentinel + pooling (true-HA direction); building it
from source keeps any build image working without an extra system package.

The store code does not depend on redis++ yet — this only lands and de-risks the
dependency (toolchain, -Werror, link against system hiredis) ahead of the seam
migration. A skippable link/build smoke test (test_redispp_smoke) proves it
compiles, links, and pings a reachable store. All 49 existing Redis-backend
tests still pass.
…fly/cluster)

Make the metadata store a sidecar the app container talks to over host
networking instead of a redis-server built from source inside the app
container. docker-compose.yml gains compose profiles for single Redis,
Dragonfly, and a 3-master+3-replica Redis Cluster (with an idempotent
cluster-init one-shot). A new store.sh wraps up/down/status/seeds and prints the
exact UMBP_REDIS_* env to export; it auto-falls-back to run_local_backends.sh
for single/dragonfly when docker is unavailable.

run_local_backends.sh is demoted to the documented no-docker fallback. Verified
on a host with docker: the cluster profile forms (cluster_state:ok, 6 nodes, 3
masters) and the redis++ cluster smoke test connects to it.
… backend

The master used to build the Redis store and start "healthy" even when the store
was unreachable or the config was contradictory, then return UNAVAILABLE on
every RPC. The factory now:

- Pings every configured endpoint at startup. UMBP_REDIS_REQUIRED (default true)
  turns an unreachable store into a clear immediate failure; set 0 to start
  degraded and rely on runtime reconnect.
- Recognizes UMBP_REDIS_CLUSTER and rejects contradictory modes: cluster is
  mutually exclusive with UMBP_REDIS_SHARD_URIS, and (until the store's cluster
  path lands) is refused with a clear "not supported yet" message instead of
  silently running single-endpoint.

Validated on umbp_master: cluster flag and dead-store default abort with a clear
message; UMBP_REDIS_REQUIRED=0 starts degraded. All 49 backend tests still pass.
…iners)

Update the design doc to reflect the productization decisions: true-HA target,
redis-plus-plus as the client library (vendored submodule + source build on the
system hiredis, §9), the UMBP_REDIS_CLUSTER (reserved, validated) and new
UMBP_REDIS_REQUIRED readiness knobs (§10), and independent-container store
deployment via tools/redis/store.sh. §13 flips from open questions to locked
decisions.
Split RespValue/RespError and a new IRespClient interface (Command/Eval/
EvalPipeline/Ping) into resp_value.h; RespClient now implements IRespClient, and
the store holds vector<unique_ptr<IRespClient>> instead of the concrete client.
No behavior change (pure refactor) — this lets a redis-plus-plus cluster client
implement the same seam for Redis Cluster mode while the hiredis client keeps
driving single / multi-endpoint. All 49 backend tests still pass.
… seam

RespClusterClient implements IRespClient on sw::redis::RedisCluster: it routes
each command (by args[1]) and script (by KEYS[0]) to the node owning that hash
tag's slot and delegates MOVED/ASK redirection, slot-map refresh, and
master-failover reconnect to redis-plus-plus's redirection loop. Server errors
are surfaced as Error RespValues (error-as-value preserved via a catch of
ReplyError); transport failures throw RespError. EvalPipeline fans its
single-slot groups out concurrently through a small worker pool, one
redirection-aware EVAL each. Scripts use EVAL (Redis caches by SHA server-side)
to avoid a per-node EVALSHA cache across failover/resharding.

redis++_static is now linked into umbp_common; the store does not construct the
cluster client yet (next: mode wiring). Compiles clean, all 49 tests still pass.
Add a cluster deployment mode driven by UMBP_REDIS_CLUSTER=1: the store builds a
single RespClusterClient and uses the split control-script + per-shard-block
write path (gated on a new split_writes() = mode != single, since cluster's
control and block keys live in different slots and a single atomic script would
CROSSSLOT). Block keys spread across the cluster's nodes via block_shards tags
(factory default 16 in cluster mode). The four scripts that previously ran with
empty KEYS (list_alive / expire_control / apply_block_events / wipe_node_blocks)
now pass a same-slot routing key as KEYS[1] so the cluster client can route them
and Redis fixes the slot; the Lua is unchanged and single/multi-endpoint stay
byte-for-byte the same. Factory enables cluster (mutually exclusive with
UMBP_REDIS_SHARD_URIS) with seeds from the UMBP_REDIS_URI comma list.

Verified against a real 3-master+3-replica cluster: the full store conformance
suite passes in a new cluster mode (no CROSSSLOT), alongside single / multi.
Add a "cluster" StoreMode to the parameterized RedisStoreTest: when
UMBP_REDIS_CLUSTER_SEEDS is set it drives the store in cluster mode (16 block
shard tags) and runs every existing assertion (register/heartbeat/routeget/
exists/full-sync/unregister/expire, incl. the cross-shard cases) against the
cluster, so a stray cross-slot script surfaces as a CROSSSLOT failure. The probe
is now an IRespClient (RespClusterClient for cluster, RespClient otherwise).
Skips cleanly when no cluster is configured, so single-node CI is unaffected.
Verified: 63 tests pass (49 single/multi + 14 cluster).
Update §9 (two IRespClient impls: hiredis RespClient for single/multi,
redis-plus-plus RespClusterClient for cluster) and §10 (UMBP_REDIS_CLUSTER now
selects a working RedisCluster client; UMBP_REDIS_BLOCK_SHARDS defaults to 16 in
cluster mode). Verified against a real 3-master+3-replica cluster: conformance
suite + failover + live reshard.
The cluster client now EVALSHAs (SHA loaded once and cached, with a SCRIPT LOAD
+ retry on NOSCRIPT for a promoted replica / resharded node) instead of shipping
the ~1-2KB Lua body on every hot-path call, matching the hiredis client.

A/B at a fixed slot mapping (fixed namespace, shards=6, t16, 3 runs each) shows a
small but consistent win: routeget ~4478 -> ~4670 ops/s (+~4%), p50 ~3700 ->
~3557us. The earlier random-namespace comparison masked this under slot-mapping
bimodality. The gain is larger on higher-RTT links where the body bytes dominate.
Cluster conformance suite still passes.
In cluster mode, if UMBP_REDIS_BLOCK_SHARDS is not set, the factory now discovers
the master count (RespClusterClient::DiscoverMasterCount via CLUSTER SLOTS) and
sets block_shards = 2 x masters, falling back to 16 only if discovery fails. This
replaces the fixed default of 16, which over-split each RouteGet batch into many
tiny per-shard EVALs (throughput ~= single node). ~2x spreads a batch across the
cluster's nodes while keeping per-batch scripts few; measured best in the shard
sweep. An explicit UMBP_REDIS_BLOCK_SHARDS still wins.

Verified: auto resolves to 6 on a 3-master cluster (matches explicit shards=6);
cluster conformance suite still passes.
Cluster throughput was ~single-node and highly namespace-dependent because the
formulaic {umbp:<ns>:bS} tags hash to arbitrary slots — measured (per-node CPU +
commandstats) to pile most block shards onto one master (saturated) while others
sat idle. It was NOT redis++/client overhead: the busy node was CPU-bound and a
manually-balanced namespace already hit ~85% of multi-endpoint.

So place shards deterministically: at startup (cluster mode, when
UMBP_REDIS_BLOCK_SHARDS is unset) read CLUSTER SLOTS and pick one block-shard
hash tag per master whose CRC16 slot that node owns, so a RouteGet batch spreads
one EVALSHA per node.

- cluster_slots.h: CRC16 (bitwise) + SlotOfKey (hash-tag aware) + FindTagForRanges.
- RespClusterClient::DiscoverMasterSlotRanges: parse CLUSTER SLOTS into per-master
  ranges (stable order).
- KeySchema gains an explicit-block-tags ctor; store BuildKeySchema uses it when
  the factory supplies Config::cluster_block_tags, else the formulaic tags.
- Factory computes the balanced tags (one per master), falling back to formulaic
  shards if discovery/search fails.

Measured on a 3-master glibc cluster: ~4.6k (bimodal 3.5-6.9k) -> ~11.7k stable
across random namespaces (~86% of multi-endpoint 13.5k), all masters evenly
saturated (~105k evalsha each). Correctness is independent of the local CRC16
(redis++ still routes by the real slot); only balance depends on it.

Single-endpoint and multi-endpoint (UMBP_REDIS_SHARD_URIS / hicache) paths are
untouched: cfg.cluster is false there, BuildKeySchema returns the identical
formulaic KeySchema, and the factory's cluster branch is skipped. All 63 backend
tests pass (single + Dragonfly + endpoints3 + cluster).
- ClusterSlotsTest (pure, always runs): CRC16/SlotOfKey against reference
  CLUSTER KEYSLOT values (foo=12182, bar=5061, 123456789=12739 the CRC16/XMODEM
  check value), the hash-tag rule (block key routes by its shard tag; empty
  braces are not a tag), and FindTagForRanges landing in / distinct across
  disjoint ranges.
- A "clusterbalanced" StoreMode runs the full store conformance suite against a
  real cluster using the balanced per-master tags (same DiscoverMasterSlotRanges
  + FindTagForRanges the factory uses), so the balanced path — previously only
  bench-verified — now has automated coverage. MetaField rebuilds the block key
  from those tags. Skips unless UMBP_REDIS_CLUSTER_SEEDS is set.

81 tests pass (49 single/multi + 14 formulaic-cluster + 14 balanced-cluster + 4
slot unit).
…ackends

run_mp_redis_bench.sh only drove a single Redis URI. Add REDIS_CLUSTER=1 (pass
UMBP_REDIS_CLUSTER=1 + the comma seed list as UMBP_REDIS_URI) and SHARD_URIS=...
(multi-endpoint) so the standalone-master proxy harness can benchmark all three
deployments through the full Router/gRPC(+RDMA) path. Cluster/multi rely on the
per-run unique namespace for isolation instead of a single-node FLUSHALL.

Measured single-host, 32 clients, gap0, get=both: BatchRouteGet p50 single
6.6ms / cluster(balanced) 4.9ms / multi-3 2.8ms.
Single-endpoint block sharding (UMBP_REDIS_BLOCK_SHARDS>1) gave no speedup
on Dragonfly's multi-threaded architecture. Root cause: launching Dragonfly
with --default_lua_flags=allow-undeclared-keys forces EVERY Lua script into
global-transaction mode (a store-wide lock across all proactor threads), so
even the read hot path (route_get_batch / exists_batch) — which already
declares every key via KEYS[] — was serialized, erasing the sharding win.
(Confirmed by Dragonfly docs + a baseline: ~1.2k ops/s flat across shards
and client threads, the signature of a global lock.)

Fix (zero Redis impact): the two read hot-path scripts stay untouched (they
declare all keys, so Dragonfly runs them per-shard in lock-ahead mode,
parallel across threads). The 10 write/control scripts that derive auxiliary
same-slot keys from the hash tag each carry a first-line
"--!df flags=allow-undeclared-keys" directive so ONLY they run global on
Dragonfly. Redis honours only a "#!" shebang, so that line is a plain Lua
comment there — single-node and Cluster behaviour is byte-for-byte unchanged
(the read scripts are literally unmodified). Dragonfly is launched WITHOUT the
global flag (docker-compose.yml / run_local_backends.sh) so the per-script
directives take effect.

Scope: this optimizes the READ path only (routeget is the hicache hot path).
Write/control scripts still run global via --!df, which is acceptable
(heartbeat/register/expire are low-frequency and off the RouteGet path). A
follow-up to parallelize writes (declare keys -> lock-ahead) is tracked in the
handoff doc.

Measured (039, single Dragonfly v1.23.2, routeget batch32 keys50000):
  - P8:  ~1.2k -> ~13.3k ops/s peak (t32, shards=8)
  - P16: ~22k, P32: ~43k ops/s peak (scales with --proactor_threads)
  - single-node Redis unchanged (~4k, sharding still a no-op as expected)
  - test_redis_master_metadata_store: 53 PASSED / 0 FAILED on Redis AND
    Dragonfly (cluster cases skipped without seeds)
…gonfly

- resp_cluster_client: extract SeedConnectionOptions() so ConnectCluster() and
  DiscoverMasterSlotRanges() share the one seed-URI -> ConnectionOptions mapping
  (host/port + password + timeouts). Pure refactor, no behavior change.

- factory: when a single-endpoint backend reports itself as Dragonfly (best-effort
  INFO server probe for "dragonfly_version") AND UMBP_REDIS_BLOCK_SHARDS is left at
  the default 1, log a WARN recommending it be set to ~the --proactor_threads count
  so the RouteGet read hot path parallelizes across Dragonfly's threads. It does
  NOT silently change the default: block_shards is fixed for a deployment's
  lifetime, so auto-bumping it would strand existing block keys. Probe failure is
  ignored (the readiness Ping still surfaces a real outage).

No functional change to the Redis single-node / Cluster paths; correctness
unchanged (53/53 on Redis + Dragonfly, 32/32 cluster cases).
@isytwu isytwu self-assigned this Jul 13, 2026
isytwu and others added 8 commits July 13, 2026 15:47
…ailures, retry stale reads

- SHA cache: key by the Lua script object address instead of its ~1.5KB text, so
  the read hot path (route_get_batch / exists_batch) no longer rehashes and
  compares the whole script body under the cache mutex on every call. Scripts are
  now stable `inline const std::string` objects. Applies to both the single-node
  and cluster RESP clients; behaviour is unchanged.

- Cluster reads: a single down or erroring node now degrades its keys to misses
  instead of failing the whole RouteGet/Exists batch, matching multi-endpoint
  mode. The tolerate-failures decision is passed in explicitly (a cluster is one
  client, so it could not be inferred from the client count).

- Stale connections: RespClient::Command retries once on a fresh connection after
  a transport failure (a pooled socket dropped by the server or a load balancer).
  Read-only path only; scripted writes are never retried.

- Metrics: optional cold-path counters/histograms (connection-pool waits,
  transport errors, NOSCRIPT reloads, degraded-shard misses), gated on a metrics
  sink and emitted only on rare paths, so the success path is unaffected.

- Remove unused DiscoverMasterCount and num_endpoints().

81 tests pass (single/multi and cluster/clusterbalanced against a real cluster);
store-microbench shows no regression across single, multi-endpoint, cluster, and
Dragonfly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two RESP client implementations (RespClient on hiredis, RespClusterClient on redis-plus-plus) had duplicated the EVALSHA SHA cache, the EVALSHA argv layout, and the const-char*/length splitting. A change to any of them (keying the SHA cache by script identity, or adding cold-path metrics) had to be made twice.

Extract three header-only helpers used by both: ScriptCache (identity-keyed SHA cache: GetOrLoad + Invalidate), ToArgv (split a vector<string> into the parallel argv/len arrays hiredis wants), and BuildEvalshaArgv (assemble the EVALSHA argv).

Pure refactor, no behaviour change. 81 tests pass (single/multi and cluster/clusterbalanced against a real cluster).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The batch bucketing + fan-out + fault-tolerance + reply-scatter logic (the trickiest, most order-sensitive part of the backend) lived in the store .cpp anonymous namespace, so it could only be exercised via the live integration test, which skips when no Redis is reachable.

Move ShardedBatch / GroupKeysByShard / RunShardedRead into redis/sharded_read.h (no behaviour change) and add test_sharded_read: deterministic unit tests against a fake IRespClient covering reply scatter to caller order, multi-instance fan-out, and the tolerate-shard-failures degrade-to-miss vs propagate behaviour. These run without a backend.

8 new unit tests pass; the 81 integration tests still pass (single/multi and cluster/clusterbalanced).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…, dragonfly-cluster

Document four operational caveats surfaced while reviewing the backend: (1) the store is rebuildable soft state (projection of heartbeats), so disk persistence is optional and separate from replica HA; (2) route_get_batch makes every read a write, so synchronous persistence (AOF appendfsync=always) must stay off; (3) the control plane (clients_[0] / the control-tag node) does not scale horizontally; (4) Dragonfly-in-cluster underperformance is largely a client limitation (no cross-slot pipelining), not purely topological.

Docs only; no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l tag

Replaces the Phase-1 `throw unimplemented` stubs for the external-KV
read/write family, its hit-count accounting, and hit GC with real
single-slot Lua on the control tag, so a real hicache PD
(kv_events_subscriber=true) can run on the Redis backend.

- extkv/hit/hit-index all hash under the control tag {umbp:<ns>}, so
  RegisterExternalKvIfAlive / MatchExternalKv / Unregister* /
  GetExternalKvHitCounts / GarbageCollectHits are each one keyed Lua on
  the control instance in every mode (single / multi-endpoint control
  instance / Redis Cluster control slot).
- Tier-set is a bitmask int per node (bit == 1<<TierType), computed with
  plain arithmetic in Lua (no `bit`/bitop library) so the same script
  runs on Redis / Dragonfly / Valkey.
- RegisterExternalKvIfAlive does the alive-check + write in one atomic
  Lua (register-if-alive TOCTOU); MatchExternalKv's count_as_hit branch
  increments hit:<hash>.c and stamps ls=now once per unique matched hash.
- GarbageCollectHits walks a hit:index reverse SET instead of SCAN
  (SCAN has no key and cannot be routed per-node in cluster mode).
- GetExternalKvCount is O(1) SCARD of the per-node reverse index.
- Fix a latent cascade bug: UnregisterClient / ExpireStaleClients (and
  the split-mode control scripts) previously only DEL'd the extkv
  reverse index, orphaning each node's bits in extkv:<hash>; they now
  clear the node from every hash it registered, matching the in-memory
  backend's whole-node external-KV wipe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hot-path change)

Replaces the Phase-1 empty-map EnumerateEvictionCandidates so master-driven
eviction works on the Redis backend, WITHOUT touching the read hot path.

A per-(node,tier) LRU ZSET would have to be updated inside route_get_batch,
but the lru:<node>:<tier> key is data-derived (the (node,tier) is only known
after reading the block inside the script), so it cannot be pre-declared in
KEYS[]; adding it under allow-undeclared-keys would force route_get_batch
global on Dragonfly and lose the read-parallel win. Instead this reuses the
existing per-node block reverse index (node:<id>:blocks) plus the
_lease/_lacc already maintained on each block hash:

- one keyed Lua per shard walks each requested node's reverse index, skips
  leased blocks (_lease > now), and emits (key,node,tier,size,_lacc) for the
  wanted (node,tier) pairs;
- shards are fanned out and aggregated with the existing RunShardedRead
  (cluster-safe: every group routes by its shard-tag key);
- ordering (kLeastRecentlyAccessed) and the per-bucket cap are applied in
  C++, mirroring the in-memory backend's scan-and-sort.

route_get_batch / exists_batch and their store methods are byte-for-byte
unchanged, so read throughput cannot regress.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…all modes

Extends the parameterized RedisStoreTest (shards1 / shards16 / endpoints3 /
cluster / clusterbalanced) with assertions for the newly-implemented methods,
so each runs in single / multi-endpoint / Redis Cluster (cluster auto-skips
without UMBP_REDIS_CLUSTER_SEEDS):

- external-KV alive-gate + match, tier bitmask across HBM/DRAM, unregister by
  hash / by tier / by node, and O(1) GetExternalKvCount;
- hit counting (count_as_hit accumulation, per-unique-hash increment),
  GetExternalKvHitCounts dedup/skip-missing, GarbageCollectHits by last_seen;
- the cascade fix: UnregisterClient / ExpireStaleClients drop the node's
  external-KV entries, not just the reverse index;
- eviction LRU order + per-bucket cap, skip-leased, only-requested-buckets,
  and tie-timestamp / cross-shard aggregation.

Assertions verify the master_metadata_store.h contract, not in-memory
internals (so the Redis-native choices — SCARD count, tier bitmask,
hit:index, reverse-index eviction scan — are asserted as they behave).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… the design

design-redis-metadata-store.md: mark the external-KV, hit-count, GC, and
eviction methods implemented; add the hit:index reverse-set row; document
extkv/hit control-tag placement and the tier bitmask; replace the "LRU ZSET" /
"SCAN" descriptions with the reverse-index eviction scan and the keyed GC;
update the §8 caveat accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant