generation-aware playback statuses

This commit is contained in:
Dmitry Sergeev
2026-09-01 18:03:18 +03:00
parent 7a19fe0dad
commit 9bc08109fc
12 changed files with 210 additions and 41 deletions
+9 -8
View File
@@ -72,7 +72,8 @@ func (s *stabilityAudioSink) ConsumeAudio(
return err return err
} }
func (w *AudioWorker) emit(status Status) { func (w *AudioWorker) emit(ctx context.Context, status Status) {
status.Generation = generationFromContext(ctx)
if w.observer != nil { if w.observer != nil {
w.observer(status) w.observer(status)
} }
@@ -99,7 +100,7 @@ func (w *AudioWorker) Run(
if attemptNumber > 1 { if attemptNumber > 1 {
state = StateReconnecting state = StateReconnecting
} }
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitAudio, Unit: UnitAudio,
State: state, State: state,
Attempt: attemptNumber, Attempt: attemptNumber,
@@ -108,7 +109,7 @@ func (w *AudioWorker) Run(
attemptSink := &stabilityAudioSink{ attemptSink := &stabilityAudioSink{
sink: w.sink, sink: w.sink,
onStable: func() { onStable: func() {
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitAudio, Unit: UnitAudio,
State: StatePlaying, State: StatePlaying,
Attempt: attemptNumber, Attempt: attemptNumber,
@@ -134,7 +135,7 @@ func (w *AudioWorker) Run(
return return
} }
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitAudio, Unit: UnitAudio,
State: StateReconnecting, State: StateReconnecting,
Attempt: attemptNumber + 1, Attempt: attemptNumber + 1,
@@ -154,11 +155,11 @@ func (w *AudioWorker) Run(
) )
if ctx.Err() != nil { if ctx.Err() != nil {
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitAudio, Unit: UnitAudio,
State: StateStopping, State: StateStopping,
}) })
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitAudio, Unit: UnitAudio,
State: StateIdle, State: StateIdle,
}) })
@@ -166,7 +167,7 @@ func (w *AudioWorker) Run(
} }
if err != nil { if err != nil {
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitAudio, Unit: UnitAudio,
State: StateFailed, State: StateFailed,
Attempt: attemptNumber, Attempt: attemptNumber,
@@ -176,7 +177,7 @@ func (w *AudioWorker) Run(
return err return err
} }
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitAudio, Unit: UnitAudio,
State: StateIdle, State: StateIdle,
}) })
+25
View File
@@ -116,6 +116,31 @@ func TestAudioWorkerRejectsInactiveFeed(t *testing.T) {
} }
} }
func TestAudioWorkerStatusesInheritGeneration(t *testing.T) {
openErr := errors.New("unavailable")
var statuses []Status
worker := newAudioWorkerForTest(
t,
&queuedAudioFactory{errs: []error{openErr}},
&fakeAudioSink{},
1,
func(error) bool { return true },
func(status Status) { statuses = append(statuses, status) },
)
_ = worker.Run(
withGeneration(context.Background(), 8),
FeedConfig{Domain: "/audio", UUID: "audio", Active: true},
)
if len(statuses) == 0 {
t.Fatal("no statuses emitted")
}
for _, status := range statuses {
if status.Generation != 8 {
t.Fatalf("status generation = %d, want 8: %+v", status.Generation, status)
}
}
}
func TestAudioWorkerPublishesPlayingThenFailed(t *testing.T) { func TestAudioWorkerPublishesPlayingThenFailed(t *testing.T) {
readErr := errors.New("audio disappeared") readErr := errors.New("audio disappeared")
reader := &fakeAudioReader{ reader := &fakeAudioReader{
+11 -7
View File
@@ -109,9 +109,8 @@ func (c *SessionController) Run(
} }
desired := initial desired := initial
runtime := c.startSessionRuntime(ctx, plan)
generation := uint64(1) generation := uint64(1)
runtime := c.startSessionRuntime(ctx, plan, generation)
c.publish(SessionSnapshot{ c.publish(SessionSnapshot{
Desired: initial, Desired: initial,
Plan: plan, Plan: plan,
@@ -148,11 +147,16 @@ func (c *SessionController) Run(
continue continue
} }
nextGeneration := generation
if plan.Topology != nextPlan.Topology {
nextGeneration++
}
nextRuntime, err := c.reconcileSessionRuntime( nextRuntime, err := c.reconcileSessionRuntime(
ctx, ctx,
runtime, runtime,
plan, plan,
nextPlan, nextPlan,
nextGeneration,
) )
if err != nil { if err != nil {
stopSessionRuntime(runtime) stopSessionRuntime(runtime)
@@ -162,9 +166,7 @@ func (c *SessionController) Run(
return err return err
} }
if plan.Topology != nextPlan.Topology { generation = nextGeneration
generation++
}
desired = nextDesired desired = nextDesired
plan = nextPlan plan = nextPlan
runtime = nextRuntime runtime = nextRuntime
@@ -180,12 +182,13 @@ func (c *SessionController) Run(
func (c *SessionController) startSessionRuntime( func (c *SessionController) startSessionRuntime(
ctx context.Context, ctx context.Context,
plan SessionPlan, plan SessionPlan,
generation uint64,
) *sessionRuntime { ) *sessionRuntime {
if plan.Topology == TopologyIdle { if plan.Topology == TopologyIdle {
return &sessionRuntime{topology: TopologyIdle} return &sessionRuntime{topology: TopologyIdle}
} }
runtimeCtx, cancel := context.WithCancel(ctx) runtimeCtx, cancel := context.WithCancel(withGeneration(ctx, generation))
runtime := &sessionRuntime{ runtime := &sessionRuntime{
topology: plan.Topology, topology: plan.Topology,
cancel: cancel, cancel: cancel,
@@ -255,13 +258,14 @@ func (c *SessionController) reconcileSessionRuntime(
runtime *sessionRuntime, runtime *sessionRuntime,
current SessionPlan, current SessionPlan,
next SessionPlan, next SessionPlan,
nextGeneration uint64,
) (*sessionRuntime, error) { ) (*sessionRuntime, error) {
if current.Topology != next.Topology { if current.Topology != next.Topology {
stopSessionRuntime(runtime) stopSessionRuntime(runtime)
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return runtime, err return runtime, err
} }
return c.startSessionRuntime(ctx, next), nil return c.startSessionRuntime(ctx, next, nextGeneration), nil
} }
switch next.Topology { switch next.Topology {
+16 -3
View File
@@ -90,6 +90,7 @@ func TestNewSessionControllerStoresSyncPredicate(t *testing.T) {
type controllerEvent struct { type controllerEvent struct {
unit Unit unit Unit
action string action string
generation uint64
feed FeedConfig feed FeedConfig
pair SyncPairConfig pair SyncPairConfig
} }
@@ -101,7 +102,10 @@ func (s recordingVideoSlot) Run(
initial FeedConfig, initial FeedConfig,
commands <-chan FeedConfig, commands <-chan FeedConfig,
) error { ) error {
s.events <- controllerEvent{unit: UnitVideo, action: "start", feed: initial} s.events <- controllerEvent{
unit: UnitVideo, action: "start",
generation: generationFromContext(ctx), feed: initial,
}
for { for {
select { select {
case config := <-commands: case config := <-commands:
@@ -120,7 +124,10 @@ func (s recordingAudioSlot) Run(
initial FeedConfig, initial FeedConfig,
commands <-chan FeedConfig, commands <-chan FeedConfig,
) error { ) error {
s.events <- controllerEvent{unit: UnitAudio, action: "start", feed: initial} s.events <- controllerEvent{
unit: UnitAudio, action: "start",
generation: generationFromContext(ctx), feed: initial,
}
for { for {
select { select {
case config := <-commands: case config := <-commands:
@@ -139,7 +146,10 @@ func (s recordingSyncSlot) Run(
initial SyncPairConfig, initial SyncPairConfig,
commands <-chan SyncPairConfig, commands <-chan SyncPairConfig,
) error { ) error {
s.events <- controllerEvent{unit: UnitSync, action: "start", pair: initial} s.events <- controllerEvent{
unit: UnitSync, action: "start",
generation: generationFromContext(ctx), pair: initial,
}
for { for {
select { select {
case config := <-commands: case config := <-commands:
@@ -291,6 +301,9 @@ func TestSessionControllerStopsIndependentSlotsBeforeStartingSync(t *testing.T)
if !stopped[UnitVideo] || !stopped[UnitAudio] { if !stopped[UnitVideo] || !stopped[UnitAudio] {
t.Fatalf("sync started before both independent slots stopped: %v", stopped) t.Fatalf("sync started before both independent slots stopped: %v", stopped)
} }
if event.generation != 2 {
t.Fatalf("sync runtime generation = %d, want 2", event.generation)
}
break break
} }
if event.action != "stop" || (event.unit != UnitVideo && event.unit != UnitAudio) { if event.action != "stop" || (event.unit != UnitVideo && event.unit != UnitAudio) {
+13
View File
@@ -1,6 +1,7 @@
package playback package playback
import ( import (
"context"
"fmt" "fmt"
"time" "time"
) )
@@ -27,6 +28,7 @@ const (
type Status struct { type Status struct {
Unit Unit Unit Unit
State State State State
Generation uint64
Attempt int Attempt int
FailedAttempts int FailedAttempts int
RetryIn time.Duration RetryIn time.Duration
@@ -35,6 +37,17 @@ type Status struct {
type StatusObserver func(Status) type StatusObserver func(Status)
type generationContextKey struct{}
func withGeneration(ctx context.Context, generation uint64) context.Context {
return context.WithValue(ctx, generationContextKey{}, generation)
}
func generationFromContext(ctx context.Context) uint64 {
generation, _ := ctx.Value(generationContextKey{}).(uint64)
return generation
}
func (u Unit) String() string { func (u Unit) String() string {
switch u { switch u {
case UnitVideo: case UnitVideo:
+11
View File
@@ -1,11 +1,22 @@
package playback package playback
import ( import (
"context"
"errors" "errors"
"testing" "testing"
"time" "time"
) )
func TestGenerationContext(t *testing.T) {
if got := generationFromContext(context.Background()); got != 0 {
t.Fatalf("background generation = %d, want 0", got)
}
ctx := withGeneration(context.Background(), 42)
if got := generationFromContext(ctx); got != 42 {
t.Fatalf("generation = %d, want 42", got)
}
}
func TestStatusPreservesValues(t *testing.T) { func TestStatusPreservesValues(t *testing.T) {
wantErr := errors.New("producer missing") wantErr := errors.New("producer missing")
status := Status{ status := Status{
+9 -1
View File
@@ -4,6 +4,7 @@ import "sync"
type StatusStore struct { type StatusStore struct {
mu sync.RWMutex mu sync.RWMutex
generation uint64
statuses map[Unit]Status statuses map[Unit]Status
} }
@@ -15,8 +16,15 @@ func NewStatusStore() *StatusStore {
func (s *StatusStore) Observe(status Status) { func (s *StatusStore) Observe(status Status) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock()
if status.Generation < s.generation {
return
}
if status.Generation > s.generation {
clear(s.statuses)
s.generation = status.Generation
}
s.statuses[status.Unit] = status s.statuses[status.Unit] = status
s.mu.Unlock()
} }
func (s *StatusStore) Snapshot(unit Unit) (Status, bool) { func (s *StatusStore) Snapshot(unit Unit) (Status, bool) {
+47
View File
@@ -106,3 +106,50 @@ func TestStatusStoreConcurrentAccess(t *testing.T) {
} }
} }
} }
func TestStatusStoreNewGenerationClearsPreviousUnits(t *testing.T) {
store := NewStatusStore()
store.Observe(Status{Unit: UnitVideo, State: StatePlaying, Generation: 1})
store.Observe(Status{Unit: UnitAudio, State: StatePlaying, Generation: 1})
want := Status{Unit: UnitSync, State: StateConnecting, Generation: 2}
store.Observe(want)
if _, ok := store.Snapshot(UnitVideo); ok {
t.Fatal("video status survived generation change")
}
if _, ok := store.Snapshot(UnitAudio); ok {
t.Fatal("audio status survived generation change")
}
if got, ok := store.Snapshot(UnitSync); !ok || got != want {
t.Fatalf("sync Snapshot() = %#v, %t; want %#v, true", got, ok, want)
}
}
func TestStatusStoreIgnoresOlderGeneration(t *testing.T) {
store := NewStatusStore()
want := Status{Unit: UnitSync, State: StatePlaying, Generation: 3}
store.Observe(want)
store.Observe(Status{Unit: UnitVideo, State: StateIdle, Generation: 2})
if _, ok := store.Snapshot(UnitVideo); ok {
t.Fatal("older video status was stored")
}
if got, ok := store.Snapshot(UnitSync); !ok || got != want {
t.Fatalf("sync Snapshot() = %#v, %t; want %#v, true", got, ok, want)
}
}
func TestStatusStoreKeepsEqualGenerationUnitsIndependent(t *testing.T) {
store := NewStatusStore()
wantVideo := Status{Unit: UnitVideo, State: StatePlaying, Generation: 4}
wantAudio := Status{Unit: UnitAudio, State: StateReconnecting, Generation: 4}
store.Observe(wantVideo)
store.Observe(wantAudio)
if got, ok := store.Snapshot(UnitVideo); !ok || got != wantVideo {
t.Fatalf("video Snapshot() = %#v, %t", got, ok)
}
if got, ok := store.Snapshot(UnitAudio); !ok || got != wantAudio {
t.Fatalf("audio Snapshot() = %#v, %t", got, ok)
}
}
+9 -8
View File
@@ -59,7 +59,8 @@ func NewSyncWorker(
}, nil }, nil
} }
func (w *SyncWorker) emit(status Status) { func (w *SyncWorker) emit(ctx context.Context, status Status) {
status.Generation = generationFromContext(ctx)
if w.observer != nil { if w.observer != nil {
w.observer(status) w.observer(status)
} }
@@ -90,7 +91,7 @@ func (w *SyncWorker) Run(
if attemptNumber > 1 { if attemptNumber > 1 {
state = StateReconnecting state = StateReconnecting
} }
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitSync, Unit: UnitSync,
State: state, State: state,
Attempt: attemptNumber, Attempt: attemptNumber,
@@ -99,7 +100,7 @@ func (w *SyncWorker) Run(
attemptAudioSink := &stabilityAudioSink{ attemptAudioSink := &stabilityAudioSink{
sink: w.audioSink, sink: w.audioSink,
onStable: func() { onStable: func() {
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitSync, Unit: UnitSync,
State: StatePlaying, State: StatePlaying,
Attempt: attemptNumber, Attempt: attemptNumber,
@@ -133,7 +134,7 @@ func (w *SyncWorker) Run(
if !event.WillRetry { if !event.WillRetry {
return return
} }
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitSync, Unit: UnitSync,
State: StateReconnecting, State: StateReconnecting,
Attempt: attemptNumber + 1, Attempt: attemptNumber + 1,
@@ -151,18 +152,18 @@ func (w *SyncWorker) Run(
observeRetry, observeRetry,
) )
if ctx.Err() != nil { if ctx.Err() != nil {
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitSync, Unit: UnitSync,
State: StateStopping, State: StateStopping,
}) })
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitSync, Unit: UnitSync,
State: StateIdle, State: StateIdle,
}) })
return ctx.Err() return ctx.Err()
} }
if err != nil { if err != nil {
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitSync, Unit: UnitSync,
State: StateFailed, State: StateFailed,
Attempt: attemptNumber, Attempt: attemptNumber,
@@ -171,7 +172,7 @@ func (w *SyncWorker) Run(
}) })
return err return err
} }
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitSync, Unit: UnitSync,
State: StateIdle, State: StateIdle,
}) })
+23
View File
@@ -126,6 +126,29 @@ func TestSyncWorkerRejectsInvalidOrInactiveFeeds(t *testing.T) {
} }
} }
func TestSyncWorkerStatusesInheritGeneration(t *testing.T) {
openErr := errors.New("unavailable")
var statuses []Status
worker := newTestSyncWorker(
t,
&scriptedSyncFactory{results: []syncOpenResult{{err: openErr}}},
&fakeVideoSink{},
&fakeAudioSink{},
1,
func(status Status) { statuses = append(statuses, status) },
)
video, audio := activeSyncConfigs()
_ = worker.Run(withGeneration(context.Background(), 9), video, audio)
if len(statuses) == 0 {
t.Fatal("no statuses emitted")
}
for _, status := range statuses {
if status.Generation != 9 {
t.Fatalf("status generation = %d, want 9: %+v", status.Generation, status)
}
}
}
func TestSyncWorkerExhaustsOpenRetries(t *testing.T) { func TestSyncWorkerExhaustsOpenRetries(t *testing.T) {
openErr := errors.New("sync producer unavailable") openErr := errors.New("sync producer unavailable")
factory := &scriptedSyncFactory{results: []syncOpenResult{{err: openErr}, {err: openErr}}} factory := &scriptedSyncFactory{results: []syncOpenResult{{err: openErr}, {err: openErr}}}
+9 -8
View File
@@ -72,7 +72,8 @@ func (s *stabilityVideoSink) ConsumeVideo(
return err return err
} }
func (w *VideoWorker) emit(status Status) { func (w *VideoWorker) emit(ctx context.Context, status Status) {
status.Generation = generationFromContext(ctx)
if w.observer != nil { if w.observer != nil {
w.observer(status) w.observer(status)
} }
@@ -99,7 +100,7 @@ func (w *VideoWorker) Run(
if attemptNumber > 1 { if attemptNumber > 1 {
state = StateReconnecting state = StateReconnecting
} }
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitVideo, Unit: UnitVideo,
State: state, State: state,
Attempt: attemptNumber, Attempt: attemptNumber,
@@ -108,7 +109,7 @@ func (w *VideoWorker) Run(
attemptSink := &stabilityVideoSink{ attemptSink := &stabilityVideoSink{
sink: w.sink, sink: w.sink,
onStable: func() { onStable: func() {
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitVideo, Unit: UnitVideo,
State: StatePlaying, State: StatePlaying,
Attempt: attemptNumber, Attempt: attemptNumber,
@@ -134,7 +135,7 @@ func (w *VideoWorker) Run(
return return
} }
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitVideo, Unit: UnitVideo,
State: StateReconnecting, State: StateReconnecting,
Attempt: attemptNumber + 1, Attempt: attemptNumber + 1,
@@ -154,11 +155,11 @@ func (w *VideoWorker) Run(
) )
if ctx.Err() != nil { if ctx.Err() != nil {
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitVideo, Unit: UnitVideo,
State: StateStopping, State: StateStopping,
}) })
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitVideo, Unit: UnitVideo,
State: StateIdle, State: StateIdle,
}) })
@@ -166,7 +167,7 @@ func (w *VideoWorker) Run(
} }
if err != nil { if err != nil {
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitVideo, Unit: UnitVideo,
State: StateFailed, State: StateFailed,
Attempt: attemptNumber, Attempt: attemptNumber,
@@ -176,7 +177,7 @@ func (w *VideoWorker) Run(
return err return err
} }
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitVideo, Unit: UnitVideo,
State: StateIdle, State: StateIdle,
}) })
+22
View File
@@ -151,6 +151,28 @@ func TestVideoWorkerRejectsInactiveFeed(t *testing.T) {
} }
} }
func TestVideoWorkerStatusesInheritGeneration(t *testing.T) {
openErr := errors.New("unavailable")
var statuses []Status
worker := newTestVideoWorker(
t,
&scriptedVideoFactory{results: []videoOpenResult{{err: openErr}}},
&fakeVideoSink{},
1,
func(error) bool { return true },
func(status Status) { statuses = append(statuses, status) },
)
_ = worker.Run(withGeneration(context.Background(), 7), activeVideoConfig())
if len(statuses) == 0 {
t.Fatal("no statuses emitted")
}
for _, status := range statuses {
if status.Generation != 7 {
t.Fatalf("status generation = %d, want 7: %+v", status.Generation, status)
}
}
}
func TestVideoWorkerExhaustsOpenRetries(t *testing.T) { func TestVideoWorkerExhaustsOpenRetries(t *testing.T) {
openErr := errors.New("producer unavailable") openErr := errors.New("producer unavailable")
factory := &scriptedVideoFactory{ factory := &scriptedVideoFactory{