Node.js client
Package: ruvio-client on npm · Source on GitHub
Node 18+, zero dependencies, MIT licensed.
npm install ruvio-clientimport { createClient } from 'ruvio-client';
const c = createClient('redis://127.0.0.1:6379/0'); // or { host, port, db, username, password, tls }await c.set('k', 'v', { px: 5000 });console.log(await c.get('k')); // 'v'console.log(await c.binary.get('k')); // Bufferawait c.quit();Design (speed and memory first)
Section titled “Design (speed and memory first)”- One multiplexed socket per client. Concurrent calls share it; replies resolve FIFO from a ring-buffer
pending queue (no
Array.shift, no per-call closure: the promise executor is a shared function). - Write coalescing without timers. Frames queued in the same tick go out as one
socket.write(flush: 'microtask', default;'immediate'also collects callers resumed by the same I/O event). Serial calls are sent at once. - Encoder: command names are pre-encoded (
*N\r\n$3\r\nGET\r\ncached per arity), integers are written as ASCII digits directly into a reusable 64 KiB slab, short ASCII strings are copied char by char, longer strings useutf8Write. Buffers of 64 KiB or more go to the socket without a copy. - Parser: incremental and resumable (a large array arriving in many reads is parsed in linear time),
works in place on the socket’s reusable read buffer (
onread), copies only an unfinished tail. Strings are decoded withutf8Slice;client.binary.*/getBufferreturn copies asBuffer. - RESP2 by default;
protocol: 3sendsHELLO 3(maps become objects, pushes are emitted aspushevents,CLIENT TRACKINGinvalidations work).
Typed methods for every command in docs/compatibility-matrix.json (287/287, enforced by test/coverage.test.ts),
including the Ruvio options: set(k, v, { ifEq }), incrbyMax, fillget/fillset (WAIT, SWR), getex(k, ms),
delex(k, ifEq), lpushcap/rpushcap, bf*, json* (incl. jsonIndexCreate, jsonFind), idemBegin/idemComplete,
lease/semaphore/release, once, take, setv, changes*, limit. Anything else: c.call('CMD', ...args).
- Pipeline:
await c.pipeline().set('a', 1).get('a').exec()(one write; per-command errors stay in their slot,execStrict()rejects). - Transaction:
await c.multi().incr('{k}a').incr('{k}b').exec():MULTI..EXECin one contiguous write;nullif a WATCH failed.WATCHneeds its own connection:c.isolated(async iso => { await iso.watch(k); ... iso.multi()...exec() }). - Pub/Sub:
const s = c.subscriber(); s.on('message', m => ...)orfor await (const m of s); a dedicated socket, re-subscribes after a drop. Supportspsubscribe,ssubscribe,binary: truepayloads. - Blocking commands (
blpop,xread block,fillget ... WAIT,wait) park the shared socket: usec.duplicate(). - Errors: server errors reject with
RuvioError(.code=ERR,WRONGTYPE…); a lost connection rejects pending calls withConnectionError, and the next call reconnects (database and name are re-applied). Nil replies arenull.
Helpers
Section titled “Helpers”| Helper | Built on |
|---|---|
await using lock = await c.acquireLock(key, ttlMs); lock.renew(), lock.release(), c.withLock(...) |
SET NX PX, SET IFEQ, DELEX IFEQ (never deletes another owner’s lock) |
c.acquireLease(key, ttl) (lease.token is the fencing token) |
LEASE / RELEASE |
c.acquireSemaphore(key, max, ttl) |
SEMAPHORE / RELEASE |
c.rateLimit(key, max, windowMs) -> { allowed, remaining } |
LIMIT |
c.getOrCreate(key, ttlMs, factory, { staleMs }) |
FILLGET/FILLSET; waiters poll plain FILLGET with backoff (never WAIT) |
c.idempotent(key, fingerprint, ttlMs, fn) |
IDEM BEGIN/COMPLETE |
c.scanIterator(), hscanIterator, sscanIterator, zscanIterator |
cursor-driven async generators |
c.jsonGetAs<T>(), c.jsonSetAs() |
JSON.GET/SET + JSON.parse/stringify |
c.pushRecent(key, cap, ...), c.recent(key, n) |
LPUSHCAP |
c.seenBefore(key, ...items), c.mightContain |
BF.MADD / BF.MEXISTS |
Symbol.asyncDispose is polyfilled for Node 18/20. The await using syntax itself is not available in plain JavaScript on Node 22 (it failed in a .mjs file on Node 22.22): use it from TypeScript 5.2+ (the compiler lowers it), or call lock.release() / c.withLock(...) instead.
Cluster
Section titled “Cluster”import { createClusterClient } from 'ruvio-client';
const c = createClusterClient('redis://127.0.0.1:17340'); // any shard; or { host, port, ...ClientOptions }await c.set('user:1', 'a'); // routed by CRC16 slot ({hashtag} aware)console.log(await c.mget('user:1', 'user:2', 'user:3')); // split per slot, replies in your orderClusterClient has the same typed commands, helpers (acquireLock, rateLimit, getOrCreate, …), binary, pipeline() and
multi() as RuvioClient. On the first command (or await c.connect()) it asks the seed for CLUSTER SLOTS, builds a
16384-entry slot table and keeps one lazy multiplexed connection per node. A seed that answers cluster mode is disabled is a
standalone server: the client then encodes straight into that one connection, exactly like RuvioClient (cluster: true makes that an error instead).
How routing works (no change to the typed commands): each command is encoded into a private scratch encoder, the key is read from the
encoded bytes (no key strings are built), and the frame, a zero-copy view of the encoder slab, goes to the owner’s connection. The frame is kept
only for a possible MOVED retry.
| Command shape | Routing |
|---|---|
single key (GET, HSET, LEASE, LIMIT, FILLGET, IDEM, BF.*, JSON.*, XADD, …) |
node of the key’s slot |
XGROUP / XINFO / OBJECT / MEMORY |
key is argument 2 |
RENAME, COPY, LMOVE, SMOVE, GEOSEARCHSTORE, SINTER*, SUNION*, SDIFF*, PFCOUNT/MERGE, BITOP, JSON.MGET, ZUNION*, ZINTER*, ZDIFF*, SINTERCARD, LMPOP, BLMPOP, BLPOP…, EVAL*/FCALL* with keys, XREAD*, WATCH, MSETNX |
all their real keys must share one slot, else RuvioError CROSSSLOT is raised before anything is sent |
MGET, MSET, DEL, UNLINK, EXISTS, TOUCH over several slots |
one sub-command per slot (the server refuses even two slots of the same shard), run concurrently, replies re-assembled in your order (DEL/EXISTS/… counts are summed). Not atomic: a failure in one part rejects the call while other parts may have run |
DBSIZE, KEYS, FLUSHDB, FLUSHALL |
sent to every node and merged (sum / concatenation / first reply) |
other keyless commands (PING, INFO, CLIENT, CONFIG, PUBLISH, SCRIPT, FUNCTION, ACL, …, EVAL with 0 keys) |
the seed node |
-MOVED: the slot is repointed to the named node and the command is sent there; up to 3 redirects are followed per command, a fourthMOVEDis returned as the error. The whole table is re-read withCLUSTER SLOTSonly when the named node is unknown or on the secondMOVEDfor the same command (one request is shared by all callers). Transport failures are never replayed: the affected calls reject withConnectionError, only that node reconnects (lazily), other nodes are untouched.- Pipelines are grouped per node: frames are written to each node’s connection in call order (one coalesced write per node), replies come back in call order.
A cross-slot
MGET-style command inside a pipeline is split, an unsplittable cross-slot one leaves aCROSSSLOTerror in its own slot, andMOVEDis followed per command. A lost connection rejectsexec(), as for a single connection. - Transactions (
c.multi()): all keys must hash to one slot, otherwiseexec()rejects withCROSSSLOTand sends nothing; keyless ones run on the seed.MOVEDinside a transaction is not retried (it surfaces as theEXECABORT/command error; the next call fixes the table).WATCHneeds the owner’s own connection:c.isolated(key, async iso => {...}). - Blocking commands (
BLPOP,BRPOP,BZPOP*,BLMOVE,BLMPOP,XREAD/XREADGROUP ... BLOCK,FILLGET ... WAIT,WAIT) run on a pooled dedicated connection to the owning node, so they never park the shared one. (Inside a pipeline they use the shared connection.) - Pub/Sub:
c.subscriber()connects to the seed node (SUBSCRIBE,PSUBSCRIBE);await c.subscriberFor(channel)connects to the node owning the channel’s slot (use it withSSUBSCRIBE/SPUBLISH, which route by channel). - Scan family:
c.scanIterator()walksSCANon every node, one after the other; a plainc.scan(cursor)reaches only the seed node.hscanIterator/sscanIterator/zscanIteratorroute by key. - Introspection:
c.isCluster,c.nodes,c.nodeFor(key)(a node with its ownRuvioClient),c.slotOf(key),c.refreshSlots(),c.redirectCount,c.refreshCount. hashSlot(key)is exported and is checked againstCLUSTER KEYSLOTin the live tests. Cluster mode has database 0 only (SELECTis not routed).
Tests and benchmark
Section titled “Tests and benchmark”npm test # codec unit tests + coverage; live tests skipRUVIO_TEST_ADDR=127.0.0.1:17106 npm test # + live tests per command family and the cluster client's standalone fallbackRUVIO_TEST_ADDR=127.0.0.1:17343 RUVIO_CLUSTER_ADDR=127.0.0.1:17340 npm test # + live cluster testsRUVIO_TEST_ADDR=127.0.0.1:17106 npm run bench # codec ns/B per op, live GET/SET ops/s, ioredis comparisonRUVIO_CLUSTER_ADDR=127.0.0.1:17340 RUVIO_TEST_ADDR=127.0.0.1:17343 npm run bench # + cluster GET/SET next to standaloneLive tests use their own logical databases and FLUSHDB; one test runs FLUSHALL (use a scratch server).
ioredis is a dev dependency used only by the benchmark.
Local numbers (4 vCPU Linux VM, Node 22, loopback, 256-byte values, 1.5 s per case, single connection, one Ruvio process; indicative only, not a release benchmark):
| client | GET c=1 | SET c=1 | GET c=128 | SET c=128 |
|---|---|---|---|---|
| ruvio-client (microtask flush) | 13,787 | 14,996 | 605,419 | 755,983 |
| ruvio-client (setImmediate flush) | 13,912 | 13,897 | 641,112 | 697,912 |
| ioredis 5 | 11,228 | 12,291 | 104,733 | 92,169 |
| ioredis 5 (autoPipelining) | 8,794 | 8,768 | 146,256 | 132,264 |
ruvio-client pipeline() of 128 GETs |
663,990 ops/s |
Serial numbers vary a lot run to run in this VM (earlier runs of the same code gave 25-31k). Codec: encode GET ~66 ns, encode SET with a 256-byte
string ~230 ns (Buffer ~130 ns), parse +OK ~20 ns, :int ~57 ns, nil ~37 ns, 256-byte bulk to string ~240 ns. The B/op column from the
benchmark is a heap-delta estimate, and its small-window readings for parse (100-270 B) are the returned string/Buffer values themselves.
Cluster client (3-shard server port_mode = "per-shard" and a standalone server on the same VM, 256-byte values, 1.5 s per case, one client; indicative, noisy run to run):
| client | GET c=1 | SET c=1 | GET c=128 | SET c=128 |
|---|---|---|---|---|
RuvioClient, standalone server |
55,660 | 53,285 | 838,995 | 789,889 |
ClusterClient, standalone server (fallback path) |
60,772 | 52,990 | 875,575 | 802,657 |
ClusterClient, 3 shards |
46,834 | 38,842 | 659,344 | 523,403 |
ClusterClient pipeline() of 128 GETs, 3 shards |
505,758 ops/s |
The fallback path costs nothing measurable; routed commands pay for reading the key from the frame and one extra promise-free callback per call, and the 3-shard server shares this VM’s CPUs with the benchmark, so the cluster rows are a lower bound, not a scaling claim.
Not done / not verified
Section titled “Not done / not verified”- Cluster, not done:
ASKredirects and replica reads (READONLY) are not handled (Ruvio’s per-shard layout has no slot migration); a plainscan()call is single-node (usescanIterator());MOVEDinside a transaction is not retried; splitMSET/DEL… are not atomic;SELECTis not routed (database 0 only);CLUSTER NODES/SHARDS/INFO/MYIDgo to the seed node only. Cluster routing is verified only against Ruvio’s 3-shardper-shardport layout on one machine, not against a Redis Cluster. - Not verified live: TLS and unix-socket connections;
ACL LOAD/ACL SAVEsuccess (server has no acl_file; the call round-trips an error);WAITwith real replicas; Node 18 and 20 (only Node 22 was available);HELLO ... AUTH. - Older server builds had quirks the client still tolerates: a blocked
XREAD/XREADGROUPwoken byXADDreplied one entry without the outer list,SUNION/SINTERorder was not sorted, and a queue-time error inside MULTI did not give EXECABORT. All three are fixed in the server; EXEC now answers EXECABORT and runs nothing. EXPIRE-family methods take no NX/XX/GT/LT (the server rejects them).- Pipelines re-use frames in a private buffer; a pipeline cannot be re-run after
exec().