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(attribute, python_type)

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. The key is read by the attribute name the model declares (id when 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_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