Skip to content

Commit e6578e2

Browse files
authored
feature: Auto-cleanup derived refreshable subscriptions via runtime.AddCleanup (#435)
* feature: Auto-cleanup derived refreshable subscriptions via runtime.AddCleanup Introduce cleanupState to coordinate GC and subscriber lifecycles for derived refreshables. Upstream subscriptions are removed only when the wrapper is GC'd AND all subscribers have unsubscribed, fixing a regression where fire-and-forget subscriptions on derived refreshables silently stopped receiving updates. Add MapValues helper for map-typed refreshables with per-key lifecycle management including context cancellation on key removal. Add benchmarks demonstrating that parent.Update cost stays constant regardless of how many derived refreshables are dropped (previously leaked O(N) subscribers on master). * Replace unwrapValidated type assertion with push-based value propagation MergeValidated and CollectValidatedMutable previously used unwrapValidated to extract the inner Validated from derivedValidated wrappers before capturing them in doUpdate closures. This avoided reference cycles that would prevent runtime.AddCleanup from firing, but required a type assertion on the private derivedValidated type. The new approach eliminates the type assertion entirely: subscriber callbacks push extracted values (plain T and error) into shared mutable state protected by a sync.Mutex, and doUpdate reads from that shared state instead of from captured Validated[T] interface values. Since doUpdate never holds a reference to any Validated (wrapper or inner), no reference cycle can form. Also replaces atomic.Value with atomic.Pointer[T] in defaultRefreshable, removing the remaining type assertions in the package. * go fix * Added a gcCollect() helper with a doc comment explaining why two GC cycles are needed (stale register/stack references can prevent collection on the first pass). * comments
1 parent 67f2b69 commit e6578e2

13 files changed

Lines changed: 1951 additions & 144 deletions

refreshable/benchmark_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright (c) 2026 Palantir Technologies. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package refreshable_test
6+
7+
import (
8+
"fmt"
9+
"sync/atomic"
10+
"testing"
11+
"time"
12+
13+
"github.com/palantir/pkg/refreshable/v2"
14+
)
15+
16+
// BenchmarkParentUpdateAfterDerivedDrop measures the cost of parent.Update()
17+
// after creating and dropping N derived refreshables without calling stop().
18+
// Without runtime.AddCleanup, each dropped derived leaks a subscriber, so
19+
// Update iterates all N callbacks. With cleanup, GC removes them.
20+
func BenchmarkParentUpdateAfterDerivedDrop(b *testing.B) {
21+
for _, dropped := range []int{0, 100, 1000, 10000} {
22+
b.Run(fmt.Sprintf("dropped=%d", dropped), func(b *testing.B) {
23+
parent := refreshable.New(0)
24+
for i := range dropped {
25+
derived := refreshable.MapAuto(parent, func(v int) int { return v + i })
26+
_ = derived.Current()
27+
}
28+
gcCollect()
29+
time.Sleep(100 * time.Millisecond)
30+
b.ResetTimer()
31+
for i := range b.N {
32+
parent.Update(i + 1)
33+
}
34+
})
35+
}
36+
}
37+
38+
// BenchmarkParentSubscriberCountAfterDrop verifies that the parent's subscriber
39+
// list does not grow when derived refreshables are dropped and GC'd.
40+
// This is a correctness-flavored benchmark: without runtime.AddCleanup,
41+
// subscriberCount == dropped; with cleanup, subscriberCount == 0 after GC.
42+
func BenchmarkParentSubscriberCountAfterDrop(b *testing.B) {
43+
for _, dropped := range []int{100, 1000} {
44+
b.Run(fmt.Sprintf("dropped=%d", dropped), func(b *testing.B) {
45+
for range b.N {
46+
parent := refreshable.New(0)
47+
var callCount atomic.Int64
48+
parent.Subscribe(func(int) { callCount.Add(1) })
49+
callCount.Store(0) // reset after initial Subscribe callback
50+
51+
for i := range dropped {
52+
derived := refreshable.MapAuto(parent, func(v int) int { return v + i })
53+
_ = derived.Current()
54+
}
55+
gcCollect()
56+
time.Sleep(100 * time.Millisecond)
57+
58+
callCount.Store(0)
59+
parent.Update(42)
60+
if got := callCount.Load(); got != 1 {
61+
b.Fatalf("expected 1 subscriber callback, got %d (leaked %d)", got, got-1)
62+
}
63+
}
64+
})
65+
}
66+
}

refreshable/derived.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright (c) 2026 Palantir Technologies. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package refreshable
6+
7+
import (
8+
"runtime"
9+
"sync"
10+
"sync/atomic"
11+
)
12+
13+
// derivedRefreshable wraps a Refreshable and ties upstream subscription lifetime
14+
// to its own GC reachability and active subscriber count via cleanupState.
15+
//
16+
// Must be a separate allocation from the inner Refreshable: the inner is
17+
// typically captured by upstream subscription callbacks, so combining them
18+
// would create a reference cycle that prevents runtime.AddCleanup from firing.
19+
//
20+
// Upstream derived wrappers do NOT need to be kept alive by this wrapper.
21+
// When an upstream wrapper is collected, its cleanupState.gcDone is set but
22+
// cleanup is deferred until subCount reaches zero. The subscription callbacks
23+
// reference the upstream inner (not the wrapper), so updates continue to flow.
24+
// When the downstream eventually unsubscribes, the deferred cleanup fires and
25+
// correctly propagates the unsubscribe to the grandparent.
26+
type derivedRefreshable[T any] struct {
27+
inner Refreshable[T]
28+
state *cleanupState
29+
}
30+
31+
func newDerivedRefreshable[T any](inner Refreshable[T], unsubs ...UnsubscribeFunc) *derivedRefreshable[T] {
32+
state := &cleanupState{
33+
unsubs: unsubs,
34+
}
35+
d := &derivedRefreshable[T]{
36+
inner: inner,
37+
state: state,
38+
}
39+
runtime.AddCleanup(d, (*cleanupState).markGCd, state)
40+
return d
41+
}
42+
43+
func (d *derivedRefreshable[T]) Current() T {
44+
return d.inner.Current()
45+
}
46+
47+
func (d *derivedRefreshable[T]) Subscribe(consumer func(T)) UnsubscribeFunc {
48+
d.state.addSubscriber()
49+
innerUnsub := d.inner.Subscribe(consumer)
50+
state := d.state // capture state not d, so d remains GC-eligible
51+
return sync.OnceFunc(func() {
52+
innerUnsub()
53+
state.removeSubscriber()
54+
})
55+
}
56+
57+
// derivedValidated wraps a Validated with the same GC cleanup semantics as
58+
// derivedRefreshable. See derivedRefreshable for design rationale.
59+
type derivedValidated[T any] struct {
60+
inner Validated[T]
61+
state *cleanupState
62+
}
63+
64+
func newDerivedValidated[T any](inner Validated[T], unsubs ...UnsubscribeFunc) *derivedValidated[T] {
65+
state := &cleanupState{
66+
unsubs: unsubs,
67+
}
68+
d := &derivedValidated[T]{
69+
inner: inner,
70+
state: state,
71+
}
72+
runtime.AddCleanup(d, (*cleanupState).markGCd, state)
73+
return d
74+
}
75+
76+
func (d *derivedValidated[T]) Unvalidated() T {
77+
return d.inner.Unvalidated()
78+
}
79+
80+
func (d *derivedValidated[T]) Validation() (T, error) {
81+
return d.inner.Validation()
82+
}
83+
84+
func (d *derivedValidated[T]) SubscribeValidated(consumer func(Validated[T])) UnsubscribeFunc {
85+
d.state.addSubscriber()
86+
innerUnsub := d.inner.SubscribeValidated(consumer)
87+
state := d.state // capture state not d, so d remains GC-eligible
88+
return sync.OnceFunc(func() {
89+
innerUnsub()
90+
state.removeSubscriber()
91+
})
92+
}
93+
94+
// cleanupState coordinates between GC and active subscribers. Upstream
95+
// unsubscribe functions fire only when the derived wrapper is GC'd AND
96+
// the subscriber count reaches zero, allowing subscriptions on a derived
97+
// refreshable to outlive the derived reference itself.
98+
type cleanupState struct {
99+
subCount atomic.Int32 // incremented by Subscribe, decremented by its returned unsub
100+
gcDone atomic.Bool // set by runtime.AddCleanup callback
101+
unsubs []UnsubscribeFunc // upstream unsubscribe functions
102+
once sync.Once // ensures cleanup runs exactly once
103+
}
104+
105+
func (s *cleanupState) addSubscriber() {
106+
s.subCount.Add(1)
107+
}
108+
109+
func (s *cleanupState) removeSubscriber() {
110+
s.subCount.Add(-1)
111+
s.tryCleanup()
112+
}
113+
114+
func (s *cleanupState) markGCd() {
115+
s.gcDone.Store(true)
116+
s.tryCleanup()
117+
}
118+
119+
func (s *cleanupState) tryCleanup() {
120+
if s.gcDone.Load() && s.subCount.Load() <= 0 {
121+
s.once.Do(func() {
122+
for _, unsub := range s.unsubs {
123+
unsub()
124+
}
125+
// Release references captured by unsub closures so that objects
126+
// reachable only through these closures (e.g. transport chains,
127+
// upstream inners) become unreachable immediately rather than
128+
// waiting for this cleanupState to be garbage collected.
129+
s.unsubs = nil
130+
})
131+
}
132+
}

0 commit comments

Comments
 (0)