previews · rcas · prd-us-db memory

prd-us-db memory exhaustion

Why the shared v3 Postgres instance crossed its 95% memory alarm on August 13, and why it will keep crossing it every busy afternoon until it gets more memory.

Fix proposed Author Adolfo Tamayo Date 2026-08-13 Alert P2 prd-us-db-memory Slack alert thread Fix lawhive.infra.compute#122
TL;DR
The shared prd-us Postgres instance is a db.t4g.small with 2 GiB of memory, and it hosts a database for every one of the 16 v3 services plus two CDC readers. The pressure grows with the platform, but not where we first guessed: a live look at pg_stat_activity shows the CDC user holding 106 of the ~156 connections, while all sixteen service pools together hold 42. Every database added brings replication slots and CDC connections, and around the front-office launch on August 3 the connection floor stepped up by ~25; since then the daily working-set peak grazes the 95% alarm line, and today it crossed it (77 MB free). Nothing is leaking. The instance is out of headroom. The fix is one line: PR #122 takes it to a db.t4g.large (8 GiB), sized for the firm data migrations landing in the coming months.
77 MB
free at the trough (of 2 GiB)
68%
of connections held by CDC (106 of 156)
16 + 2
services + CDC readers on one box
swap growth in 30 days

What fired

At 21:20 BST the prd-us-db-memory P2 fired: RDS freeable memory below 5% of the instance's 2 GiB, which is about 102 MB. The alarm is generated by the shared rds/pg/v1 Terraform module from FreeableMemory, so "memory utilization above 95%" means the operating system on the DB host has less than 102 MB it could hand out.

Memory on this instance follows a daily cycle: it recovers overnight to roughly 250 MB free, then falls through the US business afternoon as queries warm the working set. The alert is simply the afternoon trough crossing the line. Here is the day it fired:

010020030000:0004:0008:0012:0016:0020:0095% alarm (102 MB free)P2 fires 20:20 UTC
Hourly minimum freeable memory (MB), August 13, UTC. The afternoon slide starts around 12:00 UTC and crosses the 102 MB alarm line at 18:00; the P2 fires at 20:20 UTC (21:20 BST) with 77 MB free.

Three months of ratchet

The trough has been sinking all quarter. Daily minimum freeable memory was around 400 MB in early May; this week it bottomed between 77 and 130 MB. The drops are not gradual: each one lines up with services being added to the instance.

0100200300400500MayJunJulAug95% alarm (102 MB free)v3 migration wavetelephony, workflow-web, agent-workerfront-office77 MB
Daily minimum freeable memory (MB), May 1 to August 13. Each marked event is a service launch on the shared instance. The red dashed line is the 95% alarm threshold.

The mechanism is connections, but not the services' own pools. A live check of pg_stat_activity (August 13, 22:30 UTC) shows who actually holds them: the CDC user has 106 of the ~156 connections, and all sixteen service pools together hold 42, each service sitting at 2 or 3. pgbouncer's per-service cap of 20 never comes close. The CDC connections scale with databases: every database on the instance carries a prd_us_v3_* replication slot costing ~3 connections, and the five databases Sequin actively syncs (messaging, lawrence_api, identity, matter, work) hold 14 CDC connections each. So a service launch raises the floor mainly through CDC: a new database means a new slot, and Sequin onboarding means ~11 more connections on top. A Postgres connection is a process with several MB of resident memory, so 106 CDC connections cost hundreds of MB that never come back.

UserConnectionsWhat it is
prd-us-db-cdc.v1106CDC: 22 replication slots across all databases, plus ~11 Sequin sync connections on each of the 5 actively synced databases
prd-us-db-rw.v242All 16 services via pgbouncer, 2 to 3 connections each
rdsadmin + internal8RDS housekeeping

The overnight connection floor still tells the growth story cleanly, it just needs the right reading: each step is a launch bringing its database, its replication slots, and its CDC sync connections, not a service-side pool.

0255075100125MayJunJulAugv3 migration wavetelephony, workflow-web, agent-workerfront-office114
Daily minimum database connections, May 1 to August 13. A staircase, not a curve: ~20 before the v3 migration wave, ~77 by June, ~85 after the mid-July launches (telephony, workflow-web, agent-worker), ~110 the day front-office launched, 114 now. Each step is dominated by the CDC connections that arrive with a new database, not the service's own pool. The one-day dip to zero on June 8 is a restart artifact.

The host has been compensating by swapping. Average swap usage grew from 24 MB in mid-July to around 100 MB now, which is the operating system telling us the working set no longer fits in RAM:

04080120Jul 14Jul 24Aug 3Aug 13
Daily average swap usage (MB), July 14 to August 13. The step up on August 4 is the day after front-office launched.

What shares this instance

The instance is defined in lawhive.infra.compute/databases/tf/modules/service/service.tf and sits behind a pgbouncer + haproxy proxy layer. One logical database per service:

platform-apilawrence-apilead-apimatteridentitymessagingemailassignmentcontentfront-officeledgerpaymenttelephonytransactionworkagent-workerSequin (CDC)GCP Datastream (CDC)

The two CDC readers hold 22 active replication slots between them (a prd_us_v3_* slot per database, plus a sequin_slot_* on each actively synced one) and 106 of the instance's ~156 connections. Each walsender doing logical decoding can also claim up to logical_decoding_work_mem (64 MB by default) while it works, so CDC is the biggest memory consumer on the box as well as the biggest connection holder. CPU is not part of this story: utilization averages 7% and the burst-credit balance has sat pegged at its 576 maximum for the entire month.

Root cause

Structural, not a leak. The instance was sized at 2 GiB when v3 had a handful of services. Every database added since then brings a replication slot for each CDC reader, and the databases Sequin actively syncs hold ~14 CDC connections each; the CDC user now owns two-thirds of all connections on the box. Add the services' own (small) pools and the ordinary afternoon working set, and after the launches of telephony, workflow-web, agent-worker and front-office within a month the peak reaches the 95% line, so the alert now fires on busy days without anything being wrong beyond the sizing. August 13 was not an incident; it was the day the growth met the threshold.

The fix, and what applying it costs

lawhive.infra.compute#122 bumps prd-us from db.t4g.small to db.t4g.large: same 2 vCPU, memory quadruples to 8 GiB, cost goes from about $24 to about $96 a month. The backend guild (2026-08-14) chose large over medium deliberately: two firm data migrations land in the coming months, bulk loads amplify through logical decoding across all 22 CDC slots (each walsender can claim up to 64 MB while decoding), and going straight to large is one resize outage instead of a possible two. The guild also agreed to migrate this instance to Aurora, which resets the sizing question later; large is the interim ceiling, not a trajectory.

Applying it needs care on two points:

Before the data-migration service lands

A new service for data migrations is planned, and it will bulk-load two firms' worth of data into this instance. The resize to large covers the memory side: another database means another replication slot per CDC reader plus a small service pool, and 8 GiB absorbs the decode-buffer spikes that bulk loads push through the CDC slots. Two other limits will bite first:

Follow-ups

ActionOwnerWhen
Merge #122, then dispatch the Opentofu (db) apply for prd-us in a quiet window (5 to 15 min outage)infrathis week
Raise storage.max_gb for prd-us before the data-migration service does its first bulk load (online, no downtime)infra + migrations ownerbefore that service ships
Review the CDC connection budget: the five Sequin-synced databases hold 14 CDC connections each (~70 of the 156 total), and trimming Sequin's per-database connection count is free headroominfra + dataopportunistic
Decide whether the shared instance should be multi_az: it would turn the next resize from a 5-to-15-minute outage into a ~1-minute failover, at double the instance costinfranext sizing change