Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion server/remote_discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,13 @@ func (p *remoteDiscoveryPool) refresh() {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
if !p.lastRefresh.IsZero() && now.Sub(p.lastRefresh) <= p.refreshEvery {
// Rate-limit refreshes only once we hold a populated snapshot. While the cache
// is empty (e.g. right after startup, before the orchestrator pool's first poll
// has populated the node's network capabilities), keep re-deriving on every call
// so orchestrators surface as soon as they appear instead of being locked out for
// a full refresh interval. refresh() itself does no network I/O — it only reads
// the in-memory GetNetworkCapabilities() snapshot — so retrying while empty is cheap.
if len(p.cached) > 0 && !p.lastRefresh.IsZero() && now.Sub(p.lastRefresh) <= p.refreshEvery {
return
}

Expand Down
55 changes: 55 additions & 0 deletions server/remote_signer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1311,6 +1311,61 @@ func TestRemoteSigner_Discovery(t *testing.T) {
require.Equal("application/json", ineligibleRR.Header().Get("Content-Type"))
}

// TestRemoteSigner_Discovery_EmptyCacheRetriesBeforeInterval guards against the
// startup race where the first refresh runs before the orchestrator pool's poll has
// populated the node's network capabilities. An empty snapshot must not be locked in
// for a full refresh interval: while the cache is empty, every request re-derives, so
// orchestrators surface as soon as they appear rather than after -liveAICapReportInterval.
func TestRemoteSigner_Discovery_EmptyCacheRetriesBeforeInterval(t *testing.T) {
require := require.New(t)

capability := core.Capability_LiveVideoToVideo
modelID := "scope"
BroadcastCfg.SetCapabilityMaxPrice(capability, modelID, core.NewFixedPrice(big.NewRat(200, 995328000000)))
defer BroadcastCfg.SetCapabilityMaxPrice(capability, modelID, nil)

// Node starts with no network capabilities (pool poll has not populated the cache yet).
node := &core.LivepeerNode{}

// Long interval: with the bug, an empty first refresh would lock discovery out for an hour.
rdp := &remoteDiscoveryPool{
node: node,
refreshEvery: time.Hour,
}
ls := &LivepeerServer{}

// First request lands before the cache is populated: 503, cache empty.
emptyReq := httptest.NewRequest(http.MethodGet, "/discover-orchestrators?caps=live-video-to-video/scope", nil)
emptyRR := httptest.NewRecorder()
ls.GetOrchestrators(rdp, emptyRR, emptyReq)
require.Equal(http.StatusServiceUnavailable, emptyRR.Code)

// The orchestrator pool finishes its poll and populates network capabilities.
require.NoError(node.UpdateNetworkCapabilities([]*common.OrchNetworkCapabilities{
{
OrchURI: "https://late.example.com:8935",
Discovery: discoveryRaw(t, `[{
"address": "https://late.example.com:8935",
"runners": [
{"url":"https://late.example.com:8935/apps/runner/session","app":"live-video-to-video/scope","capacity":1,"price_info":{"price_per_unit":5,"pixels_per_unit":995328000000,"unit":"WEI"}}
]
}]`),
},
}))

// A follow-up request arrives well within refreshEvery. With the fix it re-derives
// (empty cache is not rate-limited) and returns the newly-registered orchestrator.
req := httptest.NewRequest(http.MethodGet, "/discover-orchestrators?caps=live-video-to-video/scope", nil)
rr := httptest.NewRecorder()
ls.GetOrchestrators(rdp, rr, req)

require.Equal(http.StatusOK, rr.Code)
var resp []discoveryResponse
require.NoError(json.NewDecoder(rr.Body).Decode(&resp))
require.Len(resp, 1)
require.Equal("https://late.example.com:8935", resp[0].Address)
}

func TestRemoteSigner_Discovery_UsesRunnerDiscoveryWhenRPCCapabilitiesMissing(t *testing.T) {
require := require.New(t)

Expand Down
Loading