Skip to content

Commit 4b63ff4

Browse files
MagicalTuxclaude
andcommitted
Add introspection methods, stack traces, and RWMutex optimization
- Add Len() and Has() methods for cache introspection - Add Cleanup() method to remove expired entries - Include stack trace in PanicError for better debugging - Use RWMutex with RLock fast path in DoUntil for better read performance 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c156e0b commit 4b63ff4

File tree

2 files changed

+209
-4
lines changed

2 files changed

+209
-4
lines changed

unison.go

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ package unison
22

33
import (
44
"fmt"
5+
"runtime/debug"
56
"sync"
67
"time"
78
)
89

9-
// PanicError wraps a panic value as an error.
10+
// PanicError wraps a panic value as an error, including the stack trace.
1011
type PanicError struct {
1112
Value any
13+
Stack []byte
1214
}
1315

1416
func (e *PanicError) Error() string {
@@ -27,10 +29,47 @@ type call[T any] struct {
2729
// Group represents a class of work and forms a namespace in which
2830
// units of work can be executed with duplicate suppression.
2931
type Group[K comparable, T any] struct {
30-
mu sync.Mutex
32+
mu sync.RWMutex
3133
m map[K]*call[T]
3234
}
3335

36+
// Len returns the number of entries currently in the group.
37+
// This includes both in-flight calls and cached results (which may be expired).
38+
func (g *Group[K, T]) Len() int {
39+
g.mu.RLock()
40+
defer g.mu.RUnlock()
41+
return len(g.m)
42+
}
43+
44+
// Has reports whether a key exists in the group with a valid (non-expired) entry.
45+
// Returns true if the key has an in-flight call or a cached result that hasn't expired.
46+
func (g *Group[K, T]) Has(key K) bool {
47+
g.mu.RLock()
48+
defer g.mu.RUnlock()
49+
c, ok := g.m[key]
50+
if !ok {
51+
return false
52+
}
53+
// If it's done and expired, it's not valid
54+
if c.done && time.Now().After(c.exp) {
55+
return false
56+
}
57+
return true
58+
}
59+
60+
// Cleanup removes all expired entries from the group.
61+
// This is useful for reclaiming memory when using DoUntil with many unique keys.
62+
func (g *Group[K, T]) Cleanup() {
63+
g.mu.Lock()
64+
defer g.mu.Unlock()
65+
now := time.Now()
66+
for key, c := range g.m {
67+
if c.done && now.After(c.exp) {
68+
delete(g.m, key)
69+
}
70+
}
71+
}
72+
3473
// Forget removes a key from the group, causing future calls to execute
3574
// the function again even if a previous call is still in-flight.
3675
// Goroutines already waiting for the result will still receive it.
@@ -63,7 +102,7 @@ func (g *Group[K, T]) Do(key K, fn func() (T, error)) (val T, err error) {
63102

64103
defer func() {
65104
if r := recover(); r != nil {
66-
c.err = &PanicError{Value: r}
105+
c.err = &PanicError{Value: r, Stack: debug.Stack()}
67106
}
68107
g.mu.Lock()
69108
delete(g.m, key)
@@ -81,11 +120,32 @@ func (g *Group[K, T]) Do(key K, fn func() (T, error)) (val T, err error) {
81120
// Subsequent calls within the cache window return the cached result without
82121
// executing the function again.
83122
func (g *Group[K, T]) DoUntil(key K, dur time.Duration, fn func() (T, error)) (val T, err error) {
123+
// Fast path: check for cached result with read lock
124+
g.mu.RLock()
125+
if g.m != nil {
126+
if c, ok := g.m[key]; ok {
127+
if c.done && !time.Now().After(c.exp) {
128+
// cached and still valid
129+
g.mu.RUnlock()
130+
return c.val, c.err
131+
}
132+
if !c.done {
133+
// in-flight, wait for it
134+
g.mu.RUnlock()
135+
c.wg.Wait()
136+
return c.val, c.err
137+
}
138+
}
139+
}
140+
g.mu.RUnlock()
141+
142+
// Slow path: need write lock to create or replace entry
84143
g.mu.Lock()
85144
if g.m == nil {
86145
g.m = make(map[K]*call[T])
87146
}
88147

148+
// Double-check after acquiring write lock
89149
if c, ok := g.m[key]; ok {
90150
if c.done && time.Now().After(c.exp) {
91151
// cached result has expired, remove it and continue to create new call
@@ -109,7 +169,7 @@ func (g *Group[K, T]) DoUntil(key K, dur time.Duration, fn func() (T, error)) (v
109169

110170
defer func() {
111171
if r := recover(); r != nil {
112-
c.err = &PanicError{Value: r}
172+
c.err = &PanicError{Value: r, Stack: debug.Stack()}
113173
}
114174
g.mu.Lock()
115175
c.exp = time.Now().Add(dur)

unison_test.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,3 +389,148 @@ func TestDoUntilPanicCached(t *testing.T) {
389389
t.Errorf("number of calls = %d; want 1 (panic should be cached)", got)
390390
}
391391
}
392+
393+
func TestPanicErrorStack(t *testing.T) {
394+
var g Group[string, string]
395+
_, err := g.Do("key", func() (string, error) {
396+
panic("test panic")
397+
})
398+
if err == nil {
399+
t.Fatal("expected error from panic")
400+
}
401+
panicErr, ok := err.(*PanicError)
402+
if !ok {
403+
t.Fatalf("expected *PanicError, got %T", err)
404+
}
405+
if len(panicErr.Stack) == 0 {
406+
t.Error("expected stack trace in PanicError")
407+
}
408+
// Verify stack trace contains relevant info
409+
stackStr := string(panicErr.Stack)
410+
if len(stackStr) < 100 {
411+
t.Error("stack trace seems too short")
412+
}
413+
}
414+
415+
func TestDoUntilPanicErrorStack(t *testing.T) {
416+
var g Group[string, string]
417+
_, err := g.DoUntil("key", time.Second, func() (string, error) {
418+
panic("test panic")
419+
})
420+
if err == nil {
421+
t.Fatal("expected error from panic")
422+
}
423+
panicErr, ok := err.(*PanicError)
424+
if !ok {
425+
t.Fatalf("expected *PanicError, got %T", err)
426+
}
427+
if len(panicErr.Stack) == 0 {
428+
t.Error("expected stack trace in PanicError")
429+
}
430+
}
431+
432+
func TestLen(t *testing.T) {
433+
var g Group[string, int]
434+
435+
if g.Len() != 0 {
436+
t.Errorf("Len = %d; want 0", g.Len())
437+
}
438+
439+
g.DoUntil("key1", time.Minute, func() (int, error) { return 1, nil })
440+
g.DoUntil("key2", time.Minute, func() (int, error) { return 2, nil })
441+
442+
if g.Len() != 2 {
443+
t.Errorf("Len = %d; want 2", g.Len())
444+
}
445+
}
446+
447+
func TestHas(t *testing.T) {
448+
var g Group[string, int]
449+
450+
// Empty group
451+
if g.Has("key") {
452+
t.Error("Has returned true for non-existent key")
453+
}
454+
455+
// Add a cached entry
456+
g.DoUntil("key", 100*time.Millisecond, func() (int, error) {
457+
return 42, nil
458+
})
459+
460+
if !g.Has("key") {
461+
t.Error("Has returned false for existing key")
462+
}
463+
464+
// Wait for expiration
465+
time.Sleep(110 * time.Millisecond)
466+
467+
if g.Has("key") {
468+
t.Error("Has returned true for expired key")
469+
}
470+
}
471+
472+
func TestHasInFlight(t *testing.T) {
473+
var g Group[string, int]
474+
started := make(chan struct{})
475+
proceed := make(chan struct{})
476+
477+
go func() {
478+
g.Do("key", func() (int, error) {
479+
close(started)
480+
<-proceed
481+
return 42, nil
482+
})
483+
}()
484+
485+
<-started // Wait for goroutine to start
486+
487+
if !g.Has("key") {
488+
t.Error("Has returned false for in-flight key")
489+
}
490+
491+
close(proceed) // Let the goroutine finish
492+
}
493+
494+
func TestCleanup(t *testing.T) {
495+
var g Group[string, int]
496+
var calls atomic.Int32
497+
498+
fn := func() (int, error) {
499+
return int(calls.Add(1)), nil
500+
}
501+
502+
// Create some cached entries
503+
g.DoUntil("key1", 50*time.Millisecond, fn)
504+
g.DoUntil("key2", 50*time.Millisecond, fn)
505+
g.DoUntil("key3", 200*time.Millisecond, fn)
506+
507+
if g.Len() != 3 {
508+
t.Errorf("Len = %d; want 3", g.Len())
509+
}
510+
511+
// Wait for some to expire
512+
time.Sleep(60 * time.Millisecond)
513+
514+
// Cleanup should remove expired entries
515+
g.Cleanup()
516+
517+
if g.Len() != 1 {
518+
t.Errorf("Len after Cleanup = %d; want 1", g.Len())
519+
}
520+
521+
// key3 should still be there
522+
if !g.Has("key3") {
523+
t.Error("key3 should still exist after cleanup")
524+
}
525+
}
526+
527+
func TestCleanupEmpty(t *testing.T) {
528+
var g Group[string, int]
529+
530+
// Should not panic on empty group
531+
g.Cleanup()
532+
533+
if g.Len() != 0 {
534+
t.Errorf("Len = %d; want 0", g.Len())
535+
}
536+
}

0 commit comments

Comments
 (0)