Files
silo-server/internal/jellycompat/playback_sessions_postgres_test.go
Puks The PirateandGitHub 20c6b146eb fix(jellycompat): repair episode handoff (#529)
Continue Watching clients can retain stale user and media-source values across episode transitions. Keep token-derived authorization authoritative and fall back to the requested item files.

Strip NUL code points across durable playback-session JSON so PostgreSQL persistence cannot leave negotiated sessions cache-only.
2026-08-11 12:15:59 -04:00

984 lines
39 KiB
Go

package jellycompat
import (
"context"
"encoding/json"
"fmt"
"os"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/watchsync"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestMarshalPlaybackSessionStripsNestedNUL(t *testing.T) {
wantLiteral := `literal\u0000text`
data, err := marshalPlaybackSession(PlaybackSession{
ClientDeviceID: "device\x00id",
MediaSources: []PlaybackMediaSource{{
Version: catalog.FileVersion{
FileName: "episode\x00name.mkv",
EditionRaw: wantLiteral,
},
}},
})
if err != nil {
t.Fatalf("marshal playback session: %v", err)
}
var got PlaybackSession
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal sanitized playback session: %v", err)
}
if got.ClientDeviceID != "deviceid" || got.MediaSources[0].Version.FileName != "episodename.mkv" {
t.Fatalf("nested NUL was retained: %#v", got)
}
if got.MediaSources[0].Version.EditionRaw != wantLiteral {
t.Fatalf("literal escape changed: %q", got.MediaSources[0].Version.EditionRaw)
}
}
func newCompatTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
var tableName *string
if err := pool.QueryRow(ctx, `SELECT to_regclass('public.jellycompat_playback_sessions')::text`).Scan(&tableName); err != nil {
t.Fatalf("check jellycompat_playback_sessions table: %v", err)
}
if tableName == nil || *tableName == "" {
t.Skip("test database has not applied jellycompat playback sessions migration")
}
return pool
}
// A session written by one store instance must be reloadable by a fresh instance
// (empty cache) — i.e. it survived in Postgres, as it would across a restart.
func TestDurableCompatPlaybackStore_SurvivesRestart(t *testing.T) {
pool := newCompatTestPool(t)
ctx := context.Background()
id := fmt.Sprintf("compat-test-%d", time.Now().UnixNano())
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM jellycompat_playback_sessions WHERE id = $1`, id) })
store1 := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
store1.Put(PlaybackSession{
ID: id,
CompatToken: "tok",
UserID: "u1",
RouteItemID: "route-1",
UpstreamSessionID: "up-1",
InitialSeekSeconds: 12.5,
MediaSources: []PlaybackMediaSource{{ID: "src-1", FileID: 7}},
})
// Fresh instance => empty cache => must hit Postgres.
store2 := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
got, ok := store2.Get(id)
if !ok {
t.Fatal("session not reloaded from Postgres after restart")
}
if got.UpstreamSessionID != "up-1" || got.RouteItemID != "route-1" || got.InitialSeekSeconds != 12.5 {
t.Fatalf("reloaded session lost fields: %+v", got)
}
// FindByRoute on the fresh instance resolves via a DB-backed scan.
if _, _, ok := store2.FindByRoute("tok", "route-1"); !ok {
t.Fatal("FindByRoute failed to resolve a persisted session")
}
// Update persists; reload on yet another instance sees it.
if err := store2.Update(id, func(s *PlaybackSession) error {
s.TranscodeStarted = true
return nil
}); err != nil {
t.Fatalf("update: %v", err)
}
store3 := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
if got, ok := store3.Get(id); !ok || !got.TranscodeStarted {
t.Fatalf("update did not persist: ok=%v got=%+v", ok, got)
}
store3.Delete(id)
store4 := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
if _, ok := store4.Get(id); ok {
t.Fatal("session still present after delete")
}
}
func TestDurableCompatPlaybackStoreRevalidatesUnstartedNegotiationAcrossInstances(t *testing.T) {
pool := newCompatTestPool(t)
ctx := context.Background()
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
firstID := "compat-first-" + suffix
secondID := "compat-second-" + suffix
t.Cleanup(func() {
_, _ = pool.Exec(
ctx,
`DELETE FROM jellycompat_playback_sessions WHERE id = ANY($1)`,
[]string{firstID, secondID},
)
})
first := PlaybackSession{
ID: firstID,
CompatToken: "owner-" + suffix,
ClientDeviceID: "device-1",
RouteItemID: "route-1",
}
seed := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
seed.PutNegotiated(first)
stale := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
if _, ok := stale.Get(firstID); !ok {
t.Fatal("first negotiation was not cached")
}
replacer := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
second := first
second.ID = secondID
replacer.PutNegotiated(second)
if _, ok := stale.Get(firstID); ok {
t.Fatal("superseded unstarted negotiation remained routable from another instance's cache")
}
}
func TestDurableCompatPlaybackStorePutNegotiatedReplacesAcrossInstances(t *testing.T) {
pool := newCompatTestPool(t)
ctx := context.Background()
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
firstID := "compat-negotiated-first-" + suffix
secondID := "compat-negotiated-second-" + suffix
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM jellycompat_playback_sessions WHERE id = ANY($1)`, []string{firstID, secondID})
})
first := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
first.PutNegotiated(PlaybackSession{
ID: firstID,
CompatToken: "negotiated-token-" + suffix,
ClientDeviceID: "web-device",
RouteItemID: "route-1",
})
second := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
second.PutNegotiated(PlaybackSession{
ID: secondID,
CompatToken: "negotiated-token-" + suffix,
ClientDeviceID: "web-device",
RouteItemID: "route-1",
})
fresh := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
if _, ok := fresh.Get(firstID); ok {
t.Fatal("superseded negotiation remained durable")
}
if _, ok := fresh.Get(secondID); !ok {
t.Fatal("replacement negotiation was not durable")
}
}
// M5: an empty compat token must never trigger a DB scan — FindByRoute returns
// the in-memory result only. With a non-nil pool but no live DB, a scan attempt
// would block/error on the pool; instead the empty-token path returns cleanly
// from cache. We assert: (a) a cached empty-token route resolves, and (b) a
// cache-miss empty-token lookup returns false without consulting the DB. The
// nil-pool variant proves the early return independent of any pool.
func TestDurableCompatPlaybackStore_EmptyTokenFindByRouteNoDBScan(t *testing.T) {
store := NewDurableCompatPlaybackStore(nil, time.Hour, nil)
store.Put(PlaybackSession{ID: "ps-empty", CompatToken: "", RouteItemID: "route-x"})
// Cached empty-token route resolves from memory.
if _, _, ok := store.FindByRoute("", "route-x"); !ok {
t.Fatal("empty-token FindByRoute should resolve a cached route")
}
// Cache miss with an empty token returns false without a DB fallback.
if _, _, ok := store.FindByRoute("", "route-missing"); ok {
t.Fatal("empty-token FindByRoute should not resolve an unknown route")
}
// loadByCompatToken must early-return for an empty token even with a non-nil
// pool, so it can never issue a full-table query. Use a closed pool so any
// query attempt would error; reaching the early return means no query ran.
if dsn := os.Getenv("SILO_TEST_DATABASE_URL"); dsn != "" {
pool := newCompatTestPool(t)
s := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
// Should be a no-op (no panic, no scan); cache stays empty.
s.loadByCompatToken("")
if _, _, ok := s.FindByRoute("", "anything"); ok {
t.Fatal("empty-token FindByRoute resolved unexpectedly against DB")
}
}
}
// M6: an Update must not lose a concurrent writer's field. We simulate two
// interleaved writers that each mutate a different field; after both commit, the
// DB-authoritative row (reloaded on a fresh instance) must carry BOTH fields,
// proving the second writer merged onto the first's committed row rather than
// clobbering it with a stale whole-document upsert.
func TestDurableCompatPlaybackStore_UpdateAtomicNoLostField(t *testing.T) {
pool := newCompatTestPool(t)
ctx := context.Background()
id := fmt.Sprintf("compat-atomic-%d", time.Now().UnixNano())
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM jellycompat_playback_sessions WHERE id = $1`, id) })
seed := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
seed.Put(PlaybackSession{ID: id, CompatToken: "tok", UserID: "u1"})
// Writer A (its own cache) sets TranscodeStarted.
writerA := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
if err := writerA.Update(id, func(s *PlaybackSession) error {
s.TranscodeStarted = true
return nil
}); err != nil {
t.Fatalf("writerA update: %v", err)
}
// Writer B started before A committed (its cache lacks A's field) sets a
// different field. Its DB step re-reads A's committed row FOR UPDATE and
// merges UpstreamPlayMethod on top, so A's TranscodeStarted survives.
writerB := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
if err := writerB.Update(id, func(s *PlaybackSession) error {
s.UpstreamPlayMethod = "Transcode"
return nil
}); err != nil {
t.Fatalf("writerB update: %v", err)
}
fresh := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
got, ok := fresh.Get(id)
if !ok {
t.Fatal("session missing after interleaved updates")
}
if !got.TranscodeStarted {
t.Fatalf("writerA's field was lost: %+v", got)
}
if got.UpstreamPlayMethod != "Transcode" {
t.Fatalf("writerB's field was lost: %+v", got)
}
}
// M11: the DB expiry filter must honor the injected clock, not Postgres now().
// A row written with a near-future expiry is visible while the fake clock is
// before it and invisible once the fake clock advances past it, even though
// Postgres wall-clock now() never moves enough to matter.
func TestDurableCompatPlaybackStore_InjectedClockExpiry(t *testing.T) {
pool := newCompatTestPool(t)
ctx := context.Background()
id := fmt.Sprintf("compat-clock-%d", time.Now().UnixNano())
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM jellycompat_playback_sessions WHERE id = $1`, id) })
base := time.Now()
fake := base
clock := func() time.Time { return fake }
// TTL 1m; row expires at base+1m by the injected clock.
store := NewDurableCompatPlaybackStore(pool, time.Minute, clock)
store.Put(PlaybackSession{ID: id, CompatToken: "tok", UserID: "u1", RouteItemID: "r1"})
// Fresh instance, empty cache, same injected clock: load hits the DB and the
// row is live (fake clock still at base).
reader := NewDurableCompatPlaybackStore(pool, time.Minute, clock)
if _, ok := reader.Get(id); !ok {
t.Fatal("row should be live by injected clock before expiry")
}
// Advance the fake clock past expiry. The DB filter uses d.now(), so a fresh
// instance must treat the row as expired even though wall-clock now() is still
// far before base+1m.
fake = base.Add(2 * time.Minute)
expired := NewDurableCompatPlaybackStore(pool, time.Minute, clock)
if _, ok := expired.Get(id); ok {
t.Fatal("row should be expired by injected clock past expiry")
}
if _, _, ok := expired.FindByRoute("tok", "r1"); ok {
t.Fatal("FindByRoute should not resolve an injected-clock-expired row")
}
}
// With a nil pool the durable store degrades to the in-memory cache only, so it
// still satisfies the interface and basic operations work (no DB available).
func TestDurableCompatPlaybackStore_NilPoolInMemory(t *testing.T) {
store := NewDurableCompatPlaybackStore(nil, time.Hour, nil)
store.Put(PlaybackSession{ID: "x", UpstreamSessionID: "u"})
if got, ok := store.Get("x"); !ok || got.UpstreamSessionID != "u" {
t.Fatalf("nil-pool Get failed: ok=%v got=%+v", ok, got)
}
store.Delete("x")
if _, ok := store.Get("x"); ok {
t.Fatal("nil-pool Delete failed")
}
}
func TestPlaybackSessionStoreTerminalClaimIsAtomicAndOwnerScoped(t *testing.T) {
store := NewPlaybackSessionStore(time.Hour, nil)
store.Put(PlaybackSession{ID: "play-1", CompatToken: "owner", UpstreamSessionID: "upstream-1"})
event := watchsync.ScrobbleEvent{PlaybackSessionID: "upstream-1"}
if _, err := store.StageTerminal("play-1", "other", event, true); err == nil {
t.Fatal("foreign token staged terminal event")
}
if _, err := store.StageTerminal("play-1", "owner", event, true); err != nil {
t.Fatal("owner failed to stage terminal event")
}
claimUntil := time.Now().Add(time.Minute)
if _, err := store.ClaimTerminal("play-1", "other", claimUntil); err == nil {
t.Fatal("foreign token claimed terminal event")
}
got, err := store.ClaimTerminal("play-1", "owner", claimUntil)
if err != nil || got.UpstreamSessionID != "upstream-1" {
t.Fatalf("owner claim failed: err=%v session=%+v", err, got)
}
if _, err := store.ClaimTerminal("play-1", "owner", claimUntil.Add(time.Minute)); err == nil {
t.Fatal("terminal event was claimed twice")
}
}
func TestPlaybackSessionStoreDeactivateRetainsOnlyFinalReportLookup(t *testing.T) {
store := NewPlaybackSessionStore(time.Hour, nil)
store.Put(PlaybackSession{
ID: "play-1",
CompatToken: "owner",
ClientPlaySessionID: "client-play-1",
UpstreamSessionID: "upstream-1",
})
event := watchsync.ScrobbleEvent{PlaybackSessionID: "upstream-1"}
terminal, err := store.StageTerminal("play-1", "owner", event, true)
if err != nil || !terminal.Terminal {
t.Fatalf("terminal stage failed: err=%v session=%+v", err, terminal)
}
if _, ok := store.Get("play-1"); ok {
t.Fatal("terminal session remained available to ordinary lookup")
}
if _, ok := store.FindByClientPlaySessionID("owner", "client-play-1"); ok {
t.Fatal("terminal session remained available to ordinary alias lookup")
}
if _, ok := store.GetFinalizable("play-1", "owner"); !ok {
t.Fatal("terminal session was unavailable to final report lookup")
}
if _, ok := store.FindFinalizableByClientPlaySessionID("owner", "client-play-1", "", ""); !ok {
t.Fatal("terminal session was unavailable to final alias lookup")
}
claimUntil := time.Now().Add(time.Minute)
claimed, err := store.ClaimTerminal("play-1", "owner", claimUntil)
if err != nil || !claimed.Terminal {
t.Fatalf("final report could not claim terminal session: err=%v session=%+v", err, claimed)
}
store.CompleteTerminal("play-1", "owner", claimUntil, claimed.TerminalClaimVersion)
if _, ok := store.GetFinalizable("play-1", "owner"); ok {
t.Fatal("authoritatively completed terminal session was retained")
}
}
func TestPlaybackSessionStoreFinalAliasLookupDisambiguatesReusedAlias(t *testing.T) {
store := NewPlaybackSessionStore(time.Hour, nil)
store.Put(PlaybackSession{
ID: "old-play",
CompatToken: "owner",
ClientPlaySessionID: "reused-client-play",
RouteItemID: "old-item",
MediaSources: []PlaybackMediaSource{{ID: "old-source"}},
UpstreamSessionID: "old-upstream",
})
if _, err := store.StageTerminal(
"old-play",
"owner",
watchsync.ScrobbleEvent{PlaybackSessionID: "old-upstream"},
false,
); err != nil {
t.Fatalf("stage old terminal play: %v", err)
}
store.Put(PlaybackSession{
ID: "current-play",
CompatToken: "owner",
ClientPlaySessionID: "reused-client-play",
RouteItemID: "current-item",
MediaSources: []PlaybackMediaSource{{ID: "current-source"}},
UpstreamSessionID: "current-upstream",
})
if _, ok := store.FindFinalizableByClientPlaySessionID(
"owner", "reused-client-play", "", "",
); ok {
t.Fatal("unscoped reused alias unexpectedly selected an arbitrary play")
}
current, ok := store.FindFinalizableByClientPlaySessionID(
"owner", "reused-client-play", "current-item", "current-source",
)
if !ok || current.ID != "current-play" {
t.Fatalf("current report resolved to ok=%v session=%+v", ok, current)
}
old, ok := store.FindFinalizableByClientPlaySessionID(
"owner", "reused-client-play", "old-item", "old-source",
)
if !ok || old.ID != "old-play" {
t.Fatalf("late old report resolved to ok=%v session=%+v", ok, old)
}
oldByRoute, _, ok := store.FindFinalizableByRoute("owner", "old-source")
if !ok || oldByRoute.ID != "old-play" {
t.Fatalf("terminal route resolved to ok=%v session=%+v", ok, oldByRoute)
}
}
func TestPlaybackSessionStoreExpiredTerminalLeaseCanBeReclaimed(t *testing.T) {
now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
store := NewPlaybackSessionStore(time.Hour, func() time.Time { return now })
store.Put(PlaybackSession{ID: "play-1", CompatToken: "owner"})
event := watchsync.ScrobbleEvent{PlaybackSessionID: "upstream-1"}
if _, err := store.StageTerminal("play-1", "owner", event, true); err != nil {
t.Fatalf("stage terminal: %v", err)
}
firstLease := now.Add(10 * time.Second)
firstClaim, err := store.ClaimTerminal("play-1", "owner", firstLease)
if err != nil {
t.Fatalf("first claim: %v", err)
}
now = firstLease.Add(time.Microsecond)
secondLease := now.Add(10 * time.Second)
secondClaim, err := store.ClaimTerminal("play-1", "owner", secondLease)
if err != nil {
t.Fatalf("reclaim expired lease: %v", err)
}
store.CompleteTerminal("play-1", "owner", firstLease, firstClaim.TerminalClaimVersion)
if _, ok := store.GetFinalizable("play-1", "owner"); !ok {
t.Fatal("stale first lease completed the successor's terminal row")
}
store.CompleteTerminal("play-1", "owner", secondLease, secondClaim.TerminalClaimVersion)
if _, ok := store.GetFinalizable("play-1", "owner"); ok {
t.Fatal("successor lease did not complete terminal row")
}
}
func TestPlaybackSessionStorePendingScanEvictsExpiredTerminal(t *testing.T) {
now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
store := NewPlaybackSessionStore(time.Minute, func() time.Time { return now })
store.Put(PlaybackSession{ID: "play-1", CompatToken: "owner"})
if _, err := store.StageTerminal(
"play-1", "owner", watchsync.ScrobbleEvent{PlaybackSessionID: "upstream-1"}, false,
); err != nil {
t.Fatalf("stage terminal: %v", err)
}
now = now.Add(2 * time.Minute)
pending, err := store.ListPendingTerminals(context.Background(), 100)
if err != nil {
t.Fatalf("list pending terminals: %v", err)
}
if len(pending) != 0 {
t.Fatalf("expired pending terminals = %d, want 0", len(pending))
}
store.mu.RLock()
remaining := len(store.sessions)
store.mu.RUnlock()
if remaining != 0 {
t.Fatalf("expired terminal entries retained in memory = %d", remaining)
}
}
func TestDurableCompatPlaybackStoreExpiryClearsFailureBookkeeping(t *testing.T) {
now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
store := NewDurableCompatPlaybackStore(nil, time.Minute, func() time.Time { return now })
store.mem.Put(PlaybackSession{ID: "play-1", CompatToken: "owner"})
store.markUnpersisted("play-1")
store.appendPendingUpdate("play-1", "owner", func(*PlaybackSession) error { return nil })
now = now.Add(2 * time.Minute)
if _, err := store.DeleteExpired(context.Background()); err != nil {
t.Fatalf("delete expired: %v", err)
}
if store.isUnpersisted("play-1") {
t.Fatal("expired session retained its unpersisted marker")
}
if store.hasPendingUpdates("play-1") {
t.Fatal("expired session retained pending update closures")
}
}
func TestDurableCompatPlaybackStoreBoundsGenerationTombstones(t *testing.T) {
store := NewDurableCompatPlaybackStore(nil, time.Hour, nil)
before := store.idGenerationSnapshot("old-session")
for i := 0; i <= compatValidationCacheLimit; i++ {
store.bumpCacheGenerations(fmt.Sprintf("missing-%d", i), "")
}
store.generationMu.Lock()
count := len(store.idGenerations)
epoch := store.generationEpoch
store.generationMu.Unlock()
if count > compatValidationCacheLimit {
t.Fatalf("generation tombstones = %d, limit = %d", count, compatValidationCacheLimit)
}
if epoch == 0 || before == store.idGenerationSnapshot("old-session") {
t.Fatal("generation eviction did not invalidate an older captured stamp")
}
}
func TestDurableCompatPlaybackStoreIDWriteDoesNotRefreshTokenSnapshot(t *testing.T) {
now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
store := NewDurableCompatPlaybackStore(nil, time.Hour, func() time.Time { return now })
store.markTokenValidated("owner")
now = now.Add(4 * time.Second)
store.markIDValidated("play-2")
now = now.Add(2 * time.Second)
if !store.shouldRevalidateToken("owner") {
t.Fatal("single-row validation incorrectly extended the token-wide cache window")
}
}
func TestPlaybackSessionStoreNewAuthoritativeEventSurvivesStaleCompletion(t *testing.T) {
store := NewPlaybackSessionStore(time.Hour, nil)
store.Put(PlaybackSession{ID: "play-1", CompatToken: "owner"})
firstEvent := watchsync.ScrobbleEvent{PlaybackSessionID: "upstream-1", PositionSeconds: 45}
if _, err := store.StageTerminal("play-1", "owner", firstEvent, true); err != nil {
t.Fatalf("stage first event: %v", err)
}
firstLease := time.Now().Add(time.Minute)
firstClaim, err := store.ClaimTerminal("play-1", "owner", firstLease)
if err != nil {
t.Fatalf("claim first event: %v", err)
}
secondEvent := watchsync.ScrobbleEvent{PlaybackSessionID: "upstream-1", PositionSeconds: 90}
if _, err := store.StageTerminal("play-1", "owner", secondEvent, true); err != nil {
t.Fatalf("stage replacement event: %v", err)
}
store.CompleteTerminal("play-1", "owner", firstLease, firstClaim.TerminalClaimVersion)
store.ReleaseTerminalClaim("play-1", "owner", firstLease, firstClaim.TerminalClaimVersion, false)
pending, ok := store.GetFinalizable("play-1", "owner")
if !ok || pending.TerminalScrobbleEvent == nil || pending.TerminalScrobbleEvent.PositionSeconds != 90 {
t.Fatalf("replacement event was lost: ok=%v session=%+v", ok, pending)
}
secondLease := firstLease.Add(time.Minute)
secondClaim, err := store.ClaimTerminal("play-1", "owner", secondLease)
if err != nil {
t.Fatalf("claim replacement event: %v", err)
}
store.CompleteTerminal("play-1", "owner", secondLease, secondClaim.TerminalClaimVersion)
if _, ok := store.GetFinalizable("play-1", "owner"); ok {
t.Fatal("replacement event was not completed")
}
}
func TestDurableCompatPlaybackStoreTerminalClaimAcrossInstances(t *testing.T) {
pool := newCompatTestPool(t)
ctx := context.Background()
id := fmt.Sprintf("compat-take-%d", time.Now().UnixNano())
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM jellycompat_playback_sessions WHERE id = $1`, id) })
seed := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
seed.Put(PlaybackSession{ID: id, CompatToken: "owner", UpstreamSessionID: "upstream-1"})
skewedNow := time.Now().Add(-6 * time.Hour)
first := NewDurableCompatPlaybackStore(pool, time.Hour, func() time.Time { return skewedNow })
second := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
if _, ok := first.Get(id); !ok {
t.Fatal("first instance did not load session")
}
if _, ok := second.Get(id); !ok {
t.Fatal("second instance did not load session")
}
event := watchsync.ScrobbleEvent{PlaybackSessionID: "upstream-1"}
if _, err := seed.StageTerminal(id, "owner", event, true); err != nil {
t.Fatal("failed to stage durable terminal event")
}
var dbBefore time.Time
if err := pool.QueryRow(ctx, `SELECT clock_timestamp()`).Scan(&dbBefore); err != nil {
t.Fatalf("read database clock: %v", err)
}
claimUntil := skewedNow.Add(time.Minute)
claimed, err := first.ClaimTerminal(id, "owner", claimUntil)
if err != nil {
t.Fatal("first instance did not claim session")
}
var dbAfter time.Time
if err := pool.QueryRow(ctx, `SELECT clock_timestamp()`).Scan(&dbAfter); err != nil {
t.Fatalf("read database clock after claim: %v", err)
}
if claimed.TerminalClaimUntil.Before(dbBefore.Add(55*time.Second)) ||
claimed.TerminalClaimUntil.After(dbAfter.Add(65*time.Second)) {
t.Fatalf(
"claim deadline %s was not anchored to database clock interval [%s, %s]",
claimed.TerminalClaimUntil,
dbBefore.Add(55*time.Second),
dbAfter.Add(65*time.Second),
)
}
if _, err := second.ClaimTerminal(id, "owner", time.Now().Add(time.Minute)); err == nil {
t.Fatal("second instance claimed an already leased durable session")
}
}
func TestDurableCompatPlaybackStoreTerminalStageSurvivesInstanceBoundary(t *testing.T) {
pool := newCompatTestPool(t)
ctx := context.Background()
id := fmt.Sprintf("compat-deactivate-%d", time.Now().UnixNano())
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM jellycompat_playback_sessions WHERE id = $1`, id) })
now := time.Now()
clock := func() time.Time { return now }
seed := NewDurableCompatPlaybackStore(pool, time.Hour, clock)
seed.Put(PlaybackSession{
ID: id,
CompatToken: "owner",
ClientPlaySessionID: "client-play-1",
UpstreamSessionID: "upstream-1",
})
fresh := NewDurableCompatPlaybackStore(pool, time.Hour, clock)
if _, ok := fresh.Get(id); !ok {
t.Fatal("fresh instance did not preload the active session")
}
if _, ok := fresh.FindByClientPlaySessionID("owner", "client-play-1"); !ok {
t.Fatal("fresh instance did not preload the active alias")
}
event := watchsync.ScrobbleEvent{PlaybackSessionID: "upstream-1"}
if terminal, err := seed.StageTerminal(id, "owner", event, false); err != nil || !terminal.Terminal {
t.Fatalf("durable terminal stage failed: err=%v session=%+v", err, terminal)
}
// Cross-process invalidation is deliberately bounded rather than putting a
// DB query on every segment request. Advance past that window before the
// other instance revalidates its preloaded cache.
now = now.Add(compatCacheRevalidationInterval)
if _, ok := fresh.Get(id); ok {
t.Fatal("instance routed a terminal session from its stale active cache")
}
if _, ok := fresh.FindByClientPlaySessionID("owner", "client-play-1"); ok {
t.Fatal("instance routed a terminal alias from its stale active cache")
}
if _, ok := fresh.GetFinalizable(id, "owner"); !ok {
t.Fatal("fresh instance could not resolve terminal session for final report")
}
if _, ok := fresh.FindFinalizableByClientPlaySessionID("owner", "client-play-1", "", ""); !ok {
t.Fatal("fresh instance could not resolve terminal alias for final report")
}
claimUntil := time.Now().UTC().Truncate(time.Microsecond).Add(time.Minute)
claimed, err := fresh.ClaimTerminal(id, "owner", claimUntil)
if err != nil || !claimed.Terminal {
t.Fatalf("fresh instance could not claim terminal session: err=%v session=%+v", err, claimed)
}
fresh.ReleaseTerminalClaim(id, "owner", claimed.TerminalClaimUntil, claimed.TerminalClaimVersion, true)
if claimed, err := fresh.ClaimTerminal(id, "owner", claimUntil.Add(time.Minute)); err == nil || claimed != nil {
t.Fatalf("delivered fallback was claimed again: err=%v session=%+v", err, claimed)
}
}
func TestDurableCompatPlaybackStorePreservesCachedSessionOnValidationFailure(t *testing.T) {
pool := newCompatTestPool(t)
now := time.Now()
clock := func() time.Time { return now }
store := NewDurableCompatPlaybackStore(pool, time.Hour, clock)
pool.Close()
store.Put(PlaybackSession{ID: "cached-session", CompatToken: "owner"})
// A request inside the validation window must be served entirely from cache.
if _, ok := store.Get("cached-session"); !ok {
t.Fatal("hot cache lookup consulted the closed database")
}
// Once validation is due, the failed query must not evict a last-known-good
// active session and interrupt playback.
now = now.Add(compatCacheRevalidationInterval)
if _, ok := store.Get("cached-session"); !ok {
t.Fatal("database validation failure evicted the cached active session")
}
}
func TestDurableCompatPlaybackStoreRepairsFailedInitialPersistence(t *testing.T) {
pool := newCompatTestPool(t)
ctx := context.Background()
id := fmt.Sprintf("compat-repair-%d", time.Now().UnixNano())
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM jellycompat_playback_sessions WHERE id = $1`, id) })
now := time.Now()
clock := func() time.Time { return now }
store := NewDurableCompatPlaybackStore(pool, time.Hour, clock)
stored := store.mem.putNormalized(PlaybackSession{ID: id, CompatToken: "owner", UpstreamSessionID: "upstream-1"})
store.markIDValidated(id)
store.markUnpersisted(id)
now = now.Add(compatCacheRevalidationInterval)
if got, ok := store.Get(id); !ok || got.UpstreamSessionID != "upstream-1" {
t.Fatalf("unpersisted cache entry was not retained and repaired: ok=%v session=%+v", ok, got)
}
if store.isUnpersisted(id) {
t.Fatal("successfully repaired session remained marked unpersisted")
}
fresh := NewDurableCompatPlaybackStore(pool, time.Hour, clock)
if got, ok := fresh.Get(stored.ID); !ok || got.UpstreamSessionID != "upstream-1" {
t.Fatalf("repaired session was not durable: ok=%v session=%+v", ok, got)
}
}
func TestDurableCompatPlaybackStorePreservesUnpersistedTerminalOnRevalidation(t *testing.T) {
pool := newCompatTestPool(t)
id := fmt.Sprintf("compat-unpersisted-terminal-%d", time.Now().UnixNano())
now := time.Now()
clock := func() time.Time { return now }
store := NewDurableCompatPlaybackStore(pool, time.Hour, clock)
store.mem.Put(PlaybackSession{ID: id, CompatToken: "owner"})
if err := store.mem.HideFromRouting(id, "owner"); err != nil {
t.Fatalf("hide terminal: %v", err)
}
store.markUnpersisted(id)
store.markIDValidated(id)
now = now.Add(compatCacheRevalidationInterval)
if _, ok := store.Get(id); ok {
t.Fatal("terminal session became routable during missing-row revalidation")
}
terminal, ok := store.GetFinalizable(id, "owner")
if !ok || !terminal.Terminal || !store.isUnpersisted(id) {
t.Fatalf("unpersisted terminal state was lost: ok=%v session=%+v", ok, terminal)
}
}
func TestDurableCompatPlaybackStoreDiscardsTokenSnapshotAfterLocalMutation(t *testing.T) {
store := NewDurableCompatPlaybackStore(nil, time.Hour, nil)
generation := store.tokenGenerationSnapshot("owner")
store.cacheMutationMu.Lock()
store.mem.Put(PlaybackSession{ID: "new-session", CompatToken: "owner", RouteItemID: "route-1"})
store.bumpCacheGenerations("new-session", "owner")
store.cacheMutationMu.Unlock()
if store.applyCompatTokenSnapshot("owner", nil, generation) {
t.Fatal("stale token snapshot applied after a concurrent local mutation")
}
if _, _, ok := store.mem.FindByRoute("owner", "route-1"); !ok {
t.Fatal("stale token snapshot deleted the concurrent local session")
}
}
func TestDurableCompatPlaybackStoreAppliesTokenSnapshotAfterUnrelatedMutation(t *testing.T) {
store := NewDurableCompatPlaybackStore(nil, time.Hour, nil)
store.mem.Put(PlaybackSession{ID: "stale-session", CompatToken: "owner", RouteItemID: "route-1"})
generation := store.tokenGenerationSnapshot("owner")
store.cacheMutationMu.Lock()
store.mem.Put(PlaybackSession{ID: "other-session", CompatToken: "other"})
store.bumpCacheGenerations("other-session", "other")
store.cacheMutationMu.Unlock()
if !store.applyCompatTokenSnapshot("owner", nil, generation) {
t.Fatal("unrelated local mutation incorrectly invalidated the token snapshot")
}
if _, _, ok := store.mem.FindByRoute("owner", "route-1"); ok {
t.Fatal("valid token snapshot was not applied after unrelated mutation")
}
}
func TestDurableCompatPlaybackStoreDoesNotReviveLocalTerminalFromActiveSnapshot(t *testing.T) {
store := NewDurableCompatPlaybackStore(nil, time.Hour, nil)
active := PlaybackSession{ID: "play-1", CompatToken: "owner", RouteItemID: "route-1"}
store.mem.Put(active)
if err := store.mem.HideFromRouting("play-1", "owner"); err != nil {
t.Fatalf("hide local terminal: %v", err)
}
generation := store.tokenGenerationSnapshot("owner")
if !store.applyCompatTokenSnapshot("owner", []PlaybackSession{active}, generation) {
t.Fatal("active durable snapshot was unexpectedly discarded")
}
if _, ok := store.mem.Get("play-1"); ok {
t.Fatal("active durable snapshot revived a locally terminal session")
}
if terminal, ok := store.mem.GetFinalizable("play-1", "owner"); !ok || !terminal.Terminal {
t.Fatalf("local terminal marker was lost: ok=%v session=%+v", ok, terminal)
}
}
func TestDurableCompatPlaybackStorePreservesPendingUpdateFromOlderSnapshot(t *testing.T) {
store := NewDurableCompatPlaybackStore(nil, time.Hour, nil)
local := PlaybackSession{ID: "play-1", CompatToken: "owner", UpstreamSessionID: "upstream-new"}
store.mem.Put(local)
store.appendPendingUpdate("play-1", "owner", func(session *PlaybackSession) error {
session.UpstreamSessionID = "upstream-new"
return nil
})
generation := store.tokenGenerationSnapshot("owner")
durable := local
durable.UpstreamSessionID = "upstream-old"
if !store.applyCompatTokenSnapshot("owner", []PlaybackSession{durable}, generation) {
t.Fatal("durable snapshot was unexpectedly discarded")
}
if got, ok := store.mem.Get("play-1"); !ok || got.UpstreamSessionID != "upstream-new" {
t.Fatalf("pending local update was overwritten: ok=%v session=%+v", ok, got)
}
}
func TestDurableCompatPlaybackStoreDropsPendingSessionMissingFromSnapshot(t *testing.T) {
store := NewDurableCompatPlaybackStore(nil, time.Hour, nil)
store.mem.Put(PlaybackSession{ID: "play-1", CompatToken: "owner", RouteItemID: "route-1"})
store.appendPendingUpdate("play-1", "owner", func(session *PlaybackSession) error {
session.UpstreamSessionID = "upstream-new"
return nil
})
generation := store.tokenGenerationSnapshot("owner")
if !store.applyCompatTokenSnapshot("owner", nil, generation) {
t.Fatal("durable deletion snapshot was unexpectedly discarded")
}
if _, _, ok := store.mem.FindByRoute("owner", "route-1"); ok {
t.Fatal("missing durable row remained routable due to a pending update")
}
if store.hasPendingUpdates("play-1") {
t.Fatal("pending update survived authoritative durable deletion")
}
}
func TestDurableCompatPlaybackStoreTokenSnapshotKeepsOtherTokenPendingUpdates(t *testing.T) {
store := NewDurableCompatPlaybackStore(nil, time.Hour, nil)
store.mem.Put(PlaybackSession{ID: "play-a", CompatToken: "owner-a"})
store.mem.Put(PlaybackSession{ID: "play-b", CompatToken: "owner-b"})
store.appendPendingUpdate("play-b", "owner-b", func(session *PlaybackSession) error {
session.UpstreamSessionID = "upstream-b"
return nil
})
generation := store.tokenGenerationSnapshot("owner-a")
if !store.applyCompatTokenSnapshot(
"owner-a", []PlaybackSession{{ID: "play-a", CompatToken: "owner-a"}}, generation,
) {
t.Fatal("token A snapshot was unexpectedly discarded")
}
if !store.hasPendingUpdates("play-b") {
t.Fatal("token A snapshot discarded token B's pending update")
}
}
func TestDurableCompatPlaybackStoreSerializesSameSessionMutations(t *testing.T) {
store := NewDurableCompatPlaybackStore(nil, time.Hour, nil)
unlock := store.lockSessionMutation("play-1")
acquired := make(chan struct{})
released := make(chan struct{})
go func() {
secondUnlock := store.lockSessionMutation("play-1")
close(acquired)
<-released
secondUnlock()
}()
select {
case <-acquired:
t.Fatal("same-session mutation lock was acquired concurrently")
case <-time.After(20 * time.Millisecond):
}
unlock()
select {
case <-acquired:
close(released)
case <-time.After(time.Second):
t.Fatal("same-session mutation did not resume after release")
}
}
func TestDurableCompatPlaybackStorePersistsTerminalShell(t *testing.T) {
pool := newCompatTestPool(t)
ctx := context.Background()
id := fmt.Sprintf("compat-terminal-shell-%d", time.Now().UnixNano())
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM jellycompat_playback_sessions WHERE id = $1`, id) })
store := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
store.Put(PlaybackSession{ID: id, CompatToken: "owner", UpstreamSessionID: "upstream-1"})
if err := store.HideFromRouting(id, "owner"); err != nil {
t.Fatalf("persist terminal shell: %v", err)
}
fresh := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
if _, ok := fresh.Get(id); ok {
t.Fatal("fresh process routed a durably hidden terminal shell")
}
terminal, ok := fresh.GetFinalizable(id, "owner")
if !ok || !terminal.Terminal || terminal.TerminalScrobbleEvent != nil {
t.Fatalf("terminal shell = ok=%v session=%+v", ok, terminal)
}
pending, err := fresh.ListPendingTerminals(context.Background(), 100)
if err != nil {
t.Fatalf("list terminal shells: %v", err)
}
if len(pending) != 0 {
t.Fatalf("terminal shell appeared as a pending event: %+v", pending)
}
claimUntil := time.Now().UTC().Truncate(time.Microsecond).Add(time.Minute)
if claimed, err := fresh.ClaimTerminal(id, "owner", claimUntil); err == nil || claimed != nil {
t.Fatalf("terminal shell without an event was claimable: err=%v session=%+v", err, claimed)
}
}
func TestDurableCompatPlaybackStoreTerminalizesConflictingUnpersistedShell(t *testing.T) {
pool := newCompatTestPool(t)
ctx := context.Background()
id := fmt.Sprintf("compat-terminal-conflict-%d", time.Now().UnixNano())
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM jellycompat_playback_sessions WHERE id = $1`, id) })
seed := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
seed.Put(PlaybackSession{ID: id, CompatToken: "owner", UpstreamSessionID: "upstream-1"})
local := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
local.mem.Put(PlaybackSession{ID: id, CompatToken: "owner", UpstreamSessionID: "upstream-1"})
local.markUnpersisted(id)
if err := local.HideFromRouting(id, "owner"); err != nil {
t.Fatalf("hide conflicting terminal shell: %v", err)
}
fresh := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
if _, ok := fresh.Get(id); ok {
t.Fatal("insert conflict left the durable session routable")
}
}
func TestDurableCompatPlaybackStoreReplaysPendingUpdates(t *testing.T) {
pool := newCompatTestPool(t)
ctx := context.Background()
id := fmt.Sprintf("compat-pending-update-%d", time.Now().UnixNano())
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM jellycompat_playback_sessions WHERE id = $1`, id) })
store := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
store.Put(PlaybackSession{ID: id, CompatToken: "owner", UpstreamSessionID: "upstream-old"})
if err := store.mem.Update(id, func(session *PlaybackSession) error {
session.UpstreamSessionID = "upstream-new"
return nil
}); err != nil {
t.Fatalf("seed pending cache update: %v", err)
}
store.appendPendingUpdate(id, "owner", func(session *PlaybackSession) error {
session.UpstreamSessionID = "upstream-new"
return nil
})
if err := store.Update(id, func(session *PlaybackSession) error {
session.TranscodeStarted = true
return nil
}); err != nil {
t.Fatalf("update with pending replay: %v", err)
}
if store.hasPendingUpdates(id) {
t.Fatal("successfully replayed update remained pending")
}
fresh := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
got, ok := fresh.Get(id)
if !ok || got.UpstreamSessionID != "upstream-new" || !got.TranscodeStarted {
t.Fatalf("durable replay lost updates: ok=%v session=%+v", ok, got)
}
}
func TestDurableCompatPlaybackStoreColdLookupIgnoresReservedThrottle(t *testing.T) {
pool := newCompatTestPool(t)
ctx := context.Background()
id := fmt.Sprintf("compat-cold-load-%d", time.Now().UnixNano())
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM jellycompat_playback_sessions WHERE id = $1`, id) })
seed := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
seed.Put(PlaybackSession{ID: id, CompatToken: "owner", RouteItemID: "route-1"})
fresh := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
_ = fresh.shouldRevalidateID(id)
_ = fresh.shouldRevalidateToken("owner")
if _, ok := fresh.Get(id); !ok {
t.Fatal("cold ID lookup treated an in-flight validation reservation as not-found")
}
other := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
_ = other.shouldRevalidateToken("owner")
if _, _, ok := other.FindByRoute("owner", "route-1"); !ok {
t.Fatal("cold token lookup treated an in-flight validation reservation as not-found")
}
}