How a Read Receipts Launch Burned Through 2.1 Billion IDs and Stopped Every Write
← Back
September 2, 2026Database9 min read

How a Read Receipts Launch Burned Through 2.1 Billion IDs and Stopped Every Write

Published September 2, 20269 min read

03:47 UTC. PagerDuty: "notifications-worker error rate 100%, last 5 min." Every notification delivery in the pipeline failing the same way, no partial degradation, nothing intermittent. Just off.


the setup

notifications-db-01 is a dedicated Postgres 15 instance backing notifications-svc, separate from the checkout cluster. Its busiest table, notification_events, logs one row per lifecycle event on every push notification we send, created back in 2019 with a plain id SERIAL PRIMARY KEY. Nobody revisited that choice for seven years because nobody had a reason to.

Nineteen days before the page, notifications-svc shipped read receipts. Before that launch, a notification wrote a single row: sent. After it, the same notification wrote three: sent, delivered, opened. Product measured the launch by open-rate lift. Nobody measured it by sequence consumption.


the scramble

First theory: the 03:10 UTC deploy, a batching change to how notifications-worker flushed delivery confirmations. It was the only thing that had touched the path recently. On-call rolled it back. The error rate stayed at 100%.

Second theory: disk. A full volume was the closest thing anyone had seen behave like this before. df -h on notifications-db-01 came back at 34% used, nowhere close.

Third theory: connection pool exhaustion from the read receipts traffic finally catching up with PgBouncer's pool size. SHOW POOLS; showed spare capacity in every pool. Three theories, three dead ends, eleven minutes gone, and every insert into notification_events was still failing.

notifications-worker, Sentry
psycopg2.errors.SequenceGeneratorLimitExceeded: nextval: reached maximum value
of sequence "notification_events_id_seq" (2147483647)

Nobody had read the actual error yet. They'd been reading Sentry's grouped summary, "insert failed on notification_events," and guessing at causes upstream of it instead.


the hunt

Once someone opened the raw exception, there was nothing left to guess about. A sequence had hit its ceiling. The next question was how close it had been sitting to that ceiling before tonight, and whether this was sudden or a long time coming.

notifications-db-01, psql
SELECT seqrelid::regclass, last_value, is_called
FROM pg_sequences
WHERE sequencename = 'notification_events_id_seq';

        seqrelid          | last_value | is_called
---------------------------+------------+-----------
 notification_events_id_seq | 2147483647 |    t

Sitting exactly at 2147483647, the maximum value a Postgres integer column can hold. Not close to the ceiling, at it. A pull of the read receipts launch metrics from Datadog filled in the rest: the table had been consuming roughly 170 million IDs a month before the launch, about four months of headroom left at that rate. The moment read receipts went live, every notification started writing three rows instead of one, and the burn rate roughly tripled overnight.

deploy history
$ git log --oneline -- notifications-svc/features/read-receipts/
e91a204 feat: read receipts - sent/delivered/opened event tracking

$ git log -1 --format=%cd e91a204
Fri Aug 14 16:20:03 2026 +0000

Nineteen days from that deploy to the sequence hitting its ceiling. Four months of runway, cut to nineteen days, and nobody watching the one number that would have said so.


the find

Root cause: notification_events.id was a 2019-era SERIAL column, backed by an int4 sequence capped at 2,147,483,647. The read receipts launch on August 14 tripled the table's write rate by turning every single-row notification event into three. That tripled burn rate consumed the sequence's remaining headroom in nineteen days instead of the roughly four months it would have taken at the pre-launch rate, and at 03:47 UTC on September 2 the sequence issued its last valid value. Every subsequent nextval() call raised SequenceGeneratorLimitExceeded, and every insert into the table failed the same way, immediately, with no partial window where some writes still got through.


the fix

A table backed by int4 at 2.1 billion rows can't just get ALTER COLUMN ... TYPE bigint run against it during an outage. That rewrites every row under an ACCESS EXCLUSIVE lock, and on a table this size that's hours, not minutes, of every reader and writer blocked. We needed writes working again in the next few minutes, not after a multi-hour rewrite finished.

The immediate fix leans on something most engineers never need to know about int4: the range isn't 1 to 2.1 billion, it's roughly negative 2.1 billion to positive 2.1 billion. A SERIAL sequence only ever uses the positive half by default. Nothing in notification_events had ever been assigned a negative ID, and a quick grep of notifications-svc confirmed no code path assumed IDs were always positive, no URL parsing, no client-side range checks. That cleared the sequence to keep counting into the half of its range it had never touched.

notifications-db-01, psql
ALTER SEQUENCE notification_events_id_seq MINVALUE -2147483648;
ALTER SEQUENCE notification_events_id_seq RESTART WITH -2147483648;

Writes started succeeding again within seconds of the second statement. Nine minutes of hard failure, start to finish, from the first alert to the restart landing.

That bought runway, not a real fix, roughly another four billion IDs before the sequence would hit zero and cross back into positive territory it had already used, a collision nobody wanted to find out about the hard way. The actual fix was the bigint migration we'd been putting off since 2019, done properly this time: a new bigint column backfilled in batches, kept in sync with new writes through a trigger, then swapped in as the primary key with zero downtime.

migration, run over the following two weeks
ALTER TABLE notification_events ADD COLUMN id_bigint bigint;

CREATE OR REPLACE FUNCTION sync_notification_events_id() RETURNS trigger AS $$
BEGIN
  NEW.id_bigint := NEW.id;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_sync_notification_events_id
  BEFORE INSERT ON notification_events
  FOR EACH ROW EXECUTE FUNCTION sync_notification_events_id();

-- backfilled in 50k-row batches against ctid ranges, off-peak, over ~11 days

CREATE UNIQUE INDEX CONCURRENTLY notification_events_id_bigint_key
  ON notification_events (id_bigint);

BEGIN;
ALTER TABLE notification_events DROP CONSTRAINT notification_events_pkey;
ALTER TABLE notification_events ADD CONSTRAINT notification_events_pkey
  PRIMARY KEY USING INDEX notification_events_id_bigint_key;
ALTER TABLE notification_events ALTER COLUMN id_bigint SET DEFAULT
  nextval('notification_events_id_bigint_seq'::regclass);
COMMIT;

Every new table in notifications-svc now defaults to bigint generated always as identity, enforced by a CI lint rule that fails a migration PR on any SERIAL or bare integer primary key. A Datadog monitor tracks last_value / 2147483647 for every remaining int4 sequence in the fleet and pages at 75% consumed, which would have caught this with well over a month to spare instead of zero minutes.


the aftermath

2,147,483,647 Max value of the int4 sequence that ran out
19 days From feature launch to sequence exhaustion
9 min Writes hard-failing before the negative-range fix
26,800 Notification events that failed or queued for retry

Queued events retried successfully once writes recovered, no notification data was lost, but delivery and open tracking for that nine-minute window undercounted, and the read receipts dashboard showed a visible dip the next morning that took a support thread to explain.

  • A capacity limit that took seven years to become a problem is still a capacity limit. It doesn't announce itself until a write-rate change compresses the timeline, and by then there's no slack left to notice gradually.
  • Grouped error monitoring can hide the one line that would have ended the guessing immediately. Sentry's summary said "insert failed," Postgres's actual message named the sequence and the ceiling by number.
  • int4's negative half isn't a curiosity, it's roughly two billion IDs of real emergency runway, as long as nothing downstream assumes IDs are always positive. Worth confirming that before you need it, not during the outage.
  • A feature that changes a table's write multiplier is a capacity-planning event, not just a product launch. Nobody modeled read receipts against the one number that actually ran out.

checkout-svc and the rest of the fleet never touched this table and never saw an error. notifications-db-01 was its own instance, its own sequence, its own ceiling, reached alone.

Share this
← All Posts9 min read