feat(routing): add route log writer and admin api
This commit is contained in:
@@ -27,6 +27,9 @@ type Queries struct {
|
||||
LogicalGroupModels *LogicalGroupModelsRepo
|
||||
LogicalGroupRoutes *LogicalGroupRoutesRepo
|
||||
LogicalGroupRouteModels *LogicalGroupRouteModelsRepo
|
||||
RouteDecisionLogs *RouteDecisionLogsRepo
|
||||
RouteFailoverEvents *RouteFailoverEventsRepo
|
||||
RouteStickyAudit *RouteStickyAuditRepo
|
||||
ProviderDrafts *ProviderDraftsRepo
|
||||
ImportBatches *ImportBatchesRepo
|
||||
ImportBatchItems *ImportBatchItemsRepo
|
||||
@@ -112,6 +115,18 @@ func (db *DB) LogicalGroupRouteModels() *LogicalGroupRouteModelsRepo {
|
||||
return db.queries.LogicalGroupRouteModels
|
||||
}
|
||||
|
||||
func (db *DB) RouteDecisionLogs() *RouteDecisionLogsRepo {
|
||||
return db.queries.RouteDecisionLogs
|
||||
}
|
||||
|
||||
func (db *DB) RouteFailoverEvents() *RouteFailoverEventsRepo {
|
||||
return db.queries.RouteFailoverEvents
|
||||
}
|
||||
|
||||
func (db *DB) RouteStickyAudit() *RouteStickyAuditRepo {
|
||||
return db.queries.RouteStickyAudit
|
||||
}
|
||||
|
||||
func (db *DB) ProviderDrafts() *ProviderDraftsRepo {
|
||||
return db.queries.ProviderDrafts
|
||||
}
|
||||
@@ -188,6 +203,9 @@ func newQueries(db execQuerier) *Queries {
|
||||
LogicalGroupModels: newLogicalGroupModelsRepo(db),
|
||||
LogicalGroupRoutes: newLogicalGroupRoutesRepo(db),
|
||||
LogicalGroupRouteModels: newLogicalGroupRouteModelsRepo(db),
|
||||
RouteDecisionLogs: newRouteDecisionLogsRepo(db),
|
||||
RouteFailoverEvents: newRouteFailoverEventsRepo(db),
|
||||
RouteStickyAudit: newRouteStickyAuditRepo(db),
|
||||
ProviderDrafts: newProviderDraftsRepo(db),
|
||||
ImportBatches: newImportBatchesRepo(db),
|
||||
ImportBatchItems: newImportBatchItemsRepo(db),
|
||||
|
||||
@@ -111,6 +111,9 @@ func TestOpenAppliesLogicalRoutingTables(t *testing.T) {
|
||||
"logical_group_models",
|
||||
"logical_group_routes",
|
||||
"logical_group_route_models",
|
||||
"route_decision_logs",
|
||||
"route_failover_events",
|
||||
"route_sticky_audit",
|
||||
} {
|
||||
found, err := tableExists(context.Background(), db, table)
|
||||
if err != nil {
|
||||
|
||||
208
internal/store/sqlite/route_decision_logs_repo.go
Normal file
208
internal/store/sqlite/route_decision_logs_repo.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RouteDecisionLog struct {
|
||||
ID int64
|
||||
RequestID string
|
||||
LogicalGroupID string
|
||||
PublicModel string
|
||||
UserKey string
|
||||
ConversationKey string
|
||||
StickyKey string
|
||||
StickyKeyType string
|
||||
StickyHit bool
|
||||
SelectedRouteID string
|
||||
SelectedShadowGroupID string
|
||||
FallbackUsed bool
|
||||
ErrorClass string
|
||||
UpstreamStatus int
|
||||
LatencyMS int
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type RouteDecisionLogFilter struct {
|
||||
RequestID string
|
||||
LogicalGroupID string
|
||||
PublicModel string
|
||||
SelectedRouteID string
|
||||
StickyKey string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type RouteDecisionLogsRepo struct {
|
||||
db execQuerier
|
||||
}
|
||||
|
||||
func newRouteDecisionLogsRepo(db execQuerier) *RouteDecisionLogsRepo {
|
||||
return &RouteDecisionLogsRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *RouteDecisionLogsRepo) Create(ctx context.Context, row RouteDecisionLog) (int64, error) {
|
||||
row, err := normalizeRouteDecisionLog(row)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
result, err := r.db.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO route_decision_logs (
|
||||
request_id,
|
||||
logical_group_id,
|
||||
public_model,
|
||||
user_key,
|
||||
conversation_key,
|
||||
sticky_key,
|
||||
sticky_key_type,
|
||||
sticky_hit,
|
||||
selected_route_id,
|
||||
selected_shadow_group_id,
|
||||
fallback_used,
|
||||
error_class,
|
||||
upstream_status,
|
||||
latency_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
row.RequestID,
|
||||
row.LogicalGroupID,
|
||||
row.PublicModel,
|
||||
row.UserKey,
|
||||
row.ConversationKey,
|
||||
row.StickyKey,
|
||||
row.StickyKeyType,
|
||||
boolToSQLiteInt(row.StickyHit),
|
||||
row.SelectedRouteID,
|
||||
row.SelectedShadowGroupID,
|
||||
boolToSQLiteInt(row.FallbackUsed),
|
||||
row.ErrorClass,
|
||||
row.UpstreamStatus,
|
||||
row.LatencyMS,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert route decision log %q: %w", row.RequestID, err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read inserted route decision log id for %q: %w", row.RequestID, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *RouteDecisionLogsRepo) ListRecent(ctx context.Context, filter RouteDecisionLogFilter) ([]RouteDecisionLog, error) {
|
||||
clauses := make([]string, 0, 5)
|
||||
args := make([]any, 0, 6)
|
||||
|
||||
if requestID := strings.TrimSpace(filter.RequestID); requestID != "" {
|
||||
clauses = append(clauses, "request_id = ?")
|
||||
args = append(args, requestID)
|
||||
}
|
||||
if logicalGroupID := strings.TrimSpace(filter.LogicalGroupID); logicalGroupID != "" {
|
||||
clauses = append(clauses, "logical_group_id = ?")
|
||||
args = append(args, logicalGroupID)
|
||||
}
|
||||
if publicModel := strings.TrimSpace(filter.PublicModel); publicModel != "" {
|
||||
clauses = append(clauses, "public_model = ?")
|
||||
args = append(args, publicModel)
|
||||
}
|
||||
if selectedRouteID := strings.TrimSpace(filter.SelectedRouteID); selectedRouteID != "" {
|
||||
clauses = append(clauses, "selected_route_id = ?")
|
||||
args = append(args, selectedRouteID)
|
||||
}
|
||||
if stickyKey := strings.TrimSpace(filter.StickyKey); stickyKey != "" {
|
||||
clauses = append(clauses, "sticky_key = ?")
|
||||
args = append(args, stickyKey)
|
||||
}
|
||||
|
||||
query := `SELECT id, request_id, logical_group_id, public_model, user_key, conversation_key, sticky_key, sticky_key_type, sticky_hit, selected_route_id, selected_shadow_group_id, fallback_used, error_class, upstream_status, latency_ms, created_at
|
||||
FROM route_decision_logs`
|
||||
if len(clauses) > 0 {
|
||||
query += " WHERE " + strings.Join(clauses, " AND ")
|
||||
}
|
||||
query += " ORDER BY id DESC LIMIT ?"
|
||||
args = append(args, normalizeRouteLogListLimit(filter.Limit))
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list route decision logs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]RouteDecisionLog, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
item RouteDecisionLog
|
||||
stickyHit int
|
||||
fallbackUsed int
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.RequestID,
|
||||
&item.LogicalGroupID,
|
||||
&item.PublicModel,
|
||||
&item.UserKey,
|
||||
&item.ConversationKey,
|
||||
&item.StickyKey,
|
||||
&item.StickyKeyType,
|
||||
&stickyHit,
|
||||
&item.SelectedRouteID,
|
||||
&item.SelectedShadowGroupID,
|
||||
&fallbackUsed,
|
||||
&item.ErrorClass,
|
||||
&item.UpstreamStatus,
|
||||
&item.LatencyMS,
|
||||
&item.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan route decision log: %w", err)
|
||||
}
|
||||
item.StickyHit = stickyHit != 0
|
||||
item.FallbackUsed = fallbackUsed != 0
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate route decision logs: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func normalizeRouteDecisionLog(row RouteDecisionLog) (RouteDecisionLog, error) {
|
||||
row.RequestID = strings.TrimSpace(row.RequestID)
|
||||
row.LogicalGroupID = strings.TrimSpace(row.LogicalGroupID)
|
||||
row.PublicModel = strings.TrimSpace(row.PublicModel)
|
||||
row.UserKey = strings.TrimSpace(row.UserKey)
|
||||
row.ConversationKey = strings.TrimSpace(row.ConversationKey)
|
||||
row.StickyKey = strings.TrimSpace(row.StickyKey)
|
||||
row.StickyKeyType = strings.TrimSpace(row.StickyKeyType)
|
||||
row.SelectedRouteID = strings.TrimSpace(row.SelectedRouteID)
|
||||
row.SelectedShadowGroupID = strings.TrimSpace(row.SelectedShadowGroupID)
|
||||
row.ErrorClass = strings.TrimSpace(row.ErrorClass)
|
||||
|
||||
switch {
|
||||
case row.RequestID == "":
|
||||
return RouteDecisionLog{}, fmt.Errorf("request_id is required")
|
||||
case row.LogicalGroupID == "":
|
||||
return RouteDecisionLog{}, fmt.Errorf("logical_group_id is required")
|
||||
case row.PublicModel == "":
|
||||
return RouteDecisionLog{}, fmt.Errorf("public_model is required")
|
||||
case row.SelectedRouteID == "":
|
||||
return RouteDecisionLog{}, fmt.Errorf("selected_route_id is required")
|
||||
case row.SelectedShadowGroupID == "":
|
||||
return RouteDecisionLog{}, fmt.Errorf("selected_shadow_group_id is required")
|
||||
case row.UpstreamStatus < 0:
|
||||
return RouteDecisionLog{}, fmt.Errorf("upstream_status must be >= 0")
|
||||
case row.LatencyMS < 0:
|
||||
return RouteDecisionLog{}, fmt.Errorf("latency_ms must be >= 0")
|
||||
}
|
||||
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func boolToSQLiteInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
163
internal/store/sqlite/route_failover_events_repo.go
Normal file
163
internal/store/sqlite/route_failover_events_repo.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RouteFailoverEvent struct {
|
||||
ID int64
|
||||
RequestID string
|
||||
LogicalGroupID string
|
||||
PublicModel string
|
||||
FromRouteID string
|
||||
ToRouteID string
|
||||
Reason string
|
||||
FailureCount int
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type RouteFailoverEventFilter struct {
|
||||
RequestID string
|
||||
LogicalGroupID string
|
||||
PublicModel string
|
||||
FromRouteID string
|
||||
ToRouteID string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type RouteFailoverEventsRepo struct {
|
||||
db execQuerier
|
||||
}
|
||||
|
||||
func newRouteFailoverEventsRepo(db execQuerier) *RouteFailoverEventsRepo {
|
||||
return &RouteFailoverEventsRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *RouteFailoverEventsRepo) Create(ctx context.Context, row RouteFailoverEvent) (int64, error) {
|
||||
row, err := normalizeRouteFailoverEvent(row)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
result, err := r.db.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO route_failover_events (
|
||||
request_id,
|
||||
logical_group_id,
|
||||
public_model,
|
||||
from_route_id,
|
||||
to_route_id,
|
||||
reason,
|
||||
failure_count
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
row.RequestID,
|
||||
row.LogicalGroupID,
|
||||
row.PublicModel,
|
||||
row.FromRouteID,
|
||||
row.ToRouteID,
|
||||
row.Reason,
|
||||
row.FailureCount,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert route failover event %q: %w", row.RequestID, err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read inserted route failover event id for %q: %w", row.RequestID, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *RouteFailoverEventsRepo) ListRecent(ctx context.Context, filter RouteFailoverEventFilter) ([]RouteFailoverEvent, error) {
|
||||
clauses := make([]string, 0, 5)
|
||||
args := make([]any, 0, 6)
|
||||
|
||||
if requestID := strings.TrimSpace(filter.RequestID); requestID != "" {
|
||||
clauses = append(clauses, "request_id = ?")
|
||||
args = append(args, requestID)
|
||||
}
|
||||
if logicalGroupID := strings.TrimSpace(filter.LogicalGroupID); logicalGroupID != "" {
|
||||
clauses = append(clauses, "logical_group_id = ?")
|
||||
args = append(args, logicalGroupID)
|
||||
}
|
||||
if publicModel := strings.TrimSpace(filter.PublicModel); publicModel != "" {
|
||||
clauses = append(clauses, "public_model = ?")
|
||||
args = append(args, publicModel)
|
||||
}
|
||||
if fromRouteID := strings.TrimSpace(filter.FromRouteID); fromRouteID != "" {
|
||||
clauses = append(clauses, "from_route_id = ?")
|
||||
args = append(args, fromRouteID)
|
||||
}
|
||||
if toRouteID := strings.TrimSpace(filter.ToRouteID); toRouteID != "" {
|
||||
clauses = append(clauses, "to_route_id = ?")
|
||||
args = append(args, toRouteID)
|
||||
}
|
||||
|
||||
query := `SELECT id, request_id, logical_group_id, public_model, from_route_id, to_route_id, reason, failure_count, created_at
|
||||
FROM route_failover_events`
|
||||
if len(clauses) > 0 {
|
||||
query += " WHERE " + strings.Join(clauses, " AND ")
|
||||
}
|
||||
query += " ORDER BY id DESC LIMIT ?"
|
||||
args = append(args, normalizeRouteLogListLimit(filter.Limit))
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list route failover events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]RouteFailoverEvent, 0)
|
||||
for rows.Next() {
|
||||
var item RouteFailoverEvent
|
||||
if err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.RequestID,
|
||||
&item.LogicalGroupID,
|
||||
&item.PublicModel,
|
||||
&item.FromRouteID,
|
||||
&item.ToRouteID,
|
||||
&item.Reason,
|
||||
&item.FailureCount,
|
||||
&item.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan route failover event: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate route failover events: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func normalizeRouteFailoverEvent(row RouteFailoverEvent) (RouteFailoverEvent, error) {
|
||||
row.RequestID = strings.TrimSpace(row.RequestID)
|
||||
row.LogicalGroupID = strings.TrimSpace(row.LogicalGroupID)
|
||||
row.PublicModel = strings.TrimSpace(row.PublicModel)
|
||||
row.FromRouteID = strings.TrimSpace(row.FromRouteID)
|
||||
row.ToRouteID = strings.TrimSpace(row.ToRouteID)
|
||||
row.Reason = strings.TrimSpace(row.Reason)
|
||||
|
||||
switch {
|
||||
case row.RequestID == "":
|
||||
return RouteFailoverEvent{}, fmt.Errorf("request_id is required")
|
||||
case row.LogicalGroupID == "":
|
||||
return RouteFailoverEvent{}, fmt.Errorf("logical_group_id is required")
|
||||
case row.PublicModel == "":
|
||||
return RouteFailoverEvent{}, fmt.Errorf("public_model is required")
|
||||
case row.FromRouteID == "":
|
||||
return RouteFailoverEvent{}, fmt.Errorf("from_route_id is required")
|
||||
case row.ToRouteID == "":
|
||||
return RouteFailoverEvent{}, fmt.Errorf("to_route_id is required")
|
||||
case row.Reason == "":
|
||||
return RouteFailoverEvent{}, fmt.Errorf("reason is required")
|
||||
case row.FailureCount < 0:
|
||||
return RouteFailoverEvent{}, fmt.Errorf("failure_count must be >= 0")
|
||||
}
|
||||
|
||||
return row, nil
|
||||
}
|
||||
17
internal/store/sqlite/route_logging_helpers.go
Normal file
17
internal/store/sqlite/route_logging_helpers.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package sqlite
|
||||
|
||||
const (
|
||||
defaultRouteLogListLimit = 50
|
||||
maxRouteLogListLimit = 200
|
||||
)
|
||||
|
||||
func normalizeRouteLogListLimit(limit int) int {
|
||||
switch {
|
||||
case limit <= 0:
|
||||
return defaultRouteLogListLimit
|
||||
case limit > maxRouteLogListLimit:
|
||||
return maxRouteLogListLimit
|
||||
default:
|
||||
return limit
|
||||
}
|
||||
}
|
||||
129
internal/store/sqlite/route_logging_repos_test.go
Normal file
129
internal/store/sqlite/route_logging_repos_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRouteDecisionLogsRepoCreateAndListRecent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := store.RouteDecisionLogs().Create(ctx, RouteDecisionLog{
|
||||
RequestID: "req-1",
|
||||
LogicalGroupID: "gpt-shared",
|
||||
PublicModel: "gpt-5.4",
|
||||
UserKey: "user-a",
|
||||
ConversationKey: "conv-a",
|
||||
StickyKey: "sticky-a",
|
||||
StickyKeyType: "conversation",
|
||||
StickyHit: true,
|
||||
SelectedRouteID: "asxs",
|
||||
SelectedShadowGroupID: "gpt-shared__asxs",
|
||||
FallbackUsed: false,
|
||||
ErrorClass: "",
|
||||
UpstreamStatus: 200,
|
||||
LatencyMS: 123,
|
||||
}); err != nil {
|
||||
t.Fatalf("RouteDecisionLogs().Create() error = %v", err)
|
||||
}
|
||||
|
||||
logs, err := store.RouteDecisionLogs().ListRecent(ctx, RouteDecisionLogFilter{
|
||||
LogicalGroupID: "gpt-shared",
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RouteDecisionLogs().ListRecent() error = %v", err)
|
||||
}
|
||||
if len(logs) != 1 {
|
||||
t.Fatalf("RouteDecisionLogs().ListRecent() len = %d, want 1", len(logs))
|
||||
}
|
||||
if logs[0].SelectedRouteID != "asxs" || !logs[0].StickyHit || logs[0].UpstreamStatus != 200 {
|
||||
t.Fatalf("RouteDecisionLogs().ListRecent()[0] = %+v", logs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteFailoverEventsRepoCreateAndListRecent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := store.RouteFailoverEvents().Create(ctx, RouteFailoverEvent{
|
||||
RequestID: "req-2",
|
||||
LogicalGroupID: "gpt-shared",
|
||||
PublicModel: "gpt-5.4",
|
||||
FromRouteID: "asxs",
|
||||
ToRouteID: "codex2api",
|
||||
Reason: "upstream_5xx",
|
||||
FailureCount: 2,
|
||||
}); err != nil {
|
||||
t.Fatalf("RouteFailoverEvents().Create() error = %v", err)
|
||||
}
|
||||
|
||||
items, err := store.RouteFailoverEvents().ListRecent(ctx, RouteFailoverEventFilter{
|
||||
RequestID: "req-2",
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RouteFailoverEvents().ListRecent() error = %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("RouteFailoverEvents().ListRecent() len = %d, want 1", len(items))
|
||||
}
|
||||
if items[0].ToRouteID != "codex2api" || items[0].FailureCount != 2 {
|
||||
t.Fatalf("RouteFailoverEvents().ListRecent()[0] = %+v", items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteStickyAuditRepoCreateAndListRecent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := store.RouteStickyAudit().Create(ctx, RouteStickyAudit{
|
||||
StickyKey: "sticky-3",
|
||||
StickyKeyType: "user_model",
|
||||
LogicalGroupID: "gpt-shared",
|
||||
PublicModel: "gpt-5.4-mini",
|
||||
RouteID: "asxs",
|
||||
Action: "bind",
|
||||
ExpiresAt: "2026-05-28T18:00:00Z",
|
||||
}); err != nil {
|
||||
t.Fatalf("RouteStickyAudit().Create() error = %v", err)
|
||||
}
|
||||
|
||||
items, err := store.RouteStickyAudit().ListRecent(ctx, RouteStickyAuditFilter{
|
||||
StickyKey: "sticky-3",
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RouteStickyAudit().ListRecent() error = %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("RouteStickyAudit().ListRecent() len = %d, want 1", len(items))
|
||||
}
|
||||
if items[0].Action != "bind" || items[0].RouteID != "asxs" {
|
||||
t.Fatalf("RouteStickyAudit().ListRecent()[0] = %+v", items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteLoggingReposRejectInvalidRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := store.RouteDecisionLogs().Create(ctx, RouteDecisionLog{}); err == nil {
|
||||
t.Fatal("RouteDecisionLogs().Create() error = nil, want validation error")
|
||||
}
|
||||
if _, err := store.RouteFailoverEvents().Create(ctx, RouteFailoverEvent{}); err == nil {
|
||||
t.Fatal("RouteFailoverEvents().Create() error = nil, want validation error")
|
||||
}
|
||||
if _, err := store.RouteStickyAudit().Create(ctx, RouteStickyAudit{}); err == nil {
|
||||
t.Fatal("RouteStickyAudit().Create() error = nil, want validation error")
|
||||
}
|
||||
}
|
||||
167
internal/store/sqlite/route_sticky_audit_repo.go
Normal file
167
internal/store/sqlite/route_sticky_audit_repo.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RouteStickyAudit struct {
|
||||
ID int64
|
||||
StickyKey string
|
||||
StickyKeyType string
|
||||
LogicalGroupID string
|
||||
PublicModel string
|
||||
RouteID string
|
||||
Action string
|
||||
ExpiresAt string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type RouteStickyAuditFilter struct {
|
||||
StickyKey string
|
||||
StickyKeyType string
|
||||
LogicalGroupID string
|
||||
PublicModel string
|
||||
RouteID string
|
||||
Action string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type RouteStickyAuditRepo struct {
|
||||
db execQuerier
|
||||
}
|
||||
|
||||
func newRouteStickyAuditRepo(db execQuerier) *RouteStickyAuditRepo {
|
||||
return &RouteStickyAuditRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *RouteStickyAuditRepo) Create(ctx context.Context, row RouteStickyAudit) (int64, error) {
|
||||
row, err := normalizeRouteStickyAudit(row)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
result, err := r.db.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO route_sticky_audit (
|
||||
sticky_key,
|
||||
sticky_key_type,
|
||||
logical_group_id,
|
||||
public_model,
|
||||
route_id,
|
||||
action,
|
||||
expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
row.StickyKey,
|
||||
row.StickyKeyType,
|
||||
row.LogicalGroupID,
|
||||
row.PublicModel,
|
||||
row.RouteID,
|
||||
row.Action,
|
||||
row.ExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert route sticky audit %q: %w", row.StickyKey, err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read inserted route sticky audit id for %q: %w", row.StickyKey, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *RouteStickyAuditRepo) ListRecent(ctx context.Context, filter RouteStickyAuditFilter) ([]RouteStickyAudit, error) {
|
||||
clauses := make([]string, 0, 6)
|
||||
args := make([]any, 0, 7)
|
||||
|
||||
if stickyKey := strings.TrimSpace(filter.StickyKey); stickyKey != "" {
|
||||
clauses = append(clauses, "sticky_key = ?")
|
||||
args = append(args, stickyKey)
|
||||
}
|
||||
if stickyKeyType := strings.TrimSpace(filter.StickyKeyType); stickyKeyType != "" {
|
||||
clauses = append(clauses, "sticky_key_type = ?")
|
||||
args = append(args, stickyKeyType)
|
||||
}
|
||||
if logicalGroupID := strings.TrimSpace(filter.LogicalGroupID); logicalGroupID != "" {
|
||||
clauses = append(clauses, "logical_group_id = ?")
|
||||
args = append(args, logicalGroupID)
|
||||
}
|
||||
if publicModel := strings.TrimSpace(filter.PublicModel); publicModel != "" {
|
||||
clauses = append(clauses, "public_model = ?")
|
||||
args = append(args, publicModel)
|
||||
}
|
||||
if routeID := strings.TrimSpace(filter.RouteID); routeID != "" {
|
||||
clauses = append(clauses, "route_id = ?")
|
||||
args = append(args, routeID)
|
||||
}
|
||||
if action := strings.TrimSpace(filter.Action); action != "" {
|
||||
clauses = append(clauses, "action = ?")
|
||||
args = append(args, action)
|
||||
}
|
||||
|
||||
query := `SELECT id, sticky_key, sticky_key_type, logical_group_id, public_model, route_id, action, expires_at, created_at
|
||||
FROM route_sticky_audit`
|
||||
if len(clauses) > 0 {
|
||||
query += " WHERE " + strings.Join(clauses, " AND ")
|
||||
}
|
||||
query += " ORDER BY id DESC LIMIT ?"
|
||||
args = append(args, normalizeRouteLogListLimit(filter.Limit))
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list route sticky audit: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]RouteStickyAudit, 0)
|
||||
for rows.Next() {
|
||||
var item RouteStickyAudit
|
||||
if err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.StickyKey,
|
||||
&item.StickyKeyType,
|
||||
&item.LogicalGroupID,
|
||||
&item.PublicModel,
|
||||
&item.RouteID,
|
||||
&item.Action,
|
||||
&item.ExpiresAt,
|
||||
&item.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan route sticky audit: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate route sticky audit: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func normalizeRouteStickyAudit(row RouteStickyAudit) (RouteStickyAudit, error) {
|
||||
row.StickyKey = strings.TrimSpace(row.StickyKey)
|
||||
row.StickyKeyType = strings.TrimSpace(row.StickyKeyType)
|
||||
row.LogicalGroupID = strings.TrimSpace(row.LogicalGroupID)
|
||||
row.PublicModel = strings.TrimSpace(row.PublicModel)
|
||||
row.RouteID = strings.TrimSpace(row.RouteID)
|
||||
row.Action = strings.TrimSpace(row.Action)
|
||||
row.ExpiresAt = strings.TrimSpace(row.ExpiresAt)
|
||||
|
||||
switch {
|
||||
case row.StickyKey == "":
|
||||
return RouteStickyAudit{}, fmt.Errorf("sticky_key is required")
|
||||
case row.StickyKeyType == "":
|
||||
return RouteStickyAudit{}, fmt.Errorf("sticky_key_type is required")
|
||||
case row.LogicalGroupID == "":
|
||||
return RouteStickyAudit{}, fmt.Errorf("logical_group_id is required")
|
||||
case row.PublicModel == "":
|
||||
return RouteStickyAudit{}, fmt.Errorf("public_model is required")
|
||||
case row.RouteID == "":
|
||||
return RouteStickyAudit{}, fmt.Errorf("route_id is required")
|
||||
case row.Action == "":
|
||||
return RouteStickyAudit{}, fmt.Errorf("action is required")
|
||||
}
|
||||
|
||||
return row, nil
|
||||
}
|
||||
Reference in New Issue
Block a user