loom.core.cache¶
- class loom.core.cache.BatchFingerprintResolver(*args, **kwargs)[source]¶
Bases:
ProtocolOptional capability: fingerprint several tag groups in one round trip.
A
DependencyResolverthat also implements this protocol is asked for every fingerprint of a page at once, which turns the per-entity counter lookups of a cached list into a single backend read. Resolvers that do not implement it keep working throughDependencyResolver.fingerprint(), one call per group.Implementations must return exactly the same value
fingerprintwould return for each group, in the same order astag_groups.
- class loom.core.cache.CacheGateway(*, alias='default')[source]¶
Bases:
objectFacade over aiocache with msgpack serialization for entity data.
Two distinct usage modes:
Data gateway — configured with
MsgspecSerializer. Used byCachedRepositoryfor entity and list storage. All values are msgpack-encoded on write and decoded on read.Counter gateway — configured without a serializer (raw backend). Used by
GenerationalDependencyResolverfor generation counter storage. Values are stored as plain Python integers, enabling atomic native increment on both Redis andSimpleMemoryCache.The gateway auto-detects which mode it is in at construction time and routes
incr()accordingly — no manual configuration needed beyond choosing the right aiocache alias.- Parameters:
alias (str) – Registered aiocache alias to retrieve the backend from.
Example:
data_gateway = CacheGateway(alias=config.aiocache_alias) counter_gateway = CacheGateway(alias=config.effective_counter_alias) resolver = GenerationalDependencyResolver(counter_gateway)
- static configure(raw_config)[source]¶
Apply a configuration mapping to the global aiocache registry.
- classmethod apply_config(config)[source]¶
Configure aiocache from a
CacheConfig.Equivalent to
configure()but also injectsconfig.max_sizeinto everyaiocache.SimpleMemoryCachebackend entry that does not already declare its ownmax_size. Entries for other backend types (Redis, Memcached, …) are forwarded unchanged.- Parameters:
config (CacheConfig) – Resolved cache configuration.
- Return type:
None
Example:
cache_cfg = CacheConfig( aiocache_alias="cache", counter_alias="counters", max_size=1000, aiocache_config={ "cache": {"cache": "aiocache.SimpleMemoryCache", ...}, "counters": {"cache": "aiocache.SimpleMemoryCache"}, }, ) CacheGateway.apply_config(cache_cfg)
- async get_value(key: str, *, type: type[T]) T | None[source]¶
- async get_value(key: str, *, type: None = None) Any
- async get_value(key: str, *, type: type[T] | None = None) T | Any | None
Retrieve a cached value, optionally converting it to the given type.
- async multi_get_values(keys, *, type=None)[source]¶
Retrieve multiple values in a single round-trip.
- async set_value(key, value, ttl=None)[source]¶
Store a value under the given key.
On a serialised backend the value is encoded here, so a value the serializer rejects raises
CacheWriteErrorand nothing reaches the backend. A raw backend stores value as is.- Parameters:
- Raises:
CacheWriteError – If the backend serializer cannot encode value.
- Return type:
None
- async multi_set_values(pairs, ttl=None)[source]¶
Store multiple key-value pairs in a single round-trip.
On a serialised backend every pair is encoded before any is written, so one rejected value stores none of them.
- async incr(key, delta=1)[source]¶
Increment a numeric value at the given key.
Routes to the optimal strategy based on the backend configuration detected at construction time:
Raw backend (no serializer): delegates to the backend’s native
incrementmethod when available. On Redis this is an atomicINCR/INCRBYcommand; onSimpleMemoryCacheit is asyncio-safe. Falls back to GET+SET for backends that do not exposeincrement.Serialized backend (
MsgspecSerializer): uses a non-atomic GET+SET. Correct for single-process deployments; the asyncio event loop does not preempt between the two awaits within a single coroutine execution.
- class loom.core.cache.CachedCalls(*args, **kwargs)[source]¶
Bases:
ProtocolBinder that turns declared coroutines into cached ones.
- class loom.core.cache.CacheBackend(*args, **kwargs)[source]¶
Bases:
ProtocolAbstract cache backend defining the contract for key-value storage operations.
- async get_value(key, *, type=None)[source]¶
Retrieve a value by key, optionally converting it to the given type.
- async set_value(key, value, ttl=None)[source]¶
Store a value under the given key with an optional TTL in seconds.
- async multi_get_values(keys, *, type=None)[source]¶
Retrieve multiple values by their keys in a single round-trip.
- async multi_set_values(pairs, ttl=None)[source]¶
Store multiple key-value pairs in a single round-trip.
- class loom.core.cache.CacheConfig(*, enabled=True, aiocache_alias='default', counter_alias=None, aiocache_config=<factory>, default_ttl=200, default_list_ttl=120, ttl_jitter=0.1, ttl=<factory>, max_size=None)[source]¶
Bases:
LoomFrozenStructConfiguration for cache behaviour including TTLs and aiocache backend settings.
Unknown keys are rejected: a misspelt override (
ttls:) would otherwise disable every TTL in silence. Theaiocache:short key thatfrom_mapping()accepts is therefore not read from YAML; declareaiocache_config:.- Parameters:
- aiocache_alias¶
Named alias for the aiocache data backend. The data backend must be configured with
MsgspecSerializer.- Type:
- counter_alias¶
Named alias for the counter backend used by
GenerationalDependencyResolver. This backend must have no serializer so that native atomic increment operations (RedisINCR,SimpleMemoryCache.increment) work correctly. Defaults toNone, meaning the same alias asaiocache_aliasis used (safe for single-process deployments; uses a non-atomic GET+SET fallback automatically).- Type:
str | None
- aiocache_config¶
Raw configuration mapping forwarded to
aiocache. Keyed by alias name. Useapply_configto havemax_sizeinjected automatically for memory backends.
- ttl_jitter¶
Fraction of the TTL used as a random spread on every cache write, in
[0, 1). A write receives its TTL multiplied by a random factor in[1 - ttl_jitter, 1 + ttl_jitter](never below one second), so entries populated in the same burst do not expire at the same instant.0disables the spread.- Type:
- ttl¶
Per-entity TTL overrides keyed by entity name. Append
_listfor list overrides (e.g.{"user": 300, "user_list": 150}).
- max_size¶
Maximum number of entries for
aiocache.SimpleMemoryCachebackends. Injected automatically when callingapply_config(). Has no effect on Redis or other non-memory backends.- Type:
int | None
Example YAML (Redis + separate counter backend):
cache: aiocache_alias: cache counter_alias: counters default_ttl: 300 default_list_ttl: 120 ttl_jitter: 0.1 max_size: 1000 ttl: user: 600 user_list: 300 aiocache_config: cache: cache: aiocache.RedisCache endpoint: ${oc.env:REDIS_HOST,redis} port: 6379 namespace: myapp serializer: class: loom.core.cache.serializer.MsgspecSerializer counters: cache: aiocache.RedisCache endpoint: ${oc.env:REDIS_HOST,redis} port: 6379 namespace: myapp_counters # no serializer — raw integer storage, atomic Redis INCRExample YAML (in-memory for development):
cache: aiocache_alias: cache counter_alias: counters max_size: 500 aiocache_config: cache: cache: aiocache.SimpleMemoryCache serializer: class: loom.core.cache.serializer.MsgspecSerializer counters: cache: aiocache.SimpleMemoryCache # no serializer
- property effective_counter_alias: str¶
Return the resolved counter alias, falling back to
aiocache_alias.
- classmethod from_mapping(data)[source]¶
Create a
CacheConfigfrom a raw dictionary (e.g. parsed TOML/YAML).
- exception loom.core.cache.CacheWriteError[source]¶
Bases:
ValueErrorA value could not be serialised for the cache; nothing was stored.
- class loom.core.cache.CachedRepository(repository, *, config, cache, dependency_resolver)[source]¶
Bases:
Repository[OutputT,CreateT,UpdateT,IdT],Generic[OutputT,CreateT,UpdateT,IdT]Cache-aside wrapper with generational invalidation.
Attributes the wrapper does not define pass through to the wrapped repository, so its capability set (
create_many,count, custom queries) is exactly the wrapped one.The cached list path reloads the entities missing from a warm index with a single
<primary key> IN (...)query, so the wrapped repository should supportINon the primary key; a repository whoseallowed_filter_fieldsexcludes the primary key is detected and served with per-id reads instead. The key is read by the attribute name the model declares (idwhen there is no model or the model declares no primary key).Concurrent misses of the same key on the entity read and on a
@cache_queryread are coalesced inside the process: the first caller loads and the rest await that result, so a burst on a hot key costs one repository call. Two conditions bound that:The wrapper must be application-scoped. A wrapper built per request has a coalescing group of one, so it never coalesces anything and only pays the bookkeeping.
Coalescing is skipped while the wrapped repository reports a caller-scoped session (
has_caller_scoped_session()), which is what a@transactionalscope or a unit of work binds. A coalesced load runs in its own task and outlives the caller that started it, so inside a transaction it would query a session already closed by that caller’s teardown, and would serve another caller the uncommitted writes of the first. Inside a transaction the read runs inline.
Callers that miss together are served the same object by the coalesced load, on the entity read as on a
@cache_queryread, while a caller served from the cache gets a freshly decoded one; treat a cached result as immutable, since aBaseModelstruct is mutable unless declared frozen.The cached list and query reads are deliberately left out of the coalescing: their miss path is not a pure loader — it caches the page’s entities as a side effect — and its result feeds
_load_items_from_index, which re-enters the coalesced entity read, so the herd is already collapsed one level down.Every TTL is spread inside
ttl_jitteras it reaches the backend, so two write calls do not choose the same expiry. The spread is per write call: a batch write (a cached page, an index refill) carries one TTL for the whole batch becauseCacheBackendtakes one TTL per call, so the rows of a single page still expire together.- Parameters:
repository (Repository[OutputT, CreateT, UpdateT, IdT])
config (CacheConfig)
cache (CacheBackend)
dependency_resolver (DependencyResolver)
- async get_by_id(obj_id, profile='default')[source]¶
Fetch a single entity by its primary key.
- Parameters:
obj_id (IdT) – Primary key of the entity.
profile (str) – Loading profile name for eager-load options.
- Returns:
The entity output struct, or
Noneif not found.- Return type:
OutputT | None
- async get_by(field, value, profile='default')[source]¶
Fetch one entity by arbitrary field.
This path intentionally delegates to the wrapped repository without cache-aside behavior for now. Field-based lookups can target mutable columns and the cache invalidation surface is broader than id-based access; keeping it uncached preserves correctness while the lookup cache policy is designed explicitly.
- async exists_by(field, value)[source]¶
Check existence by arbitrary field.
Existence checks are delegated directly to the wrapped repository to avoid stale negative/positive cache entries on mutable fields.
- async count()[source]¶
Count every entity, forwarded uncached to the wrapped repository.
Counts are not cached: a total changes on every write and a stale value is worse than the single round trip.
- Return type:
- async list_paginated(page_params, filter_params=None, profile='default')[source]¶
Fetch a paginated list of entities.
- Parameters:
page_params (PageParams) – Pagination parameters (page and limit).
filter_params (FilterParams | None) – Optional filter criteria.
profile (str) – Loading profile name for eager-load options.
- Returns:
A
PageResultwith the matching items and pagination metadata.- Return type:
PageResult[OutputT]
- async list_with_query(query, profile='default')[source]¶
Fetch entities using a structured
QuerySpec.Supports both offset and cursor pagination, structured filters, and explicit sort directives. The concrete return type depends on
query.pagination:PaginationMode.OFFSET→PageResultPaginationMode.CURSOR→CursorResult
- Parameters:
- Returns:
A
PageResultfor offset queries or aCursorResultfor cursor queries.- Return type:
PageResult[OutputT] | CursorResult[OutputT]
- async create(data)[source]¶
Persist a new entity.
- Parameters:
data (CreateT) – Creation payload struct.
- Returns:
The newly created entity output struct.
- Return type:
OutputT
- async update(obj_id, data)[source]¶
Apply a partial update to an existing entity.
- Parameters:
obj_id (IdT) – Primary key of the entity to update.
data (UpdateT) – Partial update payload struct.
- Returns:
The updated entity output struct, or
Noneif not found.- Return type:
OutputT | None
- async delete(obj_id)[source]¶
Delete an entity by its primary key.
- Parameters:
obj_id (IdT) – Primary key of the entity to delete.
- Returns:
Trueif the entity was deleted,Falseif not found.- Return type:
- async on_transaction_committed(events)[source]¶
Bump the tags of the mixins’ events and forward them downstream.
Under
@transactionalthe mixins’ tagged events arrive here in the plain post-commit lane, while the wrapper’s own event runs in the shielded priority lane queued bycreate/update/delete. Both bumps are wanted: they are idempotent, and only this lane carries the relation/projection tags the wrapper cannot compute.- Parameters:
events (tuple[MutationEvent, ...]) – Mutation events committed by the wrapped repository.
- Return type:
None
- class loom.core.cache.DependencyResolver(*args, **kwargs)[source]¶
Bases:
ProtocolProtocol for cache dependency tracking and invalidation.
- async fingerprint(tags)[source]¶
Compute a composite fingerprint from the current generation of each tag.
- async bump_from_events(events)[source]¶
Increment generation counters for the tags affected by mutation events.
A resolver implemented outside the framework keeps whatever granularity it implements; see
GenerationalDependencyResolverfor the built-in policy.- Parameters:
events (tuple[MutationEvent, ...]) – Mutation events produced within a transaction.
- Return type:
None
- class loom.core.cache.GenerationalDependencyResolver(cache)[source]¶
Bases:
DependencyResolver,BatchFingerprintResolverGenerational tags with monotonic counters in cache backend.
A mutation bumps only the tags it affects (see
bump_from_events()), so updating one row leaves every other cached row of the same entity warm. The bareentitytag stays in every key’s tag list but the framework never bumps it: it is the manual entity-wide flush handle. An operator incrementstag:<entity>by hand to evict every key of that entity at once.- Parameters:
cache (CacheBackend)
- async fingerprint(tags)[source]¶
Compute a composite fingerprint from generation counters of all tags.
- async fingerprint_many(tag_groups)[source]¶
Compute one fingerprint per tag group, reading every counter once.
The distinct tags of every group are read together, in batches, so the cost is one round trip per
COUNTER_BATCH_SIZEdistinct tags instead of one per group.
- async bump_from_events(events)[source]¶
Increment generation counters for the tags affected by mutation events.
Every event bumps
entity:listandentity:id:<k>per id; the event’s owntagsare bumped as they come. The bareentitytag is never bumped by the framework: it is reserved for a manual entity-wide flush.The counters are bumped concurrently rather than one round trip at a time: with K distinct tags, a sequential loop pays K round trips in series, while awaiting them together pays roughly one round trip regardless of K once the backend is network-bound (see the benchmark referenced in the PR that introduced this).
- Parameters:
events (tuple[MutationEvent, ...]) – Mutation events to process.
- Return type:
None
- class loom.core.cache.MsgspecSerializer[source]¶
Bases:
objectaiocache serializer backed by msgspec msgpack.
- loom.core.cache.cache_call(*, ttl_key=None, unless=None, version=1)[source]¶
Mark a coroutine whose result a bound
CachedCallsmay store.The decorator only declares: it writes the policy on the function and returns the very same object, so the module imports with no configuration and the coroutine stays importable and unit-testable on its own. The composition root binds it later.
The coroutine must be a pure function of its arguments: it may not read ambient identity — a contextvar tenant, a caller’s credential — and may not hold a caller-scoped session. The key sees only the arguments, and the load is detached into its own task, so a coroutine that reads ambient state serves one caller’s answer to another.
A cached call is a TTL cache with no invalidation: unlike a repository read it carries no dependency tags, because loom cannot know what a coroutine depends on. It expires, or the caller bumps version.
- Parameters:
ttl_key (str | None) – Key whose
ttl:override applies. It shares the namespace with entity TTLs, so a key equal to an entity name deliberately shares that entity’s override.unless (Callable[[Any], bool] | None) – Predicate over the result; truthy means the result is returned and nothing is stored. An empty answer from a rate-limited service is the case it exists for.
version (int) – Bump to invalidate every entry this function already wrote.
- Returns:
The decorator that marks the coroutine.
- Raises:
TypeError – The decorated object is not a coroutine function. A cached call is awaited once and its single result is stored, which a plain function, a generator and an async generator cannot honour.
- Return type:
Callable[[F], F]
- loom.core.cache.cached_calls(container)[source]¶
Return the container’s binder, or a pass-through when it has none.
A container built outside the two bootstraps that apply the cache module has no binding, and
LoomContainer.resolve()raises for one. A factory called with such a container gets the announcing pass-through instead, so it never has to guess how its application was built.- Parameters:
container (LoomContainer) – Container handed to the factory.
- Returns:
The registered binder, or a fresh pass-through.
- Return type:
- loom.core.cache.cache_query(*, scope='list', ttl_key=None)[source]¶
Declarative marker for custom repository read methods.
Annotate the return type. The wrapper derives a codec from it and applies it to the cached read and to the fresh one alike, so a hit and a miss return the same type; the supported grammar is a
msgspec.Struct, a scalar, or alist,tupleor optional of those. A return type outside it — a mapping, a generic container,Any, a forward reference the defining module cannot resolve — emits aDeprecationWarningwhen the repository is wrapped, and keeps the old behaviour, where the cached call returns the decoded payload rather than the declared type.The declared type is what the caller gets, on the fresh call as on the cached one: a method annotated
-> Statsthat returns a subclass ofStatshands back a narrowedStats, and the fields the subclass added are dropped. Declare the type you mean to return.A method that returns
Noneis not cached: the backend cannot tell a storedNonefrom a miss, so the read runs again next time. A cached payload that no longer fits the declared type — an older deployment wrote it, and the type has since gained a field — is treated as a miss and overwritten, not raised to the caller.Treat the returned value as immutable. Concurrent callers that miss together are served the same object by the coalesced load, while a caller served from the cache gets a freshly decoded one, so mutating a result makes the two paths disagree. Return a struct, or a fresh copy.
scope="entity"requires the model’s primary key as the first positional argument (a keyword argument does not count); the wrapper raisesTypeErrorotherwise, before touching the cache backend. When the primary-key type resolves to a plain class the argument must be of that exact type — adatetimefor adatekey or aboolfor anintkey is rejected — while a key declaredint | Noneresolves no class and gets no call-time validation. A read keyed by any other field is a list-scoped read.- Parameters:
- Returns:
The decorator that marks the method.
- Return type:
Callable[[F], F]