This section illustrates how to write a custom storage backend for Irmin using an in-memory store as an example.
Unlike writing a custom datatype, there is no tidy way of doing this. A backend is built from a number of lower level stores (commits, nodes, contents or branches), where each store implements some of the operations needed by the backend. In this example we instantiate two functors: one of type [Irmin.Content_addressable.Maker] (for the block store) and [Irmin.Atomic_write.Maker] (for the reference store). The two are used in creating a module of type [Irmin.Maker], which is in turn used in a functor of type Irmin.KV_maker.
The readonly store
The process for writing a backend for Irmin requires implementing a few functors -- to accomplish this, we can start off by writing a helper module that provides a generic implementation that can be re-used by the content-addressable store and the atomic-write store:
t
: the store typekey
: the key typevalue
: the value/content type
open Lwt.Syntax
module Helper (K: Irmin.Type.S) (V: Irmin.Type.S) = struct
module Tbl = Hashtbl.Make(struct
type t = K.t
let equal a b = Irmin.Type.(unstage (equal K.t)) a b
let hash k = Irmin.Type.(unstage (short_hash K.t)) k
end)
type 'a t = V.t Tbl.t (* Store type: a hashtable mapping keys to values *)
type key = K.t (* Key type *)
type value = V.t (* Value type *)
Additionally, it requires a few functions:
v
: used to create a value of typet
mem
: checks whether or not a key existsfind
: returns the value associated with a key (if it exists)
When creating a new backend, you can utilize the functions in
Irmin.Backend.Conf
to work with Irmin.config
values. Additionally, each
backend should register a new config specification using
Irmin.Backend.Conf.Spec
:
let spec = Irmin.Backend.Conf.Spec.v "tutorial"
let init_size = Irmin.Backend.Conf.key ~spec "init-size" Irmin.Type.int 8
let v config =
let module C = Irmin.Backend.Conf in
let init_size = C.get config init_size in
Lwt.return (Tbl.create init_size)
mem
can be implemented directly using Tbl.mem
:
let mem t key =
Lwt.return (Tbl.mem t key)
find
uses Tbl.find_opt
:
let find t key =
Lwt.return (Tbl.find_opt t key)
clear
is used to cleanup any data in the store:
let clear t =
Tbl.clear t;
Lwt.return_unit
end
The content-addressable store
Next is the content-addressable Irmin.Content_addressable.S interface - the
majority of the required methods can be inherited from Helper
!
module Content_addressable : Irmin.Content_addressable.Maker = functor
(K: Irmin.Hash.S)
(V: Irmin.Type.S) -> struct
include Helper(K)(V)
This module needs an add
function, which takes a value, hashes it, stores the
association and returns the hash:
let encode_value = Irmin.Type.(unstage (to_bin_string V.t))
let unsafe_add t k v =
Tbl.replace t k v;
Lwt.return_unit
let add t value =
let hash = K.hash (fun f -> f (encode_value value)) in
let+ () = unsafe_add t hash value in
hash
Then a batch
function, which can be used to group writes together. We will use
the most basic implementation with a global lock:
let lock = Mutex.create ()
let batch t f =
Mutex.lock lock;
let+ x = Lwt.catch (fun () -> f t)
(fun exn ->
Mutex.unlock lock;
raise exn)
in
Mutex.unlock lock;
x
Finally, we must provide a close
function to free any resources held by the
backend. In our case, this can be a simple no-op:
let close _t = Lwt.return_unit
end
The atomic-write store
Irmin.Atomic_write.S has many more types and values that need to be defined than the previous examples, but luckily this is the last step!
To start off we can use the Helper
functor defined above:
module Atomic_write: Irmin.Atomic_write.Maker = functor
(K: Irmin.Type.S)
(V: Irmin.Type.S) -> struct
module H = Helper(K)(V)
There are a few types we need to declare next. key
and value
should match
H.key
and H.value
and watch
is used to declare the type of the watcher --
this is used to send notifications when the store has been updated.
irmin-watcher has some more information on watchers.
module W = Irmin.Backend.Watch.Make(K)(V)
type t = { t: [`Write] H.t; w: W.t } (* Store type *)
type key = H.key (* Key type *)
type value = H.value (* Value type *)
type watch = W.watch (* Watch type *)
The watches
variable defined below creates a context used to track active
watches.
let watches = W.v ()
Again, we need a v
function for creating a value of type t
:
let v config =
let* t = H.v config in
Lwt.return {t; w = watches }
The next few functions (find
and mem
) are just wrappers around the
implementations in H
:
let find t = H.find t.t
let mem t = H.mem t.t
A few more simple functions: watch_key
, watch
and unwatch
, used to created
or destroy watches:
let watch_key t key = W.watch_key t.w key
let watch t = W.watch t.w
let unwatch t = W.unwatch t.w
We will need to implement a few more functions:
list
, lists files at a specific path.set
, writes a value to the store.remove
, deletes a value from the store.test_and_set
, modifies a key only if thetest
value matches the current value for the given key.close
, closes any resources held by the backend.
The list
implementation will get a list of keys in the store:
let list {t; _} =
let keys = H.Tbl.to_seq_keys t |> List.of_seq in
Lwt.return keys
set
stores a key/value pair in the store. When this operation updates the
store, the watchers have to be notified:
let set {t; w} key value =
let exists = H.Tbl.mem t key in
H.Tbl.replace t key value;
if exists then W.notify w key (Some value)
else Lwt.return_unit
remove
deletes stored values and then notifies the watchers:
let remove {t; w} key =
H.Tbl.remove t key;
W.notify w key None
test_and_set
will modify a key if the current value is equal to test
. This
requires an atomic check and set:
let value_equal = Irmin.Type.(unstage (equal (option V.t)))
let test_and_set {t; w} key ~test ~set:set_value =
let v = H.Tbl.find_opt t key in
if value_equal v test then (
let () =
match set_value with
| Some set_value ->
H.Tbl.replace t key set_value
| None ->
H.Tbl.remove t key
in
let* () = W.notify w key set_value in
Lwt.return_true
) else Lwt.return_false
Finally, we need to pull in clear
from our Helper
implementation and add
another close
function:
let clear {t; _} =
H.Tbl.clear t;
Lwt.return_unit
let close _t = Lwt.return_unit
end
Now, let's use the Make
and KV
functors for creating in-memory Irmin stores:
module Maker: Irmin.Maker = Irmin.Maker (Content_addressable) (Atomic_write)
module KV = struct
type endpoint = unit
type metadata = unit
module Make(C: Irmin.Contents.S) = struct
include Maker.Make
(struct
module Info = Irmin.Info.Default
module Metadata = Irmin.Metadata.None
module Contents = C
module Path = Irmin.Path.String_list
module Branch = Irmin.Branch.String
module Hash = Irmin.Hash.SHA1
module Node = Irmin.Node.Make(Hash)(Path)(Metadata)
module Commit = Irmin.Commit.Make(Hash)
end)
end
end
We also have to provide a configuration for our backend specifying the
parameters needed when initialising a store. In our example, we start with an
empty configuration, which comes with root
as a parameter. We can then
instantiate the store and create a repo:
let config ?(config = Irmin.Backend.Conf.empty) ?root () =
let module C = Irmin.Backend.Conf in
C.add config C.root root
module Store = KV (Irmin.Contents.String)
let _repo = Store.Repo.v (config ())