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.
Requirements
Section titled “Requirements”- PostgreSQL 18 or later. The schema uses
uuidv7(), which first appeared in Postgres 18. On older versions the first migration fails. - The
citextextension (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.
Database roles
Section titled “Database roles”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 |
Tables that belong to the installation
Section titled “Tables that belong to the installation”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 forspeculapro_app; queries through the parent table are filtered by the parent’s policy as before; - leave
speculapro_appable 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.
What is row-level security?
Section titled “What is row-level security?”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.
How to prepare the database
Section titled “How to prepare the database”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.
-
Set real passwords. Copy
deploy/sql/roles.sqland replace eachchange-mewith a long random password (openssl rand -base64 32is one way to make one). Store the passwords in your secret manager. -
Create the database, as a PostgreSQL superuser:
Terminal window psql -c "CREATE DATABASE speculapro" -
Create the roles, as a superuser, connected to the new database:
Terminal window psql -d speculapro -f roles.sqlThis creates the three roles, makes
speculapro_ownerthe owner of the database and of thepublicschema, 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. -
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 -
Grant the sign-in functions, as a superuser or as
speculapro_owner:Terminal window psql -d speculapro -f deploy/sql/grants.sqlBefore 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.sqllets the app and system roles call them. The functions are created by the migrations, which is why this step comes last.
With Pigsty
Section titled “With Pigsty”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.
Direct or through PgBouncer
Section titled “Direct or through PgBouncer”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_statementsabove 0 (recent releases set it to 200 by default), or - any PgBouncer, with
default_query_exec_mode=cache_describeadded 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.
How to run migrations
Section titled “How to run migrations”Migrations are built into the binary. Point SP_DB_URL at the owner role for these commands only:
export SP_DB_URL='postgres://speculapro_owner:<password>@pg.internal:5432/speculapro?sslmode=verify-full'
speculapro migrate status # what is applied, what is pendingspeculapro migrate up # apply everything pendingstatus 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.
Partitions
Section titled “Partitions”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.
Connections
Section titled “Connections”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 read replica for backtests
Section titled “A read replica for backtests”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:
-
Set up a streaming (physical) replica of the SpeculaPro database, as you would for failover. Pigsty and managed services offer this.
-
On every
workerprocess that runs backtests (SP_BACKTEST_WORKERSabove 0), setSP_DB_REPLICA_URLto the replica, connecting asspeculapro_system:Terminal window SP_DB_REPLICA_URL='postgres://speculapro_system:<password>@pg-replica.internal:5432/speculapro?sslmode=verify-full' -
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.
Backups
Section titled “Backups”- 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.