Skip to content

Commit 5658502

Browse files
author
Jenkins
committed
feat: add database migration management with versioned schemas (Loop 38)
- Create versioned migration SQL files for SQLite (3 migrations) and PostgreSQL (1 migration) in crates/llmtrace-storage/migrations/ - Implement migration runner (migration.rs) with schema_version tracking table, transactional application, and idempotent re-runs - Embed migration SQL at compile time via include_str! — no runtime filesystem access needed - Replace inline SQL constants in sqlite.rs and postgres.rs with calls to the versioned migration runner - Add 'migrate' CLI subcommand: llmtrace-proxy migrate --config config.yaml - Add storage.auto_migrate config option (default true for dev) - Add 7 new tests: migration ordering, idempotency, version tracking, table existence, partial resume - All existing tests continue to pass unchanged
1 parent ad7cf66 commit 5658502

12 files changed

Lines changed: 669 additions & 159 deletions

File tree

config.example.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ storage:
5656
# Redis connection URL for hot-query cache and sessions.
5757
# redis_url: "redis://127.0.0.1:6379"
5858

59+
# Automatically run pending database migrations on startup.
60+
# Default: true (convenient for development). Set to false in production
61+
# and use `llmtrace-proxy migrate --config config.yaml` explicitly.
62+
auto_migrate: true
63+
5964
# ---------------------------------------------------------------------------
6065
# Logging
6166
# ---------------------------------------------------------------------------

crates/llmtrace-core/src/lib.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,6 +916,12 @@ pub struct StorageConfig {
916916
/// Redis connection URL (used by the `"production"` profile).
917917
#[serde(default)]
918918
pub redis_url: Option<String>,
919+
/// Automatically run pending database migrations on startup.
920+
///
921+
/// Defaults to `true` for development (lite/memory profiles). Set to
922+
/// `false` in production to require explicit `llmtrace-proxy migrate`.
923+
#[serde(default = "default_auto_migrate")]
924+
pub auto_migrate: bool,
919925
}
920926

921927
fn default_storage_profile() -> String {
@@ -926,6 +932,10 @@ fn default_database_path() -> String {
926932
"llmtrace.db".to_string()
927933
}
928934

935+
fn default_auto_migrate() -> bool {
936+
true
937+
}
938+
929939
impl Default for StorageConfig {
930940
fn default() -> Self {
931941
Self {
@@ -935,6 +945,7 @@ impl Default for StorageConfig {
935945
clickhouse_database: None,
936946
postgres_url: None,
937947
redis_url: None,
948+
auto_migrate: default_auto_migrate(),
938949
}
939950
}
940951
}
@@ -2391,6 +2402,7 @@ mod tests {
23912402
clickhouse_database: None,
23922403
postgres_url: None,
23932404
redis_url: None,
2405+
auto_migrate: true,
23942406
},
23952407
timeout_ms: 60000,
23962408
connection_timeout_ms: 10000,

crates/llmtrace-proxy/src/main.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ struct Cli {
5353
enum Commands {
5454
/// Validate a configuration file and print resolved settings.
5555
Validate,
56+
/// Run pending database migrations and exit.
57+
Migrate,
5658
}
5759

5860
// ---------------------------------------------------------------------------
@@ -68,6 +70,10 @@ async fn main() -> anyhow::Result<()> {
6870

6971
match cli.command {
7072
Some(Commands::Validate) => run_validate(&config),
73+
Some(Commands::Migrate) => {
74+
init_logging(&config)?;
75+
run_migrate(&config).await
76+
}
7177
None => {
7278
init_logging(&config)?;
7379
config::validate_config(&config)?;
@@ -127,6 +133,46 @@ fn run_validate(config: &ProxyConfig) -> anyhow::Result<()> {
127133
Ok(())
128134
}
129135

136+
// ---------------------------------------------------------------------------
137+
// Subcommand: migrate
138+
// ---------------------------------------------------------------------------
139+
140+
/// Run pending database migrations and exit.
141+
async fn run_migrate(config: &ProxyConfig) -> anyhow::Result<()> {
142+
info!(
143+
profile = %config.storage.profile,
144+
"Running database migrations"
145+
);
146+
147+
match config.storage.profile.as_str() {
148+
"lite" => {
149+
let url = format!("sqlite:{}", config.storage.database_path);
150+
let pool = llmtrace_storage::migration::open_sqlite_pool(&url).await?;
151+
llmtrace_storage::migration::run_sqlite_migrations(&pool).await?;
152+
info!("SQLite migrations complete");
153+
}
154+
"memory" => {
155+
info!("Memory profile uses no persistent storage — nothing to migrate");
156+
}
157+
"production" => {
158+
// Run PostgreSQL migrations for production metadata storage.
159+
if let Some(ref pg_url) = config.storage.postgres_url {
160+
let pool = llmtrace_storage::migration::open_pg_pool(pg_url).await?;
161+
llmtrace_storage::migration::run_pg_migrations(&pool).await?;
162+
info!("PostgreSQL migrations complete");
163+
} else {
164+
anyhow::bail!("storage.postgres_url is required for production profile migrations");
165+
}
166+
}
167+
other => {
168+
anyhow::bail!("Unknown storage profile: {other}");
169+
}
170+
}
171+
172+
println!("✓ All migrations applied successfully.");
173+
Ok(())
174+
}
175+
130176
// ---------------------------------------------------------------------------
131177
// Subcommand: run proxy (default)
132178
// ---------------------------------------------------------------------------

crates/llmtrace-storage/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ serde_json.workspace = true
2121
async-trait.workspace = true
2222
chrono.workspace = true
2323
uuid.workspace = true
24+
tracing.workspace = true
2425
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid"] }
2526
dashmap = "6"
2627
bytes = "1"
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
-- 001_initial_schema.sql: tenants, tenant_configs, audit_events, api_keys for PostgreSQL
2+
3+
CREATE TABLE IF NOT EXISTS tenants (
4+
id UUID PRIMARY KEY,
5+
name VARCHAR(255) NOT NULL,
6+
plan VARCHAR(50) NOT NULL,
7+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
8+
config JSONB NOT NULL DEFAULT '{}'
9+
);
10+
11+
CREATE TABLE IF NOT EXISTS tenant_configs (
12+
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
13+
security_thresholds JSONB NOT NULL DEFAULT '{}',
14+
feature_flags JSONB NOT NULL DEFAULT '{}'
15+
);
16+
17+
CREATE TABLE IF NOT EXISTS audit_events (
18+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
19+
tenant_id UUID NOT NULL REFERENCES tenants(id),
20+
event_type VARCHAR(100) NOT NULL,
21+
actor VARCHAR(255) NOT NULL,
22+
resource VARCHAR(255) NOT NULL,
23+
data JSONB NOT NULL DEFAULT '{}',
24+
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
25+
);
26+
27+
CREATE INDEX IF NOT EXISTS idx_audit_tenant_time ON audit_events(tenant_id, timestamp DESC);
28+
CREATE INDEX IF NOT EXISTS idx_audit_type ON audit_events(tenant_id, event_type);
29+
30+
CREATE TABLE IF NOT EXISTS api_keys (
31+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
32+
tenant_id UUID NOT NULL REFERENCES tenants(id),
33+
name VARCHAR(255) NOT NULL,
34+
key_hash VARCHAR(64) NOT NULL UNIQUE,
35+
key_prefix VARCHAR(16) NOT NULL,
36+
role VARCHAR(20) NOT NULL,
37+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
38+
revoked_at TIMESTAMPTZ
39+
);
40+
41+
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
42+
CREATE INDEX IF NOT EXISTS idx_api_keys_tenant ON api_keys(tenant_id);
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
-- 001_initial_schema.sql: traces and spans tables for SQLite
2+
3+
CREATE TABLE IF NOT EXISTS traces (
4+
trace_id TEXT NOT NULL,
5+
tenant_id TEXT NOT NULL,
6+
created_at TEXT NOT NULL,
7+
PRIMARY KEY (tenant_id, trace_id)
8+
);
9+
10+
CREATE INDEX IF NOT EXISTS idx_traces_created ON traces(tenant_id, created_at);
11+
12+
CREATE TABLE IF NOT EXISTS spans (
13+
span_id TEXT NOT NULL,
14+
trace_id TEXT NOT NULL,
15+
parent_span_id TEXT,
16+
tenant_id TEXT NOT NULL,
17+
operation_name TEXT NOT NULL,
18+
start_time TEXT NOT NULL,
19+
end_time TEXT,
20+
provider TEXT NOT NULL,
21+
model_name TEXT NOT NULL,
22+
prompt TEXT NOT NULL,
23+
response TEXT,
24+
prompt_tokens INTEGER,
25+
completion_tokens INTEGER,
26+
total_tokens INTEGER,
27+
time_to_first_token_ms INTEGER,
28+
duration_ms INTEGER,
29+
status_code INTEGER,
30+
error_message TEXT,
31+
estimated_cost_usd REAL,
32+
security_score INTEGER,
33+
security_findings TEXT NOT NULL DEFAULT '[]',
34+
tags TEXT NOT NULL DEFAULT '{}',
35+
events TEXT NOT NULL DEFAULT '[]',
36+
PRIMARY KEY (tenant_id, span_id)
37+
);
38+
39+
CREATE INDEX IF NOT EXISTS idx_spans_trace ON spans(tenant_id, trace_id);
40+
CREATE INDEX IF NOT EXISTS idx_spans_time ON spans(tenant_id, start_time);
41+
CREATE INDEX IF NOT EXISTS idx_spans_provider ON spans(tenant_id, provider);
42+
CREATE INDEX IF NOT EXISTS idx_spans_model ON spans(tenant_id, model_name);
43+
CREATE INDEX IF NOT EXISTS idx_spans_security ON spans(tenant_id, security_score);
44+
CREATE INDEX IF NOT EXISTS idx_spans_operation ON spans(tenant_id, operation_name);
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
-- 002_metadata_tables.sql: tenants, tenant_configs, audit_events, api_keys
2+
3+
CREATE TABLE IF NOT EXISTS tenants (
4+
id TEXT NOT NULL PRIMARY KEY,
5+
name TEXT NOT NULL,
6+
plan TEXT NOT NULL,
7+
created_at TEXT NOT NULL,
8+
config TEXT NOT NULL DEFAULT '{}'
9+
);
10+
11+
CREATE TABLE IF NOT EXISTS tenant_configs (
12+
tenant_id TEXT NOT NULL PRIMARY KEY,
13+
security_thresholds TEXT NOT NULL DEFAULT '{}',
14+
feature_flags TEXT NOT NULL DEFAULT '{}'
15+
);
16+
17+
CREATE TABLE IF NOT EXISTS audit_events (
18+
id TEXT NOT NULL PRIMARY KEY,
19+
tenant_id TEXT NOT NULL,
20+
event_type TEXT NOT NULL,
21+
actor TEXT NOT NULL,
22+
resource TEXT NOT NULL,
23+
data TEXT NOT NULL DEFAULT '{}',
24+
timestamp TEXT NOT NULL
25+
);
26+
27+
CREATE INDEX IF NOT EXISTS idx_audit_tenant ON audit_events(tenant_id, timestamp);
28+
CREATE INDEX IF NOT EXISTS idx_audit_type ON audit_events(tenant_id, event_type);
29+
30+
CREATE TABLE IF NOT EXISTS api_keys (
31+
id TEXT NOT NULL PRIMARY KEY,
32+
tenant_id TEXT NOT NULL,
33+
name TEXT NOT NULL,
34+
key_hash TEXT NOT NULL UNIQUE,
35+
key_prefix TEXT NOT NULL,
36+
role TEXT NOT NULL,
37+
created_at TEXT NOT NULL,
38+
revoked_at TEXT
39+
);
40+
41+
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
42+
CREATE INDEX IF NOT EXISTS idx_api_keys_tenant ON api_keys(tenant_id);
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- 003_add_agent_actions.sql: agent_actions column on spans (Loop 18)
2+
3+
ALTER TABLE spans ADD COLUMN agent_actions TEXT NOT NULL DEFAULT '[]';

crates/llmtrace-storage/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
99
mod cache;
1010
mod memory;
11+
pub mod migration;
1112
mod sqlite;
1213

1314
#[cfg(feature = "clickhouse")]

0 commit comments

Comments
 (0)