fix: [#1003] store the cache expiration with the value instead of a timer - #1560
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1560 +/- ##
==========================================
+ Coverage 72.70% 72.73% +0.02%
==========================================
Files 412 412
Lines 26786 26820 +34
==========================================
+ Hits 19476 19507 +31
- Misses 7308 7311 +3
Partials 2 2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
🤖 Automated reviewThis is an AI-generated code review. Please double-check each finding before acting. SummaryReplaces Verdict
FindingsMust Fix
Should Fix
Nits
Automated Checks
|
An expiration stored with the value is only acted on when the key is touched, so a key written once and never read again held its value for the life of the process: rate limiter buckets for clients that stopped calling, one-shot tokens nobody presented. The timers this branch removes did reclaim those. A write now sweeps the whole map, at most once a minute. Memory has no shutdown hook to hang a janitor goroutine on, so the sweep is started by a write rather than by a ticker, and it runs in the background because ranging the map is O(n) and does not belong on the request that happened to reach the interval. The rest comes out of the review: * Add answers an already taken key before building the item it would throw away. Lock.Get is built on Add and polls it every 10ms; a failed take goes from 152ns/252B/4 allocs on master, and 138ns/72B/3 allocs here, to 107ns/8B/1 alloc. * A stored value that is not an *item is treated as a miss instead of panicking every concurrent reader. * TestAddWithConcurrent repeats the contention. A single round is won before most of its goroutines have started, so it passed even with the CompareAndSwap replaced by a plain Store; 500 rounds of eight catch that in about fifteen percent of them. * TestExpiredItemIsNotDroppedOverAFreshValue covers the CompareAndDelete a read uses, which no test distinguished from an unconditional Delete. * The limiter test requires its constructor to succeed and asserts that requests are admitted at all. It also stops counting a request that crossed an interval boundary while it waited for the lock: the bucket counts the interval it serves a request in, the loop counted the one it sent it in, and that let interval 0 record 21 takes against a limit of 20.
|
Thanks. The memory one was real, I reproduced it: 1000 keys with a 10ms TTL are still in the map after they expire, and 1000 unrelated writes afterwards do not touch them. A write now sweeps the map at most once a minute, in the background, since Taken as well: the Two I did differently. The start barrier alone did not exercise the swap. With the Two I left alone. Storing a bare value when One thing the review did not catch: that limiter test was flaky. The bucket counts the interval it serves a request in, the loop counted the one it sent it in, so a request that waited for the lock across a boundary could record 21 takes against a limit of 20. It failed once under |
|
Thanks, @darakanoit CI failed. |
…t of the clock The two tests stored an item with a one nanosecond TTL and took it for expired. time.Now() advances in milliseconds on Windows, so a whole round of the test ran inside a single tick: the item still counted as live, all eight Add calls returned false, and TestAddWithConcurrent failed on every round. They now store the item with an expiry already in the past, which no clock granularity can read as live, and the sweep test no longer sleeps for one. NewMemory also starts the sweep clock, so the first write to a fresh cache does not sweep a map that has nothing to reclaim.
|
My fault, and it is the tests I added rather than the cache. They stored an item with a one nanosecond TTL and treated it as expired, but |
📑 Description
Closes goravel/goravel#1003
Memory.AddandMemory.Putschedule expiration with a baretime.AfterFunc:The timer knows the key, not the value it was created for. It is never stopped,
and it deletes whatever sits under the key when it fires. Three consequences:
Lock.Getreleases a lock someone else holds.Addarms thetimer before
LoadOrStoreand keeps it when the key is already taken, andLock.Getis built onAdd. A contender with a shorter TTL deletes theholder's lock, and a third caller then acquires it;
Flushremoves a key stored after it, including onestored with
NoExpiration;still fires.
memoryis the default store in the skeleton, sofacades.Cache()andeverything built on it is affected. The HTTP rate limiter stores its bucket with
Put(key, bucket, interval)and guards it with a lock on every request: from thesecond interval on it lets every request through.
TestStoreWithMemoryKeepsLimitingfails on
masterwith 54 requests allowed where the limit is 20.What changed
The expiration is now stored next to the value and checked on read, the way
Laravel's
Illuminate\Cache\ArrayStoredoes it. Nothing is scheduled, so nothingcan outlive the value it belongs to.
Addreplaces an expired item withCompareAndSwap, so two callers racing foran expired key cannot both win;
CompareAndDelete, so it cannot remove avalue stored in the meantime;
write sweeps the whole map at most once a minute, so a key nothing reads again
does not hold its value for the life of the process. This also removes the timers that kept expired values alive: 200k
Putcalls with a one hour TTL leave ~404k live heap objects on
masterand ~4kafter the change.
Verification
TestAddDoesNotExpireTheStoredValueTestLockIsNotReleasedByAFailedGetTestPutAfterFlushKeepsTheNewValueTestPutExtendsTheExpirationTestStoreWithMemoryKeepsLimitinggo test ./cache/ -racego test ./cache/ -racefailed onmasterbecauseTestIncrementWithConcurrentand
TestDecrementWithConcurrentshare oneerracross goroutines and assertinside them; both now count failures and assert on the test goroutine. The cache,
http, queue, schedule, session and auth suites pass.
Scope
cache/memory.go: expiry stored with the value, timers removedcache/memory_test.go: regression tests for the three cases, a concurrentAddtest, and the race fix in the two existing concurrent testshttp/limit/store_memory_test.go: the limiter against the real memory driverNo public API changed. Nothing visible to callers changed: an expired key is
still removed in the background, by a sweep instead of a per-key timer.
✅ Checks