Key/Value Store (Valstore)
Sometimes a module needs to keep a small piece of shared, short-lived state that doesn’t belong in a database table — a rate-limit counter, a cached result of an expensive external call, a cross-worker “already ran today” flag, a one-time verification token. Fullfinity gives you a fast key/value scratchpad for exactly this, backed by the platform’s Valkey instance.
You reach it through the environment, naming your module:
store = env.valstore("crm")
await store.set("digest_sent", True, ttl=86400) # expires in 24hif await store.get("digest_sent"): return # already sent today
calls = await store.incr("api_calls") # atomic counterThat’s the whole surface you need. The rest of this page covers the method reference, isolation guarantees, and when not to use it.
Why a dedicated store
Section titled “Why a dedicated store”Two things make this different from just caching in a Python variable or writing your own key/value rows:
- It’s shared across every worker and request. Fullfinity runs multiple worker processes;
a module-level dict or an
lru_cachelives in one of them. A value written to the store is immediately visible to all of them. - It’s isolated for you automatically. Every key you write is namespaced to your module
and the current database, so you never have to think about collisions with another module,
another tenant, or the platform’s own internals. Two modules can both use the key
"counter"and never see each other’s value; tenant A can never read tenant B’s.
You always pass your module identifier as the namespace (env.valstore("crm")). The namespace
is required — there is no default.
Method reference
Section titled “Method reference”| Method | Description |
|---|---|
await store.set(key, value, *, ttl=None) | Store a JSON-serializable value. ttl is seconds-to-live. Set a ttl on almost everything (see below). |
await store.get(key, default=None) | Return the value, or default if the key is absent or expired. |
await store.delete(*keys) | Delete one or more keys; returns how many existed. |
await store.exists(key) | True if the key is set. |
await store.incr(key, by=1) | Atomically add by (default 1) to an integer counter and return the new value. Interoperates with get/set for integers. |
await store.expire(key, ttl) | Set/refresh the TTL (seconds) on an existing key. Returns False if the key is absent. |
await store.ttl(key) | Remaining TTL in seconds; -1 if the key has no expiry, -2 if it doesn’t exist. |
await store.keys() | List the bare keys currently set in your namespace (non-blocking scan). |
await store.clear() | Delete every key in your namespace; returns the count removed. Only ever touches your own keys. |
store.lock(key, timeout=10.0) | A cross-worker distributed lock scoped to your namespace (see below). |
Values are JSON
Section titled “Values are JSON”Anything JSON-round-trippable works — str, int, float, bool, None, list, dict:
await store.set("preferences", {"theme": "dark", "rows": 50})prefs = await store.get("preferences") # -> {"theme": "dark", "rows": 50}A stored None is distinct from an absent key: get("missing", "x") returns "x",
but after set("k", None), get("k", "x") returns None.
Counters and sliding windows
Section titled “Counters and sliding windows”incr is atomic across workers, so it’s the right tool for usage counters and rate limits.
Pair it with expire to make a sliding window:
# Allow 100 API calls per hour, per companykey = f"ratelimit:{env.company_id}"count = await store.incr(key)if count == 1: await store.expire(key, 3600) # start the window on the first hitif count > 100: raise UserError("Rate limit exceeded — try again later.")Cross-worker locks
Section titled “Cross-worker locks”When a section must run on only one worker at a time — rebuilding a shared cache, draining a queue, a nightly job that several workers might each trigger — use a distributed lock. It auto-extends while held and auto-expires if the holder crashes, so you can’t deadlock the system by dying mid-section:
async with env.valstore("crm").lock("nightly-rebuild"): await rebuild_shared_cache() # only one worker is ever inside this blockWhen not to use it
Section titled “When not to use it”The store is a cache and coordination scratchpad, not a database. Follow these rules:
- Set a
ttlon almost everything. A key with no expiry lives until something deletes it. The store is not swept for you, so un-TTL’d keys accumulate. - Never keep anything here you can’t recalculate or afford to lose. Treat every read as if the key might be gone. Anything that must survive and be queried, reported on, or related to other records belongs in a model/table, not the store.
- Keep values small. It’s for flags, counters, tokens, and small blobs — not large documents or lists of records.
If you find yourself reaching for keys() to iterate a dataset, or storing records you later
need to filter or join, that’s a signal the data wants a model instead.
env.valstore(...) is the only door
Section titled “env.valstore(...) is the only door”env.valstore(...) is the only supported way for a module to use the platform’s Valkey.
The engine keeps its own separate, internal Valkey databases for session tokens, the ORM cache,
and cross-worker coordination; module code must never touch those. This is checked
mechanically — a module that imports the low-level Valkey client instead of using
env.valstore(...) is rejected by the build check and refused at startup — so reach for
env.valstore(...) and you never have to think about it.