Repository navigation
2.4.0 - 2026-10-20
π Description
This release is mostly about correctness. The fixes come from audits of races between loads, refreshes, writes and expiration, of how the cache handles panics and runtime.Goexit in user code, and of its lifecycle. A new test checks random concurrent histories of the cache for linearizability with porcupine. Loading and refresh are now linearizable: a load or refresh that started before a write no longer replaces the value the write stored (comparison with other caches). Updates of live entries now happen in place, which makes them 2β19Γ faster depending on the value type and contention. Binaries are smaller too: a program with one cache is 3.15 MB instead of 4.41 MB, and each additional cache type adds about 160 KB instead of 350 KB.
β¨Features
- Added
CoarseClock, aClockthat reads the time once per resolution and can be shared by several caches. With expiration it makesGetIfPresent26β57% faster (#281) - A
GetorBulkGetthat waits for another goroutine's load now follows its own context. It returnsctx.Err()when its context is done; the load keeps running and caches its value. If the load failed because the owner's context was done, a waiter with a live context loads the key again (#237)
π Improvements
- Updates of live entries happen in place, without a new node or a write-buffer task, and values of at most one word are updated without allocating. With 12 goroutines an int update is 95% faster (399.8 β 21.1 ns) and a string update 93% faster (#194, #195, #196, #197, #198)
- Reads with
ExpiryWritingandExpiryCreatingno longer callExpireAfterRead(up to 29% faster), and reads with access-based expiration don't write a deadline that moves by less than a second (37β48% faster from 4+ goroutines) (#225, #226) - The read buffer picks its stripe without
sync.Pool, loses fewer reads and no longer shares cache lines with other objects. A mix of 75% reads on 8 threads is 55% faster (#219, #236) - The hash table hashes integer keys without
maphashand unlinks empty overflow buckets, which saves ~34 MB per 1M entries and speeds up misses. Resizes and allocating a largeInitialCapacityare faster too (#211, #212, #213, #214, #224) - Writes and evictions are faster: singleflight calls are looked up without locking, scheduling a drain doesn't allocate, and policy nodes are linked through raw pointers (#221, #222, #246, #283)
- Write-buffer tasks are allocated instead of pooled. Inserts are up to 16% faster, but each insert, replacement or deletion now allocates 48 B (#223)
- Node code is compiled once per program, so each additional cache type adds 154 KB to the binary instead of 476 KB (#293)
- New benchmarks (eviction, expiration, loading, scaling), regenerated performance pages and a comparison of the linearizability of the benchmarked caches (#229, #238, #284)
- The documentation now says which cache methods a callback may call, what an
Executormust not do and whatStopAllGoroutinesstops. Places where it disagreed with the code are fixed (#258, #262, #266, #294, #295)
β οΈ Behavior changes
- An
Executormust not block the submitter. A bounded pool should run the task on the caller when it is full (#263) - Panics in
OnDeletion,OnAtomicDeletion, the stats recorder and the logger are logged and dropped. Panics inCompute, the weigher and the calculators still reach the caller, but the entry is left unchanged (#191) SetExpiresAfterandSetRefreshableAftertake the key's bucket lock, so calling them fromCompute*,OnAtomicDeletion, a weigher or a calculator deadlocks (#247)- An expired entry that hasn't been removed yet is absent for every operation:
Setreturns(value, true),Invalidatereports nothing invalidated, and a write over it is a creation (ExpireAfterCreate,RefreshAfterCreate) (#208, #230) - On a miss,
GetandBulkGetwith a context that is already done returnctx.Err()without calling the loader. The loader may be called more than once for oneGetwhen the key is invalidated while it loads (#288, #295) BulkGetno longer returns keys that the bulk loader leaves out, and extra keys returned by the bulk loader only fill gaps instead of overwriting values (#202, #206)- An expired entry removed by a write or by size eviction is reported as
CauseExpirationand counted inEvictions; evictions are recorded beforeOnDeletionruns (#235, #253) - With access-based expiration, a deadline set by a read is accurate to within a second for lifetimes over a second (#225)
OnAtomicDeletionruns before the node is retired and may be called again for the same removal if it exits withruntime.Goexit(#286)- Iteration (
All,Keys,Values,Hottest,Coldest) is weakly consistent rather than a snapshot (#245) - The cache no longer uses
runtime.AddCleanup: the periodic clean-up goroutine holds a weak pointer and exits after the cache is collected (#279) Newpanics whenInitialCapacityis too large to allocate (#252)
π Bug Fixes
Loading and refresh:
GetandBulkGetcould call the loader more than once for one key (#152, #184, #241)- A load or a refresh could overwrite a value that
SetorComputewrote while it ran; with the default executor 970 of 1,000Sets after a staleGetwere lost (#203, #240, #243) - A key could be refreshed twice, a read racing
Invalidatecould bring the key back, and aGetthat joined a discarded or failed refresh got that refresh's result instead of the key's value (#243, #288) - Stale reads could fill a bounded blocking executor with tasks that only waited for a refresh, and deadlock the cache (#243)
- A refresh was dropped when an old node of its key was evicted or an expired entry was removed (#200, #243)
- Eviction cancelled in-flight loads (#188, #191)
ComputereturningCancelOpover an expired entry cancelled the key's in-flight load (#210)BulkGetreported a key missing or lost the error of a load it joined when its own bulk loader also returned that key (#288)- A manual
Refreshthat joined aGetwhose context was done reported that cancellation instead of loading the key (#292) - A nil loader or context left the key's load registered forever, so later
Gets hung (#257)
Expiration:
- Durations of
math.MaxInt64overflowed into a past deadline, so the entry expired at once and everyGetreloaded it (#204) - An entry that expired while a write waited for the bucket lock was treated as live, and
SetExpiresAftercould revive an expired entry (#208, #249) SetExpiresAfterandSetRefreshableAfterwere undone by a concurrent write or a failed reload (#247)- A deadline moved earlier by
SetExpiresAftercould be ignored by the timer wheel until the old deadline (#209) - A deadline the timer wheel had already passed was put into a bucket ~6.5 days ahead (#196)
RefreshCreatingreloaded a value written over an expired entry on its first read (#230)CleanUp,Entry.HasExpiredandLoadCacheFromtreated an entry as live at its exact deadline. A clock started before 1970 expired every entry at once, and one whose readings crossed zero left passed deadlines unexpired (#234, #268)
Eviction and policy:
- Setting a key twice before the first insert was applied left a node that was counted in the size but could never be evicted, so the cache grew without bound (#189)
- Write-buffer tasks of one key replayed out of order left dead nodes and their weight in the policy (#193)
- An idle cache without expiration stayed over its maximum after
Hottest,ColdestorInvalidateAll(#288) - The hill climber kept a quota from the old maximum after
SetMaximum, collapsed the window of a cache created with a smallInitialCapacity, and counted a refresh as an access (#260, #271) ComputeIfPresentrecorded a hit and calledExpireAfterReadbefore running its function (#271)- An
InitialCapacityabove ~8e9 wrapped the table size to zero (#252) InvalidateAlland iteration (includingSaveCacheTo) skipped keys whose node was replaced meanwhile, andHottest/Coldestmerged the window and probation queues in the wrong order (#205, #207, #245)
Listeners and stats:
OnDeletionandOnAtomicDeletioncould get different causes for one removal (#196)- With a caller-runs executor, an eviction caused by a write was reported before the replacement that caused it (#272)
SetorComputeracing a read could report an entry as present and as expired at the same time (#290)- An expired entry evicted for size was reported as
CauseOverflow, an expired entry removed by a write wasn't counted as an eviction, and a load whose result panicked wasn't counted at all (#201, #235, #253)
Persistence:
LoadCacheFromfilled the cache in the snapshot's units instead of its own, moved restored deadlines later, recorded phantom hits, and lost its warm-up withoutInitialCapacity(#259, #270, #274)SaveCacheToFilewasn't atomic (#268)
Panics, runtime.Goexit and lifecycle:
- A panic or
runtime.Goexitin a callback, loader, clock, executor, logger or stats recorder could leave a bucket or the eviction lock held, waiters hanging, a write outside the policies or a refresh unreported (#191, #264, #265, #268, #286, #287, #295) - A cache whose callbacks referenced it was never garbage-collected, and collecting a cache created in a
testing/synctestbubble crashed the program (#279) StopAllGoroutinesdeadlocked when called fromHottest,Coldestor aSaveCacheTowriter; a closedTickchannel made the clean-up goroutine spin; the default clock leaked its ticker (#294)testingand testify were compiled into every program that imported otter (#185, #186)