Files
livekit/pkg/agent/endpoint/scope.go
T
Paul Wells 1944cb495d agent endpoints: scope the registry, let the embedder own tenancy
The registry was doing three jobs at once: fencing worker epochs by id,
listing a deployment's candidates, and holding its merged route table. The
last two are per-deployment state, so it keyed them on
(api key, agent name, deployment) and grew a tenancy concept that only an
embedder can actually define. Cloud has to lie to it, passing a project id
in a field named APIKey.

Split them. Scope is one deployment's serving state and stores no identity
at all; whoever embeds the package keys a map of scopes however its own
tenancy works, and hands the front a resolved one. Registry keeps only the
worker-id fence, which is genuinely node-wide: worker ids are server-issued,
so an epoch is superseded wherever it was scoped.

The front loses its registry, its SingleKeyFallback and FallbackRequest: the
resolver now returns the scope and a fallback already curried on the
deployment, plus an ok that carries the 401-vs-503 split the empty api key
used to encode. routeTable drops its key and takes the scope's logger, so
identity is curried in rather than stored.

pkg/service takes ownership of the "api key is the tenant" rule, which is
true there and nowhere else, and of releasing a scope once nothing holds it.
Behavior is unchanged, including serving public routes to an unauthenticated
caller when one configured key or one attached tenant makes the key
unambiguous.
2026-09-16 08:47:37 -07:00

124 lines
3.4 KiB
Go

// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package endpoint
import (
"slices"
"sync"
"github.com/livekit/protocol/logger"
)
// Scope is one deployment's serving state on this node: the registrations that
// hold it and their merged route table. Who a scope belongs to is the embedder's
// business - nothing here stores a tenancy identity, and the embedder owns the
// map that scopes it.
//
// Registrations and routes move together under lock, so a worker present in the
// candidate set always has its routes installed.
type Scope struct {
logger logger.Logger
lock sync.RWMutex
regs []*Registration
table *routeTable // nil while nothing declares a route
}
// NewScope takes a logger the embedder has already curried with whatever
// identifies the scope; the package adds no identity of its own.
func NewScope(l logger.Logger) *Scope {
return &Scope{logger: l}
}
// Candidates returns the registrations holding this scope. A nil scope holds
// none, so a caller that could not resolve one needs no separate test.
func (s *Scope) Candidates() []*Registration {
if s == nil {
return nil
}
s.lock.RLock()
defer s.lock.RUnlock()
return slices.Clone(s.regs)
}
// Empty reports that no registration holds this scope, so the embedder may drop
// it.
func (s *Scope) Empty() bool {
if s == nil {
return true
}
s.lock.RLock()
defer s.lock.RUnlock()
return len(s.regs) == 0
}
// routeTable returns the merged table, or nil when nothing declares a route. A
// scope whose last worker left must report nil rather than an empty tree: the
// front maps a missing table to "no worker for this deployment" (503) and an
// empty one to "no such path" (404).
func (s *Scope) routeTable() *routeTable {
if s == nil {
return nil
}
s.lock.RLock()
defer s.lock.RUnlock()
return s.table
}
// replace swaps one registration for another as a single transaction. Routes the
// removal empties are dropped only once the addition has run, so a registration
// replaced by an equivalent one keeps its Route pointers and the published tree
// stays live. Either side may be nil.
func (s *Scope) replace(remove, add *Registration) {
s.lock.Lock()
defer s.lock.Unlock()
if remove != nil {
s.unlinkLocked(remove)
}
if add != nil {
s.regs = append(s.regs, add)
}
s.mutateLocked(remove, add)
}
// remove drops a registration and its routes.
func (s *Scope) remove(r *Registration) {
s.lock.Lock()
defer s.lock.Unlock()
s.unlinkLocked(r)
s.mutateLocked(r, nil)
}
func (s *Scope) unlinkLocked(r *Registration) {
if i := slices.Index(s.regs, r); i != -1 {
s.regs = slices.Delete(s.regs, i, i+1)
}
}
// mutateLocked applies the route change, creating the table on first use and
// dropping it once it empties.
func (s *Scope) mutateLocked(remove, add *Registration) {
if s.table == nil {
if add == nil {
return
}
s.table = newRouteTable(s.logger)
}
s.table.mutate(remove, add)
if s.table.empty() {
s.table = nil
}
}