cacherine 2.5.0
cacherine: ^2.5.0 copied to clipboard
A Dart in-memory cache library with FIFO, LRU, MRU, LFU, TTL expiry, async-safe variants, and monitoring metrics.
2.5.0 Composable Cache Engine, Weight-Based Eviction #
Maintenance #
- Extracted the
getOrCompute/updatecontrol flow that every legacy facade composing (not extending) an engine had hand-rolled identically — this PR's own history is direct evidence of the resulting cost: the "write through this class's ownsetinstead of the engine directly" fix had to be independently discovered and applied three separate times at three separate generality tiers.LRUCache/MRUCache/FIFOCache/LFUCache/EphemeralFIFOCache/TTLCachenow call shared top-level functions (composedGetOrCompute/composedUpdatein the newlib/src/caches/_composed_engine_ops.dart, plus sync counterpartssyncComposedGetOrSet/syncComposedUpdateused bySimpleTTLCache); theirMonitored*counterparts (MonitoredLRUCache/MonitoredMRUCache/MonitoredFIFOCache/MonitoredLFUCache/MonitoredEphemeralFIFOCache/MonitoredTTLCache) call newmonitoredGetOrCompute/monitoredUpdatemethods added to theCacheMonitoringmixin. No public API changed; each facade keeps its own narrow signature and dispatch-through-setbehavior — only the previously-duplicated method bodies moved. - Fixed
AsyncCache/MonitoredCache/MonitoredTTLCache/MonitoredEphemeralFIFOCache'sgetAll()/removeWhere()acquiring the instance lock once per key instead of once for the whole batch —setAll()already did this; these now match it, cutting a 10,000-keygetAll()from 10,000-20,000 lock acquisitions down to 1.Cache.getAll()/removeWhere()(the sync engine) similarly now read the clock once for the whole batch instead of once per key, which is still one consistent point-in-time view (this method's own contract) at a fraction of the cost on a TTL-enabled instance. - Consolidated
Cache's two internal call sites ofcheckWeightRejection()(trySet()and_storeOrThrow()) down to one:_storeOrThrow()now delegates totrySet()instead of duplicating its "check then write throughset()" logic.checkWeightRejection()'s doc comment now spells out the correct-vs-incorrect usage explicitly, since a future caller of this public method (the docs already invite subclassingCache/AsyncCache/MonitoredCachedirectly) could otherwise silently reopen the exact double-weigher-invocation issue this method exists to prevent. - Added a regression test for the
getAll()/removeWhere()lock-batching fix above: a slow (realistically async)removeWhere()predicate now provably blocks a concurrentset()on an unrelated key for the whole call, not just between individual key checks — confirmed by verifying this same test fails against the prior per-key-lock-acquisition implementation. - Added an
api-diffCI job (.github/workflows/ci.yaml) that runsdart_apitoolon every PR to diff this branch's public API against the latest version published on pub.dev, publishing the full report as a build artifact and failing the job if it finds a breaking change without a matching major version bump (--version-check-mode onlyBreakingChanges). Deliberately not the stricterfullymode, which would also require every additive change to already carry its eventual minor bump — this project bumpspubspec.yaml's version as a release-time step, not per-PR (true of several additions in this very release, still sitting on the currently-published version), sofullymode would false-positive on ordinary feature work.onlyBreakingChangesinstead automates exactly what this project already polices by hand (this file's own "Breaking Changes" sections); validated by both confirming it passes cleanly against this release's substantial-but-non-breaking additions and by injecting a real breaking rename and observing an immediate, correctly-classified failure.
Fixes #
- Fixed
getOrCompute()/update()on the five non-TTL legacy facades (LRUCache/MRUCache/FIFOCache/LFUCache/EphemeralFIFOCacheand theirMonitored*counterparts) checking presence and reading by calling the composed engine directly, instead of this class's own (overridable)containsKey()/get()— a downstream subclass's override of either was silently bypassed, unlike the pre-existingThreadSafeCache.getOrCompute()/update()defaults these facades stand in for (which always dispatched throughcontainsKey()/get()). For theMonitored*variants this also meant a hit was not recorded at all if the caller'supdate/valueFactorycallback later threw, since the whole check-compute-store sequence was wrapped in onemonitoredGet()call instead of recording the hit as soon as the read resolved. Both now dispatch throughcontainsKey()/get()— safe for these five facades specifically because none of them configure attl, so (unlikeTTLCache/MonitoredTTLCache, which keep their existing atomic-snapshot implementation) there's no check-then-fetch race for two separate calls to reintroduce; the whole sequence still runs under one continuously-held, reentrant lock acquisition. - Fixed
EphemeralFIFOCache/MonitoredEphemeralFIFOCache'sgetAll()/removeWhere(), which were left toThreadSafeCache's default implementations (correct for every other legacy facade, since theirget/peekare non-destructive) — butget()for this store is destructive (an entry is removed on retrieval), so the default's separatecontainsKey()-then-get()/peek()calls, each independently acquiring the lock, left a gap where a concurrent caller'sget()could consume the entry first:getAll()would silently omit a key that was confirmed present a moment earlier, andremoveWhere()would throw aTypeErrorcasting the resultingnullto a non-nullableV. Both now read (and, perget()'s documented behavior, consume) each key via a single atomic snapshot instead, matching the fix already applied elsewhere in this release for TTL/weight check-then-fetch races. - Fixed
CacheMetrics.getLatencyPercentile(50)(andCacheMetricsSnapshot.p50Latency) discarding all sub-millisecond precision on an even sample count — the median branch truncated both middle samples to whole milliseconds via.inMillisecondsbefore averaging, so two latencies like 400µs/800µs (realistic for an in-memory cache) incorrectly reported a median of0, disagreeing withaverageLatency's correct600µsfor the same data. Now averages in microseconds throughout. - Fixed
CacheAlertManagerfiring a spurious "Low hit rate detected" alert on a freshly-constructed cache that hasn't served any traffic yet:CacheMetrics.hitRatedocumented-returns0whentotalRequestsis0, and0is below almost any positivehitRateThreshold(the default is0.5), so an idle cache looked identical to one with a genuine 0% hit rate._checkAlerts()now skips both the hit-rate and miss-rate checks entirely whentotalRequests == 0— neither rate means anything without at least one request to compute it from. Found via an edge-case coverage audit, confirmed by reproducing the spurious alert against the unfixed code before applying the fix. - Fixed
Cache's capacity/weight eviction loop silently giving up on a cache holding a literalnullkey (Knullable):FIFOStore/LRUStore/EphemeralFIFOStore/TTLFifoStore'sevictOne()delegated toselectVictim()and checkedvictim == nullto mean "nothing evictable" — indistinguishable from a legitimately-selected victim whose key isnull.Cache._write()'swhile (exceedsCount() || exceedsWeight())loop then broke out immediately instead of evicting, so e.g.SimpleFIFOCache<int?, String>(1)followed byset(null, 'a')thenset(1, 'b')left both entries — over the declaredmaxSize.selectVictim()/evictOne()onCacheStorenow return a single-field record ((K,)?/(K, V)?) rather than a bareK?, so "found, and the key isnull" and "found nothing" are distinguishable regardless ofK; every store'sevictOne()was audited and, where needed, rewritten to stop relying on the ambiguous bare-K?check internally. See PR #69 review discussion for the original report.
Testing #
- Following up on the set()-bypass fixes above, audited the suite for missing coverage of documented performance/robustness/behavioral guarantees and closed the highest-confidence gaps:
Cache.update()'s callback-throws-mid-computation path now asserts the cache/weight ledger is left untouched and the instance stays usable afterward;AsyncCache/MonitoredCache'sgetOrCompute()/update()now assert the instance's lock is actually released (not just that the exception propagates) when the caller'svalueFactory/updatecallback throws, guarding against a silent deadlock on every later call;EvictionReasonattribution is now tested withmaxSizeandmaxWeightconfigured together (previously each was only tested in isolation), pinning down that a write exceeding both at once is always attributed.weight, never.capacity; andTTLFifoStorewas added to the sharedCacheStoreconformance suite (it was the only store implementation not covered by it) plus a dedicated policy test for its "update refreshes FIFO position" behavior, the one place it deliberately diverges fromFIFOStore. - Closed the remaining backlog of performance/robustness/behavioral test gaps from the same audit: weigher invocation count on
getOrSet/update/trySet/getOrComputeis now pinned down explicitly (see thecheckWeightRejectionfix below); a slowgetOrCompute()on one key is confirmed to actually block a concurrentset()on an unrelated key, perAsyncCache's documented single-instance-lock tradeoff; aset()override that reentrantly calls a different public method (clear()) mid-write is confirmed not to deadlock or corrupt state; a fully unboundedCache(nomaxSize/maxWeight/ttl) is confirmed to hold 50,000 entries correctly; a temporarily backward-jumping injected clock is confirmed not to corrupt cache state and to resume normal purging once the clock catches back up;Cache._minExpiry's post-purge restoration is now verified to keep short-circuiting a subsequent write, not just the write that triggered the purge; a weight-bounded LFU cache'sexcluding-key eviction fallthrough (previously only tested againstLFUStorein isolation) is now driven end-to-end throughCache;CacheMetrics.snapshot()is now smoke-tested for O(n)-not-worse cost at the fullmaxEvictionSamplesretention cap, and for correct window-filtering under sustained churn well past that cap;CacheAlertConfig's strict-inequality thresholds are now tested at their exact boundary (hit rate and per-reason eviction rate) to catch an accidental<=/>=flip;LRUStore/FIFOStore/EphemeralFIFOStoregained the sameselectVictim(excluding:)multi-candidate fallback testMRUStore/LFUStorealready had;LFUCache.getKeys()— the only policy whose key order is documented as unspecified — was added to the canonical order-contract test file, asserting the correct key set rather than an order; andPeriodicSweepergained a dedicated test file, including a direct test of its documented "an in-flight sweep still runs to completion afterdispose()" guarantee. - Fixed a latent correctness gap the above audit surfaced in the set()-bypass fix itself:
trySet()/getOrSet()/update()/AsyncCache.storeOrThrow()computed a write's weight twice — once as a pre-check (to decide whether to reject/throw), once again inside the delegatedset()/_write()— so a non-deterministicweigher(unsupported per its documented "should be pure" contract, but not otherwise guarded against) could disagree with itself between the two calls, lettingtrySet()report success (orgetOrSet()/update()return a value) for a write that was actually silently rejected for exceedingmaxWeight.Cache.wouldRejectWrite()was replaced withcheckWeightRejection(), which computes the weight once and threads it back into the delegatedset()call as an explicitweight:, so the weigher is now invoked exactly once per logical write (down from up to twice) and the two call sites can no longer disagree. - Following up on the
getOrCompute/updateread-dispatch fix below: the bug it fixes previously existed identically in all five non-TTL legacy facades (and theirMonitored*counterparts) but had only ever been exercised by a test for one of them (LRUCache) — the same "pattern applied uniformly across several call sites, verified against only one" shape as the bug itself.legacy_facade_subclass_compat_test.dart's subclass-dispatch coverage is now driven through a single parameterized check across every concrete non-TTL facade (LRUCache/MRUCache/FIFOCache/LFUCache/EphemeralFIFOCacheand theirMonitored*counterparts) instead of one hand-picked representative, so a future change that regresses just one sibling can no longer hide behind the others' tests staying green. - Added
test/caches/model_based_lru_cache_test.dart: a differential test that runsLRUCacheagainst an independent, deliberately naive reference model (written without consultingLRUStore's implementation) over 50 random seeds × 200 operations each (set/get/peek/containsKey/remove/getOrCompute/update), asserting the two agree — including the full key set, not just the immediately-preceding return value — after every single step. Every other test in this suite is example-based (a hand-picked sequence exercising one specific behavior); this instead covers the much larger space of interaction sequences no one thought to hand-write, and was confirmed to have real detection power by deliberately injecting a wrong-eviction-victim bug intoLRUStoreand observing an immediate, specific failure. - Extended the model-based differential testing above to the four remaining eviction policies:
test/caches/model_based_mru_cache_test.dart,model_based_fifo_cache_test.dart,model_based_lfu_cache_test.dart, andmodel_based_ephemeral_fifo_cache_test.dart, each with its own from-scratch reference model rather than a copy ofLRUCache's (MRU evicts the most-recently-used entry and, unlike LRU, must therefore evict before inserting a new key — a fresh key would otherwise instantly become its own victim; FIFO/EphemeralFIFO never reorder on read or on updating an existing key; LFU tracks frequency-then-recency and, perLFUStore.put()'s documented contract, must not bump frequency on a plain overwrite). Empirically probing the real classes first surfaced a genuine, non-obvious behavior worth pinning down explicitly:EphemeralFIFOCache.update()'s hit path reads via the destructiveget()and then writes the result back throughset()— since the key was just removed by the read, the write reinserts it as a brand-new entry at the newest position, unlikeFIFOCache.update(), which leaves an existing key's position untouched. Each of the four gained the same bug-injection validation as the originalLRUCachetest (a policy-specific store bug — wrong eviction end, reordering on read, frequency not preserved on overwrite — confirmed to fail immediately and specifically), so all five cache-policy differential tests now carry the same evidence of real detection power. - Added
test/caches/concurrency_stress_test.dart: every prior concurrency test in this suite races a hand-picked pair of calls viaCompleters to pin down one exact interleaving — the right tool for proving a specific ordering is safe, but unable to catch a bug that only shows up under much wider fan-out (e.g. a dedup path that happens to hold for two racing callers but not for the twentieth). These tests instead fire a large, unstructured batch of calls viaFuture.waitand assert on the aggregate outcome, acrossLRUCache/MRUCache/FIFOCache/LFUCache/AsyncCache/MonitoredCache: 100 concurrentgetOrCompute()calls on the same missing key collapse into exactly onevalueFactoryinvocation; hundreds of mixedset/get/remove/getOrCompute/updatecalls against a capacity-bounded cache never push it overmaxSizeand leave it fully usable afterward; concurrentgetAll()/removeWhere()batch calls interleaved with regular traffic on aMonitoredCachenever deadlock (the lock-batching change earlier in this release is exactly the kind of change that could have reintroduced one) and settle at a consistent count; and aMonitoredLRUCache's hit/miss/total-request counters stay exactly consistent under 300 concurrentget()calls. Empirically probingEphemeralFIFOCachefor this surfaced another genuine, non-obvious behavior: because itsgetOrCompute()reads a hit through the destructiveget(), single-flight dedup cannot hold across more than one subsequent caller — a hit for caller N is a fresh miss for caller N+1 — so it's covered by its own dedicated test asserting exactly that pattern (half the calls recompute, the cache ends up empty) rather than folded into the generic sweep. Every wait in this file carries an explicit.timeout()naming what would have deadlocked, since a hungFuture.waitwould otherwise just run until the test runner's own timeout with a generic message. Validated the same way as the model-based tests: injecting a lock-removal bug intocomposedGetOrComputeproduced immediate, specific failures across every affected target. - Closed a further batch of edge-case gaps from a targeted audit: the
WeightedLRUCache/SimpleWeightedLRUCache/MonitoredWeightedLRUCachefamily'supdate()/getOrCompute()StateError-on-oversized-write contract (previously only exercised viaset()'s silent-rejection path, never through the non-void-return methods that actually throw) is now tested for all three classes; aweigherthat throws is now tested for the same three classes, confirming the instance is left untouched and — for the async/monitored variants — that the lock is released rather than leaked;TTLCachenow has a dedicated test for the exact instant an entry's TTL elapses (now == expiry), not just well-before/well-after, pinning down that equality counts as expired;getAll()with an empty keys iterable and with a repeated key are now both tested (the latter also forEphemeralFIFOCachespecifically, where a repeated key's second occurrence is a same-call miss following the first occurrence's destructive read — the result still reflects the first, successful read). This same audit found and fixed theCacheAlertManagerzero-traffic bug above; a dedicated regression test for it (and a fix to the one pre-existing test that had been unknowingly relying on that bug's behavior to observe the alert timer firing) were added tocache_alert_manager_test.dart. - Added regression coverage for the null-keyed-victim eviction fix above:
cache_store_conformance_test.dart's "documented limitation" group (which previously asserted the bug as expected behavior) was replaced with a group proving every store selects/evicts anull-keyed entry once it's a genuine candidate, plus a narrower group confirming the one remainingexcluding-default limitation stays exactly that limitation, not something worse;cache_engine_test.dartgained end-to-endCache-level tests driving amaxSize- and amaxWeight-bounded cache past capacity with a pre-existingnullkey; andsimple_fifo_cache_test.dart/simple_weighted_lru_cache_test.darteach gained a facade-level test matching the exact reported repro shape.
New Features #
- Weight-based eviction (closes #67): Added
SimpleWeightedLRUCache,WeightedLRUCache, andMonitoredWeightedLRUCache— LRU caches bounded by a caller-supplied per-entry weight (e.g. estimated byte size) via aweighercallback andmaxWeight, optionally alongside an entry-countmaxSize. An explicitweight:argument can also be passed perset()call. - Composable cache engine: Added public
Cache/AsyncCache/MonitoredCacheclasses and aCacheStoreinterface (withLRUStore/MRUStore/FIFOStore/EphemeralFIFOStore/LFUStoreimplementations). Every named cache class in this package (SimpleLRUCache,TTLCache,MonitoredWeightedLRUCache, ...) is now a thin facade over this engine — power users can configure combinations that don't have a dedicated name (e.g. a weight-and-TTL-bounded LRU cache) by constructingCache/AsyncCache/MonitoredCachedirectly. - Per-cause eviction tracking: Added
EvictionReason(capacity,weight,expired,manual,unspecified) and a newCacheMetrics.recordEvictionReason(EvictionReason)method to record it, alongside an additiveevictionsPerMinuteByReasonfield onCacheMetricsSnapshot/DashboardSnapshot. The existing zero-argumentrecordEviction()is unchanged (it now delegates torecordEvictionReason(EvictionReason.unspecified)) — see the "Maintenance" section further below for whyEvictionReasonisn't instead an optional parameter onrecordEviction()itself.CacheAlertConfiggained an optionalevictionsPerReasonThresholdso alerting can distinguish an expectedexpiredrate from acapacity/weightrate that signals the cache is undersized. - Added a shared
PeriodicSweepermixin (implementsDisposable) that centralizes the "owns a periodic timer" lifecycle previously hand-rolled per TTL/Monitored class.
Documentation #
- Documented that cached values should be treated as immutable once stored under a
weigher— the recorded weight is never recomputed, so a value mutated in place after caching will silently drift from the ledger; re-set()(with an explicitweight:override if needed) to refresh it. - Documented that
getOrCompute/updatehold the whole cache instance's lock across the awaited callback (not just the affected key), so slow work (e.g. a network request) should not run directly inside it. - Documented
LFUStore's eviction-candidate fallthrough: when the min-frequency bucket's only occupant is the key currently being excluded from eviction, selection falls through to the next occupied frequency bucket rather than reporting nothing evictable. - Documented the one remaining nullable-
Klimitation onCacheStore.selectVictim()/evictOne()'sexcludingparameter: its own default (null, "exclude nothing") is indistinguishable from an explicit request to exclude the literal keynull, so a store holding only anull-keyed entry, queried withexcludingleft at its default, still reports nothing evictable. This doesn't affectCache's own eviction loop (which always passes the key it's currently writing asexcluding, never the default) — only a caller drivingCacheStoredirectly. SeeCacheStore.selectVictim()'s doc comment for the full analysis. (The broader version of this limitation —nullalso overloading the return value's "no victim" result — is fixed; see Fixes below.) - Documented that
Cache.getOrSet()/update(),AsyncCache.getOrCompute()/update(), andMonitoredCache.getOrCompute()/update()do not dispatch their presence check/read through this cache's own (possibly overridden)get()/containsKey()— unlikeset(), which always does. This is deliberate, not an oversight, and structurally different from the same-shaped bug just fixed in the non-TTL legacy facades below: those facades never have attl, so dispatching throughcontainsKey()/get()is safe; these three classes are the general engine itself, used both with and without attl/weigherdepending on how a given instance was built, so there is no way to know at the call site whether a specific instance needspresentValue()'s single-clock-snapshot safety — dispatching throughget()/containsKey()here would silently reintroduce the TTL check-then-fetch race for anyttl-configured instance. Previously undocumented despite the class docs explicitly inviting "power users" to subclass these engines directly. - Fixed 8
dart docwarnings from unresolved doc-reference syntax:CacheStatsDashboard.snapshot()referenced[totalRequests]unqualified (now[CacheMetrics.totalRequests]), and sevenMonitored*Cacheclasses' doc comments used the grammatical shorthandfollow[s](e.g. "update()follow[s]getOrCompute()..."), which dartdoc parsed as a broken[s]cross-reference rather than prose (now spelled out asfollows).dart doc .now reports 0 warnings.
Maintenance #
- All ~19 pre-existing concrete cache classes (FIFO/EphemeralFIFO/LRU/MRU/LFU/TTL × Simple/async/Monitored) were internally rewritten to compose the new engine, with byte-for-byte-preserved public constructors and behavior — the full pre-existing test suite passes unchanged against them.
- Fixed a remaining set of TTL check-then-fetch races (the same class of bug as
presentValue()above fixed forgetOrSet/getOrCompute/update), where a separate presence check and read/peek could each read the clock independently and observe an entry expire in between:Cache.getAll()/removeWhere(),AsyncCache.getAll()/removeWhere()(previously falling back to the base interfaces' racy defaults), andSimpleTTLCache.getOrSet()/update()/getAll()/removeWhere(),TTLCache.update()/getAll()/removeWhere(),MonitoredTTLCache.update()/getAll()/removeWhere()(previously falling back to their parent interfaces' racy defaults instead of the composed engine's atomic helpers). AddedCache.presentPeek()— a peek-based (non-mutating) counterpart topresentValue()— soremoveWhere()can test entries for removal without perturbing LRU/LFU eviction-policy state as a side effect. - Fixed
LFUStore's eviction-candidate fallthrough degrading toO(distinct frequencies)per victim (and repeating per victim across a multi-eviction write, i.e. up toO(n × distinct frequencies)) by chaining frequency buckets into their own linked list instead of rescanning a plain frequency map; eviction, promotion, and selection (including the excluded-key fallthrough) are now O(1) worst case. - Fixed
MonitoredCache.getAll()/update()andMonitoredTTLCache.getAll()/removeWhere()/update()silently dropping the hit/miss/latency/eviction metricsdoc/monitored_cache.mddocuments, by delegating straight to an unmonitored inner engine call instead of routing through the monitoredget()/remove()path (update()'s gap was a regression from thepresentValue()-based TTL-race fix above, which bypassed the old default implementation'sget()call that used to record these metrics via virtual dispatch). - Fixed
Cache.validateSetArgs()silently accepting a negative explicitweight:ongetOrSet()/getOrCompute()/update()when the key was already present (only a miss reached the negative-weight check inside_write()); it's now rejected eagerly regardless of hit or miss, matchingset()'s validate-first contract. - Fixed
getOrSet()/getOrCompute()/update()(Cache,AsyncCache,MonitoredCache) reporting a value as cached when its weight actually exceededmaxWeightand_write()silently rejected it as a no-op — the same "can never fit" caseset()accepts silently, but these methods have a non-voidreturn contract, so a caller previously got back a value (the newly-computed one, or the update callback's result) that was never actually stored, while any prior entry under that key was left unchanged with no signal anything went wrong. They now throwStateErrorinstead when the write is rejected. Cache._write()no longer scans every stored key to purge expired entries on a capacity-triggered write when nothing has actually expired yet (tracked via a cheap lower-bound on the earliest expiry deadline), so inserting into a TTL-and-maxSize-bounded cache at steady-state capacity stays O(1) amortized instead of O(n) per insert. AddedCacheStore.removesOnAccesssoCache.get()skips a redundant presence recheck for every policy (LRU/LFU/FIFO/MRU/TTL) exceptEphemeralFIFOStore, the only one whereaccess()removes the entry.- Fixed a source-compatibility break in
SimpleLRUCache/SimpleFIFOCache/SimpleLFUCache/SimpleMRUCache/SimpleEphemeralFIFOCache, their async equivalents, and the correspondingMonitored*Cacheclasses: extendingCache/AsyncCache/MonitoredCachedirectly (rather than composing them, asTTLCache/MonitoredTTLCache/SimpleTTLCachealready correctly did) pulled the new engine'sweight/ttlnamed parameters into these classes' inheritedset/setAll/getOrSet/getOrCompute/update, so any pre-existing downstream subclass overriding one of those methods with the old, narrower signature would fail to compile (Dart doesn't allow an override to drop optional named parameters the overridden method declares). All 15 of these "legacy" facades now compose an internal engine instead, restoring their original method surface; theMonitored*ones additionally mix inCacheMonitoring/PeriodicSweeperdirectly (matchingMonitoredTTLCache) sois CacheMonitoring<K, V>/is Disposablekeep holding. - Fixed these same 15 legacy facades' bulk/compound helpers (
setAll/getAll/getOrSet/getOrCompute/update/removeWhere) delegating straight to the internal engine, bypassing a downstream subclass's override ofset/get/etc. entirely — before the composable-engine refactor, these were inherited fromSimpleCache/ThreadSafeCacheand dynamically called the (overridable) facade methods. The non-monitored facades now leave these to the interface defaults, which call this class's own methods; theMonitored*ones keep customgetOrCompute/update(for hit/miss metrics) but now write through their ownsetinstead of the engine directly.AsyncCache's internal lock is now reentrant so that in-lock write can happen without deadlocking, preserving the existing no-duplicate-computation-for-a-racing-key guarantee forgetOrCompute/update. - Fixed
CacheMetrics.recordEviction()gaining an optionalEvictionReasonparameter, which was source-breaking for the same reason as above (downstream override with the original zero-argument signature). Split it back into a genuine zero-argumentrecordEviction()and a newrecordEvictionReason(EvictionReason). - Fixed the weight-based-eviction README/
doc/weighted_lru_cache.mdexamples labeling aList<int>'s.lengthas a byte-size weight — aList<int>has substantial per-element overhead beyond one byte, so this understated real memory usage. Switched the examples toUint8List/lengthInBytes, which is exactly the byte count. - Found and fixed the same
set-bypass issue inTTLCache/MonitoredTTLCache/SimpleTTLCache'sgetOrCompute/getOrSet/update, which wrote through the internal engine directly instead of this class's ownset. Unlike the 15 facades above, these classes'getAll/removeWherewere deliberately left calling the engine directly (not fixed) — they need a single atomic clock snapshot per key to avoid a TTL check-then-fetch race already fixed earlier in this same effort, which routing through separateget/peek/containsKeycalls would reintroduce;getOrCompute/updatedon't have that conflict since they already run under a single lock hold, so the same reentrant-lock technique applies. - Found and fixed the same
set-bypass issue at its root, inCache/AsyncCache/MonitoredCachethemselves:Cache.getOrSet()/update()/setAll()wrote via private_write/_writeOrThrowhelpers, andAsyncCache/MonitoredCache'sgetOrCompute()/update()/setAll()wrote viastoreOrThrow()calling the composedCacheengine'strySet()directly — both bypassingthis.set()entirely. SinceSimpleWeightedLRUCache/WeightedLRUCache/MonitoredWeightedLRUCacheextend these classes directly and add noset()of their own, they (and any subclass overridingset()on any of these six classes) were silently affected.Cache.getOrSet()/update()/setAll()/(the now-set()-delegating)trySet()andAsyncCache.storeOrThrow()/setAll()now write throughthis.set(); a newCache.wouldRejectWrite()lets each of these detect a weight-exceeds-maxWeightrejection before delegating toset()(which, like_write()before it, has no way to report back what it actually stored), so the reject-and-throw contract ongetOrSet/update/getOrComputeis preserved without needingset()itself to return anything. Addedtest/caches/core_engine_subclass_compat_test.dart, covering all six classes, to guard against this regressing again.
2.4.0 Conditional Mutation Helpers and Bulk Operations #
New Features #
- Added
putIfAbsent(),update(), andremoveWhere()default APIs to simple, async-safe, and TTL cache interfaces. - Added TTL-aware
putIfAbsent()andupdate()overloads so new or updated entries can receive per-entry TTL overrides through TTL abstractions. - Added
getAll(),setAll(), andremoveAll()bulk operation APIs to simple, async-safe, and TTL cache interfaces. - Added TTL-aware
setAll()overloads so batches can receive a shared per-entry TTL override through TTL abstractions.
Documentation #
- Documented conditional mutation helpers and clarified
getOrCompute()same-instance serialization semantics. - Documented bulk operation helpers and their cache policy side effects.
Maintenance #
- Added injectable clock support to
CacheMetricsfor deterministic eviction-window and dashboard tests. - Expanded contract coverage for conditional mutation helpers, bulk operations, TTL helper forwarding, and TTL
getOrCompute()concurrent computation behavior.
2.3.0 Peek, Occupancy APIs, and TTL Purge Cleanup #
Documentation #
- Expanded the runnable example and README cache-aside snippets to cover both
getOrSet()and TTL-awaregetOrCompute(). - Documented the non-mutating
peek()API across README and cache guides. - Documented occupancy APIs (
size,isEmpty, andisNotEmpty) across README and cache guides. - Documented explicit TTL expiry cleanup with
purgeExpired().
New Features #
- Added
peek()to simple, async-safe, monitored, and TTL cache variants so callers can read values without updating cache eviction state. - Added
size,isEmpty, andisNotEmptyto simple, async-safe, monitored, and TTL cache variants so callers can inspect cache occupancy without materializing keys directly. - Added
purgeExpired()toSimpleTTLCache,TTLCache,MonitoredTTLCache, and the TTL cache interfaces so callers can explicitly remove expired TTL entries and inspect how many were removed.
Maintenance #
- Updated dependency constraints for
synchronizedandlintsto the newest resolvable versions for the current SDK range. - Added regression coverage for
peek()nullable-value behavior, policy side effects, monitored traffic metrics, and TTL expiry. - Added regression coverage for cache occupancy APIs across standard, simple, monitored, ephemeral, and TTL caches.
- Added regression coverage for explicit TTL expiry cleanup and monitored eviction metrics.
2.2.0 Simple TTL Cache, Cache-Aside Helpers, and Contract Coverage #
New Features #
- SimpleTTLCache: Added a synchronous TTL cache variant with global and per-entry TTL, lazy expiry,
containsKey(), optionalmaxSize, and FIFO capacity eviction. - TTL cache interfaces: Added
SimpleTTLCacheInterfaceandThreadSafeTTLCacheInterfaceso abstract cache references can still expose per-entry TTL overrides. - Cache-aside population: Added
getOrSet()to simple caches andgetOrCompute()to async-safe caches, including TTL per-entry override support through TTL-specific interfaces.
Documentation #
- Added runnable package examples covering
SimpleTTLCache,TTLCache, and monitored cache dashboard snapshots. - Documented synchronous TTL usage in the README and TTL guide.
- Documented TTL-specific interfaces for callers that need per-entry expiry through cache abstractions.
- Documented
getKeys()ordering contracts,Disposablelifecycle behavior, and boundedCacheMetricssample storage.
Maintenance #
- Added regression coverage for
SimpleTTLCache,TTLCache, andMonitoredTTLCachethrough the new TTL-specific interfaces. - Added regression coverage for cache-aside population,
dispose()idempotency, post-dispose operations, andgetKeys()ordering contracts.
2.1.0 Monitored TTL Cache, containsKey API, and Monitoring Improvements #
New Features #
- MonitoredTTLCache: Added a monitored TTL cache variant with hit/miss and latency metrics, eviction tracking for expiry/capacity/manual removals, alert support, and the same TTL configuration options as
TTLCache. - containsKey() API: Added
containsKey()to simple, async-safe, monitored, and TTL cache variants so callers can distinguish storednullvalues from missing keys without mutating eviction state. - CacheMetricsSnapshot: Added a typed
CacheMetrics.snapshot(Duration window)API with hit/miss rates, latency percentiles, eviction rate, total requests, and capture time. - Monitored cache constructors: Made
CacheAlertConfigoptional for monitored cache variants by providing a default no-op alert callback and default thresholds.
Performance #
- MonitoredLFUCache: Replaced O(n) eviction scans with frequency buckets for constant-time LFU eviction.
- CacheMetrics: Reused a single sorted latency snapshot when computing multiple percentiles for metrics snapshots and dashboards.
- TTLCache: Added a capacity benchmark to quantify expired-entry cleanup and FIFO capacity enforcement costs.
Bug Fixes #
- Nullable monitored values: Fixed monitored caches so stored
nullvalues are recorded as hits when the key exists. - TTLCache: Validates
sweepIntervaland per-entry TTL values so zero or negative intervals fail fast. - CacheStatsDashboard: Migrated dashboard snapshots to the typed metrics snapshot source for consistent captured timestamps and metric values.
Documentation #
- Clarified async-safe cache contracts and isolate boundaries for
ThreadSafeCacheimplementations. - Documented
containsKey()semantics, nullable value behavior, and TTL expiry behavior. - Documented MonitoredTTLCache usage in the TTL guide and README.
- Clarified monitored cache
toString()output as diagnostic point-in-time state.
Maintenance #
- Split CI formatting suggestions into a separate least-privilege Reviewdog job.
- Reduced default workflow token permissions to read-only for CI jobs.
- Added regression coverage for nullable cached values,
containsKey()policy side effects, monitored cache constructors, metrics snapshots, and MonitoredTTLCache expiry paths.
2.0.1 LFU Performance Improvements, Bug Fixes, and Maintenance #
Performance #
- LFUCache: Replaced O(n) eviction scan with an O(1) frequency-bucket structure. Eviction is now constant-time regardless of cache size.
- LFUCache: Eliminated O(n)
_minFreqrecomputation inremove(). The minimum-frequency pointer is now maintained incrementally.
Bug Fixes #
- LFUCache:
toString()now eagerly snapshots_keyMapbefore formatting, preventing a data-race window between the map read and string construction in async contexts.
Documentation #
- LFUCache: Class-level note added clarifying that
toString()is not covered by the lock-based thread-safety guarantee; result is a point-in-time snapshot. - LFUCache: Documented unspecified iteration order for
getKeys()andtoString(). - LRUCache / MRUCache: Documented LRU-recency-refresh behavior of
set()on an existing key and the tiebreak semantics.
Maintenance #
- Adjusted SDK constraint floor to
>=3.8.0(minimum required bysynchronized ^3.4.0) - Kept
synchronizedat^3.3.1(resolves to 3.4.0+1 in practice, which requires SDK >=3.8.0) - Updated
lintsdev dependency from^5.0.0to^5.1.1to align with the revised SDK baseline - Raised
testdev dependency from^1.25.8to^1.31.0(resolved: 1.31.1) - Removed
dart_code_metrics ^5.7.6(incompatible with theanalyzerversions required by modern test tooling) - CI: Added matrix testing across Dart 3.8.0 and stable; updated GitHub Actions to v4; optimized permissions and tightened job timeouts; added Reviewdog-based format suggestions on PR
- Added
.fvm/and.fvmrcto.gitignore - No public API changes; no breaking changes
2.0.0 CacheStatsDashboard, TTLCache, and Lifecycle Management #
New Features #
- CacheStatsDashboard: New class that wraps
CacheMetricsto produce typedDashboardSnapshotobjects for terminal-ready metric display. - TTLCache: Added a standalone cache implementation supporting Time-To-Live (TTL) for both global and per-entry expiry.
- Disposable Interface: Introduced a standard
Disposableinterface to handle resource cleanup (timers, controllers) across monitored and TTL caches. - DashboardSnapshot: Immutable value type capturing
hitRate,missRate, latency percentiles (p50,p95,p99),evictionsPerMinute,totalRequests, andcapturedAt. - formatDashboard(): New top-level function that renders a
DashboardSnapshotas a Unicode box-drawing terminal panel with adaptive unit formatting (µs, ms, s).
Breaking Changes #
- Interface Implementation: All
Monitored*caches andTTLCachenow implementDisposable. Callers managing these instances should call.dispose()to prevent timer leaks. CacheStatsDashboard.snapshot(Duration window)now throwsArgumentErrorfor zero or negativewindowvalues (previously undefined behaviour propagated fromCacheMetrics.getRecentStats).CacheStatsDashboard.stream(Duration window, Duration interval)now throwsArgumentErrorfor zero or negativeintervalvalues.
1.1.5 Bug Fixes #
- Fixed spurious eviction in FIFO
set()when updating an existing key. - Fixed unawaited
Futurein LFU/MRU eviction causing silent async errors. - Fixed LFU
set()incorrectly resetting usage count and spuriously evicting when updating an existing key. - Fixed unbounded memory growth in
CacheMetricsby capping stored latency samples. - Fixed miss latency silently discarded in
CacheMetrics/CacheMonitoring. - Fixed incorrect cache descriptions for LRU and MRU.
1.1.4 Add Project Logo to README #
- Added project logo to README header for improved visual branding.
1.1.3 Add Cache Algorithm Documentation #
- Added documentation for Cache algorithms to the README.
- Updated Dart SDK constraints to require version 3.7.2 or higher in
pubspec.yaml.
1.1.2 Add Badges to README #
- Added the following badges to the README:
- Dart CI Badge
- OpenSSF Scorecard Badge
- Codecov Badge
- Documentation Badge
1.1.1 Maintenance and Dependency Updates #
- Renamed
docsdirectory todocto comply with pub.dev package layout convention. - Updated dependencies:
- Resolved version constraints for
lints,synchronized, andjspackages.
- Resolved version constraints for
- Refactored project structure for improved consistency across environments.
- No functionality changes; preparation for release and ongoing maintenance.
1.1.0 Introduce MonitoredCache with Performance Metrics #
New Features #
- MonitoredCache: Added a new cache variant with built-in performance monitoring.
- Tracks hit rate, miss rate, request latency, and eviction events.
- Provides percentile-based latency insights (e.g., p95, p99).
- Includes an alert system that triggers warnings when performance thresholds are exceeded.
- Supports FIFO, LRU, MRU, LFU, and EphemeralFIFO strategies.
- Updated README:
- Introduced MonitoredCache as a tool for debugging and optimizing cache selection.
- Added API references and usage instructions for monitored caches.
- Provided a link to detailed documentation in
docs/monitored_cache.md.
This update enables developers to analyze cache performance in real-time and choose the optimal caching strategy based on actual usage patterns.
1.0.2 Improve package description #
- Updated the
descriptionfield inpubspec.yamlto meetpub.devrequirements. - Expanded the package description to provide a clearer explanation of its functionality and target use cases.
1.0.1 Fix description in pubspec.yaml #
- Updated the
descriptionfield inpubspec.yamlto provide a more specific and accurate explanation of the package's functionality.
1.0.0 Initial release #
- First release of
cacherinepackage on pub.dev - Provides basic memory cache implementations: FIFO, LRU, MRU, and LFU
- Includes two types of cache implementations:
- Simple, single-threaded usage
- Async-enabled versions for concurrent environments
- Designed to provide flexible, easy-to-use caching solutions for Dart applications
This is the first stable release. Feedback and contributions are welcome!