detached e8cd8f6

feat(broker): add idle lifecycle supervisor

commit
e8cd8f6034413cb47a0e2661767303d32cf56fa5
author
brice · · 2026-09-16 12:40 UTC
parent
75fae3a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +948 -0
A internal/usecase/broker/lifecycle.go +408 -0
@@ -0,0 +1,408 @@
1+package broker
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+// Lifecycle supervisor (Plan 001 P2.4, not activated).
14+//
15+// The supervisor is the single owner of the broker's idle lifetime. Exactly two
16+// kinds of work can pin the broker open:
17+//
18+// - an admitted client lease: one accepted broker client connection, and
19+// - an in-flight user operation lease: one operation a user asked the broker
20+// to perform while it is admitted (for example opening a stream or editing
21+// the configured-host set).
22+//
23+// Nothing else pins. Work the broker schedules for itself -- observation
24+// retries, durable snapshot writes, opening the store, or keeping physical
25+// transports warm in the idle pool -- is deliberately not represented here and
26+// can never extend the broker's life: a broker with no admitted client and no
27+// in-flight user operation shuts down on schedule even while those components
28+// run. Registered background components therefore receive the root context and
29+// an ordered drain hook, but never a lease.
30+//
31+// Idle shutdown is committed atomically. The idle timer is active exactly when
32+// both lease counts are zero. A lease admitted before the commit stops the timer
33+// and keeps the broker open; once the commit wins, every later admission is
34+// refused with the typed, non-terminal ports.BrokerAdmissionClosed so the caller
35+// reconnects to a fresh broker instead of treating the exit as fatal.
36+//
37+// Arming installs a fresh timer and claims a new arm number, and admission
38+// disarms it under the same mutex. The run goroutine therefore commits only for
39+// the arm it is still observing: a fire from a superseded arm can never shut the
40+// broker down out of turn, so an admission that wins the race always leaves the
41+// broker open for a full grace.
42+//
43+// Shutdown itself runs in a fixed order: the supervisor marks itself closed and
44+// stops/drains the idle timer under its mutex, cancels the root context, then
45+// drains every registered hook outside the mutex in reverse registration order
46+// (so a component registered later, which may depend on an earlier one, is
47+// drained first). The supervisor joins every hook before Done closes. Close runs
48+// the same sequence and is idempotent and concurrent-safe.
49+
50+// DefaultIdleGrace is the idle grace granted before a broker with no admitted
51+// client lease and no in-flight user operation lease shuts itself down.
52+const DefaultIdleGrace = 5 * time.Minute
53+
54+// SupervisorConfig configures a Supervisor. The zero value is valid and selects
55+// DefaultIdleGrace.
56+type SupervisorConfig struct {
57+ // IdleGrace overrides DefaultIdleGrace when positive.
58+ IdleGrace time.Duration
59+}
60+
61+// ShutdownHook drains one registered broker-owned component during shutdown.
62+// It runs after the root context is cancelled and must return once the
63+// component has stopped; the supervisor joins every hook before Done closes.
64+// Hooks run outside the supervisor mutex, in reverse registration order. A hook
65+// must not call Close, which would wait for the very shutdown running it.
66+type ShutdownHook func(ctx context.Context) error
67+
68+// Runner is the minimal seam for a broker-owned background component that runs
69+// until its context is cancelled (for example the observation Registry). Run
70+// must return once ctx is cancelled so the supervisor can join it.
71+type Runner interface {
72+ Run(ctx context.Context)
73+}
74+
75+// Closeable is the minimal seam for a broker-owned resource closed at shutdown
76+// (for example the physical-transport Pool). The supervisor never inspects pool
77+// or registry internals; it only asks them to drain.
78+type Closeable interface {
79+ Close() error
80+}
81+
82+// Lease is an idempotent admission handle. Release may be called any number of
83+// times, from any goroutine, and after the supervisor has shut down; only the
84+// first call releases the lease.
85+type Lease struct {
86+ once sync.Once
87+ release func()
88+}
89+
90+// Release returns the lease exactly once. A nil Lease is a no-op.
91+func (l *Lease) Release() {
92+ if l == nil || l.release == nil {
93+ return
94+ }
95+ l.once.Do(l.release)
96+}
97+
98+type leaseKind uint8
99+
100+const (
101+ leaseClient leaseKind = iota + 1
102+ leaseOperation
103+)
104+
105+type registeredHook struct {
106+ name string
107+ hook ShutdownHook
108+}
109+
110+// Supervisor owns the broker idle lifetime, the root context handed to
111+// background components, and the ordered shutdown drain.
112+type Supervisor struct {
113+ clock ports.Clock
114+ idle time.Duration
115+
116+ root context.Context
117+ cancel context.CancelFunc
118+
119+ // ready is closed once the idle timer is armed and the run goroutine is
120+ // accepting lifecycle signals. done is closed only after the root context is
121+ // cancelled and every hook has drained. stopped is closed as the run
122+ // goroutine exits. closing wakes the run goroutine on the committed shutdown,
123+ // and wake nudges it whenever a lease changes the timer state.
124+ ready chan struct{}
125+ done chan struct{}
126+ stopped chan struct{}
127+ closing chan struct{}
128+ wake chan struct{}
129+
130+ mu sync.Mutex
131+ closed bool
132+ committed bool
133+ clients int
134+ operations int
135+ timer ports.Timer
136+ timerArmed bool
137+ arm uint64
138+ hooks []registeredHook
139+ shutdownErr error
140+}
141+
142+// NewSupervisor starts the idle lifetime. The idle timer is armed immediately:
143+// a broker that serves nothing shuts down one idle grace after construction.
144+func NewSupervisor(clock ports.Clock, cfg SupervisorConfig) (*Supervisor, error) {
145+ if nilDependency(clock) {
146+ return nil, errors.New("broker: supervisor requires a clock")
147+ }
148+ grace := cfg.IdleGrace
149+ if grace <= 0 {
150+ grace = DefaultIdleGrace
151+ }
152+ root, cancel := context.WithCancel(context.Background())
153+ s := &Supervisor{
154+ clock: clock, idle: grace, root: root, cancel: cancel,
155+ ready: make(chan struct{}), done: make(chan struct{}),
156+ stopped: make(chan struct{}), closing: make(chan struct{}),
157+ wake: make(chan struct{}, 1),
158+ }
159+ go s.run()
160+ <-s.ready
161+ return s, nil
162+}
163+
164+// RootContext returns the supervisor-owned root context. Background components
165+// must derive their work from it and must stop when it is cancelled.
166+func (s *Supervisor) RootContext() context.Context { return s.root }
167+
168+// Done is closed once the root context is cancelled and every registered hook
169+// has been drained and joined.
170+func (s *Supervisor) Done() <-chan struct{} { return s.done }
171+
172+// Register admits one background component with an ordered drain hook. The
173+// component must derive its work from RootContext and must stop when that
174+// context is cancelled; hook must return once it has. Registration never grants
175+// a lease, so a registered component cannot pin the broker open. Register is
176+// refused with ports.BrokerAdmissionClosed after shutdown commits and with
177+// ports.BrokerAdmissionInvalid for an empty name or nil hook.
178+func (s *Supervisor) Register(name string, hook ShutdownHook) error {
179+ if name == "" || hook == nil {
180+ return ports.BrokerAdmissionInvalid
181+ }
182+ s.mu.Lock()
183+ defer s.mu.Unlock()
184+ if s.closed {
185+ return ports.BrokerAdmissionClosed
186+ }
187+ s.hooks = append(s.hooks, registeredHook{name: name, hook: hook})
188+ return nil
189+}
190+
191+// RegisterRunner starts a broker-owned background component under the root
192+// context and joins it at shutdown: its drain hook waits for Run to return. The
193+// component must therefore honor cancellation promptly. Registration is refused
194+// exactly as Register is.
195+func (s *Supervisor) RegisterRunner(name string, runner Runner) error {
196+ if nilDependency(runner) {
197+ return ports.BrokerAdmissionInvalid
198+ }
199+ drained := make(chan struct{})
200+ if err := s.Register(name, func(context.Context) error {
201+ <-drained
202+ return nil
203+ }); err != nil {
204+ return err
205+ }
206+ ctx := s.root
207+ go func() {
208+ defer close(drained)
209+ runner.Run(ctx)
210+ }()
211+ return nil
212+}
213+
214+// RegisterCloseable registers a broker-owned resource to be closed after the
215+// root context is cancelled. Its Close error is reported by Close and never
216+// aborts the remaining drains. Registration is refused exactly as Register is.
217+func (s *Supervisor) RegisterCloseable(name string, closeable Closeable) error {
218+ if nilDependency(closeable) {
219+ return ports.BrokerAdmissionInvalid
220+ }
221+ return s.Register(name, func(context.Context) error { return closeable.Close() })
222+}
223+
224+// AdmitClient admits one client-connection lease. It returns
225+// ports.BrokerAdmissionClosed once shutdown has committed.
226+func (s *Supervisor) AdmitClient() (*Lease, error) { return s.admit(leaseClient) }
227+
228+// AdmitOperation admits one in-flight user-operation lease. It returns
229+// ports.BrokerAdmissionClosed once shutdown has committed.
230+func (s *Supervisor) AdmitOperation() (*Lease, error) { return s.admit(leaseOperation) }
231+
232+// Close commits shutdown and waits for the root context to be cancelled and
233+// every hook to drain. It is idempotent and concurrent-safe: every caller
234+// observes the same terminal state and the same joined hook error.
235+func (s *Supervisor) Close() error {
236+ s.mu.Lock()
237+ s.commitLocked()
238+ s.mu.Unlock()
239+ <-s.stopped
240+ s.mu.Lock()
241+ defer s.mu.Unlock()
242+ return s.shutdownErr
243+}
244+
245+func (s *Supervisor) admit(kind leaseKind) (*Lease, error) {
246+ s.mu.Lock()
247+ if s.closed {
248+ s.mu.Unlock()
249+ return nil, ports.BrokerAdmissionClosed
250+ }
251+ switch kind {
252+ case leaseClient:
253+ s.clients++
254+ case leaseOperation:
255+ s.operations++
256+ }
257+ // An admission that wins the commit race disarms the pending idle shutdown.
258+ s.disarmTimerLocked()
259+ s.mu.Unlock()
260+ s.signal()
261+ return &Lease{release: func() { s.release(kind) }}, nil
262+}
263+
264+func (s *Supervisor) release(kind leaseKind) {
265+ s.mu.Lock()
266+ switch kind {
267+ case leaseClient:
268+ if s.clients > 0 {
269+ s.clients--
270+ }
271+ case leaseOperation:
272+ if s.operations > 0 {
273+ s.operations--
274+ }
275+ }
276+ if !s.closed && s.clients == 0 && s.operations == 0 {
277+ s.armTimerLocked()
278+ }
279+ s.mu.Unlock()
280+ s.signal()
281+}
282+
283+// signal nudges the run goroutine to re-read the timer state. Signals coalesce.
284+func (s *Supervisor) signal() {
285+ select {
286+ case s.wake <- struct{}{}:
287+ default:
288+ }
289+}
290+
291+// run owns the idle timer and is the sole shutdown executor. It waits on the
292+// current arm's timer and finalizes exactly once, either when an un-superseded
293+// arm fires while the broker is still idle or when Close commits the shutdown.
294+func (s *Supervisor) run() {
295+ defer close(s.stopped)
296+
297+ s.mu.Lock()
298+ s.armTimerLocked()
299+ s.mu.Unlock()
300+ close(s.ready)
301+
302+ for {
303+ s.mu.Lock()
304+ timer, arm := s.timer, s.arm
305+ s.mu.Unlock()
306+
307+ var fire <-chan time.Time
308+ if timer != nil {
309+ fire = timer.C()
310+ }
311+ select {
312+ case <-s.closing:
313+ s.finalize()
314+ return
315+ case <-s.wake:
316+ continue
317+ case <-fire:
318+ if s.tryCommitIdleExpiry(arm) {
319+ s.finalize()
320+ return
321+ }
322+ }
323+ }
324+}
325+
326+// tryCommitIdleExpiry reports whether a fire from arm must commit the idle
327+// shutdown. It returns true only for the run goroutine that must finalize: a
328+// superseded or already disarmed arm, a committed supervisor, or a still-held
329+// lease all make the fire a no-op.
330+func (s *Supervisor) tryCommitIdleExpiry(arm uint64) bool {
331+ s.mu.Lock()
332+ defer s.mu.Unlock()
333+ if s.closed || !s.timerArmed || s.arm != arm {
334+ return false
335+ }
336+ if s.clients > 0 || s.operations > 0 {
337+ return false
338+ }
339+ return s.commitLocked()
340+}
341+
342+// commitLocked marks the supervisor closed and disarms the idle timer. It
343+// returns true exactly once, for the caller that committed. Because admission
344+// and commitment share the mutex, an admission either disarms the pending expiry
345+// before the commit or observes the closed state after it.
346+func (s *Supervisor) commitLocked() bool {
347+ if s.committed {
348+ return false
349+ }
350+ s.committed = true
351+ s.closed = true
352+ s.disarmTimerLocked()
353+ close(s.closing)
354+ return true
355+}
356+
357+// finalize cancels the root context, drains every hook outside the mutex in
358+// reverse registration order, and only then closes Done. It runs exactly once,
359+// on the run goroutine.
360+func (s *Supervisor) finalize() {
361+ s.cancel()
362+ s.mu.Lock()
363+ hooks := append([]registeredHook(nil), s.hooks...)
364+ s.mu.Unlock()
365+
366+ var errs []error
367+ for i := len(hooks) - 1; i >= 0; i-- {
368+ if err := hooks[i].hook(s.root); err != nil {
369+ errs = append(errs, fmt.Errorf("broker: %s shutdown: %w", hooks[i].name, err))
370+ }
371+ }
372+
373+ s.mu.Lock()
374+ s.shutdownErr = errors.Join(errs...)
375+ s.mu.Unlock()
376+ close(s.done)
377+}
378+
379+// armTimerLocked installs a fresh idle timer and claims a new arm number. A
380+// fresh timer per idle cycle lets the run goroutine attribute a fire to the arm
381+// that produced it, so a fire from a superseded arm is ignored instead of
382+// shutting the broker down out of turn. Callers hold mu and have established
383+// that both lease counts are zero.
384+func (s *Supervisor) armTimerLocked() {
385+ if s.timerArmed || s.committed {
386+ return
387+ }
388+ s.timer = s.clock.NewTimer(s.idle)
389+ s.timerArmed = true
390+ s.arm++
391+}
392+
393+// disarmTimerLocked stops and drains the idle timer, superseding its arm,
394+// supporting buffered timer adapters with pre-Go-1.23 reset semantics. Callers
395+// hold mu.
396+func (s *Supervisor) disarmTimerLocked() {
397+ s.timerArmed = false
398+ if s.timer == nil {
399+ return
400+ }
401+ if !s.timer.Stop() {
402+ select {
403+ case <-s.timer.C():
404+ default:
405+ }
406+ }
407+ s.timer = nil
408+}
@@ -0,0 +1,408 @@
1+package broker
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+// Lifecycle supervisor (Plan 001 P2.4, not activated).
14+//
15+// The supervisor is the single owner of the broker's idle lifetime. Exactly two
16+// kinds of work can pin the broker open:
17+//
18+// - an admitted client lease: one accepted broker client connection, and
19+// - an in-flight user operation lease: one operation a user asked the broker
20+// to perform while it is admitted (for example opening a stream or editing
21+// the configured-host set).
22+//
23+// Nothing else pins. Work the broker schedules for itself -- observation
24+// retries, durable snapshot writes, opening the store, or keeping physical
25+// transports warm in the idle pool -- is deliberately not represented here and
26+// can never extend the broker's life: a broker with no admitted client and no
27+// in-flight user operation shuts down on schedule even while those components
28+// run. Registered background components therefore receive the root context and
29+// an ordered drain hook, but never a lease.
30+//
31+// Idle shutdown is committed atomically. The idle timer is active exactly when
32+// both lease counts are zero. A lease admitted before the commit stops the timer
33+// and keeps the broker open; once the commit wins, every later admission is
34+// refused with the typed, non-terminal ports.BrokerAdmissionClosed so the caller
35+// reconnects to a fresh broker instead of treating the exit as fatal.
36+//
37+// Arming installs a fresh timer and claims a new arm number, and admission
38+// disarms it under the same mutex. The run goroutine therefore commits only for
39+// the arm it is still observing: a fire from a superseded arm can never shut the
40+// broker down out of turn, so an admission that wins the race always leaves the
41+// broker open for a full grace.
42+//
43+// Shutdown itself runs in a fixed order: the supervisor marks itself closed and
44+// stops/drains the idle timer under its mutex, cancels the root context, then
45+// drains every registered hook outside the mutex in reverse registration order
46+// (so a component registered later, which may depend on an earlier one, is
47+// drained first). The supervisor joins every hook before Done closes. Close runs
48+// the same sequence and is idempotent and concurrent-safe.
49+
50+// DefaultIdleGrace is the idle grace granted before a broker with no admitted
51+// client lease and no in-flight user operation lease shuts itself down.
52+const DefaultIdleGrace = 5 * time.Minute
53+
54+// SupervisorConfig configures a Supervisor. The zero value is valid and selects
55+// DefaultIdleGrace.
56+type SupervisorConfig struct {
57+ // IdleGrace overrides DefaultIdleGrace when positive.
58+ IdleGrace time.Duration
59+}
60+
61+// ShutdownHook drains one registered broker-owned component during shutdown.
62+// It runs after the root context is cancelled and must return once the
63+// component has stopped; the supervisor joins every hook before Done closes.
64+// Hooks run outside the supervisor mutex, in reverse registration order. A hook
65+// must not call Close, which would wait for the very shutdown running it.
66+type ShutdownHook func(ctx context.Context) error
67+
68+// Runner is the minimal seam for a broker-owned background component that runs
69+// until its context is cancelled (for example the observation Registry). Run
70+// must return once ctx is cancelled so the supervisor can join it.
71+type Runner interface {
72+ Run(ctx context.Context)
73+}
74+
75+// Closeable is the minimal seam for a broker-owned resource closed at shutdown
76+// (for example the physical-transport Pool). The supervisor never inspects pool
77+// or registry internals; it only asks them to drain.
78+type Closeable interface {
79+ Close() error
80+}
81+
82+// Lease is an idempotent admission handle. Release may be called any number of
83+// times, from any goroutine, and after the supervisor has shut down; only the
84+// first call releases the lease.
85+type Lease struct {
86+ once sync.Once
87+ release func()
88+}
89+
90+// Release returns the lease exactly once. A nil Lease is a no-op.
91+func (l *Lease) Release() {
92+ if l == nil || l.release == nil {
93+ return
94+ }
95+ l.once.Do(l.release)
96+}
97+
98+type leaseKind uint8
99+
100+const (
101+ leaseClient leaseKind = iota + 1
102+ leaseOperation
103+)
104+
105+type registeredHook struct {
106+ name string
107+ hook ShutdownHook
108+}
109+
110+// Supervisor owns the broker idle lifetime, the root context handed to
111+// background components, and the ordered shutdown drain.
112+type Supervisor struct {
113+ clock ports.Clock
114+ idle time.Duration
115+
116+ root context.Context
117+ cancel context.CancelFunc
118+
119+ // ready is closed once the idle timer is armed and the run goroutine is
120+ // accepting lifecycle signals. done is closed only after the root context is
121+ // cancelled and every hook has drained. stopped is closed as the run
122+ // goroutine exits. closing wakes the run goroutine on the committed shutdown,
123+ // and wake nudges it whenever a lease changes the timer state.
124+ ready chan struct{}
125+ done chan struct{}
126+ stopped chan struct{}
127+ closing chan struct{}
128+ wake chan struct{}
129+
130+ mu sync.Mutex
131+ closed bool
132+ committed bool
133+ clients int
134+ operations int
135+ timer ports.Timer
136+ timerArmed bool
137+ arm uint64
138+ hooks []registeredHook
139+ shutdownErr error
140+}
141+
142+// NewSupervisor starts the idle lifetime. The idle timer is armed immediately:
143+// a broker that serves nothing shuts down one idle grace after construction.
144+func NewSupervisor(clock ports.Clock, cfg SupervisorConfig) (*Supervisor, error) {
145+ if nilDependency(clock) {
146+ return nil, errors.New("broker: supervisor requires a clock")
147+ }
148+ grace := cfg.IdleGrace
149+ if grace <= 0 {
150+ grace = DefaultIdleGrace
151+ }
152+ root, cancel := context.WithCancel(context.Background())
153+ s := &Supervisor{
154+ clock: clock, idle: grace, root: root, cancel: cancel,
155+ ready: make(chan struct{}), done: make(chan struct{}),
156+ stopped: make(chan struct{}), closing: make(chan struct{}),
157+ wake: make(chan struct{}, 1),
158+ }
159+ go s.run()
160+ <-s.ready
161+ return s, nil
162+}
163+
164+// RootContext returns the supervisor-owned root context. Background components
165+// must derive their work from it and must stop when it is cancelled.
166+func (s *Supervisor) RootContext() context.Context { return s.root }
167+
168+// Done is closed once the root context is cancelled and every registered hook
169+// has been drained and joined.
170+func (s *Supervisor) Done() <-chan struct{} { return s.done }
171+
172+// Register admits one background component with an ordered drain hook. The
173+// component must derive its work from RootContext and must stop when that
174+// context is cancelled; hook must return once it has. Registration never grants
175+// a lease, so a registered component cannot pin the broker open. Register is
176+// refused with ports.BrokerAdmissionClosed after shutdown commits and with
177+// ports.BrokerAdmissionInvalid for an empty name or nil hook.
178+func (s *Supervisor) Register(name string, hook ShutdownHook) error {
179+ if name == "" || hook == nil {
180+ return ports.BrokerAdmissionInvalid
181+ }
182+ s.mu.Lock()
183+ defer s.mu.Unlock()
184+ if s.closed {
185+ return ports.BrokerAdmissionClosed
186+ }
187+ s.hooks = append(s.hooks, registeredHook{name: name, hook: hook})
188+ return nil
189+}
190+
191+// RegisterRunner starts a broker-owned background component under the root
192+// context and joins it at shutdown: its drain hook waits for Run to return. The
193+// component must therefore honor cancellation promptly. Registration is refused
194+// exactly as Register is.
195+func (s *Supervisor) RegisterRunner(name string, runner Runner) error {
196+ if nilDependency(runner) {
197+ return ports.BrokerAdmissionInvalid
198+ }
199+ drained := make(chan struct{})
200+ if err := s.Register(name, func(context.Context) error {
201+ <-drained
202+ return nil
203+ }); err != nil {
204+ return err
205+ }
206+ ctx := s.root
207+ go func() {
208+ defer close(drained)
209+ runner.Run(ctx)
210+ }()
211+ return nil
212+}
213+
214+// RegisterCloseable registers a broker-owned resource to be closed after the
215+// root context is cancelled. Its Close error is reported by Close and never
216+// aborts the remaining drains. Registration is refused exactly as Register is.
217+func (s *Supervisor) RegisterCloseable(name string, closeable Closeable) error {
218+ if nilDependency(closeable) {
219+ return ports.BrokerAdmissionInvalid
220+ }
221+ return s.Register(name, func(context.Context) error { return closeable.Close() })
222+}
223+
224+// AdmitClient admits one client-connection lease. It returns
225+// ports.BrokerAdmissionClosed once shutdown has committed.
226+func (s *Supervisor) AdmitClient() (*Lease, error) { return s.admit(leaseClient) }
227+
228+// AdmitOperation admits one in-flight user-operation lease. It returns
229+// ports.BrokerAdmissionClosed once shutdown has committed.
230+func (s *Supervisor) AdmitOperation() (*Lease, error) { return s.admit(leaseOperation) }
231+
232+// Close commits shutdown and waits for the root context to be cancelled and
233+// every hook to drain. It is idempotent and concurrent-safe: every caller
234+// observes the same terminal state and the same joined hook error.
235+func (s *Supervisor) Close() error {
236+ s.mu.Lock()
237+ s.commitLocked()
238+ s.mu.Unlock()
239+ <-s.stopped
240+ s.mu.Lock()
241+ defer s.mu.Unlock()
242+ return s.shutdownErr
243+}
244+
245+func (s *Supervisor) admit(kind leaseKind) (*Lease, error) {
246+ s.mu.Lock()
247+ if s.closed {
248+ s.mu.Unlock()
249+ return nil, ports.BrokerAdmissionClosed
250+ }
251+ switch kind {
252+ case leaseClient:
253+ s.clients++
254+ case leaseOperation:
255+ s.operations++
256+ }
257+ // An admission that wins the commit race disarms the pending idle shutdown.
258+ s.disarmTimerLocked()
259+ s.mu.Unlock()
260+ s.signal()
261+ return &Lease{release: func() { s.release(kind) }}, nil
262+}
263+
264+func (s *Supervisor) release(kind leaseKind) {
265+ s.mu.Lock()
266+ switch kind {
267+ case leaseClient:
268+ if s.clients > 0 {
269+ s.clients--
270+ }
271+ case leaseOperation:
272+ if s.operations > 0 {
273+ s.operations--
274+ }
275+ }
276+ if !s.closed && s.clients == 0 && s.operations == 0 {
277+ s.armTimerLocked()
278+ }
279+ s.mu.Unlock()
280+ s.signal()
281+}
282+
283+// signal nudges the run goroutine to re-read the timer state. Signals coalesce.
284+func (s *Supervisor) signal() {
285+ select {
286+ case s.wake <- struct{}{}:
287+ default:
288+ }
289+}
290+
291+// run owns the idle timer and is the sole shutdown executor. It waits on the
292+// current arm's timer and finalizes exactly once, either when an un-superseded
293+// arm fires while the broker is still idle or when Close commits the shutdown.
294+func (s *Supervisor) run() {
295+ defer close(s.stopped)
296+
297+ s.mu.Lock()
298+ s.armTimerLocked()
299+ s.mu.Unlock()
300+ close(s.ready)
301+
302+ for {
303+ s.mu.Lock()
304+ timer, arm := s.timer, s.arm
305+ s.mu.Unlock()
306+
307+ var fire <-chan time.Time
308+ if timer != nil {
309+ fire = timer.C()
310+ }
311+ select {
312+ case <-s.closing:
313+ s.finalize()
314+ return
315+ case <-s.wake:
316+ continue
317+ case <-fire:
318+ if s.tryCommitIdleExpiry(arm) {
319+ s.finalize()
320+ return
321+ }
322+ }
323+ }
324+}
325+
326+// tryCommitIdleExpiry reports whether a fire from arm must commit the idle
327+// shutdown. It returns true only for the run goroutine that must finalize: a
328+// superseded or already disarmed arm, a committed supervisor, or a still-held
329+// lease all make the fire a no-op.
330+func (s *Supervisor) tryCommitIdleExpiry(arm uint64) bool {
331+ s.mu.Lock()
332+ defer s.mu.Unlock()
333+ if s.closed || !s.timerArmed || s.arm != arm {
334+ return false
335+ }
336+ if s.clients > 0 || s.operations > 0 {
337+ return false
338+ }
339+ return s.commitLocked()
340+}
341+
342+// commitLocked marks the supervisor closed and disarms the idle timer. It
343+// returns true exactly once, for the caller that committed. Because admission
344+// and commitment share the mutex, an admission either disarms the pending expiry
345+// before the commit or observes the closed state after it.
346+func (s *Supervisor) commitLocked() bool {
347+ if s.committed {
348+ return false
349+ }
350+ s.committed = true
351+ s.closed = true
352+ s.disarmTimerLocked()
353+ close(s.closing)
354+ return true
355+}
356+
357+// finalize cancels the root context, drains every hook outside the mutex in
358+// reverse registration order, and only then closes Done. It runs exactly once,
359+// on the run goroutine.
360+func (s *Supervisor) finalize() {
361+ s.cancel()
362+ s.mu.Lock()
363+ hooks := append([]registeredHook(nil), s.hooks...)
364+ s.mu.Unlock()
365+
366+ var errs []error
367+ for i := len(hooks) - 1; i >= 0; i-- {
368+ if err := hooks[i].hook(s.root); err != nil {
369+ errs = append(errs, fmt.Errorf("broker: %s shutdown: %w", hooks[i].name, err))
370+ }
371+ }
372+
373+ s.mu.Lock()
374+ s.shutdownErr = errors.Join(errs...)
375+ s.mu.Unlock()
376+ close(s.done)
377+}
378+
379+// armTimerLocked installs a fresh idle timer and claims a new arm number. A
380+// fresh timer per idle cycle lets the run goroutine attribute a fire to the arm
381+// that produced it, so a fire from a superseded arm is ignored instead of
382+// shutting the broker down out of turn. Callers hold mu and have established
383+// that both lease counts are zero.
384+func (s *Supervisor) armTimerLocked() {
385+ if s.timerArmed || s.committed {
386+ return
387+ }
388+ s.timer = s.clock.NewTimer(s.idle)
389+ s.timerArmed = true
390+ s.arm++
391+}
392+
393+// disarmTimerLocked stops and drains the idle timer, superseding its arm,
394+// supporting buffered timer adapters with pre-Go-1.23 reset semantics. Callers
395+// hold mu.
396+func (s *Supervisor) disarmTimerLocked() {
397+ s.timerArmed = false
398+ if s.timer == nil {
399+ return
400+ }
401+ if !s.timer.Stop() {
402+ select {
403+ case <-s.timer.C():
404+ default:
405+ }
406+ }
407+ s.timer = nil
408+}
A internal/usecase/broker/lifecycle_test.go +540 -0
@@ -0,0 +1,540 @@
1+package broker
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// newTestSupervisor builds a supervisor over a manual clock and always drains it
15+// at test end so an unexpired run goroutine can never leak into another test.
16+func newTestSupervisor(t testing.TB, grace time.Duration) (*Supervisor, *manualClock) {
17+ t.Helper()
18+ clock := newManualClock(time.Unix(0, 0))
19+ s, err := NewSupervisor(clock, SupervisorConfig{IdleGrace: grace})
20+ require.NoError(t, err)
21+ // Ignore the error: a test may deliberately register a failing drain hook.
22+ t.Cleanup(func() { _ = s.Close() })
23+ return s, clock
24+}
25+
26+// activeTimers reports the manual clock's live (armed, not yet fired) timers.
27+func (c *manualClock) activeTimers() int {
28+ c.mu.Lock()
29+ defer c.mu.Unlock()
30+ return len(c.timers)
31+}
32+
33+func supervisorState(s *Supervisor) (clients, operations int, armed, closed bool) {
34+ s.mu.Lock()
35+ defer s.mu.Unlock()
36+ return s.clients, s.operations, s.timerArmed, s.closed
37+}
38+
39+func supervisorTimerArmed(s *Supervisor) bool {
40+ _, _, armed, _ := supervisorState(s)
41+ return armed
42+}
43+
44+func requireSupervisorOpen(t *testing.T, s *Supervisor) {
45+ t.Helper()
46+ select {
47+ case <-s.Done():
48+ t.Fatal("supervisor shut down unexpectedly")
49+ default:
50+ }
51+}
52+
53+func requireBrokerClosed(t *testing.T, err error) {
54+ t.Helper()
55+ require.Error(t, err)
56+ var admission ports.BrokerAdmissionError
57+ require.ErrorAs(t, err, &admission)
58+ require.Equal(t, ports.BrokerAdmissionClosed, admission)
59+}
60+
61+func requireBrokerInvalid(t *testing.T, err error) {
62+ t.Helper()
63+ require.Error(t, err)
64+ var admission ports.BrokerAdmissionError
65+ require.ErrorAs(t, err, &admission)
66+ require.Equal(t, ports.BrokerAdmissionInvalid, admission)
67+}
68+
69+type closeFunc func() error
70+
71+func (f closeFunc) Close() error { return f() }
72+
73+// blockingRunner stands in for a broker-owned background component such as the
74+// observation Registry: it runs until cancellation and reports what it saw.
75+type blockingRunner struct {
76+ started chan struct{}
77+ observed chan error
78+}
79+
80+func newBlockingRunner() *blockingRunner {
81+ return &blockingRunner{started: make(chan struct{}), observed: make(chan error, 1)}
82+}
83+
84+func (r *blockingRunner) Run(ctx context.Context) {
85+ close(r.started)
86+ <-ctx.Done()
87+ r.observed <- ctx.Err()
88+}
89+
90+func TestSupervisorInitialIdleExpires(t *testing.T) {
91+ s, clock := newTestSupervisor(t, time.Minute)
92+ _, _, armed, closed := supervisorState(s)
93+ require.True(t, armed, "the idle timer must be armed at construction")
94+ require.False(t, closed)
95+
96+ clock.Advance(time.Minute - time.Nanosecond)
97+ requireSupervisorOpen(t, s)
98+
99+ clock.Advance(time.Nanosecond)
100+ await(t, s.Done())
101+ require.ErrorIs(t, s.RootContext().Err(), context.Canceled)
102+ require.False(t, supervisorTimerArmed(s))
103+ require.Equal(t, 0, clock.activeTimers())
104+
105+ _, err := s.AdmitClient()
106+ requireBrokerClosed(t, err)
107+ _, err = s.AdmitOperation()
108+ requireBrokerClosed(t, err)
109+ requireBrokerClosed(t, s.Register("late", func(context.Context) error { return nil }))
110+ requireBrokerClosed(t, s.RegisterRunner("late-runner", newBlockingRunner()))
111+ requireBrokerClosed(t, s.RegisterCloseable("late-close", closeFunc(func() error { return nil })))
112+}
113+
114+func TestSupervisorAdmissionBeforeDeadlineCancelsIdleShutdown(t *testing.T) {
115+ s, clock := newTestSupervisor(t, time.Minute)
116+ clock.Advance(30 * time.Second)
117+
118+ lease, err := s.AdmitClient()
119+ require.NoError(t, err)
120+ require.False(t, supervisorTimerArmed(s))
121+
122+ // Past the original deadline the broker is still open because the admission
123+ // cancelled the pending idle shutdown.
124+ clock.Advance(time.Hour)
125+ requireSupervisorOpen(t, s)
126+
127+ lease.Release()
128+ lease.Release() // idempotent
129+ require.True(t, supervisorTimerArmed(s))
130+
131+ clock.Advance(time.Minute - time.Nanosecond)
132+ requireSupervisorOpen(t, s)
133+ clock.Advance(time.Nanosecond)
134+ await(t, s.Done())
135+}
136+
137+func TestSupervisorClientLeaseProtectsIdle(t *testing.T) {
138+ s, clock := newTestSupervisor(t, time.Minute)
139+ lease, err := s.AdmitClient()
140+ require.NoError(t, err)
141+
142+ for i := 0; i < 3; i++ {
143+ clock.Advance(time.Minute)
144+ requireSupervisorOpen(t, s)
145+ require.False(t, supervisorTimerArmed(s), "the timer must be inactive while a client lease is held")
146+ }
147+
148+ lease.Release()
149+ require.True(t, supervisorTimerArmed(s))
150+ clock.Advance(time.Minute)
151+ await(t, s.Done())
152+}
153+
154+func TestSupervisorOperationLeaseProtectsIdle(t *testing.T) {
155+ s, clock := newTestSupervisor(t, time.Minute)
156+ lease, err := s.AdmitOperation()
157+ require.NoError(t, err)
158+
159+ clock.Advance(10 * time.Minute)
160+ requireSupervisorOpen(t, s)
161+
162+ lease.Release()
163+ clock.Advance(time.Minute)
164+ await(t, s.Done())
165+}
166+
167+func TestSupervisorIdleTimerTracksBothLeaseKinds(t *testing.T) {
168+ s, clock := newTestSupervisor(t, time.Minute)
169+ require.True(t, supervisorTimerArmed(s))
170+
171+ client, err := s.AdmitClient()
172+ require.NoError(t, err)
173+ require.False(t, supervisorTimerArmed(s))
174+
175+ operation, err := s.AdmitOperation()
176+ require.NoError(t, err)
177+ require.False(t, supervisorTimerArmed(s))
178+
179+ // Releasing one kind leaves the other; the timer stays inactive until both
180+ // counts are zero.
181+ client.Release()
182+ clients, operations, armed, closed := supervisorState(s)
183+ require.Equal(t, 0, clients)
184+ require.Equal(t, 1, operations)
185+ require.False(t, armed)
186+ require.False(t, closed)
187+
188+ operation.Release()
189+ require.True(t, supervisorTimerArmed(s))
190+
191+ clock.Advance(time.Minute)
192+ await(t, s.Done())
193+}
194+
195+// TestSupervisorSupersededArmDoesNotCommit pins the guarantee behind the
196+// concurrent race test: a fire from an arm that an admission/release pair has
197+// superseded must be ignored so the broker still gets a full fresh grace.
198+func TestSupervisorSupersededArmDoesNotCommit(t *testing.T) {
199+ s, clock := newTestSupervisor(t, time.Minute)
200+
201+ s.mu.Lock()
202+ stale := s.arm
203+ s.mu.Unlock()
204+
205+ lease, err := s.AdmitClient()
206+ require.NoError(t, err)
207+ lease.Release()
208+ require.True(t, supervisorTimerArmed(s))
209+ require.False(t, s.tryCommitIdleExpiry(stale), "a fire from a superseded arm must not commit")
210+ requireSupervisorOpen(t, s)
211+
212+ clock.Advance(time.Minute - time.Nanosecond)
213+ requireSupervisorOpen(t, s)
214+ clock.Advance(time.Nanosecond)
215+ await(t, s.Done())
216+}
217+
218+// TestSupervisorCurrentArmCommits pins the positive half: a fire from the arm the
219+// run goroutine is still observing commits while the broker is idle.
220+func TestSupervisorCurrentArmCommits(t *testing.T) {
221+ s, _ := newTestSupervisor(t, time.Minute)
222+ s.mu.Lock()
223+ current := s.arm
224+ s.mu.Unlock()
225+ require.True(t, s.tryCommitIdleExpiry(current))
226+ await(t, s.Done())
227+}
228+
229+func TestSupervisorRepeatedLeaseCyclesStillExpire(t *testing.T) {
230+ s, clock := newTestSupervisor(t, time.Minute)
231+ for i := 0; i < 50; i++ {
232+ lease, err := s.AdmitClient()
233+ require.NoError(t, err)
234+ lease.Release()
235+ }
236+
237+ require.True(t, supervisorTimerArmed(s))
238+ require.Equal(t, 1, clock.activeTimers(), "idle cycles must not accumulate timers")
239+ clock.Advance(time.Minute)
240+ await(t, s.Done())
241+ require.Equal(t, 0, clock.activeTimers())
242+}
243+
244+// TestSupervisorConcurrentExpiryAndAdmission races the idle expiry against a
245+// burst of admissions. Because commitment and admission share the mutex, the
246+// outcome is all-or-nothing: either the expiry committed first and every
247+// admission is rejected with the typed broker-closed error, or an admission won
248+// first and every admission is accepted and the broker stays open while a lease
249+// is held.
250+func TestSupervisorConcurrentExpiryAndAdmission(t *testing.T) {
251+ const (
252+ admitters = 8
253+ rounds = 50
254+ )
255+ for round := 0; round < rounds; round++ {
256+ s, clock := newTestSupervisor(t, time.Minute)
257+
258+ var mu sync.Mutex
259+ var accepted []*Lease
260+ var rejected []error
261+ start := make(chan struct{})
262+ var wg sync.WaitGroup
263+ for i := 0; i < admitters; i++ {
264+ wg.Add(1)
265+ go func() {
266+ defer wg.Done()
267+ <-start
268+ lease, err := s.AdmitClient()
269+ mu.Lock()
270+ if err != nil {
271+ rejected = append(rejected, err)
272+ } else {
273+ accepted = append(accepted, lease)
274+ }
275+ mu.Unlock()
276+ }()
277+ }
278+
279+ clock.Advance(time.Minute) // fire the idle timer concurrently with admissions
280+ close(start)
281+ wg.Wait()
282+
283+ if len(accepted) > 0 {
284+ require.Empty(t, rejected, "an accepted admission must prevent every later rejection")
285+ requireSupervisorOpen(t, s)
286+ } else {
287+ require.Len(t, rejected, admitters)
288+ for _, err := range rejected {
289+ requireBrokerClosed(t, err)
290+ }
291+ await(t, s.Done())
292+ }
293+
294+ for _, lease := range accepted {
295+ lease.Release()
296+ }
297+ if len(accepted) > 0 {
298+ clock.Advance(time.Minute)
299+ await(t, s.Done())
300+ }
301+ require.Equal(t, 0, clock.activeTimers())
302+ require.NoError(t, s.Close())
303+ }
304+}
305+
306+func TestSupervisorBlockedRunnerObservesCancellation(t *testing.T) {
307+ s, clock := newTestSupervisor(t, time.Minute)
308+ runner := newBlockingRunner()
309+ require.NoError(t, s.RegisterRunner("registry", runner))
310+ <-runner.started
311+
312+ clock.Advance(time.Minute)
313+ select {
314+ case observed := <-runner.observed:
315+ require.Equal(t, context.Canceled, observed)
316+ case <-time.After(3 * time.Second):
317+ t.Fatal("runner never observed cancellation")
318+ }
319+ await(t, s.Done())
320+ require.ErrorIs(t, s.RootContext().Err(), context.Canceled)
321+}
322+
323+func TestSupervisorBlockedHookObservesCancellation(t *testing.T) {
324+ s, clock := newTestSupervisor(t, time.Minute)
325+ observed := make(chan error, 1)
326+ require.NoError(t, s.Register("blocked", func(ctx context.Context) error {
327+ <-ctx.Done()
328+ observed <- ctx.Err()
329+ return nil
330+ }))
331+
332+ clock.Advance(time.Minute)
333+ select {
334+ case got := <-observed:
335+ require.Equal(t, context.Canceled, got)
336+ case <-time.After(3 * time.Second):
337+ t.Fatal("hook never observed cancellation")
338+ }
339+ await(t, s.Done())
340+}
341+
342+func TestSupervisorBackgroundComponentsDoNotPinIdle(t *testing.T) {
343+ s, clock := newTestSupervisor(t, time.Minute)
344+ runner := newBlockingRunner()
345+ poolDrained := make(chan struct{})
346+ require.NoError(t, s.RegisterRunner("registry", runner))
347+ require.NoError(t, s.RegisterCloseable("pool", closeFunc(func() error {
348+ close(poolDrained)
349+ return nil
350+ })))
351+ <-runner.started
352+
353+ // Registering background components takes no lease: the timer stays armed
354+ // and the broker still expires at the plain deadline.
355+ require.True(t, supervisorTimerArmed(s))
356+ clock.Advance(time.Minute)
357+ await(t, s.Done())
358+ await(t, poolDrained)
359+ require.Equal(t, context.Canceled, <-runner.observed)
360+}
361+
362+func TestSupervisorDrainsHooksInReverseRegistrationOrder(t *testing.T) {
363+ s, clock := newTestSupervisor(t, time.Minute)
364+ var mu sync.Mutex
365+ var order []string
366+ for _, name := range []string{"pool", "registry", "store"} {
367+ name := name
368+ require.NoError(t, s.Register(name, func(context.Context) error {
369+ mu.Lock()
370+ order = append(order, name)
371+ mu.Unlock()
372+ return nil
373+ }))
374+ }
375+
376+ clock.Advance(time.Minute)
377+ await(t, s.Done())
378+
379+ mu.Lock()
380+ defer mu.Unlock()
381+ require.Equal(t, []string{"store", "registry", "pool"}, order)
382+}
383+
384+func TestSupervisorCloseIsIdempotentAndForceCloses(t *testing.T) {
385+ s, _ := newTestSupervisor(t, time.Hour)
386+ lease, err := s.AdmitClient()
387+ require.NoError(t, err)
388+
389+ const closers = 8
390+ errs := make(chan error, closers)
391+ var wg sync.WaitGroup
392+ for i := 0; i < closers; i++ {
393+ wg.Add(1)
394+ go func() {
395+ defer wg.Done()
396+ errs <- s.Close()
397+ }()
398+ }
399+ wg.Wait()
400+ for i := 0; i < closers; i++ {
401+ require.NoError(t, <-errs)
402+ }
403+
404+ await(t, s.Done())
405+ require.ErrorIs(t, s.RootContext().Err(), context.Canceled)
406+ require.NoError(t, s.Close())
407+
408+ // A held lease that drains after the forced close must neither re-arm the
409+ // timer nor change the terminal state.
410+ lease.Release()
411+ lease.Release()
412+ clients, operations, armed, closed := supervisorState(s)
413+ require.Equal(t, 0, clients)
414+ require.Equal(t, 0, operations)
415+ require.False(t, armed)
416+ require.True(t, closed)
417+}
418+
419+func TestSupervisorCloseReportsHookErrorsOnly(t *testing.T) {
420+ s, _ := newTestSupervisor(t, time.Hour)
421+ boom := errors.New("pool close failed")
422+ require.NoError(t, s.RegisterCloseable("pool", closeFunc(func() error { return boom })))
423+ require.NoError(t, s.Register("ok", func(context.Context) error { return nil }))
424+
425+ err := s.Close()
426+ require.Error(t, err)
427+ require.ErrorIs(t, err, boom)
428+ // Repeated Close observes the exact same terminal error.
429+ require.ErrorIs(t, s.Close(), boom)
430+}
431+
432+func TestSupervisorRegistrationValidation(t *testing.T) {
433+ s, _ := newTestSupervisor(t, time.Hour)
434+ requireBrokerInvalid(t, s.Register("", func(context.Context) error { return nil }))
435+ requireBrokerInvalid(t, s.Register("nil-hook", nil))
436+ requireBrokerInvalid(t, s.RegisterRunner("nil-runner", nil))
437+ requireBrokerInvalid(t, s.RegisterCloseable("nil-closeable", nil))
438+ require.NoError(t, s.Register("hook", func(context.Context) error { return nil }))
439+}
440+
441+// TestSupervisorIdleExpiryLeavesNoTimer pins that an idle expiry shuts the
442+// supervisor down without leaving an armed timer behind.
443+func TestSupervisorIdleExpiryLeavesNoTimer(t *testing.T) {
444+ s, clock := newTestSupervisor(t, time.Minute)
445+
446+ clock.Advance(time.Minute)
447+ await(t, s.Done())
448+ await(t, s.stopped)
449+
450+ require.Equal(t, 0, clock.activeTimers())
451+}
452+
453+// TestSupervisorCloseLeavesNoTimer pins that Close disarms the idle timer and
454+// still reports shutdown through Done and stopped.
455+func TestSupervisorCloseLeavesNoTimer(t *testing.T) {
456+ s, clock := newTestSupervisor(t, time.Minute)
457+
458+ require.NoError(t, s.Close())
459+ await(t, s.Done())
460+ await(t, s.stopped)
461+
462+ require.Equal(t, 0, clock.activeTimers())
463+}
464+
465+// TestSupervisorCloseRacingIdleExpiryDrainsOnce pins the atomic commit: whether
466+// Close or an idle fire wins, the drain hook runs exactly once and every caller
467+// observes the same terminal error.
468+func TestSupervisorCloseRacingIdleExpiryDrainsOnce(t *testing.T) {
469+ boom := errors.New("pool drain failed")
470+ for range 25 {
471+ s, clock := newTestSupervisor(t, time.Minute)
472+
473+ var mu sync.Mutex
474+ drains := 0
475+ require.NoError(t, s.RegisterCloseable("pool", closeFunc(func() error {
476+ mu.Lock()
477+ drains++
478+ mu.Unlock()
479+ return boom
480+ })))
481+
482+ start := make(chan struct{})
483+ closed := make(chan error, 1)
484+ var wg sync.WaitGroup
485+ wg.Add(2)
486+ go func() {
487+ defer wg.Done()
488+ <-start
489+ clock.Advance(time.Minute)
490+ }()
491+ go func() {
492+ defer wg.Done()
493+ <-start
494+ closed <- s.Close()
495+ }()
496+ close(start)
497+ wg.Wait()
498+ await(t, s.Done())
499+ await(t, s.stopped)
500+
501+ mu.Lock()
502+ got := drains
503+ mu.Unlock()
504+ require.Equal(t, 1, got, "the drain hook must run exactly once")
505+ require.ErrorIs(t, <-closed, boom)
506+ require.ErrorIs(t, s.Close(), boom, "every Close caller observes the same terminal error")
507+ require.Equal(t, 0, clock.activeTimers())
508+ }
509+}
510+
511+// TestSupervisorFailingHookDoesNotAbortRemainingDrains pins that a hook error is
512+// collected rather than fatal: the remaining hooks still drain in reverse
513+// registration order and the joined error surfaces through Close.
514+func TestSupervisorFailingHookDoesNotAbortRemainingDrains(t *testing.T) {
515+ s, clock := newTestSupervisor(t, time.Minute)
516+ boom := errors.New("registry drain failed")
517+ var mu sync.Mutex
518+ var order []string
519+ register := func(name string, err error) {
520+ t.Helper()
521+ require.NoError(t, s.Register(name, func(context.Context) error {
522+ mu.Lock()
523+ order = append(order, name)
524+ mu.Unlock()
525+ return err
526+ }))
527+ }
528+ register("pool", nil)
529+ register("registry", boom)
530+ register("store", nil)
531+
532+ clock.Advance(time.Minute)
533+ await(t, s.Done())
534+ await(t, s.stopped)
535+
536+ mu.Lock()
537+ defer mu.Unlock()
538+ require.Equal(t, []string{"store", "registry", "pool"}, order, "a failing hook must not skip later drains")
539+ require.ErrorIs(t, s.Close(), boom)
540+}
@@ -0,0 +1,540 @@
1+package broker
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// newTestSupervisor builds a supervisor over a manual clock and always drains it
15+// at test end so an unexpired run goroutine can never leak into another test.
16+func newTestSupervisor(t testing.TB, grace time.Duration) (*Supervisor, *manualClock) {
17+ t.Helper()
18+ clock := newManualClock(time.Unix(0, 0))
19+ s, err := NewSupervisor(clock, SupervisorConfig{IdleGrace: grace})
20+ require.NoError(t, err)
21+ // Ignore the error: a test may deliberately register a failing drain hook.
22+ t.Cleanup(func() { _ = s.Close() })
23+ return s, clock
24+}
25+
26+// activeTimers reports the manual clock's live (armed, not yet fired) timers.
27+func (c *manualClock) activeTimers() int {
28+ c.mu.Lock()
29+ defer c.mu.Unlock()
30+ return len(c.timers)
31+}
32+
33+func supervisorState(s *Supervisor) (clients, operations int, armed, closed bool) {
34+ s.mu.Lock()
35+ defer s.mu.Unlock()
36+ return s.clients, s.operations, s.timerArmed, s.closed
37+}
38+
39+func supervisorTimerArmed(s *Supervisor) bool {
40+ _, _, armed, _ := supervisorState(s)
41+ return armed
42+}
43+
44+func requireSupervisorOpen(t *testing.T, s *Supervisor) {
45+ t.Helper()
46+ select {
47+ case <-s.Done():
48+ t.Fatal("supervisor shut down unexpectedly")
49+ default:
50+ }
51+}
52+
53+func requireBrokerClosed(t *testing.T, err error) {
54+ t.Helper()
55+ require.Error(t, err)
56+ var admission ports.BrokerAdmissionError
57+ require.ErrorAs(t, err, &admission)
58+ require.Equal(t, ports.BrokerAdmissionClosed, admission)
59+}
60+
61+func requireBrokerInvalid(t *testing.T, err error) {
62+ t.Helper()
63+ require.Error(t, err)
64+ var admission ports.BrokerAdmissionError
65+ require.ErrorAs(t, err, &admission)
66+ require.Equal(t, ports.BrokerAdmissionInvalid, admission)
67+}
68+
69+type closeFunc func() error
70+
71+func (f closeFunc) Close() error { return f() }
72+
73+// blockingRunner stands in for a broker-owned background component such as the
74+// observation Registry: it runs until cancellation and reports what it saw.
75+type blockingRunner struct {
76+ started chan struct{}
77+ observed chan error
78+}
79+
80+func newBlockingRunner() *blockingRunner {
81+ return &blockingRunner{started: make(chan struct{}), observed: make(chan error, 1)}
82+}
83+
84+func (r *blockingRunner) Run(ctx context.Context) {
85+ close(r.started)
86+ <-ctx.Done()
87+ r.observed <- ctx.Err()
88+}
89+
90+func TestSupervisorInitialIdleExpires(t *testing.T) {
91+ s, clock := newTestSupervisor(t, time.Minute)
92+ _, _, armed, closed := supervisorState(s)
93+ require.True(t, armed, "the idle timer must be armed at construction")
94+ require.False(t, closed)
95+
96+ clock.Advance(time.Minute - time.Nanosecond)
97+ requireSupervisorOpen(t, s)
98+
99+ clock.Advance(time.Nanosecond)
100+ await(t, s.Done())
101+ require.ErrorIs(t, s.RootContext().Err(), context.Canceled)
102+ require.False(t, supervisorTimerArmed(s))
103+ require.Equal(t, 0, clock.activeTimers())
104+
105+ _, err := s.AdmitClient()
106+ requireBrokerClosed(t, err)
107+ _, err = s.AdmitOperation()
108+ requireBrokerClosed(t, err)
109+ requireBrokerClosed(t, s.Register("late", func(context.Context) error { return nil }))
110+ requireBrokerClosed(t, s.RegisterRunner("late-runner", newBlockingRunner()))
111+ requireBrokerClosed(t, s.RegisterCloseable("late-close", closeFunc(func() error { return nil })))
112+}
113+
114+func TestSupervisorAdmissionBeforeDeadlineCancelsIdleShutdown(t *testing.T) {
115+ s, clock := newTestSupervisor(t, time.Minute)
116+ clock.Advance(30 * time.Second)
117+
118+ lease, err := s.AdmitClient()
119+ require.NoError(t, err)
120+ require.False(t, supervisorTimerArmed(s))
121+
122+ // Past the original deadline the broker is still open because the admission
123+ // cancelled the pending idle shutdown.
124+ clock.Advance(time.Hour)
125+ requireSupervisorOpen(t, s)
126+
127+ lease.Release()
128+ lease.Release() // idempotent
129+ require.True(t, supervisorTimerArmed(s))
130+
131+ clock.Advance(time.Minute - time.Nanosecond)
132+ requireSupervisorOpen(t, s)
133+ clock.Advance(time.Nanosecond)
134+ await(t, s.Done())
135+}
136+
137+func TestSupervisorClientLeaseProtectsIdle(t *testing.T) {
138+ s, clock := newTestSupervisor(t, time.Minute)
139+ lease, err := s.AdmitClient()
140+ require.NoError(t, err)
141+
142+ for i := 0; i < 3; i++ {
143+ clock.Advance(time.Minute)
144+ requireSupervisorOpen(t, s)
145+ require.False(t, supervisorTimerArmed(s), "the timer must be inactive while a client lease is held")
146+ }
147+
148+ lease.Release()
149+ require.True(t, supervisorTimerArmed(s))
150+ clock.Advance(time.Minute)
151+ await(t, s.Done())
152+}
153+
154+func TestSupervisorOperationLeaseProtectsIdle(t *testing.T) {
155+ s, clock := newTestSupervisor(t, time.Minute)
156+ lease, err := s.AdmitOperation()
157+ require.NoError(t, err)
158+
159+ clock.Advance(10 * time.Minute)
160+ requireSupervisorOpen(t, s)
161+
162+ lease.Release()
163+ clock.Advance(time.Minute)
164+ await(t, s.Done())
165+}
166+
167+func TestSupervisorIdleTimerTracksBothLeaseKinds(t *testing.T) {
168+ s, clock := newTestSupervisor(t, time.Minute)
169+ require.True(t, supervisorTimerArmed(s))
170+
171+ client, err := s.AdmitClient()
172+ require.NoError(t, err)
173+ require.False(t, supervisorTimerArmed(s))
174+
175+ operation, err := s.AdmitOperation()
176+ require.NoError(t, err)
177+ require.False(t, supervisorTimerArmed(s))
178+
179+ // Releasing one kind leaves the other; the timer stays inactive until both
180+ // counts are zero.
181+ client.Release()
182+ clients, operations, armed, closed := supervisorState(s)
183+ require.Equal(t, 0, clients)
184+ require.Equal(t, 1, operations)
185+ require.False(t, armed)
186+ require.False(t, closed)
187+
188+ operation.Release()
189+ require.True(t, supervisorTimerArmed(s))
190+
191+ clock.Advance(time.Minute)
192+ await(t, s.Done())
193+}
194+
195+// TestSupervisorSupersededArmDoesNotCommit pins the guarantee behind the
196+// concurrent race test: a fire from an arm that an admission/release pair has
197+// superseded must be ignored so the broker still gets a full fresh grace.
198+func TestSupervisorSupersededArmDoesNotCommit(t *testing.T) {
199+ s, clock := newTestSupervisor(t, time.Minute)
200+
201+ s.mu.Lock()
202+ stale := s.arm
203+ s.mu.Unlock()
204+
205+ lease, err := s.AdmitClient()
206+ require.NoError(t, err)
207+ lease.Release()
208+ require.True(t, supervisorTimerArmed(s))
209+ require.False(t, s.tryCommitIdleExpiry(stale), "a fire from a superseded arm must not commit")
210+ requireSupervisorOpen(t, s)
211+
212+ clock.Advance(time.Minute - time.Nanosecond)
213+ requireSupervisorOpen(t, s)
214+ clock.Advance(time.Nanosecond)
215+ await(t, s.Done())
216+}
217+
218+// TestSupervisorCurrentArmCommits pins the positive half: a fire from the arm the
219+// run goroutine is still observing commits while the broker is idle.
220+func TestSupervisorCurrentArmCommits(t *testing.T) {
221+ s, _ := newTestSupervisor(t, time.Minute)
222+ s.mu.Lock()
223+ current := s.arm
224+ s.mu.Unlock()
225+ require.True(t, s.tryCommitIdleExpiry(current))
226+ await(t, s.Done())
227+}
228+
229+func TestSupervisorRepeatedLeaseCyclesStillExpire(t *testing.T) {
230+ s, clock := newTestSupervisor(t, time.Minute)
231+ for i := 0; i < 50; i++ {
232+ lease, err := s.AdmitClient()
233+ require.NoError(t, err)
234+ lease.Release()
235+ }
236+
237+ require.True(t, supervisorTimerArmed(s))
238+ require.Equal(t, 1, clock.activeTimers(), "idle cycles must not accumulate timers")
239+ clock.Advance(time.Minute)
240+ await(t, s.Done())
241+ require.Equal(t, 0, clock.activeTimers())
242+}
243+
244+// TestSupervisorConcurrentExpiryAndAdmission races the idle expiry against a
245+// burst of admissions. Because commitment and admission share the mutex, the
246+// outcome is all-or-nothing: either the expiry committed first and every
247+// admission is rejected with the typed broker-closed error, or an admission won
248+// first and every admission is accepted and the broker stays open while a lease
249+// is held.
250+func TestSupervisorConcurrentExpiryAndAdmission(t *testing.T) {
251+ const (
252+ admitters = 8
253+ rounds = 50
254+ )
255+ for round := 0; round < rounds; round++ {
256+ s, clock := newTestSupervisor(t, time.Minute)
257+
258+ var mu sync.Mutex
259+ var accepted []*Lease
260+ var rejected []error
261+ start := make(chan struct{})
262+ var wg sync.WaitGroup
263+ for i := 0; i < admitters; i++ {
264+ wg.Add(1)
265+ go func() {
266+ defer wg.Done()
267+ <-start
268+ lease, err := s.AdmitClient()
269+ mu.Lock()
270+ if err != nil {
271+ rejected = append(rejected, err)
272+ } else {
273+ accepted = append(accepted, lease)
274+ }
275+ mu.Unlock()
276+ }()
277+ }
278+
279+ clock.Advance(time.Minute) // fire the idle timer concurrently with admissions
280+ close(start)
281+ wg.Wait()
282+
283+ if len(accepted) > 0 {
284+ require.Empty(t, rejected, "an accepted admission must prevent every later rejection")
285+ requireSupervisorOpen(t, s)
286+ } else {
287+ require.Len(t, rejected, admitters)
288+ for _, err := range rejected {
289+ requireBrokerClosed(t, err)
290+ }
291+ await(t, s.Done())
292+ }
293+
294+ for _, lease := range accepted {
295+ lease.Release()
296+ }
297+ if len(accepted) > 0 {
298+ clock.Advance(time.Minute)
299+ await(t, s.Done())
300+ }
301+ require.Equal(t, 0, clock.activeTimers())
302+ require.NoError(t, s.Close())
303+ }
304+}
305+
306+func TestSupervisorBlockedRunnerObservesCancellation(t *testing.T) {
307+ s, clock := newTestSupervisor(t, time.Minute)
308+ runner := newBlockingRunner()
309+ require.NoError(t, s.RegisterRunner("registry", runner))
310+ <-runner.started
311+
312+ clock.Advance(time.Minute)
313+ select {
314+ case observed := <-runner.observed:
315+ require.Equal(t, context.Canceled, observed)
316+ case <-time.After(3 * time.Second):
317+ t.Fatal("runner never observed cancellation")
318+ }
319+ await(t, s.Done())
320+ require.ErrorIs(t, s.RootContext().Err(), context.Canceled)
321+}
322+
323+func TestSupervisorBlockedHookObservesCancellation(t *testing.T) {
324+ s, clock := newTestSupervisor(t, time.Minute)
325+ observed := make(chan error, 1)
326+ require.NoError(t, s.Register("blocked", func(ctx context.Context) error {
327+ <-ctx.Done()
328+ observed <- ctx.Err()
329+ return nil
330+ }))
331+
332+ clock.Advance(time.Minute)
333+ select {
334+ case got := <-observed:
335+ require.Equal(t, context.Canceled, got)
336+ case <-time.After(3 * time.Second):
337+ t.Fatal("hook never observed cancellation")
338+ }
339+ await(t, s.Done())
340+}
341+
342+func TestSupervisorBackgroundComponentsDoNotPinIdle(t *testing.T) {
343+ s, clock := newTestSupervisor(t, time.Minute)
344+ runner := newBlockingRunner()
345+ poolDrained := make(chan struct{})
346+ require.NoError(t, s.RegisterRunner("registry", runner))
347+ require.NoError(t, s.RegisterCloseable("pool", closeFunc(func() error {
348+ close(poolDrained)
349+ return nil
350+ })))
351+ <-runner.started
352+
353+ // Registering background components takes no lease: the timer stays armed
354+ // and the broker still expires at the plain deadline.
355+ require.True(t, supervisorTimerArmed(s))
356+ clock.Advance(time.Minute)
357+ await(t, s.Done())
358+ await(t, poolDrained)
359+ require.Equal(t, context.Canceled, <-runner.observed)
360+}
361+
362+func TestSupervisorDrainsHooksInReverseRegistrationOrder(t *testing.T) {
363+ s, clock := newTestSupervisor(t, time.Minute)
364+ var mu sync.Mutex
365+ var order []string
366+ for _, name := range []string{"pool", "registry", "store"} {
367+ name := name
368+ require.NoError(t, s.Register(name, func(context.Context) error {
369+ mu.Lock()
370+ order = append(order, name)
371+ mu.Unlock()
372+ return nil
373+ }))
374+ }
375+
376+ clock.Advance(time.Minute)
377+ await(t, s.Done())
378+
379+ mu.Lock()
380+ defer mu.Unlock()
381+ require.Equal(t, []string{"store", "registry", "pool"}, order)
382+}
383+
384+func TestSupervisorCloseIsIdempotentAndForceCloses(t *testing.T) {
385+ s, _ := newTestSupervisor(t, time.Hour)
386+ lease, err := s.AdmitClient()
387+ require.NoError(t, err)
388+
389+ const closers = 8
390+ errs := make(chan error, closers)
391+ var wg sync.WaitGroup
392+ for i := 0; i < closers; i++ {
393+ wg.Add(1)
394+ go func() {
395+ defer wg.Done()
396+ errs <- s.Close()
397+ }()
398+ }
399+ wg.Wait()
400+ for i := 0; i < closers; i++ {
401+ require.NoError(t, <-errs)
402+ }
403+
404+ await(t, s.Done())
405+ require.ErrorIs(t, s.RootContext().Err(), context.Canceled)
406+ require.NoError(t, s.Close())
407+
408+ // A held lease that drains after the forced close must neither re-arm the
409+ // timer nor change the terminal state.
410+ lease.Release()
411+ lease.Release()
412+ clients, operations, armed, closed := supervisorState(s)
413+ require.Equal(t, 0, clients)
414+ require.Equal(t, 0, operations)
415+ require.False(t, armed)
416+ require.True(t, closed)
417+}
418+
419+func TestSupervisorCloseReportsHookErrorsOnly(t *testing.T) {
420+ s, _ := newTestSupervisor(t, time.Hour)
421+ boom := errors.New("pool close failed")
422+ require.NoError(t, s.RegisterCloseable("pool", closeFunc(func() error { return boom })))
423+ require.NoError(t, s.Register("ok", func(context.Context) error { return nil }))
424+
425+ err := s.Close()
426+ require.Error(t, err)
427+ require.ErrorIs(t, err, boom)
428+ // Repeated Close observes the exact same terminal error.
429+ require.ErrorIs(t, s.Close(), boom)
430+}
431+
432+func TestSupervisorRegistrationValidation(t *testing.T) {
433+ s, _ := newTestSupervisor(t, time.Hour)
434+ requireBrokerInvalid(t, s.Register("", func(context.Context) error { return nil }))
435+ requireBrokerInvalid(t, s.Register("nil-hook", nil))
436+ requireBrokerInvalid(t, s.RegisterRunner("nil-runner", nil))
437+ requireBrokerInvalid(t, s.RegisterCloseable("nil-closeable", nil))
438+ require.NoError(t, s.Register("hook", func(context.Context) error { return nil }))
439+}
440+
441+// TestSupervisorIdleExpiryLeavesNoTimer pins that an idle expiry shuts the
442+// supervisor down without leaving an armed timer behind.
443+func TestSupervisorIdleExpiryLeavesNoTimer(t *testing.T) {
444+ s, clock := newTestSupervisor(t, time.Minute)
445+
446+ clock.Advance(time.Minute)
447+ await(t, s.Done())
448+ await(t, s.stopped)
449+
450+ require.Equal(t, 0, clock.activeTimers())
451+}
452+
453+// TestSupervisorCloseLeavesNoTimer pins that Close disarms the idle timer and
454+// still reports shutdown through Done and stopped.
455+func TestSupervisorCloseLeavesNoTimer(t *testing.T) {
456+ s, clock := newTestSupervisor(t, time.Minute)
457+
458+ require.NoError(t, s.Close())
459+ await(t, s.Done())
460+ await(t, s.stopped)
461+
462+ require.Equal(t, 0, clock.activeTimers())
463+}
464+
465+// TestSupervisorCloseRacingIdleExpiryDrainsOnce pins the atomic commit: whether
466+// Close or an idle fire wins, the drain hook runs exactly once and every caller
467+// observes the same terminal error.
468+func TestSupervisorCloseRacingIdleExpiryDrainsOnce(t *testing.T) {
469+ boom := errors.New("pool drain failed")
470+ for range 25 {
471+ s, clock := newTestSupervisor(t, time.Minute)
472+
473+ var mu sync.Mutex
474+ drains := 0
475+ require.NoError(t, s.RegisterCloseable("pool", closeFunc(func() error {
476+ mu.Lock()
477+ drains++
478+ mu.Unlock()
479+ return boom
480+ })))
481+
482+ start := make(chan struct{})
483+ closed := make(chan error, 1)
484+ var wg sync.WaitGroup
485+ wg.Add(2)
486+ go func() {
487+ defer wg.Done()
488+ <-start
489+ clock.Advance(time.Minute)
490+ }()
491+ go func() {
492+ defer wg.Done()
493+ <-start
494+ closed <- s.Close()
495+ }()
496+ close(start)
497+ wg.Wait()
498+ await(t, s.Done())
499+ await(t, s.stopped)
500+
501+ mu.Lock()
502+ got := drains
503+ mu.Unlock()
504+ require.Equal(t, 1, got, "the drain hook must run exactly once")
505+ require.ErrorIs(t, <-closed, boom)
506+ require.ErrorIs(t, s.Close(), boom, "every Close caller observes the same terminal error")
507+ require.Equal(t, 0, clock.activeTimers())
508+ }
509+}
510+
511+// TestSupervisorFailingHookDoesNotAbortRemainingDrains pins that a hook error is
512+// collected rather than fatal: the remaining hooks still drain in reverse
513+// registration order and the joined error surfaces through Close.
514+func TestSupervisorFailingHookDoesNotAbortRemainingDrains(t *testing.T) {
515+ s, clock := newTestSupervisor(t, time.Minute)
516+ boom := errors.New("registry drain failed")
517+ var mu sync.Mutex
518+ var order []string
519+ register := func(name string, err error) {
520+ t.Helper()
521+ require.NoError(t, s.Register(name, func(context.Context) error {
522+ mu.Lock()
523+ order = append(order, name)
524+ mu.Unlock()
525+ return err
526+ }))
527+ }
528+ register("pool", nil)
529+ register("registry", boom)
530+ register("store", nil)
531+
532+ clock.Advance(time.Minute)
533+ await(t, s.Done())
534+ await(t, s.stopped)
535+
536+ mu.Lock()
537+ defer mu.Unlock()
538+ require.Equal(t, []string{"store", "registry", "pool"}, order, "a failing hook must not skip later drains")
539+ require.ErrorIs(t, s.Close(), boom)
540+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close