Skip to content

feat(storage): replace SQLite with PostgreSQL#386

Merged
TeKrop merged 16 commits intomainfrom
feature/pgdb
Feb 25, 2026
Merged

feat(storage): replace SQLite with PostgreSQL#386
TeKrop merged 16 commits intomainfrom
feature/pgdb

Conversation

@TeKrop
Copy link
Copy Markdown
Owner

@TeKrop TeKrop commented Feb 24, 2026

Summary by Sourcery

Migrate persistent storage from SQLite to PostgreSQL, updating the storage adapter, configuration, metrics, and services to use the new backend and a more flexible storage contract.

New Features:

  • Introduce a PostgreSQL-based storage adapter with connection pooling, JSONB static data, and compressed player profile storage.
  • Add in-memory FakeStorage and StoragePort tests to validate storage behavior without a real database.
  • Add PostgreSQL and postgres_exporter services to the Docker Compose stack and wire them into Prometheus monitoring.

Bug Fixes:

  • Prevent errors when parsing empty player summary JSON by returning an empty dict early.

Enhancements:

  • Add a fast path to player requests to serve fresh player profiles directly from persistent storage without calling Blizzard when within the staleness threshold.
  • Refine storage metrics to be backend-agnostic and extend data freshness tracking to a seven-day window.
  • Simplify player profile cleanup by removing SQLite-specific vacuum/optimize steps and relying on delete-by-age only.
  • Update static data services and hero/map services to treat persistent storage generically and work with structured JSON instead of serialized strings.
  • Document PostgreSQL-based storage configuration in README and settings, replacing SQLite-specific options.

Build:

  • Replace SQLite-specific dependencies and settings with asyncpg-based PostgreSQL configuration and DSN generation.

Deployment:

  • Adjust Docker volumes and services to use PostgreSQL for persistent data instead of a local SQLite file, including health checks and exporters.

Documentation:

  • Update documentation to describe PostgreSQL as the persistent storage backend and its configuration environment variables.

Tests:

  • Replace SQLite-based storage fixtures with FakeStorage and add comprehensive tests covering static data, player profiles, stats, and data integrity for the storage contract.

Chores:

  • Remove the SQLite storage implementation and its tests in favor of the new PostgreSQL-focused storage layer.

@TeKrop TeKrop self-assigned this Feb 24, 2026
@TeKrop TeKrop added the enhancement New feature or request label Feb 24, 2026
@TeKrop TeKrop requested a review from Copilot February 24, 2026 07:56
@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented Feb 24, 2026

Reviewer's Guide

Replaces the SQLite-based persistent storage with a PostgreSQL-backed adapter, updates the storage port and metrics to be backend-agnostic, adds a fast-path for serving fresh player profiles directly from persistent storage, and wires Postgres into the runtime, tests, and monitoring stack.

Sequence diagram for player profile request with PostgreSQL fast path

sequenceDiagram
    actor Client
    participant APIServer as APIServer
    participant PlayerService as PlayerService
    participant Storage as PostgresStorage
    participant Blizzard as BlizzardAPI

    Client->>APIServer: HTTP GET /players/{player_id}
    APIServer->>PlayerService: execute_player_request(player_id)

    Note over PlayerService: Fast path: check persistent storage freshness
    PlayerService->>Storage: get_player_id_by_battletag(player_id) / direct if Blizzard ID
    PlayerService->>Storage: get_player_profile(blizzard_id)
    Storage-->>PlayerService: profile or None

    alt Fresh_profile_in_storage
        PlayerService->>PlayerService: data_factory(profile.html, profile.summary)
        PlayerService->>Storage: set_player_profile (background updates as needed)
        PlayerService-->>APIServer: data, is_stale=false, age
        APIServer-->>Client: 200 OK (served from Postgres)
    else No_fresh_profile
        PlayerService->>PlayerService: _resolve_player_identity(player_id)
        PlayerService->>Blizzard: fetch_player_html(blizzard_id)
        Blizzard-->>PlayerService: html
        PlayerService->>PlayerService: data_factory(html, player_summary)
        PlayerService->>Storage: set_player_profile(player_id, html, summary)
        PlayerService-->>APIServer: data, is_stale, age=0
        APIServer-->>Client: 200 OK (served from Blizzard)
    end
Loading

ER diagram for PostgreSQL static_data and player_profiles tables

erDiagram
    static_data {
        varchar key PK
        jsonb data
        static_data_category category
        smallint data_version
        timestamptz created_at
        timestamptz updated_at
    }

    player_profiles {
        text player_id PK
        text battletag
        text name
        bytea html_compressed
        jsonb summary
        bigint last_updated_blizzard
        smallint data_version
        timestamptz created_at
        timestamptz updated_at
    }

    %% Indexes (conceptual, shown as relationships to virtual entities)
    battletag_index {
        text battletag
    }

    updated_at_index {
        timestamptz updated_at
    }

    player_profiles ||--o{ battletag_index : idx_player_profiles_battletag
    player_profiles ||--o{ updated_at_index : idx_player_profiles_updated_at
Loading

Class diagram for storage port and PostgreSQL adapter

classDiagram
    class StoragePort {
        <<protocol>>
        +initialize() async
        +get_static_data(key: str) async dict|None
        +set_static_data(key: str, data: dict, category: StaticDataCategory, data_version: int=1) async
        +get_player_profile(player_id: str) async dict|None
        +get_player_id_by_battletag(battletag: str) async str|None
        +set_player_profile(player_id: str, html: str, summary: dict|None, battletag: str|None, name: str|None, last_updated_blizzard: int|None, data_version: int=1) async
        +delete_old_player_profiles(max_age_seconds: int) async int
        +clear_all_data() async
        +get_stats() async dict
        +close() async
    }

    class StaticDataCategory {
        <<enum>>
        +HEROES
        +HERO
        +GAMEMODES
        +MAPS
        +ROLES
    }

    class PostgresStorage {
        -_pool: asyncpg.Pool|None
        -_initialized: bool
        -_init_lock: asyncio.Lock
        -_MAX_POOL_CREATION_ATTEMPTS: int
        +initialize() async
        +close() async
        +get_static_data(key: str) async dict|None
        +set_static_data(key: str, data: dict, category: StaticDataCategory, data_version: int=1) async
        +get_player_profile(player_id: str) async dict|None
        +get_player_id_by_battletag(battletag: str) async str|None
        +set_player_profile(player_id: str, html: str, summary: dict|None, battletag: str|None, name: str|None, last_updated_blizzard: int|None, data_version: int=1) async
        +delete_old_player_profiles(max_age_seconds: int) async int
        +clear_all_data() async
        +get_stats() async dict
        -_create_schema() async
        -_compress(data: str) bytes
        -_decompress(data: bytes) str
        -_init_connection(conn: asyncpg.Connection) async
    }

    class Settings {
        +postgres_host: str
        +postgres_port: int
        +postgres_db: str
        +postgres_user: str
        +postgres_password: str
        +postgres_pool_min_size: int
        +postgres_pool_max_size: int
        +player_profile_max_age: int
        +postgres_dsn: str
    }

    class StaticDataService {
        +get_or_fetch(config: StaticFetchConfig) async tuple
        -_load_from_storage(storage_key: str) async dict|None
        -_serve_from_storage(stored: dict, config: StaticFetchConfig) async tuple
        -_refresh_static(config: StaticFetchConfig) async
        -_fetch_and_store(config: StaticFetchConfig) async Any
        -_cold_fetch(config: StaticFetchConfig) async tuple
        -_store_in_storage(storage_key: str, data: Any, entity_type: str) async
        -_apply_filter(data: Any, result_filter: Callable) Any
        -storage: StoragePort
    }

    class PlayerService {
        +get_player_profile_cache(player_id: str) async dict|None
        +update_player_profile_cache(player_id: str, html: str, summary: dict|None, battletag: str|None, name: str|None) async
        -_execute_player_request(request: PlayerRequest) async tuple
        -_get_fresh_stored_profile(player_id: str) async tuple|None
        -_get_player_html(effective_id: str, identity: PlayerIdentity) async str
        -_resolve_player_identity(player_id: str) async PlayerIdentity
        -storage: StoragePort
    }

    class PostgresStorageSingleton {
        <<metaclass Singleton>>
    }

    StoragePort <|.. PostgresStorage
    StaticDataService --> StoragePort
    PlayerService --> StoragePort
    PostgresStorage ..> StaticDataCategory
    Settings ..> PostgresStorage
    PostgresStorage ..> PostgresStorageSingleton
Loading

File-Level Changes

Change Details Files
Introduce PostgreSQL storage adapter and schema, removing SQLite implementation.
  • Add PostgresStorage singleton using asyncpg with JSONB, zstd-compressed player HTML, percentiles-based stats, and maintenance helpers.
  • Replace SQLite schema with PostgreSQL schema and enum type for static data categories, including appropriate indexes.
  • Remove sqlite_storage implementation and its tests, and update storage adapter exports to expose PostgresStorage only.
app/adapters/storage/postgres_storage.py
app/adapters/storage/schema.sql
app/adapters/storage/__init__.py
app/adapters/storage/sqlite_storage.py
tests/test_sqlite_storage.py
Update storage port contract and services to support the new Postgres-backed data model and fast-path profile reads.
  • Extend StoragePort with StaticDataCategory enum, JSON/dict-based static data API, and revised player profile metadata fields and return shapes.
  • Adjust StaticDataService and hero/map services to read/write JSON(B) via storage, including passing entity type as StaticDataCategory.
  • Add PlayerService fast path that serves profiles directly from persistent storage when within staleness threshold, including Blizzard-ID resolution via stored mapping and returning profile age.
  • Simplify player cleanup loop to only delete old profiles, removing SQLite-specific vacuum/optimize steps.
app/domain/ports/storage.py
app/domain/services/static_data_service.py
app/domain/services/hero_service.py
app/domain/services/map_service.py
app/domain/services/player_service.py
app/domain/services/base_service.py
app/main.py
Generalize and rename SQLite-specific metrics and monitoring to storage-agnostic ones and integrate Postgres into observability.
  • Rename sqlite_* Prometheus counters/histograms to storage_* variants, remove WAL/lock-specific metrics, and update descriptions.
  • Rename and repurpose the operation-tracking decorator to track_storage_operation, updating its usage in the Postgres adapter.
  • Update monitoring router to use PostgresStorage for stats and to emit storage_* metrics, including player profile age histograms.
  • Extend docker-compose and Prometheus/Grafana setup to run Postgres and postgres-exporter, and update redis-exporter image and healthchecks.
app/monitoring/metrics.py
app/monitoring/router.py
build/prometheus/prometheus.yml
build/grafana/provisioning/dashboards/overfast-api-health.json
docker-compose.yml
Wire Postgres configuration into application settings, dependencies, and documentation.
  • Replace SQLite-specific settings with PostgreSQL connection and pool parameters, and add a computed postgres_dsn property.
  • Update dependency wiring and application lifespan to use PostgresStorage, including startup logging text.
  • Update README and env template/docs to describe PostgreSQL-based persistent storage and connection environment variables.
  • Increase player_profile_max_age from 3 to 7 days to match new storage behavior.
app/config.py
app/api/dependencies.py
app/main.py
README.md
.env.dist
Introduce an in-memory FakeStorage for tests and adjust tests to the new storage contract.
  • Add FakeStorage implementing StoragePort semantics in memory, mirroring PostgresStorage behavior without compression or I/O.
  • Update pytest fixtures to use FakeStorage instead of SQLiteStorage and clear it between tests.
  • Add contract tests for static data, player profiles, stats, and integrity using the new storage API, and relax a tests-only Ruff rule comment wording.
  • Add a small safety guard in parse_player_summary_json for empty payloads to work with the new profile behavior.
tests/fake_storage.py
tests/test_storage.py
tests/conftest.py
pyproject.toml
app/adapters/blizzard/parsers/player_summary.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • In docker-compose, the Postgres service mounts pg-data at /var/lib/postgresql, but the official Postgres image persists data under /var/lib/postgresql/data; updating the mount path will ensure the database actually uses the volume for persistence.
  • The Postgres image is pinned to postgres:18-alpine, which does not currently exist; consider using a stable, existing tag such as postgres:16-alpine or similar to avoid pull/startup failures.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In docker-compose, the Postgres service mounts `pg-data` at `/var/lib/postgresql`, but the official Postgres image persists data under `/var/lib/postgresql/data`; updating the mount path will ensure the database actually uses the volume for persistence.
- The Postgres image is pinned to `postgres:18-alpine`, which does not currently exist; consider using a stable, existing tag such as `postgres:16-alpine` or similar to avoid pull/startup failures.

## Individual Comments

### Comment 1
<location path="app/domain/services/player_service.py" line_range="268" />
<code_context>
+                logger.info(
+                    "Serving player data from persistent storage (within staleness threshold)"
+                )
+                data = request.data_factory(profile["profile"], profile["summary"])
+            else:
+                identity = await self._resolve_player_identity(request.player_id)
</code_context>
<issue_to_address>
**issue (bug_risk):** Use the stored HTML field instead of a non-existent `profile` key when serving from persistent storage.

`get_player_profile_cache`/`get_player_profile` return the HTML under the `"html"` key, not `"profile"`, so `profile["profile"]` will raise a KeyError at runtime. This branch should call `request.data_factory(profile["html"], profile["summary"])` to match the non-cache path.
</issue_to_address>

### Comment 2
<location path="app/domain/services/player_service.py" line_range="287-288" />
<code_context>

     async def get_player_profile_cache(self, player_id: str) -> dict | None:
-        """Get player profile from SQLite storage."""
+        """Get player profile from persistent storage storage."""
         profile = await self.storage.get_player_profile(player_id)
         if not profile:
</code_context>
<issue_to_address>
**nitpick (typo):** Fix duplicated word in the `get_player_profile_cache` docstring.

There’s a duplicated word here; please change `"persistent storage storage"` to `"persistent storage"`.

```suggestion
    async def get_player_profile_cache(self, player_id: str) -> dict | None:
        """Get player profile from persistent storage."""
```
</issue_to_address>

### Comment 3
<location path="docker-compose.yml" line_range="40" />
<code_context>
       timeout: 2s

+  postgres:
+    image: postgres:18-alpine
+    environment:
+      - POSTGRES_DB=${POSTGRES_DB:-overfast}
</code_context>
<issue_to_address>
**issue (bug_risk):** The `postgres:18-alpine` image tag likely does not exist and will break environment startup.

Official Postgres images only go up to major version 16, so `postgres:18-alpine` will fail to pull and prevent `docker-compose` from starting. Please use an existing tag (e.g. `postgres:16-alpine`) or make the version configurable via an environment variable.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread app/domain/services/player_service.py
Comment thread app/domain/services/player_service.py Outdated
Comment thread docker-compose.yml
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request implements a major architectural change by replacing SQLite with PostgreSQL as the persistent storage backend for OverFast API. The migration includes updates to the storage adapter, configuration, Docker setup, monitoring infrastructure, and test fixtures.

Changes:

  • Replaced SQLite storage adapter with PostgreSQL adapter using asyncpg driver and connection pooling
  • Updated Docker Compose configuration to include PostgreSQL 18 service with health checks and postgres-exporter for monitoring
  • Migrated schema from SQLite to PostgreSQL with JSONB for structured data and BYTEA for compressed HTML
  • Updated all monitoring metrics and Grafana dashboards to reflect the storage backend change
  • Replaced in-memory test fixtures with FakeStorage implementation for better test isolation

Reviewed changes

Copilot reviewed 26 out of 27 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
uv.lock Removed aiosqlite dependency, added asyncpg 0.31.0, updated virtualenv
pyproject.toml Updated dependency from aiosqlite to asyncpg, version bump to 3.45.1
app/config.py Replaced SQLite configuration with PostgreSQL connection settings and DSN builder
app/adapters/storage/postgres_storage.py New PostgreSQL adapter with connection pooling, JSONB support, and zstd compression
app/adapters/storage/schema.sql Migrated schema from SQLite to PostgreSQL with ENUM types and TIMESTAMPTZ
app/domain/ports/storage.py Updated port interface to use dict for data (JSONB) instead of JSON strings, added StaticDataCategory enum
app/domain/services/*.py Updated service layer to work with dict-based storage instead of JSON strings
tests/fake_storage.py New in-memory FakeStorage implementation for testing without database dependencies
tests/test_storage.py Simplified tests to exercise StoragePort contract via FakeStorage
tests/conftest.py Updated test fixtures to use FakeStorage instead of SQLite
app/monitoring/metrics.py Renamed all sqlite_* metrics to storage_*, removed WAL-specific metrics
app/monitoring/router.py Updated to use PostgresStorage instead of SQLiteStorage
docker-compose.yml Added PostgreSQL service and postgres-exporter, removed SQLite volume
build/grafana/provisioning/dashboards/*.json Updated Grafana dashboards with PostgreSQL-specific metrics
build/prometheus/prometheus.yml Added PostgreSQL scrape configuration
.env.dist Replaced SQLite configuration with PostgreSQL connection settings
README.md Updated documentation to reference PostgreSQL instead of SQLite

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread app/adapters/storage/postgres_storage.py
Comment thread docker-compose.yml Outdated
Comment thread .env.dist
Comment thread docker-compose.yml
Comment thread README.md
Comment thread app/adapters/storage/postgres_storage.py Outdated
Comment thread tests/fake_storage.py Outdated
Comment thread app/adapters/storage/postgres_storage.py Outdated
@sonarqubecloud
Copy link
Copy Markdown

@TeKrop TeKrop merged commit 3668f63 into main Feb 25, 2026
5 checks passed
@TeKrop TeKrop deleted the feature/pgdb branch February 25, 2026 07:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants