* feat(playback): plan v3 routes from pooled node capabilities Protocol v3 planning previously gated every server transformation on the API host's local ffmpeg probe, so deployments whose toolchain lives on transcode nodes (libx264/aac/dovi_rpu on nodes, minimal binary locally) received conversion terminals before transport preparation ever consulted the selected node's capabilities. Planning now draws on two registries split by executor pool: - Registry stays the local probe and keeps gating progressive remux routes, which execute in this process and can never offload. - HLSRegistry widens availability for HLS deliveries with the pooled transcode nodes' advertised transformations (name and recipe version pinned to the local specs), fetched concurrently under a short planning deadline through the existing TTL cache. Failures are now negatively cached so an unreachable node costs one timeout per window rather than one per start. The remux family picks the executor per branch: a recipe needing transformations only nodes carry skips the progressive remux and ships the same recipe on the HLS remux delivery instead. The local-fallback path in prepareTransportV3 now validates plans against the local registry's advertised set — mirroring the per-node validation — and returns the existing retryable transcode_node_capability_unavailable terminal when no executor can run the recipe, instead of spawning an ffmpeg that would fail at runtime. Deferred from PR #398 review (comment 3579105380). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): harden union capability planning from review Addresses all four review findings on the capability-union feature: - Select capability-matching nodes: plans carrying server transformations now restrict node selection to nodes whose advertised capabilities validate against the plan (nodepool.PlanSessionWith with a set-lookup predicate), so heterogeneous pools cannot load-balance a recipe onto a node that would reject it while a capable sibling exists. Transformation-free plans keep pure load-based selection. - Split the capability cache by consumer: planning honors negatively cached fetch failures (one timeout per window), while the transport path fetches through them — a memoized 3s planning deadline must not reject an already-selected node that the 10s transport budget could still validate. - Gate node-widened availability on the HLS engine: a progressive-only client that needs audio conversion keeps its specific retryable audio_conversion_unsupported terminal instead of falling through to a non-retryable adaptation_unavailable for routes it can never run; the DV strip union flag is gated identically. - Make HLSRegistry a lazy, memoized producer: the planner only builds the widened registry when a route decision depends on node capabilities, so direct-play and other source-preserving starts never wait on node capability fetches (or their dead-node deadlines). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
512 lines
18 KiB
Go
512 lines
18 KiB
Go
package nodepool
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func strPtr(s string) *string { return &s }
|
|
func intPtr(i int) *int { return &i }
|
|
|
|
type plannerFixture struct {
|
|
planner *Planner
|
|
proxies *ProxyPool
|
|
transcodes *TranscodePool
|
|
now time.Time
|
|
}
|
|
|
|
func newFixture(proxies, transcodes []*Node) *plannerFixture {
|
|
pp := NewProxyPool()
|
|
pp.SetNodes(proxies)
|
|
tp := NewTranscodePool()
|
|
tp.SetNodes(transcodes)
|
|
f := &plannerFixture{
|
|
planner: NewPlanner(pp, tp),
|
|
proxies: pp,
|
|
transcodes: tp,
|
|
now: time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC),
|
|
}
|
|
f.planner.now = func() time.Time { return f.now }
|
|
return f
|
|
}
|
|
|
|
func proxyNode(id int, url string, group *string) *Node {
|
|
return &Node{ID: id, Name: url, Type: NodeTypeProxy, URL: url, Enabled: true, Healthy: true, Group: group}
|
|
}
|
|
|
|
func transcodeNode(id int, url string, group *string, activeJobs int) *Node {
|
|
return &Node{ID: id, Name: url, Type: NodeTypeTranscode, URL: url, Enabled: true, Healthy: true, Group: group, ActiveJobs: activeJobs}
|
|
}
|
|
|
|
func TestPlanTranscodePairsProxyFromSameGroup(t *testing.T) {
|
|
f := newFixture(
|
|
[]*Node{
|
|
proxyNode(1, "http://proxy-a", strPtr("rack-a")),
|
|
proxyNode(2, "http://proxy-b", strPtr("rack-b")),
|
|
},
|
|
[]*Node{
|
|
transcodeNode(3, "http://tc-a", strPtr("rack-a"), 5),
|
|
transcodeNode(4, "http://tc-b", strPtr("rack-b"), 0),
|
|
},
|
|
)
|
|
|
|
plan := f.planner.PlanSession("s1", "", true, 0)
|
|
if plan.TranscodeNode == nil || plan.TranscodeNode.URL != "http://tc-b" {
|
|
t.Fatalf("expected least-loaded tc-b, got %+v", plan.TranscodeNode)
|
|
}
|
|
if plan.ProxyNode == nil || plan.ProxyNode.URL != "http://proxy-b" {
|
|
t.Fatalf("expected same-group proxy-b, got %+v", plan.ProxyNode)
|
|
}
|
|
}
|
|
|
|
func TestPlanSessionWithRestrictsEligibleTranscodeNodes(t *testing.T) {
|
|
f := newFixture(nil, []*Node{
|
|
transcodeNode(1, "http://tc-a", nil, 0),
|
|
transcodeNode(2, "http://tc-b", nil, 5),
|
|
})
|
|
eligible := func(n *Node) bool { return n != nil && n.URL == "http://tc-b" }
|
|
|
|
plan := f.planner.PlanSessionWith("s1", "", true, 0, eligible)
|
|
if plan.TranscodeNode == nil || plan.TranscodeNode.URL != "http://tc-b" {
|
|
t.Fatalf("expected the eligible node despite its higher load, got %+v", plan.TranscodeNode)
|
|
}
|
|
if none := f.planner.PlanSessionWith("s2", "", true, 0, func(*Node) bool { return false }); none.TranscodeNode != nil {
|
|
t.Fatalf("no eligible node must select nothing, got %+v", none.TranscodeNode)
|
|
}
|
|
// Soft affinity to the session's current node must not survive the
|
|
// current node becoming ineligible.
|
|
if sticky := f.planner.PlanSessionWith("s3", "http://tc-a", true, 0, eligible); sticky.TranscodeNode == nil || sticky.TranscodeNode.URL != "http://tc-b" {
|
|
t.Fatalf("affinity to an ineligible node must yield to an eligible one, got %+v", sticky.TranscodeNode)
|
|
}
|
|
if unrestricted := f.planner.PlanSessionWith("s4", "", true, 0, nil); unrestricted.TranscodeNode == nil || unrestricted.TranscodeNode.URL != "http://tc-a" {
|
|
t.Fatalf("nil predicate must behave like PlanSession, got %+v", unrestricted.TranscodeNode)
|
|
}
|
|
}
|
|
|
|
func TestReleaseSessionDropsProvisionalReservation(t *testing.T) {
|
|
node := transcodeNode(1, "http://tc-1", nil, 0)
|
|
node.MaxJobs = intPtr(1)
|
|
f := newFixture(nil, []*Node{node})
|
|
if got := f.planner.PlanSession("s1", "", true, 0).TranscodeNode; got == nil {
|
|
t.Fatal("first session was not reserved")
|
|
}
|
|
if got := f.planner.PlanSession("s2", "", true, 0).TranscodeNode; got != nil {
|
|
t.Fatalf("second session bypassed reservation: %+v", got)
|
|
}
|
|
f.planner.ReleaseSession("s1")
|
|
if got := f.planner.PlanSession("s2", "", true, 0).TranscodeNode; got == nil {
|
|
t.Fatal("released reservation still blocked the node")
|
|
}
|
|
}
|
|
|
|
func TestDegradedGroupExcludesItsTranscodeNodes(t *testing.T) {
|
|
unhealthyProxy := proxyNode(1, "http://proxy-a", strPtr("rack-a"))
|
|
unhealthyProxy.Healthy = false
|
|
f := newFixture(
|
|
[]*Node{
|
|
unhealthyProxy,
|
|
proxyNode(2, "http://proxy-b", strPtr("rack-b")),
|
|
},
|
|
[]*Node{
|
|
transcodeNode(3, "http://tc-a", strPtr("rack-a"), 0), // idle but group degraded
|
|
transcodeNode(4, "http://tc-b", strPtr("rack-b"), 9),
|
|
},
|
|
)
|
|
|
|
plan := f.planner.PlanSession("s1", "", true, 0)
|
|
if plan.TranscodeNode == nil || plan.TranscodeNode.URL != "http://tc-b" {
|
|
t.Fatalf("expected tc-b (rack-a degraded), got %+v", plan.TranscodeNode)
|
|
}
|
|
if plan.ProxyNode == nil || plan.ProxyNode.URL != "http://proxy-b" {
|
|
t.Fatalf("expected proxy-b, got %+v", plan.ProxyNode)
|
|
}
|
|
}
|
|
|
|
func TestUnhealthyTranscodeMemberDegradesGroup(t *testing.T) {
|
|
deadTC := transcodeNode(5, "http://tc-a2", strPtr("rack-a"), 0)
|
|
deadTC.Healthy = false
|
|
f := newFixture(
|
|
[]*Node{proxyNode(1, "http://proxy-a", strPtr("rack-a"))},
|
|
[]*Node{
|
|
transcodeNode(3, "http://tc-a1", strPtr("rack-a"), 0),
|
|
deadTC,
|
|
},
|
|
)
|
|
|
|
// All enabled members of a group must be healthy for the group to be
|
|
// eligible — even the healthy sibling is excluded.
|
|
plan := f.planner.PlanSession("s1", "", true, 0)
|
|
if plan.TranscodeNode != nil {
|
|
t.Fatalf("expected no transcode node, got %+v", plan.TranscodeNode)
|
|
}
|
|
}
|
|
|
|
func TestUngroupedNodesKeepLegacyBehavior(t *testing.T) {
|
|
f := newFixture(
|
|
[]*Node{
|
|
proxyNode(1, "http://proxy-1", nil),
|
|
proxyNode(2, "http://proxy-2", nil),
|
|
},
|
|
[]*Node{
|
|
transcodeNode(3, "http://tc-1", nil, 2),
|
|
transcodeNode(4, "http://tc-2", nil, 1),
|
|
},
|
|
)
|
|
|
|
plan := f.planner.PlanSession("s1", "", true, 0)
|
|
if plan.TranscodeNode == nil || plan.TranscodeNode.URL != "http://tc-2" {
|
|
t.Fatalf("expected least-connections tc-2, got %+v", plan.TranscodeNode)
|
|
}
|
|
if plan.ProxyNode == nil {
|
|
t.Fatal("expected a proxy node")
|
|
}
|
|
|
|
// Round-robin across both proxies for subsequent sessions.
|
|
first := plan.ProxyNode.URL
|
|
second := f.planner.PlanSession("s2", "", true, 0).ProxyNode.URL
|
|
if first == second {
|
|
t.Fatalf("expected round-robin to alternate proxies, got %s twice", first)
|
|
}
|
|
}
|
|
|
|
func TestGroupWithoutProxiesFallsBackToGlobalProxy(t *testing.T) {
|
|
f := newFixture(
|
|
[]*Node{proxyNode(1, "http://proxy-1", nil)},
|
|
[]*Node{transcodeNode(2, "http://tc-a", strPtr("rack-a"), 0)},
|
|
)
|
|
|
|
plan := f.planner.PlanSession("s1", "", true, 0)
|
|
if plan.TranscodeNode == nil || plan.TranscodeNode.URL != "http://tc-a" {
|
|
t.Fatalf("expected tc-a, got %+v", plan.TranscodeNode)
|
|
}
|
|
if plan.ProxyNode == nil || plan.ProxyNode.URL != "http://proxy-1" {
|
|
t.Fatalf("expected global proxy fallback, got %+v", plan.ProxyNode)
|
|
}
|
|
}
|
|
|
|
func TestSoftAffinityKeepsCurrentNode(t *testing.T) {
|
|
f := newFixture(
|
|
[]*Node{proxyNode(1, "http://proxy-1", nil)},
|
|
[]*Node{
|
|
transcodeNode(2, "http://tc-1", nil, 2),
|
|
transcodeNode(3, "http://tc-2", nil, 1),
|
|
},
|
|
)
|
|
|
|
// Difference of 1 job: stay on current.
|
|
plan := f.planner.PlanSession("s1", "http://tc-1", true, 0)
|
|
if plan.TranscodeNode == nil || plan.TranscodeNode.URL != "http://tc-1" {
|
|
t.Fatalf("expected soft affinity to keep tc-1, got %+v", plan.TranscodeNode)
|
|
}
|
|
|
|
// Difference of 2+: switch to the less-loaded node.
|
|
f.transcodes.Nodes()[0].ActiveJobs = 4
|
|
plan = f.planner.PlanSession("s1", "http://tc-1", true, 0)
|
|
if plan.TranscodeNode == nil || plan.TranscodeNode.URL != "http://tc-2" {
|
|
t.Fatalf("expected switch to tc-2, got %+v", plan.TranscodeNode)
|
|
}
|
|
}
|
|
|
|
func TestTranscodeCapSkipsFullNode(t *testing.T) {
|
|
capped := transcodeNode(2, "http://tc-1", nil, 3)
|
|
capped.MaxJobs = intPtr(3)
|
|
f := newFixture(
|
|
[]*Node{proxyNode(1, "http://proxy-1", nil)},
|
|
[]*Node{
|
|
capped,
|
|
transcodeNode(3, "http://tc-2", nil, 5),
|
|
},
|
|
)
|
|
|
|
plan := f.planner.PlanSession("s1", "", true, 0)
|
|
if plan.TranscodeNode == nil || plan.TranscodeNode.URL != "http://tc-2" {
|
|
t.Fatalf("expected at-cap tc-1 to be skipped, got %+v", plan.TranscodeNode)
|
|
}
|
|
|
|
// All nodes at cap: no transcode node.
|
|
f.transcodes.Nodes()[1].MaxJobs = intPtr(5)
|
|
plan = f.planner.PlanSession("s2", "", true, 0)
|
|
if plan.TranscodeNode != nil {
|
|
t.Fatalf("expected no eligible node, got %+v", plan.TranscodeNode)
|
|
}
|
|
}
|
|
|
|
func TestProxyCapSkipsFullProxy(t *testing.T) {
|
|
capped := proxyNode(1, "http://proxy-1", nil)
|
|
capped.MaxJobs = intPtr(2)
|
|
capped.ActiveJobs = 2
|
|
f := newFixture(
|
|
[]*Node{capped, proxyNode(2, "http://proxy-2", nil)},
|
|
[]*Node{},
|
|
)
|
|
|
|
for i := 0; i < 3; i++ {
|
|
plan := f.planner.PlanSession("s", "", false, 0)
|
|
if plan.ProxyNode == nil || plan.ProxyNode.URL != "http://proxy-2" {
|
|
t.Fatalf("expected proxy-2 (proxy-1 at cap), got %+v", plan.ProxyNode)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGroupAtProxyCapacityExcludesGroupTranscode(t *testing.T) {
|
|
groupProxy := proxyNode(1, "http://proxy-a", strPtr("rack-a"))
|
|
groupProxy.MaxJobs = intPtr(1)
|
|
groupProxy.ActiveJobs = 1
|
|
f := newFixture(
|
|
[]*Node{groupProxy, proxyNode(2, "http://proxy-1", nil)},
|
|
[]*Node{
|
|
transcodeNode(3, "http://tc-a", strPtr("rack-a"), 0),
|
|
transcodeNode(4, "http://tc-1", nil, 7),
|
|
},
|
|
)
|
|
|
|
// rack-a's only proxy is full, so its transcode node must not be used —
|
|
// streams pinned to rack-a would have nowhere to go.
|
|
plan := f.planner.PlanSession("s1", "", true, 0)
|
|
if plan.TranscodeNode == nil || plan.TranscodeNode.URL != "http://tc-1" {
|
|
t.Fatalf("expected ungrouped tc-1, got %+v", plan.TranscodeNode)
|
|
}
|
|
if plan.ProxyNode == nil || plan.ProxyNode.URL != "http://proxy-1" {
|
|
t.Fatalf("expected ungrouped proxy-1, got %+v", plan.ProxyNode)
|
|
}
|
|
}
|
|
|
|
func TestGroupProxyReservationsGateGroupCapacity(t *testing.T) {
|
|
groupProxy := proxyNode(1, "http://proxy-a", strPtr("rack-a"))
|
|
groupProxy.MaxJobs = intPtr(1)
|
|
f := newFixture(
|
|
[]*Node{groupProxy},
|
|
[]*Node{transcodeNode(2, "http://tc-a", strPtr("rack-a"), 0)},
|
|
)
|
|
|
|
// The first session reserves the group's only proxy slot.
|
|
plan := f.planner.PlanSession("s1", "", true, 0)
|
|
if plan.TranscodeNode == nil || plan.ProxyNode == nil {
|
|
t.Fatalf("first session should get both nodes, got %+v", plan)
|
|
}
|
|
|
|
// With the group's proxy fully reserved, its transcode node is
|
|
// ineligible too — streams pinned to the group would have nowhere to go.
|
|
plan = f.planner.PlanSession("s2", "", true, 0)
|
|
if plan.TranscodeNode != nil || plan.ProxyNode != nil {
|
|
t.Fatalf("second session should be rejected, got %+v", plan)
|
|
}
|
|
}
|
|
|
|
func TestReservationsCountTowardCaps(t *testing.T) {
|
|
capped := transcodeNode(2, "http://tc-1", nil, 0)
|
|
capped.MaxJobs = intPtr(2)
|
|
lastCheck := time.Date(2026, 6, 10, 11, 59, 0, 0, time.UTC)
|
|
capped.LastHealthCheck = &lastCheck
|
|
f := newFixture(
|
|
[]*Node{proxyNode(1, "http://proxy-1", nil)},
|
|
[]*Node{capped},
|
|
)
|
|
|
|
// Two sessions fill the cap via reservations before any health refresh.
|
|
if f.planner.PlanSession("s1", "", true, 0).TranscodeNode == nil {
|
|
t.Fatal("first session should be admitted")
|
|
}
|
|
if f.planner.PlanSession("s2", "", true, 0).TranscodeNode == nil {
|
|
t.Fatal("second session should be admitted")
|
|
}
|
|
if got := f.planner.PlanSession("s3", "", true, 0).TranscodeNode; got != nil {
|
|
t.Fatalf("third session should be rejected, got %+v", got)
|
|
}
|
|
|
|
// Re-planning an admitted session must not double-count it.
|
|
if f.planner.PlanSession("s2", "http://tc-1", true, 0).TranscodeNode == nil {
|
|
t.Fatal("re-plan of s2 should be admitted")
|
|
}
|
|
|
|
// A health report newer than the reservations becomes authoritative:
|
|
// the node now says 1 job, so one slot is free again.
|
|
newer := f.now.Add(10 * time.Second)
|
|
capped.LastHealthCheck = &newer
|
|
capped.ActiveJobs = 1
|
|
f.now = f.now.Add(20 * time.Second)
|
|
if f.planner.PlanSession("s4", "", true, 0).TranscodeNode == nil {
|
|
t.Fatal("session should be admitted after fresh health report")
|
|
}
|
|
}
|
|
|
|
func TestReservationsExpire(t *testing.T) {
|
|
capped := transcodeNode(2, "http://tc-1", nil, 0)
|
|
capped.MaxJobs = intPtr(1)
|
|
f := newFixture(
|
|
[]*Node{proxyNode(1, "http://proxy-1", nil)},
|
|
[]*Node{capped},
|
|
)
|
|
|
|
if f.planner.PlanSession("s1", "", true, 0).TranscodeNode == nil {
|
|
t.Fatal("first session should be admitted")
|
|
}
|
|
if got := f.planner.PlanSession("s2", "", true, 0).TranscodeNode; got != nil {
|
|
t.Fatalf("second session should be rejected, got %+v", got)
|
|
}
|
|
|
|
// Without health reports (LastHealthCheck nil) reservations still expire
|
|
// after maxReservationAge so a stalled health checker can't wedge admission.
|
|
f.now = f.now.Add(maxReservationAge + time.Second)
|
|
if f.planner.PlanSession("s3", "", true, 0).TranscodeNode == nil {
|
|
t.Fatal("session should be admitted after reservation expiry")
|
|
}
|
|
}
|
|
|
|
func TestDirectPlayIgnoresGroups(t *testing.T) {
|
|
f := newFixture(
|
|
[]*Node{proxyNode(1, "http://proxy-a", strPtr("rack-a"))},
|
|
[]*Node{},
|
|
)
|
|
|
|
plan := f.planner.PlanSession("s1", "", false, 0)
|
|
if plan.ProxyNode == nil || plan.ProxyNode.URL != "http://proxy-a" {
|
|
t.Fatalf("expected grouped proxy to serve direct play, got %+v", plan.ProxyNode)
|
|
}
|
|
if plan.TranscodeNode != nil {
|
|
t.Fatalf("direct play must not pick a transcode node, got %+v", plan.TranscodeNode)
|
|
}
|
|
}
|
|
|
|
func TestGroupRoundRobinAcrossGroupProxies(t *testing.T) {
|
|
f := newFixture(
|
|
[]*Node{
|
|
proxyNode(1, "http://proxy-a1", strPtr("rack-a")),
|
|
proxyNode(2, "http://proxy-a2", strPtr("rack-a")),
|
|
},
|
|
[]*Node{transcodeNode(3, "http://tc-a", strPtr("rack-a"), 0)},
|
|
)
|
|
|
|
seen := map[string]bool{}
|
|
for i, id := range []string{"s1", "s2"} {
|
|
plan := f.planner.PlanSession(id, "", true, 0)
|
|
if plan.ProxyNode == nil {
|
|
t.Fatalf("plan %d: expected a proxy", i)
|
|
}
|
|
seen[plan.ProxyNode.URL] = true
|
|
}
|
|
if len(seen) != 2 {
|
|
t.Fatalf("expected round-robin across both group proxies, saw %v", seen)
|
|
}
|
|
}
|
|
|
|
func TestNilPlannerReturnsEmptyPlan(t *testing.T) {
|
|
var p *Planner
|
|
plan := p.PlanSession("s1", "", true, 0)
|
|
if plan.TranscodeNode != nil || plan.ProxyNode != nil {
|
|
t.Fatalf("expected empty plan from nil planner, got %+v", plan)
|
|
}
|
|
}
|
|
|
|
func TestBandwidthCapSkipsSaturatedProxy(t *testing.T) {
|
|
saturated := proxyNode(1, "http://proxy-1", nil)
|
|
saturated.MaxBandwidthKbps = intPtr(100_000) // 100 Mbps
|
|
saturated.EgressKbps = 97_000
|
|
f := newFixture(
|
|
[]*Node{saturated, proxyNode(2, "http://proxy-2", nil)},
|
|
[]*Node{},
|
|
)
|
|
|
|
// A 6 Mbps stream doesn't fit in proxy-1's 3 Mbps of headroom.
|
|
for i := 0; i < 3; i++ {
|
|
plan := f.planner.PlanSession("s", "", false, 6_000)
|
|
if plan.ProxyNode == nil || plan.ProxyNode.URL != "http://proxy-2" {
|
|
t.Fatalf("expected proxy-2 (proxy-1 saturated), got %+v", plan.ProxyNode)
|
|
}
|
|
}
|
|
|
|
// A 2 Mbps stream still fits.
|
|
plan := f.planner.PlanSession("s2", "", false, 2_000)
|
|
if plan.ProxyNode == nil {
|
|
t.Fatal("expected a proxy for a stream that fits")
|
|
}
|
|
}
|
|
|
|
func TestBandwidthReservationsCountDuringBridge(t *testing.T) {
|
|
capped := proxyNode(1, "http://proxy-1", nil)
|
|
capped.MaxBandwidthKbps = intPtr(10_000)
|
|
f := newFixture([]*Node{capped}, []*Node{})
|
|
|
|
// Two 4 Mbps admissions fit; the third would exceed the 10 Mbps cap
|
|
// because the first two are still bridged as reservations.
|
|
if f.planner.PlanSession("s1", "", false, 4_000).ProxyNode == nil {
|
|
t.Fatal("first stream should be admitted")
|
|
}
|
|
if f.planner.PlanSession("s2", "", false, 4_000).ProxyNode == nil {
|
|
t.Fatal("second stream should be admitted")
|
|
}
|
|
if got := f.planner.PlanSession("s3", "", false, 4_000).ProxyNode; got != nil {
|
|
t.Fatalf("third stream should be rejected, got %+v", got)
|
|
}
|
|
|
|
// Unlike job reservations, bandwidth bridges ignore health freshness —
|
|
// a report right after admission would not reflect the streams yet.
|
|
newer := f.now.Add(5 * time.Second)
|
|
f.proxies.ApplyHealth(1, true, 0, 0, newer)
|
|
f.now = f.now.Add(10 * time.Second)
|
|
if got := f.planner.PlanSession("s4", "", false, 4_000).ProxyNode; got != nil {
|
|
t.Fatalf("stream should still be rejected during bridge window, got %+v", got)
|
|
}
|
|
|
|
// After the bridge window the measured egress is authoritative. The
|
|
// meter now reports 8 Mbps, so one more 4 Mbps stream still won't fit,
|
|
// but a 2 Mbps one will.
|
|
f.now = f.now.Add(bandwidthBridgeAge)
|
|
f.proxies.ApplyHealth(1, true, 0, 8_000, f.now)
|
|
if got := f.planner.PlanSession("s5", "", false, 4_000).ProxyNode; got != nil {
|
|
t.Fatalf("4 Mbps stream should not fit at 8/10 Mbps, got %+v", got)
|
|
}
|
|
if f.planner.PlanSession("s6", "", false, 2_000).ProxyNode == nil {
|
|
t.Fatal("2 Mbps stream should fit at 8/10 Mbps")
|
|
}
|
|
}
|
|
|
|
func TestGroupBandwidthGatesGroupTranscode(t *testing.T) {
|
|
groupProxy := proxyNode(1, "http://proxy-a", strPtr("rack-a"))
|
|
groupProxy.MaxBandwidthKbps = intPtr(10_000)
|
|
groupProxy.EgressKbps = 9_000
|
|
f := newFixture(
|
|
[]*Node{groupProxy, proxyNode(2, "http://proxy-1", nil)},
|
|
[]*Node{
|
|
transcodeNode(3, "http://tc-a", strPtr("rack-a"), 0),
|
|
transcodeNode(4, "http://tc-1", nil, 7),
|
|
},
|
|
)
|
|
|
|
// rack-a's proxy has no bandwidth headroom for a 4 Mbps stream, so the
|
|
// group's idle transcode node must be skipped.
|
|
plan := f.planner.PlanSession("s1", "", true, 4_000)
|
|
if plan.TranscodeNode == nil || plan.TranscodeNode.URL != "http://tc-1" {
|
|
t.Fatalf("expected ungrouped tc-1, got %+v", plan.TranscodeNode)
|
|
}
|
|
if plan.ProxyNode == nil || plan.ProxyNode.URL != "http://proxy-1" {
|
|
t.Fatalf("expected ungrouped proxy-1, got %+v", plan.ProxyNode)
|
|
}
|
|
|
|
// A 500 kbps stream fits and stays pinned to the group.
|
|
plan = f.planner.PlanSession("s2", "", true, 500)
|
|
if plan.TranscodeNode == nil || plan.TranscodeNode.URL != "http://tc-a" {
|
|
t.Fatalf("expected rack-a tc-a for small stream, got %+v", plan.TranscodeNode)
|
|
}
|
|
if plan.ProxyNode == nil || plan.ProxyNode.URL != "http://proxy-a" {
|
|
t.Fatalf("expected rack-a proxy, got %+v", plan.ProxyNode)
|
|
}
|
|
}
|
|
|
|
func TestUnknownBitrateAdmittedBelowCap(t *testing.T) {
|
|
p := proxyNode(1, "http://proxy-1", nil)
|
|
p.MaxBandwidthKbps = intPtr(10_000)
|
|
p.EgressKbps = 9_999
|
|
f := newFixture([]*Node{p}, []*Node{})
|
|
|
|
// Unknown bitrate (0): admitted while measured egress is below the cap.
|
|
if f.planner.PlanSession("s1", "", false, 0).ProxyNode == nil {
|
|
t.Fatal("unknown-bitrate stream should be admitted below cap")
|
|
}
|
|
|
|
f.proxies.ApplyHealth(1, true, 0, 10_000, f.now)
|
|
if got := f.planner.PlanSession("s2", "", false, 0).ProxyNode; got != nil {
|
|
t.Fatalf("unknown-bitrate stream should be rejected at cap, got %+v", got)
|
|
}
|
|
}
|