Normandy.Behaviours.MemoryStore behaviour
(normandy v2.2.0)
View Source
Contract for agent long-term memory: path-addressed UTF-8 documents grouped in namespaces, with optimistic versioning.
A namespace is an opaque string the application chooses (per user, per tenant, per
agent: Normandy gives it no meaning). Paths are validated by Normandy.Tools.Memory
before they reach a store. Directories exist only as path prefixes.
Semantics every impl must pass (Normandy.Test.MemoryStoreContract):
- A missing document is
:not_found, a value. A backend failure (timeout, lost connection, aborted transaction) is always{:error, reason}, so a timeout is never read as "no memory yet". - Versions count per document from 1.
put(…, :new)conflicts when the path exists;put(…, v)conflicts unless the stored version isv;put(…, :any)always writes. list/3anddelete_tree/3match the prefix itself or on a/boundary, so/memories/anever matches/memories/ab.list/3returns at mostlist_limit/0entries, sorted by path (byte order).rename/4is atomic: a document atfrommoves alone; otherwise every document underfrom/moves, keeping relative paths and versions.{:error, :exists}whentois a document or has documents under it.delete_tree/3anddelete_namespace/2are atomic;delete_namespace/2is idempotent and exists for erasure (the memory tool never calls it).put/6takeslimits::max_bytes(the namespace's total content bytes) and:max_documents(its document count), each a positive integer ornil(no limit); a key left out isnil. Aputthat would raise the bytes above:max_bytes, or the count above:max_documents, writes nothing and returns{:error, {:namespace_full, :bytes | :documents, would_be}}(bytes are checked first). The check and the write are one atomic step, so two writers that each fit alone but not together get one success and one:namespace_full, and the totals count every document (no list limit). Aputthat does not raise the bytes, or does not raise the count, is never refused for that limit, so a namespace already over a lowered limit can still shrink. A version conflict wins over a limit.check_limits/3is the rule; every backend implementsput/6withlimits \ [], soput/5writes with no limit.
Summary
Callbacks
Deletes the document at path and returns :ok.
Deletes every document of namespace, atomically, and returns :ok.
Deletes every document at prefix or below it, in one atomic step, and returns
{:ok, count}, the number deleted.
Returns {:ok, %{content: content, version: version}} for the document at path.
Returns {:ok, entries}, the documents at prefix or below it, sorted by path.
Writes content at path and returns {:ok, version}, the document's new version.
Moves the document at from, or every document below from/, to to, atomically, and
returns {:ok, count}, the number of documents moved.
Functions
True when path is prefix or lies below it on a / boundary.
The limits rule of put/6, for a write that takes a namespace from before to after
(each {bytes, documents}): {:error, {:namespace_full, :bytes, bytes}} when it raises
the bytes above :max_bytes, else {:error, {:namespace_full, :documents, documents}}
when it raises the count above :max_documents, else :ok.
The most entries list/3 returns.
Moves path from below from to the same place below to.
True when path lies strictly below prefix on a / boundary.
Types
@type entry() :: %{path: path(), size: non_neg_integer(), updated_at: DateTime.t()}
@type handle() :: term()
@type limits() :: [max_bytes: pos_integer() | nil, max_documents: pos_integer() | nil]
@type namespace() :: String.t()
@type namespace_full() :: {:namespace_full, :bytes | :documents, non_neg_integer()}
@type path() :: String.t()
@type version() :: pos_integer()
Callbacks
@callback delete(handle(), namespace(), path(), version() | :any) :: :ok | :not_found | {:error, :conflict} | {:error, term()}
Deletes the document at path and returns :ok.
expected is a version or :any. A missing document is :not_found, whatever expected
is. A stored version other than expected is {:error, :conflict}, and nothing is
deleted.
Deletes every document of namespace, atomically, and returns :ok.
It is idempotent: an empty or unknown namespace is :ok. It is for erasing a user's data;
the memory tool never calls it.
@callback delete_tree(handle(), namespace(), prefix :: path()) :: {:ok, non_neg_integer()} | {:error, term()}
Deletes every document at prefix or below it, in one atomic step, and returns
{:ok, count}, the number deleted.
{:ok, 0} means nothing matched; it is not an error. prefix matches itself or a /
boundary.
@callback get(handle(), namespace(), path()) :: {:ok, %{content: String.t(), version: version()}} | :not_found | {:error, term()}
Returns {:ok, %{content: content, version: version}} for the document at path.
A missing document is :not_found. Only a backend failure is {:error, reason}.
Returns {:ok, entries}, the documents at prefix or below it, sorted by path.
Each entry is %{path:, size:, updated_at:} (size in bytes). prefix matches itself or
a / boundary, and at most list_limit/0 entries come back. A namespace or prefix with
no documents is {:ok, []}, not an error. Normandy.Tools.Memory calls it to view a
directory and to tell a directory from a file.
@callback put( handle(), namespace(), path(), content :: String.t(), version() | :new | :any, limits() ) :: {:ok, version()} | {:error, :conflict} | {:error, namespace_full()} | {:error, term()}
Writes content at path and returns {:ok, version}, the document's new version.
The version is 1 for a new document and the old version plus one otherwise. expected
is a compare-and-set: :new writes only when path has no document, a version writes
only when it is the stored one, and :any always writes. Anything else returns
{:error, :conflict} and writes nothing. limits is [max_bytes:, max_documents:], a
key left out meaning no limit; a write over a limit returns
{:error, {:namespace_full, :bytes | :documents, would_be}} and writes nothing (see the
moduledoc). A backend failure is {:error, reason}.
@callback rename(handle(), namespace(), from :: path(), to :: path()) :: {:ok, pos_integer()} | :not_found | {:error, :exists} | {:error, term()}
Moves the document at from, or every document below from/, to to, atomically, and
returns {:ok, count}, the number of documents moved.
Paths below from/ keep their relative path, and every moved document keeps its version.
It returns :not_found when nothing is at or below from, and {:error, :exists} when
to is a document or has documents below it; nothing moves in either case.
Functions
True when path is prefix or lies below it on a / boundary.
@spec check_limits( limits(), {non_neg_integer(), non_neg_integer()}, {non_neg_integer(), non_neg_integer()} ) :: :ok | {:error, namespace_full()}
The limits rule of put/6, for a write that takes a namespace from before to after
(each {bytes, documents}): {:error, {:namespace_full, :bytes, bytes}} when it raises
the bytes above :max_bytes, else {:error, {:namespace_full, :documents, documents}}
when it raises the count above :max_documents, else :ok.
@spec list_limit() :: pos_integer()
The most entries list/3 returns.
Moves path from below from to the same place below to.
True when path lies strictly below prefix on a / boundary.