Commit Graph
34 Commits
Author SHA1 Message Date
binwiederhier c94b70000a Merge main into stats-deltas
Release notes: main moved the v2.32.0 notes to the released section, so this PR's bullet goes under v2.33.0's Bug fixes + maintenance. The test's one-line t.Cleanup literals are spread over lines.
2026-10-19 00:33:03 +00:00
binwiederhier 030247b993 Derive each store's target schema version from its migration steps
Migrate took the target version as a separate argument, kept in step with the steps by hand:
adding a step without bumping the version constant silently skipped it on existing installs,
while fresh installs got the new tables. The target is now the version after the last step (1
without steps), and the steps must be contiguous, so a forgotten one fails at startup. The
per-store version constants are gone.
2026-10-17 11:54:38 +00:00
binwiederhier 1c54ae6c8c Write the published-messages total as per-node deltas
The stored total was overwritten with one node's in-memory count, so only the leader could
write it (#2066) and publishes on other nodes were never counted. Every node now adds the
publishes since its last flush to the stored total, reads the total back, and moves its flush
marker to it, so peer counts are never added twice. The total loaded at startup is the first
marker, so a restart does not count it again; a single node counts exactly as before.
2026-10-17 11:24:56 +00:00
binwiederhier 58dfef9aa8 Create both replay indexes in a 16 -> 17 migration
Instead of on every start: the schema is defined in one place again. Postgres gets
idx_message_topic_published_id (topic, published, id), SQLite idx_topic_published_time (topic,
published, time), both with IF NOT EXISTS (ntfy.sh got the Postgres one by hand). A downgrade to
v2.31.0 or earlier now needs the schema version set back by hand, as described in the release notes;
from v2.31.1 on, a newer schema only logs a warning (#2059).
2026-10-16 22:46:18 +00:00
binwiederhier ea14a8342b Add the (topic, published, id) index, and split the replay by time
On ntfy.sh, v2.30.0's own replay (topic = $1 AND id > $2) was expensive: on (topic, published,
time, id), id > marker is checked inside the index but walks every entry of the topic, or the plan
scans the primary key from the marker across all topics. Replica-1 spent about one vCPU on it.
idx_message_topic_published_id (topic, published, id), created by hand on ntfy.sh on 2026-10-16,
made it ~50x cheaper in the lab. It is now created on every start (IF NOT EXISTS, no schema
version, so rollbacks to 2.30.0 still work).

With that index, the late-written half of the replay (time >= marker AND id < marker) picked it
and walked the topic's history (6 ms -> 104 ms in the lab). The replay is now split by time
instead: rows published in the marker's second or later (time index, from the marker), and rows
written after the marker but published before it (the new index, from the marker; time + 0 keeps
it off the time index). Same result as before, and both parts start at the marker.

The plan test now runs on prod-shaped data (200k rows, a few busy topics) with statistics, and
judges each scan by its index: a scan must start at the marker on that index, and replays must
stay within the topic. It fails on v2.31.0's query and on v2.30.0's without the index.
2026-10-16 21:08:31 +00:00
binwiederhier 3105128de9 Check generic plans, and split the tail query the same way
The replay query of v2.31.0 only read whole topics in the plan Postgres makes without the
parameter values, which it switches to after a prepared statement has run five times (pgx
prepares statements); with the values, it had found a cheaper plan. The plan tests now check that
generic plan. They found the same problem in the tail read (id > cursor OR id = ANY(holes)): its
generic plan scans the whole message table through the primary key, 700 ms on 3.3M rows in the
lab, on the primary every second. SQLite, which always plans without the values, scanned the whole
table for it too. Both are now two parts joined with UNION ALL.
2026-10-16 19:03:54 +00:00
binwiederhier 7706730cd1 Fix since=<id> replays reading every message of the topic
The replay query of #2040 matched "id > marker OR (time >= marker time AND id <> marker)". The index
can check neither side of the OR, so Postgres read every row of the topic from the table and filtered
it afterwards; after the v2.31.0 upgrade, the reconnects of ntfy.sh's subscribers saturated a read
replica within a minute.

The two halves are now separate queries joined with UNION ALL (they cannot overlap: one has
id > marker, the other id < marker), and the index checks every condition of each: rows after
the marker as in v2.30.0, and late-written rows through a time range. SQLite gets an index on
(topic, published, time) for the time half, created on every start instead of in a migration, so
older versions still open the cache file. On ntfy.sh's busiest topic the replay now takes ~60 ms
(rows after the marker) and 0.2 ms (late-written rows), both from the index alone.
2026-10-16 14:47:47 +00:00
binwiederhier ad29697ba3 Return the rows a since=<id> marker was written after, and wait for an unwritten marker
Cluster nodes write their batches independently, and late when one is
cut off from the database, so a message can be written after one that
was published later. A client whose marker is the late-written one never
got the later message: its row id is lower, and the node it reconnects
to delivered it before the client arrived, so the tail does not resend
it. since=<id> now also returns the rows published in the marker's
second or later with a lower row id. The marker's own second is replayed
as a consequence, which clients drop by id.

A marker that is not written yet (the client reconnected right after
receiving it) used to resolve to row 0 and replay the topic's entire
history. It is now looked up again for up to the batch timeout plus a
second first.
2026-10-13 00:31:44 +00:00
binwiederhier 8e7e6a3e0f Keep the tail's order across reads, retry failed reads whole, and seed holes at startup
A subscriber's missed messages from one read now wait for those of the
previous read, so a later read cannot overtake an earlier one.

A read that fails part way no longer moves the cursor or clears holes, so
it is retried whole instead of losing the rows read before the failure.

A new tail records the gaps among the last rows as holes, so a row whose
id was handed out before startup but committed after it is not skipped.
2026-10-12 19:58:13 +00:00
binwiederhier f9a87cc4da Send subscribers what they missed by tailing the message cache
Every node reads the rows written to the message cache once a second, in
write order (by row id), and sends each message to the local subscribers
that did not get it live: all of them if its fan-out to this node was
lost, or those that subscribed after it went out (they were reconnecting,
and their database read ran before the message was written).

Row ids are handed out on insert but become visible on commit, so a row
id the tail had to skip is re-checked until its row shows up or a minute
passes. In a cluster, since=<id> is read as a time a few seconds before
that message, since nodes write their batches independently.
2026-10-12 15:21:09 +00:00
Philipp C. Heckel ba326a0b75 Merge pull request #2011 from binwiederhier/postgres-insert-shape
Write message batches with one fixed INSERT statement on PostgreSQL
2026-10-07 21:31:57 -04:00
binwiederhier a1c1e1ef43 Split the message insert into literal PostgreSQL and SQLite statements
The shared insert helper only returns the values of a message in a fixed
order. Each backend writes its own INSERT text as a constant: PostgreSQL
the unnest statement with the array casts, SQLite the multi-row INSERT
head and row placeholder. No column names or types travel with the values
and no renaming between backends.
2026-10-08 00:58:02 +00:00
binwiederhier 71c52c9fa7 Carry column names and types with the insert values
One insertMessageValues function now yields column name, PostgreSQL type
and value side by side per row. The backends build their statements from
the row, so the separate column lists (and the order they had to share
with the values) are gone. SQLite keeps a one-entry rename for its user
column.
2026-10-07 19:42:18 +00:00
binwiederhier e50dfee8a8 Write message batches with one fixed INSERT statement on PostgreSQL
The batch writer built a different multi-row INSERT text for every batch
size, and pgx prepares each distinct text server-side and keeps up to 512
per connection. With pool connections now long-lived, every backend on
ntfy.sh accumulated hundreds of multi-MB plans: primary memory crept from
50% to 65% in three hours after 2.29.0. Locally, one backend grew from
20 MB to 1.15 GB after 1000 batch sizes and kept growing once the cache
was full.

The INSERT now takes one array parameter per column and unnests them, so
the statement text is the same for any batch size (32 MB after the same
workload). SQLite keeps the multi-row INSERT, chunked at 1000 rows. The
docs now recommend pool settings, including a connection lifetime.
2026-10-07 19:16:25 +00:00
binwiederhier 12a29df804 Name the query in its own comment 2026-10-07 18:43:07 +00:00
binwiederhier 19e0ee399e Keep the SQLite schema version in step, and shorten the claim comments
SQLite gets a nop 15 -> 16 so the two backends keep the same version numbers;
the claim column is Postgres-only because SQLite is single-node by definition.

Also fixes a comment that no longer matched the code: claimMessagesDue stopped
marking rows published two commits ago.
2026-10-07 18:31:11 +00:00
binwiederhier 5c3cf43496 Claim due messages with a claim column, not by marking them published
Marking a claimed message published was wrong in the way a reviewer would
notice first: published is read by four other queries (since= replays, polls,
scheduled-message replacement, expiry), so a claimed-but-undelivered message
looked delivered to all of them, and a node dying after the claim lost the
push entirely.

The claim is now its own column. claimed_at holds the row off from the other
nodes, the claim statement is a single CTE that locks what it picks and skips
what another node is already claiming, and MarkPublished stays where it was,
after dispatch. So published still means delivered, and delivery is still at
least once.

The claim expires after two minutes, which is what makes a dead claimer
harmless: nothing would ever deliver its rows again if the claim were
permanent. Two minutes is far more than dispatching a claimed batch takes
(side effects are goroutine handoffs), and the claim also takes at most 1000
rows, so a backlog drains over several ticks instead of one node holding all
of it.

Schema 15 -> 16 adds the column; the migration test asserts the migrated
database is structurally identical to a fresh one, so the ALTER and the CREATE
TABLE cannot drift.

Tests: concurrent callers get disjoint sets, an abandoned claim is invisible
until it expires and claimable afterwards, and a published message never comes
back. Both halves of the reclaim test fail if the claim filter is dropped.
2026-10-07 18:20:44 +00:00
binwiederhier 3f29be4027 Claim due delayed messages so exactly one node sends them
Every node's sender loop runs the same MessagesDue query, so once there is
more than one node, a scheduled message is delivered once per node. On
PostgreSQL the query is now SELECT ... FOR UPDATE SKIP LOCKED inside a
transaction, and the claimed rows are marked published in that transaction:
each row goes to exactly one caller and the others skip it instead of
blocking. SQLite and the in-memory store keep the old path.

This does change PostgreSQL semantics. A row used to be marked published
after dispatch returned, so a node dying mid-delivery re-sent on the next
tick; now it is marked before, so a node dying between claim and delivery
loses that message. The alternative is holding the row lock across Firebase,
web push, email and upstream delivery, where one stuck delivery would block
the claim path for every node. A narrow at-most-once window buys a
short-lived lock, and only for delayed messages.

The test runs six concurrent callers against 40 due messages and asserts each
was claimed exactly once; without the claim it reports "claimed 6 times".
2026-10-07 17:35:27 +00:00
binwiederhier ac512e96c0 Use the multi-row INSERT on SQLite too, and derive placeholders from the column list
One write path for both backends; the magic column count is gone. A column added to the list
but not to insertMessageArgs (or vice versa) now fails every message write on every backend.
2026-10-07 13:33:50 +00:00
binwiederhier 99d55175d4 Keep the message batch writer ahead of publish bursts
On cron bursts (top of the hour), the single batch writer fell behind: it wrote one INSERT round
trip per message, so against a busy remote Postgres it managed ~100 rows/s while ~300/s arrived.
New messages took up to ~40s to reach the database (polls missed them), and publishes blocked on
the unbuffered queue for up to ~25s.

- Postgres: write each batch with one multi-row INSERT (up to 1000 rows per statement) instead of
  one statement per row. SQLite keeps the row-by-row path.
- Skip DeleteScheduledBySequenceID when the client did not set a sequence ID: the sequence ID is
  then the message's own fresh ID, so no scheduled message can match. This saves a transaction on
  the primary for almost every publish.
- Buffer up to 100 batches in the BatchingQueue, so a briefly slow writer does not stall publishes.
- dbtest: add a latency proxy (CreateTestPostgresWithLatency, WithLatency) to test round trips.
2026-10-07 11:54:46 +00:00
binwiederhier f08d46dd00 Resolve since=<message-id> on the primary when the replica does not know it
The since marker was resolved by an inline subquery on the replica. A message
ID the replica had not seen yet (a client reconnecting right after receiving
a message, ahead of replication) resolved to 'id > 0' and replayed the topic's
whole retained history to that client.

Resolution is now its own lookup: on the replica, and only if the ID is
unknown there, on the primary. At most two point lookups before the replay,
and none on the primary unless a replica is configured. An ID unknown to the
primary as well (expired) keeps the full-replay fallback.
2026-10-03 19:00:33 +00:00
binwiederhier 4f52663dda Fix exact count that caused the RO replicate to fall over... 2026-09-23 23:13:22 +00:00
binwiederhier 7826c86f67 Remove message-poll-limit again, hardcode title/tags/etc limits, count polls toward bandwidth limit 2026-08-27 19:42:30 +00:00
binwiederhier cae7816e5d Add message-poll-limit ... 2026-08-27 16:38:21 +00:00
binwiederhier fdaf3316a0 Use schema migration lib for message cache 2026-07-28 22:22:13 +02:00
binwiederhier 071543efda Fixes 2026-03-25 15:28:23 -04:00
binwiederhier 075f2ffa15 Refine sync() to properly update sizes 2026-03-23 12:44:40 -04:00
binwiederhier ef314960d0 Refactor 2026-03-17 20:53:41 -04:00
binwiederhier ab33ac7ae5 Refine 2026-03-11 11:58:40 -04:00
binwiederhier f1865749d7 WIP: Postgres read-only replica 2026-03-10 22:17:40 -04:00
binwiederhier 0e6b467cf0 Manual fixes 2026-03-02 12:58:01 -05:00
binwiederhier 11c79a6369 Refine again 2026-03-01 14:24:06 -05:00
binwiederhier 10a6939d8e Rename queries for consistency 2026-03-01 13:47:42 -05:00
binwiederhier 9736973286 REmove store interface 2026-03-01 13:19:53 -05:00