Skip to content

feat(storage): implement SQLite persistent storage with complete Port/Adapter pattern#361

Merged
TeKrop merged 28 commits intomainfrom
feature/ddd-phase-3-persistent-storage
Feb 16, 2026
Merged

feat(storage): implement SQLite persistent storage with complete Port/Adapter pattern#361
TeKrop merged 28 commits intomainfrom
feature/ddd-phase-3-persistent-storage

Conversation

@TeKrop
Copy link
Owner

@TeKrop TeKrop commented Feb 14, 2026

Summary by Sourcery

Add SQLite-based persistent storage and integrate it with existing caching and player controllers, introducing async cache operations, exponential backoff for unknown players, and enhanced error-handling and metrics.

New Features:

  • Introduce a SQLiteStorage adapter with schema-managed tables for static data, player profiles, and unknown player status tracking with zstd compression.
  • Add persistent storage integration to controllers for dual-writing static data and storing full player profiles separate from Valkey cache.
  • Expose enhanced 404 player-not-found responses that include exponential-backoff retry metadata to help clients schedule retries.

Bug Fixes:

  • Ensure Valkey connection errors in cache operations fail gracefully without crashing handlers or tests.

Enhancements:

  • Refactor Valkey cache adapter and CachePort to fully async operations, add structured application-specific cache methods, and improve error handling around Valkey failures.
  • Update player controllers to use storage-backed profile caching, unified unknown-player guarding via a decorator, and exponential backoff instead of simple TTL flags.
  • Extend StoragePort with richer contracts for static data, player profiles, and unknown player status plus associated metrics collection.
  • Wire storage initialization into app startup and shutdown, add storage-related Prometheus counters, and mount a persistent data volume in Docker Compose.

Build:

  • Add aiosqlite as a dependency for async SQLite access.

Deployment:

  • Persist SQLite database data via a new Docker volume for app and worker services.

Documentation:

  • Document the STORAGE_PATH configuration option for persistent SQLite storage and clarify player 404 responses with retry semantics in the OpenAPI models.

Tests:

  • Adapt cache tests and fixtures to async Valkey usage, add in-memory SQLite storage to tests, and reset both Valkey and storage state around each test run.

@TeKrop TeKrop self-assigned this Feb 14, 2026
@TeKrop TeKrop added the enhancement New feature or request label Feb 14, 2026
@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Feb 14, 2026

Reviewer's Guide

Introduce a SQLite-based persistent storage layer (Port/Adapter) alongside the existing Valkey cache, refactor cache and controller flows to be fully async, and add exponential-backoff tracking for unknown players with richer 404 responses and Prometheus metrics, while wiring storage into app startup and tests.

ER diagram for SQLite persistent storage tables

erDiagram
    static_data {
        TEXT key PK
        TEXT data_type
        BLOB data_compressed
        INTEGER created_at
        INTEGER updated_at
        INTEGER schema_version
    }

    player_profiles {
        TEXT player_id PK
        TEXT blizzard_id
        BLOB html_compressed
        TEXT summary_json
        INTEGER last_updated_blizzard
        INTEGER created_at
        INTEGER updated_at
        INTEGER schema_version
    }

    player_status {
        TEXT player_id PK
        INTEGER check_count
        INTEGER last_checked_at
        INTEGER retry_after
    }

    player_profiles ||--|| player_status : shares_player_id
Loading

Class diagram for cache and storage ports and their adapters

classDiagram
    class CachePort {
        <<protocol>>
        +async get(key: str) bytes|None
        +async set(key: str, value: bytes, expire: int|None) None
        +async delete(key: str) None
        +async exists(key: str) bool
        +async get_api_cache(cache_key: str) dict|list|None
        +async update_api_cache(cache_key: str, value: dict|list, expire: int) None
        +async get_player_cache(player_id: str) dict|list|None
        +async update_player_cache(player_id: str, value: dict) None
        +async is_being_rate_limited() bool
        +async get_global_rate_limit_remaining_time() int
        +async set_global_rate_limit() None
        +async is_player_unknown(player_id: str) bool
        +async set_player_as_unknown(player_id: str) None
    }

    class ValkeyCache {
        +valkey_server
        +static get_cache_key_from_request(request)
        +static _compress_json_value(value: dict|list) bytes
        +static _decompress_json_value(value: bytes) dict|list
        +async get(key: str) bytes|None
        +async set(key: str, value: bytes, expire: int|None) None
        +async delete(key: str) None
        +async exists(key: str) bool
        +async get_api_cache(cache_key: str) dict|list|None
        +async update_api_cache(cache_key: str, value: dict|list, expire: int) None
        +async get_player_cache(player_id: str) dict|list|None
        +async update_player_cache(player_id: str, value: dict) None
        +async is_being_rate_limited() bool
        +async get_global_rate_limit_remaining_time() int
        +async set_global_rate_limit() None
        +async is_player_unknown(player_id: str) bool
        +async set_player_as_unknown(player_id: str) None
    }

    class StoragePort {
        <<protocol>>
        +async get_static_data(key: str) dict|None
        +async set_static_data(key: str, data: str, data_type: str, schema_version: int) None
        +async get_player_profile(player_id: str) dict|None
        +async set_player_profile(player_id: str, html: str, summary: dict|None, blizzard_id: str|None, last_updated_blizzard: int|None, schema_version: int) None
        +async get_player_status(player_id: str) dict|None
        +async set_player_status(player_id: str, check_count: int, retry_after: int) None
        +async delete_player_status(player_id: str) None
        +async clear_player_data() None
        +async close() None
    }

    class SQLiteStorage {
        -db_path: str
        -_initialized: bool
        -_shared_connection
        +__init__(db_path: str|None)
        +async initialize() None
        +async close() None
        +async get_static_data(key: str) dict|None
        +async set_static_data(key: str, data: str, data_type: str, schema_version: int) None
        +async get_player_profile(player_id: str) dict|None
        +async set_player_profile(player_id: str, html: str, summary: dict|None, blizzard_id: str|None, last_updated_blizzard: int|None, schema_version: int) None
        +async get_player_status(player_id: str) dict|None
        +async set_player_status(player_id: str, check_count: int, retry_after: int) None
        +async delete_player_status(player_id: str) None
        +async get_stats() dict
        +async clear_player_data() None
    }

    class AbstractController {
        <<abstract>>
        +cache_manager: CachePort
        +storage: StoragePort
        +cache_key: str
        +response
        +timeout: int
        +async update_static_cache(data: dict|list, storage_key: str, data_type: str) None
        -static _track_storage_error(error_type: str) None
        +async process_request(**kwargs) dict|list
    }

    class BasePlayerController {
        +async get_player_profile_cache(player_id: str) dict[str, str|dict]|None
        +async update_player_profile_cache(player_id: str, player_summary: dict, html: str) None
        +_calculate_retry_after(check_count: int) int
        +async check_unknown_player(player_id: str) None
        +async mark_player_unknown_on_404(player_id: str, exception) None
        +async process_request(**kwargs) dict
    }

    class GetPlayerCareerController {
        +async process_request(**kwargs) dict
        +async _fetch_player_html(client, player_id: str) str
    }

    class BlizzardClientPort {
        <<protocol>>
        +async get(url: str, headers, timeout) Response
        +async aclose() None
    }

    CachePort <|.. ValkeyCache
    StoragePort <|.. SQLiteStorage

    AbstractController ..> CachePort
    AbstractController ..> StoragePort

    BasePlayerController --|> AbstractController
    GetPlayerCareerController --|> BasePlayerController

    GetPlayerCareerController ..> BlizzardClientPort
    BasePlayerController ..> StoragePort
    AbstractController ..> ValkeyCache
    AbstractController ..> SQLiteStorage
Loading

File-Level Changes

Change Details Files
Refactor Valkey cache adapter and cache protocol to be fully async with centralized error handling and richer, app-specific cache operations.
  • Switch Valkey client to valkey.asyncio and remove asyncio.to_thread usage so all cache I/O is natively async.
  • Introduce a handle_valkey_error decorator to uniformly catch ValkeyError and return safe fallbacks while logging warnings.
  • Extend CachePort with async API-cache, player-cache, rate-limit, and unknown-player methods and update ValkeyCache and tests to conform.
  • Change compression from zlib to zstd for cache payloads to align with new storage compression.
app/adapters/cache/valkey_cache.py
app/domain/ports/cache.py
tests/test_cache_manager.py
tests/conftest.py
Add a SQLite storage adapter implementing StoragePort with zstd compression and schema for static data, player profiles, and unknown-player status; integrate it into controllers and app lifecycle.
  • Implement SQLiteStorage with async aiosqlite connections, schema initialization from schema.sql, zstd compression helpers, and CRUD methods for static data, player profiles, and player status plus metrics helpers and test-only clear_player_data.
  • Define StoragePort protocol with methods for static data, player profiles, unknown-player status management, and close().
  • Wire SQLiteStorage into AbstractController as the concrete StoragePort, including a dual-write update_static_cache helper that writes to both Valkey and SQLite and emits Prometheus error metrics on failure.
  • Initialize and close SQLiteStorage in FastAPI lifespan, and expose STORAGE_PATH configuration and Docker volume for persisting the DB.
app/adapters/storage/__init__.py
app/adapters/storage/sqlite_storage.py
app/adapters/storage/schema.sql
app/domain/ports/storage.py
app/controllers.py
app/main.py
app/config.py
docker-compose.yml
README.md
app/monitoring/metrics.py
tests/conftest.py
.env.dist
uv.lock
Migrate player cache and unknown-player handling from Valkey to SQLite-based storage with exponential backoff, and expose richer 404 responses via a decorator-based guard.
  • Introduce with_unknown_player_guard decorator to wrap controller process_request methods, checking unknown-player status via storage before execution and marking players as unknown on 404.
  • Replace Valkey-based player cache in BasePlayerController with storage-backed get_player_profile_cache and update_player_profile_cache that read/write HTML and full summary to SQLite.
  • Implement exponential-backoff unknown-player logic in BasePlayerController using StoragePort get/set_player_status and a configurable base/multiplier/max; return detailed 404s including retry_after, next_check_at, and check_count.
  • Update GetPlayerCareerController, GetPlayerCareerStatsController, and GetPlayerStatsSummaryController to use the new guard, storage-backed player profile cache, and to await async cache writes; adjust error handling for parsing errors that imply unknown players.
app/players/controllers/base_player_controller.py
app/players/controllers/get_player_career_controller.py
app/players/controllers/get_player_career_stats_controller.py
app/players/controllers/get_player_stats_summary_controller.py
app/api/routers/players.py
app/players/models.py
app/players/controllers/search_players_controller.py
Add exponential-backoff-aware player-not-found API surface and Prometheus metrics for storage, and adjust Blizzard client to use async cache-based rate limiting.
  • Define PlayerNotFoundError model carrying error, retry_after, next_check_at, and check_count, and update the players router 404 response metadata to describe the backoff behaviour.
  • Add Prometheus counters/gauges for storage entries and write errors; hook storage write errors into AbstractController.update_static_cache.
  • Refactor Blizzard client to use async cache-based rate-limit checks and to set the global rate-limit flag via async cache APIs.
  • Adjust various controllers (heroes, gamemodes, maps, roles, search) to use async cache writes and, where applicable, the new update_static_cache dual-write helper or storage key builders.
app/players/models.py
app/api/routers/players.py
app/monitoring/metrics.py
app/adapters/blizzard/client.py
app/controllers.py
app/heroes/controllers/get_hero_stats_summary_controller.py
app/heroes/controllers/get_hero_controller.py
app/gamemodes/controllers/list_gamemodes_controller.py
app/maps/controllers/list_maps_controller.py
app/roles/controllers/list_roles_controller.py
app/players/controllers/search_players_controller.py
app/adapters/blizzard/parsers/player_profile.py
app/adapters/blizzard/parsers/player_search.py
app/adapters/blizzard/parsers/player_summary.py
pyproject.toml

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
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 4 issues, and left some high level feedback:

  • The global AbstractController.storage instance is never initialized in the main lifespan (you create and initialize a separate storage local variable), so any code using AbstractController.storage in the running app will hit an uninitialized DB schema; consider wiring the initialized storage from lifespan into AbstractController.storage (or a DI mechanism) instead of instantiating a separate adapter.
  • The with_unknown_player_guard decorator relies on player_id being present in kwargs and raises ValueError otherwise; given this now decorates several process_request implementations, it would be safer to either accept *args, **kwargs and extract player_id from the positional arguments when present, or enforce/validate this contract at the controller base level to avoid subtle runtime failures if signatures change.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The global `AbstractController.storage` instance is never initialized in the main lifespan (you create and initialize a separate `storage` local variable), so any code using `AbstractController.storage` in the running app will hit an uninitialized DB schema; consider wiring the initialized storage from `lifespan` into `AbstractController.storage` (or a DI mechanism) instead of instantiating a separate adapter.
- The `with_unknown_player_guard` decorator relies on `player_id` being present in `kwargs` and raises `ValueError` otherwise; given this now decorates several `process_request` implementations, it would be safer to either accept `*args, **kwargs` and extract `player_id` from the positional arguments when present, or enforce/validate this contract at the controller base level to avoid subtle runtime failures if signatures change.

## Individual Comments

### Comment 1
<location> `app/players/controllers/base_player_controller.py:14` </location>
<code_context>
 from app.overfast_logger import logger


+def with_unknown_player_guard(func):
+    """
+    Decorator to guard player endpoints against unknown players.
</code_context>

<issue_to_address>
**issue (bug_risk):** Missing `wraps` import will cause a NameError at runtime.

This module uses `@wraps(func)` but never imports it, so the decorator will raise `NameError` when defined. Add `from functools import wraps` at the top of this module (as in `valkey_cache.py`).
</issue_to_address>

### Comment 2
<location> `app/controllers.py:31-32` </location>
<code_context>
+    # Cache manager for Valkey operations (Protocol type for dependency inversion)
+    cache_manager: CachePort = CacheManager()
+
+    # Storage adapter for persistent data (Protocol type for dependency inversion)
+    storage: StoragePort = SQLiteStorage()

     def __init__(self, request: Request, response: Response):
</code_context>

<issue_to_address>
**issue (bug_risk):** Controller `storage` instance is never initialized with the schema, which may lead to missing-table errors.

The lifespan handler initializes its own `SQLiteStorage` and calls `initialize()`, but `AbstractController.storage` is a separate instance created at import time and never initialized. All controller methods (`get_player_profile`, `set_static_data`, etc.) use this uninitialized instance, so required tables may be missing. Either share a single `SQLiteStorage` instance between lifespan and controllers, or ensure `await AbstractController.storage.initialize()` is called once at startup and reuse that instance in lifespan instead of creating a new one.
</issue_to_address>

### Comment 3
<location> `app/adapters/cache/valkey_cache.py:39-48` </location>
<code_context>
     from fastapi import Request


+def handle_valkey_error(
+    default_return: Any = None,
+) -> Callable[[Callable], Callable]:
+    """
+    Decorator to handle Valkey connection errors gracefully.
+
+    Args:
+        default_return: Value to return when ValkeyError is caught (default: None)
+
+    Returns:
+        Decorated async function that catches ValkeyError and returns default_return
+    """
+
+    def decorator(func: Callable) -> Callable:
+        @wraps(func)
+        async def wrapper(*args, **kwargs):
+            try:
+                return await func(*args, **kwargs)
+            except valkey.ValkeyError as err:
+                func_name = getattr(func, "__name__", "unknown")
+                logger.warning(f"Valkey server error in {func_name}: {err}")
</code_context>

<issue_to_address>
**issue (bug_risk):** Catching `valkey.ValkeyError` on the asyncio client may not match the actual exception type being raised.

The previous code caught `valkey.exceptions.ValkeyError`, but the decorator now catches `valkey.ValkeyError` on the asyncio client. Depending on the actual Valkey/redis-py API, this may not match the real base exception, so these errors might bypass the handler. Consider either catching `valkey.exceptions.ValkeyError` from the root package, or another verified base exception actually raised by the asyncio client, so transient Valkey issues are still handled here.
</issue_to_address>

### Comment 4
<location> `tests/conftest.py:21-28` </location>
<code_context>
-@pytest.fixture(scope="session")
-def valkey_server():
-    return fakeredis.FakeValkey(protocol=3)  # ty: ignore[possibly-missing-attribute]
+@pytest_asyncio.fixture(scope="session")
+async def valkey_server():
+    """Provide async FakeValkey server for tests"""
+    return fakeredis.FakeAsyncRedis(protocol=3)
+
+
+@pytest_asyncio.fixture(scope="session")
+async def storage_db() -> AsyncIterator[SQLiteStorage]:
+    """Provide an in-memory SQLite storage for tests"""

</code_context>

<issue_to_address>
**suggestion (testing):** Add dedicated tests for the SQLiteStorage adapter to validate compression, schema, and player/unknown status flows

Right now the fixture only provides a `SQLiteStorage` instance; the adapter itself isn’t directly exercised. Given its central role (profiles, unknown tracking, static data), it would be valuable to add a `tests/test_sqlite_storage.py` that covers:

- `set_static_data` / `get_static_data`: round-trip, `data_type`, `schema_version`, correct JSON compression/decompression
- `set_player_profile` / `get_player_profile`: persistence and reconstruction of `html` and `summary`, including when `summary_json` is `None`
- `set_player_status` / `get_player_status` / `delete_player_status`: exponential-backoff metadata is stored as expected
- `clear_player_data`: tables are actually cleared

These tests will also implicitly verify `:memory:` connection handling and schema initialization.

Suggested implementation:

```python
from __future__ import annotations

from typing import TYPE_CHECKING

import fakeredis
import pytest_asyncio
from fastapi.testclient import TestClient

from app.adapters.storage import SQLiteStorage
from app.main import app

if TYPE_CHECKING:
    from collections.abc import AsyncIterator


@pytest_asyncio.fixture(scope="session")
async def valkey_server():
    """Provide async FakeValkey server for tests."""
    # Use async fake redis/valkey instance for tests that depend on Valkey
    return fakeredis.FakeAsyncRedis(protocol=3)


@pytest_asyncio.fixture(scope="session")
async def storage_db() -> AsyncIterator[SQLiteStorage]:
    """Provide an in-memory SQLite storage for tests.

    This fixture ensures that tests share a single in-memory SQLiteStorage
    instance for the session, implicitly exercising :memory: connection
    handling and schema initialization.
    """
    storage = SQLiteStorage(":memory:")
    try:
        yield storage
    finally:
        await storage.close()


@pytest_asyncio.fixture
async def client() -> AsyncIterator[TestClient]:
    """Provide a TestClient for FastAPI app.

    Wrapped in an async fixture so it composes cleanly with other async
    fixtures (e.g. valkey_server, storage_db) if the app wiring depends on them.
    """
    with TestClient(app) as test_client:
        yield test_client

```

To fully implement your suggestion about dedicated SQLiteStorage adapter tests, add a new file `tests/test_sqlite_storage.py` that uses the `storage_db` fixture and includes tests covering at least:

1. `set_static_data` / `get_static_data`:
   - Store static data with a given `data_type` and `schema_version`.
   - Verify round-trip correctness, including JSON compression/decompression and versioning.

2. `set_player_profile` / `get_player_profile`:
   - Store a profile with both `html` and `summary_json`.
   - Store a profile with `summary_json=None`.
   - Assert retrieved profiles reconstruct `html` and `summary` as expected in both cases.

3. `set_player_status` / `get_player_status` / `delete_player_status`:
   - Store a status with exponential-backoff metadata (e.g. `retry_count`, `next_retry_at` or equivalent in your implementation).
   - Verify the metadata is persisted and retrieved correctly.
   - Verify `delete_player_status` removes the status.

4. `clear_player_data`:
   - Seed profiles/status/static data for one or more players.
   - Call `clear_player_data` and assert all related rows are removed from the underlying tables.

Tailor the tests to the exact method signatures and field names exposed by your `SQLiteStorage` implementation, and ensure they run against the `:memory:` DB provided by the `storage_db` fixture so that schema initialization and lifecycle are implicitly tested.
</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.

@sonarqubecloud
Copy link

@TeKrop TeKrop merged commit cec3172 into main Feb 16, 2026
5 checks passed
@TeKrop TeKrop deleted the feature/ddd-phase-3-persistent-storage branch February 16, 2026 20:35
TeKrop added a commit that referenced this pull request Feb 16, 2026
…/Adapter pattern (#361)

* feat: first part of phase 3

* feat: progress on phase 3

* feat: made valkey cache async

* feat: finished phase 3

* fix: fixes after self review

* fix: fixed tests and added sqlite ones

* fix: added metrics for storage

* fix: fixed nginx lua issue

* fix: fixed dashboards

* fix: updated down command to not remove volume by default

* fix: fixed again commands to not remove volumes

* fix: fixed code deduplication

* fix: fixed issue

* fix: adjusted SQLite usage

* fix: improved player search workflow

* fix: several bugfixes and introduced blizzard_id as main identifier

* fix: last fixes before E2E tests

* feat: added additional usage of blizzard ID & battletag mapping

* fix: added reverse player data enrichment

* fix: finished grafana boards

* fix: fixed code deduplication

* fix: added remaining sqlite metrics
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.

1 participant

Comments