410 lines
12 KiB
Go
410 lines
12 KiB
Go
package promptkit_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/promptkit"
|
|
)
|
|
|
|
func TestEngineLimitsInjectedClientConcurrency(t *testing.T) {
|
|
release := make(chan struct{})
|
|
client := newCapacityGateClient(release, 8)
|
|
engine := newBackendCapacityEngine(t, client, 2, capacityInt(4), nil)
|
|
|
|
results := make(chan capacityRunResult, 6)
|
|
for i := 0; i < 6; i++ {
|
|
go runCapacityRequest(engine, context.Background(), promptkit.RunRequest{
|
|
PromptID: "prompt",
|
|
Execution: &promptkit.ExecutionTargetOverride{
|
|
Endpoint: "http://request.example/v1",
|
|
},
|
|
}, results)
|
|
}
|
|
|
|
first := awaitCapacityRequest(t, client.started)
|
|
second := awaitCapacityRequest(t, client.started)
|
|
if first.Target.BackendID != "limited" || second.Target.BackendID != "limited" {
|
|
t.Fatalf("endpoint override changed backend pool: first=%q second=%q",
|
|
first.Target.BackendID, second.Target.BackendID)
|
|
}
|
|
if active, peak, _ := client.snapshot(); active != 2 || peak != 2 {
|
|
t.Fatalf("client concurrency before release=(active=%d peak=%d), want 2", active, peak)
|
|
}
|
|
|
|
close(release)
|
|
for i := 0; i < 6; i++ {
|
|
outcome := awaitCapacityRun(t, results)
|
|
if outcome.err != nil || outcome.result == nil {
|
|
t.Fatalf("run outcome=(%+v, %v), want success", outcome.result, outcome.err)
|
|
}
|
|
}
|
|
if _, peak, calls := client.snapshot(); peak > 2 || calls != 6 {
|
|
t.Fatalf("client observations=(peak=%d calls=%d), want peak <= 2 and 6 calls", peak, calls)
|
|
}
|
|
}
|
|
|
|
func TestEngineRejectsRunBeforeCompletionWhenAdmissionIsFull(t *testing.T) {
|
|
artifactRelease := make(chan struct{})
|
|
reader := &capacityArtifactReader{
|
|
entered: make(chan struct{}, 2),
|
|
release: artifactRelease,
|
|
}
|
|
client := newCapacityGateClient(closedCapacityChannel(), 2)
|
|
engine := newBackendCapacityEngine(t, client, 1, capacityInt(0), reader)
|
|
firstResult := make(chan capacityRunResult, 1)
|
|
go runCapacityRequest(engine, context.Background(), capacityInputRequest("http://first.example/v1"), firstResult)
|
|
|
|
awaitCapacitySignal(t, reader.entered, "first artifact read")
|
|
|
|
canceledContext, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
result, err := engine.Run(canceledContext, capacityInputRequest("http://canceled.example/v1"))
|
|
if result != nil || !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("canceled capacity admission=(%+v, %v), want context cancellation", result, err)
|
|
}
|
|
var canceledCapacityErr *promptkit.CapacityError
|
|
if errors.Is(err, promptkit.ErrCapacityExceeded) || errors.As(err, &canceledCapacityErr) {
|
|
t.Fatalf("canceled admission exposed capacity rejection: %v", err)
|
|
}
|
|
|
|
result, err = engine.Run(context.Background(), capacityInputRequest("http://second.example/v1"))
|
|
if result != nil {
|
|
t.Fatalf("capacity rejection returned partial result: %+v", result)
|
|
}
|
|
if !errors.Is(err, promptkit.ErrCapacityExceeded) {
|
|
t.Fatalf("capacity rejection=%v, want ErrCapacityExceeded", err)
|
|
}
|
|
if errors.Is(err, promptkit.ErrInvalidRequest) || errors.Is(err, promptkit.ErrLLMGenerate) {
|
|
t.Fatalf("capacity rejection had an unrelated category: %v", err)
|
|
}
|
|
var capacityErr *promptkit.CapacityError
|
|
if !errors.As(err, &capacityErr) || capacityErr == nil {
|
|
t.Fatalf("capacity rejection=%v, want CapacityError", err)
|
|
}
|
|
if capacityErr.BackendID != "limited" {
|
|
t.Fatalf("capacity backend ID=%q, want limited", capacityErr.BackendID)
|
|
}
|
|
capacityErr.BackendID = "changed"
|
|
|
|
result, err = engine.Run(context.Background(), capacityInputRequest("http://third.example/v1"))
|
|
var subsequentCapacityErr *promptkit.CapacityError
|
|
if result != nil || !errors.As(err, &subsequentCapacityErr) ||
|
|
subsequentCapacityErr == nil || subsequentCapacityErr.BackendID != "limited" {
|
|
t.Fatalf("subsequent capacity rejection=(%+v, %v), want independent limited CapacityError", result, err)
|
|
}
|
|
if calls := reader.callCount(); calls != 1 {
|
|
t.Fatalf("artifact calls=%d, want only the admitted run", calls)
|
|
}
|
|
if _, _, calls := client.snapshot(); calls != 0 {
|
|
t.Fatalf("client calls=%d before admitted run was released, want 0", calls)
|
|
}
|
|
|
|
close(artifactRelease)
|
|
outcome := awaitCapacityRun(t, firstResult)
|
|
if outcome.err != nil || outcome.result == nil {
|
|
t.Fatalf("first run outcome=(%+v, %v), want success", outcome.result, outcome.err)
|
|
}
|
|
}
|
|
|
|
func TestBackendCapacityIsIndependentBetweenEngines(t *testing.T) {
|
|
firstRelease := make(chan struct{})
|
|
firstClient := newCapacityGateClient(firstRelease, 1)
|
|
firstEngine := newBackendCapacityEngine(t, firstClient, 1, capacityInt(0), nil)
|
|
secondClient := newCapacityGateClient(closedCapacityChannel(), 1)
|
|
secondEngine := newBackendCapacityEngine(t, secondClient, 1, capacityInt(0), nil)
|
|
|
|
firstResult := make(chan capacityRunResult, 1)
|
|
go runCapacityRequest(firstEngine, context.Background(), promptkit.RunRequest{PromptID: "prompt"}, firstResult)
|
|
awaitCapacityRequest(t, firstClient.started)
|
|
|
|
result, err := secondEngine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt"})
|
|
if err != nil || result == nil {
|
|
t.Fatalf("second engine run=(%+v, %v), want independent success", result, err)
|
|
}
|
|
if _, _, calls := secondClient.snapshot(); calls != 1 {
|
|
t.Fatalf("second engine client calls=%d, want 1", calls)
|
|
}
|
|
|
|
close(firstRelease)
|
|
outcome := awaitCapacityRun(t, firstResult)
|
|
if outcome.err != nil || outcome.result == nil {
|
|
t.Fatalf("first engine run=(%+v, %v), want success", outcome.result, outcome.err)
|
|
}
|
|
}
|
|
|
|
func TestUnlimitedBackendsRetainInjectedClientConcurrency(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
configure func(*testing.T, promptkit.LLMClient) *promptkit.Engine
|
|
}{
|
|
{
|
|
name: "custom backend",
|
|
configure: func(t *testing.T, client promptkit.LLMClient) *promptkit.Engine {
|
|
return newBackendCapacityEngine(t, client, 0, nil, nil)
|
|
},
|
|
},
|
|
{
|
|
name: "endpoint-only profile",
|
|
configure: func(t *testing.T, client promptkit.LLMClient) *promptkit.Engine {
|
|
engine, err := promptkit.NewEngine(promptkit.Config{},
|
|
promptkit.WithPromptFS(contractPromptFS("prompt", "profile", "message"), "."),
|
|
promptkit.WithProfiles(promptkit.Profile{
|
|
ID: "profile", Endpoint: "http://endpoint.example/v1", Model: "model",
|
|
}),
|
|
promptkit.WithLLMClient(client),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("construct endpoint-only engine: %v", err)
|
|
}
|
|
return engine
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
release := make(chan struct{})
|
|
client := newCapacityGateClient(release, 2)
|
|
engine := tc.configure(t, client)
|
|
results := make(chan capacityRunResult, 2)
|
|
for i := 0; i < 2; i++ {
|
|
go runCapacityRequest(
|
|
engine,
|
|
context.Background(),
|
|
promptkit.RunRequest{PromptID: "prompt"},
|
|
results,
|
|
)
|
|
}
|
|
|
|
awaitCapacityRequest(t, client.started)
|
|
awaitCapacityRequest(t, client.started)
|
|
if active, peak, _ := client.snapshot(); active != 2 || peak != 2 {
|
|
t.Fatalf("unlimited concurrency=(active=%d peak=%d), want 2", active, peak)
|
|
}
|
|
close(release)
|
|
for i := 0; i < 2; i++ {
|
|
outcome := awaitCapacityRun(t, results)
|
|
if outcome.err != nil || outcome.result == nil {
|
|
t.Fatalf("run outcome=(%+v, %v), want success", outcome.result, outcome.err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCapacityExceededSentinelContract(t *testing.T) {
|
|
if promptkit.ErrCapacityExceeded == nil {
|
|
t.Fatal("ErrCapacityExceeded is nil")
|
|
}
|
|
var nilCapacityErr *promptkit.CapacityError
|
|
zeroCapacityErr := &promptkit.CapacityError{}
|
|
populatedCapacityErr := &promptkit.CapacityError{BackendID: "limited"}
|
|
for _, capacityErr := range []error{nilCapacityErr, zeroCapacityErr} {
|
|
if !errors.Is(capacityErr, promptkit.ErrCapacityExceeded) {
|
|
t.Fatalf("capacity error=%v, want ErrCapacityExceeded", capacityErr)
|
|
}
|
|
}
|
|
var discoveredCapacityErr *promptkit.CapacityError
|
|
if !errors.As(populatedCapacityErr, &discoveredCapacityErr) || discoveredCapacityErr != populatedCapacityErr {
|
|
t.Fatalf("populated capacity error is not discoverable: %v", populatedCapacityErr)
|
|
}
|
|
|
|
for _, unrelated := range []error{
|
|
promptkit.ErrInvalidConfig,
|
|
promptkit.ErrInvalidRequest,
|
|
promptkit.ErrLLMGenerate,
|
|
promptkit.ErrValidation,
|
|
} {
|
|
if errors.Is(promptkit.ErrCapacityExceeded, unrelated) ||
|
|
errors.Is(unrelated, promptkit.ErrCapacityExceeded) ||
|
|
errors.Is(populatedCapacityErr, unrelated) {
|
|
t.Fatalf("ErrCapacityExceeded aliases unrelated sentinel %v", unrelated)
|
|
}
|
|
}
|
|
}
|
|
|
|
type capacityRunResult struct {
|
|
result *promptkit.RunResult
|
|
err error
|
|
}
|
|
|
|
func runCapacityRequest(
|
|
engine *promptkit.Engine,
|
|
ctx context.Context,
|
|
request promptkit.RunRequest,
|
|
results chan<- capacityRunResult,
|
|
) {
|
|
result, err := engine.Run(ctx, request)
|
|
results <- capacityRunResult{result: result, err: err}
|
|
}
|
|
|
|
func newBackendCapacityEngine(
|
|
t *testing.T,
|
|
client promptkit.LLMClient,
|
|
limit int,
|
|
queueCapacity *int,
|
|
reader promptkit.ArtifactReader,
|
|
) *promptkit.Engine {
|
|
t.Helper()
|
|
promptFS := contractPromptFS("prompt", "profile", "message")
|
|
if reader != nil {
|
|
promptFS = contractInputPromptFS()
|
|
}
|
|
options := []promptkit.Option{
|
|
promptkit.WithPromptFS(promptFS, "."),
|
|
promptkit.WithBackend(promptkit.Backend{
|
|
ID: "limited",
|
|
Endpoint: "http://backend.example/v1",
|
|
ConcurrencyLimit: limit,
|
|
QueueCapacity: queueCapacity,
|
|
}),
|
|
promptkit.WithProfiles(promptkit.Profile{
|
|
ID: "profile", BackendID: "limited", Model: "model",
|
|
}),
|
|
promptkit.WithLLMClient(client),
|
|
}
|
|
if reader != nil {
|
|
options = append(options, promptkit.WithArtifactReader(reader))
|
|
}
|
|
engine, err := promptkit.NewEngine(promptkit.Config{}, options...)
|
|
if err != nil {
|
|
t.Fatalf("construct capacity engine: %v", err)
|
|
}
|
|
return engine
|
|
}
|
|
|
|
func capacityInputRequest(endpoint string) promptkit.RunRequest {
|
|
return promptkit.RunRequest{
|
|
PromptID: "input-prompt",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"input": promptkit.Inline("input"),
|
|
},
|
|
Execution: &promptkit.ExecutionTargetOverride{Endpoint: endpoint},
|
|
}
|
|
}
|
|
|
|
type capacityGateClient struct {
|
|
mu sync.Mutex
|
|
active int
|
|
peak int
|
|
calls int
|
|
started chan promptkit.GenerateRequest
|
|
release <-chan struct{}
|
|
}
|
|
|
|
func newCapacityGateClient(release <-chan struct{}, buffer int) *capacityGateClient {
|
|
return &capacityGateClient{
|
|
started: make(chan promptkit.GenerateRequest, buffer),
|
|
release: release,
|
|
}
|
|
}
|
|
|
|
func (c *capacityGateClient) Generate(
|
|
ctx context.Context,
|
|
request promptkit.GenerateRequest,
|
|
) (*promptkit.GenerateResponse, error) {
|
|
c.mu.Lock()
|
|
c.calls++
|
|
c.active++
|
|
if c.active > c.peak {
|
|
c.peak = c.active
|
|
}
|
|
c.mu.Unlock()
|
|
defer func() {
|
|
c.mu.Lock()
|
|
c.active--
|
|
c.mu.Unlock()
|
|
}()
|
|
|
|
c.started <- request
|
|
select {
|
|
case <-c.release:
|
|
return &promptkit.GenerateResponse{Content: "ok"}, nil
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (c *capacityGateClient) snapshot() (active, peak, calls int) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.active, c.peak, c.calls
|
|
}
|
|
|
|
type capacityArtifactReader struct {
|
|
mu sync.Mutex
|
|
calls int
|
|
entered chan struct{}
|
|
release <-chan struct{}
|
|
}
|
|
|
|
func (r *capacityArtifactReader) Read(
|
|
ctx context.Context,
|
|
_ promptkit.ArtifactRef,
|
|
) (*promptkit.Artifact, error) {
|
|
r.mu.Lock()
|
|
r.calls++
|
|
r.mu.Unlock()
|
|
r.entered <- struct{}{}
|
|
select {
|
|
case <-r.release:
|
|
return &promptkit.Artifact{Body: []byte("input")}, nil
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (r *capacityArtifactReader) callCount() int {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.calls
|
|
}
|
|
|
|
func awaitCapacityRequest(
|
|
t *testing.T,
|
|
requests <-chan promptkit.GenerateRequest,
|
|
) promptkit.GenerateRequest {
|
|
t.Helper()
|
|
select {
|
|
case request := <-requests:
|
|
return request
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for client invocation")
|
|
return promptkit.GenerateRequest{}
|
|
}
|
|
}
|
|
|
|
func awaitCapacityRun(t *testing.T, results <-chan capacityRunResult) capacityRunResult {
|
|
t.Helper()
|
|
select {
|
|
case result := <-results:
|
|
return result
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for Run")
|
|
return capacityRunResult{}
|
|
}
|
|
}
|
|
|
|
func awaitCapacitySignal(t *testing.T, signal <-chan struct{}, name string) {
|
|
t.Helper()
|
|
select {
|
|
case <-signal:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatalf("timed out waiting for %s", name)
|
|
}
|
|
}
|
|
|
|
func capacityInt(value int) *int {
|
|
return &value
|
|
}
|
|
|
|
func closedCapacityChannel() <-chan struct{} {
|
|
channel := make(chan struct{})
|
|
close(channel)
|
|
return channel
|
|
}
|