Skip to content
GitHub

Architecture

Internal design of the lexigram-cache package.


lexigram-cache is the caching abstraction layer in the Lexigram framework. It depends only on lexigram and lexigram-contracts. Other packages consume caching through CacheBackendProtocol resolved via the DI container — never through direct imports.

flowchart BT
    App[Application Layer]
    Ext[lexigram-* Extensions]
    LC[lexigram-cache<br/>CacheService · Backends · Stampede Protection]
    Core[lexigram<br/>DI · Config · Logging]
    Contracts[lexigram-contracts<br/>CacheBackendProtocol · CacheKeyBuilderProtocol]

    App --> Ext
    Ext --> LC
    LC --> Core
    LC --> Contracts
    Core --> Contracts

Import direction: Arrows point toward the dependency. Services depend on protocols from contracts; backends implement those protocols. The cache provider wires both sides through the container.


All cache backends implement CacheBackendProtocol from lexigram-contracts. Operations return Result[T, CacheError] — backend errors are signalled via Err rather than exceptions, letting callers decide how to handle failures.

flowchart LR
    subgraph Contracts[lexigram-contracts]
        CBP[CacheBackendProtocol<br/>get · set · delete · clear<br/>get_many · set_many · delete_many<br/>exists · delete_pattern · health_check]
    end
    subgraph Backends[lexigram-cache · backends]
        MEM[MemoryCacheBackend<br/>in-process dict<br/>TTL · LRU eviction]
        REDIS[RedisCacheBackend<br/>StateStoreProtocol<br/>pipeline · SCAN]
        MEMCACHED[MemcachedCacheBackend<br/>pymemcache]
    end
    subgraph ThirdParty[Third Party]
        EP[entry-point<br/>lexigram.cache.backends]
    end

    Backends --> CBP
    EP --> CBP
    CBP -->|DI binding| Provider[CacheProvider<br/>register · boot · shutdown]

Backends are configured via CacheConfig.backends. Each backend specifies a name, type (memory / redis / memcached), and default flag. The provider registers an unnamed CacheBackendProtocol singleton for the default backend and named singletons for all enabled backends:

container.singleton(CacheBackendProtocol, factory=lambda: self.get_backend(None))
container.singleton(CacheBackendProtocol, factory=lambda n=name: self.get_backend(n), name=name)

CacheService is the main high-level API. It composes three mixins:

ClassSourceAdds
CacheServicecore.pyget, set, delete, exists, clear, delete_pattern, get_typed, health check
PipelineMixinpipeline.pyget_many, set_many, delete_many
InvalidationMixininvalidation.pyset_with_tags, invalidate_by_tag
PatternsMixinpatterns.pyget_or_set, get_or_compute, get_or_set_result, remember
  • TTL jitter: set() applies ±10% random jitter to spread expiry times and reduce thundering-herd risk.
  • Fast path: Primitive values (str, int, float, bool) are stored directly without serialization round-trip.
  • Namespace scoping: Keys can be request-scoped via request_scoped=True, which prepends req:<request_id>: to the key.
  • Error handling: Backend errors are caught (RuntimeError, OSError, ConnectionError, etc.) and logged; get returns default, set/delete return False.
  • Metrics: Every operation updates an in-memory counter (hits, misses, errors, operations). Exposed via get_metrics() and reset_metrics().
cache = container.resolve(CacheService)
await cache.set("user:42", profile, ttl=300)
profile = await cache.get("user:42", default=None)
await cache.delete("user:42")
batch = await cache.get_many(["a", "b", "c"])
await cache.set_many({"x": 1, "y": 2}, ttl=60)
# Tag-based invalidation
await cache.set_with_tags("pet:1", pet, tags=["user:42"])
await cache.invalidate_by_tag("user:42")
# Type-safe access
count = await cache.get_typed("counter", type_=int, default=0)

Two complementary mechanisms prevent cache stampedes (thundering-herd problems):

_add_ttl_jitter() applies ±10% random variation to every TTL, spreading out natural expiry times so multiple keys don’t expire simultaneously.

2. Single-Flight Pattern (StampedeProtectedCache)

Section titled “2. Single-Flight Pattern (StampedeProtectedCache)”

StampedeProtectedCache wraps a CacheBackendProtocol (injected via @inject) and implements single-flight coalescing:

flowchart TD
    R1[Request A<br/>get_or_compute(key)]
    R2[Request B<br/>get_or_compute(key)]
    R3[Request C<br/>get_or_compute(key)]
    STAMP[StampedeProtectedCache]
    CACHE[Cache Backend]
    COMPUTE[Compute Function]

    R1 --> STAMP
    R2 --> STAMP
    R3 --> STAMP

    STAMP -->|try cache| CACHE
    CACHE -->|miss| STAMP
    STAMP -->|acquire lock| STAMP
    STAMP -->|double-check cache| CACHE
    CACHE -->|still miss| STAMP
    STAMP -->|single flight task| COMPUTE
    COMPUTE -->|value| STAMP
    STAMP -->|store + return| R1
    STAMP -->|return same| R2
    STAMP -->|return same| R3

Key implementation details:

MechanismHow It Works
In-process lockasyncio.Lock per key via WeakValueDictionary — concurrent requests for the same key serialize
Double-checkAfter acquiring the lock, re-checks cache (another request may have filled it)
Single-flight taskOne asyncio.Task per key; subsequent requests await the same task
XFetch early refreshProbabilistic early refresh: the closer to expiry, the higher the probability a request refreshes before the key expires
from lexigram.cache.service.stampede import StampedeProtectedCache
protected = StampedeProtectedCache(backend, lock_timeout=10)
# Only one compute call per key, regardless of concurrent requests
result = await protected.get_or_compute(
key="expensive:data",
compute=load_data_from_db,
ttl=300,
ttl_jitter=0.2,
)

When ttl_jitter > 0, StampedeProtectedCache applies probabilistic early refresh (XFetch). A request that finds a cached but partially-stale entry will refresh it with probability proportional to how close it is to expiry:

staleness = 1.0 - (time_left / ttl)
refresh if random() < staleness # higher probability near expiry

sequenceDiagram
    actor User as Container
    participant P as CacheProvider
    participant REG as BackendRegistry
    participant B as Backend Instance
    participant PROT as StampedeProtectedCache
    participant SVC as CacheService

    User->>P: CacheProvider(config)
    P->>P: configure(config)

    User->>P: register(container)
    P->>REG: BackendRegistry()
    P->>REG: register entry-point backends
    P->>P: bind factories (no I/O)
    Note over P: CacheBackendProtocol → lazy factory<br/>CacheService → lazy factory<br/>BackendRegistry → singleton<br/>CacheStatusRegistry → singleton

    User->>P: boot(container)
    P->>B: create_backend(config)
    Note over B: Redis connect<br/>or Memory init
    B-->>P: backend instance
    P->>PROT: StampedeProtectedCache(backend)
    P->>SVC: CacheService(provider, protection)
    Note over SVC: PipelineMixin + InvalidationMixin<br/>+ PatternsMixin composed

    User->>SVC: get/set operations
    SVC->>B: delegated backend call

    User->>P: shutdown()
    P->>SVC: close()
    P->>B: close()
    Note over P: services.clear()<br/>backends.clear()
class CacheProvider(Provider):
name = "cache"
priority = ProviderPriority.INFRASTRUCTURE # 10
config_key = "cache" # LEX_CACHE__* env vars
PhaseActionI/O?
__init__Store config, initialize serializersNo
register()Bind lazy factories, discover entry-point backends, register admin componentsNo
boot()Create backends (open connections), wire stampede protection, create services, register admin hooksYes
shutdown()Close services → close backends → clear stateYes

ContractSourcePurpose
CacheBackendProtocollexigram.contracts.infra.cachePrimary backend interface: get, set, delete, clear, exists, get_many, set_many, delete_many, delete_pattern, health_check
CacheKeyBuilderProtocollexigram.contracts.infra.cacheKey construction and namespace management
CacheProtectionStrategyProtocollexigram.contracts.infra.cacheStampede protection contract
CacheProviderProtocollexigram.contracts.infra.cacheProvider self-reference for DI resolution
CacheHealthCheckerProtocollexigram.contracts.infra.cacheBackend health checks
StateStoreProtocollexigram.contracts.infraLow-level key-value store (Redis backends)
AsyncStringSerializerProtocollexigram.contracts.core.serializationSerialization interface (JSON, Pickle, Msgpack)
SemanticCacheProtocollexigram.contracts.ai.llmSemantic caching (optional, requires faiss)
EmbeddingClientProtocollexigram.contracts.ai.llmEmbedding generation for semantic caching
HealthCheckResultlexigram.contracts.coreStructured health check type
HookRegistryProtocollexigram.contracts.coreLifecycle hook emission

PointMechanism
Custom backendImplement CacheBackendProtocol, register via lexigram.cache.backends entry point
Custom serializerImplement AsyncStringSerializerProtocol
Custom cache key builderImplement CacheKeyBuilderProtocol
Custom stampede strategyImplement CacheProtectionStrategyProtocol
Named multi-backendAdd entries to CacheConfig.backends with unique name
Cache status handlerImplement CacheStatusHandler, register with CacheStatusRegistry
Cache warmerSubclass or configure CacheWarmer
Semantic cachingInstall faiss, provide EmbeddingClientProtocol
Cacheable decoratorUse @cacheable from decorators.py
Repository cachingUse CacheRepository from repository/ with invalidation observer pattern
Admin widgetsRegistered via CacheAdminContributor; extend with custom handlers
Operator CLIExtend the lexigram cache CLI commands
HooksRegister HookRegistryProtocol action handlers (cache.hit, cache.miss, cache.evicted)

Third-party backends self-register using the lexigram.cache.backends entry point group. During register() the provider scans this group and calls each provider’s register() method:

pyproject.toml
[project.entry-points."lexigram.cache.backends"]
my_backend = "my_package:MyBackendProvider"

Each entry point must resolve to a Provider subclass. The provider handles container bindings for its backend; the CacheProvider’s boot() never references third-party types directly.