Custom Substrate — Plugin Example¶
Bring your own storage. Implement StoreSubstrate — the one seam that decides
where functualize keeps its own bookkeeping — and install it at boot. Every
store follows, because there is one place that decides and one object handed to
all of them.
This example's implementation is a dict. The shape is what transfers: it is the
same shape functualize-substrate-sqlite fills with a database.
Source¶
examples/plugins/custom_state_backend/
The Protocol¶
Six members. A substrate is asked for a document by key; it is never asked what a scope is or which document refuses an unreadable read — those are decisions about meaning and they stay on the stores.
from functualize._types.protocols import Stored, StoreSubstrate
class MySubstrate:
def read(self, key: str) -> Stored | None: ...
def write(self, key: str, payload: dict, *, expect: int | None = None) -> bool: ...
def lock(self, *keys: str): ... # a context manager
def clear(self, key: str) -> str | None: ... # `func builtin data clear`
def delete(self, key: str) -> bool: ... # the scope purge
def describe(self, key: str) -> str: ... # `func builtin data show`
assert isinstance(MySubstrate(), StoreSubstrate)
Two distinctions the stores depend on:
- Nothing stored is not an empty document. A missing
scopesreads as "no scopes"; an empty one reads as "a scope file that happens to be empty". ReturnNonefor the first and aStoredfor the second. clearis notdelete.clearis the documented way out of a document that cannot be read and may keep a copy aside;deleteis the scope purge and must not.
Two ways to be safe, because backends differ. A filesystem gets mutual
exclusion from flock; a remote store often cannot offer it and needs
compare-and-swap instead, so write(..., expect=) is in the port from the start
and reports refusal rather than assuming a lock was held.
Plugin Boot Class¶
from functualize.plugin import PluginHost
class MyPlugin:
name = "state-my-substrate"
def __call__(self, app: PluginHost) -> None:
app.offer_substrate(self._choose)
def _choose(self, app: PluginHost) -> MySubstrate:
return MySubstrate()
Offer at registration; boot asks. Boot calls the offer once, while it
selects the store — after configuration has resolved, so _choose can read the
plugin's own settings through app.configuration, and before the engine is
built, so what it returns is the storage every store uses. A plugin whose choice
needs no configuration may call app.install_substrate(MySubstrate()) from
__call__ instead.
APP_READY is too late: the engine already holds its storage by then, and an
install from there is refused — boot stops with SubstrateInstallError
rather than half-applying it. Some of a run's documents in one backend and some
in another is the state this seam exists to make unreachable. So is choosing
between two storage plugins: if two claim storage, boot refuses and names both.
Why this is not a StateBackend¶
It used to be. ADR-022
records why the key-value domain was retired: a backend-agnostic key-value
protocol can only offer the intersection of every backend, which is worth
least exactly where having a real database is worth most. The MCP history tools
were the proof — get_job_history could not ask for "recent executions", so it
probed four method names and took whichever the installed backend had.
Durable state for a job is a different question, answered by rc.state and by
the workflow scope — not by a storage plugin.
Key Concepts¶
- Protocol compliance —
isinstance(substrate, StoreSubstrate)validates the shape - One decision — the substrate is chosen once; fingerprints, scopes and the run log all follow it
- Entry points — auto-discovery without manual configuration