MishkaCmsCore.Runtime.States.EditorHistoryState (Mishka CMS Core v0.0.1)

Copy Markdown View Source

What the undo history holds, kept beside the session rather than inside it.

Why they are not in the session

MishkaCmsCore.Runtime.States.EditorSessionState stores one row per editing session, and ETS copies a whole row on every read and every write. One placed component reads and writes that row about thirteen times.

Patches, not documents

Each row is a MishkaCmsCore.Runtime.PageBuilder.Tree.Patch — the nodes as they were before one step, and nothing else.

It used to be a whole document per entry. In memory that is nearly free, because fifty versions share almost every node, but ETS discards structural sharing: a term written to a table is flattened, so fifty entries landed as fifty independent copies. Measured on a 5,001-node document that was 1.2 MB an entry and 57.5 MB for one person's session — and the fifty-entry cap that bounded it was itself the bug that made the editor stop recording after fifty changes.

A patch is proportional to what changed, not to what exists: 290 bytes for a typical edit whatever the document's size, 84× to 4,160× smaller than the document it belongs to. So the depth of the history became a question about memory, which is answerable, instead of a count, which was a guess.

The budget

@max_bytes per session, not a number of entries. A count is wrong in both directions at once — fifty entries is a few minutes' work on a small page and 57 MB on a large one. A budget gives a small document thousands of steps and a large one as many as it can afford, and either way the memory a session can hold is a number you can multiply by your editor count.

config :mishka_cms_core, builder_history_bytes: 8_388_608

Reading one back

A patch reverses one step from the version after it, so reaching a version several steps away means applying each patch in between. MishkaCmsCore.Runtime.PageBuilder.EditorSession does that walking because it is the thing that knows the order; here they are only stored and fetched by reference. Measured at 79 µs for a hundred steps.

Same crash-safe shape as its sibling: a GenServer owns a :named_table, :public table and every read guards on :ets.whereis/1, so racing the table's creation returns :error rather than raising. Rows are dropped with their session, and swept hourly for any session that vanished without saying so.

Summary

Functions

What this session's history is holding, in bytes.

What one step is holding, in bytes — 0 for one that is not held.

Returns a specification to start this module under a supervisor.

How many steps are held right now, across every session. For the health panel and tests.

Drops one step — what falls off the far end when a session is over its budget.

Drops every step a session kept — what closing the editor does.

What a reference remembers — {:patch, patch} for a step in the stack, {:tree, tree} for one that stands alone — or :error.

Remembers a whole document under ref, and returns ref.

The memory one session's history may hold, in bytes.

Remembers before as one step back from during under ref, and returns ref.

Functions

bytes(token)

@spec bytes(String.t()) :: non_neg_integer()

What this session's history is holding, in bytes.

What the budget is measured against, and what the runtime-health panel reports.

bytes(token, ref)

@spec bytes(String.t(), reference()) :: non_neg_integer()

What one step is holding, in bytes — 0 for one that is not held.

What trimming counts down by: the steps it drops are deleted only once the session that forgot them is stored, so the total cannot be asked again after each one.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

count()

@spec count() :: non_neg_integer()

How many steps are held right now, across every session. For the health panel and tests.

delete(token, ref)

@spec delete(String.t(), reference()) :: :ok

Drops one step — what falls off the far end when a session is over its budget.

delete_all(token)

@spec delete_all(String.t()) :: :ok

Drops every step a session kept — what closing the editor does.

fetch(token, ref)

What a reference remembers — {:patch, patch} for a step in the stack, {:tree, tree} for one that stands alone — or :error.

:error is a real answer rather than a crash: the sweep, a closed session or a step dropped for the budget can all take a row away, and an undo that finds nothing should leave the document alone rather than take the editor down.

keep(token, ref, tree)

Remembers a whole document under ref, and returns ref.

For the steps that do not sit in the undo stack at all — the row an undo RUN writes, which describes net movement rather than one transition. A patch only means something relative to the version after it, and those have no version after them, so they keep the document itself.

Rare by construction: one per run of undos, not one per change. The reference is the caller's for the reason put/4 gives.

max_bytes()

@spec max_bytes() :: pos_integer()

The memory one session's history may hold, in bytes.

put(token, ref, before, during)

Remembers before as one step back from during under ref, and returns ref.

Both documents are wanted because a patch is the difference between them; the caller has each of them at the moment it decides to remember one, and asking for both here is what keeps this from having to guess what the previous version was.

The reference is the CALLER'S, made with make_ref/0 before the change that records it is first tried. A session change can be worked out more than once — see MishkaCmsCore.Runtime.States.EditorSessionState.update/2 — and a reference minted here on every try left a row behind for each try that lost. Writing under one the caller already holds replaces what it remembered instead, so a retry costs nothing and leaves nothing.

start_link(opts \\ [])