feat(routing): add logical group schema foundation
This commit is contained in:
78
internal/store/migrations/0010_logical_groups_and_routes.sql
Normal file
78
internal/store/migrations/0010_logical_groups_and_routes.sql
Normal file
@@ -0,0 +1,78 @@
|
||||
CREATE TABLE logical_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
logical_group_id TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
route_policy TEXT NOT NULL DEFAULT 'priority',
|
||||
sticky_mode TEXT NOT NULL DEFAULT 'conversation_preferred',
|
||||
conversation_ttl_seconds INTEGER NOT NULL DEFAULT 7200,
|
||||
user_model_ttl_seconds INTEGER NOT NULL DEFAULT 1800,
|
||||
failover_threshold INTEGER NOT NULL DEFAULT 2,
|
||||
cooldown_seconds INTEGER NOT NULL DEFAULT 600,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_logical_groups_status ON logical_groups(status);
|
||||
|
||||
CREATE TABLE logical_group_models (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
logical_group_id TEXT NOT NULL,
|
||||
public_model TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_logical_group_models_group
|
||||
FOREIGN KEY (logical_group_id)
|
||||
REFERENCES logical_groups(logical_group_id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT uq_logical_group_models_group_model
|
||||
UNIQUE (logical_group_id, public_model)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_logical_group_models_group_id ON logical_group_models(logical_group_id);
|
||||
CREATE INDEX idx_logical_group_models_status ON logical_group_models(status);
|
||||
|
||||
CREATE TABLE logical_group_routes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
route_id TEXT NOT NULL UNIQUE,
|
||||
logical_group_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL,
|
||||
weight INTEGER NOT NULL DEFAULT 100,
|
||||
shadow_group_id TEXT NOT NULL,
|
||||
shadow_host_id TEXT NOT NULL,
|
||||
upstream_base_url_hint TEXT NOT NULL DEFAULT '',
|
||||
cooldown_until TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_logical_group_routes_group
|
||||
FOREIGN KEY (logical_group_id)
|
||||
REFERENCES logical_groups(logical_group_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_logical_group_routes_group_id ON logical_group_routes(logical_group_id);
|
||||
CREATE INDEX idx_logical_group_routes_shadow_host_id ON logical_group_routes(shadow_host_id);
|
||||
CREATE INDEX idx_logical_group_routes_status_priority ON logical_group_routes(status, priority);
|
||||
|
||||
CREATE TABLE logical_group_route_models (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
route_id TEXT NOT NULL,
|
||||
public_model TEXT NOT NULL,
|
||||
shadow_model TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_logical_group_route_models_route
|
||||
FOREIGN KEY (route_id)
|
||||
REFERENCES logical_group_routes(route_id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT uq_logical_group_route_models_route_model
|
||||
UNIQUE (route_id, public_model)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_logical_group_route_models_route_id ON logical_group_route_models(route_id);
|
||||
CREATE INDEX idx_logical_group_route_models_status ON logical_group_route_models(status);
|
||||
@@ -102,6 +102,26 @@ func TestTableExists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAppliesLogicalRoutingTables(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
db := store.SQLDB()
|
||||
|
||||
for _, table := range []string{
|
||||
"logical_groups",
|
||||
"logical_group_models",
|
||||
"logical_group_routes",
|
||||
"logical_group_route_models",
|
||||
} {
|
||||
found, err := tableExists(context.Background(), db, table)
|
||||
if err != nil {
|
||||
t.Fatalf("tableExists(%q) error = %v", table, err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("tableExists(%q) = false, want true", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectLegacy0001Schema(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
db := store.SQLDB()
|
||||
|
||||
@@ -133,7 +133,15 @@ func TestStoreAppliesLatestMigration(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
defer closeTestStore(t, store)
|
||||
|
||||
for _, table := range []string{"import_runs", "import_run_items", "import_run_item_events"} {
|
||||
for _, table := range []string{
|
||||
"import_runs",
|
||||
"import_run_items",
|
||||
"import_run_item_events",
|
||||
"logical_groups",
|
||||
"logical_group_models",
|
||||
"logical_group_routes",
|
||||
"logical_group_route_models",
|
||||
} {
|
||||
if !tableExists(t, store.SQLDB(), table) {
|
||||
t.Fatalf("table %q does not exist after latest migration", table)
|
||||
}
|
||||
@@ -165,6 +173,185 @@ func TestStoreAppliesLatestMigration(t *testing.T) {
|
||||
t.Fatalf("column %q missing from import_run_items", column)
|
||||
}
|
||||
}
|
||||
|
||||
for _, column := range []string{
|
||||
"logical_group_id",
|
||||
"display_name",
|
||||
"route_policy",
|
||||
"sticky_mode",
|
||||
"conversation_ttl_seconds",
|
||||
"user_model_ttl_seconds",
|
||||
"failover_threshold",
|
||||
"cooldown_seconds",
|
||||
} {
|
||||
if !tableColumnExists(t, store.SQLDB(), "logical_groups", column) {
|
||||
t.Fatalf("column %q missing from logical_groups", column)
|
||||
}
|
||||
}
|
||||
|
||||
for _, column := range []string{
|
||||
"logical_group_id",
|
||||
"public_model",
|
||||
"status",
|
||||
} {
|
||||
if !tableColumnExists(t, store.SQLDB(), "logical_group_models", column) {
|
||||
t.Fatalf("column %q missing from logical_group_models", column)
|
||||
}
|
||||
}
|
||||
|
||||
for _, column := range []string{
|
||||
"route_id",
|
||||
"logical_group_id",
|
||||
"priority",
|
||||
"weight",
|
||||
"shadow_group_id",
|
||||
"shadow_host_id",
|
||||
"upstream_base_url_hint",
|
||||
"cooldown_until",
|
||||
} {
|
||||
if !tableColumnExists(t, store.SQLDB(), "logical_group_routes", column) {
|
||||
t.Fatalf("column %q missing from logical_group_routes", column)
|
||||
}
|
||||
}
|
||||
|
||||
for _, column := range []string{
|
||||
"route_id",
|
||||
"public_model",
|
||||
"shadow_model",
|
||||
"status",
|
||||
} {
|
||||
if !tableColumnExists(t, store.SQLDB(), "logical_group_route_models", column) {
|
||||
t.Fatalf("column %q missing from logical_group_route_models", column)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreInitEnforcesLogicalRoutingConstraints(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := openTestStore(t)
|
||||
defer closeTestStore(t, store)
|
||||
|
||||
db := store.SQLDB()
|
||||
mustExecSQL(t, db, `
|
||||
INSERT INTO logical_groups (
|
||||
logical_group_id,
|
||||
display_name,
|
||||
status
|
||||
) VALUES (?, ?, ?)`,
|
||||
"gpt-shared",
|
||||
"GPT Shared",
|
||||
"active",
|
||||
)
|
||||
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO logical_groups (
|
||||
logical_group_id,
|
||||
display_name,
|
||||
status
|
||||
) VALUES (?, ?, ?)`,
|
||||
"gpt-shared",
|
||||
"GPT Shared Duplicate",
|
||||
"active",
|
||||
); err == nil {
|
||||
t.Fatal("duplicate logical_group_id insert error = nil, want unique constraint failure")
|
||||
}
|
||||
|
||||
mustExecSQL(t, db, `
|
||||
INSERT INTO logical_group_models (
|
||||
logical_group_id,
|
||||
public_model
|
||||
) VALUES (?, ?)`,
|
||||
"gpt-shared",
|
||||
"gpt-5.4",
|
||||
)
|
||||
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO logical_group_models (
|
||||
logical_group_id,
|
||||
public_model
|
||||
) VALUES (?, ?)`,
|
||||
"gpt-shared",
|
||||
"gpt-5.4",
|
||||
); err == nil {
|
||||
t.Fatal("duplicate logical_group_models insert error = nil, want unique constraint failure")
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO logical_group_routes (
|
||||
route_id,
|
||||
logical_group_id,
|
||||
name,
|
||||
status,
|
||||
priority,
|
||||
shadow_group_id,
|
||||
shadow_host_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
"route-missing-group",
|
||||
"missing-group",
|
||||
"Missing Group Route",
|
||||
"active",
|
||||
10,
|
||||
"shadow-group-a",
|
||||
"shadow-host-a",
|
||||
); err == nil {
|
||||
t.Fatal("logical_group_routes missing group error = nil, want foreign key failure")
|
||||
}
|
||||
|
||||
mustExecSQL(t, db, `
|
||||
INSERT INTO logical_group_routes (
|
||||
route_id,
|
||||
logical_group_id,
|
||||
name,
|
||||
status,
|
||||
priority,
|
||||
shadow_group_id,
|
||||
shadow_host_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
"route-asxs",
|
||||
"gpt-shared",
|
||||
"ASXS",
|
||||
"active",
|
||||
10,
|
||||
"gpt-shared__asxs",
|
||||
"remote43",
|
||||
)
|
||||
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO logical_group_route_models (
|
||||
route_id,
|
||||
public_model,
|
||||
shadow_model
|
||||
) VALUES (?, ?, ?)`,
|
||||
"missing-route",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4",
|
||||
); err == nil {
|
||||
t.Fatal("logical_group_route_models missing route error = nil, want foreign key failure")
|
||||
}
|
||||
|
||||
mustExecSQL(t, db, `
|
||||
INSERT INTO logical_group_route_models (
|
||||
route_id,
|
||||
public_model,
|
||||
shadow_model
|
||||
) VALUES (?, ?, ?)`,
|
||||
"route-asxs",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4",
|
||||
)
|
||||
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO logical_group_route_models (
|
||||
route_id,
|
||||
public_model,
|
||||
shadow_model
|
||||
) VALUES (?, ?, ?)`,
|
||||
"route-asxs",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-alt",
|
||||
); err == nil {
|
||||
t.Fatal("duplicate logical_group_route_models insert error = nil, want unique constraint failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreInitBackfillsLedgerForCompletePreLedgerSchema(t *testing.T) {
|
||||
@@ -303,6 +490,14 @@ func mustExec(t *testing.T, db *sql.DB, statement string) {
|
||||
}
|
||||
}
|
||||
|
||||
func mustExecSQL(t *testing.T, db *sql.DB, statement string, args ...any) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := db.ExecContext(context.Background(), statement, args...); err != nil {
|
||||
t.Fatalf("ExecContext() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func closeTestStore(t *testing.T, store *sqlite.DB) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user