Database Scalability
Sizing guidance for PostgreSQL and MinIO as captured data grows. Figures come from an audit of the live schema, repositories, and infrastructure config.
Note
Growth figures are estimates for typical mixed traffic. Packet density varies widely between captures, so treat them as planning shape rather than precise numbers.
Table Growth
Every packet is stored as a row, with the payload capped at 64 bytes
(PacketEntity.PAYLOAD_BYTE_LIMIT, i.e. at most 128 hex characters).
~1.5–2M ``packets`` rows per GB of PCAP.
A row is roughly 250–400 B, plus four indexes, so PostgreSQL grows to about 1–1.5× the PCAP size — almost entirely
packets.conversationsholds hundreds to low thousands of rows per GB, but carries 14 indexes (5 of them GIN), so it is write-amplified despite the small row count.ip_geo_cacheandhost_classificationsare bounded by the number of distinct IPs and hosts. Neither is a scaling concern.
The practical consequence: packets is the only table whose growth matters, and it is
what any retention or capacity policy should target.
Indexing
No critical index is missing for the current per-file query patterns. Two observations:
The
payloadContainsfilter runsLIKE '%hex%'againstpackets.payload, which is unindexable and results in a sequential scan. It stays file-scoped, so this is acceptable at present.Aggregation and anomaly queries filter by
file_idandGROUP BYsource or destination IP, producing full per-file scans. Also fine at current scale.
Partitioning
packets is LIST-partitioned by ``file_id``, one partition per file
(issue #394). Because retention
deletes whole files, the partition boundary matches the deletion boundary exactly, so
reclaiming a file’s frames is a single DROP TABLE — an O(1) unlink with no row visits,
no index maintenance and nothing left for autovacuum. Previously this was a cascading
delete of millions of rows per file on every cleanup cycle.
Per-file queries benefit too: the planner prunes to the single relevant partition instead of scanning an index spanning every file.
Two operational consequences:
A partition is created at ingest, before the first packet insert. There is deliberately no default partition, so a missing one fails loudly rather than silently landing rows somewhere that cannot be dropped instantly.
Partition count tracks the number of live files. Retention bounds it in normal operation; planning cost grows with very large partition counts, so keep retention enabled.
TimescaleDB is a weaker fit here. It optimises time-range operations, whereas TracePcap deletes per file, not per time window.
Splitting packet retention from summary retention
packets is the bulky table; conversations and analysis_results are compact
summaries that stay useful long after the raw frames are worth storing. Setting
PACKET_RETENTION_HOURS below FILE_RETENTION_HOURS drops the packet partitions early
while the file keeps its summaries, reclaiming most of the storage at the cost of
packet-level drill-down.
Pruned files are stamped with files.packets_pruned_at, which distinguishes “packets
pruned” from “this capture had no packets”. Conversation lists, the network graph and all
per-file summaries are unaffected; packet step-through returns an empty list.
Connection Pooling
HikariCP is sized via DB_POOL_MAX_SIZE (default 20) with DB_POOL_MIN_IDLE
(default 5), set in docker-compose.yml and inherited by all profiles. This stays well
under the PostgreSQL max_connections default of 100.
Note
An earlier deployment bug left the pool capped at 10 because the dev profile did
not override the datasource and the prod profile’s sizing was never active. Fixed
in issue #393; the pool is now
configured in the base compose file so it applies regardless of profile.
Capacity Planning
Storage consumed is roughly 2.5× the PCAP volume ingested. Concretely, for every 100 GB of captures you put in:
What it is |
Disk needed |
|---|---|
The capture files themselves |
100 GB |
The database they build |
100 – 150 GB |
Total |
~250 GB |
Use that multiplier in both directions — to size a disk for a given workload, or to work out the retention window a given disk can sustain.
Checking where you stand
scripts/capacity.sh reports current usage and projects forward from the
observed ingest rate:
bash scripts/capacity.sh
It is read-only and safe on a live deployment. --quiet prints a single summary
line, and the exit code makes it usable as a cron canary — 0 OK, 2
warning, 3 critical:
0 7 * * * cd /path/to/TracePcap && bash scripts/capacity.sh --quiet || mail -s 'TracePcap capacity' ops@example.com
Thresholds are configurable via CAPACITY_WARN_PERCENT (default 75),
CAPACITY_CRIT_PERCENT (90), CAPACITY_WARN_DAYS (30) and
CAPACITY_RATE_WINDOW_DAYS (7).
Sizing a deployment
With retention enabled, the working set is bounded — you only ever hold one retention window of captures, so the disk requirement does not grow with time:
steady state ~= ingest_per_day x 2.5 x (FILE_RETENTION_HOURS / 24)
For 20 GB of captures a week (~2.9 GB/day) at the default 12-hour retention:
2.9 × 2.5 × 0.5 ≈ 3.6 GB steady state. Retention, not disk size, is what
determines the footprint.
Inverting it gives the retention window a given disk can sustain:
FILE_RETENTION_HOURS ~= (usable_disk x 24) / (ingest_per_day x 2.5)
With retention disabled (FILE_RETENTION_ENABLED=false), growth is
unbounded and the disk is the only limit:
days_until_full ~= free_disk / (ingest_per_day x 2.5)
The same 2.9 GB/day on a 2 TB disk gives roughly 285 days. Long-term retention deployments should plan around that number and revisit it as the ingest rate changes.
Important
Leave headroom beyond the steady-state figure. scripts/backup.sh stages an
uncompressed copy of the data set before archiving, so a backup run needs
roughly twice the live data size free. capacity.sh checks this and
reports CRITICAL if the margin is gone.
Two caveats
Monitor snapshots are exempt from
FILE_RETENTION_HOURSand default to never expiring, so they are not covered by the steady-state formula. On a monitor-heavy deployment they are the component that actually grows without bound.Packet density varies widely between captures. The 2.5× multiplier is a planning figure, not a guarantee;
capacity.shmeasures your real rate, so prefer its output once the deployment has seen representative traffic.
Object Storage
MinIO runs single-node on a local volume, so capacity is bounded by the host disk and
there is no redundancy. See Storage Redundancy & Capacity for the SPOF discussion and the
SNMD/MNMD upgrade paths.
Archival & Retention
A scheduled job prunes analysis files after FILE_RETENTION_HOURS (default 12h).
FILE_RETENTION_ENABLED=false keeps everything indefinitely — it is the master
switch, and while it is false the scheduler is not registered, so no other retention
setting has any effect.
Warning
0 means “never” only for MONITOR_FILE_RETENTION_HOURS and
PACKET_RETENTION_HOURS. For FILE_RETENTION_HOURS it means delete everything
now. Disable deletion with FILE_RETENTION_ENABLED=false.
Within an enabled scheduler, packet retention is tunable separately from file
retention: setting PACKET_RETENTION_HOURS below FILE_RETENTION_HOURS drops the
bulky packets partitions early while keeping the compact analysis_results and
conversations summaries. See “Splitting packet retention from summary retention”
above.
Monitor-mode files default to never expiring
(MONITOR_FILE_RETENTION_HOURS=0), because a monitor network is a time series and
expiring its snapshots destroys the drift history. Monitor mode is therefore where
unbounded growth accumulates on a long-lived deployment. Note this pulls against the
partitioning guidance above — retention is what bounds partition count, but monitor
snapshots are exempt from it by default, so a monitor-heavy deployment accrues partitions
that nothing reclaims. Prune those snapshots through the UI rather than by setting a
non-zero value here, until issue #635 is resolved.
See Environment Variables for all four settings.
Schema Notes
Corrections against older documentation:
dns_query_logdoes exist, created byV20__dns_query_log.sql. It did not when the original sizing audit was written, and that stale note was carried forward here in error.The geolocation table is ``ip_geo_cache`` (keyed by IP), not
ip_geo_info;IpGeoInfoEntitymaps toip_geo_cache.V1__baseline_schema.sqlis the source of truth for the original indexes; later migrations amend it (packetspartitioning arrived inV41).