Ruvio.

Go client

Every example on this page was run against a live Ruvio server (standalone on one port, three shards on three ports) before it was pasted here. Output lines marked // => are what that run printed. Where something was not run, the page says so.

Overview

The Go client is the module github.com/salihcantekin/ruvio/clients/go (package ruvio). It uses only the Go standard library. One Client is safe for concurrent use and shares one TCP connection between all callers. Every call takes a context.Context.

Modulegithub.com/salihcantekin/ruvio/clients/go (go get github.com/salihcantekin/ruvio/clients/go)
VersionNot released. No tag exists yet (see Install).
LicenseMIT
RuntimeGo 1.22 or newer in go.mod. Built and tested with Go 1.24.7; the 1.22 floor was not tried on a 1.22 toolchain.
ModelSynchronous calls with context, safe from many goroutines. One multiplexed connection; blocking commands use a pooled dedicated connection.
DependenciesNone (standard library only).
ProtocolRESP2 by default. Options.Protocol = 3 sends HELLO 3 (RESP3, push frames for CLIENT TRACKING). Connecting sends nothing unless you configure AUTH, SELECT, CLIENT SETNAME or HELLO 3.
ClusterYes. Options.Cluster = true with one seed address; slot routing, split multi-key commands, MOVED handling. A standalone server is detected and handled as before.
Command coverage287 of 287 commands in docs/compatibility-matrix.json, checked by TestCoverage in coverage_test.go. Routing of every command is checked by TestRoutingCoversMatrix.

Install

The module is not released. A Go module that lives in a subdirectory of a repository is versioned by tags with that directory as prefix, so a release of this client needs a tag such as clients/go/v0.1.0. That tag does not exist yet. Until it does, there is no version number to put in go.mod.

# what you will run once the tag exists
go get github.com/salihcantekin/ruvio/clients/go@v0.1.0
# what was tested for this page: a local checkout and a replace directive
git clone https://github.com/salihcantekin/ruvio
# in your go.mod:
require github.com/salihcantekin/ruvio/clients/go v0.0.0
replace github.com/salihcantekin/ruvio/clients/go => ../ruvio/clients/go

Fetching the module from GitHub (an untagged go get, which Go resolves to a pseudo-version) was not tried. Every example here ran against the local checkout. Release by tag is pending.

Quick start

import (
    "context"
    "time"

    ruvio "github.com/salihcantekin/ruvio/clients/go"
)

ctx := context.Background()
c, err := ruvio.Connect(ctx, ruvio.Options{Addr: "127.0.0.1:17390", DB: 3})
if err != nil { log.Fatal(err) }
defer c.Close()

c.Set(ctx, "greeting", "hello")
v, ok, err := c.Get(ctx, "greeting")   // => "hello", true, nil
v, ok, err = c.Get(ctx, "nope")        // => "", false, nil   (a missing key is ok=false, not an error)
n, err := c.Incr(ctx, "visits")        // => 1
c.Expire(ctx, "greeting", time.Minute) // => true
c.TTL(ctx, "greeting")                 // => 60

// SET options: NX, XX, TTL, ExpireAt, KeepTTL. The bool says whether the write happened.
ok, err = c.SetArgs(ctx, "k", "v", ruvio.SetOptions{NX: true, TTL: 30 * time.Second}) // => true
ok, err = c.SetArgs(ctx, "k", "zzz", ruvio.SetOptions{NX: true})                      // => false (key exists)

// Anything without a typed method: arguments as redis-cli would send them
val, err := c.Do(ctx, "OBJECT", "ENCODING", "visits")  // => val.Str() == "int"

Options fields: Addr (default 127.0.0.1:6379), Username and Password (AUTH on every new connection when Password is set), DB (SELECT on every new connection), ClientName, Protocol, DialTimeout, Dial (your own dialer, for TLS or unix sockets), MaxIdleDedicated (default 4), OnPush and Cluster. DB, ClientName and DialTimeout were run here. AUTH, a custom Dial and TLS were not run on this page; the client has no TLS code of its own, only the Dial hook.

Close the client with Close(). Do not call Select or Hello on a shared Client to switch database: they change the connection for every caller. Use Options.DB or a dedicated c.Conn(ctx).

Performance

  • One multiplexed connection. Many goroutines share one socket. Replies are matched to callers through a FIFO.
  • Write coalescing, no timers. The first caller that finds no write in flight writes inline, so serial calls go out at once. Frames that arrive during that write are flushed together by a writer goroutine. Two output buffers are swapped; there is no batch-fill wait.
  • Pre-encoded verbs. Every command name and option is a complete RESP bulk fragment ($3\r\nGET\r\n) built once at start-up.
  • No int-to-string allocation. Integers and floats are appended into the frame with strconv.AppendInt / AppendFloat. Typed methods do not box arguments into []any; Do does take ...any.
  • In-place parser. A hand-written, resumable RESP2/RESP3 parser works on one reusable read buffer. Integer, +OK, +QUEUED and +PONG replies allocate nothing; a bulk allocates exactly its value; the bulks of one array share one arena allocation.
  • Pooled requests. Request structs, their buffers and completion signals come from a sync.Pool. A cancelled context abandons the request with an atomic compare-and-swap and the reader recycles it when the reply arrives, so the FIFO never gets out of step.
  • Serial calls read their own reply. A caller that finds nobody reading takes the read token and reads from the socket directly, with no goroutine hand-off.

Measured with the benchmarks in the client directory (go test -bench . -benchmem; 4-vCPU shared VM, loopback, Ruvio 0.3.0 single shard). The numbers are noisy (about 2x between runs) and are not release evidence. Allocation counts are exact; compare times only inside one run. I did not re-run the benchmarks for this page; these are the figures in the client README.

Measurementtimealloc/op
encode typed SET k v PX 60000 (no I/O)69 ns0
parse :int / +OK (no I/O)26–34 / 23–29 ns0 / 0
parse 64-byte bulk89–174 ns1 (the value)
parse array of 10 bulks790–2160 ns2
live, serial GET / SETabout 40 / 39 µs1 / 0
live, 64 goroutines on one connection, GET / SETabout 3.9 / 3.4 µs per op (260k–290k ops/s)
live, 16-command pipelineabout 1.5 µs per command

The README also reports about 0.42–0.46 socket writes per command in the parallel case. In the same period a bare Go net.Conn write-and-read loop against the same server measured 19–47 µs per operation, so serial latency is dominated by the server and loopback round trip, not by the client.

For best speed: share one Client between goroutines instead of one per goroutine, use Pipelined for batches, use a Ruvio command that replaces a round trip (SetIfEq instead of GET then SET), and use GetBytes / SetBytes / DoBytes to avoid string copies.

Go strings carry any bytes, so every string argument is already binary safe. Large values are copied once, from the request buffer into the connection buffer.

Commands

Every command in the compatibility matrix has a typed method on *Client. This table lists the families; the full list is in the client README and the source. Command syntax is on Commands.

FamilyExamples
StringsGet, GetBytes, Set, SetArgs, SetEX, SetBytes, MGet, MSet, IncrBy, Append, GetEx
KeysDel, Exists, Expire, TTL, Rename, Scan, ScanIter, ClusterKeySlot
HashesHSet, HGet, HGetAll, HIncrBy, HScanIter
ListsRPush, LPop, LRange, BLPop
SetsSAdd, SMembers, SUnion, SScanIter
Sorted setsZAdd, ZAddArgs, ZAddIncr, ZRange, ZRevRangeWithScores, ZRangeArgs, ZScore, ZScanIter
StreamsXAdd, XGroupCreate, XReadGroup, XAck
JSONJSONSet, JSONGet, JSONArrAppend, JSONIndexCreate, JSONFind
GeoGeoAdd, GeoDist, GeoSearch
Bitmaps / HyperLogLogSetBit, BitCount, PFAdd, PFCount
Bloom filtersBFReserve, BFMAdd, BFMExists
Transactions, scriptsTx, Transaction, Pipelined (see Pipelines); scripting methods follow the matrix. Lua in Ruvio uses ruvio.call(...), not redis.call.
Pub/SubPubSub, Publish
Server, ACL, clusterPing, DBSize, ClusterNodes, ClusterKeySlot, ACL and CLIENT subcommands

Conventions: a nullable reply returns (value, ok, err); nullable elements inside a list use NullString, NullInt, NullFloat. Typed examples, run against a standalone server:

// Strings and expiry
c.SetEX(ctx, "token:1", "abc", 30*time.Second)
c.MSet(ctx, "a", "1", "b", "2")
c.MGet(ctx, "a", "b", "c")             // => [{1 true} {2 true} { false}]   ([]NullString)
b, ok, _ := c.GetBytes(ctx, "a")       // => [49] true

// Hashes
c.HSet(ctx, "user:1", "name", "Ada", "plan", "pro")
c.HGet(ctx, "user:1", "name")          // => "Ada", true, nil
c.HGetAll(ctx, "user:1")               // => map[name:Ada plan:pro]

// Lists
c.RPush(ctx, "jobs", "a", "b", "c")
c.LRange(ctx, "jobs", 0, -1)           // => [a b c]
c.LPop(ctx, "jobs")                    // => "a", true, nil

// Sets
c.SAdd(ctx, "tags", "x", "y")
c.SMembers(ctx, "tags")                // => [x y]   (order not fixed)

// Sorted sets: ZADD ... GT CH
c.ZAdd(ctx, "leaderboard", ruvio.Z{Member: "player1", Score: 100}, ruvio.Z{Member: "player2", Score: 80})
c.ZAddArgs(ctx, "leaderboard", ruvio.ZAddOptions{GT: true, CH: true}, ruvio.Z{Member: "player2", Score: 90}) // => 1
c.ZRevRangeWithScores(ctx, "leaderboard", 0, 9) // => [{player1 100} {player2 90}]
c.ZScore(ctx, "leaderboard", "player1")         // => 100, true, nil

// Streams with a consumer group
c.XAdd(ctx, ruvio.XAddArgs{Stream: "events", ID: "*", Values: []string{"kind", "signup", "user", "ada"}})
c.XGroupCreate(ctx, "events", "workers", "0", false)
msgs, _ := c.XReadGroup(ctx, ruvio.XReadGroupArgs{Group: "workers", Consumer: "w1", Stream: "events", Count: 10})
// => [{1791796296456-0 [kind signup user ada]}]    (XMessage{ID, Values})
for _, m := range msgs {
    c.XAck(ctx, "events", "workers", m.ID)          // => 1
}

// JSON (values are JSON text)
c.JSONSet(ctx, "user:ada", "$", `{"name":"Ada","city":"Berlin","tags":["a"]}`)
c.JSONGet(ctx, "user:ada", "$.name")                // => "\"Ada\"", true, nil
c.JSONArrAppend(ctx, "user:ada", "$.tags", `"b"`)   // => 2

// Geo
c.GeoAdd(ctx, "cities", ruvio.GeoAddOptions{},
    ruvio.GeoLocation{Name: "Palermo", Longitude: 13.361389, Latitude: 38.115556},
    ruvio.GeoLocation{Name: "Catania", Longitude: 15.087269, Latitude: 37.502669}) // => 2
c.GeoDist(ctx, "cities", "Palermo", "Catania", "km")   // => 166.27415156960032, true, nil
c.GeoSearch(ctx, "cities", ruvio.GeoSearchQuery{Longitude: 15, Latitude: 37, Radius: 200, Unit: "km", Sort: "ASC"})
// => Catania, Palermo  (names only; set WithDist / WithCoord to fill the other fields)

// Bitmaps and HyperLogLog
c.SetBit(ctx, "online", 7, 1)
c.BitCount(ctx, "online")              // => 1
c.PFAdd(ctx, "visitors", "u1", "u2", "u3")
c.PFCount(ctx, "visitors")             // => 3

Do returns a Value (Kind, Int, Bytes, Array) with helpers Str, AsInt64, AsFloat64, Strings, IsNil.

Ruvio commands

These are the commands Redis does not have, or has with different semantics. Replies below are from a live run.

// SET key value IFEQ expected PX ttl  (compare-and-set; replaces WATCH/MULTI or a Lua script)
c.Set(ctx, "cfg", "v1")
c.SetIfEq(ctx, "cfg", "v2", "v1", ruvio.SetOptions{TTL: time.Minute}) // => true
c.SetIfEq(ctx, "cfg", "v3", "v1", ruvio.SetOptions{})                 // => false (value is now v2)

// DELEX key IFEQ value  (delete only if equal; the safe lock release, no Lua)
c.DelIfEq(ctx, "cfg", "nope")          // => false
c.DelIfEq(ctx, "cfg", "v2")            // => true

// INCRBY key delta MAX limit  (bounded counter; ok=false when it would pass the limit)
c.IncrByMax(ctx, "stock", 1, 2)        // => 1, true     then 2, true     then 0, false

// LIMIT key max window  (fixed-window rate limit; returns remaining, allowed)
c.Limit(ctx, "api:ada", 2, time.Minute) // => 1, true     then 0, true     then 0, false

// FILLGET / FILLSET  (cache fill with stampede protection)
r, _ := c.FillGet(ctx, "page", 5*time.Second) // => State FILL, Token "1"   (you fill it)
c.FillGet(ctx, "page", 5*time.Second)         // => State BUSY              (someone else is filling)
c.FillSet(ctx, "page", "<html>", time.Minute, r.Token, 0) // => true
c.FillGet(ctx, "page", 5*time.Second)         // => State HIT, Value "<html>"

// LEASE / SEMAPHORE / RELEASE  (fenced lease, counting semaphore)
c.Lease(ctx, "leader", 10*time.Second)        // => 1, true     (fence token)
c.Lease(ctx, "leader", 10*time.Second)        // => 0, false
tok, _, _ := c.Semaphore(ctx, "dl", 1, 10*time.Second) // => token, true
c.Semaphore(ctx, "dl", 1, 10*time.Second)     // => 0, false (full)
c.Release(ctx, "dl", tok)                     // => true

// ONCE key ttl  (true on the first call, false afterwards)
c.Once(ctx, "job:1", time.Minute)      // => true, then false

// TAKE key count  (take from a numeric stock; ok=false when there is not enough)
c.Set(ctx, "tickets", "5")
c.Take(ctx, "tickets", 2)              // => 3, true   (remaining)
c.Take(ctx, "tickets", 9)              // => 0, false

// SETV key value version  (versioned write; 0 = no version check)
v1, _ := c.SetV(ctx, "doc", "one", 0)  // => 1
c.SetV(ctx, "doc", "two", v1)          // => 2

// GETEX key ttl  (read and set a TTL; the client sends milliseconds)
c.GetEx(ctx, "g", 5*time.Second)       // => "hello", true; PTTL is now 5000

// LPUSHCAP key cap v...  (push and trim in one command)
c.LPushCap(ctx, "recent", 3, "1", "2", "3", "4", "5") // => 3
c.LRange(ctx, "recent", 0, -1)                        // => [5 4 3]

// Bloom filter
c.BFReserve(ctx, "seen", 0.01, 1000, 0, false)
c.BFMAdd(ctx, "seen", "a", "b")        // => [true true]
c.BFMExists(ctx, "seen", "a", "z")     // => [true false]

// JSON.INDEX / JSON.FIND (database 0 only; see the note below)
c.JSONIndexCreate(ctx, "by_city", "user:", "$.city", "STRING")
c.JSONFind(ctx, "by_city", `"Berlin"`, 100, "")  // => [user:1]

// CHANGES
cur, _ := c.ChangesStart(ctx)
c.Set(ctx, "watched", "1")
c.Changes(ctx, cur)                    // => [{1 SET watched}]

Two things I hit while running this. JSON.INDEX CREATE only works in database 0, so that part ran on a client with DB: 0. And JSON.FIND right after the create returned ERR index is building on the first try and the right key on the second, 50 ms later: retry on that error.

Other methods that exist but were not run here: RPushCap, JSONIndexList, IdemBegin / IdemComplete on their own (the Idempotent helper uses them and was run), and XAdd with Delay.

Helpers

Thin layers over the native commands. All of them issue only single-key commands, so they work unchanged on a cluster client (lock, rate limit and GetOrCreate were run on the cluster, see Cluster).

// Lock: SET key token NX PX, Refresh = SET IFEQ, Unlock = DELEX IFEQ. The token is random (128 bit).
l, ok, err := c.AcquireLock(ctx, "lock:report", 30*time.Second) // ok=false when someone else holds it
l.Refresh(ctx, 30*time.Second)         // => true
l.Unlock(ctx)                          // => true; GET lock:report is now missing

// Scoped form: always releases, even when fn fails
err = c.WithLock(ctx, "lock:job", 30*time.Second, func(ctx context.Context) error {
    _, e := c.Incr(ctx, "runs")
    return e
})                                     // => nil
// Lock already held: err is ruvio.ErrNotAcquired (errors.Is works)

// Wait for the lock; stops with the context error when ctx ends
cx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
_, err = c.AcquireLockWait(cx, "lock:held", time.Second, 50*time.Millisecond)
cancel()                               // => context deadline exceeded, after 300 ms

Failure behavior. AcquireLock tries once and returns ok=false if the lock is taken. WithLock tries once and returns ErrNotAcquired. AcquireLockWait retries at your interval until the context ends.

If your lock expired and someone else took it, Refresh and Unlock both return false and change nothing, because DELEX IFEQ only deletes your own token (checked: after a failed refresh and unlock the new holder's key was still there).

Nothing tells you mid-work that the lock expired; check Refresh if the work is long. WithLock unlocks with context.WithoutCancel, so a cancelled ctx still releases the lock. The error from that release call is dropped.

// Fenced lease and semaphore permit (RELEASE on exit)
err = c.WithLease(ctx, "lease:x", 10*time.Second, func(ctx context.Context, token int64) error {
    // token is the fence (1 here); pass it to the protected resource
    return nil
})
err = c.WithPermit(ctx, "permits", 2, 10*time.Second, func(ctx context.Context) error { return nil })
// AcquireLease / AcquirePermit return a handle with Release(ctx) and ok=false when held / full.

// Rate limit: LIMIT -> (allowed, remaining)
for i := 0; i < 3; i++ {
    allowed, remaining, _ := c.RateLimit(ctx, "login:ada", 2, time.Minute)
    // => true 1,  true 0,  false 0
}

// Cache-aside with stampede protection (FILLGET / FILLSET)
s, err := c.GetOrCreate(ctx, "page:home", time.Minute, func(ctx context.Context) (string, error) {
    return "<html>home</html>", nil
})                                     // second call: the factory is not run (it ran once)
type User struct{ Name, City string }
u, err := ruvio.GetOrCreateJSON(ctx, c, "user:cache", time.Minute,
    func(ctx context.Context) (User, error) { return User{"Ada", "Izmir"}, nil }) // => {Ada Izmir}

WithLease and WithPermit return ErrNotAcquired when the lease is held or the semaphore is full. GetOrCreate polls plain FILLGET with a 2 to 50 ms backoff while another caller is filling, because FILLGET ... WAIT would park the shared connection.

It stops when the context ends. If the factory fails, its error is returned, nothing is stored, and the fill marker lapses after FillTimeout (default 5 s; checked: the factory error came back as is). GetOrCreateOptions also has Stale (stale-while-revalidate: stale readers get the old value and one refreshes in the background) and PollMin / PollMax. The busy-polling path inside GetOrCreate and the stale path were not run here.

// Idempotency (IDEM BEGIN / COMPLETE)
for i := 0; i < 2; i++ {
    res, err := c.Idempotent(ctx, "pay:42", "fp-1", time.Hour, func(ctx context.Context) ([]byte, error) {
        return []byte("charged"), nil   // runs once
    })                                   // both calls: "charged", nil
}
_, err = c.Idempotent(ctx, "pay:42", "other-fp", time.Hour, fn)
// => ruvio.ErrIdemConflict

The second caller gets the stored result without running the work. While another caller is still running it you get ErrIdemPending. If the work function fails, the reservation stays pending until the TTL passes. This is not exactly-once for external side effects. The ErrIdemPending path was not run here.

// Lazy scan iterators: one page per round trip
it := c.ScanIter(ruvio.ScanOptions{Match: "session:*", Count: 2})
for it.Next(ctx) {
    key := it.Val()                      // 5 keys seen
}
if err := it.Err(); err != nil { /* a page failed */ }
// Go 1.23+: range over the same iterator
for key, err := range c.ScanIter(ruvio.ScanOptions{Match: "session:*"}).All(ctx) { /* ... */ }
// HScanIter(key, opts), SScanIter, ZScanIter work the same; HScanIter yields field, value, field, value ...

// Capped recent list (LPUSHCAP) and Bloom "seen before" (BF.MADD)
for i := 0; i < 5; i++ { c.PushRecent(ctx, "recent:ada", 3, fmt.Sprintf("item:%d", i)) }
c.Recent(ctx, "recent:ada", 10)                      // => [item:4 item:3 item:2]
c.SeenBefore(ctx, "emails:seen", "a@x", "b@x")       // => [false false]
c.SeenBefore(ctx, "emails:seen", "a@x", "c@x")       // => [true false]   (Bloom: true means "probably")

// Typed JSON with encoding/json
c.JSONSetValue(ctx, "user:t", "$", User{"Ada", "Izmir"})
var got User
c.JSONGetValue(ctx, "user:t", "$", &got)            // => {Ada Izmir}, true, nil
c.JSONSetValue(ctx, "user:t", "$.City", "Berlin")    // a plain string is sent with quotes
c.JSONGet(ctx, "user:t", "$.City")                   // => "\"Berlin\"", true, nil
u2, ok, err := ruvio.JSONGetAs[User](ctx, c, "user:t", "$") // => {Ada Berlin}, true, nil

The scan iterator is not safe for concurrent use. The All form needs Go 1.23 or newer; the iterator itself works on 1.22.

Pipelines, transactions, Pub/Sub, blocking

// Pipeline: one write, replies in order
vals, err := c.Pipelined(ctx, func(p *ruvio.Pipeline) {
    p.Set("pa", "1"); p.Incr("pa"); p.Get("pa")
})                                       // => [OK 2 2], nil

// A server error does not drop the other replies: err is the first one, vals still has all
vals, err = c.Pipelined(ctx, func(p *ruvio.Pipeline) {
    p.Set("ps", "x"); p.Do("LPUSH", "ps", "v"); p.Get("ps")
})                                       // => [OK (error) WRONGTYPE ... x], err = WRONGTYPE ...

// Transaction: the queued commands run as MULTI ... EXEC
res, err := c.Transaction(ctx, func(p *ruvio.Pipeline) { p.Incr("x"); p.Get("x") }) // => [1 1]

// WATCH on a dedicated connection; ErrTxAborted when the watched key changed
tx, _ := c.Tx(ctx)
defer tx.Close()
tx.Watch(ctx, "balance")
cur, _, _ := tx.Get(ctx, "balance")
_, err = tx.Exec(ctx, func(p *ruvio.Pipeline) { p.Set("balance", cur+"0") })
// => nil when nothing changed in between.
// If another client wrote "balance" after Watch: errors.Is(err, ruvio.ErrTxAborted) == true (checked)
// Pub/Sub on a dedicated connection; Publish and normal commands stay on the shared one
ps, _ := c.PubSub(ctx, 16)               // 16 = channel buffer
ps.Subscribe(ctx, "news")
ps.PSubscribe(ctx, "alerts.*")
c.Publish(ctx, "news", "hello")          // => 1 receiver
c.Publish(ctx, "alerts.cpu", "hot")
for m := range ps.Channel() {
    fmt.Println(m.Kind, m.Channel, m.Pattern, m.Payload)
    // => message news  hello
    // => pmessage alerts.cpu alerts.* hot
}
ps.Close()

// Blocking pop: runs on a pooled dedicated connection, so other callers are not stalled
kv, ok, err := c.BLPop(ctx, 2*time.Second, "queue")   // => {queue job-1}, true  after another goroutine pushed
kv, ok, err = c.BLPop(ctx, 200*time.Millisecond, "empty") // => ok=false after 200 ms

Tx.Multi, Tx.Queue, Tx.ExecQueued and Tx.Discard give the explicit step-by-step form (not run here). SSubscribe exists for shard channels (not run here). Blocking commands (BLPOP, BRPOP, BLMOVE, BLMPOP, BZPOPMIN/MAX, XREAD / XREADGROUP with Block, WAIT, FILLGET ... WAIT) all use a dedicated connection.

RESP3 is supported: Options.Protocol = 3 and OnPush for CLIENT TRACKING invalidations. That path is covered by the repository's live suite, I did not run it by hand.

Cluster

Set Options.Cluster = true and pass any one shard as Addr. On first use the client sends CLUSTER SLOTS to the seed and builds a 16384-entry slot table (CRC16 of the key or of its {hash tag}, modulo 16384). It keeps one multiplexed connection per node, dialed on first use.

If the seed answers ERR cluster mode is disabled (a standalone server, or a multi-shard server in single-port mode) the client stays on the plain single-connection path; the cost is one nil check per call. Every typed method, Do, helper, pipeline and transaction works the same in both modes.

  • Single key: sent to the node that owns the slot. Keys that are not the first argument (EVAL numkeys, XREAD STREAMS, BITOP dst keys, ZUNIONSTORE, LMOVE, RENAME ...) are extracted from the real key list.
  • Several keys, one slot: sent to that node. Keys in different slots for a command that cannot be split fail with ErrCrossSlot before anything is sent. That includes RENAME, COPY, SMOVE, LMOVE, SUNION and similar; there is no copy-and-delete emulation.
  • Split and gathered: MGET, MSET, DEL, UNLINK, EXISTS, TOUCH are split into one command per slot, sent as one pipelined write per node, nodes in parallel, and the replies come back in your order. Not atomic across slots.
  • Every node: DBSIZE (summed), KEYS, FLUSHDB, FLUSHALL, SCRIPT LOAD/FLUSH, FUNCTION LOAD/DELETE/FLUSH.
  • SCAN walks the nodes one after the other with a cursor that encodes the node, so ScanIter sees all keys. Treat the cursor as opaque. A topology change during a walk can skip or repeat keys. HSCAN, SSCAN, ZSCAN go to the key's node.
  • Keyless commands (PING, INFO, CONFIG, PUBLISH, JSON.INDEX, ...) go to the seed node.
  • Pipelines: grouped by node, one write each, groups in parallel, replies in your order. A pipeline does not split multi-key commands and does not fan out DBSIZE or SCAN. A pipeline containing MULTI is a transaction and needs one slot.
  • Transactions and dedicated connections (Tx, Transaction, Conn) bind to the node of the first key they see (or TxFor(ctx, key) / ConnFor(ctx, key)). Every later key must hash to the same slot, otherwise ErrCrossSlot.
  • MOVED: the slot is patched in the table, the table is reloaded when the target node is unknown or a second slot moved, and the command is sent once more to the new node. A second MOVED is returned to you as *ruvio.Error.
  • Connection loss to one node fails only the calls pending on it. They are never replayed. The next call redials that node. Other nodes are untouched.
  • Only database 0 exists in cluster mode.

Run against three shards (ports 17410 to 17412, seed 17410):

c, err := ruvio.Connect(ctx, ruvio.Options{Addr: "127.0.0.1:17410", Cluster: true})
c.Set(ctx, "warm", "1")                // the first call loads the slot table
c.IsCluster()                          // => true
c.Nodes()                              // => [127.0.0.1:17410 127.0.0.1:17411 127.0.0.1:17412]
ruvio.KeySlot("somekey")               // => 11058   (same as c.ClusterKeySlot(ctx, "somekey"))
ruvio.KeySlot("{user1000}.following")  // => 3443    (same slot as "{user1000}.profile")

for _, k := range []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot"} { c.Set(ctx, k, k) }
// slots 865, 8623, 1769, 9053, 14438, 3172: all three shards hold keys
c.MGet(ctx, "foxtrot", "alpha", "missing", "delta", "bravo")
// => [{foxtrot true} {alpha true} { false} {delta true} {bravo true}]   (caller's order)

c.MSet(ctx, "m1", "1", "m2", "2", "m3", "3", "m4", "4")
c.Del(ctx, "m1", "m2", "m3", "m4", "nokey")      // => 4
c.Exists(ctx, "alpha", "bravo", "nokey")         // => 2

c.Pipelined(ctx, func(p *ruvio.Pipeline) {
    p.Set("p1", "1"); p.Set("p2", "2"); p.Get("p1"); p.Get("p2"); p.Incr("p3")
})                                               // => [OK OK 1 2 1]

_, err = c.SUnion(ctx, "s1", "s2")               // => ErrCrossSlot, nothing sent
err = c.Rename(ctx, "alpha", "bravo")            // => ErrCrossSlot
c.Transaction(ctx, func(p *ruvio.Pipeline) { p.Set("{t}a", "1"); p.Set("{t}b", "2") })   // => [OK OK] (one hash tag)
c.Transaction(ctx, func(p *ruvio.Pipeline) { p.Set("alpha", "1"); p.Set("bravo", "2") }) // => ErrCrossSlot

it := c.ScanIter(ruvio.ScanOptions{})            // sees all 12 keys; DBSize() == 12

l, ok, _ := c.AcquireLock(ctx, "lock:x", 5*time.Second)  // works on the cluster client
c.RateLimit(ctx, "rl:ada", 2, time.Minute)               // => true 1
c.GetOrCreate(ctx, "page", time.Minute, factory)         // works

Without Cluster: true the same seed behaves like a single node: c.Get(ctx, "somekey") on the seed returned MOVED 11058 127.0.0.1:17412 as a *ruvio.Error (Prefix() is MOVED). A client seeded with 17412 found the same three nodes and read alpha through the right shard. On the standalone server, Cluster: true gave IsCluster() == false and plain behavior.

// Pub/Sub and blocking on the cluster client
ps, _ := c.PubSub(ctx, 8)
ps.Subscribe(ctx, "news")                // runs on the seed node
c.Publish(ctx, "news", "hi")             // => 1; the message arrived on ps.Channel()
c.BLPop(ctx, 2*time.Second, "queue")     // => {queue job-1}, true  (dedicated connection to the owning node)

Pub/Sub on the cluster: Subscribe / PSubscribe run on the seed, and Publish goes to the seed too, so they meet. A message published on another node does not reach these subscribers. SSubscribe opens one extra connection per node that owns a channel's slot; SPublish routes by channel. Losing any of these connections closes the subscription.

Cluster numbers (from the client README, not re-run; 4-core box shared by client and server, 3-shard server against 1-shard server, 16 x GOMAXPROCS goroutines, 64-byte values, 256 keys; noisy, not release evidence):

standalonecluster
serial GET33.2 µs/op31.1 µs/op
concurrent GET3.3 µs/op (about 300k ops/s)5.4 µs/op (about 185k ops/s)
concurrent SET3.2 µs/op (about 310k ops/s)6.2 µs/op (about 160k ops/s)
Cluster: true on a standalone server, concurrent GET3.0 µs/op
MGET of 16 keys in 16 slots149 µs/op, 99 allocs

Serial calls cost the same, so routing itself is cheap (reading the key from the encoded frame is about 56 ns with no allocation; the slot is a table-driven CRC16, about 27 ns). Concurrent throughput is lower on this box because three server shards and the benchmark compete for four cores, and each node connection coalesces fewer frames per write. This says nothing about a multi-machine deployment; that was not measured.

Live test result. With RUVIO_CLUSTER_ADDR=127.0.0.1:17410 pointing at the three-shard server, the repository's cluster live tests passed together with the rest of the suite (see Testing). Per the README they cover keys spread over all shards, cross-slot MGET/MSET/DEL order, pipeline order, forced stale-table recovery, transactions, helpers, blocking commands, SCAN, Pub/Sub, scripts, concurrency and the standalone fallback. I ran that suite but did not read each test; MOVED recovery was not run by hand here.

Errors

Every method returns an error as its last value. A server -ERR ... reply is a *ruvio.Error with the full message; Prefix() gives the first word (WRONGTYPE, NOSCRIPT, MOVED, ...).

Transport failures wrap the cause and match errors.Is(err, ruvio.ErrClosed) when the connection is closed. Other sentinels: ErrCrossSlot, ErrNotAcquired, ErrIdemPending, ErrIdemConflict, ErrTxAborted, ErrProtocol. A server error leaves the connection usable.

c.Set(ctx, "str", "x")
_, err := c.LPush(ctx, "str", "v")
var se *ruvio.Error
errors.As(err, &se)                   // => true
se.Prefix()                            // => "WRONGTYPE"
se.Error()                             // => WRONGTYPE Operation against a key holding the wrong kind of value
c.Ping(ctx)                            // => nil, still usable
c.Get(ctx, "missing")                  // => "", false, nil   (nil reply is ok=false, not an error)

ruvio.Connect(ctx, ruvio.Options{Addr: "127.0.0.1:17399"})
// => dial tcp 127.0.0.1:17399: connect: connection refused

c2, _ := ruvio.Connect(ctx, ruvio.Options{Addr: "127.0.0.1:17390"})
c2.Close()
_, _, err = c2.Get(ctx, "a")           // => ruvio: connection closed; errors.Is(err, ruvio.ErrClosed) == true

Nil is ok == false (Get, HGet, LPop, ZScore, BLPop, ...). Limit, Take, Lease, Semaphore and IncrByMax also use ok == false for "refused". Inside lists, NullString, NullInt, NullFloat carry a Valid flag.

Reconnect. There is no automatic retry or replay after a transport failure, by design. A command that was in flight fails; the next call redials. With Options.DB set, the database is selected again on the new connection. I did not run the redial path on this page.

Timeouts and cancellation. Pass a context with a deadline or cancel it. A deadline on a call is enforced with the socket read deadline; a context that can only be cancelled makes the caller wait on the reader. A cancelled call returns the context error and abandons its slot in the reply FIFO, so later replies stay matched.

Checked on a blocking call: BLPop with a 100 ms context and a 5 s server timeout returned context deadline exceeded, and the next Get on the same client worked. A deadline on an ordinary shared-connection call was not run separately. There is no write deadline: if the server stops reading, the writer blocks and queued frames grow.

Not done or not verified

  • Release. No tag, so no version. A tag like clients/go/v0.1.0 is needed. Fetching the module from GitHub was not tried.
  • Go 1.22. go.mod says 1.22; the code was only built and tested with Go 1.24.7. The Scanner.All range-over-func form needs 1.23.
  • No TLS code in the client (use the Dial option). AUTH was verified only on a standalone server per the README, and not run on this page.
  • No automatic retry or replay after a transport failure. No write deadline.
  • Cluster, not done: replicas and READONLY reads (the client routes to the slot's primary only), -ASK (the server has no live slot migration), copy-and-delete emulation for cross-slot RENAME / COPY / SMOVE / LMOVE, splitting of MSETNX / BITOP / SUNION and other all-or-nothing commands, background topology refresh (the table is refreshed on MOVED or RefreshSlots; a gone node is found by the next call to it), multiple databases.
  • Cluster, not verified: CLIENT TRACKING / HELLO 3 push frames on a cluster, AUTH on a cluster, TLS to a cluster. A node that advertises a different host than the seed was reached at is used as advertised. PUBLISH reaches only subscribers on the node it was sent to.
  • RESP3: HELLO 3 works, but typed results for RESP3-only shapes are tested only for maps (HGETALL, HELLO) and pushes.
  • Server-side: OBJECT IDLETIME/FREQ were verified only up to the server's policy error under the default noeviction. SAVE / BGSAVE only against the memory profile.
  • Benchmark numbers on this page are from the README, not re-run.
  • Not run on this page: GetOrCreate busy and stale paths, ErrIdemPending, Tx.Multi/Queue/ExecQueued, SSubscribe, RESP3, AUTH, reconnect, a forced MOVED, Pub/Sub on shards.

Testing

cd clients/go
go vet ./...
go test ./...                                   # codec, routing, scripted fake cluster, coverage; live tests skip
RUVIO_TEST_ADDR=127.0.0.1:17390 go test -race ./...    # also the live suites (databases 11 and 12, FLUSHDB)
RUVIO_CLUSTER_ADDR=127.0.0.1:17410 go test -race ./... # also the cluster live tests (3 shards, per-shard ports)

Counts from my run (Go 1.24.7, no -race): without the environment variables, 30 tests pass and 44 skip (74 in total). With both RUVIO_TEST_ADDR (17390) and RUVIO_CLUSTER_ADDR (17410) set, all 74 pass and none skip. go vet was clean.

I did not run the -race form. The live suites flush databases 11 and 12 on the standalone server and database 0 on the cluster, so point them at a throwaway server. The live suite covers every command family, JSON.INDEX / JSON.FIND (database 0, own key prefix), Pub/Sub, RESP3 client tracking, pipelines, transactions and every helper. TestCoverage reads docs/compatibility-matrix.json and fails if a command has no typed method.

Start a server for the tests as on Start. For three shards, set shards = 3 and port_mode = "per-shard" in the config; shard i listens on base port + i. Benchmarks: RUVIO_TEST_ADDR=127.0.0.1:17390 go test -run '^$' -bench . -benchmem -benchtime=1s; the cluster ones also need RUVIO_CLUSTER_ADDR.

See also: Clients overview, Rust, C#, Java, Python, Node.js, Examples, Commands.