schema.yml, and the
semantic layer’s definitions, whether those are dbt’s or a native format’s. It is
a git-reviewable filesystem artifact by design, it stays one, and it never moves
into a datastore. No backend choice changes that.
The scratch state is everything dex learns along the way: the exploration
cache, the reconcile baseline, the last drift report, the append-only query and
spend ledgers, and the stored transform plans. Delete all of it and nothing
canonical is lost. That is what a Store holds.
Three backends ship. FilesystemStore writes plain files under .dex/ and is
what the CLI uses by default, so persistence is git and a reviewer can read the
state in a pull request. MemoryStore writes nothing and is the library
default, which is why import exmergo_dex_core cannot leave a .dex/ directory
in a consumer’s repo. SqliteStore is opt-in: one .dex/dex.db file with real
tables in place of loose JSON, for a host that wants durable local state without
a directory of files to review, gitignore, or clean up. Select it with
cache.backend: sqlite or --cache-backend sqlite; filesystem stays the
default, and .dex/dex.db is not reviewable the way .dex/cache.json is, so add
it to .gitignore the way you would any local database file.
Secrets never live in any backend.
Why you would write your own
One reason, and it is a real one: a process that serves more than one end user. Such a host federates state per user in its own datastore, so that user A’s cache is not user B’s, and it wants that state to outlive a single request without putting anything on a shared disk. Note thatMemoryStore does not cover this. It gives you “nothing on disk” but
not “durable across requests”, and those are different requirements. The
distinction matters more than it looks: the query firewall resolves every table
and column reference against the exploration cache, so cache membership decides
what a query is permitted to name and under whose PII policy. A map() in one
request must still be visible to a query() in the next, or the firewall refuses
work the user has already paid to make legal.
The three tiers
The contract is three nested protocols. Implement the one that matches what your host actually does, and stop there: a narrower implementation is a complete implementation of a declared contract, not a partial one.ExploreStore is six methods, which is the useful thing to know before starting:
a host embedding dex for read-only sense-making is not signing up for sixteen.
The transform surface is the one place the widest tier is required, and it checks
at runtime. Passing an explore-only store to a transform command refuses with a
message naming the tier and the missing members, rather than failing on a missing
attribute several frames down.
Three capabilities sit alongside the tiers rather than inside them, and all are
optional: SpendLock, so the cumulative spend ceiling binds when two commands
overlap; SpendHistory, so an over-ceiling refusal can say how far this
connector’s past estimates ran from what they actually billed; and the
construction contract, so your backend can be named in configuration. A backend
is a complete backend without any of them, and the first is one every concurrent
host wants.
Writing one
Store, and
isinstance(store, ExploreStore) confirms it. Pass the instance to the engine:
spend_total rather than reimplementing the ledger arithmetic. It is exported
for exactly this reason: every backend then answers the session-budget question
identically, which is a property you want in a guard.
Serializing the spend admission
One optional capability, and the only place in this seam where skipping something costs correctness rather than convenience. If your backend can be reached by more than one command at a time, implement it.budget.session_ceiling, and books that much headroom in
the ledger, all inside one spend_lock. It is held for the decision only, never
across a warehouse query, so it costs microseconds and never serializes work that
has no reason to wait.
Three properties, and the third is the one most often missed.
- It excludes. A second holder waits.
- It is re-entrant for one holder. dex settles a gate from more than one funnel, so a settle inside a section already held is expected rather than a deadlock to design around.
- It is scoped to the ledger this store reads, not to the backend. A single global mutex satisfies the first two and quietly serializes every tenant in the deployment against every other. That is the same isolation the tier contracts already require, and a lock is the easy place to forget it, because a single-tenant test cannot see the difference.
TimeoutError when timeout elapses; dex turns it into a
named refusal carrying the cost. The builtin is what the protocol asks for so
your backend needs no import from dex to satisfy it.
Without it your backend still works. dex books the headroom regardless, which
narrows the race from the duration of a warehouse query to the microseconds
around the ledger read, and every billed command carries a warning that the
cumulative ceiling is advisory. That is a warning rather than a refusal on
purpose, matching the call already made for a session_ceiling nobody set:
refusing would break a backend written before this existed. It is not a reason to
skip the lock on anything serving concurrent requests.
SpendLockContract in the conformance suite is the executable version of all of
the above.
Reading the ledger back
A second optional capability, and a gentler one: skipping it costs a sentence, not correctness.spend_since answers the one question the cumulative
ceiling asks, “how much today”, and answers it as a single float, which is what
keeps that hot path cheap. There is a second question worth asking of the same
ledger. A dry-run estimate on a partitioned or clustered table is an upper bound
by construction, so a build refused at an estimated 6.9 GB against a 5 GB ceiling
may bill 4.75 GB when it is finally run, and “raise the budget or narrow the
work” is then answered by a guess made under the impression that the estimate
approximates the cost. dex records both halves already: every settlement entry
carries an estimate beside the figure it settled at. This member is what reads
them back, so the refusal can end with
The last 8 settled bigquery commands in this project’s spend ledger billed a median 69% of estimate (range 61%-88%) …Three properties.
- Return the entries uninterpreted, as dicts, exactly as they were appended.
The guard pairs
estimatewith the settled figure and groups byreservation_id; a backend that drops keys it does not recognize breaks that. - Append order, oldest first. Not sorted by
at: the ledger is append-only, so insertion order is the true order and it stays right for entries whose stamp is missing. - Filter by connector, then cap.
limittakes the most recent matching entries. Capping before filtering returns fewer thanlimitof the asked-for connector whenever another connector shares the ledger.
spend_since and for the reason
that separates them: nothing is admitted or refused on what this returns. It only
decides whether an already-decided refusal carries one more sentence, so dex
swallows an error here and drops the sentence. Raise or return []; both read as
“no history”.
Without it your backend still works, and nothing warns: no guard is narrower
than it looks, so there is nothing to disclose. Over-ceiling refusals simply read
as they always did.
SpendHistoryContract in the conformance suite is the executable version of the
above.
Constructing one
The section above is the whole contract if your host builds the store itself. If you also want your backend named in configuration and built by dex (see Selecting a backend), there is a second contract, and it is deliberately separate fromStore.
It is separate because the shipped backends disagree about what they are built
from, and the disagreement does not resolve by picking a winner:
So construction takes one argument, a
StoreContext, and a factory is anything
callable that turns one into a store:
repo_root is the directory dex was pointed at, or None when there is no
repository in the picture. Your backend is free to ignore it, and a tenant-keyed
one will. options is your own non-secret coordinates, passed through verbatim;
dex does not interpret it, so the keys are yours to define and yours to validate.
Three shapes qualify, so you can use whichever your backend already has:
FilesystemStore.from_context refuses a context with no repo
root rather than falling back to the working directory, which would write one
project’s exploration cache into wherever the process happened to start. Raise
ConfigurationError so a host catches your refusal with the same except it
already uses for dex’s.
No secret ever reaches a StoreContext. .dex/config.yml is committed, so a
password, key, token, or connection string in options is a credential in version
control. Read your credential the way the rest of the engine does, from the
environment at construction time. If your credentials arrive per request rather
than per process, skip this contract entirely and hand the engine a store you
built yourself: DexEngine(store=...) is the right shape for that, and it always
wins over anything named in configuration.
One note if you are writing the code that resolves a name: StoreFactory is
runtime_checkable for symmetry with the store tiers, but a callable protocol can
only check that __call__ exists, which every callable satisfies. Build the store
and check the result against the tier you need. The tiers are genuinely
isinstance-checkable; the factory is not.
The contracts that are not obvious from the signatures
Each of these is stated on the protocol member it governs instorage/base.py,
and each is asserted by the conformance suite. They are collected here because
they are the ones an implementer gets wrong.
Documents do not alias the caller’s object, in either direction. What a caller
saves and what a later load_* returns must be independent objects. The
filesystem backend gets this free by serializing; the memory backend deep-copies
both ways specifically to match. A backend that hands back a live reference lets
a mutation after a save silently rewrite history.
save_cache(cache, now=...) stamps the caller’s object too, not only the
stored copy. The command layer reports the timestamp it just persisted, so a
backend that stamps only what it writes makes the reported and stored times
disagree.
Documents are pydantic models. Round-trip them with model_validate_json and
model_dump_json. A backend storing its own hand-rolled dict shape diverges on
the first schema change.
save_cache is a whole-document, atomic write. Cache membership decides what
a query may name, so a half-written cache is a security-relevant state rather than
merely an inconsistent one. A backend whose store caps document size chunks
internally and presents one logical document. There is deliberately no
per-dataset write seam, because that seam is what would let a reader observe half
a cache.
A corrupt document raises; a corrupt ledger line is skipped. Two opposite
policies, both deliberate. An unreadable cache means dex does not know what it may
query, and treating that as “nothing explored yet” would silently re-profile a
warehouse and bill for it. An unreadable ledger line means one spend record is
lost, and refusing every subsequent billed command over one bad line is the worse
failure.
Raise a ValueError for the document that will not parse. Pydantic’s
ValidationError is one, so a backend that round-trips the models as described
above gets this for free and needs to do nothing. It matters for a backend that
deserializes its own rows: maintain catches a ValueError out of load_snapshot
and classifies it as a prerequisite failure, which is what tells a host to stop and
rebuild the baseline rather than retry the command. Anything else reaches the
engine’s catch-all and is reported to the operator as a bad request they made, when
the fix is a dex command they have not run. The other two loads are wrapped the
same way now, and they resolve to opposite answers on purpose. load_cache goes
through readable_cache, which raises CacheUnreadableError naming explore map
and saying that rebuilding bills. load_drift goes through _stored_drift, which
treats an unparseable report as absent: a drift report is derived rather than
vouched for, maintain check regenerates it from the baseline on demand, and both
callers already had a path for a missing one. So raise a ValueError from any of
the three and the engine classifies it; what it does next depends on whether the
document can be rebuilt without asking anyone.
A stored schema_version is not the store’s to police. Documents carry one and
the engine reads it: the query firewall degrades on an old cache, and maintain
refuses a baseline whose version it does not read, naming maintain snapshot.
Load what you were given and let the engine decide which of those it is.
The ledger is scoped to the store instance, so store granularity is ceiling
granularity. One principal spanning two stores has two independent session
ceilings and nothing bounds their sum. Nothing warns about it and it errs
permissive, which makes it worth stating plainly: key stores exactly as you key
principals. A host that splits stores for some other reason, one per repo root
say, has split the budget too and will not be told.
The ledger read-then-write has to be atomic for the cumulative ceiling to
bind, and SpendLock is how a backend provides it. See
Serializing the spend admission above, which
states the obligation and what dex does for a backend that provides no lock.
Entries are stored, not interpreted. A backend stores the dict it was
handed, whole, and three properties of that dict are the whole obligation: a
release carries a negative magnitude, so clamping or filtering on sign leaks
held headroom for the rest of the UTC day; every entry carries the same keys with
null where one does not apply, so dropping unrecognized keys breaks a reader
joining settlements to reservations; and projecting the entry onto columns of
your own diverges the first time dex adds a key. Sum what you are given. What
each key holds, and what the three kinds mean to a reader, is in
�1.
Two properties follow, and neither required a change to any backend written
before reservations existed:
- A day that has finished sums to actual spend, exactly as it always did, so nothing summing settled spend saw a different number.
- A day with commands still running sums to actual spend plus the headroom they hold. That is not an accounting error; it is the number a concurrent command has to see.
spend_since to
tell the entry kinds apart and reason about time.
The protocol is synchronous. A backend whose client is async wraps it, running
the coroutine to completion inside the method. Every caller in the engine is
synchronous, and a second async surface would double the contract for no caller
that exists.
Proving it works
The contract ships as an executable suite. Install it and subclass the class for your tier:make_store
must return a store that shares nothing with a store built from a different key.
Whether the key is a directory, a tenant id, or a table prefix is your business.
Two tenants leaking into each other is the failure this seam exists to prevent, so
those assertions are the ones worth reading if any fail.
The extra brings pytest and the dialect engine, because the plan-tier assertions
build a TransformPlan and that reaches the SQL guard. Implementing only
ExploreStore needs neither the plans nor the engine, and that install stays
viable: nothing in the narrow tier touches SQL.
What the suite guarantees about the key
It never hands the same key to two assertions, within a run or across runs. So you need no reset hook, no truncate step, and no fixture of your own to keep one assertion’s writes out of the next. That matters because of what it leaves you free to do. Two calls tomake_store
with the same key may return two views of one shared state, which is exactly what
a durable backend is and what a hosted deployment depends on. The contract
constrains you in one direction only: two different keys must share nothing. It
says nothing about the same key twice, and the suite never depends on an answer.
Nothing is cleaned up when the run ends, so a durable backend accumulates one
namespace per run. Pruning them is yours; the alternative was a teardown hook, which
is a second thing to implement and get wrong for a guarantee the suite does not
need.
If your backend is meant to be named in configuration, mix in
StoreFactoryContract as well. It routes make_store through your own factory, so
everything above then runs against stores built the way dex builds them:
SpendLockContract the
same way, and it checks all three properties from
Serializing the spend admission, including
the scope one:
spend_entries, mix in SpendHistoryContract the
same way. It checks the three properties from
Reading the ledger back: entries come back
uninterpreted, oldest first, and limit caps the most recent entries after the
connector filter rather than before it.
Which calls need nothing on the filesystem
A host with no project on disk can run the whole explore surface:inventory,
profile, relationships, map, query, and cluster need a connector and a
store and nothing else. diagram needs less still: it reads the cache out of the
store and renders it, so a host holding a populated store can call it with no
connector resolved and no credential in play. Hosted semantic-layer calls
(semantic_list and
semantic_query against dbt Cloud) need even less: no store, no connector, no
repo root.
A native semantic layer is the mirror image of that hosted case and worth stating
because the pairing is easy to get backwards. It needs a repo_root, since the
documents are files, and it needs no connector and no warehouse at all: reading,
validating, fingerprinting and authoring one are repository operations. The
connector enters only where the layer is resolved onto physical relations, which
is what decides whether a field links to a column.
Everything in transform needs repo_root, because the project is a filesystem
artifact and stays one, and so is a native semantic document. So does
explore map --use-project, which reads the project and the semantic layer to
rank and annotate what it found. Those refuse with a message naming what needed the root
rather than inventing one.
Selecting a backend
Two ways in. A library caller passes an instance,DexEngine(store=...), and that
always wins: a caller holding a store has already made the decision configuration
exists to make for the callers who have not. Everyone else names one in
.dex/config.yml:
options reaches your factory verbatim, and repo_root carries whatever directory
dex was pointed at. --cache-backend overrides the name for one run, the way
--connector overrides the configured connector; naming a different backend that
way leaves options behind, since one backend’s coordinates are not another’s and
the usual reason to reach for the flag is falling back to filesystem for a single
command.
It is an open registry, not a closed enum, so a backend published as its own
package is selectable without a change to dex. Three kinds of name resolve, in this
order:
The entry point is what makes a published backend feel like a shipped one:
backend: acme resolves. A shipped name always wins over
a registration, so installing a package can never silently move where an existing
repo’s state lands.
memory is deliberately not selectable. Each CLI command runs as its own process,
so a MemoryStore would drop the cache between explore map and explore query,
and the second command would refuse with “run explore map first” having just run
it. That reads as a broken tool rather than a chosen backend, so the refusal
explains the process boundary instead. It remains the default for a library caller,
where one process holds the engine.
Every failure here refuses with a ConfigurationError naming the fix: an unknown
name lists what exists and both open forms, a dotted path that will not import says
so and points at the environment, and a factory that builds something which is not
a store names the members it is missing. The tier check is on the constructed store
rather than on the factory, because a callable protocol can only verify __call__
exists.
Two rejected alternatives
Recorded so they are not re-argued. Always passrepo_root. It builds all three shipped backends, but it cannot
build a tenant-keyed backend at all. That is the class of backend this seam was
made public for, and widening a released config schema afterwards costs a
deprecation.
Require a from_config classmethod on the store. It puts an obligation on the
structural protocol, which is what makes “no base class to inherit and no
registration step” true, and it hands every backend author dex’s whole
configuration model to bind against. The factory contract keeps the two concerns
apart: a store is still just a class with the right methods, and construction is a
separate thing you implement only if you want it.
Source: references/storage.md (edit on main). This page is generated; change the source, not the site.