cacherine 2.5.0 copy "cacherine: ^2.5.0" to clipboard
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/update control 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 own set instead 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/TTLCache now call shared top-level functions (composedGetOrCompute/composedUpdate in the new lib/src/caches/_composed_engine_ops.dart, plus sync counterparts syncComposedGetOrSet/syncComposedUpdate used by SimpleTTLCache); their Monitored* counterparts (MonitoredLRUCache/MonitoredMRUCache/MonitoredFIFOCache/MonitoredLFUCache/MonitoredEphemeralFIFOCache/MonitoredTTLCache) call new monitoredGetOrCompute/monitoredUpdate methods added to the CacheMonitoring mixin. No public API changed; each facade keeps its own narrow signature and dispatch-through-set behavior — only the previously-duplicated method bodies moved.
  • Fixed AsyncCache/MonitoredCache/MonitoredTTLCache/MonitoredEphemeralFIFOCache's getAll()/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-key getAll() 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 of checkWeightRejection() (trySet() and _storeOrThrow()) down to one: _storeOrThrow() now delegates to trySet() instead of duplicating its "check then write through set()" 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 subclassing Cache/AsyncCache/MonitoredCache directly) 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 concurrent set() 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-diff CI job (.github/workflows/ci.yaml) that runs dart_apitool on 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 stricter fully mode, which would also require every additive change to already carry its eventual minor bump — this project bumps pubspec.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), so fully mode would false-positive on ordinary feature work. onlyBreakingChanges instead 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/EphemeralFIFOCache and their Monitored* 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-existing ThreadSafeCache.getOrCompute()/update() defaults these facades stand in for (which always dispatched through containsKey()/get()). For the Monitored* variants this also meant a hit was not recorded at all if the caller's update/valueFactory callback later threw, since the whole check-compute-store sequence was wrapped in one monitoredGet() call instead of recording the hit as soon as the read resolved. Both now dispatch through containsKey()/get() — safe for these five facades specifically because none of them configure a ttl, so (unlike TTLCache/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's getAll()/removeWhere(), which were left to ThreadSafeCache's default implementations (correct for every other legacy facade, since their get/peek are non-destructive) — but get() for this store is destructive (an entry is removed on retrieval), so the default's separate containsKey()-then-get()/peek() calls, each independently acquiring the lock, left a gap where a concurrent caller's get() could consume the entry first: getAll() would silently omit a key that was confirmed present a moment earlier, and removeWhere() would throw a TypeError casting the resulting null to a non-nullable V. Both now read (and, per get()'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) (and CacheMetricsSnapshot.p50Latency) discarding all sub-millisecond precision on an even sample count — the median branch truncated both middle samples to whole milliseconds via .inMilliseconds before averaging, so two latencies like 400µs/800µs (realistic for an in-memory cache) incorrectly reported a median of 0, disagreeing with averageLatency's correct 600µs for the same data. Now averages in microseconds throughout.
  • Fixed CacheAlertManager firing a spurious "Low hit rate detected" alert on a freshly-constructed cache that hasn't served any traffic yet: CacheMetrics.hitRate documented-returns 0 when totalRequests is 0, and 0 is below almost any positive hitRateThreshold (the default is 0.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 when totalRequests == 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 literal null key (K nullable): FIFOStore/LRUStore/EphemeralFIFOStore/TTLFifoStore's evictOne() delegated to selectVictim() and checked victim == null to mean "nothing evictable" — indistinguishable from a legitimately-selected victim whose key is null. Cache._write()'s while (exceedsCount() || exceedsWeight()) loop then broke out immediately instead of evicting, so e.g. SimpleFIFOCache<int?, String>(1) followed by set(null, 'a') then set(1, 'b') left both entries — over the declared maxSize. selectVictim()/evictOne() on CacheStore now return a single-field record ((K,)?/(K, V)?) rather than a bare K?, so "found, and the key is null" and "found nothing" are distinguishable regardless of K; every store's evictOne() 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's getOrCompute()/update() now assert the instance's lock is actually released (not just that the exception propagates) when the caller's valueFactory/update callback throws, guarding against a silent deadlock on every later call; EvictionReason attribution is now tested with maxSize and maxWeight configured together (previously each was only tested in isolation), pinning down that a write exceeding both at once is always attributed .weight, never .capacity; and TTLFifoStore was added to the shared CacheStore conformance 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 from FIFOStore.
  • Closed the remaining backlog of performance/robustness/behavioral test gaps from the same audit: weigher invocation count on getOrSet/update/trySet/getOrCompute is now pinned down explicitly (see the checkWeightRejection fix below); a slow getOrCompute() on one key is confirmed to actually block a concurrent set() on an unrelated key, per AsyncCache's documented single-instance-lock tradeoff; a set() override that reentrantly calls a different public method (clear()) mid-write is confirmed not to deadlock or corrupt state; a fully unbounded Cache (no maxSize/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's excluding-key eviction fallthrough (previously only tested against LFUStore in isolation) is now driven end-to-end through Cache; CacheMetrics.snapshot() is now smoke-tested for O(n)-not-worse cost at the full maxEvictionSamples retention 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/EphemeralFIFOStore gained the same selectVictim(excluding:) multi-candidate fallback test MRUStore/LFUStore already 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; and PeriodicSweeper gained a dedicated test file, including a direct test of its documented "an in-flight sweep still runs to completion after dispose()" 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 delegated set()/_write() — so a non-deterministic weigher (unsupported per its documented "should be pure" contract, but not otherwise guarded against) could disagree with itself between the two calls, letting trySet() report success (or getOrSet()/update() return a value) for a write that was actually silently rejected for exceeding maxWeight. Cache.wouldRejectWrite() was replaced with checkWeightRejection(), which computes the weight once and threads it back into the delegated set() call as an explicit weight:, 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/update read-dispatch fix below: the bug it fixes previously existed identically in all five non-TTL legacy facades (and their Monitored* 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/EphemeralFIFOCache and their Monitored* 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 runs LRUCache against an independent, deliberately naive reference model (written without consulting LRUStore'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 into LRUStore and 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, and model_based_ephemeral_fifo_cache_test.dart, each with its own from-scratch reference model rather than a copy of LRUCache'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, per LFUStore.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 destructive get() and then writes the result back through set() — since the key was just removed by the read, the write reinserts it as a brand-new entry at the newest position, unlike FIFOCache.update(), which leaves an existing key's position untouched. Each of the four gained the same bug-injection validation as the original LRUCache test (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 via Completers 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 via Future.wait and assert on the aggregate outcome, across LRUCache/MRUCache/FIFOCache/LFUCache/AsyncCache/MonitoredCache: 100 concurrent getOrCompute() calls on the same missing key collapse into exactly one valueFactory invocation; hundreds of mixed set/get/remove/getOrCompute/update calls against a capacity-bounded cache never push it over maxSize and leave it fully usable afterward; concurrent getAll()/removeWhere() batch calls interleaved with regular traffic on a MonitoredCache never 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 a MonitoredLRUCache's hit/miss/total-request counters stay exactly consistent under 300 concurrent get() calls. Empirically probing EphemeralFIFOCache for this surfaced another genuine, non-obvious behavior: because its getOrCompute() reads a hit through the destructive get(), 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 hung Future.wait would 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 into composedGetOrCompute produced immediate, specific failures across every affected target.
  • Closed a further batch of edge-case gaps from a targeted audit: the WeightedLRUCache/SimpleWeightedLRUCache/MonitoredWeightedLRUCache family's update()/getOrCompute() StateError-on-oversized-write contract (previously only exercised via set()'s silent-rejection path, never through the non-void-return methods that actually throw) is now tested for all three classes; a weigher that 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; TTLCache now 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 for EphemeralFIFOCache specifically, 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 the CacheAlertManager zero-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 to cache_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 a null-keyed entry once it's a genuine candidate, plus a narrower group confirming the one remaining excluding-default limitation stays exactly that limitation, not something worse; cache_engine_test.dart gained end-to-end Cache-level tests driving a maxSize- and a maxWeight-bounded cache past capacity with a pre-existing null key; and simple_fifo_cache_test.dart/simple_weighted_lru_cache_test.dart each gained a facade-level test matching the exact reported repro shape.

New Features #

  • Weight-based eviction (closes #67): Added SimpleWeightedLRUCache, WeightedLRUCache, and MonitoredWeightedLRUCache — LRU caches bounded by a caller-supplied per-entry weight (e.g. estimated byte size) via a weigher callback and maxWeight, optionally alongside an entry-count maxSize. An explicit weight: argument can also be passed per set() call.
  • Composable cache engine: Added public Cache/AsyncCache/MonitoredCache classes and a CacheStore interface (with LRUStore/MRUStore/FIFOStore/EphemeralFIFOStore/LFUStore implementations). 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 constructing Cache/AsyncCache/MonitoredCache directly.
  • Per-cause eviction tracking: Added EvictionReason (capacity, weight, expired, manual, unspecified) and a new CacheMetrics.recordEvictionReason(EvictionReason) method to record it, alongside an additive evictionsPerMinuteByReason field on CacheMetricsSnapshot/DashboardSnapshot. The existing zero-argument recordEviction() is unchanged (it now delegates to recordEvictionReason(EvictionReason.unspecified)) — see the "Maintenance" section further below for why EvictionReason isn't instead an optional parameter on recordEviction() itself. CacheAlertConfig gained an optional evictionsPerReasonThreshold so alerting can distinguish an expected expired rate from a capacity/weight rate that signals the cache is undersized.
  • Added a shared PeriodicSweeper mixin (implements Disposable) 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 explicit weight: override if needed) to refresh it.
  • Documented that getOrCompute/update hold 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-K limitation on CacheStore.selectVictim()/evictOne()'s excluding parameter: its own default (null, "exclude nothing") is indistinguishable from an explicit request to exclude the literal key null, so a store holding only a null-keyed entry, queried with excluding left at its default, still reports nothing evictable. This doesn't affect Cache's own eviction loop (which always passes the key it's currently writing as excluding, never the default) — only a caller driving CacheStore directly. See CacheStore.selectVictim()'s doc comment for the full analysis. (The broader version of this limitation — null also overloading the return value's "no victim" result — is fixed; see Fixes below.)
  • Documented that Cache.getOrSet()/update(), AsyncCache.getOrCompute()/update(), and MonitoredCache.getOrCompute()/update() do not dispatch their presence check/read through this cache's own (possibly overridden) get()/containsKey() — unlike set(), 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 a ttl, so dispatching through containsKey()/get() is safe; these three classes are the general engine itself, used both with and without a ttl/weigher depending on how a given instance was built, so there is no way to know at the call site whether a specific instance needs presentValue()'s single-clock-snapshot safety — dispatching through get()/containsKey() here would silently reintroduce the TTL check-then-fetch race for any ttl-configured instance. Previously undocumented despite the class docs explicitly inviting "power users" to subclass these engines directly.
  • Fixed 8 dart doc warnings from unresolved doc-reference syntax: CacheStatsDashboard.snapshot() referenced [totalRequests] unqualified (now [CacheMetrics.totalRequests]), and seven Monitored*Cache classes' doc comments used the grammatical shorthand follow[s] (e.g. "update() follow[s] getOrCompute()..."), which dartdoc parsed as a broken [s] cross-reference rather than prose (now spelled out as follows). 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 for getOrSet/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), and SimpleTTLCache.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). Added Cache.presentPeek() — a peek-based (non-mutating) counterpart to presentValue() — so removeWhere() can test entries for removal without perturbing LRU/LFU eviction-policy state as a side effect.
  • Fixed LFUStore's eviction-candidate fallthrough degrading to O(distinct frequencies) per victim (and repeating per victim across a multi-eviction write, i.e. up to O(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() and MonitoredTTLCache.getAll()/removeWhere()/update() silently dropping the hit/miss/latency/eviction metrics doc/monitored_cache.md documents, by delegating straight to an unmonitored inner engine call instead of routing through the monitored get()/remove() path (update()'s gap was a regression from the presentValue()-based TTL-race fix above, which bypassed the old default implementation's get() call that used to record these metrics via virtual dispatch).
  • Fixed Cache.validateSetArgs() silently accepting a negative explicit weight: on getOrSet()/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, matching set()'s validate-first contract.
  • Fixed getOrSet()/getOrCompute()/update() (Cache, AsyncCache, MonitoredCache) reporting a value as cached when its weight actually exceeded maxWeight and _write() silently rejected it as a no-op — the same "can never fit" case set() accepts silently, but these methods have a non-void return 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 throw StateError instead 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. Added CacheStore.removesOnAccess so Cache.get() skips a redundant presence recheck for every policy (LRU/LFU/FIFO/MRU/TTL) except EphemeralFIFOStore, the only one where access() removes the entry.
  • Fixed a source-compatibility break in SimpleLRUCache/SimpleFIFOCache/SimpleLFUCache/SimpleMRUCache/SimpleEphemeralFIFOCache, their async equivalents, and the corresponding Monitored*Cache classes: extending Cache/AsyncCache/MonitoredCache directly (rather than composing them, as TTLCache/MonitoredTTLCache/SimpleTTLCache already correctly did) pulled the new engine's weight/ttl named parameters into these classes' inherited set/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; the Monitored* ones additionally mix in CacheMonitoring/PeriodicSweeper directly (matching MonitoredTTLCache) so is CacheMonitoring<K, V>/is Disposable keep 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 of set/get/etc. entirely — before the composable-engine refactor, these were inherited from SimpleCache/ThreadSafeCache and dynamically called the (overridable) facade methods. The non-monitored facades now leave these to the interface defaults, which call this class's own methods; the Monitored* ones keep custom getOrCompute/update (for hit/miss metrics) but now write through their own set instead 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 for getOrCompute/update.
  • Fixed CacheMetrics.recordEviction() gaining an optional EvictionReason parameter, 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-argument recordEviction() and a new recordEvictionReason(EvictionReason).
  • Fixed the weight-based-eviction README/doc/weighted_lru_cache.md examples labeling a List<int>'s .length as a byte-size weight — a List<int> has substantial per-element overhead beyond one byte, so this understated real memory usage. Switched the examples to Uint8List/lengthInBytes, which is exactly the byte count.
  • Found and fixed the same set-bypass issue in TTLCache/MonitoredTTLCache/SimpleTTLCache's getOrCompute/getOrSet/update, which wrote through the internal engine directly instead of this class's own set. Unlike the 15 facades above, these classes' getAll/removeWhere were 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 separate get/peek/containsKey calls would reintroduce; getOrCompute/update don'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, in Cache/AsyncCache/MonitoredCache themselves: Cache.getOrSet()/update()/setAll() wrote via private _write/_writeOrThrow helpers, and AsyncCache/MonitoredCache's getOrCompute()/update()/setAll() wrote via storeOrThrow() calling the composed Cache engine's trySet() directly — both bypassing this.set() entirely. Since SimpleWeightedLRUCache/WeightedLRUCache/MonitoredWeightedLRUCache extend these classes directly and add no set() of their own, they (and any subclass overriding set() on any of these six classes) were silently affected. Cache.getOrSet()/update()/setAll()/(the now-set()-delegating) trySet() and AsyncCache.storeOrThrow()/setAll() now write through this.set(); a new Cache.wouldRejectWrite() lets each of these detect a weight-exceeds-maxWeight rejection before delegating to set() (which, like _write() before it, has no way to report back what it actually stored), so the reject-and-throw contract on getOrSet/update/getOrCompute is preserved without needing set() itself to return anything. Added test/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(), and removeWhere() default APIs to simple, async-safe, and TTL cache interfaces.
  • Added TTL-aware putIfAbsent() and update() overloads so new or updated entries can receive per-entry TTL overrides through TTL abstractions.
  • Added getAll(), setAll(), and removeAll() 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 CacheMetrics for 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-aware getOrCompute().
  • Documented the non-mutating peek() API across README and cache guides.
  • Documented occupancy APIs (size, isEmpty, and isNotEmpty) 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, and isNotEmpty to simple, async-safe, monitored, and TTL cache variants so callers can inspect cache occupancy without materializing keys directly.
  • Added purgeExpired() to SimpleTTLCache, 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 synchronized and lints to 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(), optional maxSize, and FIFO capacity eviction.
  • TTL cache interfaces: Added SimpleTTLCacheInterface and ThreadSafeTTLCacheInterface so abstract cache references can still expose per-entry TTL overrides.
  • Cache-aside population: Added getOrSet() to simple caches and getOrCompute() 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, Disposable lifecycle behavior, and bounded CacheMetrics sample storage.

Maintenance #

  • Added regression coverage for SimpleTTLCache, TTLCache, and MonitoredTTLCache through the new TTL-specific interfaces.
  • Added regression coverage for cache-aside population, dispose() idempotency, post-dispose operations, and getKeys() 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 stored null values 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 CacheAlertConfig optional 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 null values are recorded as hits when the key exists.
  • TTLCache: Validates sweepInterval and 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 ThreadSafeCache implementations.
  • 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) _minFreq recomputation in remove(). The minimum-frequency pointer is now maintained incrementally.

Bug Fixes #

  • LFUCache: toString() now eagerly snapshots _keyMap before 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() and toString().
  • 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 by synchronized ^3.4.0)
  • Kept synchronized at ^3.3.1 (resolves to 3.4.0+1 in practice, which requires SDK >=3.8.0)
  • Updated lints dev dependency from ^5.0.0 to ^5.1.1 to align with the revised SDK baseline
  • Raised test dev dependency from ^1.25.8 to ^1.31.0 (resolved: 1.31.1)
  • Removed dart_code_metrics ^5.7.6 (incompatible with the analyzer versions 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 .fvmrc to .gitignore
  • No public API changes; no breaking changes

2.0.0 CacheStatsDashboard, TTLCache, and Lifecycle Management #

New Features #

  • CacheStatsDashboard: New class that wraps CacheMetrics to produce typed DashboardSnapshot objects 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 Disposable interface 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, and capturedAt.
  • formatDashboard(): New top-level function that renders a DashboardSnapshot as a Unicode box-drawing terminal panel with adaptive unit formatting (µs, ms, s).

Breaking Changes #

  • Interface Implementation: All Monitored* caches and TTLCache now implement Disposable. Callers managing these instances should call .dispose() to prevent timer leaks.
  • CacheStatsDashboard.snapshot(Duration window) now throws ArgumentError for zero or negative window values (previously undefined behaviour propagated from CacheMetrics.getRecentStats).
  • CacheStatsDashboard.stream(Duration window, Duration interval) now throws ArgumentError for zero or negative interval values.

1.1.5 Bug Fixes #

  • Fixed spurious eviction in FIFO set() when updating an existing key.
  • Fixed unawaited Future in 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 CacheMetrics by 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 docs directory to doc to comply with pub.dev package layout convention.
  • Updated dependencies:
    • Resolved version constraints for lints, synchronized, and js packages.
  • 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 description field in pubspec.yaml to meet pub.dev requirements.
  • 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 description field in pubspec.yaml to provide a more specific and accurate explanation of the package's functionality.

1.0.0 Initial release #

  • First release of cacherine package 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!

2
likes
160
points
1.3k
downloads

Documentation

API reference

Publisher

verified publisheryom-engine.com

Weekly Downloads

A Dart in-memory cache library with FIFO, LRU, MRU, LFU, TTL expiry, async-safe variants, and monitoring metrics.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

synchronized

More

Packages that depend on cacherine