Scarlet Industries

Redis client

Talk to Redis from Scarlet, with a typed result for every one of its 275 commands.

A first program

Add the client to your project as a git submodule:

git submodule add https://github.com/scarletindustries/redis_client lib/redis

Keep hyphens out of the directory name, because Scarlet reads an import path as names and a hyphen as a minus sign. With Redis running locally, this stores a greeting and reads it back:

import scarlet/result
import ./lib/redis/redis.{Conn}
import ./lib/redis/strings

fn greet(c Conn) Result(Nil, redis.RedisError) {
	_ <- result.then(strings.set(c, 'greeting', 'hello'))
	value <- result.then(strings.get(c, 'greeting'))
	println(value or '(nil)')
	Ok(Nil)
}

pub fn main() {
	redis.with_conn('127.0.0.1', 6379, greet)
}

with_conn closes the connection when greet returns. Each <- line stops the function at the first command that fails.

Errors

Every command returns a Result, and the error says what kind of failure it was:

pub type RedisError {
	Net(err NetError)        // the connection failed, so open a new one
	Server(message String)   // Redis refused the command, the connection is fine
	Protocol(why String)     // the reply made no sense, so drop the connection
	Misuse(why String)       // the call was wrong before anything was sent
	Pool(fault PoolFault)    // the pool has ended
	Tls(err TlsError)
}
fn read_greeting(c Conn) String {
	match strings.get(c, 'greeting') {
		Ok(Some(text)) -> text
		Ok(None) -> 'nobody has set a greeting yet'
		Err(Server(message)) -> 'Redis refused: ${message}'
		Err(Net(_)) -> 'the connection is gone, so reconnect'
		Err(e) -> redis.show_error(e)
	}
}

Connecting

A hosted Redis usually comes as a URL. rediss:// is encrypted with TLS, redis:// is not, and a path such as /2 picks the database.

redis.with_conn_url('rediss://default:s3cret@cache.example.com:6380/2', greet)

connect, connect_tls, connect_auth and connect_tls_auth take the parts separately. The client needs Redis 6 or newer, because it speaks RESP3.

Pools

A plain connection belongs to the process that opened it. When many processes need Redis, start a pool, which is also a Conn and costs nothing to pass around:

import ./lib/redis/pool

fn handle(p Conn, job String) Nil {
	match strings.incr(p, 'processed') {
		Ok(n) -> println('${job} done, ${n} so far')
		Err(e) -> println(redis.show_error(e))
	}
}

fn run(p Conn) Result(Nil, redis.RedisError) {
	_ = process.spawn(fn() handle(p, 'job:1'))
	_ = process.spawn(fn() handle(p, 'job:2'))
	Ok(Nil)
}

pub fn main() {
	pool.with_pool('127.0.0.1', 6379, 4, run)
}

Commands on a pool can land on different connections, so wrap a WATCH or MULTI in pool.with_conn to keep them on one.

Pipelines

redis.pipeline sends a batch of commands in one write. Against a local Redis, 500 SETs took 152 ms one at a time and 6 ms as one pipeline.

replies <- result.then(
	redis.pipeline(c, [['SET', 'hits', '0'], ['INCR', 'hits'], ['GET', 'hits']]),
)

Each reply is its own Result, in the order the commands were sent:

match replies {
	[_, incr, get] -> {
		n <- result.then(redis.expect_int(incr))
		v <- result.then(redis.expect_bulk(get))
		println('${n} increment, value ${v or '(nil)'}')
		Ok(Nil)
	}
	_ -> Err(redis.Protocol('expected three replies'))
}

Pub/sub

Publishing returns how many subscribers got the message:

subscribers <- result.then(pubsub.publish(c, 'news', 'hello'))

A subscribed connection cannot carry ordinary commands, so subscribe takes it and returns a Subscription. Each next_message returns a new one along with the event, and the next call must use that.

import ./lib/redis/pubsub.{Message}

fn listen(c Conn) Result(Nil, redis.RedisError) {
	sub <- result.then(pubsub.subscribe(c, ['news']))
	(rest, event) = pubsub.next_message(sub)
	match event {
		Ok(Message(channel, payload)) -> println('${channel}: ${payload}')
		Ok(_) -> Nil
		Err(e) -> println(redis.show_error(e))
	}
	pubsub.close(rest)
	Ok(Nil)
}

Everything else

The commands are grouped into modules the same way Redis groups them:

strings  keys  hashes  lists  sets  sorted_sets  streams  pubsub  geo
bitmaps  hyperloglog  arrays  transactions  scripting  connection
server  cluster

For a command the client does not wrap, send it yourself:

encoding <- result.then(redis.command(c, ['OBJECT', 'ENCODING', 'user:1']))
stored <- result.then(redis.command_raw(c, [<<'SET'>>, key, jpeg]))