LemonCore. Store. Backend behaviour
(lemon_core v0.1.0)
View Source
Behaviour for pluggable storage backends.
A backend provides key-value storage across multiple logical tables. It is the
half of LemonCore.Store that persists: the store owns the process and
serialises access, the backend owns the bytes. Backends are plain modules —
they have no process, no supervision and no lifecycle beyond init/1.
LemonCore.Store.EtsBackend (ephemeral), LemonCore.Store.SqliteBackend
(durable) and LemonCore.Store.JsonlBackend (append-only files) are the
built-ins.
Contract
Beyond the callback signatures, implementations must hold to the following.
LemonPlatformTest.BackendCase turns each rule into a test; run it against
your backend rather than re-deriving the rules from the built-ins.
State is threaded.
init/1returns opaque state and every other callback takes it and returns it — including the read paths, which return{:ok, result, state}. The store always uses the state it was last handed. A backend may keep its real data outside the state (ETS table references, a database connection), but must never require the caller to discard the returned state.Tables spring into existence.
tableis any atom the caller chooses, possibly for the first time and possibly on a read. Reading an unknown table yieldsnil/[]; writing to one creates it.init/1cannot know the set of tables in advance.Reads are total.
get/3on a missing key is{:ok, nil, state}, never an error. A storednilis therefore indistinguishable from an absent key throughget/3—list/2is where the difference shows, and it must show there.Writes are idempotent, deletes are forgiving.
put/4overwrites silently;delete/3on an absent key succeeds.put_new/4is the only conditional write, and{:exists, state}must leave the stored value untouched — it is the store's compare-and-set primitive, which idempotency keys depend on.Terms round-trip. Keys and values are Erlang terms: binaries (including non-ASCII), atoms, numbers, tuples, nested maps and lists must come back equal. Backends that serialise inherit their encoding's limits; pids, references and functions are not required to survive.
Error reasons are backend-specific
Every {:error, reason} in this behaviour types reason as term(), and
that is the honest description: the built-in SQLite backend answers with
{:error, :sqlite_busy} and {:error, {:sqlite_init_failed, path, reason}},
a Redis backend would answer with something else entirely. Callers must not
pattern-match on a reason to decide behaviour — treat any {:error, _} as
"this operation did not happen" and log the reason. If a portable
classification (retryable vs fatal) is ever needed it has to be added to the
behaviour first.
No teardown callback
There is deliberately no close/1/terminate/2: LemonCore.Store never
tells a backend it is going away, so a backend holding a connection or file
handle must be able to lose it to process exit. SqliteBackend.close/1
exists but is not part of this behaviour and is not called by the store.
Summary
Types
Backend-specific failure description. Not portable: log it, do not match on it.
Any Erlang term the backend's encoding can round-trip.
Opaque backend state, threaded through every callback.
Logical table name. Any atom, chosen by the caller, created on demand.
Any Erlang term the backend's encoding can round-trip. May be nil.
Callbacks
Delete a value by table and key.
Retrieve a value by table and key.
Initialize the backend with the given options.
List all key-value pairs in a table.
List the most recent limit entries from a table, ordered by recency.
Performs a non-mutating backend liveness check.
Store a value under the given table and key, overwriting any existing value.
Store a value only if the table/key does not already exist.
Types
@type error_reason() :: term()
Backend-specific failure description. Not portable: log it, do not match on it.
@type key() :: term()
Any Erlang term the backend's encoding can round-trip.
@type opts() :: keyword()
@type state() :: term()
Opaque backend state, threaded through every callback.
@type table() :: atom()
Logical table name. Any atom, chosen by the caller, created on demand.
@type value() :: term()
Any Erlang term the backend's encoding can round-trip. May be nil.
Callbacks
Delete a value by table and key.
Succeeds whether or not the key (or the table) exists — deleting is idempotent.
Retrieve a value by table and key.
Returns {:ok, value, state}, where value is nil when the key is absent.
An unknown table is not an error; it reads as empty. Because nil is also a
storable value, get/3 cannot distinguish "stored nil" from "absent" —
use list/2 when that distinction matters.
@callback init(opts()) :: {:ok, state()} | {:error, error_reason()}
Initialize the backend with the given options.
Called once by LemonCore.Store at start-up with the configured
:backend_opts. Returns {:ok, state} or {:error, reason}; an error takes
the store down, so a backend that can degrade should prefer to start and fail
individual operations.
Options are backend-defined (SqliteBackend requires :path), and a backend
must tolerate being initialized more than once against the same underlying
storage — a durable backend is re-init/1ed after every restart.
List all key-value pairs in a table.
Returns {:ok, [{key, value}], state}, or {:ok, [], state} for a table that
has never been written. Order is unspecified; callers that need an order sort
for themselves. Keys explicitly stored as nil appear here with a nil
value, which is what makes them distinguishable from absent keys.
@callback list_recent(state(), table(), pos_integer()) :: {:ok, [{key(), value()}], state()} | {:error, error_reason()}
List the most recent limit entries from a table, ordered by recency.
This is an optional callback. Backends that support it can push ORDER BY +
LIMIT into the storage engine (e.g. SQL) to avoid loading all rows into
memory; the store falls back to list/2 when it is not exported.
Must return at most limit entries, each of which is a real entry of that
table. "Recency" is the backend's own notion of write time — a backend with
no such notion (an ETS table, say) may return any limit entries rather than
refusing, since the caller asked for a bounded page rather than a guarantee.
Returns {:ok, [{key, value}], state}.
@callback ping(state()) :: {:ok, state()} | {:error, error_reason()}
Performs a non-mutating backend liveness check.
Optional. Returns {:ok, state} when the backend can reach its storage and
{:error, reason} when it cannot, without changing any stored data.
Backends without a meaningful external resource may omit this callback; the
store then reports the backend as unpingable rather than assuming health.
Store a value under the given table and key, overwriting any existing value.
Creates the table if it does not exist. Must not fail because the key or the table is new.
@callback put_new(state(), table(), key(), value()) :: {:ok, state()} | {:exists, state()} | {:error, error_reason()}
Store a value only if the table/key does not already exist.
Returns {:ok, state} when the value was written, {:exists, state} when a
value was already present — in which case the existing value must be left
exactly as it was. This is the store's only compare-and-set primitive.