iyi language ↗

redis.

Redis driver for iyi

v0.1.0

Get started

iyi get github.com/iyilang/redis

iyi get adds it to iyi.mod and fetches its newest version tag. Import it as github.com/iyilang/redis::*.

README

iyi-redis

A Redis client for iyi, after jgaskins/redis: RESP over TCP, TLS or a Unix socket, a connection pool, typed commands, pipelines, transactions and Pub/Sub. Written in iyi, with no dependency outside iyi's own library.

Errors are values (SPEC.md III.1): every command answers its result or a Failure, which is the server's error reply (CommandError) or the connection's (ConnectionError).

Requirements

iyi 0.18.0 or later. Tested against Redis 8.2 (Memurai 8.2 on Windows).

Installation

iyi get github.com/iyilang/redis --as redis

Files then import redis for the root module (Redis.connect, Redis.from_env, Redis::VERSION) and its other modules as redis/redis/...: the short name, then the module's path in the package.

Usage

module app

import redis
import redis/redis/errors::{Failure}

def run : Nil | Failure
  r = Redis.connect("redis://localhost:6379/0")!

  r.set("greeting", "hello", ex: 60)!
  puts r.get("greeting")!       # => hello
  puts r.incr("visits")!        # => 1
  r.rpush("queue", "a", "b")!
  puts r.lrange("queue", 0, -1)! # => ["a", "b"]
  nil
end

case result = run
in Nil     then puts "done"
in Failure then puts "failed: #{result.message}"
end

Redis.connect and Redis.from_env (the URL in REDIS_URL, redis://localhost:6379 without one) answer a Client: a pool any number of tasks can share. Redis::Connection::Connection.connect(url) is a single connection, for one task.

Every command has a method (redis/commands.iyi lists them, each linked to its Redis documentation) answering the type its reply has: get a String?, incr an Int64, expire a Bool, hgetall a Hash(String, String), zrange_with_scores an Array(Tuple(String, Float64)), xread a Hash(String, Array(StreamEntry)). Any other command goes through run, which answers the raw reply, a Value:

r.run(["OBJECT", "ENCODING", "queue"])! # => "listpack"

scan_each, hscan_each, sscan_each and zscan_each walk a keyspace or a collection a page at a time without blocking the server.

Errors

case r.lpush("greeting", "x")
in Int64           then puts "pushed"
in CommandError    then puts it.kind    # => WRONGTYPE
in ConnectionError then puts it.message # the connection failed
end

A CommandError leaves the connection usable. A ConnectionError closes it; the pool opens another for the next command, and a command that fails on a connection taken from the idle ones (the server closed it meanwhile) is sent again on another.

Pipelines

Commands queued in the block are sent in one write when it ends, and their replies answered in order: one round trip for all of them. A command's error reply is a CommandError in its place.

replies = r.pipeline do |pipe|
  pipe.incr("visits")
  pipe.set("last", "now")
  pipe.lpush("log", "visited")
end! # => [2, "OK", 1]

Transactions

multi runs the block's commands between MULTI and EXEC. It answers their replies, or nil when nothing ran: the block called txn.discard, or left early (a return, a !), which sends nothing at all, or a key the connection watched changed first.

r.with_connection do |conn|
  conn.watch("balance")!
  balance = (conn.get("balance")! || "0").to_i
  conn.multi { |txn| txn.set("balance", (balance - 10).to_s) }
end! # => ["OK"], or nil when "balance" changed meanwhile

with_connection keeps one connection of the pool for the block, which WATCH needs. A connection given back still watching (the block ended before multi) is sent UNWATCH, and one given back on another database than the URL's (select_db) is closed.

Pub/Sub

r.subscribe(["news"]) do |message, subscription|
  puts "#{message.channel}: #{message.payload}"
  subscription.unsubscribe if message.payload == "bye"
end!

The block runs for each message until every channel and pattern is unsubscribed. psubscribe takes patterns, and message.pattern is the one a message matched. Client#subscribe opens a connection of its own for the subscription and closes it after. On a Connection, subscription answers a Subscription to drive by hand, which another task may unsubscribe while each reads:

sub = conn.subscription
sub.subscribe("a", "b")!
group do |g|
  g.spawn do
    sleep(1000)
    sub.unsubscribe.or_panic
    0
  end
  sub.each { |message| puts message.payload }!
end

URLs

redis://localhost:6379/0
redis://:[email protected]/3
redis://user:[email protected]?connect_timeout=2&max_pool_size=50
rediss://example.com/0?ca_file=/etc/ssl/redis-ca.pem
unix:///var/run/redis.sock?db=2

The path is the database. A user and a password are sent with AUTH. The query takes:

NameDefault
connect_timeout5seconds to connect, TLS handshake included; 0 waits as long as the system does
read_timeoutnoneseconds a reply may take; a blocking command waits that long at most too
write_timeoutnoneseconds a command may take to send
client_namenonesent with CLIENT SETNAME
ca_filenonePEM roots a rediss server's chain must reach, instead of the system's
db0the database, for a Unix socket
max_pool_size25connections a Client opens at most
max_idle_pool_size25connections a Client keeps open when idle
checkout_timeout5seconds a Client waits for a free connection; 0 waits as long as it takes

A name not in the table is refused, so a typo is not silently a default.

TLS

rediss:// speaks TLS through iyi's own std/tls. The server's certificate is always checked: its chain to the system's roots or to those in ca_file, and its name to the URL's host.

In a debug build of iyi 0.18.0, a TLS handshake made inside a task can overrun the task's stack and corrupt memory silently (iyilang/iyi#130); a Client opens connections in the task that needs one. Build with --release, or open the connections on the main fiber, until that is fixed.

Differences from jgaskins/redis

  • Commands answer T | Failure rather than raising.
  • Inside pipeline and multi a command answers nothing (there are no futures); the block's replies are what the call answers.
  • A transaction's block left early sends nothing, where jgaskins/redis sent DISCARD.
  • RESP2 is spoken; RESP3 replies (after a HELLO 3 sent through run) are read as the RESP2 shapes nearest them.
  • Not ported: Cluster, Sentinel, RedisJSON, RediSearch, RedisTimeSeries, RedisGraph, Bloom filters, geo and vector commands, TCP keepalive (iyi's sockets have no keepalive option).

Development

The tests need a server: REDIS_TEST_URL, or database 15 on localhost, which they empty.

iyi test redis
iyi fmt --check redis.iyi redis tools examples

iyi test runs each named case as a task, so over rediss:// a debug build meets iyilang/iyi#130 (above). Run the command tests over TLS from a release build instead:

iyi build --release redis/commands_test.iyi -o commands_test
REDIS_TEST_URL="rediss://localhost:6380/15?ca_file=ca.pem" ./commands_test

redis/deferred.iyi, the commands of a pipeline and a transaction, is written from redis/commands.iyi: after changing a command, run iyi run tools/write_deferred.iyi. redis/deferred_test.iyi fails while the two disagree.

THE FIRST PACKAGES. YOURS COULD BE NEXT.

Build something iyi.

Meet the language ↗