Architecture
How Cachetic is put together, and the invariants that must not be broken.
The rules themselves live in Principles and do not change when the code does. This page is the other half: what the code currently looks like, and which test pins each part of it.
Layers
CacheticBase configuration, key naming, TTL, serialisation — no I/O
├── Cachetic 4 sync operations + cache (CacheProtocol)
└── AsyncCachetic 4 async operations + await cache() (AsyncCacheProtocol)
cachetic/_base.py holds everything that does not touch the network or disk.
Both clients inherit it, which is what keeps the storage format from forking:
there is exactly one implementation of _dump_any / _loads_any.
Below the clients, cachetic/extensions/_ttl.py is the same idea one layer
down — one definition of what ex means to a backend, so eight adapters cannot
each answer it differently.
Backends live in cachetic/extensions/ (sync) and cachetic/extensions/aio/
(async), each implementing a four-method protocol from cachetic/types/.
Invariants
Do not break these
- One value format.
CacheticandAsyncCacheticmust produce byte-identical payloads. Pinned byTestCrossClientInterop::test_identical_serialised_bytes. - Three storage formats, one reader. Principle 1
requires every version to read what earlier ones wrote; the mechanism is
_loads_any, which dispatches on Data URL (v0.7.0+), compressed legacy (v0.5.0+), and raw bytes (v0.1.0+). Detection must stay narrow: abytescache stores its payload verbatim, so pre-0.7.0 data can itself begin withdata:. Pinned bytests/test_version_compat.py. - Sync and async PostgreSQL create the same table. Both use
CREATE TABLE IF NOT EXISTS, so a mismatch diverges silently rather than raising. The schema lives once, incachetic/extensions/_postgres_sql.py. Pinned bytest_sync_and_async_share_one_ddl_definitionand bytest_async_ddl_matches_sync_ddl, which comparesinformation_schema. - URL parsing is shared.
cachetic/extensions/_url.pyis the only place that strips Cachetic's own parameters —?collection=,?table=,?pool_min_size=,?pool_max_size=. Everything else in the URL reaches the driver untouched, which is what keeps options likesslmodein effect for both clients. Pinned bytests/test_url.pyand bytest_libpq_options_reach_both_backends. - A connection URL never reaches a log line unmasked. Registry keys are
the raw URL — they have to be — and for Redis, MongoDB and PostgreSQL that
carries the password. Every
loggercall in both registries passes it throughhide_url_passwordfirst, and that function never raises and never echoes back a URL it could not redact. Pinned bytest_registry_never_logs_a_passwordandtests/utils/test_hide_url_password.py. - Backends answer the same call the same way. The TTL a backend is
handed means one thing everywhere, defined once in
cachetic/extensions/_ttl.py:Noneor non-positive stores without a deadline, positive expires. Left to the drivers this went four ways — diskcache stored an already-expired value, redis raised, MongoDB and PostgreSQL stored forever. It matters becauseCachetic.cacheis public. Pinned bytests/test_backend_parity.py. - The key on the wire is
prefix:key. Principle 4 is unenforceable from insideget_cache_key, sotests/test_key_scheme.pyreads the stored key back through the raw driver on each backend, and checks all four operations ask for the prefix. - Importing Cachetic imports no driver. Principle 5,
pinned by
tests/test_optional_imports.py, which has to run in a subprocess: the rest of the suite imports every driver at module scope. - A missing key and a stored
Noneare different things.getreturns itsdefaultonly for a real backend miss; a cache whoseTincludesNonecan storeNone, and reading it back is a hit. The distinction travels on theMISSINGsentinel in_base.py, which is what stopsget_or_raiseraising on a keyexistsreports. Pinned bytests/test_client_semantics.py.
Value format
Values are serialised as Data URLs:
The compression algorithm travels with the value, so a reader never needs to know
the writer's settings. Because the payload is always base64, it is always ASCII —
which is why the PostgreSQL backend can store it in a TEXT column.
Connection lifecycle
Principle 6 is the rule this section implements. Measured, for
every backend: constructing a Cachetic opens nothing, reaching for .cache
opens nothing — it only builds an adapter holding an EntryHandle — and the
first actual operation opens exactly one client, which every later instance on
that URL reuses. tests/test_resource_discipline.py pins all of it, including
that no value is kept in the process between calls.
Backend clients are shared between every instance that names the same URL. An
application typically builds several Cachetic objects — one per cached type —
and each opening its own connections would waste them for no gain. For MongoDB
and PostgreSQL the waste is not only sockets: pymongo keeps three monitor
threads per client and psycopg_pool a scheduler and its workers, so twenty
unshared instances would be sixty threads doing nothing.
There are two registries with a deliberately identical shape:
cachetic/extensions/_registry.py |
cachetic/extensions/aio/_registry.py |
|
|---|---|---|
| Key | (namespace, url) |
(loop, namespace, url) |
| Teardown | cachetic.close_all() |
await cachetic.aio.close_all() |
| Entry lock | threading.Lock |
asyncio.Lock |
| Eviction | only close_all() |
close_all(), plus a sweep of closed loops |
Both expose Entry, EntryHandle, acquire and close_all with the same
semantics, so what a contributor learns about one half applies to the other.
test_sync_and_async_registries_expose_the_same_api pins that.
Adapters must never hold on to an Entry. They keep an EntryHandle and
re-resolve through the registry on every operation, which costs one lock and one
dict lookup. That is what lets a cache used again after close_all() reconnect
instead of failing for the rest of the process. Pinned by
test_instance_recovers_after_close_all and its async twin.
Setup that must happen exactly once per client — create_index,
CREATE TABLE, pool.open() — is guarded by the entry's own lock and recorded
in entry.ensured, so a fresh client redoes it and a shared one does not.
A client the caller supplied is not the registry's. cache_url accepts a
live redis.Redis / diskcache.Cache — Principle 1 requires
it, v0.6.0 took one. CacheticBase.accept_a_live_backend_client moves it to
cache_client and puts a label where the URL was, because the registry keys on
the URL and this has none. The adapter holds the object directly instead of an
EntryHandle, so it is never shared, never swept, and never closed by
close_all(). Pinned by tests/test_supplied_client.py.
The registry has no upper bound
Entries are keyed by URL and only removed by close_all() or a dead loop, so
a URL built per request or per tenant keeps opening connections that are
never reused. Cache URLs must come from a fixed, small set. Both registries
warn once past BUSY_REGISTRY_SIZE entries, which is a smoke alarm, not a
limit. Eviction is deliberately not implemented: closing a client that other
live adapters are mid-operation on would trade a leak for a crash.
Event-loop affinity
Async drivers bind to the event loop that created their connection pool. A client
reused from another loop raises got Future attached to a different loop. The
async registry therefore keys by (loop, namespace, url) and uses two locks for
two different jobs:
| Guarded | Lock | Why |
|---|---|---|
| Registry lookup and client construction | threading.Lock |
Every client factory is synchronous, so the lock is never held across an await and stays safe when several threads each run a loop |
Awaitable setup — create_index, CREATE TABLE, pool.open() |
asyncio.Lock stored inside the entry |
Created under the loop that owns the entry, so it is never shared across loops |
Why not WeakKeyDictionary
Keying by a weak reference to the loop looks right and does nothing: every
async client stores a reference back to its loop, so the value keeps the key
alive and entries are never collected. Dead loops are swept explicitly on the
next acquire() instead — which, because adapters resolve through
EntryHandle, is every cache operation.
close_all() closes only the clients belonging to the running loop — a client
from another loop cannot be awaited from here. There is no reference counting, so
calling it while a request is in flight surfaces a raw driver error.
Disk is the one exception, and it is deliberate. diskcache has no async API and
its handles are not loop-bound, so the async disk adapter shares the synchronous
registry's entries and there is no per-loop entry for close_all to find. The
async teardown therefore also closes the DISK_NAMESPACE entries; without that,
an application that only ever awaits cachetic.aio.close_all() would never
release a single SQLite handle. Pinned by
test_close_all_releases_disk_handles.
Sweeping a dead loop cannot close its client. Every close these drivers
offer — Redis.aclose, AsyncMongoClient.close, AsyncConnectionPool.close —
is a coroutine, and there is no live loop to run it on. The sweep drops the
reference and the garbage collector releases the socket; it also cannot run at
all while the process makes no cache calls. The first time this happens is
logged at WARNING rather than DEBUG, because it means connections outlived the
last chance to close them deliberately.
Known trade-offs
These are decisions, not bugs. Change them only deliberately.
- Disk async is thread offload.
diskcachehas no async API. Cancelling an operation returns immediately while the worker thread finishes the write. - MongoDB and PostgreSQL expire late. Both store a whole-second deadline from
a truncated clock and compare with a strict
<, so an entry can outlive its TTL by up to a second. It never expires early. Matching the two backends exactly was judged more valuable than sub-second accuracy. - Expiry is cleaned up lazily, on read.
getandexistsdelete the entry they found expired. That delete is conditional on the deadline they read — an unconditional delete-by-key would drop a value written by a concurrentsetin between. Pinned across all four paths — sync and async,getandexists— bytests/test_lazy_expiry_race.py. Postgres shares one_fetchbetweengetandexists; Mongo'sexistscarries its own copy of the compare-and-delete, which is why testinggetalone was not enough. - Pre-0.7.0
bytesvalues need the writer'scompressionsetting. They carry no algorithm marker, and every byte string validates asbytes, so there is no failure to recover from. Every other value type recovers on its own, and 0.7.0 values are self-describing. - Which compression algorithm gets used depends on the environment. zstd
when
zstandardis importable, zlib otherwise. Values say which, so a reader only needs the matching library — but a reader without thezstdextra cannot read what a writer with it produced. Processes sharing a cache must agree on the extra. - PostgreSQL keeps one connection warm by default. psycopg's own default is
four, which multiplies across every process in a deployment for what is
optional infrastructure.
?pool_min_size=/?pool_max_size=raise it.max_sizehas to be set explicitly — psycopg readsNoneas "same asmin_size", which would serialise every query. ensuredstate lives on the registry entry, not in a module-level set, so a fresh client re-creates its index or table and a shared one does not.- zstd contexts are per thread, not per process.
zstandard's one-shotcompress/decompressreuse an internal C context, so one shared instance segfaults the interpreter under concurrent use — and Cachetic clients are designed to be shared._ZSTD_LOCALincachetic/utils/compression.pykeeps the "build it once" saving without the sharing, at the cost of one context per thread. Pinned bytest_zstd_is_safe_from_many_threads, which runs in a subprocess because the failure it guards against is a SIGSEGV. - Losing the
CREATE TABLErace is not an error. The entry lock serialises table creation within a process, but the registry is process-local andIF NOT EXISTSis not atomic across sessions. Both PostgreSQL backends catchDuplicateTable/UniqueViolationaround the DDL and treat the table as ensured — a fleet starting at once against an empty database would otherwise have one instance fail to start. Caught outside the connection block so the rollback runs first. default_ttl=0disables the client, it does not evict. The semantics are fixed by Principle 3. All four operations consultdisabledbefore the backend: reads miss, writes are dropped whateverexthe call carries, and onlydeletestill runs. An earlier version checked it ingetandexistsbut notset, so an explicitexresolved on its own and wrote a value that same client refused to read back. Pinned bytest_sync_disabled_client_drops_a_write_carrying_an_explicit_exand its async twin.