Mead.Session.Store (mead v0.1.1)

Copy Markdown View Source

Keeps the turns of each conversation in one append-only file per session.

A file is a sequence of records framed by Mead.Session.Record, each the encoding of one event: {:append, turn} when a turn starts, {:replace_last, turn} when the newest turn is closed, and :interrupt when every turn still in progress became interrupted. Replaying the events in order rebuilds the list the history agent held.

A last record that is cut short or fails its payload checksum is what a crash during a write leaves, so it is dropped with a warning. The length has a checksum of its own, so a flipped bit in a header cannot pass for a cut write: damage anywhere else is :corrupt_history, because a conversation with a hole in the middle is worse than no conversation. load/2 only reads; recover/2 also cuts the dropped tail off the file, which a session must do before it writes again, or its next record would land behind the garbage and make the whole file unreadable. A write that fails is rolled back to the size the file had before it, for the same reason. The directory is single-writer.

compact/3 rewrites a file as one {:append, turn} per turn, through a synced <id>.log.tmp renamed over the log, so a crash leaves the old file or the new one and load/2 answers the same turns before and after.

Payloads are decoded without :safe. A failed turn keeps its reason as output, and that reason can hold an exception or an atom from a module a freshly booted node has not loaded yet, which safe decoding refuses: a conversation with a failure in it could not be resumed. The files are written by this node, are private to its owner, and are guarded by a checksum; decoding never runs code, and whoever can write the directory can already do far worse. Do not point the directory at data from a source you do not trust.

A session id becomes a file name, so it is accepted only in the shape Mead.UUID.generate/0 produces, and checked before any path is built.

Summary

Functions

Appends one event to the file of a session, creating the directory and file on first use.

Rewrites the file of a session as one {:append, turn} record per turn, given in chronological order, and returns the size of the file before and after.

Returns the size in bytes a live session's file may reach before it is compacted.

Removes the file of a session, and the temporary file a crashed compaction may have left beside it; a missing file or an invalid id is not an error.

Returns the configured storage directory, or nil when persistence is off.

Returns true when a file exists for the session.

Returns the size in bytes the ledger journal may reach before it is rewritten as a checkpoint.

Lists the stored sessions, newest first, as maps with the :session_id and the :updated_at of its file.

Returns the turns of a session in chronological order, without touching the file.

Returns how many of the newest turns a conversation keeps when its file is compacted, or nil for all of them, which is the default.

Like load/2, and cuts a torn last record off the file so that later appends follow the last complete record.

Returns the size in bytes of the file of a session.

The newest maximum of turns (chronological order), or all of them when it is nil.

Types

event()

@type event() ::
  {:append, Mead.Session.Turn.t()}
  | {:replace_last, Mead.Session.Turn.t()}
  | :interrupt

Functions

append(directory, session_id, event)

@spec append(String.t(), String.t(), event()) :: :ok | {:error, term()}

Appends one event to the file of a session, creating the directory and file on first use.

compact(directory, session_id, turns)

@spec compact(String.t(), String.t(), [Mead.Session.Turn.t()]) ::
  {:ok, %{bytes_before: non_neg_integer(), bytes_after: non_neg_integer()}}
  | {:error, :not_found | {:persistence, term()}}

Rewrites the file of a session as one {:append, turn} record per turn, given in chronological order, and returns the size of the file before and after.

The file is replaced only when the new form is smaller, so a file that is already compact is left alone and both sizes are the current one. The replacement is written to <id>.log.tmp, synced, and renamed over the log, so a crash leaves the old file or the new one. A failure leaves the log as it was and removes the temporary file. A file that does not exist is {:error, :not_found}: compaction never creates a conversation.

compact_after_bytes()

@spec compact_after_bytes() :: pos_integer()

Returns the size in bytes a live session's file may reach before it is compacted.

Defaults to one mebibyte; config :mead, :storage, compact_after_bytes: sets it. Anything but a positive integer raises ArgumentError, rather than quietly never compacting.

delete(directory, session_id)

@spec delete(String.t(), String.t()) :: :ok | {:error, term()}

Removes the file of a session, and the temporary file a crashed compaction may have left beside it; a missing file or an invalid id is not an error.

directory()

@spec directory() :: String.t() | nil

Returns the configured storage directory, or nil when persistence is off.

exists?(directory, session_id)

@spec exists?(String.t(), String.t()) :: boolean()

Returns true when a file exists for the session.

ledger_journal_max_bytes()

@spec ledger_journal_max_bytes() :: pos_integer()

Returns the size in bytes the ledger journal may reach before it is rewritten as a checkpoint.

Defaults to one mebibyte; config :mead, :storage, ledger_journal_max_bytes: sets it, and nil means the default. Anything but a positive integer raises ArgumentError.

list(directory)

@spec list(String.t()) ::
  {:ok, [%{session_id: String.t(), updated_at: DateTime.t()}]}
  | {:error, {:persistence, term()}}

Lists the stored sessions, newest first, as maps with the :session_id and the :updated_at of its file.

Only names and file times are read, never the content, so the cost does not grow with the length of the conversations. Anything in the directory that is not a session file is ignored, and a damaged file is listed like any other: only recover/2 reads it. A directory that does not exist yet holds no sessions.

load(directory, session_id)

@spec load(String.t(), String.t()) ::
  {:ok, [Mead.Session.Turn.t()]}
  | {:error, :not_found | :corrupt_history | {:persistence, term()}}

Returns the turns of a session in chronological order, without touching the file.

max_turns()

@spec max_turns() :: pos_integer() | nil

Returns how many of the newest turns a conversation keeps when its file is compacted, or nil for all of them, which is the default.

config :mead, :storage, max_turns: sets it. It is the one setting that makes compaction drop turns, so it is off unless asked for; anything but nil or a positive integer raises ArgumentError.

recover(directory, session_id)

@spec recover(String.t(), String.t()) ::
  {:ok, [Mead.Session.Turn.t()]}
  | {:error, :not_found | :corrupt_history | {:persistence, term()}}

Like load/2, and cuts a torn last record off the file so that later appends follow the last complete record.

size(directory, session_id)

@spec size(String.t(), String.t()) ::
  {:ok, non_neg_integer()} | {:error, :not_found | {:persistence, term()}}

Returns the size in bytes of the file of a session.

window(turns, maximum)

@spec window([Mead.Session.Turn.t()], pos_integer() | nil) :: [Mead.Session.Turn.t()]

The newest maximum of turns (chronological order), or all of them when it is nil.