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 (secondary IDs, no-cache flag)InvalidationTimeline: Reusable operation/invalidation coordinationDurations: 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 secondary IDs to cache keys and coordinates Caffeine invalidation epochsExpiry: 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:
SpecialPromiseIf a key is invalidated while being loaded:
SpecialPromise is marked invalidSecondary-index invalidation is more complex because:
start-secondary-invalidation! method.finalize-invalidation! method, passing start state and
whether matching entries should be invalidated.Secondary-index invalidation is backend-owned and does not enumerate mount points. The Caffeine backend uses one JVM-wide index containing weak cache/key references and local invalidation timeline. Distributed backends can use native indexes and backend-specific coordination. Starting all backends before running any potentially slow invalidator gives their lockout windows the widest practical overlap; cross-backend atomicity is not implied.
SpecialPromise for the key. If this thread published it, retain the current
secondary-invalidation timeline node immediately before invoking the cached function.deliver, so a subsequent secondary-ID invalidation can find the pending promise. deliver
checks whether any result secondary ID was active at the load's start or began invalidation
before its timeline snapshot.SpecialPromise, then replace the promise with the
canonical CacheEntry. Failed candidates remove their secondary-index entries and retry.do-not-cache result removes its promise from the map before delivery. It is still checked
against result-derived secondary-ID invalidation, but is never retained as a CacheEntry.CacheEntry from the map.Steps 4 and 5 above, and the cache-hit path, retry when the result's secondary IDs lost to an
invalidation. A lockout is held open for the whole duration of the invalidating caller's write, so
an immediate retry would lose again — and re-run the cached function — for that entire window.
Instead, a retry that was rejected by a secondary-ID lockout calls
InvalidationTimeline.awaitQuiescent on the offending IDs and parks until the last overlapping
lockout on any of them ends.
awaitQuiescent waits on the same monitor that serializes timeline appends, and end-of-invalidation
appends signal it. The wait uses a one-minute monotonic deadline. If the condition is still active
at that point, it throws IllegalStateException identifying the IDs and suggesting a leaked
completion or self-lockout. The long timeout is diagnostic rather than a normal duration target:
valid invalidations should finish quickly, while a production mistake does not produce rapid
retry/failure churn. The deadline is measured across the entire wait and is not reset by
spurious wakeups or notifications for unrelated IDs. Waiters are also interruptible.
A cache hit that is locked out parks without removing the entry. The lockout may still end with
finish!(false), in which case the waiter re-reads and serves the untouched entry. No owner-thread
state is maintained, so timeline validation remains lock-free. A same-thread self-lockout is
reported by the one-minute wait timeout rather than detected in the publication path.
The timeline is a forward-linked chain with serialized transition appends and lock-free reads.
Each transition node contains the count of active invalidations per secondary ID after that transition.
A load's start node summarizes all earlier history, while start nodes appended through the finish
node record invalidations that began during the load. The SpecialPromise retains the start node,
so the JVM retains exactly the timeline suffix the load may need. Directly invalidating the promise
releases this reference immediately, even if user code ignores interruption and continues running.
For manual tagged insertion, the cache captures an operation boundary, writes the delegate entry, adds the index entries, and validates the operation against the timeline. An invalidation before or during index registration is detected by that validation and removes both registrations; one after successful validation finds the fully registered index entry. Index entry write epochs are generation identifiers that prevent stale index pointers from removing replacement values; they do not order secondary invalidations.
There is a narrow publication-boundary gap: after the promise has been replaced by a
CacheEntry, an invalidation can remove that entry while a caller already holding the
detached, successfully delivered promise still returns its value. The loader can likewise
return its computed value after concurrent removal. Memento detects invalidations during
meaningful load execution through the timeline and promise invalidation, but does not attempt to
close this final handoff race. Closing it would require successful joiners to re-read the
map while still not providing an absolute guarantee for the loader itself.
memento.core/start-invalidation! orchestrates the two backend lifecycle phases and returns a
single-use completion function. memo-clear-sec-id! starts and immediately completes that lifecycle;
with-invalidation completes it after a successful body or ends it without invalidating when the
body throws. Core does not create or interpret the Caffeine timeline state.
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 when validation or
publication fails after deliver, preventing joiners from observing a result
that the loader discarded.When a secondary-ID invalidation finds a SpecialPromise through an existing secondary-index entry:
Secondary-index pointers are removed lazily. If a stale pointer remains for a key that has since started a different load, the promise has no write epoch or result secondary IDs yet, so the invalidation conservatively interrupts that load. This can cause one unnecessary retry, but it cannot return stale data or remove a later published entry.
For a first load whose result-derived secondary IDs are not known yet, the timeline detects the overlap when the result completes; such a load cannot be interrupted through the index.
memento.base.InvalidationTimeline is a public JVM utility for cache implementations with
the same coordination problem. It exposes opaque Operation and Invalidation handles:
InvalidationTimeline timeline = new InvalidationTimeline();
InvalidationTimeline.Operation operation = timeline.startOperation();
InvalidationTimeline.Invalidation invalidation = timeline.startInvalidation(ids);
// invalidate indexed storage
timeline.endInvalidation(invalidation);
boolean retry = timeline.invalidated(operation, resultIds);
if (retry) {
// Park until the lockout closes rather than spinning through it.
timeline.awaitQuiescent(resultIds);
}
An operation handle is the timeline node itself, so capturing it does not allocate. Holding the handle keeps subsequent history reachable; implementations should release references to it as soon as the operation completes or is cancelled.
The SecondaryIndex maintains mappings from secondary IDs to cache keys:
[:user 123] -> #{CacheKey[get-user, [123]], CacheKey[get-orders, [123]]}
[:user 456] -> #{CacheKey[get-user, [456]]}
[:order 789] -> #{CacheKey[get-order, [789]], CacheKey[get-order-items, [789]]}
When memo-clear-sec-id! 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 secIds; // Set of arbitrary secondary IDs
}
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 secondary 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] ...)
(addEntries [this segment args-to-vals] ...)
(asMap [this] ...)
(asMap [this segment] ...))
Register with multimethod:
(defmethod memento.base/new-cache :my-cache-type
[conf]
(->MyCache ...))
(defmethod memento.base/start-secondary-invalidation! :my-cache-type
[_ sec-ids]
;; Establish a lightweight lockout and return backend-specific state.
...)
(defmethod memento.base/finalize-invalidation! :my-cache-type
[_ sec-ids state invalidate?]
;; Invalidate active storage domains when invalidate? is true, then release the lockout.
nil)
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 |