From a58a69380e3a38e9efc4965b4a785bc420c7156c Mon Sep 17 00:00:00 2001 From: Magnus Kokk Date: Mon, 30 Mar 2026 21:26:18 +0300 Subject: [PATCH 1/2] Refactor expiry debounce --- internal/backend/backend.go | 24 ++++--------- internal/backend/backend_test.go | 58 ++++++++++++++++++++------------ 2 files changed, 43 insertions(+), 39 deletions(-) diff --git a/internal/backend/backend.go b/internal/backend/backend.go index bf8b7ff..181f220 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -25,7 +25,6 @@ type Backend[K comparable, V any] struct { xmap map[K]*list.Element[Record[K, V]] // map of uninitialized and initialized elements list list.List[Record[K, V]] // list of initialized elements policy string - lastGCAt int64 earliestExpireAt int64 cap int defaultTTL time.Duration @@ -235,25 +234,20 @@ func (b *Backend[K, V]) prepareDeadline(ttl time.Duration) int64 { b.onceStartCleanupLoop() now := time.Now() - deadline = now.Add(ttl).UnixNano() - deadline = b.debounceDeadline(deadline) + deadline = now.Add(ttl).Round(b.debounce).UnixNano() if b.earliestExpireAt == 0 || deadline < b.earliestExpireAt { - b.earliestExpireAt = deadline - after := time.Duration(deadline - now.UnixNano()) - b.timer.Reset(after) + b.resetTimer(now.UnixNano(), deadline) } } return deadline } -func (b *Backend[K, V]) debounceDeadline(deadline int64) int64 { - if until := deadline - b.lastGCAt; until < b.debounce.Nanoseconds() { - deadline += b.debounce.Nanoseconds() - until - } - - return deadline +func (b *Backend[K, V]) resetTimer(now, deadline int64) { + b.earliestExpireAt = deadline + after := time.Duration(deadline - now) + b.timer.Reset(after) } func (b *Backend[K, V]) hit(elem *list.Element[Record[K, V]]) { @@ -339,15 +333,11 @@ func (b *Backend[K, V]) DoCleanup(nowNano int64) { b.delete(elem) } - b.lastGCAt = nowNano - switch earliest { case 0: b.earliestExpireAt = 0 default: - earliest = b.debounceDeadline(earliest) - b.earliestExpireAt = earliest - b.timer.Reset(time.Duration(earliest - nowNano)) + b.resetTimer(nowNano, earliest) } } diff --git a/internal/backend/backend_test.go b/internal/backend/backend_test.go index 6a7ad19..e58df68 100644 --- a/internal/backend/backend_test.go +++ b/internal/backend/backend_test.go @@ -3,6 +3,7 @@ package backend_test import ( "fmt" "runtime" + "slices" "sync" "testing" "time" @@ -202,50 +203,63 @@ func TestConcurrentFetch(t *testing.T) { } func TestExpiryLoopDebounce(t *testing.T) { - debounce := 100 * time.Millisecond + debounce := time.Second n := 10 - - getHalfTimeLength := func(b *backend.Backend[int, int]) int { - itemTTL := debounce / time.Duration(n) - - // Store elements with 10, 20, 30, ... ms TTL. - for i := range n { - b.StoreTTL(i, 0, time.Duration(i+1)*itemTTL) + itemTTL := debounce / time.Duration(n) + + collectLengths := func(b *backend.Backend[int, int]) []int { + var values []int + for { + l := b.Len() + if l == 0 { + break + } + values = append(values, l) + runtime.Gosched() } - time.Sleep(debounce / 2) + slices.Sort(values) + values = slices.Compact(values) - return b.Len() + return values } - var debounceDisabledLen int + // When disabled, cache expiry decrements in smaller steps - each item is deleted when expired. + var debounceDisabledBuckets int { var b backend.Backend[int, int] b.Init(0, "", 0, 0) t.Cleanup(b.Close) - debounceDisabledLen = getHalfTimeLength(&b) + // Store n elements with nth of debounce TTL. + for i := range n { + b.StoreTTL(i, 0, time.Duration(i+1)*itemTTL) + } - EventuallyTrue(t, func() bool { - return b.Len() == 0 - }) + values := collectLengths(&b) + + debounceDisabledBuckets = len(values) } - var debounceEnabledLen int + // When enabled, cache expiry decrements in bigger steps - items are deleted in batches. + var debounceEnabledBuckets int { var b backend.Backend[int, int] - b.Init(0, "", 0, 100*time.Millisecond) + b.Init(0, "", 0, debounce) t.Cleanup(b.Close) - debounceEnabledLen = getHalfTimeLength(&b) + // Store n elements with nth of debounce TTL. + for i := range n { + b.StoreTTL(i, 0, time.Duration(i+1)*itemTTL) + } - EventuallyTrue(t, func() bool { - return b.Len() == 0 - }) + values := collectLengths(&b) + + debounceEnabledBuckets = len(values) } t.Log("assert that debounce disabled expires elements earlier than debounce enabled") - Equal(t, debounceDisabledLen < debounceEnabledLen, true) + Equal(t, debounceDisabledBuckets > debounceEnabledBuckets, true) } func TestEvict(t *testing.T) { From 147bf0a843d51f8460ddb75d1c1a4bbf5d389f23 Mon Sep 17 00:00:00 2001 From: Magnus Kokk Date: Tue, 31 Mar 2026 13:32:51 +0300 Subject: [PATCH 2/2] Always round deadline up --- internal/backend/backend.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 181f220..929d4ac 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -234,7 +234,7 @@ func (b *Backend[K, V]) prepareDeadline(ttl time.Duration) int64 { b.onceStartCleanupLoop() now := time.Now() - deadline = now.Add(ttl).Round(b.debounce).UnixNano() + deadline = now.Add(ttl).Truncate(b.debounce).Add(b.debounce).UnixNano() if b.earliestExpireAt == 0 || deadline < b.earliestExpireAt { b.resetTimer(now.UnixNano(), deadline)