Skip to content

clock for test - #2

Merged
mbrostami merged 7 commits into
mainfrom
fix/single-flight-stale-cap-capacity
Aug 11, 2026
Merged

clock for test#2
mbrostami merged 7 commits into
mainfrom
fix/single-flight-stale-cap-capacity

Conversation

@mbrostami

Copy link
Copy Markdown
Owner

No description provided.

mbrostami and others added 7 commits August 11, 2026 17:49
Waiters in fetchOnce blocked on wg.Wait() with no way to honor their own
context, and the shared fetch ran on the initiating caller's context, so
one caller giving up canceled the fetch and poisoned the result for every
waiter.

The shared fetch now runs in its own goroutine on a cancel-free copy of
the initiator's context (values kept for tracing, cancellation dropped),
completion is signaled by closing a channel, and every caller selects on
that against its own ctx.Done(). A caller that gives up gets ctx.Err();
the fetch still completes and its result is cached for the others.

context.WithoutCancel requires go 1.21, so the module floor moves up
from 1.18.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Previously a failed refresh re-put the stale value with a fresh StaleTTL,
so every failure pushed the expiry out again: under a long outage
staleness was unbounded, and the re-put could overwrite a value a
concurrent successful fetch had just stored.

Items now record fetchedAt instead of a mutable expiry. A value is fresh
until fetchedAt+TTL and may be served stale until fetchedAt+TTL+StaleTTL;
past that it is dead and the next lookup fetches synchronously, GetAsync
included. Failed refreshes no longer touch the stored item, so the cap
never moves and the overwrite race is gone. "Serve stale for at most
StaleTTL" is now a guarantee an operator can state.

StaleTTL == 0 keeps its existing meaning: Get never serves stale, and
GetAsync's serve-while-refreshing staleness stays unbounded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The store was an unbounded sync.Map, which rules the cache out for
attacker-influenced key spaces (caching by API key or request path lets
a client grow memory without limit).

Config.Capacity (<= 0 keeps today's unbounded behavior) now bounds the
entry count; on overflow dead entries — past the staleness cap — are
evicted first, then arbitrary ones. Enforcing a size atomically needs a
size-aware store, so the value store moves from sync.Map to an
RWMutex-guarded map; the read path stays a single RLock'd map lookup.
Range now iterates a snapshot, so its callback may safely mutate the
cache. The inflight/refreshing bookkeeping is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A fetch error was always treated as transient, which made stale-if-error
dangerous for lookups where the upstream can authoritatively say a key is
gone: a deleted model or revoked API key kept being served stale for up
to StaleTTL.

Config gains two opt-in fields. NotFound classifies a fetch error as an
authoritative "does not exist"; such a miss evicts any cached value
(authoritative-wins — it is never served stale, in GetStale's error path
and in background refreshes alike). NegativeTTL then caches the miss
itself: lookups within the TTL are answered from the negative entry
without touching the upstream, and misses are never served past it (no
stale negatives). NegativeTTL of 0 keeps evicting without caching.

OnError no longer fires for authoritative background misses — they are
cache state, not failures. With NotFound unset every error stays
transient and nothing changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The go.mod floor moved to 1.21 for context.WithoutCancel, so the 1.18
matrix leg fails to build (and fail-fast cancels the stable legs with
it). Test 1.21 as the minimum plus stable.

Also bump checkout/setup-go past their Node 20 deprecation warnings and
disable the setup-go module cache — a zero-dependency module has no
go.sum to key it on, which logged a restore warning every run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The clock was already injectable internally; expose it so consumers can
pin time in their own tests instead of sleeping through TTLs. Nil keeps
time.Now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mbrostami
mbrostami merged commit 1e9da0b into main Aug 11, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant