Skip to content

PostgreSQL

PostgreSQL holds SpeculaPro’s permanent record: workspaces and users, customers and accounts, transactions, rules and their versions, alerts, cases and the audit log. It is the system you must protect and back up most carefully.

  • PostgreSQL 18 or later. The schema uses uuidv7(), which first appeared in Postgres 18. On older versions the first migration fails.
  • The citext extension (case-insensitive text, used for email addresses). It ships with PostgreSQL and is created by the migrations automatically.
  • In production, Pigsty is the recommended way to run Postgres. It packages high availability (Patroni), connection pooling (PgBouncer), backups (pgBackRest) and monitoring (Prometheus and Grafana) into one tested distribution. Any well-run PostgreSQL 18 works, including managed services.

SpeculaPro uses three database users, each with only the rights it needs. This is a safety measure: the part of SpeculaPro that faces the internet cannot read across institutions or change the schema, even if it is compromised.

Role Used by Rights
speculapro_owner speculapro migrate only Owns the tables; creates and changes the schema
speculapro_app The api and ingest roles (SP_DB_URL) Reads and writes data. Row-level security applies: every query only sees the current workspace’s rows
speculapro_system The decision, persist, stream, connector, analytics and worker roles for their batch work, and every process for the installation’s own tables (SP_DB_SYSTEM_URL) Reads and writes data across all workspaces in batches. Has BYPASSRLS. It is also the only role that can read the installation’s own tables

A few tables hold the installation’s own state, not a workspace’s: the licence, its daily usage figures and the key that signs usage reports (license_installation, licenses, license_usage_daily), the backtest runners’ heartbeats (backtest_runners) and the notification channel record (notify_channel_config). They have no workspace column for row-level security to filter on, so from migration 00056 they are closed to speculapro_app altogether, twice over: row-level security is switched on with no policy, so the role is refused every row, and grants.sql revokes its privileges on the tables. Only speculapro_system and the owner read them.

The same migration and grants.sql also:

  • switch row-level security on, with no policy, on every monthly partition (and on each new one as it is created). A query that names a partition directly, such as transactions_p202610, is therefore refused for speculapro_app; queries through the parent table are filtered by the parent’s policy as before;
  • leave speculapro_app able to read and add to the shared, signed catalogues (rule_packs, rule_pack_bundles, jurisdiction_bundles) but not to change or delete them, able to read but not write the migration tool’s version table, and unable to rewrite the per-number count of WhatsApp codes.

Row-level security (RLS) is a PostgreSQL feature that filters rows inside the database itself. SpeculaPro tells Postgres which workspace each request belongs to, and Postgres then refuses to return or change any other workspace’s rows, even if the application has a bug. See Tenant isolation.

Two files in deploy/sql/ set up the database: roles.sql (before migrations) and grants.sql (after migrations). Do the steps below once, in this order.

  1. Set real passwords. Copy deploy/sql/roles.sql and replace each change-me with a long random password (openssl rand -base64 32 is one way to make one). Store the passwords in your secret manager.

  2. Create the database, as a PostgreSQL superuser:

    Terminal window
    psql -c "CREATE DATABASE speculapro"
  3. Create the roles, as a superuser, connected to the new database:

    Terminal window
    psql -d speculapro -f roles.sql

    This creates the three roles, makes speculapro_owner the owner of the database and of the public schema, removes the default rights every PostgreSQL user has to connect and create objects, and gives the app and system roles access to every table the owner creates later.

  4. Run the migrations as the owner:

    Terminal window
    SP_DB_URL='postgres://speculapro_owner:<password>@pg.internal:5432/speculapro?sslmode=verify-full' \
    speculapro migrate up
  5. Grant the sign-in functions, as a superuser or as speculapro_owner:

    Terminal window
    psql -d speculapro -f deploy/sql/grants.sql

    Before someone signs in, SpeculaPro doesn’t yet know which workspace they belong to, so it can’t rely on row-level security to find their account. A few narrow database functions do exactly those lookups (find a user, a session, an API key, a workspace for single sign-on, a SCIM token) and nothing else. grants.sql lets the app and system roles call them. The functions are created by the migrations, which is why this step comes last.

Declare the three users in your Pigsty inventory’s pg_users (with bypassrls for speculapro_system) and the database in pg_databases, owned by speculapro_owner, instead of running roles.sql by hand. Give speculapro_app pgbouncer: true so it can connect through the pooler; leave the other two out of it. Then run the ALTER SCHEMA, REVOKE, GRANT and default-privilege statements from roles.sql in the speculapro database, run the migrations as speculapro_owner, and finish with grants.sql. See the Pigsty documentation for the exact inventory syntax.

SpeculaPro connects to PostgreSQL with up to three logins, and they don’t all need the same thing:

Connection Holds session state? Where to point it
System role (SP_DB_SYSTEM_URL): the background roles Yes. The worker’s leader lock, the licence check, the screening runner and the analytics sync each hold a session-level advisory lock on one connection The primary directly (Pigsty’s default service, port 5436), or a pool in session mode. A transaction-mode pool breaks these locks
Owner role: SP_DB_URL set to speculapro_owner for speculapro migrate only Yes. A migration run holds a session lock The same: direct, or session mode
App role (SP_DB_URL): the api and ingest roles No. It sets the workspace for each transaction and uses only transaction-scoped locks A transaction-mode pool (Pigsty’s primary service, port 5433, which goes through PgBouncer), with the conditions below. Direct works too

For the app role through a transaction-mode PgBouncer, one of these must hold, because the database driver runs queries as prepared statements:

  • PgBouncer 1.21 or newer with max_prepared_statements above 0 (recent releases set it to 200 by default), or
  • any PgBouncer, with default_query_exec_mode=cache_describe added to the connection string, so the driver keeps no named statements on the server.

You may not need a pooler at all: each SpeculaPro process keeps its own pool and reuses connections (SP_DB_MAX_CONNS, default 20 per pool per process), so connecting directly never means a connection per request. PgBouncer starts to matter when the number of API replicas times that figure approaches the server’s max_connections.

Migrations are built into the binary. Point SP_DB_URL at the owner role for these commands only:

Terminal window
export SP_DB_URL='postgres://speculapro_owner:<password>@pg.internal:5432/speculapro?sslmode=verify-full'
speculapro migrate status # what is applied, what is pending
speculapro migrate up # apply everything pending

status prints one line per migration, marked applied or pending. up is safe to run repeatedly; it only applies what is pending. In development, make migrate and make migrate-status do the same with the local settings. When SP_DB_OWNER_URL is set they connect with it instead of SP_DB_URL, so migrations run as the schema owner even where SP_DB_URL names speculapro_app; the connection string isn’t printed.

The largest tables are split by month into partitions, so old months can be archived or dropped cheaply and queries over recent data stay fast:

Table Split by
transactions When the transaction happened
decisions When the decision was made
rule_hits When the rule fired
audit_log When the event was recorded
customer_events When the account event happened

The migrations create partitions from one month back to three months ahead of the day you run them, plus a default partition that catches anything outside that range.

Each SpeculaPro process opens at most SP_DB_MAX_CONNS connections (default 20) per pool. Every process with SP_DB_SYSTEM_URL set has a second pool for the system role, so it can open up to twice that (an api-only process uses its second pool very little: the licence state is read at most every 30 seconds). A worker with SP_DB_REPLICA_URL set opens a third pool, on the replica. Add up all processes and keep the total below the server’s max_connections, leaving room for administrators and backups. Because each process pools its own connections, you often don’t need a pooler in front; if you add PgBouncer, see which connection can go through it.

A backtest reads up to a year of one workspace’s transactions in time order: the period, up to 180 days, plus up to 180 days of warm-up before it. These are long sequential reads. On a small installation the primary handles them easily. On a large one, send them to a streaming replica so they don’t compete with live monitoring:

  1. Set up a streaming (physical) replica of the SpeculaPro database, as you would for failover. Pigsty and managed services offer this.

  2. On every worker process that runs backtests (SP_BACKTEST_WORKERS above 0), set SP_DB_REPLICA_URL to the replica, connecting as speculapro_system:

    Terminal window
    SP_DB_REPLICA_URL='postgres://speculapro_system:<password>@pg-replica.internal:5432/speculapro?sslmode=verify-full'
  3. Restart the workers. At start-up each one checks that this user can bypass row-level security, and refuses to start otherwise with SP_DB_REPLICA_URL: database role cannot bypass row-level security (use the speculapro_system role on the replica). Backtest queries filter by workspace themselves, as the other system-role queries do.

Only the history reads go to the replica. The queue, progress and results are written to the primary. A replica that lags behind the primary simply means the most recent data isn’t in the run: each result records data up to, the latest time a transaction it read was received.

  • Use continuous archiving with point-in-time recovery (pgBackRest, which Pigsty includes, or your managed service’s equivalent), not only nightly dumps. Monitoring records are evidence; losing a day of them is a compliance problem.
  • Keep the encryption keys out of the database backup and store them separately. A backup is only useful together with the keys.
  • Test a restore regularly, into an isolated environment.
  • Check how long your regulator requires you to keep records, and set retention accordingly.