ocaml-valkey

A modern Valkey client for OCaml 5 + Eio.

Status: alpha. v0.2.0 released (0.1.0 superseded β€” @runtest tried to hit a server that doesn't exist in the opam sandbox, so 0.1.0 never made it through opam CI). Full core + cluster + batch (incl. WATCH guards + cross-slot pfcount_cluster) + fuzz + CI

Why

Existing OCaml Redis clients predate Valkey, target RESP2, and use Lwt or Async. This project targets the current era of both stacks:

No Lwt compat layer. No legacy Redis support.

Docs

What you get

Connection spine

Cluster router

Typed commands

Batch (scatter-gather + atomic)

See docs/batch.md.

Transactions

Pub/sub

Two handles that cover the whole pub/sub surface:

Publish side has typed Client.publish (cluster-wide broadcast) and Client.spublish (slot-pinned).

Testing, fuzzing, chaos

CI / CD

Benchmarks

Apples-to-apples with ocaml-redis (RESP2, blocking) and valkey-benchmark (the C client, as a reference ceiling):

Scenario

Ours

ocaml-redis

C

Ours/C

Ours/ocaml-redis

SET 100 B conc=1

7.3 k r/s

8.5 k r/s

8.8 k

83 %

0.86x

GET 100 B conc=100

199 k r/s

60 k r/s

202 k

99 %

3.3x

MIX 1 KiB conc=100

110 k r/s

47 k r/s

β€”

β€”

2.3x

SET 16 KiB conc=10

49 k r/s

26 k r/s

55 k

91 %

1.9x

At concurrency β‰₯ 10 we are 3–3.5Γ— faster than ocaml-redis and within 85–96 % of the C reference. Full matrix + methodology + optimisation history in BENCHMARKS.md. Run locally with bash scripts/run-bench.sh. Batch paths add a further β‰ˆ20Γ— speedup vs per-key loops on 1000-key bulk operations in cluster mode (examples/10-batch/bulk.ml).

Installation

Requires OCaml 5.3+ and opam 2.2+.

opam install . --deps-only --with-test
dune build

opam install valkey will work once opam-repository PR #29748 merges.

Quick start

let () =
  Eio_main.run @@ fun env ->
  Eio.Switch.run @@ fun sw ->
  let net = Eio.Stdenv.net env in
  let clock = Eio.Stdenv.clock env in
  let client =
    Valkey.Client.connect
      ~sw ~net ~clock
      ~host:"localhost" ~port:6379 ()
  in

  let _ = Valkey.Client.set client "greeting" "hello" in
  (match Valkey.Client.get client "greeting" with
   | Ok (Some v) -> Printf.printf "got: %s\n" v
   | Ok None     -> print_endline "no value"
   | Error e     ->
       Format.printf "error: %a\n" Valkey.Connection.Error.pp e);

  Valkey.Client.close client

Connecting to a cluster

let config =
  Valkey.Cluster_router.Config.default
    ~seeds:[ "node-a.example.com", 6379;
             "node-b.example.com", 6379;
             "node-c.example.com", 6379 ]
in
match Valkey.Cluster_router.create ~sw ~net ~clock ~config () with
| Error m -> failwith m
| Ok router ->
    let client =
      Valkey.Client.from_router ~config:Valkey.Client.Config.default router
    in
    let _ = Valkey.Client.set client "user:42" "ada" in
    ...

See docs/cluster.md.

Bulk ops across cluster slots

(* MGET that spans slots β€” splits by slot, parallel pipelines,
   merges in input order. *)
match Valkey.Batch.mget_cluster client
        [ "user:1"; "user:2"; "user:3"; (* ...1000 more... *) ]
with
| Ok pairs -> List.iter (fun (k, v_opt) -> ...) pairs
| Error e  -> ...

Or a heterogeneous batch:

let b = Valkey.Batch.create () in
let _ = Valkey.Batch.queue b [| "SET"; "a"; "1" |] in
let _ = Valkey.Batch.queue b [| "INCR"; "ctr" |] in
let _ = Valkey.Batch.queue b [| "HSET"; "h"; "k"; "v" |] in
let _ = Valkey.Batch.queue b [| "GET"; "a" |] in
match Valkey.Batch.run ~timeout:2.0 client b with
| Ok (Some results) -> Array.iter decode results
| _ -> ...

See docs/batch.md.

Transactions

match
  Valkey.Transaction.with_transaction client ~hint_key:"user:42" @@ fun tx ->
  let _ = Valkey.Transaction.queue tx [| "HSET"; "user:42"; "seen"; "now" |] in
  let _ = Valkey.Transaction.queue tx [| "EXPIRE"; "user:42"; "3600" |] in
  ()
with
| Ok (Some replies) -> (* committed; replies.[i] = i-th queued reply *)
| Ok None           -> (* WATCH abort β€” caller decides whether to retry *)
| Error e           -> (* transport / protocol failure *)

See docs/transactions.md.

Pub/sub (cluster-aware)

let cp =
  Valkey.Cluster_pubsub.create ~sw ~net ~clock ~router ()
in
let _ = Valkey.Cluster_pubsub.ssubscribe cp [ "orders:created" ] in

let rec loop () =
  match Valkey.Cluster_pubsub.next_message ~timeout:30.0 cp with
  | Ok (Shard { channel; payload }) ->
      Printf.printf "[%s] %s\n%!" channel payload;
      loop ()
  | Error `Timeout -> loop ()
  | Error `Closed  -> ()
in
loop ()

On primary failover the watchdog re-pins the slot's connection and replays SSUBSCRIBE automatically. See docs/pubsub.md.

With TLS against a managed service

let tls =
  match Valkey.Tls_config.with_system_cas
          ~server_name:"your.redis.amazonaws.com" () with
  | Ok t -> t | Error m -> failwith m
in
let config =
  { Valkey.Client.Config.default with
    connection = { Valkey.Connection.Config.default with tls = Some tls } }
in
let client = Valkey.Client.connect ~sw ~net ~clock ~config
               ~host:"your.redis.amazonaws.com" ~port:6379 () in
...

See docs/tls.md.

Development setup

Requires: Docker, OCaml 5.3+, opam 2.2+.

# One-time: generate self-signed certs for the TLS integration tests
bash scripts/gen-tls-certs.sh

# Start a local Valkey 9 on :6379 (plain) and :6390 (TLS)
docker compose up -d

# Optional: spin up a 3-primary / 3-replica cluster for integration tests
sudo bash scripts/cluster-hosts-setup.sh     # one-time: /etc/hosts entries
docker compose -f docker-compose.cluster.yml up -d

# Build everything + pure-unit tests (no server needed)
dune build
dune runtest

# Full integration suite (needs the docker services above)
dune exec test/run_tests.exe

See CONTRIBUTING.md for the full developer workflow β€” fuzzers, bench, coverage, pre-push gate, style rules, PR checklist.

Architecture

Four layers, bottom up:

Pre-push gate

scripts/git-hooks/pre-push runs dune build, the full test suite, the parser fuzz at 100 k iterations (strict), and a 30-second stability fuzz (both standalone and, if up, the cluster) with a zero-error threshold. Set it up once:

bash scripts/install-git-hooks.sh

Override knobs:

Roadmap

See ROADMAP.md for the full 12-phase plan. Current state:

License

MIT. See LICENSE.