Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
205
backend/internal/redisqueue/plugin.go
Normal file
205
backend/internal/redisqueue/plugin.go
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
package redisqueue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
internallogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
|
||||
coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
|
||||
)
|
||||
|
||||
func init() {
|
||||
coreusage.RegisterPlugin(&usageQueuePlugin{})
|
||||
}
|
||||
|
||||
type usageQueuePlugin struct{}
|
||||
|
||||
func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Record) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !Enabled() || !UsageStatisticsEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := record.RequestedAt
|
||||
if timestamp.IsZero() {
|
||||
timestamp = time.Now()
|
||||
}
|
||||
|
||||
modelName := strings.TrimSpace(record.Model)
|
||||
if modelName == "" {
|
||||
modelName = "unknown"
|
||||
}
|
||||
aliasName := strings.TrimSpace(record.Alias)
|
||||
if aliasName == "" {
|
||||
aliasName = modelName
|
||||
}
|
||||
provider := strings.TrimSpace(record.Provider)
|
||||
if provider == "" {
|
||||
provider = "unknown"
|
||||
}
|
||||
executorType := strings.TrimSpace(record.ExecutorType)
|
||||
if executorType == "" {
|
||||
executorType = "unknown"
|
||||
}
|
||||
authType := strings.TrimSpace(record.AuthType)
|
||||
if authType == "" {
|
||||
authType = "unknown"
|
||||
}
|
||||
apiKey := strings.TrimSpace(record.APIKey)
|
||||
requestID := strings.TrimSpace(internallogging.GetRequestID(ctx))
|
||||
reasoningEffort := strings.TrimSpace(record.ReasoningEffort)
|
||||
if reasoningEffort == "" {
|
||||
reasoningEffort = coreusage.ReasoningEffortFromContext(ctx)
|
||||
}
|
||||
serviceTier := strings.TrimSpace(record.ServiceTier)
|
||||
if serviceTier == "" {
|
||||
serviceTier = strings.TrimSpace(record.RequestServiceTier)
|
||||
}
|
||||
if serviceTier == "" {
|
||||
serviceTier = coreusage.ServiceTierFromContext(ctx)
|
||||
}
|
||||
responseServiceTier := strings.TrimSpace(record.ResponseServiceTier)
|
||||
clientRequestMetadata := internallogging.GetClientRequestMetadata(ctx)
|
||||
|
||||
usageDetail := coreusage.EnsureTokenBreakdownForProvider(record.Detail, record.Provider, record.ExecutorType)
|
||||
tokens := tokenStats{
|
||||
InputTokens: usageDetail.InputTokens,
|
||||
OutputTokens: usageDetail.OutputTokens,
|
||||
ReasoningTokens: usageDetail.ReasoningTokens,
|
||||
CachedTokens: usageDetail.CachedTokens,
|
||||
CacheReadTokens: usageDetail.CacheReadTokens,
|
||||
CacheReadTokensPresent: true,
|
||||
CacheCreationTokens: usageDetail.CacheCreationTokens,
|
||||
TotalTokens: usageDetail.TotalTokens,
|
||||
}
|
||||
|
||||
failed := record.Failed
|
||||
if !failed {
|
||||
failed = !resolveSuccess(ctx)
|
||||
}
|
||||
fail := resolveFail(ctx, record, failed)
|
||||
|
||||
detail := requestDetail{
|
||||
Timestamp: timestamp,
|
||||
LatencyMs: record.Latency.Milliseconds(),
|
||||
TTFTMs: record.TTFT.Milliseconds(),
|
||||
Source: record.Source,
|
||||
AuthIndex: record.AuthIndex,
|
||||
AccessTokenHash: record.AccessTokenSHA256,
|
||||
ClientIP: clientRequestMetadata.ClientIP,
|
||||
XForwardedFor: clientRequestMetadata.XForwardedFor,
|
||||
UserAgent: clientRequestMetadata.UserAgent,
|
||||
Tokens: tokens,
|
||||
Failed: failed,
|
||||
Generate: coreusage.GenerateEnabled(record.Generate),
|
||||
Fail: fail,
|
||||
ResponseHeaders: record.ResponseHeaders,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(queuedUsageDetail{
|
||||
requestDetail: detail,
|
||||
AccountingVersion: coreusage.TokenAccountingSchemaVersion,
|
||||
TokenBreakdown: usageDetail.TokenBreakdown,
|
||||
Provider: provider,
|
||||
ExecutorType: executorType,
|
||||
Model: modelName,
|
||||
Alias: aliasName,
|
||||
Endpoint: resolveEndpoint(ctx),
|
||||
AuthType: authType,
|
||||
APIKey: apiKey,
|
||||
RequestID: requestID,
|
||||
ReasoningEffort: reasoningEffort,
|
||||
ServiceTier: serviceTier,
|
||||
ResponseServiceTier: responseServiceTier,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
Enqueue(payload)
|
||||
}
|
||||
|
||||
type queuedUsageDetail struct {
|
||||
requestDetail
|
||||
AccountingVersion int `json:"accounting_version"`
|
||||
TokenBreakdown coreusage.TokenBreakdown `json:"token_breakdown"`
|
||||
Provider string `json:"provider"`
|
||||
ExecutorType string `json:"executor_type"`
|
||||
Model string `json:"model"`
|
||||
Alias string `json:"alias"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
AuthType string `json:"auth_type"`
|
||||
APIKey string `json:"api_key"`
|
||||
RequestID string `json:"request_id"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
ServiceTier string `json:"service_tier"`
|
||||
ResponseServiceTier string `json:"response_service_tier,omitempty"`
|
||||
}
|
||||
|
||||
type requestDetail struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
LatencyMs int64 `json:"latency_ms"`
|
||||
TTFTMs int64 `json:"ttft_ms"`
|
||||
Source string `json:"source"`
|
||||
AuthIndex string `json:"auth_index"`
|
||||
AccessTokenHash string `json:"access_token_sha256,omitempty"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
XForwardedFor string `json:"x_forwarded_for"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Tokens tokenStats `json:"tokens"`
|
||||
Failed bool `json:"failed"`
|
||||
Generate bool `json:"generate"`
|
||||
Fail failDetail `json:"fail"`
|
||||
ResponseHeaders http.Header `json:"response_headers,omitempty"`
|
||||
}
|
||||
|
||||
type tokenStats struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
ReasoningTokens int64 `json:"reasoning_tokens"`
|
||||
CachedTokens int64 `json:"cached_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
CacheReadTokensPresent bool `json:"cache_read_tokens_present"`
|
||||
CacheCreationTokens int64 `json:"cache_creation_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
}
|
||||
|
||||
type failDetail struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
func resolveFail(ctx context.Context, record coreusage.Record, failed bool) failDetail {
|
||||
fail := failDetail{
|
||||
StatusCode: record.Fail.StatusCode,
|
||||
Body: strings.TrimSpace(record.Fail.Body),
|
||||
}
|
||||
if !failed {
|
||||
return failDetail{StatusCode: 200}
|
||||
}
|
||||
if fail.StatusCode <= 0 {
|
||||
fail.StatusCode = internallogging.GetResponseStatus(ctx)
|
||||
}
|
||||
if fail.StatusCode <= 0 {
|
||||
fail.StatusCode = 500
|
||||
}
|
||||
return fail
|
||||
}
|
||||
|
||||
func resolveSuccess(ctx context.Context) bool {
|
||||
status := internallogging.GetResponseStatus(ctx)
|
||||
if status == 0 {
|
||||
return true
|
||||
}
|
||||
return status < httpStatusBadRequest
|
||||
}
|
||||
|
||||
func resolveEndpoint(ctx context.Context) string {
|
||||
return strings.TrimSpace(internallogging.GetEndpoint(ctx))
|
||||
}
|
||||
|
||||
const httpStatusBadRequest = 400
|
||||
561
backend/internal/redisqueue/plugin_test.go
Normal file
561
backend/internal/redisqueue/plugin_test.go
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
package redisqueue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
internallogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
|
||||
coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
|
||||
)
|
||||
|
||||
func TestUsageQueuePluginPayloadIncludesStableFieldsAndSuccess(t *testing.T) {
|
||||
withEnabledQueue(t, func() {
|
||||
ctx := internallogging.WithRequestID(context.Background(), "ctx-request-id")
|
||||
ctx = internallogging.WithEndpoint(ctx, "POST /v1/chat/completions")
|
||||
ctx = internallogging.WithClientRequestMetadata(ctx, internallogging.ClientRequestMetadata{
|
||||
ClientIP: "192.0.2.10",
|
||||
XForwardedFor: "203.0.113.5, 198.51.100.8",
|
||||
UserAgent: "test-client/1.0",
|
||||
})
|
||||
ctx = internallogging.WithResponseStatusHolder(ctx)
|
||||
internallogging.SetResponseStatus(ctx, http.StatusOK)
|
||||
responseHeaders := http.Header{}
|
||||
responseHeaders.Add("X-Upstream-Request-Id", "upstream-req-1")
|
||||
responseHeaders.Add("Retry-After", "30")
|
||||
|
||||
plugin := &usageQueuePlugin{}
|
||||
plugin.HandleUsage(ctx, coreusage.Record{
|
||||
Provider: "openai",
|
||||
ExecutorType: "KimiExecutor",
|
||||
Model: "gpt-5.4",
|
||||
Alias: "client-gpt",
|
||||
APIKey: "test-key",
|
||||
AuthIndex: "0",
|
||||
AccessTokenSHA256: "token-version-hash",
|
||||
AuthType: "apikey",
|
||||
Source: "user@example.com",
|
||||
ReasoningEffort: "medium",
|
||||
ServiceTier: "auto",
|
||||
ResponseServiceTier: "default",
|
||||
Generate: coreusage.GenerateFlag(true),
|
||||
RequestedAt: time.Date(2026, 4, 25, 0, 0, 0, 0, time.UTC),
|
||||
Latency: 1500 * time.Millisecond,
|
||||
Detail: coreusage.Detail{
|
||||
InputTokens: 10,
|
||||
OutputTokens: 20,
|
||||
TotalTokens: 30,
|
||||
},
|
||||
ResponseHeaders: responseHeaders.Clone(),
|
||||
})
|
||||
responseHeaders.Set("Retry-After", "999")
|
||||
|
||||
payload := popSinglePayload(t)
|
||||
requireStringField(t, payload, "provider", "openai")
|
||||
requireStringField(t, payload, "executor_type", "KimiExecutor")
|
||||
requireStringField(t, payload, "model", "gpt-5.4")
|
||||
requireStringField(t, payload, "alias", "client-gpt")
|
||||
requireStringField(t, payload, "endpoint", "POST /v1/chat/completions")
|
||||
requireStringField(t, payload, "auth_type", "apikey")
|
||||
requireStringField(t, payload, "access_token_sha256", "token-version-hash")
|
||||
requireMissingField(t, payload, "user_api_key")
|
||||
requireStringField(t, payload, "request_id", "ctx-request-id")
|
||||
requireStringField(t, payload, "client_ip", "192.0.2.10")
|
||||
requireStringField(t, payload, "x_forwarded_for", "203.0.113.5, 198.51.100.8")
|
||||
requireStringField(t, payload, "user_agent", "test-client/1.0")
|
||||
requireStringField(t, payload, "reasoning_effort", "medium")
|
||||
requireStringField(t, payload, "service_tier", "auto")
|
||||
requireMissingField(t, payload, "request_service_tier")
|
||||
requireStringField(t, payload, "response_service_tier", "default")
|
||||
requireIntField(t, payload, "accounting_version", coreusage.TokenAccountingSchemaVersion)
|
||||
requireTokenBreakdown(t, payload, coreusage.TokenAccountingQualityComplete, 30)
|
||||
requireTokensBoolField(t, payload, "cache_read_tokens_present", true)
|
||||
requireHeaderField(t, payload, "response_headers", "X-Upstream-Request-Id", []string{"upstream-req-1"})
|
||||
requireHeaderField(t, payload, "response_headers", "Retry-After", []string{"30"})
|
||||
requireBoolField(t, payload, "failed", false)
|
||||
requireBoolField(t, payload, "generate", true)
|
||||
requireFailField(t, payload, http.StatusOK, "")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsageQueuePluginNormalizesDirectSDKUsageByProvider(t *testing.T) {
|
||||
tests := []struct {
|
||||
provider string
|
||||
wantTotal int
|
||||
}{
|
||||
{provider: "openai", wantTotal: 130},
|
||||
{provider: "gemini", wantTotal: 142},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.provider, func(t *testing.T) {
|
||||
withEnabledQueue(t, func() {
|
||||
ctx := internallogging.WithResponseStatusHolder(context.Background())
|
||||
internallogging.SetResponseStatus(ctx, http.StatusOK)
|
||||
|
||||
(&usageQueuePlugin{}).HandleUsage(ctx, coreusage.Record{
|
||||
Provider: tt.provider,
|
||||
Model: "direct-sdk-model",
|
||||
Detail: coreusage.Detail{
|
||||
InputTokens: 100,
|
||||
OutputTokens: 30,
|
||||
ReasoningTokens: 12,
|
||||
},
|
||||
})
|
||||
|
||||
payload := popSinglePayload(t)
|
||||
requireIntField(t, requireTokensPayload(t, payload), "total_tokens", tt.wantTotal)
|
||||
requireTokenBreakdown(t, payload, coreusage.TokenAccountingQualityComplete, int64(tt.wantTotal))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageQueuePluginPayloadIncludesGenerateFalse(t *testing.T) {
|
||||
withEnabledQueue(t, func() {
|
||||
ctx := internallogging.WithResponseStatusHolder(context.Background())
|
||||
internallogging.SetResponseStatus(ctx, http.StatusOK)
|
||||
|
||||
(&usageQueuePlugin{}).HandleUsage(ctx, coreusage.Record{
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.4",
|
||||
Generate: coreusage.GenerateFlag(false),
|
||||
Detail: coreusage.Detail{
|
||||
InputTokens: 1,
|
||||
TotalTokens: 1,
|
||||
},
|
||||
})
|
||||
|
||||
payload := popSinglePayload(t)
|
||||
requireBoolField(t, payload, "generate", false)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsageQueuePluginPayloadDefaultsGenerateTrueWhenOmitted(t *testing.T) {
|
||||
withEnabledQueue(t, func() {
|
||||
ctx := internallogging.WithResponseStatusHolder(context.Background())
|
||||
internallogging.SetResponseStatus(ctx, http.StatusOK)
|
||||
|
||||
// Legacy callers construct usage.Record without Generate; omission must publish as true.
|
||||
(&usageQueuePlugin{}).HandleUsage(ctx, coreusage.Record{
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.4",
|
||||
Detail: coreusage.Detail{
|
||||
InputTokens: 1,
|
||||
TotalTokens: 1,
|
||||
},
|
||||
})
|
||||
|
||||
payload := popSinglePayload(t)
|
||||
requireBoolField(t, payload, "generate", true)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsageQueuePluginPreservesLegacyCachedOnlyUsage(t *testing.T) {
|
||||
withEnabledQueue(t, func() {
|
||||
ctx := internallogging.WithResponseStatusHolder(context.Background())
|
||||
internallogging.SetResponseStatus(ctx, http.StatusOK)
|
||||
|
||||
(&usageQueuePlugin{}).HandleUsage(ctx, coreusage.Record{
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.4",
|
||||
Detail: coreusage.Detail{
|
||||
CachedTokens: 13,
|
||||
},
|
||||
})
|
||||
|
||||
payload := popSinglePayload(t)
|
||||
requireTokensBoolField(t, payload, "cache_read_tokens_present", true)
|
||||
tokens := requireTokensPayload(t, payload)
|
||||
requireIntField(t, tokens, "cache_read_tokens", 13)
|
||||
requireIntField(t, tokens, "total_tokens", 13)
|
||||
requireTokenBreakdown(t, payload, coreusage.TokenAccountingQualityUnclassified, 13)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsageQueuePluginEmitsSingleCanonicalAutoTier(t *testing.T) {
|
||||
withEnabledQueue(t, func() {
|
||||
ctx := coreusage.WithServiceTier(context.Background(), coreusage.AutoServiceTier)
|
||||
ctx = internallogging.WithResponseStatusHolder(ctx)
|
||||
internallogging.SetResponseStatus(ctx, http.StatusOK)
|
||||
|
||||
(&usageQueuePlugin{}).HandleUsage(ctx, coreusage.Record{
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.4",
|
||||
Detail: coreusage.Detail{
|
||||
InputTokens: 1,
|
||||
TotalTokens: 1,
|
||||
},
|
||||
})
|
||||
|
||||
payload := popSinglePayload(t)
|
||||
requireStringField(t, payload, "service_tier", "auto")
|
||||
requireMissingField(t, payload, "request_service_tier")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsageQueuePluginAcceptsDeprecatedRequestTierRecordField(t *testing.T) {
|
||||
withEnabledQueue(t, func() {
|
||||
ctx := internallogging.WithResponseStatusHolder(context.Background())
|
||||
internallogging.SetResponseStatus(ctx, http.StatusOK)
|
||||
|
||||
(&usageQueuePlugin{}).HandleUsage(ctx, coreusage.Record{
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.4",
|
||||
RequestServiceTier: "priority",
|
||||
Detail: coreusage.Detail{InputTokens: 1, TotalTokens: 1},
|
||||
})
|
||||
|
||||
payload := popSinglePayload(t)
|
||||
requireStringField(t, payload, "service_tier", "priority")
|
||||
requireMissingField(t, payload, "request_service_tier")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsageQueuePluginAsyncUsesRecordResponseHeaders(t *testing.T) {
|
||||
withEnabledQueue(t, func() {
|
||||
ctx := internallogging.WithRequestID(context.Background(), "ctx-request-id")
|
||||
ctx = internallogging.WithEndpoint(ctx, "POST /v1/chat/completions")
|
||||
ctx = internallogging.WithResponseStatusHolder(ctx)
|
||||
ctx = internallogging.WithResponseHeadersHolder(ctx)
|
||||
internallogging.SetResponseStatus(ctx, http.StatusOK)
|
||||
initialHeaders := http.Header{}
|
||||
initialHeaders.Set("X-Upstream-Request-Id", "upstream-req-1")
|
||||
internallogging.SetResponseHeaders(ctx, initialHeaders)
|
||||
|
||||
mgr := coreusage.NewManager(16)
|
||||
defer mgr.Stop()
|
||||
|
||||
mgr.Register(pluginFunc(func(ctx context.Context, _ coreusage.Record) {
|
||||
nextHeaders := http.Header{}
|
||||
nextHeaders.Set("X-Upstream-Request-Id", "upstream-req-2")
|
||||
internallogging.SetResponseHeaders(ctx, nextHeaders)
|
||||
}))
|
||||
mgr.Register(&usageQueuePlugin{})
|
||||
|
||||
mgr.Publish(ctx, coreusage.Record{
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.4",
|
||||
Alias: "client-gpt",
|
||||
APIKey: "test-key",
|
||||
AuthIndex: "0",
|
||||
AuthType: "apikey",
|
||||
Source: "user@example.com",
|
||||
RequestedAt: time.Date(2026, 4, 25, 0, 0, 0, 0, time.UTC),
|
||||
Latency: 1500 * time.Millisecond,
|
||||
Detail: coreusage.Detail{
|
||||
InputTokens: 10,
|
||||
OutputTokens: 20,
|
||||
TotalTokens: 30,
|
||||
},
|
||||
ResponseHeaders: internallogging.GetResponseHeaders(ctx),
|
||||
})
|
||||
|
||||
payload := waitForSinglePayload(t, 2*time.Second)
|
||||
requireHeaderField(t, payload, "response_headers", "X-Upstream-Request-Id", []string{"upstream-req-1"})
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsageQueuePluginPayloadIncludesStableFieldsAndFailureAndGinRequestID(t *testing.T) {
|
||||
withEnabledQueue(t, func() {
|
||||
ctx := internallogging.WithRequestID(context.Background(), "gin-request-id")
|
||||
ctx = internallogging.WithEndpoint(ctx, "GET /v1/responses")
|
||||
ctx = internallogging.WithResponseStatusHolder(ctx)
|
||||
internallogging.SetResponseStatus(ctx, http.StatusInternalServerError)
|
||||
|
||||
plugin := &usageQueuePlugin{}
|
||||
plugin.HandleUsage(ctx, coreusage.Record{
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.4-mini",
|
||||
Alias: "client-mini",
|
||||
APIKey: "test-key",
|
||||
AuthIndex: "0",
|
||||
AuthType: "apikey",
|
||||
Source: "user@example.com",
|
||||
RequestedAt: time.Date(2026, 4, 25, 0, 0, 0, 0, time.UTC),
|
||||
Latency: 2500 * time.Millisecond,
|
||||
Fail: coreusage.Failure{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Body: "upstream failed",
|
||||
},
|
||||
Detail: coreusage.Detail{
|
||||
InputTokens: 10,
|
||||
OutputTokens: 20,
|
||||
TotalTokens: 30,
|
||||
},
|
||||
})
|
||||
|
||||
payload := popSinglePayload(t)
|
||||
requireStringField(t, payload, "provider", "openai")
|
||||
requireStringField(t, payload, "model", "gpt-5.4-mini")
|
||||
requireStringField(t, payload, "alias", "client-mini")
|
||||
requireStringField(t, payload, "endpoint", "GET /v1/responses")
|
||||
requireStringField(t, payload, "auth_type", "apikey")
|
||||
requireMissingField(t, payload, "user_api_key")
|
||||
requireStringField(t, payload, "request_id", "gin-request-id")
|
||||
requireBoolField(t, payload, "failed", true)
|
||||
requireFailField(t, payload, http.StatusInternalServerError, "upstream failed")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsageQueuePluginAsyncIgnoresRecycledGinContext(t *testing.T) {
|
||||
withEnabledQueue(t, func() {
|
||||
ginCtx := newTestGinContext(t, http.MethodPost, "/v1/chat/completions", http.StatusOK)
|
||||
ctx := context.WithValue(context.Background(), "gin", ginCtx)
|
||||
ctx = internallogging.WithRequestID(ctx, "ctx-request-id")
|
||||
ctx = internallogging.WithEndpoint(ctx, "POST /v1/chat/completions")
|
||||
ctx = internallogging.WithResponseStatusHolder(ctx)
|
||||
internallogging.SetResponseStatus(ctx, http.StatusInternalServerError)
|
||||
|
||||
mgr := coreusage.NewManager(16)
|
||||
defer mgr.Stop()
|
||||
|
||||
mgr.Register(pluginFunc(func(_ context.Context, _ coreusage.Record) {
|
||||
ginCtx.Request = httptest.NewRequest(http.MethodGet, "http://example.com/v1/responses", nil)
|
||||
ginCtx.Status(http.StatusOK)
|
||||
}))
|
||||
mgr.Register(&usageQueuePlugin{})
|
||||
|
||||
mgr.Publish(ctx, coreusage.Record{
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.4",
|
||||
Alias: "client-gpt",
|
||||
APIKey: "test-key",
|
||||
AuthIndex: "0",
|
||||
AuthType: "apikey",
|
||||
Source: "user@example.com",
|
||||
RequestedAt: time.Date(2026, 4, 25, 0, 0, 0, 0, time.UTC),
|
||||
Latency: 1500 * time.Millisecond,
|
||||
Fail: coreusage.Failure{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Body: "bad gateway",
|
||||
},
|
||||
Detail: coreusage.Detail{
|
||||
InputTokens: 10,
|
||||
OutputTokens: 20,
|
||||
TotalTokens: 30,
|
||||
},
|
||||
})
|
||||
|
||||
payload := waitForSinglePayload(t, 2*time.Second)
|
||||
requireStringField(t, payload, "endpoint", "POST /v1/chat/completions")
|
||||
requireStringField(t, payload, "alias", "client-gpt")
|
||||
requireMissingField(t, payload, "user_api_key")
|
||||
requireStringField(t, payload, "request_id", "ctx-request-id")
|
||||
requireBoolField(t, payload, "failed", true)
|
||||
requireFailField(t, payload, http.StatusBadGateway, "bad gateway")
|
||||
})
|
||||
}
|
||||
|
||||
func withEnabledQueue(t *testing.T, fn func()) {
|
||||
t.Helper()
|
||||
|
||||
prevQueueEnabled := Enabled()
|
||||
prevUsageEnabled := UsageStatisticsEnabled()
|
||||
|
||||
SetEnabled(false)
|
||||
SetEnabled(true)
|
||||
SetUsageStatisticsEnabled(true)
|
||||
|
||||
defer func() {
|
||||
SetEnabled(false)
|
||||
SetEnabled(prevQueueEnabled)
|
||||
SetUsageStatisticsEnabled(prevUsageEnabled)
|
||||
}()
|
||||
|
||||
fn()
|
||||
}
|
||||
|
||||
func newTestGinContext(t *testing.T, method, path string, status int) *gin.Context {
|
||||
t.Helper()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ginCtx, _ := gin.CreateTestContext(recorder)
|
||||
ginCtx.Request = httptest.NewRequest(method, "http://example.com"+path, nil)
|
||||
if status != 0 {
|
||||
ginCtx.Status(status)
|
||||
}
|
||||
return ginCtx
|
||||
}
|
||||
|
||||
func popSinglePayload(t *testing.T) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
|
||||
items := PopOldest(10)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("PopOldest() items = %d, want 1", len(items))
|
||||
}
|
||||
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal(items[0], &payload); err != nil {
|
||||
t.Fatalf("unmarshal payload: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func waitForSinglePayload(t *testing.T, timeout time.Duration) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
items := PopOldest(10)
|
||||
if len(items) == 0 {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("PopOldest() items = %d, want 1", len(items))
|
||||
}
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal(items[0], &payload); err != nil {
|
||||
t.Fatalf("unmarshal payload: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
t.Fatalf("timeout waiting for queued payload")
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireStringField(t *testing.T, payload map[string]json.RawMessage, key, want string) {
|
||||
t.Helper()
|
||||
|
||||
raw, ok := payload[key]
|
||||
if !ok {
|
||||
t.Fatalf("payload missing %q", key)
|
||||
}
|
||||
var got string
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("unmarshal %q: %v", key, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("%s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func requireIntField(t *testing.T, payload map[string]json.RawMessage, key string, want int) {
|
||||
t.Helper()
|
||||
|
||||
raw, ok := payload[key]
|
||||
if !ok {
|
||||
t.Fatalf("payload missing %q", key)
|
||||
}
|
||||
var got int
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("unmarshal %q: %v", key, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("%s = %d, want %d", key, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func requireTokenBreakdown(t *testing.T, payload map[string]json.RawMessage, quality coreusage.TokenAccountingQuality, total int64) {
|
||||
t.Helper()
|
||||
|
||||
raw, ok := payload["token_breakdown"]
|
||||
if !ok {
|
||||
t.Fatal("payload missing token_breakdown")
|
||||
}
|
||||
var breakdown coreusage.TokenBreakdown
|
||||
if err := json.Unmarshal(raw, &breakdown); err != nil {
|
||||
t.Fatalf("unmarshal token_breakdown: %v", err)
|
||||
}
|
||||
if !breakdown.Valid() || breakdown.Quality != quality || breakdown.TotalTokens != total {
|
||||
t.Fatalf("token_breakdown = %+v, want quality=%s total=%d", breakdown, quality, total)
|
||||
}
|
||||
}
|
||||
|
||||
func requireMissingField(t *testing.T, payload map[string]json.RawMessage, key string) {
|
||||
t.Helper()
|
||||
|
||||
if _, ok := payload[key]; ok {
|
||||
t.Fatalf("payload unexpectedly contains %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
type pluginFunc func(context.Context, coreusage.Record)
|
||||
|
||||
func (fn pluginFunc) HandleUsage(ctx context.Context, record coreusage.Record) {
|
||||
fn(ctx, record)
|
||||
}
|
||||
|
||||
func requireBoolField(t *testing.T, payload map[string]json.RawMessage, key string, want bool) {
|
||||
t.Helper()
|
||||
|
||||
raw, ok := payload[key]
|
||||
if !ok {
|
||||
t.Fatalf("payload missing %q", key)
|
||||
}
|
||||
var got bool
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("unmarshal %q: %v", key, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("%s = %t, want %t", key, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func requireTokensPayload(t *testing.T, payload map[string]json.RawMessage) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
raw, ok := payload["tokens"]
|
||||
if !ok {
|
||||
t.Fatal("payload missing tokens")
|
||||
}
|
||||
var tokens map[string]json.RawMessage
|
||||
if errUnmarshal := json.Unmarshal(raw, &tokens); errUnmarshal != nil {
|
||||
t.Fatalf("unmarshal tokens: %v", errUnmarshal)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
func requireTokensBoolField(t *testing.T, payload map[string]json.RawMessage, key string, want bool) {
|
||||
t.Helper()
|
||||
requireBoolField(t, requireTokensPayload(t, payload), key, want)
|
||||
}
|
||||
|
||||
func requireFailField(t *testing.T, payload map[string]json.RawMessage, wantStatus int, wantBody string) {
|
||||
t.Helper()
|
||||
|
||||
raw, ok := payload["fail"]
|
||||
if !ok {
|
||||
t.Fatalf("payload missing %q", "fail")
|
||||
}
|
||||
var got struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("unmarshal fail: %v", err)
|
||||
}
|
||||
if got.StatusCode != wantStatus || got.Body != wantBody {
|
||||
t.Fatalf("fail = {status_code:%d body:%q}, want {status_code:%d body:%q}", got.StatusCode, got.Body, wantStatus, wantBody)
|
||||
}
|
||||
}
|
||||
|
||||
func requireHeaderField(t *testing.T, payload map[string]json.RawMessage, field, key string, want []string) {
|
||||
t.Helper()
|
||||
|
||||
raw, ok := payload[field]
|
||||
if !ok {
|
||||
t.Fatalf("payload missing %q", field)
|
||||
}
|
||||
var headers map[string][]string
|
||||
if err := json.Unmarshal(raw, &headers); err != nil {
|
||||
t.Fatalf("unmarshal %q: %v", field, err)
|
||||
}
|
||||
got, ok := headers[key]
|
||||
if !ok {
|
||||
t.Fatalf("%s missing header %q", field, key)
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%s[%q] = %v, want %v", field, key, got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("%s[%q] = %v, want %v", field, key, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
257
backend/internal/redisqueue/queue.go
Normal file
257
backend/internal/redisqueue/queue.go
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
package redisqueue
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRetentionSeconds int64 = 60
|
||||
maxRetentionSeconds int64 = 3600
|
||||
usageSubscriberBuffer = 256
|
||||
errorSubscriberBuffer = 256
|
||||
|
||||
usageSupportRefreshPayload = `{"support_refresh":true}`
|
||||
usageRefreshPayload = `{"refresh":true}`
|
||||
)
|
||||
|
||||
type queueItem struct {
|
||||
enqueuedAt time.Time
|
||||
payload []byte
|
||||
}
|
||||
|
||||
type queue struct {
|
||||
mu sync.Mutex
|
||||
items []queueItem
|
||||
head int
|
||||
subscribers map[uint64]chan []byte
|
||||
nextSubscriberID uint64
|
||||
}
|
||||
|
||||
var (
|
||||
enabled atomic.Bool
|
||||
retentionSeconds atomic.Int64
|
||||
global queue
|
||||
errorGlobal queue
|
||||
)
|
||||
|
||||
func init() {
|
||||
retentionSeconds.Store(defaultRetentionSeconds)
|
||||
}
|
||||
|
||||
func SetEnabled(value bool) {
|
||||
enabled.Store(value)
|
||||
if !value {
|
||||
global.clear()
|
||||
errorGlobal.clear()
|
||||
}
|
||||
}
|
||||
|
||||
func Enabled() bool {
|
||||
return enabled.Load()
|
||||
}
|
||||
|
||||
func SetRetentionSeconds(value int) {
|
||||
normalized := int64(value)
|
||||
if normalized <= 0 {
|
||||
normalized = defaultRetentionSeconds
|
||||
} else if normalized > maxRetentionSeconds {
|
||||
normalized = maxRetentionSeconds
|
||||
}
|
||||
retentionSeconds.Store(normalized)
|
||||
}
|
||||
|
||||
func Enqueue(payload []byte) {
|
||||
if !Enabled() {
|
||||
return
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return
|
||||
}
|
||||
if global.publishToSubscribers(payload) {
|
||||
return
|
||||
}
|
||||
global.enqueue(payload)
|
||||
}
|
||||
|
||||
func EnqueueError(payload []byte) {
|
||||
if !Enabled() {
|
||||
return
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return
|
||||
}
|
||||
errorGlobal.publishToSubscribers(payload)
|
||||
}
|
||||
|
||||
func PopOldest(count int) [][]byte {
|
||||
if !Enabled() {
|
||||
return nil
|
||||
}
|
||||
if count <= 0 {
|
||||
return nil
|
||||
}
|
||||
return global.popOldest(count)
|
||||
}
|
||||
|
||||
func SubscribeUsage() (<-chan []byte, func()) {
|
||||
return global.subscribe(usageSubscriberBuffer, []byte(usageSupportRefreshPayload))
|
||||
}
|
||||
|
||||
func SubscribeErrors() (<-chan []byte, func()) {
|
||||
return errorGlobal.subscribe(errorSubscriberBuffer, nil)
|
||||
}
|
||||
|
||||
func NotifyUsageRefresh() {
|
||||
global.publishToSubscribers([]byte(usageRefreshPayload))
|
||||
}
|
||||
|
||||
func (q *queue) clear() {
|
||||
q.mu.Lock()
|
||||
|
||||
subscribers := make([]chan []byte, 0, len(q.subscribers))
|
||||
for _, subscriber := range q.subscribers {
|
||||
subscribers = append(subscribers, subscriber)
|
||||
}
|
||||
q.items = nil
|
||||
q.head = 0
|
||||
q.subscribers = nil
|
||||
q.mu.Unlock()
|
||||
|
||||
for _, subscriber := range subscribers {
|
||||
close(subscriber)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *queue) enqueue(payload []byte) {
|
||||
now := time.Now()
|
||||
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
q.pruneLocked(now)
|
||||
q.items = append(q.items, queueItem{
|
||||
enqueuedAt: now,
|
||||
payload: append([]byte(nil), payload...),
|
||||
})
|
||||
q.maybeCompactLocked()
|
||||
}
|
||||
|
||||
func (q *queue) publishToSubscribers(payload []byte) bool {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
if len(q.subscribers) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for id, subscriber := range q.subscribers {
|
||||
cloned := append([]byte(nil), payload...)
|
||||
select {
|
||||
case subscriber <- cloned:
|
||||
default:
|
||||
delete(q.subscribers, id)
|
||||
close(subscriber)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *queue) subscribe(buffer int, initialPayload []byte) (<-chan []byte, func()) {
|
||||
subscriber := make(chan []byte, buffer)
|
||||
if len(initialPayload) > 0 {
|
||||
subscriber <- append([]byte(nil), initialPayload...)
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
if q.subscribers == nil {
|
||||
q.subscribers = make(map[uint64]chan []byte)
|
||||
}
|
||||
q.nextSubscriberID++
|
||||
id := q.nextSubscriberID
|
||||
q.subscribers[id] = subscriber
|
||||
q.mu.Unlock()
|
||||
|
||||
var once sync.Once
|
||||
unsubscribe := func() {
|
||||
once.Do(func() {
|
||||
q.unsubscribe(id)
|
||||
})
|
||||
}
|
||||
return subscriber, unsubscribe
|
||||
}
|
||||
|
||||
func (q *queue) unsubscribe(id uint64) {
|
||||
q.mu.Lock()
|
||||
subscriber, ok := q.subscribers[id]
|
||||
if ok {
|
||||
delete(q.subscribers, id)
|
||||
}
|
||||
q.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
close(subscriber)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *queue) popOldest(count int) [][]byte {
|
||||
now := time.Now()
|
||||
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
q.pruneLocked(now)
|
||||
available := len(q.items) - q.head
|
||||
if available <= 0 {
|
||||
q.items = nil
|
||||
q.head = 0
|
||||
return nil
|
||||
}
|
||||
if count > available {
|
||||
count = available
|
||||
}
|
||||
|
||||
out := make([][]byte, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
item := q.items[q.head+i]
|
||||
out = append(out, item.payload)
|
||||
}
|
||||
q.head += count
|
||||
q.maybeCompactLocked()
|
||||
return out
|
||||
}
|
||||
|
||||
func (q *queue) pruneLocked(now time.Time) {
|
||||
if q.head >= len(q.items) {
|
||||
q.items = nil
|
||||
q.head = 0
|
||||
return
|
||||
}
|
||||
|
||||
windowSeconds := retentionSeconds.Load()
|
||||
if windowSeconds <= 0 {
|
||||
windowSeconds = defaultRetentionSeconds
|
||||
}
|
||||
cutoff := now.Add(-time.Duration(windowSeconds) * time.Second)
|
||||
for q.head < len(q.items) && q.items[q.head].enqueuedAt.Before(cutoff) {
|
||||
q.head++
|
||||
}
|
||||
}
|
||||
|
||||
func (q *queue) maybeCompactLocked() {
|
||||
if q.head == 0 {
|
||||
return
|
||||
}
|
||||
if q.head >= len(q.items) {
|
||||
q.items = nil
|
||||
q.head = 0
|
||||
return
|
||||
}
|
||||
if q.head < 1024 && q.head*2 < len(q.items) {
|
||||
return
|
||||
}
|
||||
q.items = append([]queueItem(nil), q.items[q.head:]...)
|
||||
q.head = 0
|
||||
}
|
||||
135
backend/internal/redisqueue/queue_test.go
Normal file
135
backend/internal/redisqueue/queue_test.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package redisqueue
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEnqueueBroadcastsToUsageSubscribersAndSkipsQueue(t *testing.T) {
|
||||
withEnabledQueue(t, func() {
|
||||
first, unsubscribeFirst := SubscribeUsage()
|
||||
defer unsubscribeFirst()
|
||||
second, unsubscribeSecond := SubscribeUsage()
|
||||
defer unsubscribeSecond()
|
||||
|
||||
requireUsageSubscriberPayload(t, first, usageSupportRefreshPayload)
|
||||
requireUsageSubscriberPayload(t, second, usageSupportRefreshPayload)
|
||||
|
||||
Enqueue([]byte("usage-record"))
|
||||
|
||||
requireUsageSubscriberPayload(t, first, "usage-record")
|
||||
requireUsageSubscriberPayload(t, second, "usage-record")
|
||||
|
||||
if items := PopOldest(1); len(items) != 0 {
|
||||
t.Fatalf("PopOldest() items = %q, want empty after subscriber broadcast", items)
|
||||
}
|
||||
|
||||
unsubscribeFirst()
|
||||
unsubscribeSecond()
|
||||
|
||||
Enqueue([]byte("queued-record"))
|
||||
items := PopOldest(1)
|
||||
if len(items) != 1 || string(items[0]) != "queued-record" {
|
||||
t.Fatalf("PopOldest() items = %q, want queued record after unsubscribe", items)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetEnabledFalseClosesUsageSubscribers(t *testing.T) {
|
||||
withEnabledQueue(t, func() {
|
||||
subscriber, unsubscribe := SubscribeUsage()
|
||||
defer unsubscribe()
|
||||
errorSubscriber, unsubscribeErrors := SubscribeErrors()
|
||||
defer unsubscribeErrors()
|
||||
|
||||
requireUsageSubscriberPayload(t, subscriber, usageSupportRefreshPayload)
|
||||
|
||||
SetEnabled(false)
|
||||
|
||||
select {
|
||||
case _, ok := <-subscriber:
|
||||
if ok {
|
||||
t.Fatalf("subscriber channel remained open after SetEnabled(false)")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timeout waiting for subscriber close")
|
||||
}
|
||||
|
||||
select {
|
||||
case _, ok := <-errorSubscriber:
|
||||
if ok {
|
||||
t.Fatalf("error subscriber channel remained open after SetEnabled(false)")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timeout waiting for error subscriber close")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnqueueErrorBroadcastsToErrorSubscribersAndDiscardsWithoutSubscribers(t *testing.T) {
|
||||
withEnabledQueue(t, func() {
|
||||
subscriber, unsubscribe := SubscribeErrors()
|
||||
defer unsubscribe()
|
||||
|
||||
EnqueueError([]byte("error-record"))
|
||||
requireUsageSubscriberPayload(t, subscriber, "error-record")
|
||||
|
||||
unsubscribe()
|
||||
|
||||
EnqueueError([]byte("discarded-error"))
|
||||
requireErrorQueueEmpty(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotifyUsageRefreshBroadcastsOnlyToUsageSubscribers(t *testing.T) {
|
||||
withEnabledQueue(t, func() {
|
||||
subscriber, unsubscribe := SubscribeUsage()
|
||||
defer unsubscribe()
|
||||
errorSubscriber, unsubscribeErrors := SubscribeErrors()
|
||||
defer unsubscribeErrors()
|
||||
|
||||
requireUsageSubscriberPayload(t, subscriber, usageSupportRefreshPayload)
|
||||
|
||||
NotifyUsageRefresh()
|
||||
requireUsageSubscriberPayload(t, subscriber, usageRefreshPayload)
|
||||
|
||||
select {
|
||||
case got := <-errorSubscriber:
|
||||
t.Fatalf("error subscriber received usage refresh payload %q", string(got))
|
||||
default:
|
||||
}
|
||||
|
||||
unsubscribe()
|
||||
NotifyUsageRefresh()
|
||||
if items := PopOldest(1); len(items) != 0 {
|
||||
t.Fatalf("PopOldest() items = %q, want empty after refresh notification without subscribers", items)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func requireUsageSubscriberPayload(t *testing.T, subscriber <-chan []byte, want string) {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case got, ok := <-subscriber:
|
||||
if !ok {
|
||||
t.Fatalf("subscriber closed before receiving %q", want)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Fatalf("subscriber payload = %q, want %q", string(got), want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timeout waiting for subscriber payload %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
func requireErrorQueueEmpty(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
errorGlobal.mu.Lock()
|
||||
defer errorGlobal.mu.Unlock()
|
||||
|
||||
if len(errorGlobal.items)-errorGlobal.head != 0 {
|
||||
t.Fatalf("error queue retained %d item(s), want none", len(errorGlobal.items)-errorGlobal.head)
|
||||
}
|
||||
}
|
||||
16
backend/internal/redisqueue/usage_toggle.go
Normal file
16
backend/internal/redisqueue/usage_toggle.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package redisqueue
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
var usageStatisticsEnabled atomic.Bool
|
||||
|
||||
func init() {
|
||||
usageStatisticsEnabled.Store(true)
|
||||
}
|
||||
|
||||
// SetUsageStatisticsEnabled toggles whether usage records are enqueued into the redisqueue payload buffer.
|
||||
// This is controlled by the config field `usage-statistics-enabled` and the corresponding management API.
|
||||
func SetUsageStatisticsEnabled(enabled bool) { usageStatisticsEnabled.Store(enabled) }
|
||||
|
||||
// UsageStatisticsEnabled reports whether the usage queue plugin should publish records.
|
||||
func UsageStatisticsEnabled() bool { return usageStatisticsEnabled.Load() }
|
||||
Loading…
Reference in a new issue