Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View 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

View 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)
}
}
}

View 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
}

View 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)
}
}

View 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() }