505 lines
14 KiB
Go
505 lines
14 KiB
Go
package home
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executionregistry"
|
|
)
|
|
|
|
func concurrencyReleaseFrameFromFixture(t *testing.T) ConcurrencyReleaseFrame {
|
|
t.Helper()
|
|
raw, errRead := os.ReadFile(filepath.Join("testdata", "concurrency_release.json"))
|
|
if errRead != nil {
|
|
t.Fatal(errRead)
|
|
}
|
|
|
|
var frame ConcurrencyReleaseFrame
|
|
if errUnmarshal := json.Unmarshal(raw, &frame); errUnmarshal != nil {
|
|
t.Fatal(errUnmarshal)
|
|
}
|
|
return frame
|
|
}
|
|
|
|
func TestConcurrencyReleaseFrameFixture(t *testing.T) {
|
|
raw, errRead := os.ReadFile(filepath.Join("testdata", "concurrency_release.json"))
|
|
if errRead != nil {
|
|
t.Fatal(errRead)
|
|
}
|
|
frame := concurrencyReleaseFrameFromFixture(t)
|
|
if frame != (ConcurrencyReleaseFrame{CredentialID: "cred-1", Model: "gpt", ReleaseSeq: 1}) {
|
|
t.Fatalf("fixture frame = %#v", frame)
|
|
}
|
|
marshaled, errMarshal := json.Marshal(frame)
|
|
if errMarshal != nil {
|
|
t.Fatal(errMarshal)
|
|
}
|
|
if !bytes.Equal(marshaled, bytes.TrimSpace(raw)) {
|
|
t.Fatalf("marshaled frame = %q, want fixture %q", marshaled, bytes.TrimSpace(raw))
|
|
}
|
|
}
|
|
|
|
type recordingReleaseSender struct {
|
|
mu sync.Mutex
|
|
failures int
|
|
frames []ConcurrencyReleaseFrame
|
|
acked []ConcurrencyReleaseFrame
|
|
sent chan struct{}
|
|
}
|
|
|
|
func (s *recordingReleaseSender) Send(_ context.Context, frame ConcurrencyReleaseFrame) error {
|
|
s.mu.Lock()
|
|
s.frames = append(s.frames, frame)
|
|
failed := s.failures > 0
|
|
if failed {
|
|
s.failures--
|
|
} else {
|
|
s.acked = append(s.acked, frame)
|
|
}
|
|
s.mu.Unlock()
|
|
select {
|
|
case s.sent <- struct{}{}:
|
|
default:
|
|
}
|
|
if failed {
|
|
return errors.New("temporary Home failure")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *recordingReleaseSender) LastSequence() int64 {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if len(s.acked) == 0 {
|
|
return 0
|
|
}
|
|
return s.acked[len(s.acked)-1].ReleaseSeq
|
|
}
|
|
|
|
func (s *recordingReleaseSender) WaitForSequence(sequence int64, timeout time.Duration) bool {
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
for {
|
|
if s.LastSequence() == sequence {
|
|
return true
|
|
}
|
|
select {
|
|
case <-timer.C:
|
|
return false
|
|
case <-s.sent:
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseFlusherRetriesLatestCumulativeSequence(t *testing.T) {
|
|
sender := &recordingReleaseSender{failures: 1, sent: make(chan struct{}, 8)}
|
|
flusher := newReleaseFlusher(10*time.Millisecond, 40*time.Millisecond, sender.Send)
|
|
group := executionregistry.ReleaseGroup{CredentialID: "cred-1", Model: "gpt"}
|
|
flusher.MarkDirty(group, 1)
|
|
flusher.MarkDirty(group, 3)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
go flusher.Run(ctx)
|
|
|
|
if !sender.WaitForSequence(3, 500*time.Millisecond) {
|
|
t.Fatalf("last sequence = %d, want 3", sender.LastSequence())
|
|
}
|
|
if sender.LastSequence() != 3 {
|
|
t.Fatalf("last sequence = %d, want 3", sender.LastSequence())
|
|
}
|
|
}
|
|
|
|
type blockingReleaseSender struct {
|
|
started chan struct{}
|
|
release chan struct{}
|
|
frames chan ConcurrencyReleaseFrame
|
|
once sync.Once
|
|
}
|
|
|
|
func (s *blockingReleaseSender) Send(_ context.Context, frame ConcurrencyReleaseFrame) error {
|
|
s.once.Do(func() { close(s.started) })
|
|
select {
|
|
case s.frames <- frame:
|
|
default:
|
|
}
|
|
<-s.release
|
|
return nil
|
|
}
|
|
|
|
func TestReleaseFlusherDoesNotLoseASequenceMarkedDuringSend(t *testing.T) {
|
|
sender := &blockingReleaseSender{
|
|
started: make(chan struct{}),
|
|
release: make(chan struct{}),
|
|
frames: make(chan ConcurrencyReleaseFrame, 4),
|
|
}
|
|
flusher := newReleaseFlusher(time.Millisecond, 10*time.Millisecond, sender.Send)
|
|
group := executionregistry.ReleaseGroup{CredentialID: "cred-1", Model: "gpt"}
|
|
flusher.MarkDirty(group, 1)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go flusher.Run(ctx)
|
|
|
|
select {
|
|
case <-sender.started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("release flusher did not begin sending")
|
|
}
|
|
flusher.MarkDirty(group, 2)
|
|
close(sender.release)
|
|
|
|
deadline := time.NewTimer(time.Second)
|
|
defer deadline.Stop()
|
|
for {
|
|
select {
|
|
case frame := <-sender.frames:
|
|
if frame.ReleaseSeq == 2 {
|
|
return
|
|
}
|
|
case <-deadline.C:
|
|
t.Fatal("release flusher did not send the latest sequence")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseFlusherUsesCurrentLimiterConfig(t *testing.T) {
|
|
flusher := newReleaseFlusher(time.Hour, 2*time.Hour, func(context.Context, ConcurrencyReleaseFrame) error { return nil })
|
|
flusher.SetConfigProvider(func() internalconfig.CredentialConcurrencyConfig {
|
|
return internalconfig.CredentialConcurrencyConfig{
|
|
ReleaseFlushInterval: 5 * time.Millisecond,
|
|
ReleaseMaxBackoff: 25 * time.Millisecond,
|
|
}
|
|
})
|
|
if got := flusher.timings(); got.flushInterval != 5*time.Millisecond || got.maxBackoff != 25*time.Millisecond {
|
|
t.Fatalf("timings = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestReleaseFlusherStopsWithLifetime(t *testing.T) {
|
|
sender := &recordingReleaseSender{sent: make(chan struct{}, 1)}
|
|
flusher := newReleaseFlusher(time.Hour, time.Hour, sender.Send)
|
|
done := make(chan struct{})
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() {
|
|
defer close(done)
|
|
flusher.Run(ctx)
|
|
}()
|
|
cancel()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("release flusher did not stop with its lifetime")
|
|
}
|
|
}
|
|
|
|
type timedReleaseAttempt struct {
|
|
at time.Time
|
|
frame ConcurrencyReleaseFrame
|
|
failed bool
|
|
}
|
|
|
|
type outageReleaseSender struct {
|
|
mu sync.Mutex
|
|
outage bool
|
|
attempts []timedReleaseAttempt
|
|
sent chan struct{}
|
|
}
|
|
|
|
func (s *outageReleaseSender) Send(_ context.Context, frame ConcurrencyReleaseFrame) error {
|
|
s.mu.Lock()
|
|
failed := s.outage
|
|
s.attempts = append(s.attempts, timedReleaseAttempt{at: time.Now(), frame: frame, failed: failed})
|
|
s.mu.Unlock()
|
|
select {
|
|
case s.sent <- struct{}{}:
|
|
default:
|
|
}
|
|
if failed {
|
|
return errors.New("temporary Home outage")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *outageReleaseSender) SetOutage(outage bool) {
|
|
s.mu.Lock()
|
|
s.outage = outage
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *outageReleaseSender) WaitForAttempts(count int, timeout time.Duration) []timedReleaseAttempt {
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
for {
|
|
s.mu.Lock()
|
|
attempts := append([]timedReleaseAttempt(nil), s.attempts...)
|
|
s.mu.Unlock()
|
|
if len(attempts) >= count {
|
|
return attempts
|
|
}
|
|
select {
|
|
case <-timer.C:
|
|
return attempts
|
|
case <-s.sent:
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseFlusherCoalescesDirtyWakesDuringFailureBackoff(t *testing.T) {
|
|
const (
|
|
flushInterval = 20 * time.Millisecond
|
|
maxBackoff = 80 * time.Millisecond
|
|
tolerance = 10 * time.Millisecond
|
|
)
|
|
|
|
sender := &outageReleaseSender{outage: true, sent: make(chan struct{}, 32)}
|
|
flusher := newReleaseFlusher(flushInterval, maxBackoff, sender.Send)
|
|
group := executionregistry.ReleaseGroup{CredentialID: "cred-1", Model: "gpt"}
|
|
flusher.MarkDirty(group, 1)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
flusher.Run(ctx)
|
|
}()
|
|
defer func() {
|
|
cancel()
|
|
<-done
|
|
}()
|
|
|
|
stopReleases := make(chan struct{})
|
|
producerDone := make(chan struct{})
|
|
latest := int64(1)
|
|
go func() {
|
|
defer close(producerDone)
|
|
ticker := time.NewTicker(time.Millisecond)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-stopReleases:
|
|
return
|
|
case <-ticker.C:
|
|
latest++
|
|
flusher.MarkDirty(group, latest)
|
|
}
|
|
}
|
|
}()
|
|
|
|
attempts := sender.WaitForAttempts(3, time.Second)
|
|
close(stopReleases)
|
|
<-producerDone
|
|
if len(attempts) < 3 {
|
|
t.Fatalf("attempt count = %d, want at least 3", len(attempts))
|
|
}
|
|
for _, attempt := range attempts[:3] {
|
|
if !attempt.failed {
|
|
t.Fatal("release unexpectedly succeeded during outage")
|
|
}
|
|
}
|
|
if got := attempts[1].at.Sub(attempts[0].at); got < 2*flushInterval-tolerance {
|
|
t.Fatalf("first retry delay = %s, want at least %s", got, 2*flushInterval-tolerance)
|
|
}
|
|
if got := attempts[2].at.Sub(attempts[1].at); got < maxBackoff-tolerance {
|
|
t.Fatalf("second retry delay = %s, want at least %s", got, maxBackoff-tolerance)
|
|
}
|
|
|
|
latest++
|
|
recoverySequence := latest
|
|
recoveryStart := attempts[2].at
|
|
sender.SetOutage(false)
|
|
flusher.MarkDirty(group, recoverySequence)
|
|
|
|
attempts = sender.WaitForAttempts(4, time.Second)
|
|
if len(attempts) < 4 {
|
|
t.Fatalf("attempt count after recovery = %d, want at least 4", len(attempts))
|
|
}
|
|
recovered := attempts[3]
|
|
if recovered.failed || recovered.frame.ReleaseSeq != recoverySequence {
|
|
t.Fatalf("recovery attempt = %#v, want successful sequence %d", recovered, recoverySequence)
|
|
}
|
|
if got := recovered.at.Sub(recoveryStart); got < maxBackoff-tolerance {
|
|
t.Fatalf("recovery retry delay = %s, want at least %s", got, maxBackoff-tolerance)
|
|
}
|
|
}
|
|
|
|
type boundedForceReleaseSender struct {
|
|
attempts chan context.Context
|
|
calls int
|
|
}
|
|
|
|
func (s *boundedForceReleaseSender) Send(ctx context.Context, _ ConcurrencyReleaseFrame) error {
|
|
s.calls++
|
|
select {
|
|
case s.attempts <- ctx:
|
|
default:
|
|
}
|
|
if s.calls == 1 {
|
|
return errors.New("temporary Home failure")
|
|
}
|
|
<-ctx.Done()
|
|
return ctx.Err()
|
|
}
|
|
|
|
func TestReleaseFlusherFlushForceUsesBoundedContext(t *testing.T) {
|
|
sender := &boundedForceReleaseSender{attempts: make(chan context.Context, 2)}
|
|
flusher := newReleaseFlusher(time.Second, time.Second, sender.Send)
|
|
group := executionregistry.ReleaseGroup{CredentialID: "cred-1", Model: "gpt"}
|
|
flusher.MarkDirty(group, 1)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
flusher.Run(ctx)
|
|
}()
|
|
defer func() {
|
|
cancel()
|
|
<-done
|
|
}()
|
|
|
|
select {
|
|
case <-sender.attempts:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("release flusher did not make the initial failed attempt")
|
|
}
|
|
|
|
flushCtx, cancelFlush := context.WithTimeout(context.Background(), 40*time.Millisecond)
|
|
defer cancelFlush()
|
|
if errFlush := flusher.Flush(flushCtx); !errors.Is(errFlush, context.DeadlineExceeded) {
|
|
t.Fatalf("Flush() error = %v, want deadline exceeded", errFlush)
|
|
}
|
|
|
|
select {
|
|
case forceCtx := <-sender.attempts:
|
|
if _, ok := forceCtx.Deadline(); !ok {
|
|
t.Fatal("forced release attempt did not receive the bounded Flush context")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Flush() did not bypass the normal retry interval")
|
|
}
|
|
}
|
|
|
|
func TestScopeEndBlocksDrainUntilReleaseSinkFlushesFinalSequence(t *testing.T) {
|
|
sender := &recordingReleaseSender{sent: make(chan struct{}, 2)}
|
|
flusher := newReleaseFlusher(time.Hour, time.Hour, sender.Send)
|
|
releaseCtx, cancelRelease := context.WithCancel(context.Background())
|
|
releaseDone := make(chan struct{})
|
|
go func() {
|
|
defer close(releaseDone)
|
|
flusher.Run(releaseCtx)
|
|
}()
|
|
defer func() {
|
|
cancelRelease()
|
|
<-releaseDone
|
|
}()
|
|
|
|
registry := executionregistry.New()
|
|
sinkStarted := make(chan struct{})
|
|
unblockSink := make(chan struct{})
|
|
registry.SetReleaseSink(func(group executionregistry.ReleaseGroup, sequence int64) {
|
|
close(sinkStarted)
|
|
<-unblockSink
|
|
flusher.MarkDirty(group, sequence)
|
|
})
|
|
pending, errBegin := registry.BeginDispatch()
|
|
if errBegin != nil {
|
|
t.Fatal(errBegin)
|
|
}
|
|
scope, errInstall := registry.Install(pending, executionregistry.ScopeSpec{CredentialID: "cred-1", Model: "gpt", Accounted: true})
|
|
if errInstall != nil {
|
|
t.Fatal(errInstall)
|
|
}
|
|
|
|
endDone := make(chan struct{})
|
|
go func() {
|
|
defer close(endDone)
|
|
scope.End("complete")
|
|
}()
|
|
select {
|
|
case <-sinkStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Scope.End() did not call the release sink")
|
|
}
|
|
|
|
drainCtx, cancelDrain := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancelDrain()
|
|
drainDone := make(chan error, 1)
|
|
go func() { drainDone <- registry.Drain(drainCtx) }()
|
|
|
|
select {
|
|
case errDrain := <-drainDone:
|
|
t.Fatalf("Drain() returned before the release sink completed: %v", errDrain)
|
|
case <-time.After(20 * time.Millisecond):
|
|
}
|
|
|
|
mutexAvailable := make(chan struct{})
|
|
go func() {
|
|
registry.SetReleaseSink(nil)
|
|
close(mutexAvailable)
|
|
}()
|
|
select {
|
|
case <-mutexAvailable:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("release sink blocked the registry mutex")
|
|
}
|
|
if _, errBegin := registry.BeginDispatch(); !errors.Is(errBegin, executionregistry.ErrRegistryNotAccepting) {
|
|
t.Fatalf("BeginDispatch() error = %v, want ErrRegistryNotAccepting", errBegin)
|
|
}
|
|
|
|
close(unblockSink)
|
|
select {
|
|
case <-endDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Scope.End() did not complete after the release sink unblocked")
|
|
}
|
|
if errDrain := <-drainDone; errDrain != nil {
|
|
t.Fatalf("Drain() error = %v", errDrain)
|
|
}
|
|
|
|
flushCtx, cancelFlush := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancelFlush()
|
|
if errFlush := flusher.Flush(flushCtx); errFlush != nil {
|
|
t.Fatalf("Flush() error = %v", errFlush)
|
|
}
|
|
if got := sender.LastSequence(); got != 1 {
|
|
t.Fatalf("final flushed sequence = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestReleaseFlusherSenderReplacementPreservesTicket(t *testing.T) {
|
|
flusher := newReleaseFlusher(time.Hour, time.Hour, func(context.Context, ConcurrencyReleaseFrame) error {
|
|
return errors.New("old Home unavailable")
|
|
})
|
|
group := executionregistry.ReleaseGroup{CredentialID: "cred-1", Model: "gpt"}
|
|
ticket := flusher.MarkDirty(group, 1)
|
|
if ticket == nil {
|
|
t.Fatal("MarkDirty() ticket = nil")
|
|
}
|
|
if failed := flusher.flush(context.Background()); !failed {
|
|
t.Fatal("old sender release attempt did not fail")
|
|
}
|
|
|
|
flusher.SetSender(func(_ context.Context, frame ConcurrencyReleaseFrame) error {
|
|
if frame.CredentialID != group.CredentialID || frame.Model != group.Model || frame.ReleaseSeq != 1 {
|
|
t.Fatalf("replacement sender frame = %#v", frame)
|
|
}
|
|
return nil
|
|
})
|
|
if failed := flusher.flush(context.Background()); failed {
|
|
t.Fatal("replacement sender release attempt failed")
|
|
}
|
|
waitCtx, cancelWait := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancelWait()
|
|
if errWait := ticket.Wait(waitCtx); errWait != nil {
|
|
t.Fatalf("ticket did not survive sender replacement: %v", errWait)
|
|
}
|
|
}
|