Database Management Guide¶
SPOT Platform uses SQLAlchemy for ORM and Alembic for database migrations. This guide covers database operations and best practices.
Quick Start¶
# Check current database revision
make db:status
# Apply pending migrations
make db:migrate
# Show migration history
make db:history
# Create a new migration
make db:revision MESSAGE="Add user table"
Deploy-Time Migrations¶
Important: Migrations must be run at deploy-time, before starting services.
Services do not run migrations automatically at startup. This is a deliberate design choice:
- Predictable deployments: Migrations run once per deployment, not per service replica
- Faster startup: Services start immediately without waiting for migration locks
- No race conditions: Concurrent service startups don't conflict on migrations
- Clear separation: Infrastructure changes (schema) are separate from application startup
Deployment Workflow¶
CI/CD Integration¶
In CI/CD pipelines, ensure migrations run before service deployment:
deploy:
script:
- make db:migrate # Run migrations first
- docker compose up -d # Then start services
Architecture¶
The database layer is organized into focused components:
- Models (
shared/src/spot_shared/database/models.py,shared/src/spot_shared/database/knowledge_models.py,shared/src/spot_shared/database/plugin_models.py): Define database schema - Repositories (
shared/src/spot_shared/database/email_repository.py,shared/src/spot_shared/database/repository.py): Store and read emails and analyses, encrypting what they hold of an email - Session Management (
shared/src/spot_shared/database/session.py): Handle database connections - Migrations (
shared/src/spot_shared/database/alembic/): Manage schema changes
Components¶
- SQLAlchemy Models: Python classes that map to database tables
- Repositories: The services' way in: they convert between the contract models and the tables
- Alembic Migrations: Version-controlled schema changes
- Session Management: Context managers for safe database access
Models vs Contracts¶
SPOT uses two types of models that serve different purposes:
| Type | Purpose | Location | Technology | Use Case |
|---|---|---|---|---|
| Contract Models | Plugin contract and API models | spot_sdk package | Pydantic v2 | EmailView, AnalyzerVerdict, OrchestrationResult, Workflow, ... |
| Database Models | Data persistence | shared/src/spot_shared/database/ | SQLAlchemy | Database tables, ORM queries |
Key Differences:
- Contract Models (
spot_sdk): Define what data looks like when transmitted: the email's view sent to analyzers, their verdicts, the workflow's result - Database Models (core): Define how data is stored in PostgreSQL (tables, relationships, indexes), and what is encrypted
The repositories convert between them:
import os
from spot_shared.database import AnalysisResultRepository, EmailRepository
from spot_shared.database.session import create_async_session_factory
sessions = create_async_session_factory()
emails = EmailRepository(sessions, os.environ["ENCRYPTION_KEY"])
analyses = AnalysisResultRepository(sessions, os.environ["ENCRYPTION_KEY"])
# Intake: the raw message (email_raw) and its EmailView (emails.view_encrypted),
# both encrypted, in one transaction
await emails.store(raw, view, retention_days=365)
view = await emails.get_view(email_id) # None for an email stored before 2.0
raw = await emails.get_raw(email_id)
# A workflow's OrchestrationResult: the verdict, the score and decided_by in
# clear, the result encrypted, and one analysis_signals row per signal
await analyses.store(result)
result = await analyses.get_result(job_id)
Why separate?
- Contract models: Focused on the plugin contract and the APIs, versioned with the SDK
- Database models: Internal implementation detail, can evolve separately
- No tight coupling: Core can change storage without breaking the contract
What is stored¶
| Table | Holds |
|---|---|
emails | One row per email: the sender and the first recipient as hashes (for filters), the subject and the sender encrypted (for summaries), the view encrypted (view_encrypted), the source, the dates (the received date indexed for a backtest's period, migration 020), expires_at, dry_run for a workflow dry run's message (migration 013), and, for a dataset's copy, dataset_id and copied_from (migration 019) |
email_raw | The raw message, encrypted, with its size and SHA-256; deleted with its email (ON DELETE CASCADE) |
analysis_results | One row per analysis: the workflow, the status, the verdict, the score and decided_by in clear, for filters and statistics, the route that chose the workflow (migration 014) and the job's mode, observe or enforce (migration 015); the OrchestrationResult encrypted (result_encrypted); the error of a failed job; deleted with its email |
analysis_signals | The signals behind a verdict: the analyzer, the name and the weight in clear, the evidence and the location encrypted; deleted with their analysis |
analysis_actions | What was done with each job's email, or would have been in observe: one row per job, actor and kind (the retriever, or a responder), with the action, the mode and whether it was applied (migration 015); deleted with its email |
shadow_differences | Where a shadow workflow's verdict class differed from production's: the job, production's workflow, verdict and score, and the shadow workflow's version, verdict, score and decided_by in clear, its result encrypted (migration 014); deleted with its email |
shadow_tallies | The production results each shadow workflow ran on per day (UTC), an email analysed again counting again, and those on which its verdict class differed, for the rate of its differences; counted whether it differed or not, never an email's content (migration 020) |
knowledge_documents | The Knowledge Store's documents and their embeddings (migration 008), keyed by (source, id): an id is unique within its source, and the source is never empty (migration 017) |
benchmark_runs | One row per benchmark run: the workflow's id and version, the commit of spot.yaml's history, SPOT's version, each analyzer's version and image, the selection (the datasets' files and their SHA-256, the seed), the status, who ran it, and the metrics once it ended; all in clear, none of it an email's content (migration 018); one platform run runs at a time, which a partial unique index (migration 020) keeps even for two simultaneous starts |
benchmark_results | One row per email of a run: its key in its dataset, its truth, the workflow's verdict, score and decided_by, its time, and each analyzer's status, verdict, score and time, and for a stored email the production verdict it was compared with and that analysis's job (baseline_job_id, migration 020); deleted with its run, and not by the retention purge (migration 018) |
email_labels | An analyst's label of an email, one per email: the class in clear, the note encrypted, who set it and when, and the analysis it corrects (migration 019); deleted with its email |
datasets | The datasets analysts promote emails into: id, name, description and who created it (migration 019) |
plugin_tokens | Each plugin token's SHA-256 and a copy encrypted (migration 009) |
users, oauth_clients | Accounts and OAuth clients |
audit_logs | The audit log: who did what (the actor's name and stable id), when, to what, how it ended, what changed by name, the client's address and User-Agent, and the correlation ID, in clear: it holds no content or secret value; indexed by time, action and time, and actor id (migration 016, which recreates the table 001 created and nothing wrote) |
Everything encrypted uses ENCRYPTION_KEY (Fernet). An email past its expires_at is purged with its raw copy, analyses and signals (data_retention_days), and an audit entry older than audit_retention_days after it.
A dataset's member is a copy of an email: an emails row with dataset_id and copied_from, its own raw message, view and label. The purge skips it, and no email list, search, source filter or statistic shows it. Removing it from its dataset, or deleting the dataset, deletes it (ON DELETE CASCADE); deleting the email it was copied from leaves it (Retention).
A workflow dry run stores its message as an email flagged dry_run, for the length of the run, and deletes it when the run ends. No email list, search or source filter shows it; it expires when the gateway stops waiting for the run, so the purge deletes one left behind. A downgrade of migration 013 deletes the dry runs' emails, then the column.
Migration 010 (contract 2) adds email_raw, emails.view_encrypted, and the verdict columns and analysis_signals, and converts the analyses stored in contract 1: is_phishing false is benign; true with a high or critical threat level is malicious, and suspicious otherwise; a failed analysis is unknown. The score is confidence x 100 for a phishing verdict and (1 - confidence) x 100 for a benign one; decided_by is v1; each indicator becomes a signal legacy.<type> of weight 0; what contract 1 recorded besides is kept encrypted (legacy_encrypted). It needs ENCRYPTION_KEY as soon as there are analyses to convert, and stops before changing anything without it. Emails stored before 2.0 keep their contract 1 content and have no raw copy or view.
A downgrade of migration 010 needs ENCRYPTION_KEY too as soon as there are analyses or signals, and stops before changing anything without it. It gives each analysis contract 1's columns back: a converted one recovers what it recorded, a 2.0 one gets a classification derived from its verdict and score, and the signals become indicators. It drops what only 2.0 stores: the workflows' results (result_encrypted), the raw messages (email_raw), the views (view_encrypted) and the senders' addresses (emails.sender_encrypted).
Using the ORM¶
Services use async sessions:
from sqlalchemy import select
from spot_shared.database.models import AnalysisResult
from spot_shared.database.session import get_async_db_session
async with get_async_db_session() as session:
malicious = await session.scalars(
select(AnalysisResult.id).where(AnalysisResult.verdict == "malicious")
)
# Commit happens automatically on context exit
Synchronous code (migrations, scripts) uses get_db_session() the same way, with with.
Advanced Usage¶
from spot_shared.database.session import create_db_engine, create_session_factory
# Create engine with custom config
engine = create_db_engine(pool_size=20, max_overflow=10)
# Create session factory
SessionFactory = create_session_factory(engine)
# Use in your service
session = SessionFactory()
try:
# Your database operations
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
Migration Commands¶
Check Migration Status¶
# Show current database revision
make db:status
# Show migration history (LIMIT=N to cap)
make db:history
Apply Migrations¶
# Apply all pending migrations
make db:migrate
# Or upgrade to a specific revision
make db:migrate REVISION=003_add_priority
Rollback Migrations¶
# Rollback the last migration (REVISION defaults to -1)
make db:downgrade
# Rollback the last 3 migrations
make db:downgrade REVISION=-3
Create New Migration¶
# Autogenerate migration from model changes
make db:revision MESSAGE="Add user roles table"
# Disable autogeneration (for an empty manual migration)
make db:revision MESSAGE="Custom data backfill" AUTOGEN=0
Manual Migration Management¶
For advanced operations not exposed by the Make targets (stamp, branches, merge...), invoke alembic directly inside the api-gateway container:
# Stamp database to specific revision (advanced)
docker compose exec api-gateway alembic -c /app/.venv/lib/python3.11/site-packages/spot_shared/database/alembic.ini stamp 001_initial_schema
Creating Database Models¶
Example Model¶
from sqlalchemy import Column, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime
import uuid
from spot_shared.database.models import Base
class User(Base):
"""User model following Single Responsibility Principle."""
__tablename__ = 'users'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String(255), unique=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
emails = relationship("Email", back_populates="user")
Best Practices¶
- Single Responsibility: Each model represents one table
- Use UUIDs: For primary keys (better for distributed systems)
- Add Indexes: For frequently queried columns
- Use Relationships: Define FK relationships using SQLAlchemy ORM
- Default Values: Use
default=for columns with default values
Migration Workflow¶
1. Modify Models¶
Edit shared/src/spot_shared/database/models.py or shared/src/spot_shared/database/plugin_models.py:
# Add new column
class Email(Base):
__tablename__ = 'emails'
# ... existing columns ...
# New column
priority = Column(Integer, default=0)
2. Generate Migration¶
This creates a file like alembic/versions/002_add_priority_to_emails.py
3. Review Migration¶
Check the generated file in shared/src/spot_shared/database/alembic/versions/:
def upgrade() -> None:
op.add_column('emails', sa.Column('priority', sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column('emails', 'priority')
4. Apply Migration¶
5. Verify¶
Configuration¶
Database URL¶
Set in .env:
Connection Pooling¶
Configure in your service:
engine = create_db_engine(
pool_size=10, # Number of connections to keep open
max_overflow=20, # Max connections above pool_size
pool_recycle=3600, # Recycle connections after 1 hour
pool_pre_ping=True # Verify connections before using
)
Debug Mode¶
Enable SQL logging:
Troubleshooting¶
Migration Failed¶
# Check current state
make db:status
# Review migration history
make db:history
# If stuck, stamp to a specific revision (advanced; runs alembic in
# the api-gateway container)
docker compose exec api-gateway alembic -c /app/.venv/lib/python3.11/site-packages/spot_shared/database/alembic.ini stamp <revision_id>
Connection Issues¶
# Verify DATABASE_URL is set
echo $DATABASE_URL
# Check if postgres is running
docker compose ps postgres
# Test connection
docker compose exec postgres psql -U spot -d spot -c "SELECT 1"
Permission Errors¶
If you see permission errors when creating migrations, make sure the shared/src/spot_shared/database/alembic/versions/ directory is writable.
Directory Structure¶
shared/src/spot_shared/database/
├── alembic/ # Alembic configuration
│ ├── env.py # Environment configuration
│ ├── script.py.mako # Migration template
│ └── versions/ # Migration files, 001_initial_schema.py to 017_knowledge_ids_per_source.py
├── alembic.ini # Alembic config file
├── models.py # Core SQLAlchemy models
├── knowledge_models.py # The Knowledge Store's table
├── plugin_models.py # Plugin-related models
├── email_repository.py # Emails: raw message and view, encrypted
├── repository.py # Analyses: verdicts, results and signals
└── session.py # Session management utilities