* fix(jellycompat): sync activity dashboard immediately on compat playback start/stop Compat (Infuse/Jellyfin) sessions were only reconciled into playback_sessions_sync on the periodic 15s reconciler tick, so stopped streams lingered in the admin activity dashboard and overlapped with newly started ones as ghost sessions. Native playback handlers already trigger an immediate SessionSyncer.SyncNow on start/stop; wire the same syncer into the jellycompat playback handler and flush after teardownPlaySession (Stopped report + ActiveEncodings teardown) and after a new upstream session starts. The stop-path sync detaches from request cancellation (context.WithoutCancel) because clients often drop the connection right after reporting a stop. Also adds the ListProgressSince method to the jellycompat test fake so the package's tests compile again after the downloads-v2 interface change. Part of #205 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(worker): serialize SyncNow and bound compat request-path session sync Adversarial review follow-ups: SyncNow snapshots could commit out of order when request-path syncs raced the periodic tick (an older snapshot committing last would resurrect stopped sessions or drop fresh ones), and the detached stop-path sync had no deadline, letting a stalled DB pin request goroutines. Serialize snapshot capture + reconcile under one lock and cap request-path syncs at 5s so failures degrade to the periodic tick. Part of #205 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(worker): coalesce immediate session syncs instead of queueing on a lock Review iteration 2: a stop-triggered sync queued on a bare mutex could expire its 5s deadline waiting behind a slow tick and fail before removing the stopped row. Coalesce instead: one owner reconciles at a time, callers that arrive mid-flight return immediately after flagging a follow-up pass, and the owner re-captures a fresh (post-change) snapshot afterwards. Request goroutines never block behind another sync, snapshots still commit in capture order, and an expired-context owner leaves the queued pass for the next tick instead of burning it. Part of #205 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
92 lines
2.5 KiB
Go
92 lines
2.5 KiB
Go
package worker
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestSyncNowSerializesSnapshotCapture guards the SyncNow ordering contract:
|
|
// snapshot capture and reconciliation run under one lock, so a request-path
|
|
// sync (playback start/stop) can never interleave with the periodic tick and
|
|
// commit an older session snapshot after a newer one.
|
|
func TestSyncNowSerializesSnapshotCapture(t *testing.T) {
|
|
var inflight atomic.Int32
|
|
var overlapped atomic.Bool
|
|
provider := func() []SessionSync {
|
|
if inflight.Add(1) > 1 {
|
|
overlapped.Store(true)
|
|
}
|
|
time.Sleep(2 * time.Millisecond)
|
|
inflight.Add(-1)
|
|
return nil
|
|
}
|
|
|
|
// No pool is needed: an empty snapshot with no node name returns before
|
|
// any database work, keeping the test focused on the locking contract.
|
|
r := NewReconciler(nil, "", provider)
|
|
|
|
var wg sync.WaitGroup
|
|
for range 8 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if err := r.SyncNow(context.Background()); err != nil {
|
|
t.Errorf("SyncNow: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
if overlapped.Load() {
|
|
t.Fatal("concurrent SyncNow calls captured snapshots concurrently; capture and reconcile must be serialized")
|
|
}
|
|
}
|
|
|
|
// TestSyncNowCoalescesPendingPass guards the follow-up contract: a SyncNow
|
|
// call that arrives while a sync is in flight returns immediately, and the
|
|
// running owner re-captures a fresh snapshot afterwards — so a stop that lands
|
|
// mid-sync is still reflected without waiting for the periodic tick.
|
|
func TestSyncNowCoalescesPendingPass(t *testing.T) {
|
|
captures := make(chan struct{}, 16)
|
|
release := make(chan struct{})
|
|
first := true
|
|
provider := func() []SessionSync {
|
|
captures <- struct{}{}
|
|
if first {
|
|
first = false
|
|
<-release // hold the first sync mid-flight
|
|
}
|
|
return nil
|
|
}
|
|
r := NewReconciler(nil, "", provider)
|
|
|
|
ownerDone := make(chan error, 1)
|
|
go func() { ownerDone <- r.SyncNow(context.Background()) }()
|
|
<-captures // owner is now blocked inside its snapshot capture
|
|
|
|
// A second sync while the first is in flight must not block.
|
|
done := make(chan struct{})
|
|
go func() {
|
|
_ = r.SyncNow(context.Background())
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("SyncNow blocked behind an in-flight sync; it must coalesce and return")
|
|
}
|
|
|
|
close(release)
|
|
if err := <-ownerDone; err != nil {
|
|
t.Fatalf("owner SyncNow: %v", err)
|
|
}
|
|
select {
|
|
case <-captures: // the owner's follow-up pass with a fresh snapshot
|
|
default:
|
|
t.Fatal("no follow-up snapshot capture ran; the coalesced sync was lost")
|
|
}
|
|
}
|