This document describes Memento's internal architecture. It's intended for contributors and those who want to understand how the library works under the hood.
Memento has a layered architecture:
┌─────────────────────────────────────────┐
│ memento.core (API) │ ← User-facing functions
├─────────────────────────────────────────┤
│ memento.mount (MountPoint) │ ← Function ↔ Cache binding
├─────────────────────────────────────────┤
│ memento.caffeine (CaffeineCache) │ ← Cache implementation
├─────────────────────────────────────────┤
│ Java classes (performance-critical) │ ← Low-level operations
└─────────────────────────────────────────┘
Cache (ICache): Stores key-value pairs. One cache can serve multiple functions.
MountPoint (IMountPoint): Connects a function to a cache. Contains:
This separation enables:
A Segment contains metadata about a memoized function binding:
public class Segment {
public final IFn f; // Original function
public final IFn keyFn; // Key transformation function
public final Object id; // Identifier (typically var name)
public final Object conf; // Mount configuration
}
Cache entries are keyed by CacheKey, which combines the segment ID with transformed arguments:
public class CacheKey {
public final Object id; // Segment identifier
public final Object args; // Transformed function arguments
}
This allows multiple functions to share a cache while keeping their entries separate.
Performance-critical code is implemented in Java to:
myns$myfn.invoke
clojure.lang.AFn.applyToHelper
clojure.lang.AFn.applyTo
clojure.core$apply.invokeStatic
clojure.core$apply.invoke
memento.caffeine.CaffeineCache$fn__2536.invoke
memento.caffeine.CaffeineCache.cached
memento.mount.UntaggedMountPoint.cached
memento.mount$bind$fn__2432.doInvoke
clojure.lang.RestFn.applyTo
clojure.lang.AFunction$1.doInvoke
clojure.lang.RestFn.invoke
myns$myfn.invoke
clojure.lang.AFn.applyToHelper
memento.caffeine.CaffeineCache$fn__2052.invoke
memento.caffeine.CaffeineCache.cached
memento.mount.CachedFn.invoke
From 11 stack frames to 4.
memento.baseICache: Core cache interface with methods like cached, invalidate, addEntriesSegment: Function binding metadataCacheKey: Composite key (id + args)EntryMeta: Wrapper for cached values with metadata (tag IDs, no-cache flag)TagInvalidation: Tracks active tag invalidations by epochDurations: Time unit conversionsmemento.mountIMountPoint: Interface for mount pointsCached: Marker interface for memoized functionsCachedFn: IFn implementation that delegates to mount pointCachedMultiFn: MultiFn wrapper for memoized multimethodsmemento.caffeineCaffeineCache_: Core Caffeine operationsSecondaryIndex: Maps tag+ID pairs to cache keys for bulk invalidationExpiry: Interface for variable per-entry expirySpecialPromise: Promise that tracks invalidation state during loadsmemento.multiMultiCache: Base class for tiered cachesTieredCache: Both caches updated on missConsultingCache: Only local updated on missDaisyChainCache: Local never updatedCaffeine ensures only one load happens per key. If multiple threads request the same uncached key simultaneously:
CompletableFutureIf a key is invalidated while being loaded:
SpecialPromise is marked invalidTag invalidation is more complex because:
The TagInvalidation tracker coordinates bulk invalidations:
public class TagInvalidation {
// Map of [tag, id] -> invalidation epoch
// When invalidation starts, entry is added
// Loads compare their start epoch with active tag invalidation epochs
// When invalidation completes, the entry is removed if the epoch still matches
}
InvalidationClock value as the load's epoch.latestInvalidation as the max of
the segment's invalidation epoch, the cache's invalidation epoch, and
TagInvalidation.lastInvalidatedEpoch for the result's tag IDs.latestInvalidation, or any
of the result's tag IDs were marked invalid on the load's SpecialPromise
during the load, discard the result and retry.SpecialPromise via deliver.
Concurrent invalidate() calls also CAS the promise to EntryMeta.absent;
whichever wins determines the outcome. If deliver lost the race, the loader
removes the entry from the delegate map and retries.deliver wins, build the canonical CacheEntry, replace the promise
with it in the delegate map, then call p.reject() on the promise. Rejection
forcibly clears the promise's published value so any joiners blocked in
await() wake up, observe absent, and re-loop through the delegate map.
This redirects joiners to the now-published CacheEntry (or its successor)
and is the mechanism that lets joiners revalidate against any invalidation
that arrived between deliver and replace.Note: invalidateIds on ICache only updates its own cache's loads set and
secondary index. Cross-cache visibility of an ongoing tag invalidation is
provided by TagInvalidation.startInvalidation / endInvalidation, which
memento.core/memo-clear-tags! wraps around the per-cache invalidation calls.
SpecialPromise.result is updated through an AtomicReferenceFieldUpdater.
The transitions are:
deliver / deliverException: CAS from null to a published value. Fails
if another writer (typically invalidate) already moved the field.invalidate: getAndSet to EntryMeta.absent. Always wins; only interrupts
the loader thread if it observed a non-absent prior value (i.e. it actually
clobbered something, ensuring the interrupt has a meaningful target).reject: unconditional set to EntryMeta.absent. Used by the loader after
it has published the canonical CacheEntry to the delegate map, to push
joiners off the promise channel and back through the map.When a tag is invalidated while a load is in progress for an entry with that tag:
The SecondaryIndex maintains mappings from tag+ID pairs to cache keys:
Tag: :user
ID: 123 -> #{CacheKey[get-user, [123]], CacheKey[get-orders, [123]]}
ID: 456 -> #{CacheKey[get-user, [456]]}
Tag: :order
ID: 789 -> #{CacheKey[get-order, [789]], CacheKey[get-order-items, [789]]}
When memo-clear-tag! is called:
Cached values are wrapped in EntryMeta which tracks:
noCache flag from do-not-cache)public class EntryMeta {
public final Object v; // The cached value
public final boolean noCache; // If true, don't cache this
public final Set tagIdents; // Set of [tag, id] pairs
}
In development, namespaces are frequently reloaded. When a memoized function's var is redefined:
Reload guards use Java finalizers to clean up:
Disable for production: -Dmemento.reloadable=false
(m/create {mc/ttl [5 :m]})
memento.base/new-cache multimethod dispatches on mc/typeCaffeine instance with configurationCaffeineCache record implementing ICache(m/bind #'get-user {} my-cache)
Segment with function, key-fn, id, configCachedFn(get-user 123)
CachedFn.invoke called with argsIMountPoint.cachedCacheKey from segment ID + transformed argsret-fn if configuredEntryMetaret-fn, extracts tag IDsnoCache flag set, returns without cachingImplement memento.base/ICache:
(defrecord MyCache [...]
ICache
(conf [this] ...)
(cached [this segment args] ...)
(ifCached [this segment args] ...)
(invalidate [this segment] ...)
(invalidate [this segment args] ...)
(invalidateAll [this] ...)
(invalidateIds [this tag-ids] ...)
(addEntries [this segment args-to-vals] ...)
(asMap [this] ...)
(asMap [this segment] ...))
Register with multimethod:
(defmethod memento.base/new-cache :my-cache-type
[conf]
(->MyCache ...))
Use:
(m/memo my-fn {mc/type :my-cache-type ...})
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |