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 is v; put(…, :any) always writes.
  • list/3 and delete_tree/3 match the prefix itself or on a / boundary, so /memories/a never matches /memories/ab. list/3 returns at most list_limit/0 entries, sorted by path (byte order).
  • rename/4 is atomic: a document at from moves alone; otherwise every document under from/ moves, keeping relative paths and versions. {:error, :exists} when to is a document or has documents under it.
  • delete_tree/3 and delete_namespace/2 are atomic; delete_namespace/2 is idempotent and exists for erasure (the memory tool never calls it).
  • put/6 takes limits: :max_bytes (the namespace's total content bytes) and :max_documents (its document count), each a positive integer or nil (no limit); a key left out is nil. A put that 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). A put that 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/3 is the rule; every backend implements put/6 with limits \ [], so put/5 writes 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

entry()

@type entry() :: %{path: path(), size: non_neg_integer(), updated_at: DateTime.t()}

handle()

@type handle() :: term()

limits()

@type limits() :: [max_bytes: pos_integer() | nil, max_documents: pos_integer() | nil]

namespace()

@type namespace() :: String.t()

namespace_full()

@type namespace_full() :: {:namespace_full, :bytes | :documents, non_neg_integer()}

path()

@type path() :: String.t()

version()

@type version() :: pos_integer()

Callbacks

delete(handle, namespace, path, arg4)

@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.

delete_namespace(handle, namespace)

@callback delete_namespace(handle(), namespace()) :: :ok | {:error, term()}

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.

delete_tree(handle, namespace, prefix)

@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.

get(handle, namespace, path)

@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}.

list(handle, namespace, prefix)

@callback list(handle(), namespace(), prefix :: path()) ::
  {:ok, [entry()]} | {:error, term()}

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.

put(handle, namespace, path, content, arg5, limits)

@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}.

rename(handle, namespace, from, to)

@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

at_or_under?(path, prefix)

@spec at_or_under?(path(), path()) :: boolean()

True when path is prefix or lies below it on a / boundary.

check_limits(limits, arg1, arg2)

@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.

list_limit()

@spec list_limit() :: pos_integer()

The most entries list/3 returns.

reparent(path, from, to)

@spec reparent(path(), path(), path()) :: path()

Moves path from below from to the same place below to.

under?(path, prefix)

@spec under?(path(), path()) :: boolean()

True when path lies strictly below prefix on a / boundary.