Skip to main content

Database Overview

Apollon uses a dual-database architecture with two separate PostgreSQL instances, each optimized for its workload.

Architecture

┌──────────────────┐ ┌──────────────────────┐
│ PostgreSQL 18 │ │ TimescaleDB (pg18) │
│ Port 5432 │ │ Port 5433 │
│ │ │ │
│ clients │ │ gps_locations │
│ contact_persons │ │ (hypertable) │
│ cargo_categories│ │ │
│ trips │ │ │
└──────────────────┘ └──────────────────────┘
│ │
└──────────┬───────────────┘

┌──────┴──────┐
│ Rust API │
│ (2 pools) │
└─────────────┘

Why Two Databases?

ConcernPostgreSQLTimescaleDB
Data typeRelational (clients, trips, categories)Time-series (GPS locations)
Query patternCRUD with joins and foreign keysTime-range scans, ordered by timestamp
StorageStandard B-tree indexesHypertable with 1-day chunks
CompressionNone neededAutomatic after 7 days
ScalingVertical (standard PostgreSQL tuning)Chunk-based (drop old chunks, compression)
VolumeLow (thousands of records)High (millions of GPS points)

GPS locations are stored in TimescaleDB because:

  • Chunk partitioning automatically splits data into manageable 1-day intervals
  • Compression reduces storage by 90%+ for older GPS data
  • Time-range queries are orders of magnitude faster on hypertables than standard B-tree indexes
  • Retention policies can drop old chunks without expensive DELETE operations

Relational data stays in standard PostgreSQL because:

  • Foreign key constraints between clients, trips, and categories are enforced at the database level
  • CRUD operations benefit from standard PostgreSQL query planning
  • No time-series-specific features are needed

Connection Pools

The API maintains two separate sqlx::PgPool instances:

// Main PostgreSQL pool (relational data)
let db_pool = apollon_db::init_pool(&settings.database.url).await?;

// TimescaleDB pool (GPS time-series data)
let tsdb_pool = apollon_db::init_tsdb_pool(&settings.timescale).await?;

The TimescaleDB pool is wrapped in a TsdbPool newtype to prevent accidental pool confusion at compile time:

pub struct TsdbPool(pub PgPool);

Connection Configuration

PostgreSQL (relational)

SettingDefaultEnv Override
URLpostgresql://apollon:apollon@localhost:5432/apollonAPOLLON__DATABASE__URL
Max connections10(hardcoded)

TimescaleDB (GPS)

SettingDefaultEnv Override
URLpostgresql://apollon:apollon@localhost:5433/apollon_tsdbAPOLLON__TIMESCALE__URL
Max connections5APOLLON__TIMESCALE__MAX_CONNECTIONS
Min connections1APOLLON__TIMESCALE__MIN_CONNECTIONS
Connect timeout30sAPOLLON__TIMESCALE__CONNECT_TIMEOUT
Idle timeout600sAPOLLON__TIMESCALE__IDLE_TIMEOUT
Max lifetime1800sAPOLLON__TIMESCALE__MAX_LIFETIME

Auto-Migration

Both databases run migrations automatically at startup:

// PostgreSQL migrations
apollon_db::run_migrations(&db_pool).await?;

// TimescaleDB: enable extension + run migrations
apollon_db::init_tsdb_pool_and_migrate(&settings.timescale).await?;

The TimescaleDB initialization also runs CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE before applying migrations.

Health Checks

The API exposes a /health endpoint that checks both database connections:

pub async fn tsdb_health_check(pool: &PgPool) -> bool {
sqlx::query("SELECT 1").execute(pool).await.is_ok()
}

If either pool is unreachable, the health endpoint returns HTTP 503.

Dev Stack

See Docker Deployment for the Docker Compose configuration that runs both databases locally.