Skip to main content
VoiceGateway persists everything through an async SQLAlchemy engine with SQLModel table classes and Alembic migrations. The default backend is SQLite via aiosqlite; a fleet collector can point the same code at Postgres.

Where the layer lives

There is no SQLiteStorage class and no hand-rolled executescript() schema. The real pieces: StorageService is a thin facade: every public method calls _ensure_initialized() (which runs migrations once, lazily) and then delegates to a per-domain service or repository function opened on its own AsyncSession.

Database location

resolve_database_url() in core/database.py resolves the SQLAlchemy URL in this priority order:
  1. VOICEGW_DB_URL environment variable: a full SQLAlchemy URL (e.g. a Postgres collector). Wins outright over everything below.
  2. VOICEGW_DB_PATH environment variable: a SQLite file path.
  3. cost_tracking.db_path in voicegw.yaml.
  4. Default: ~/.config/voicegateway/voicegw.db.
For the SQLite backend, the parent directory is created automatically and the connection is put in WAL mode (PRAGMA journal_mode=WAL, PRAGMA busy_timeout=5000) so the agent’s writes and the dashboard’s reads don’t collide with “database is locked”. A Postgres URL skips the filesystem and WAL setup entirely.

Connection management

Database builds one async engine and an async_sessionmaker at construction. SQLite uses the default connection pool with pool_pre_ping=True; a non-SQLite URL (Postgres) uses NullPool (a fresh connection per checkout) because Gateway.__init__ runs its async startup through several short-lived asyncio.run() loops and a pooled asyncpg connection bound to the wrong loop raises. Callers open a session via Database.session(), an async context manager that rolls back on exception and always closes.

Migrations

Schema changes ship as Alembic revisions in alembic/versions/, not autogenerated: alembic/env.py registers every SQLModel table so --autogenerate can diff against it, but nothing in this repo actually runs autogenerate, so each migration’s DDL is written by hand and the SQLModel column definitions have to be kept in sync manually. Database.run_migrations() runs alembic upgrade head the first time any StorageService method is called (via _ensure_initialized()), not on every write: a lock guards against a race on first use, and a failure is cached and re-raised rather than retried on every subsequent write. The upgrade itself runs on a dedicated single-worker thread pool, never on the event loop, because env.py opens its own asyncio.run(). If the database was already migrated by a newer build (a stamped revision this build ships no script for), run_migrations raises DatabaseAheadOfCode; StorageService catches that, logs once, and keeps serving reads and writes against the superset schema instead of failing every request. Tests build a fresh schema with Database.create_all() (SQLModel.metadata.create_all) instead of running the migration chain.
Re-encrypting managed provider API keys under a new Fernet key is a separate, operator-invoked action (voicegw rotate-secret, backed by ManagedConfigService.rotate_credentials), not an automatic migration step. It rotates every managed_providers row on demand; it does not run when an old database is opened. See Security for the encryption details.

Tables

requests

The primary table for every completed (or failed) inference request. ORM class Request in models/request_model.py; producers build a RequestRecord dataclass (kept separate because the column metadata collides with SQLAlchemy’s reserved metadata attribute) which the request-log repository converts to a row at write time.

managed_providers, managed_models, managed_projects

Providers, models, and projects added via the dashboard or MCP server rather than YAML. API keys are encrypted with Fernet (see Security). All three carry created_at/updated_at (REAL, Unix epoch).

managed_rate_rules

DB-side rate-card overrides layered after the YAML rate_card: seed. See Rating for how a rule is resolved.

config_audit_log

Append-only record of managed-config mutations.

calls and call_legs

Written by paths that never touch inference (the LiveKit webhook receiver, a load generator reporting its own attempts), so a call that answered and said nothing, or never answered at all, still gets a row. Neither table has a real foreign key: SQLite doesn’t enforce them by default and an out-of-order or redelivered webhook must never fail a write. Both are forward-only; nothing backfills traffic that predates the writers. calls, one row per call attempt, keyed on the LiveKit room SID: call_legs, one row per participant, upserted on (call_id, participant_sid): Deliberately absent from call_legs: per-leg RTP loss/jitter/MOS. None of it is observable server-side.

node_samples

One row per scrape of one node’s Prometheus exposition: layer-7 fleet telemetry, keyed on (node, source, at_ms), filled by the background scrape worker described in Node metrics. No foreign key and no call_id: these are node-wide counters, and attributing one to a specific call would invent a per-call measurement that doesn’t exist server-side. Every value column is nullable, and NULL means “not measured”, never 0. Roughly 80 further nullable value columns group into six families: livekit-server room/packet counters, livekit-sip counters (fleet-aggregate, never per-call), node-exporter host metrics (file descriptors, load, memory, network, port headroom), Go runtime (heap, goroutines), Redis health, and the same-loop HTTP health-probe result. Counters are stored raw and diffed at read time so a process restart never bakes in a fake negative rate. See src/voicegateway/models/node_sample_model.py for the full column list; Node metrics covers what the scrape configures and what the dashboard renders from it.

diagnostics_runs

One row per LiveKit diagnostics run started from the dashboard, replacing what used to be a process-local dict trimmed to 20 entries (erased on every restart).

load_runs and load_run_tests

load_runs is one execution of an external load plan (a ramp, a soak); load_run_tests is one row per test step within it. Nothing derived is stored: counts are kept, not rates, so a bad import can’t silently disagree with the numbers beside it. load_runs: load_run_tests (unique on (run_id, name)):

Replay tables

Session replay (attach(snapshots=True)) writes into four tables: replay_stt_events, replay_llm_tokens, replay_tts_frames, replay_state_snapshots, each keyed on (session_id, t_ms) with a JSON payload column. See Replay storage costs for the byte-budget breakdown and voicegw replay for reading a session back.

Views

Two SQL views ship with the initial migration (dialect-branched: Postgres uses to_char(to_timestamp(...)), SQLite uses date(timestamp, 'unixepoch')). No application code queries either one today: the cost-aggregation reads in repository/cost_repository.py (and everything built on it, the dashboard and voicegw costs) run their own hand-written GROUP BY SQL directly against requests instead.

daily_costs

project_daily_costs

Indexes on requests

Every other table listed above carries its own indexes, matched to how it’s read (newest-first history, per-project retention scans, correlation windows); see each model file in src/voicegateway/models/ for specifics.

Fleet and ClickHouse sinks

Cost writes go through one Sink at a time, chosen by how the process is configured, never more than one per process:
  • Embedded (default voicegw serve). CostTracker writes through LocalSqliteSink, straight into the local StorageService (SQLite, or Postgres if VOICEGW_DB_URL is set).
  • Fleet agent (VOICEGW_COLLECTOR_URL + VOICEGW_API_KEY set). CostTracker writes through RemoteCollectorSink, which batches records and POSTs them to the collector’s POST /v1/ingest. An optional spool_path adds a durable local outbox, a small SQLite file used only as a retry buffer, not a query surface, so a restart resumes delivery instead of losing rows.
  • Collector process, on POST /v1/ingest: writes to ClickHouseSink when a ClickHouse host is configured, otherwise to its own StorageService. Never both. Either way the collector re-rates every incoming record against its own rate card before persisting; see Rating.
For multi-tenant collector deployments, each RequestRecord carries a tenant_id that is stamped server-side from the ingest key: auth on /v1/ingest resolves the vk_ bearer token to a tenant and sets a ContextVar, so the payload’s own field (if any) is never trusted.

Configuration layers

How SQL managed tables fit into the config merge order.

Security

Fernet encryption for API keys stored in managed_providers.

Cost tracking

How costs are computed before writing to the requests table.

Rating

How rated_price_usd and rate_rule are stamped at write time.

Replay storage costs

On-disk footprint of the conversation replay tables.