ADR-0002: PostgreSQL as the only datastore
Edit this pageStatus: Accepted Date: 2026-01-15
Context
Loomscope needs to do several things that commonly pull in separate infrastructure:
- Relational inventory — hosts, services, ports, certificates, vulnerabilities, and the relationships between them.
- Background jobs — scheduled scans, CVE matching, snapshot capture, cloud synchronisation, alert delivery.
- Realtime fan-out — pushing discovery progress to open browsers.
- Caching — mostly of CVE feed data.
- Multi-tenant isolation — several organisations in one deployment.
The reflex architecture is PostgreSQL for data, Redis for cache and pub/sub, and a broker such as RabbitMQ or NATS for jobs. Each is defensible in isolation.
The constraint that overrides this is who installs Loomscope. It is deployed on-premises, frequently by a small team, sometimes into an air-gapped environment where every component must be mirrored, and occasionally by someone whose actual job is not operations. Every stateful component is something they have to run, monitor, back up, upgrade and restore — and something that can fail in a way they have to diagnose.
Decision
PostgreSQL 17 is the only datastore. There is no Redis, no message broker and no separate search index.
- Jobs are rows, claimed with
SELECT … FOR UPDATE SKIP LOCKED. River provides the Go-side bindings. - Realtime is
LISTEN/NOTIFYtranslated to SSE at the HTTP edge. - Caching is tables, plus the CVE mirror on disk.
- Search is
pg_trgmGIN indexes. - Tenant isolation is row-level security with
FORCEenabled, scoped by aSET LOCAL app.org_idinside each transaction.
That last point deserves emphasis. Isolation is enforced by the database, not
by remembering to write WHERE organization_id = …. A query that forgets its
tenant filter returns nothing rather than another tenant's rows. This is only
possible because there is one store — a cache or search index alongside would
be a second copy of the data with no equivalent guarantee.
Alternatives considered
PostgreSQL + Redis. Rejected. Redis would serve caching and pub/sub, both of which PostgreSQL already handles adequately at this scale. The cost is a second stateful service to operate, secure and mirror for air-gap, and a second place tenant data can leak from.
PostgreSQL + a broker. Rejected. Brokers earn their keep with high job throughput, complex routing, or work spanning services that must not share a database. Loomscope has one writer, modest volume, and jobs whose state belongs next to the data they mutate — putting a scan job in a broker while its results go to PostgreSQL means distributed-transaction problems for no gain.
A dedicated search engine. Rejected. pg_trgm handles hostname and
service search at the target scale. Revisit if full-text search across
snapshot payloads becomes a requirement.
Consequences
Accepted costs.
- PostgreSQL becomes the scaling bottleneck. At very large deployments the answer is a bigger database and read replicas, not a different topology.
LISTEN/NOTIFYpayloads are capped at 8000 bytes, so notifications carry identifiers and clients re-read. This is more round trips than a broker would need.- Job processing throughput is bounded by PostgreSQL. Fine for scans measured in seconds; it would not suit millisecond-latency work.
- Long-running listeners hold connections, so pools are separated by workload (UI, daemon, session) to prevent starvation.
Gains.
docker compose upwith two services is the whole runtime.- One thing to back up. One thing to restore. One thing to mirror for air-gap. One thing to upgrade.
- Row-level security as a real tenant boundary rather than a convention.
- Jobs, data and notifications participate in the same transaction: a scan result and its job state commit together or not at all.
Boundary. This decision is about the default deployment. If a single deployment ever needs to exceed what one PostgreSQL instance can serve, the first move is read replicas and partitioning, and only then a reconsideration — documented in a superseding record.