Concurrency¶
vaultly is built for the typical Python service shape: many threads sharing one config instance, possibly inside an async event loop, possibly under load.
Threading¶
A SecretModel is safe to share across threads. Reads and writes to the
internal cache are protected by a threading.Lock; cold-cache fetches
are protected by per-key threading.RLock so 100 threads asking for the
same secret simultaneously produce exactly one backend call.
config = AppConfig(stage="prod", backend=...)
# Safe from any number of threads.
db_pw = config.db_password
api_k = config.api_key
What's protected:
cache.get/cache.set/cache.invalidate/cache.peek_expired- The cold-cache fetch sequence (lock per resolved cache key)
refresh(name)(holds the per-key lock acrossinvalidate + fetch)prefetch()(acquires every per-key lock before the batch call)
What's not protected:
- Mutation of your model's own non-secret fields (e.g.
config.stage = "dev") — Pydantic doesn't lock these; you'd needmodel_config = ConfigDict(frozen=True)for true immutability or to do this only at startup. - Replacing the
backendfield (config.backend = new_backend) — semantically unsupported; reload by reconstructing the model.
Hot-path performance¶
Cache hits take only the lightweight cache lock, not the per-key
lock, so they scale across threads — the integration test
test_warm_cache_hot_reads_dont_serialize runs 200,000 reads across
20 threads in under a second.
A regression that ever serializes hot reads — say, by always taking the per-key lock — would balloon that number 100×.
Asyncio¶
vaultly is synchronous. There's no aget, no AsyncBackend. Calls
to config.db_password block the event loop while the backend round-trip
runs.
For typical configs that load at startup (validate="fetch") and serve
from cache thereafter, this is fine — the only blocking call happens
during boot.
For configs that fetch lazily during request handling and you can't
afford to block, wrap in asyncio.to_thread:
import asyncio
# Inside an async handler:
db_pw = await asyncio.to_thread(lambda: config.db_password)
A native async API is on the roadmap.
Process boundaries¶
vaultly doesn't share state across processes. Each process is its own cache:
multiprocessing— workers each construct their own model; each has its own backend + cache.gunicorn(forking) — child processes inherit the model from the parent; the cache is shared via fork-copy, but only as a snapshot. Mutations by one child don't propagate. Construct in each child if you rely on per-child rotation.gevent/eventlet— vaultly'sthreading.Lockbecomes a green-lock under monkey-patching. Should work but is not specifically tested.
Fork safety¶
vaultly doesn't currently install os.register_at_fork handlers. If you
fork after touching the cache, the child inherits a snapshot of the
in-memory state including the locks. Best practice: construct your
SecretModel after forking (in each worker), not before.
Per-tenant patterns¶
If your app multi-tenants by interpolating tenant id into the path:
class TenantConfig(SecretModel):
tenant_id: str
api_key: str = Secret("/tenants/{tenant_id}/api_key", ttl=300)
You probably want one TenantConfig instance per tenant, each with its
own cache. The default cache + lock dicts grow as new resolved paths are
seen — bounded by tenant_count × secrets_per_tenant.
For very high tenant counts, periodically reclaim:
# Drop a specific entry.
config._cache.invalidate(resolved_path)
config._fetch_locks.discard(resolved_path)
# Or nuke everything (e.g. at the end of a request).
config._cache.clear()
config._fetch_locks.clear()
These are private APIs but stable for this use case.