Database Schema
PostgreSQL schema for persistent data storage.
Tables
players
Player and admin accounts (unified).
CREATE TABLE players (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(20) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT username_format CHECK (username ~ '^[a-zA-Z0-9_]{3,20}$')
);
CREATE INDEX idx_players_username ON players(username);
ships
Player-owned ships (multi-ship support, added in migration 005).
CREATE TABLE ships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
name VARCHAR(32) NOT NULL,
ship_class VARCHAR(32) NOT NULL DEFAULT 'fast_frigate',
system_id VARCHAR(32) NOT NULL DEFAULT 'sol',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT uq_ships_player_name UNIQUE (player_id, name)
);
CREATE INDEX idx_ships_player_created ON ships(player_id, created_at);
CREATE INDEX idx_ships_system_id ON ships(system_id);
The compound index (player_id, created_at) supports both single-column lookups on player_id and the common get_ships query pattern (WHERE player_id = $1 ORDER BY created_at).
Ship Data Ownership
| Data | Storage | Owner Service |
|---|---|---|
| player_id → ship_id mapping | PostgreSQL (ships table) | players |
| Ship real-time state | Redis | physics |
| Ship state in snapshots | PostgreSQL (snapshots.state) | tick-engine |
Data flow:
- Player registers → players service generates ship_id, stores in ships table
- players service calls physics to spawn ship with that ship_id
- physics service creates ship state in Redis
- Snapshots include full ship state from Redis
- On restart, physics restores ship state from snapshot
- player_id → ship_id mapping survives independently in PostgreSQL
snapshots
Periodic game state snapshots for recovery.
CREATE TABLE snapshots (
id SERIAL PRIMARY KEY,
tick_number BIGINT NOT NULL,
game_time TIMESTAMP WITH TIME ZONE NOT NULL,
state JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_snapshots_tick ON snapshots(tick_number DESC);
game_config
Server configuration stored in database.
CREATE TABLE game_config (
key VARCHAR(50) PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
Snapshot State Format
The state JSONB column in snapshots contains:
{
"tick_number": 123456,
"game_time": "2025-01-15T10:30:00Z",
"total_spawns": 42,
"paused": false,
"tick_rate": 1.0,
"bodies": [
{
"name": "Earth",
"position": [x, y, z],
"velocity": [vx, vy, vz]
}
],
"ships": [
{
"ship_id": "uuid",
"player_id": "uuid",
"name": "PlayerName",
"position": [x, y, z],
"velocity": [vx, vy, vz],
"attitude": [qw, qx, qy, qz],
"angular_velocity": [wx, wy, wz],
"rotation_input": [pitch, yaw, roll],
"fuel": 25000.0,
"thrust_level": 0.0,
"wheel_momentum": [mx, my, mz]
}
]
}
Game Config Keys
| Key | Value Type | Description |
|---|---|---|
tick_rate |
number | Ticks per second |
start_date |
string | Initial game date (ISO 8601) |
snapshot_interval |
number | Seconds between snapshots |
Note: current_tick and paused are stored in Redis (game:tick, game:paused) for real-time access, and persisted via snapshots. They are not stored in game_config to avoid inconsistency between Redis and PostgreSQL.
Recovery Process
On server restart:
- Load latest snapshot:
SELECT * FROM snapshots ORDER BY tick_number DESC LIMIT 1 - Check Redis state:
- Read
game:tickfrom Redis - If Redis is empty or
game:tickdoesn’t exist → use snapshot - If Redis
game:tick> snapshottick_number→ use Redis (it’s newer) - If Redis
game:tick≤ snapshottick_number→ use snapshot (Redis is stale)
- Read
- If using snapshot: restore all state to Redis from snapshot JSON
- Restore
game:pausedfrom snapshot (preserves pause state across restarts) - Restore
game:tick_ratefrom snapshot (preserves tick rate across restarts) - Restore
game:total_spawnsfrom snapshot (preserves spawn counter for position sequencing) - If paused: tick-engine starts in paused state (no tick processing until admin resumes)
- If not paused: resume tick processing from the recovered
game:tickvalue
Staleness detection:
| Redis game:tick | Snapshot tick_number | Decision |
|---|---|---|
| Not present | Any | Use snapshot |
| 1000 | 500 | Use Redis (newer) |
| 500 | 500 | Use snapshot (authoritative) |
| 500 | 1000 | Use snapshot (Redis stale) |
Redis is considered stale if its tick is less than or equal to the snapshot, because the snapshot represents a known-good state that was persisted to durable storage.
Snapshot Retention
Snapshots are pruned to prevent unbounded storage growth.
Retention policy:
| Parameter | Value | Description |
|---|---|---|
| Max snapshots | 100 | Maximum snapshots to retain |
| Min age to prune | 1 hour | Never delete snapshots less than 1 hour old |
Pruning process:
After each successful snapshot creation:
- Count total snapshots:
SELECT COUNT(*) FROM snapshots - If count > 100:
- Delete oldest snapshots that are > 1 hour old
- Keep at most 100 snapshots
DELETE FROM snapshots WHERE id IN (SELECT id FROM snapshots WHERE created_at < NOW() - INTERVAL '1 hour' ORDER BY tick_number ASC LIMIT (count - 100))
- Log: “Pruned {n} old snapshots”
Rationale:
- 100 snapshots at 60-second intervals = ~1.7 hours of history
- 1-hour minimum age protects recent snapshots during high activity
- Sufficient for debugging and recovery without excessive storage
Manual snapshot retention:
Admin-created manual snapshots follow the same retention policy (no special treatment in MVP).
Migrations
Schema migrations are managed with Alembic in the db/ directory at the project root.
Why Alembic
- Tracks migration history in an
alembic_versiontable - Supports upgrade and downgrade operations
- Integrates with Kubernetes via a Job that runs before service deployments
- Shared across all services (no single service owns the schema)
Directory Structure
db/
alembic.ini # Alembic configuration
requirements.txt # Python dependencies (alembic, psycopg2-binary)
Dockerfile # Migration runner image
alembic/
env.py # Migration environment (reads DATABASE_URL)
script.py.mako # Template for new migrations
versions/
001_initial_schema.py # Initial schema (4 tables)
tests/
test_migrations.py # Migration tests
Design Decisions
- Raw SQL migrations: Services use asyncpg directly (no SQLAlchemy models), so migrations use
op.execute()with raw SQL. No model autogenerate. - psycopg2-binary for Alembic: Alembic is synchronous; asyncpg cannot be used.
psycopg2-binaryis a separate dependency only in the migration container. - Idempotent initial migration: Uses
IF NOT EXISTSso it is safe to run against databases initialized by the ConfigMap init script. - ConfigMap init script retained: Still used for bootstrapping new PostgreSQL instances. Alembic handles ongoing schema changes.
Creating a New Migration
# From the db/ directory:
alembic -c db/alembic.ini revision -m "description_of_change"
Edit the generated file in db/alembic/versions/ — write raw SQL in upgrade() and downgrade().
Running Migrations
Locally (development):
DATABASE_URL=postgresql://galaxy:password@localhost:5432/galaxy \
alembic -c db/alembic.ini upgrade head
Kubernetes:
kubectl apply -f k8s/base/migration-job.yaml -n galaxy-dev
kubectl wait --for=condition=complete job/db-migration -n galaxy-dev --timeout=60s
maneuver_events
Persistent maneuver lifecycle events for diagnosis. Low-volume (lifecycle events only, not per-tick), survives pod restarts.
CREATE TABLE maneuver_events (
id BIGSERIAL PRIMARY KEY,
ship_id UUID NOT NULL,
maneuver_type VARCHAR(32) NOT NULL, -- circularize, set_inclination, rendezvous
event_type VARCHAR(32) NOT NULL, -- maneuver_started, phase_transition, maneuver_completed, maneuver_aborted
phase VARCHAR(32), -- current phase
from_phase VARCHAR(32), -- previous phase (for transitions)
tick BIGINT NOT NULL,
game_time TIMESTAMP WITH TIME ZONE NOT NULL,
element_errors JSONB, -- [{label, value, ok}, ...]
distance DOUBLE PRECISION,
effectivity DOUBLE PRECISION,
status_text TEXT,
extra JSONB, -- arbitrary per-event data
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_maneuver_events_ship ON maneuver_events(ship_id, created_at DESC);
CREATE INDEX idx_maneuver_events_tick ON maneuver_events(tick DESC);
Retention: 1000 rows per ship, pruned on insert.
Migration Naming
Alembic auto-generates revision IDs. Files are prefixed with a sequence number for readability:
001_initial_schema.py
002_maneuver_events.py
003_unified_admin_auth.py
004_drop_admins_table.py
005_multi_ship.py
006_reset_backups.py
007_unique_ship_names.py
008_compound_indexes.py
009_multi_system.py