loom.core.cache.repository

Functions

_derive_delegated_methods()

Return the Repository methods CachedRepository overrides.

_infer_otm_cache_dep(model, attr_name, rel)

Auto-infer entity:fk_col cache spec for a ONE_TO_MANY relation.

_infer_projection_cache_dep(model, proj)

Auto-infer entity:fk_col cache spec for a projection loader.

_is_cached_read(attr)

Whether attr is a coroutine function marked with @cache_query.

_protocol_surface(protocol)

Return the public method names protocol and its bases declare.

_require_full_repository(repository)

Check that a @cached repository provides every delegated method.

_resolve_primary_key(repository)

Resolve the primary key of the wrapped repository's model.

Classes

CachedRepository(repository, *, config, ...)

Cache-aside wrapper with generational invalidation.

_DependencySpec(entity, fk_field)

_ListIndexPayload(ids, total_count)

_PrimaryKey(attributes, python_types)

Primary key of the cached model as the wrapper reads it.

_QueryIndexPayload(ids[, total_count, ...])

class loom.core.cache.repository.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 support IN on the primary key; a repository whose allowed_filter_fields excludes the primary key is detected and served with per-id reads instead, and so is a model with a composite primary key, which a filter on one field cannot match. The key is read by the attribute names the model declares (id when there is no model or the model declares no primary key); a composite key is the tuple of its values in declaration order.

Concurrent misses of the same key on the entity read and on a @cache_query read 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 @transactional scope 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_query read, while a caller served from the cache gets a freshly decoded one; treat a cached result as immutable, since a BaseModel struct 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_jitter as 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 because CacheBackend takes one TTL per call, so the rows of a single page still expire together.

Parameters:
property entity_name: str

Normalized name of the cached entity.

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 None if 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.

Parameters:
Return type:

OutputT | None

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.

Parameters:
Return type:

bool

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:

int

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 PageResult with 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:

Parameters:
  • query (QuerySpec) – Structured query specification.

  • profile (str) – Loading profile name for eager-load options.

Returns:

A PageResult for offset queries or a CursorResult for 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 None if 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:

True if the entity was deleted, False if not found.

Return type:

bool

async on_transaction_committed(events)[source]

Bump the tags of the mixins’ events and forward them downstream.

Under @transactional the mixins’ tagged events arrive here in the plain post-commit lane, while the wrapper’s own event runs in the shielded priority lane queued by create/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