Skip to main content
See Config Store for the concept and Config Store (Admin) for deploying one. This page is for contributors working on Proxy Hopper’s own codebase — implementing a new ConfigStore, or touching ProxyRepository’s storage layer.

The interface

python_modules/proxy-hopper/src/proxy_hopper/config_store/base.py, sibling to backend/base.py:
entity_type is "target", "provider", or "pool" — the three kinds ProxyRepository handles. static/mutable mirror the same flags already carried on TargetConfig/ProxyProvider/IpPool; storing them as first-class ConfigEntity fields (not just inside data) lets ProxyRepository’s guard checks (if existing.static: raise ...) read them without deserializing the full stored object.
updated_at is naive UTC in every implementation, by convention — not timezone-aware. SQLite’s DATETIME column type doesn’t reliably round-trip tz-aware Python datetimes, so every implementation normalizes to naive UTC rather than having that inconsistency surface only on one dialect.

Implementations

MemoryConfigStore (config_store/memory.py, core package) — a dict-of-dicts, keyed by (entity_type, name). Test-only: SqlConfigStore with SQLite strictly dominates it for real deployments (same zero-external-dependency property, but actually durable across restarts), so this is never exposed as a CLI/chart option — it’s what configStoreUrl defaults to when unset. Deep-copies data on both set() and get()/list(), so callers can’t mutate the store’s internal state through a returned ConfigEntity, or corrupt it by later mutating a dict they’d previously passed to set() — a real store gets this isolation for free from its JSON serialization round-trip; this in-memory double has to do it explicitly to behave the same way. SqlConfigStore (proxy_hopper_sql/config_store.py, separate proxy-hopper-sql package) — SQLite or Postgres via a SQLAlchemy async engine. Dialect is disambiguated entirely by the connection URL’s scheme (sqlite+aiosqlite:// vs postgresql+asyncpg://) — no separate “store type” field, mirroring how redis_url alone already selects the Redis backend elsewhere in this codebase. The schema (proxy_hopper_sql/schema.py) is SQLAlchemy Core, not raw SQL strings and not the ORM — one config_entities table, composite primary key (entity_type, name).
proxy-hopper-sql is optional, like proxy-hopper-redis — the core proxy-hopper package must never import it at module scope. Every reference to it (in cli.py’s migrate command graft, and in wiring.py’s build_repo()) is a lazy import inside a function, guarded by try/except ImportError, exactly like the existing Redis backend wiring.

Migrations

proxy_hopper_sql/alembic/ holds the Alembic scaffold; proxy_hopper_sql/migrations.py wraps alembic.command programmatically (upgrade/downgrade/heads) — the migrate CLI command and the Helm chart’s migration Job/initContainer both call this, never the alembic binary directly. On Postgres, upgrade() wraps the actual migration in pg_advisory_lock/pg_advisory_unlock, so concurrent migrate invocations against the same database (e.g. two pods racing the same Helm migration Job) serialize instead of racing each other into a duplicate-table error on a fresh database. Not needed for SQLite — a single-writer local file already serializes.

Adding a new dialect

SQLAlchemy already supports far more dialects than SQLite/Postgres — adding one (MySQL, for instance) is mostly a matter of:
  1. Add the async driver to proxy-hopper-sql’s dependencies.
  2. Confirm SqlConfigStore’s engine construction (create_async_engine(url)) needs no dialect-specific branching — it shouldn’t, since the query layer is SQLAlchemy Core throughout.
  3. Add an entry to the contract suite’s _STORE_FACTORIES (see below) and, if the CI service container needs new infrastructure, extend ci.yml’s helm-lint/test jobs accordingly.
  4. Extend the Helm chart’s configStore.dialect enum and its migration-mechanism branch — Postgres-shaped dialects likely fit the existing Job pattern; anything requiring a local file follows the SQLite initContainer+PVC pattern instead.

Testing

Every ConfigStore implementation is exercised against the same test bodies via a shared contract suite, parametrized over a _STORE_FACTORIES dict — the same pattern the Backend contract tests use for memory/redis:
  • python_modules/proxy-hopper/tests/test_config_store_contract.py — the interface contract itself, memory only. Lives in the core package’s own test suite since it has no cross-package dependency.
  • python_modules/tests/test_config_store_contract.py — the same contract, plus sqlite and postgres (gated on a POSTGRES_URL environment variable, mirroring the existing REDIS_URL/fakeredis pattern). Lives in this cross-package project, not in proxy-hopper/tests/, specifically so that exercising SqlConfigStore doesn’t make the core package’s own test suite depend on proxy-hopper-sql.
  • python_modules/tests/test_repository_config_store_contract.pyProxyRepository CRUD and the provider→pool→target cascade, proven across the same memory/sqlite/postgres set, for the same reason.
  • python_modules/proxy-hopper-sql/tests/ — package-specific tests: schema creation, upgrade/downgrade/upgrade round-trips, the CI multi-head guard, and (Postgres-gated) the pg_advisory_lock concurrency test.
Adding sqlite or postgres support to some new piece of test coverage almost never means writing a new test file — it means adding one factory entry to whichever _STORE_FACTORIES dict already fits, and every existing test in that file runs against the new implementation automatically.