Commit Graph
100 Commits
Author SHA1 Message Date
binwiederhier b76f3c8356 Release notes 2026-10-07 14:22:07 +00:00
binwiederhier 87a27188f1 Test that a scheduled message is replaced when the sequence ID comes as a parameter
The existing update tests only set the sequence ID in the path; this covers the ?sid= form,
which takes the same DeleteScheduledBySequenceID path.
2026-10-07 13:41:50 +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 e536434097 Pass the BatchingQueue buffer size from the caller
The message cache decides how many batches may wait for its writer (queueBufferedBatches in
message/cache.go, next to the shutdown flush timeout it has to fit in); util stays policy-free.
2026-10-07 12:30:36 +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 13a1dced89 Merge branch 'main' of github.com:binwiederhier/ntfy 2026-10-07 04:00:06 +00:00
binwiederhier fa2bf0c955 Release notes 2026-10-07 03:59:56 +00:00
binwiederhier 1275f52b9a Bump deps 2026-10-07 03:39:36 +00:00
binwiederhier b8fed06491 Merge branch 'main' of github.com:binwiederhier/ntfy 2026-10-07 03:31:36 +00:00
binwiederhier f6d269fed8 Release notes 2026-10-07 03:31:27 +00:00
binwiederhier 8ead5751ed Enforce the burn debt cap per reservation, and say what it does not cover
A review of this PR pointed out that the cap was computed from a separate
read of the available tokens: a read and a reservation are two operations, so
anything consuming the limiter in between makes the read stale and the burn
lands deeper than one burst. The cap now comes from each reservation's own
delay, which the limiter computes atomically with the consumption, and the
chunk that would run past one burst of replenishment is cancelled.

That holds the cap against any amount of ordinary concurrent consumption. It
does not make concurrent BURNS of one limiter safe: two burners can each
reserve a chunk that looked acceptable, and the cap is then exceeded by about
a chunk per racing burner (roughly three bursts with 32 of them, measured).
Both the old and the new code behave that way, so this is a documented
contract rather than a fix: callers burning one limiter from several
goroutines serialize, which RateLimiter.Burn does with its own mutex and the
cluster does by burning peer usage from a single flush loop.

Tests cover the two invariants that hold: the cap survives ordinary traffic
racing the burn, and serialized burners share one burst of debt instead of
each taking one.
2026-10-07 00:54:14 +00:00
binwiederhier b259872393 Collect s3 test objects left behind by runs that died
A per-run key space stops concurrent runs from deleting each other's objects,
but it also means nothing ever revisits the keys of a run that was killed
before its cleanup, so those leak forever. The fixed prefix this replaced at
least swept itself on the next run.

The first test to construct an s3 store now sweeps the shared test root once
per binary, deleting anything older than two hours: a test takes seconds and a
CI job minutes, so nothing that old can belong to a live run. Failures to list
or delete are logged, not fatal, since this is hygiene and not the test.

The age filter is a pure function with its own tests, including that an object
whose modification time could not be read is left alone rather than treated as
infinitely old.

This is a backstop. A lifecycle rule on the bucket (expire testpkg-* after a
day) is the real fix, since it also covers a bucket nobody runs tests against
for a while and cannot be skipped by a crash.
2026-10-07 00:34:19 +00:00
binwiederhier a1fbe8e669 Give each s3 attachment test its own key space
The s3-backed tests shared one prefix in the shared CI bucket, and both setup
and cleanup delete everything under the prefix they are given, so two
concurrent CI runs deleted each other's objects mid-test. It surfaced as
NoSuchKey 404s in TestStore_Sync/s3 on a pull request that touches neither
attachments nor s3.

Keys now live under <prefix>/testpkg-attachment/<random per run>/<test name>,
the same scoping s3/client_test.go already uses.
2026-10-07 00:16:39 +00:00
binwiederhier 0ccbf5ad87 Reserve the experimental clustering options
Five option names and their validation, ahead of the implementation, so the
config surface is settled and the cluster change is only code. The
experimental- prefix is there because the names may still change.

The options parse into server.Config and the cross-field rules are enforced:
a listen address needs database-url, a secret and a node id; a secret without
a listen address is an error rather than clustering being silently off; a
wildcard bind needs an explicit advertise URL; a negative linger is rejected.

Setting experimental-cluster-listen then refuses to start the server, since
accepting the options and running single-node would look like a working
cluster. The cluster change deletes that one line.
2026-10-07 00:06:19 +00:00
binwiederhier bbbafcea5d Add the util primitives for clustering
A bounded non-blocking batching queue, a token-burn helper for consumption
that happened elsewhere, and MaxTime. Nothing uses them yet; they land on
their own so they can be reviewed as primitives rather than inside the
cluster diff.

LingerQueue differs from BatchingQueue in that producers never block: a full
queue drops and says so, and Close flushes the remainder before closing the
consumer channel, so per-peer queues can come and go with the nodes.

BurnTokens caps the debt it creates at one burst beyond what is available, so
usage reported from another node can drain a bucket but cannot lock a visitor
out for an unbounded time.
2026-10-07 00:03:00 +00:00
binwiederhier 0f407a16f7 Registry: run the leadership tests in parallel
They each build their own schema and their own registries, so they were
waiting out lease and hold-off windows one after another for no reason: 27s
for the package, 26s of it in six tests. With t.Parallel they overlap, and
the tick loops poll every 50ms instead of 10ms, which cuts the query rate
they put on the database by five while staying 40x finer than the windows
they measure.

Package is 8.6s now, stable over five runs with -race, and each safeguard is
still independently covered: removing the observation-freshness check fails
the heartbeat-without-observing test, and removing the hold-off restart fails
the frozen-leader test.
2026-10-06 02:02:59 +00:00
binwiederhier 5ae9dbbdfa Registry: cover the observation-freshness check and the clock decision
Both gaps came out of a review: removing either safeguard left the suite
green, so neither was actually tested.

- Observation freshness: a node can keep its registration fresh while its
  view of the registry goes stale, and it has to stop believing then, because
  the hold-off it earned rests on an observation that no longer holds. The new
  test keeps the incumbent registering but never refreshing while a returning
  lower-id node earns its own hold-off; without the check both believe.
- The clock decision: the old test wrote a row an hour in the past and would
  have passed just as well with a node-side cutoff, since the test process and
  the database share a clock. It now asserts that the heartbeat, the cutoff and
  the prune all take their instants from the database, plus the behaviour that
  follows (a row an hour-fast node would write still counts as live, one from
  an hour-slow node does not). Its comment also said "an hour ahead" while the
  row subtracted an hour.
2026-10-06 01:48:19 +00:00
binwiederhier a798381748 Registry: give the promotion hold-off a longer window than node liveness
The lease and the hold-off were both one node TTL, and the hold-off is the
one that wants to be longer: the TTL also decides how fast a dead peer leaves
the fan-out set and how fast an isolated node notices it is isolated, so it
has to stay short, while leadership would rather be slow and calm.

The hold-off is two TTLs now and the lease stays at one, so belief still ends
exactly when peers stop counting this node as live. With the default 30s TTL
a successor waits 60s, a unit of work that starts under valid leadership has
60s before a second leader is possible, and a crash leaves up to ~90s with no
leader, which costs a couple of manager ticks of pruning and nothing else.

A test asserts the invariant the factors have to satisfy (lease < TTL +
hold-off), since that is what keeps a deposed leader's belief ending before
any successor's begins.

The doc comment also states what the windows do not promise. A leader-gated
job that blocks inside a database call can sit there past its own lease:
measured on three hosts with the database partitioned away from the leader,
an unbounded call kept a node acting as leader for 14.3s after its lease
expired, 8s of that alongside the new leader. Anything whose double execution
matters needs its own claim row or has to be idempotent.
2026-10-06 01:42:45 +00:00
binwiederhier 1e4c3dc251 Am I Banned & AI Policy 2026-10-06 00:46:00 +00:00
binwiederhier 8130c9c802 Registry: give the leadership tests a database budget CI can meet
Registry calls are bounded at ttl/2, so a 200ms test TTL left 100ms per
query. That is fine against a local Postgres and times out on a CI service
container, which is what broke the pipeline. One second instead.
2026-10-06 00:32:21 +00:00
binwiederhier d383778f56 Registry: a frozen node must re-earn the promotion hold-off
Found by running two real processes against Postgres: freezing the leader
with SIGSTOP and thawing it gave 436ms of two leaders. The hold-off was
measured from when this node first saw itself as the lowest live id, and a
frozen process keeps that observation across the pause, so one successful
heartbeat on resume made the hold-off look long satisfied while the incumbent
had not yet re-read the registry.

A gap in observation no longer counts: Refresh restarts the hold-off whenever
it has not run for a TTL, and belief now also requires a recent Refresh, not
just a recent Register. The regression test freezes a leader by not ticking
it, then thaws it with one register plus one refresh and requires that it does
not believe yet.
2026-10-06 00:22:15 +00:00
binwiederhier 5ad8a8e0b3 Add the cluster node registry and leader election
First piece of multi-node ntfy: the membership table, and leadership derived
from it. Nothing uses it yet, so this is inert on a single node.

Each node upserts its own row (node_id, advertise_url, last_heartbeat) every
few seconds and reads the other fresh rows to find its peers. Reads are split
in two on purpose: Refresh queries the table and is called from the heartbeat
loop, while Peers only returns the snapshot of the last Refresh, so a message
path can ask who the peers are without ever waiting on the database, not even
during an outage.

Leadership is not a separate mechanism. The leader is the live node with the
lowest id, which every node can work out from the data it already reads, and
three conditions keep two nodes from both believing it:

- it is the lowest live id as of its last refresh;
- its own registration is fresh, dated from when the heartbeat was issued, so
  a node cut off from the database stops believing within one TTL, and stops
  before its peers write it off (its deadline is measured from before the
  write they saw);
- it has been the lowest live id for a whole TTL, because a returning
  lower-id node would otherwise believe at once while the incumbent still
  believes until its next read.

Heartbeats are stamped by the database and the liveness cutoff is evaluated
in SQL: these timestamps are compared across nodes, so one clock has to
decide. Tests cover the failure modes rather than just the happy path: a
leader that stops heartbeating, a returning lower-id node preempting, a node
whose database is gone, and a row written by a badly skewed clock.
2026-10-05 22:21:22 +00:00
binwiederhier 3fd23a158f Reelase notes 2026-10-05 21:47:24 +00:00
binwiederhier b922d99b8a Bump 2026-10-05 21:18:21 +00:00
binwiederhier fa9ff8c68a Add uppercase Content-Type test and release notes for #1995 2026-10-05 21:14:27 +00:00
binwiederhier f7fced0531 Merge pull request #1995 from cipherprofessor/fix/markdown-content-type-charset 2026-10-05 21:09:30 +00:00
binwiederhier e199f010ea Fall back to plain comparison if Content-Type can't be parsed, lowercase media type 2026-10-05 21:09:30 +00:00
binwiederhier ac0e2dfcd8 Add ExecContext and QueryContext to db.DB
The wrapper only exposed the context-free database/sql methods, so callers
that need a deadline (or cancellation on shutdown) had to reach for
Primary() directly. Delegates to the primary, like Exec and Query.
2026-10-03 19:17:53 +00:00
binwiederhier 58c414d305 Release notes for the since=<message-id> replica fix 2026-10-03 19:08:44 +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 90b1c5f9d4 Release notes for the shutdown-flush and Postgres pool fixes 2026-10-03 17:45:07 +00:00
binwiederhier 2153f646be Keep shutdown bounded, and do not start listeners after Stop
Now that the signal handler waits for Stop, two holes in Stop matter:

- Stop waits for every store to close, and those waits have no deadline of
  their own: attachment.Store.Close waits for the sync loop, whose
  AttachmentsWithSizes query runs against the database (and S3 deletes are in
  there too). A wedged database parked Stop before it ever reached the message
  cache, whose own 5s bound was then useless. Stop now gives up after 10s.
- A signal can arrive before Run has set up its listeners. Stop closed the
  stores and consumed stopOnce, and Run then started listeners against closed
  stores and blocked on the listener error, so the process neither served nor
  exited and a second signal could not stop it. Run bails out when the server
  is already stopped, outside the lock (a stuck shutdown holds it) and again
  under it.
2026-10-03 16:18:06 +00:00
binwiederhier c3c7d6eff1 Bound the shutdown flush, so a wedged database cannot block Stop forever
The final batch write has no deadline (no statement_timeout, and database/sql
is used without a context), so an unresponsive database would hold Stop until
systemd's stop timeout fires and SIGKILLs us. Cache.Close now gives up after
5 seconds, both on handing the pending messages to the batch writer and on
waiting for it, and logs that it did.
2026-10-03 16:00:29 +00:00
binwiederhier 11acdd2a31 Keep Postgres pool connections idle instead of re-opening them
Without pool_max_idle_conns, database/sql keeps only 2 idle connections, so
any burst above that closed connections after use and re-opened them on the
next query: a DNS lookup, TCP + TLS handshake and SCRAM auth per query. Under
load this shows up as HTTP 500s whenever the resolver hiccups ("hostname
resolving error"). ntfy.sh runs pool_max_conns=30 without an idle setting, so
it churns connections the same way.

pool_max_idle_conns now defaults to pool_max_conns.
2026-10-03 15:21:57 +00:00
binwiederhier e63ec41b2e Close the message cache before the other stores on shutdown
On Postgres all stores share one pool, and closeDatabases closed the user
manager first, which closed the pool under the message cache's final batch
write ("Cannot write message batch: sql: database is closed").
2026-10-03 15:06:45 +00:00
binwiederhier 303230e5dc Guard handleAccountTokenUpdate against the nil-user race (recurring prod panic)
ensureUser checks v.User(), the handler re-reads it, and a concurrent
anonymous request on the same shared IP-keyed visitor can SetUser(nil) in
between -> nil dereference (~8x/day on ntfy.sh). Return 401 instead. The
regression test makes the race deterministic.
2026-10-03 15:06:45 +00:00
binwiederhier bc1e879dc4 Persist batched messages on shutdown; graceful SIGTERM in serve
With cache-batch-size/-timeout set (ntfy.sh: 100 / 1500ms), accepted messages
wait in a BatchingQueue before they are written. Nothing drained that queue on
shutdown, and `ntfy serve` did not handle SIGTERM at all (only SIGHUP), so
every restart dropped up to one batch timeout's worth of accepted messages
from the cache: live subscribers got them, pollers and since= replays never
did.

- util.BatchingQueue.Close emits the pending elements and closes the output
  channel (Enqueue after Close is a no-op)
- message.Cache.Close closes the queue and waits for the batch writer before
  closing the database
- serve stops the server on SIGTERM/SIGINT and waits for Stop to finish (Run
  returns as soon as the listeners close); Stop is idempotent now, since the
  signal handler and serve both call it
2026-10-03 15:06:09 +00:00
binwiederhier 6560158994 Bump release notes and deps 2026-09-26 01:47:02 +00:00
binwiederhier 4f52663dda Fix exact count that caused the RO replicate to fall over... 2026-09-23 23:13:22 +00:00
binwiederhier 10cb6506f8 Bump 2026-08-27 20:16:48 +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 a0a0a5fde3 Merge branch 'main' of github.com:binwiederhier/ntfy 2026-08-27 14:14:03 +00:00
binwiederhier fbcd4b6290 Expands bandwidth limit to also include excessive polling 2026-08-27 14:12:01 +00:00
binwiederhier 423063893d Bump install notes 2026-08-04 07:05:29 +02:00
binwiederhier 9328f1f3c8 Bump fast-uri 2026-08-03 23:07:09 +02:00
binwiederhier 9dc30e4a97 Update GH actions 2026-08-03 22:39:01 +02:00
binwiederhier cd333f130f Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-08-03 18:38:38 +02:00
binwiederhier f42326605e Update deps 2026-08-03 18:38:29 +02:00
binwiederhier 7ed7fea081 Limit memory usage in templates 2026-08-03 18:34:29 +02:00
binwiederhier 0ecba37334 Combine message dispatching into a dispatch function 2026-08-03 08:11:51 +02:00
binwiederhier bd96177fdf Move pg tests to its own file 2026-07-29 08:05:50 +02:00
binwiederhier a3d43190c9 Harden migration 2026-07-29 07:25:12 +02:00
binwiederhier 95ad323d1c Schema compare tests 2026-07-29 07:12:28 +02:00
binwiederhier 244a9bc06d Use schema/ package in user/ package 2026-07-29 06:23:24 +02:00
binwiederhier fdaf3316a0 Use schema migration lib for message cache 2026-07-28 22:22:13 +02:00
binwiederhier 4abdeb8d57 Comment 2026-07-28 07:34:27 +02:00
binwiederhier 7fb1d25740 Restructure a little 2026-07-27 23:48:43 +02:00
binwiederhier ef121a3f6c Schema migration 2026-07-27 17:51:32 +02:00
binwiederhier f2d5c1ce6c Do not include secrets in the config hash 2026-07-23 08:13:27 +02:00
binwiederhier 7680cb4906 Ban-feed 2026-07-20 23:49:17 +02:00
binwiederhier 706fa3b491 Remove "experimental" from postgres option 2026-07-20 23:48:17 +02:00
binwiederhier 2bc145f3ae Merge branch 'release-2.26.x' 2026-07-20 23:42:03 +02:00
binwiederhier 311138ef7b Derp 2026-07-20 23:23:42 +02:00
binwiederhier f6b03b44dd Bump release notes 2026-07-20 23:10:22 +02:00
binwiederhier c674985699 Redo the banning logic, ban.Service 2026-07-20 21:34:47 +02:00
binwiederhier 469d263a5c Hotfix: Add ban-file/ban-threshold/ban-weights as a more lightweight mechanism abuse counter 2026-07-18 08:41:25 +02:00
binwiederhier f8d2fcd7a6 Move metrics to metrics/ pacakge 2026-07-17 22:08:21 +02:00
binwiederhier ac63a2eea0 Move Twilio to twilio/ package 2026-07-17 13:48:48 +02:00
binwiederhier 24bc50b585 Allow logging in via email 2026-07-16 21:42:42 +02:00
binwiederhier b55e78a918 Release notes 2026-07-12 10:23:38 +02:00
binwiederhier 3f56dae54a Template exec context, redone 2026-07-10 13:18:11 +02:00
binwiederhier 1e4e3b6e36 Remove unreachable link 2026-07-09 23:07:07 +02:00
binwiederhier 75c687de1c Bump Android pre-release 2026-07-09 22:50:11 +02:00
binwiederhier 432da44dc4 Bump! 2026-07-09 20:33:03 +02:00
binwiederhier 703d7bb9de Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-07-09 20:15:14 +02:00
binwiederhier ce01b357d8 Bump prettier 2026-07-08 23:21:23 +02:00
binwiederhier c40a2ce0a7 Update android release notes 2026-07-08 23:07:41 +02:00
binwiederhier 88e598d8b8 RElease notes 2026-07-08 22:44:57 +02:00
binwiederhier 6869d166ae fmt 2026-07-08 22:42:42 +02:00
binwiederhier 08d81a3645 Merge branch 'main' of github.com:binwiederhier/ntfy 2026-07-08 22:23:40 +02:00
binwiederhier 9a021aba2d Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-07-08 22:15:47 +02:00
binwiederhier 165f012ae6 Add AST check to catch calls, remove regex for block and template 2026-07-08 21:56:57 +02:00
binwiederhier 1d69ebaf58 Additional test 2026-07-08 21:18:19 +02:00
binwiederhier a001ac5195 Template execution rework 2026-07-08 21:08:07 +02:00
binwiederhier 7b54850c16 Make twilioClient 2026-07-03 21:04:06 -04:00
binwiederhier be3dbdcb48 Move action parsing to action package 2026-07-03 20:54:45 -04:00
binwiederhier 2eabfd2f01 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-07-03 18:32:40 -04:00
binwiederhier d0054ea394 Split out auth to server_auth.go 2026-07-03 18:32:25 -04:00
binwiederhier 97282d6e3d Release notes 2026-07-02 20:54:30 -04:00
binwiederhier 69c2c18a9e Moved template functions 2026-07-02 17:17:39 -04:00
binwiederhier 479e406493 Split out web functions to server_web.go 2026-07-02 17:11:24 -04:00
binwiederhier 6429d36708 Bump 2026-07-02 16:57:41 -04:00
binwiederhier 73f771dafa Release notes 2026-07-02 16:53:27 -04:00
binwiederhier 5e1f27d709 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-07-02 16:51:06 -04:00
binwiederhier b4adf85805 Remove MagicBell as sponsor 2026-07-02 14:24:56 -04:00
binwiederhier e39de727e0 Bump and Fixes 2026-07-01 11:49:48 -04:00
binwiederhier 8a67b5129e Strip unsafe URLs from Markdown links 2026-07-01 10:28:33 -04:00
binwiederhier f2b22c114f Fix lint 2026-06-29 22:32:13 -04:00
binwiederhier c873064cad Date / time format 2026-06-29 22:24:04 -04:00