Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
|
|
@ -0,0 +1,85 @@
|
|||
package executionregistry
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type recordingReleaseSink struct {
|
||||
mu sync.Mutex
|
||||
sequences map[ReleaseGroup]int64
|
||||
}
|
||||
|
||||
func (s *recordingReleaseSink) MarkDirty(group ReleaseGroup, sequence int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.sequences == nil {
|
||||
s.sequences = make(map[ReleaseGroup]int64)
|
||||
}
|
||||
if sequence > s.sequences[group] {
|
||||
s.sequences[group] = sequence
|
||||
}
|
||||
}
|
||||
|
||||
func (s *recordingReleaseSink) Sequence(credentialID, model string) int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.sequences[ReleaseGroup{CredentialID: credentialID, Model: model}]
|
||||
}
|
||||
|
||||
func installAccountedScope(t *testing.T, registry *Registry, credentialID, model string) *Scope {
|
||||
t.Helper()
|
||||
pending, errBegin := registry.BeginDispatch()
|
||||
if errBegin != nil {
|
||||
t.Fatal(errBegin)
|
||||
}
|
||||
scope, errInstall := registry.Install(pending, ScopeSpec{CredentialID: credentialID, Model: model, Accounted: true})
|
||||
if errInstall != nil {
|
||||
t.Fatal(errInstall)
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
func TestRegistryEndMarksOneDirtyGroup(t *testing.T) {
|
||||
sink := &recordingReleaseSink{}
|
||||
registry := New()
|
||||
registry.SetReleaseSink(sink.MarkDirty)
|
||||
|
||||
scope := installAccountedScope(t, registry, "cred-1", "gpt")
|
||||
scope.End("complete")
|
||||
scope.End("duplicate")
|
||||
|
||||
if got := sink.Sequence("cred-1", "gpt"); got != 1 {
|
||||
t.Fatalf("release sequence = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnaccountedScopeDoesNotRelease(t *testing.T) {
|
||||
sink := &recordingReleaseSink{}
|
||||
registry := New()
|
||||
registry.SetReleaseSink(sink.MarkDirty)
|
||||
pending, errBegin := registry.BeginDispatch()
|
||||
if errBegin != nil {
|
||||
t.Fatal(errBegin)
|
||||
}
|
||||
scope, errInstall := registry.Install(pending, ScopeSpec{CredentialID: "cred-1", Model: "gpt", Accounted: false})
|
||||
if errInstall != nil {
|
||||
t.Fatal(errInstall)
|
||||
}
|
||||
scope.End("observation_complete")
|
||||
|
||||
if got := sink.Sequence("cred-1", "gpt"); got != 0 {
|
||||
t.Fatalf("release sequence = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetReleaseSinkReplaysExistingSequences(t *testing.T) {
|
||||
registry := New()
|
||||
installAccountedScope(t, registry, "cred-1", "gpt").End("complete")
|
||||
|
||||
sink := &recordingReleaseSink{}
|
||||
registry.SetReleaseSink(sink.MarkDirty)
|
||||
if got := sink.Sequence("cred-1", "gpt"); got != 1 {
|
||||
t.Fatalf("replayed release sequence = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
74
backend/sdk/cliproxy/executionregistry/observation.go
Normal file
74
backend/sdk/cliproxy/executionregistry/observation.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package executionregistry
|
||||
|
||||
import "time"
|
||||
|
||||
// Observation is an immutable in-flight execution snapshot entry.
|
||||
type Observation struct {
|
||||
RequestID string
|
||||
CredentialID string
|
||||
Model string
|
||||
RequestKind string
|
||||
StartedAt time.Time
|
||||
Accounted bool
|
||||
}
|
||||
|
||||
// Freeze is an immutable in-flight execution snapshot.
|
||||
type Freeze struct {
|
||||
Revision int64
|
||||
BarrierRevision int64
|
||||
Executions []Observation
|
||||
}
|
||||
|
||||
// ObserveBarrier records the latest Home observation barrier.
|
||||
func (r *Registry) ObserveBarrier(revision int64) {
|
||||
if r == nil || revision <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if revision > r.observedBarrier {
|
||||
r.observedBarrier = revision
|
||||
r.pendingBarrierSequence = r.next
|
||||
}
|
||||
}
|
||||
|
||||
// FreezeInFlight copies all active executions into an immutable snapshot.
|
||||
func (r *Registry) FreezeInFlight(_ time.Time) Freeze {
|
||||
if r == nil {
|
||||
return Freeze{}
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.observedBarrier > r.publishedBarrier {
|
||||
blocked := false
|
||||
for sequence := range r.pending {
|
||||
if sequence <= r.pendingBarrierSequence {
|
||||
blocked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !blocked {
|
||||
r.publishedBarrier = r.observedBarrier
|
||||
}
|
||||
}
|
||||
|
||||
r.snapshotRevision++
|
||||
freeze := Freeze{
|
||||
Revision: r.snapshotRevision,
|
||||
BarrierRevision: r.publishedBarrier,
|
||||
Executions: make([]Observation, 0, len(r.scopes)),
|
||||
}
|
||||
for _, scope := range r.scopes {
|
||||
freeze.Executions = append(freeze.Executions, Observation{
|
||||
RequestID: scope.spec.RequestID,
|
||||
CredentialID: scope.spec.CredentialID,
|
||||
Model: scope.spec.Model,
|
||||
RequestKind: scope.spec.Kind,
|
||||
StartedAt: scope.spec.StartedAt,
|
||||
Accounted: scope.spec.Accounted,
|
||||
})
|
||||
}
|
||||
return freeze
|
||||
}
|
||||
45
backend/sdk/cliproxy/executionregistry/observation_test.go
Normal file
45
backend/sdk/cliproxy/executionregistry/observation_test.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package executionregistry
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFreezeInFlightWaitsForPendingBarrierAndCopiesScopes(t *testing.T) {
|
||||
registry := New()
|
||||
pending, errBegin := registry.BeginDispatch()
|
||||
if errBegin != nil {
|
||||
t.Fatal(errBegin)
|
||||
}
|
||||
registry.ObserveBarrier(14)
|
||||
|
||||
before := registry.FreezeInFlight(time.Unix(12, 0).UTC())
|
||||
if before.BarrierRevision != 0 {
|
||||
t.Fatalf("barrier before install = %d", before.BarrierRevision)
|
||||
}
|
||||
|
||||
scope, errInstall := registry.Install(pending, ScopeSpec{
|
||||
RequestID: "req-a", CredentialID: "cred", Model: "gpt-5",
|
||||
Kind: "http", StartedAt: time.Unix(10, 0).UTC(), Accounted: true,
|
||||
})
|
||||
if errInstall != nil {
|
||||
t.Fatal(errInstall)
|
||||
}
|
||||
|
||||
after := registry.FreezeInFlight(time.Unix(13, 0).UTC())
|
||||
if after.BarrierRevision != 14 || len(after.Executions) != 1 || !after.Executions[0].Accounted {
|
||||
t.Fatalf("freeze after install = %#v", after)
|
||||
}
|
||||
after.Executions[0].RequestID = "mutated"
|
||||
|
||||
copied := registry.FreezeInFlight(time.Unix(13, 0).UTC())
|
||||
if len(copied.Executions) != 1 || copied.Executions[0].RequestID != "req-a" {
|
||||
t.Fatalf("freeze did not copy scope = %#v", copied)
|
||||
}
|
||||
|
||||
scope.End("completed")
|
||||
ended := registry.FreezeInFlight(time.Unix(14, 0).UTC())
|
||||
if len(ended.Executions) != 0 || ended.Revision <= after.Revision {
|
||||
t.Fatalf("freeze after end = %#v", ended)
|
||||
}
|
||||
}
|
||||
470
backend/sdk/cliproxy/executionregistry/registry.go
Normal file
470
backend/sdk/cliproxy/executionregistry/registry.go
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
// Package executionregistry tracks Home-dispatched executions for one subscriber lifetime.
|
||||
package executionregistry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRegistryNotAccepting = errors.New("execution registry is not accepting dispatches")
|
||||
ErrRegistryClosed = errors.New("execution registry is closed")
|
||||
ErrInvalidPendingDispatch = errors.New("invalid pending dispatch")
|
||||
ErrInvalidExecutionResource = errors.New("invalid execution resource")
|
||||
ErrExecutionResourceAlreadyBound = errors.New("execution resource is already bound")
|
||||
)
|
||||
|
||||
// State is the lifecycle state of a Registry.
|
||||
type State uint32
|
||||
|
||||
const (
|
||||
StateAccepting State = iota
|
||||
StateDraining
|
||||
StateClosed
|
||||
)
|
||||
|
||||
// Registry owns all dispatches accepted during one Home subscriber lifetime.
|
||||
type Registry struct {
|
||||
state atomic.Uint32
|
||||
|
||||
mu sync.Mutex
|
||||
next uint64
|
||||
snapshotRevision int64
|
||||
observedBarrier int64
|
||||
pendingBarrierSequence uint64
|
||||
publishedBarrier int64
|
||||
pending map[uint64]*PendingDispatch
|
||||
scopes map[uint64]*Scope
|
||||
releaseSequences map[ReleaseGroup]int64
|
||||
releaseSink ReleaseSink
|
||||
changed chan struct{}
|
||||
|
||||
closeMu sync.Mutex
|
||||
closeStarted bool
|
||||
closeDone chan struct{}
|
||||
closeErr error
|
||||
}
|
||||
|
||||
// PendingDispatch reserves an execution slot until it is installed or ended.
|
||||
type PendingDispatch struct {
|
||||
id uint64
|
||||
registry *Registry
|
||||
mu sync.Mutex
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// ScopeSpec describes a Home-dispatched execution.
|
||||
type ScopeSpec struct {
|
||||
RequestID string
|
||||
CredentialID string
|
||||
Model string
|
||||
Kind string
|
||||
StartedAt time.Time
|
||||
Accounted bool
|
||||
}
|
||||
|
||||
// ReleaseGroup identifies the cumulative release sequence for one accounted credential and model.
|
||||
type ReleaseGroup struct {
|
||||
CredentialID string
|
||||
Model string
|
||||
}
|
||||
|
||||
// ReleaseTicket completes after Home acknowledges a cumulative release sequence.
|
||||
type ReleaseTicket struct {
|
||||
Group ReleaseGroup
|
||||
Sequence int64
|
||||
done <-chan struct{}
|
||||
}
|
||||
|
||||
// NewReleaseTicket creates a ticket backed by done. A nil done channel represents
|
||||
// a release sink that does not support acknowledgements.
|
||||
func NewReleaseTicket(group ReleaseGroup, sequence int64, done <-chan struct{}) *ReleaseTicket {
|
||||
if sequence <= 0 || done == nil {
|
||||
return nil
|
||||
}
|
||||
return &ReleaseTicket{Group: group, Sequence: sequence, done: done}
|
||||
}
|
||||
|
||||
// Wait blocks until Home acknowledges the release or ctx expires.
|
||||
func (t *ReleaseTicket) Wait(ctx context.Context) error {
|
||||
if t == nil || t.done == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
select {
|
||||
case <-t.done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// ReleaseSink receives the latest cumulative sequence for a release group and
|
||||
// optionally returns an acknowledgement ticket.
|
||||
type ReleaseSink func(ReleaseGroup, int64) *ReleaseTicket
|
||||
|
||||
// Scope owns the resource for one installed execution.
|
||||
type Scope struct {
|
||||
id uint64
|
||||
registry *Registry
|
||||
spec ScopeSpec
|
||||
|
||||
mu sync.Mutex
|
||||
closeFn func() error
|
||||
closeDone chan struct{}
|
||||
releaseTicket *ReleaseTicket
|
||||
active bool
|
||||
ended sync.Once
|
||||
}
|
||||
|
||||
// New creates an accepting registry.
|
||||
func New() *Registry {
|
||||
registry := &Registry{
|
||||
pending: make(map[uint64]*PendingDispatch),
|
||||
scopes: make(map[uint64]*Scope),
|
||||
releaseSequences: make(map[ReleaseGroup]int64),
|
||||
changed: make(chan struct{}),
|
||||
}
|
||||
registry.state.Store(uint32(StateAccepting))
|
||||
return registry
|
||||
}
|
||||
|
||||
// BeginDispatch reserves a dispatch token while the registry accepts traffic.
|
||||
func (r *Registry) BeginDispatch() (*PendingDispatch, error) {
|
||||
if r == nil || State(r.state.Load()) != StateAccepting {
|
||||
return nil, ErrRegistryNotAccepting
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if State(r.state.Load()) != StateAccepting {
|
||||
return nil, ErrRegistryNotAccepting
|
||||
}
|
||||
|
||||
r.next++
|
||||
pending := &PendingDispatch{id: r.next, registry: r}
|
||||
r.pending[pending.id] = pending
|
||||
return pending, nil
|
||||
}
|
||||
|
||||
// WaitPending waits until every dispatch with an unresolved Home response has ended or been installed.
|
||||
func (r *Registry) WaitPending(ctx context.Context) error {
|
||||
if r == nil {
|
||||
return ErrRegistryClosed
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
for len(r.pending) != 0 {
|
||||
changed := r.changed
|
||||
r.mu.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-changed:
|
||||
}
|
||||
r.mu.Lock()
|
||||
}
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// End releases a dispatch token that was not installed.
|
||||
func (p *PendingDispatch) End() {
|
||||
if p == nil || p.registry == nil {
|
||||
return
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.once.Do(func() {
|
||||
p.registry.mu.Lock()
|
||||
delete(p.registry.pending, p.id)
|
||||
p.registry.signalLocked()
|
||||
p.registry.mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
// Install atomically turns a pending dispatch token into an active execution scope.
|
||||
func (r *Registry) Install(pending *PendingDispatch, spec ScopeSpec) (*Scope, error) {
|
||||
if r == nil || pending == nil || pending.registry != r {
|
||||
return nil, ErrInvalidPendingDispatch
|
||||
}
|
||||
|
||||
pending.mu.Lock()
|
||||
defer pending.mu.Unlock()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if State(r.state.Load()) != StateAccepting {
|
||||
pending.once.Do(func() {})
|
||||
delete(r.pending, pending.id)
|
||||
r.signalLocked()
|
||||
return nil, ErrRegistryNotAccepting
|
||||
}
|
||||
if _, exists := r.pending[pending.id]; !exists {
|
||||
return nil, ErrInvalidPendingDispatch
|
||||
}
|
||||
|
||||
pending.once.Do(func() {})
|
||||
delete(r.pending, pending.id)
|
||||
scope := &Scope{id: pending.id, registry: r, spec: spec, active: true}
|
||||
r.scopes[scope.id] = scope
|
||||
r.signalLocked()
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
// SetReleaseSink replaces the cumulative release sink and replays every known group.
|
||||
// Legacy callbacks remain supported but cannot provide acknowledgement tickets.
|
||||
func (r *Registry) SetReleaseSink(rawSink any) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var sink ReleaseSink
|
||||
switch typed := rawSink.(type) {
|
||||
case nil:
|
||||
case ReleaseSink:
|
||||
sink = typed
|
||||
case func(ReleaseGroup, int64) *ReleaseTicket:
|
||||
sink = ReleaseSink(typed)
|
||||
case func(ReleaseGroup, int64):
|
||||
sink = func(group ReleaseGroup, sequence int64) *ReleaseTicket {
|
||||
typed(group, sequence)
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.releaseSink = sink
|
||||
sequences := make(map[ReleaseGroup]int64, len(r.releaseSequences))
|
||||
for group, sequence := range r.releaseSequences {
|
||||
sequences[group] = sequence
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
if sink == nil {
|
||||
return
|
||||
}
|
||||
for group, sequence := range sequences {
|
||||
if sequence > 0 {
|
||||
sink(group, sequence)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bind attaches the execution resource. A scope accepts exactly one resource.
|
||||
func (s *Scope) Bind(closeFn func() error) error {
|
||||
if s == nil || s.registry == nil || closeFn == nil {
|
||||
return ErrInvalidExecutionResource
|
||||
}
|
||||
|
||||
s.registry.mu.Lock()
|
||||
defer s.registry.mu.Unlock()
|
||||
if State(s.registry.state.Load()) != StateAccepting || !s.active {
|
||||
return ErrRegistryNotAccepting
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closeFn != nil || s.closeDone != nil {
|
||||
return ErrExecutionResourceAlreadyBound
|
||||
}
|
||||
s.closeFn = closeFn
|
||||
return nil
|
||||
}
|
||||
|
||||
// End closes the bound resource and releases this execution scope exactly once.
|
||||
func (s *Scope) End(reason string) {
|
||||
_ = s.EndWithRelease(reason)
|
||||
}
|
||||
|
||||
// EndWithRelease closes the scope and returns the release acknowledgement ticket.
|
||||
// The release sink is invoked without the registry mutex held.
|
||||
func (s *Scope) EndWithRelease(_ string) *ReleaseTicket {
|
||||
if s == nil || s.registry == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ticket *ReleaseTicket
|
||||
s.ended.Do(func() {
|
||||
s.registry.mu.Lock()
|
||||
s.mu.Lock()
|
||||
s.active = false
|
||||
s.mu.Unlock()
|
||||
s.registry.mu.Unlock()
|
||||
|
||||
s.waitForBoundResourceClose()
|
||||
|
||||
s.registry.mu.Lock()
|
||||
releaseSink, releaseGroup, releaseSequence := s.registry.markReleasedLocked(s)
|
||||
s.registry.mu.Unlock()
|
||||
|
||||
if releaseSink != nil && releaseSequence > 0 {
|
||||
ticket = releaseSink(releaseGroup, releaseSequence)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.releaseTicket = ticket
|
||||
s.mu.Unlock()
|
||||
|
||||
s.registry.mu.Lock()
|
||||
delete(s.registry.scopes, s.id)
|
||||
s.registry.signalLocked()
|
||||
s.registry.mu.Unlock()
|
||||
})
|
||||
|
||||
s.mu.Lock()
|
||||
ticket = s.releaseTicket
|
||||
s.mu.Unlock()
|
||||
return ticket
|
||||
}
|
||||
|
||||
func (r *Registry) markReleasedLocked(scope *Scope) (ReleaseSink, ReleaseGroup, int64) {
|
||||
if scope == nil || !scope.spec.Accounted {
|
||||
return nil, ReleaseGroup{}, 0
|
||||
}
|
||||
group := ReleaseGroup{CredentialID: scope.spec.CredentialID, Model: scope.spec.Model}
|
||||
r.releaseSequences[group]++
|
||||
return r.releaseSink, group, r.releaseSequences[group]
|
||||
}
|
||||
|
||||
func (s *Scope) startBoundResourceClose() <-chan struct{} {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closeDone != nil {
|
||||
return s.closeDone
|
||||
}
|
||||
closeFn := s.closeFn
|
||||
if closeFn == nil {
|
||||
return nil
|
||||
}
|
||||
closeDone := make(chan struct{})
|
||||
s.closeFn = nil
|
||||
s.closeDone = closeDone
|
||||
go func() {
|
||||
s.closeResource(closeFn)
|
||||
close(closeDone)
|
||||
}()
|
||||
return closeDone
|
||||
}
|
||||
|
||||
func (s *Scope) waitForBoundResourceClose() {
|
||||
if closeDone := s.startBoundResourceClose(); closeDone != nil {
|
||||
<-closeDone
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scope) closeResource(closeFn func() error) {
|
||||
if closeFn == nil {
|
||||
return
|
||||
}
|
||||
if errClose := closeFn(); errClose != nil {
|
||||
log.WithError(errClose).Warn("Home execution resource close failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Drain rejects new work, cancels active resources, and waits for all owners to end.
|
||||
func (r *Registry) Drain(ctx context.Context) error {
|
||||
if r == nil {
|
||||
return ErrRegistryClosed
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
if !r.state.CompareAndSwap(uint32(StateAccepting), uint32(StateDraining)) && State(r.state.Load()) != StateDraining {
|
||||
return ErrRegistryClosed
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
scopes := make([]*Scope, 0, len(r.scopes))
|
||||
for _, scope := range r.scopes {
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
for _, scope := range scopes {
|
||||
scope.startBoundResourceClose()
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
for len(r.pending) != 0 || len(r.scopes) != 0 {
|
||||
changed := r.changed
|
||||
r.mu.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-changed:
|
||||
}
|
||||
r.mu.Lock()
|
||||
}
|
||||
r.state.Store(uint32(StateClosed))
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close permanently rejects new work and closes every currently bound resource.
|
||||
func (r *Registry) Close() error {
|
||||
if r == nil {
|
||||
return ErrRegistryClosed
|
||||
}
|
||||
|
||||
r.closeMu.Lock()
|
||||
if r.closeStarted {
|
||||
closeDone := r.closeDone
|
||||
r.closeMu.Unlock()
|
||||
<-closeDone
|
||||
r.closeMu.Lock()
|
||||
errClose := r.closeErr
|
||||
r.closeMu.Unlock()
|
||||
return errClose
|
||||
}
|
||||
if State(r.state.Load()) == StateClosed {
|
||||
r.closeMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
r.closeStarted = true
|
||||
r.closeDone = make(chan struct{})
|
||||
closeDone := r.closeDone
|
||||
r.closeMu.Unlock()
|
||||
|
||||
for {
|
||||
state := State(r.state.Load())
|
||||
if state == StateClosed || r.state.CompareAndSwap(uint32(state), uint32(StateClosed)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
scopes := make([]*Scope, 0, len(r.scopes))
|
||||
for _, scope := range r.scopes {
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
for _, scope := range scopes {
|
||||
scope.waitForBoundResourceClose()
|
||||
}
|
||||
|
||||
r.closeMu.Lock()
|
||||
errClose := r.closeErr
|
||||
close(closeDone)
|
||||
r.closeMu.Unlock()
|
||||
return errClose
|
||||
}
|
||||
|
||||
func (r *Registry) signalLocked() {
|
||||
close(r.changed)
|
||||
r.changed = make(chan struct{})
|
||||
}
|
||||
385
backend/sdk/cliproxy/executionregistry/registry_test.go
Normal file
385
backend/sdk/cliproxy/executionregistry/registry_test.go
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
package executionregistry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDrainRejectsLateInstallAndCancelsBoundScopes(t *testing.T) {
|
||||
registry := New()
|
||||
pending, errBegin := registry.BeginDispatch()
|
||||
if errBegin != nil {
|
||||
t.Fatal(errBegin)
|
||||
}
|
||||
scope, errInstall := registry.Install(pending, ScopeSpec{RequestID: "req-1", CredentialID: "cred-1", Model: "gpt", Kind: "http", StartedAt: time.Now()})
|
||||
if errInstall != nil {
|
||||
t.Fatal(errInstall)
|
||||
}
|
||||
closed := atomic.Int32{}
|
||||
if errBind := scope.Bind(func() error {
|
||||
closed.Add(1)
|
||||
go scope.End("canceled")
|
||||
return nil
|
||||
}); errBind != nil {
|
||||
t.Fatal(errBind)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if errDrain := registry.Drain(ctx); errDrain != nil {
|
||||
t.Fatal(errDrain)
|
||||
}
|
||||
if closed.Load() != 1 {
|
||||
t.Fatalf("close calls = %d", closed.Load())
|
||||
}
|
||||
if _, errLate := registry.BeginDispatch(); !errors.Is(errLate, ErrRegistryNotAccepting) {
|
||||
t.Fatalf("late dispatch error = %v", errLate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeEndIsExactlyOnce(t *testing.T) {
|
||||
registry := New()
|
||||
pending, errBegin := registry.BeginDispatch()
|
||||
if errBegin != nil {
|
||||
t.Fatal(errBegin)
|
||||
}
|
||||
scope, errInstall := registry.Install(pending, ScopeSpec{})
|
||||
if errInstall != nil {
|
||||
t.Fatal(errInstall)
|
||||
}
|
||||
closed := atomic.Int32{}
|
||||
if errBind := scope.Bind(func() error {
|
||||
closed.Add(1)
|
||||
return nil
|
||||
}); errBind != nil {
|
||||
t.Fatal(errBind)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
scope.End("complete")
|
||||
close(done)
|
||||
}()
|
||||
scope.End("duplicate")
|
||||
<-done
|
||||
if closed.Load() != 1 {
|
||||
t.Fatalf("close calls = %d, want 1", closed.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrainWaitsForPendingDispatch(t *testing.T) {
|
||||
registry := New()
|
||||
pending, errBegin := registry.BeginDispatch()
|
||||
if errBegin != nil {
|
||||
t.Fatal(errBegin)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- registry.Drain(ctx) }()
|
||||
|
||||
select {
|
||||
case errDrain := <-done:
|
||||
t.Fatalf("Drain() returned before pending dispatch ended: %v", errDrain)
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
pending.End()
|
||||
if errDrain := <-done; errDrain != nil {
|
||||
t.Fatalf("Drain() error = %v", errDrain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitPendingDoesNotDrainActiveScope(t *testing.T) {
|
||||
registry := New()
|
||||
activePending, errBegin := registry.BeginDispatch()
|
||||
if errBegin != nil {
|
||||
t.Fatal(errBegin)
|
||||
}
|
||||
scope, errInstall := registry.Install(activePending, ScopeSpec{})
|
||||
if errInstall != nil {
|
||||
t.Fatal(errInstall)
|
||||
}
|
||||
defer scope.End("test cleanup")
|
||||
pending, errBegin := registry.BeginDispatch()
|
||||
if errBegin != nil {
|
||||
t.Fatal(errBegin)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- registry.WaitPending(ctx) }()
|
||||
select {
|
||||
case errWait := <-done:
|
||||
t.Fatalf("WaitPending() returned before pending dispatch ended: %v", errWait)
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
pending.End()
|
||||
if errWait := <-done; errWait != nil {
|
||||
t.Fatalf("WaitPending() error = %v", errWait)
|
||||
}
|
||||
nextPending, errNext := registry.BeginDispatch()
|
||||
if errNext != nil {
|
||||
t.Fatalf("WaitPending() stopped registry acceptance: %v", errNext)
|
||||
}
|
||||
nextPending.End()
|
||||
}
|
||||
|
||||
func TestDrainReturnsWhenBlockingResourceCloseExceedsContext(t *testing.T) {
|
||||
registry := New()
|
||||
pending, errBegin := registry.BeginDispatch()
|
||||
if errBegin != nil {
|
||||
t.Fatal(errBegin)
|
||||
}
|
||||
scope, errInstall := registry.Install(pending, ScopeSpec{})
|
||||
if errInstall != nil {
|
||||
t.Fatal(errInstall)
|
||||
}
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
if errBind := scope.Bind(func() error {
|
||||
close(started)
|
||||
<-release
|
||||
return nil
|
||||
}); errBind != nil {
|
||||
t.Fatal(errBind)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
errDrain := registry.Drain(ctx)
|
||||
if !errors.Is(errDrain, context.DeadlineExceeded) {
|
||||
t.Fatalf("Drain() error = %v, want context deadline exceeded", errDrain)
|
||||
}
|
||||
select {
|
||||
case <-started:
|
||||
default:
|
||||
t.Fatal("Drain() did not start closing the bound resource")
|
||||
}
|
||||
if state := State(registry.state.Load()); state != StateDraining {
|
||||
t.Fatalf("registry state = %v, want draining", state)
|
||||
}
|
||||
|
||||
ended := make(chan struct{})
|
||||
go func() {
|
||||
scope.End("canceled")
|
||||
close(ended)
|
||||
}()
|
||||
close(release)
|
||||
select {
|
||||
case <-ended:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Scope.End() did not wait for resource close completion")
|
||||
}
|
||||
if errDrain = registry.Drain(context.Background()); errDrain != nil {
|
||||
t.Fatalf("Drain() after resource close = %v", errDrain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrainWaitsForBlockingResourceClose(t *testing.T) {
|
||||
registry := New()
|
||||
pending, errBegin := registry.BeginDispatch()
|
||||
if errBegin != nil {
|
||||
t.Fatal(errBegin)
|
||||
}
|
||||
scope, errInstall := registry.Install(pending, ScopeSpec{})
|
||||
if errInstall != nil {
|
||||
t.Fatal(errInstall)
|
||||
}
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
if errBind := scope.Bind(func() error {
|
||||
close(started)
|
||||
<-release
|
||||
return nil
|
||||
}); errBind != nil {
|
||||
t.Fatal(errBind)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- registry.Drain(ctx) }()
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Drain() did not close the bound resource")
|
||||
}
|
||||
go scope.End("canceled")
|
||||
select {
|
||||
case errDrain := <-done:
|
||||
t.Fatalf("Drain() returned before the resource close completed: %v", errDrain)
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
if errDrain := <-done; errDrain != nil {
|
||||
t.Fatalf("Drain() error = %v", errDrain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentDrainWaitsForBlockingResourceClose(t *testing.T) {
|
||||
registry := New()
|
||||
pending, errBegin := registry.BeginDispatch()
|
||||
if errBegin != nil {
|
||||
t.Fatal(errBegin)
|
||||
}
|
||||
scope, errInstall := registry.Install(pending, ScopeSpec{})
|
||||
if errInstall != nil {
|
||||
t.Fatal(errInstall)
|
||||
}
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
if errBind := scope.Bind(func() error {
|
||||
close(started)
|
||||
<-release
|
||||
return nil
|
||||
}); errBind != nil {
|
||||
t.Fatal(errBind)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
firstDrain := make(chan error, 1)
|
||||
go func() { firstDrain <- registry.Drain(ctx) }()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("first Drain() did not close the bound resource")
|
||||
}
|
||||
ended := make(chan struct{})
|
||||
go func() {
|
||||
scope.End("canceled")
|
||||
close(ended)
|
||||
}()
|
||||
select {
|
||||
case <-ended:
|
||||
t.Fatal("Scope.End() returned before the resource close completed")
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
secondDrain := make(chan error, 1)
|
||||
go func() { secondDrain <- registry.Drain(ctx) }()
|
||||
select {
|
||||
case errDrain := <-secondDrain:
|
||||
t.Fatalf("second Drain() returned before resource close completed: %v", errDrain)
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case <-ended:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Scope.End() did not complete after the resource close")
|
||||
}
|
||||
if errDrain := <-firstDrain; errDrain != nil {
|
||||
t.Fatalf("first Drain() error = %v", errDrain)
|
||||
}
|
||||
if errDrain := <-secondDrain; errDrain != nil {
|
||||
t.Fatalf("second Drain() error = %v", errDrain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentCloseWaitsForBlockingResourceClose(t *testing.T) {
|
||||
registry := New()
|
||||
pending, errBegin := registry.BeginDispatch()
|
||||
if errBegin != nil {
|
||||
t.Fatal(errBegin)
|
||||
}
|
||||
scope, errInstall := registry.Install(pending, ScopeSpec{})
|
||||
if errInstall != nil {
|
||||
t.Fatal(errInstall)
|
||||
}
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
if errBind := scope.Bind(func() error {
|
||||
close(started)
|
||||
<-release
|
||||
return nil
|
||||
}); errBind != nil {
|
||||
t.Fatal(errBind)
|
||||
}
|
||||
|
||||
firstClose := make(chan error, 1)
|
||||
go func() { firstClose <- registry.Close() }()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("first Close() did not close the bound resource")
|
||||
}
|
||||
|
||||
secondClose := make(chan error, 1)
|
||||
go func() { secondClose <- registry.Close() }()
|
||||
select {
|
||||
case errClose := <-secondClose:
|
||||
t.Fatalf("second Close() returned before resource close completed: %v", errClose)
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(release)
|
||||
if errClose := <-firstClose; errClose != nil {
|
||||
t.Fatalf("first Close() error = %v", errClose)
|
||||
}
|
||||
if errClose := <-secondClose; errClose != nil {
|
||||
t.Fatalf("second Close() error = %v", errClose)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrainRejectsLateBind(t *testing.T) {
|
||||
registry := New()
|
||||
pending, errBegin := registry.BeginDispatch()
|
||||
if errBegin != nil {
|
||||
t.Fatal(errBegin)
|
||||
}
|
||||
scope, errInstall := registry.Install(pending, ScopeSpec{})
|
||||
if errInstall != nil {
|
||||
t.Fatal(errInstall)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- registry.Drain(ctx) }()
|
||||
|
||||
deadline := time.After(time.Second)
|
||||
for State(registry.state.Load()) == StateAccepting {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("registry did not begin draining")
|
||||
default:
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
if errBind := scope.Bind(func() error { return nil }); !errors.Is(errBind, ErrRegistryNotAccepting) {
|
||||
t.Fatalf("Bind() error = %v, want ErrRegistryNotAccepting", errBind)
|
||||
}
|
||||
scope.End("canceled")
|
||||
if errDrain := <-done; errDrain != nil {
|
||||
t.Fatalf("Drain() error = %v", errDrain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrainRejectsLateInstall(t *testing.T) {
|
||||
registry := New()
|
||||
pending, errBegin := registry.BeginDispatch()
|
||||
if errBegin != nil {
|
||||
t.Fatal(errBegin)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- registry.Drain(ctx) }()
|
||||
|
||||
deadline := time.After(time.Second)
|
||||
for State(registry.state.Load()) == StateAccepting {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("registry did not begin draining")
|
||||
default:
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
if _, errInstall := registry.Install(pending, ScopeSpec{}); !errors.Is(errInstall, ErrRegistryNotAccepting) {
|
||||
t.Fatalf("Install() error = %v, want ErrRegistryNotAccepting", errInstall)
|
||||
}
|
||||
if errDrain := <-done; errDrain != nil {
|
||||
t.Fatalf("Drain() error = %v", errDrain)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue