Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/go-memoize-package/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Choose the API and store before editing:
- Default clocks and clocks created by `WithTickerClock` are cache-owned and stopped by `Cache.Stop`; clocks injected with `WithClock` are caller-owned and may be shared across caches.
- Metrics use one public method: `RecordMetric(memoize.MetricEvent)`.
- `background.Keep` and `loader.New` share internal periodic refresh-loop infrastructure. Cache stale refresh uses cache flight machinery, not the shared refresh loop.
- A foreground `GetOrCompute` panic is propagated to the leader and same-flight followers. The flight must be removed without caching a result so later calls can retry.
- Source compatibility may change for performance or clarity; users can pin module versions.
- Keep core code standard-library-only unless the user explicitly approves a dependency.

Expand Down
44 changes: 35 additions & 9 deletions cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,19 @@ import (
)

type flight[V any] struct {
wg sync.WaitGroup
value V
err error
wg sync.WaitGroup
value V
err error
panicValue any
panicked bool
}

func (f *flight[V]) wait() (V, error) {
f.wg.Wait()
if f.panicked {
panic(f.panicValue)
}
return f.value, f.err
}

// peekingStore lets GetOrCompute inspect stored entry state without applying
Expand Down Expand Up @@ -112,8 +122,8 @@ func (c *Cache[K, V]) waitForFlight(key K) (V, bool, error) {
var zero V
return zero, false, nil
}
existing.wg.Wait()
return existing.value, true, existing.err
value, err := existing.wait()
return value, true, err
}

func (c *Cache[K, V]) startFlight(key K) (*flight[V], bool) {
Expand All @@ -132,21 +142,37 @@ func (c *Cache[K, V]) startFlight(key K) (*flight[V], bool) {
func (c *Cache[K, V]) finishFlight(key K, f *flight[V], value V, err error) {
f.value = value
f.err = err

c.flightMu.Lock()
delete(c.flights, key)
c.flightMu.Unlock()
f.wg.Done()
}

func (c *Cache[K, V]) finishPanickedFlight(key K, f *flight[V], panicValue any) {
f.panicValue = panicValue
f.panicked = true

c.flightMu.Lock()
delete(c.flights, key)
c.flightMu.Unlock()
f.wg.Done()
}

func (c *Cache[K, V]) do(key K, fn func() (V, error)) (V, error) {
func (c *Cache[K, V]) do(key K, fn func() (V, error)) (value V, err error) {
f, leader := c.startFlight(key)
if !leader {
f.wg.Wait()
return f.value, f.err
return f.wait()
}

value, err := fn()
defer func() {
if panicValue := recover(); panicValue != nil {
c.finishPanickedFlight(key, f, panicValue)
panic(panicValue)
}
}()

value, err = fn()
c.finishFlight(key, f, value, err)
return value, err
}
Expand Down
42 changes: 42 additions & 0 deletions cache_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -424,3 +424,45 @@ func TestConcurrentMissComputesOnce(t *testing.T) {
t.Fatalf("expected 1 compute call, got %d", calls)
}
}

func TestGetOrComputePanicDoesNotPoisonKey(t *testing.T) {
cache, err := memoize.New[string, int](
memoize.Opts().WithStore(memory.New[string, int](8)).WithTTL(time.Minute),
)
if err != nil {
t.Fatalf("new cache failed: %v", err)
}
t.Cleanup(cache.Stop)

func() {
defer func() {
if got := recover(); got != "boom" {
t.Fatalf("recovered panic = %v, want boom", got)
}
}()
_, _ = cache.GetOrCompute(t.Context(), "key", func(context.Context) (int, error) {
panic("boom")
})
}()

type result struct {
value int
err error
}
done := make(chan result, 1)
go func() {
value, err := cache.GetOrCompute(t.Context(), "key", func(context.Context) (int, error) {
return 42, nil
})
done <- result{value: value, err: err}
}()

select {
case got := <-done:
if got.err != nil || got.value != 42 {
t.Fatalf("retry = (%d, %v), want (42, nil)", got.value, got.err)
}
case <-time.After(time.Second):
t.Fatal("retry blocked on a poisoned single-flight")
}
}
22 changes: 22 additions & 0 deletions cache_flight_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package memoize

import (
"testing"
)

func TestFlightWaitRepanicsWithOriginalValue(t *testing.T) {
sentinel := &struct{ message string }{message: "boom"}
f := &flight[int]{
panicValue: sentinel,
panicked: true,
}
f.wg.Add(1)
f.wg.Done()

defer func() {
if got := recover(); got != sentinel {
t.Fatalf("recovered panic = %v, want identical sentinel", got)
}
}()
_, _ = f.wait()
}
2 changes: 2 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ defer cache.Stop()

The cache engine owns freshness decisions. Stores persist `memoize.Stored[V]` envelopes and return entries even when they might be stale or expired; `Cache[K,V]` decides whether to serve, refresh, or miss.

If a foreground `GetOrCompute` function panics, the leader and all callers waiting on that same-key computation panic with the same value. The failed flight is removed without caching a result, so a later call can retry normally.

`GetOrCompute` example:

```go
Expand Down
Loading