mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-08 21:05:21 +00:00
Add message-poll-limit ...
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
|||||||
1.26.5
|
1.27.0
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ var flagsServe = append(
|
|||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-call-format", Aliases: []string{"twilio_call_format"}, EnvVars: []string{"NTFY_TWILIO_CALL_FORMAT"}, Usage: "Twilio/TwiML format string for phone calls"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-call-format", Aliases: []string{"twilio_call_format"}, EnvVars: []string{"NTFY_TWILIO_CALL_FORMAT"}, Usage: "Twilio/TwiML format string for phone calls"}),
|
||||||
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "message-poll-limit", Aliases: []string{"message_poll_limit"}, EnvVars: []string{"NTFY_MESSAGE_POLL_LIMIT"}, Value: server.DefaultMessagePollLimit, Usage: "max number of cached messages returned per topic when replaying the cache (poll/since requests); default is uncapped"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-size-limit", Aliases: []string{"message_size_limit"}, EnvVars: []string{"NTFY_MESSAGE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultMessageSizeLimit), Usage: "size limit for the message (see docs for limitations)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-size-limit", Aliases: []string{"message_size_limit"}, EnvVars: []string{"NTFY_MESSAGE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultMessageSizeLimit), Usage: "size limit for the message (see docs for limitations)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
||||||
@@ -205,6 +206,7 @@ func execServe(c *cli.Context) error {
|
|||||||
twilioVerifyService := c.String("twilio-verify-service")
|
twilioVerifyService := c.String("twilio-verify-service")
|
||||||
twilioCallFormat := c.String("twilio-call-format")
|
twilioCallFormat := c.String("twilio-call-format")
|
||||||
messageSizeLimitStr := c.String("message-size-limit")
|
messageSizeLimitStr := c.String("message-size-limit")
|
||||||
|
messagePollLimit := c.Int("message-poll-limit")
|
||||||
messageDelayLimitStr := c.String("message-delay-limit")
|
messageDelayLimitStr := c.String("message-delay-limit")
|
||||||
totalTopicLimit := c.Int("global-topic-limit")
|
totalTopicLimit := c.Int("global-topic-limit")
|
||||||
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
||||||
@@ -402,6 +404,8 @@ func execServe(c *cli.Context) error {
|
|||||||
return fmt.Errorf("if ban-file is set, its directory (%s) must exist", filepath.Dir(banFile))
|
return fmt.Errorf("if ban-file is set, its directory (%s) must exist", filepath.Dir(banFile))
|
||||||
} else if runtime.GOOS == "windows" && listenUnix != "" {
|
} else if runtime.GOOS == "windows" && listenUnix != "" {
|
||||||
return errors.New("listen-unix is not supported on Windows")
|
return errors.New("listen-unix is not supported on Windows")
|
||||||
|
} else if messagePollLimit < 1 {
|
||||||
|
return errors.New("config option message-poll-limit must be at least 1")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backwards compatibility
|
// Backwards compatibility
|
||||||
@@ -524,6 +528,7 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.TwilioVerifyService = twilioVerifyService
|
conf.TwilioVerifyService = twilioVerifyService
|
||||||
conf.TwilioCallFormat = twilioCallFormatTemplate
|
conf.TwilioCallFormat = twilioCallFormatTemplate
|
||||||
conf.MessageSizeLimit = int(messageSizeLimit)
|
conf.MessageSizeLimit = int(messageSizeLimit)
|
||||||
|
conf.MessagePollLimit = messagePollLimit
|
||||||
conf.MessageDelayMax = messageDelayLimit
|
conf.MessageDelayMax = messageDelayLimit
|
||||||
conf.TotalTopicLimit = totalTopicLimit
|
conf.TotalTopicLimit = totalTopicLimit
|
||||||
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
||||||
|
|||||||
@@ -2378,6 +2378,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
|||||||
| `twilio-verify-service` | `NTFY_TWILIO_VERIFY_SERVICE` | *string* | - | Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 |
|
| `twilio-verify-service` | `NTFY_TWILIO_VERIFY_SERVICE` | *string* | - | Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 |
|
||||||
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
|
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
|
||||||
| `manager-interval` | `NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
| `manager-interval` | `NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
||||||
|
| `message-poll-limit` | `NTFY_MESSAGE_POLL_LIMIT` | *number* | *(uncapped)* | Max number of cached messages returned per topic when the cache is replayed (a poll, or a `since` request). A poll without a `since` cursor returns a topic's entire cache, which is unbounded in size. When set, the newest messages are kept and the response carries an `X-Messages-Truncated: 1` header. Together with `message-size-limit` this bounds the memory one replay can allocate. |
|
||||||
| `message-size-limit` | `NTFY_MESSAGE_SIZE_LIMIT` | *size* | 4K | The size limit for the message body. Please note that this is largely untested, and that FCM/APNS have limits around 4KB. If you increase this size limit, FCM and APNS will NOT work for large messages. |
|
| `message-size-limit` | `NTFY_MESSAGE_SIZE_LIMIT` | *size* | 4K | The size limit for the message body. Please note that this is largely untested, and that FCM/APNS have limits around 4KB. If you increase this size limit, FCM and APNS will NOT work for large messages. |
|
||||||
| `message-delay-limit` | `NTFY_MESSAGE_DELAY_LIMIT` | *duration* | 3d | Amount of time a message can be [scheduled](publish.md#scheduled-delivery) into the future when using the `Delay` header |
|
| `message-delay-limit` | `NTFY_MESSAGE_DELAY_LIMIT` | *duration* | 3d | Amount of time a message can be [scheduled](publish.md#scheduled-delivery) into the future when using the `Delay` header |
|
||||||
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
|
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
|
||||||
|
|||||||
+1
-1
@@ -4931,7 +4931,7 @@ but just in case, let's list them all:
|
|||||||
| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. |
|
| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. |
|
||||||
| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 2 MB, and the per-visitor total is 20 MB. |
|
| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 2 MB, and the per-visitor total is 20 MB. |
|
||||||
| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
|
| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
|
||||||
| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. |
|
| **Daily bandwidth** | By default, the server allows 500 MB of traffic per visitor in a 24 hour period, covering attachment GET/PUT/POST traffic and messages replayed from the cache by [poll requests](subscribe/api.md#replay-limits). Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. |
|
||||||
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
|
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
|
||||||
|
|
||||||
These limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing
|
These limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing
|
||||||
|
|||||||
@@ -2082,8 +2082,13 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
|
* Add `message-poll-limit`, an optional cap on the number of cached messages returned by a single replay (uncapped by default, so existing behavior is unchanged). A poll without a `since` cursor returns a topic's entire cache, which was previously unbounded and could reach tens of megabytes on a busy topic; when the cap is set, the newest messages are kept and a truncated response carries an `X-Messages-Truncated: 1` header. Combined with `message-size-limit` this bounds the memory a single replay can allocate
|
||||||
* `visitor-attachment-daily-bandwidth-limit` now also covers messages replayed from the message cache by poll requests, not just attachment traffic. A poll without a `since` cursor returns a topic's entire cache, so a topic that is cheap to fill can be re-read for many times its own size; polls beyond the budget are rejected with HTTP 429 (error code 42905) before anything is written. **Note that heavy pollers now consume the same budget as attachment downloads**, so operators serving both may want to raise the limit
|
* `visitor-attachment-daily-bandwidth-limit` now also covers messages replayed from the message cache by poll requests, not just attachment traffic. A poll without a `since` cursor returns a topic's entire cache, so a topic that is cheap to fill can be re-read for many times its own size; polls beyond the budget are rejected with HTTP 429 (error code 42905) before anything is written. **Note that heavy pollers now consume the same budget as attachment downloads**, so operators serving both may want to raise the limit
|
||||||
|
|
||||||
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
|
* Fix messages being returned out of publish order when polling or replaying **several topics at once** (`/topic1,topic2/json?poll=1`). `Message.Time` has second granularity, so a multi-topic replay sorts many equal keys; the sort was unstable, which could shuffle a single topic's own messages. Single-topic replays were not affected ([#1297](https://github.com/binwiederhier/ntfy/issues/1297))
|
||||||
|
|
||||||
### ntfy iOS app v1.8.0 (UNRELEASED)
|
### ntfy iOS app v1.8.0 (UNRELEASED)
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|||||||
+31
-1
@@ -245,6 +245,9 @@ combined with `since=` (defaults to `since=all`).
|
|||||||
curl -s "ntfy.sh/mytopic/json?poll=1"
|
curl -s "ntfy.sh/mytopic/json?poll=1"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note that a poll without `since=` returns a topic's **entire cache**, which on a busy topic can be
|
||||||
|
large. See [replay limits](#replay-limits) below.
|
||||||
|
|
||||||
### Fetch cached messages
|
### Fetch cached messages
|
||||||
Messages may be cached for a couple of hours (see [message caching](../config.md#message-cache)) to account for network
|
Messages may be cached for a couple of hours (see [message caching](../config.md#message-cache)) to account for network
|
||||||
interruptions of subscribers. If the server has configured message caching, you can read back what you missed by using
|
interruptions of subscribers. If the server has configured message caching, you can read back what you missed by using
|
||||||
@@ -275,6 +278,28 @@ parameter (makes most sense with the `poll=1` parameter):
|
|||||||
curl -s "ntfy.sh/mytopic/json?poll=1&sched=1"
|
curl -s "ntfy.sh/mytopic/json?poll=1&sched=1"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Replay limits
|
||||||
|
Reading cached messages (a `poll=1` request, or any request with `since=`) replays messages the server
|
||||||
|
already stored, so unlike a live subscription its cost grows with the size of the topic's cache. Two
|
||||||
|
server-side limits apply, both of which a well-behaved client should handle:
|
||||||
|
|
||||||
|
* **The number of messages may be capped.** If the server sets `message-poll-limit`, a replay returns
|
||||||
|
at most that many messages **per topic**, keeping the newest ones, and the response carries an
|
||||||
|
`X-Messages-Truncated: 1` header. If you see that header, older messages were dropped: you did not
|
||||||
|
receive the full cache. It is uncapped by default; ntfy.sh caps it.
|
||||||
|
* **Replayed bytes count against your daily bandwidth budget**, the same one attachment downloads use
|
||||||
|
(see [limitations](../publish.md#limitations)). Exceeding it returns `HTTP 429` with ntfy error code
|
||||||
|
`42905`, and no messages are written.
|
||||||
|
|
||||||
|
Both limits exist because a poll without `since=` re-reads the whole cache every time. If you are
|
||||||
|
polling repeatedly, **pass `since=<last message ID>`** rather than re-fetching everything. The limits
|
||||||
|
still apply to a `since=` replay, but it returns only what is new, so in practice you will not come
|
||||||
|
near either one:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -s "ntfy.sh/mytopic/json?poll=1&since=nFS3knfcQ1xe"
|
||||||
|
```
|
||||||
|
|
||||||
### Filter messages
|
### Filter messages
|
||||||
You can filter which messages are returned based on the well-known message fields `id`, `message`, `title`, `priority` and
|
You can filter which messages are returned based on the well-known message fields `id`, `message`, `title`, `priority` and
|
||||||
`tags`. Here's an example that only returns messages of high or urgent priority that contains the both tags
|
`tags`. Here's an example that only returns messages of high or urgent priority that contains the both tags
|
||||||
@@ -308,6 +333,11 @@ $ curl -s ntfy.sh/mytopic1,mytopic2/json
|
|||||||
{"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"}
|
{"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When replaying cached messages for several topics at once, they are ordered by their `time` field.
|
||||||
|
Because `time` has **second granularity**, messages published within the same second share a sort key:
|
||||||
|
each topic's own messages stay in publish order, but the interleaving *between* topics is not defined.
|
||||||
|
If you need a total order across topics, sort by `time` and fall back to the order received.
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
Depending on whether the server is configured to support [access control](../config.md#access-control), some topics
|
Depending on whether the server is configured to support [access control](../config.md#access-control), some topics
|
||||||
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
|
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
|
||||||
@@ -427,7 +457,7 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a
|
|||||||
|
|
||||||
| Parameter | Aliases (case-insensitive) | Description |
|
| Parameter | Aliases (case-insensitive) | Description |
|
||||||
|-------------|----------------------------|---------------------------------------------------------------------------------|
|
|-------------|----------------------------|---------------------------------------------------------------------------------|
|
||||||
| `poll` | `X-Poll`, `po` | Return cached messages and close connection |
|
| `poll` | `X-Poll`, `po` | Return cached messages and close connection (see [replay limits](#replay-limits)) |
|
||||||
| `since` | `X-Since`, `si` | Return cached messages since timestamp, duration or message ID |
|
| `since` | `X-Since`, `si` | Return cached messages since timestamp, duration or message ID |
|
||||||
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
|
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
|
||||||
| `id` | `X-ID` | Filter: Only return messages that match this exact message ID |
|
| `id` | `X-ID` | Filter: Only return messages that match this exact message ID |
|
||||||
|
|||||||
+46
-15
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,6 +19,10 @@ import (
|
|||||||
const (
|
const (
|
||||||
tagMessageCache = "message_cache"
|
tagMessageCache = "message_cache"
|
||||||
schemaStore = "message" // Store name in the schema_version table (see db/schema)
|
schemaStore = "message" // Store name in the schema_version table (see db/schema)
|
||||||
|
|
||||||
|
// NoLimit is the message limit for an uncapped read: large enough never to bind in practice,
|
||||||
|
// small enough that limit+1 stays well inside int32 for the SQL driver.
|
||||||
|
NoLimit = 1 << 30
|
||||||
)
|
)
|
||||||
|
|
||||||
var errNoRows = errors.New("no rows found")
|
var errNoRows = errors.New("no rows found")
|
||||||
@@ -186,46 +191,56 @@ func (c *Cache) addMessages(ms []*model.Message) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Messages returns messages for a topic since the given marker, optionally including scheduled messages
|
// Messages returns all cached messages for a topic, oldest first. Prefer MessagesCapped on
|
||||||
|
// request paths: an uncapped replay of a busy topic is as large as the topic's entire cache.
|
||||||
func (c *Cache) Messages(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) {
|
func (c *Cache) Messages(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) {
|
||||||
|
messages, _, err := c.MessagesCapped(topic, since, scheduled, NoLimit)
|
||||||
|
return messages, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessagesCapped returns cached messages for a topic, oldest first, at most limit of them,
|
||||||
|
// keeping the newest. The bool reports whether older messages were dropped to stay under the
|
||||||
|
// cap, so the caller can tell the client that what it got is incomplete.
|
||||||
|
func (c *Cache) MessagesCapped(topic string, since model.SinceMarker, scheduled bool, limit int) ([]*model.Message, bool, error) {
|
||||||
if since.IsNone() {
|
if since.IsNone() {
|
||||||
return make([]*model.Message, 0), nil
|
return make([]*model.Message, 0), false, nil
|
||||||
} else if since.IsLatest() {
|
} else if since.IsLatest() {
|
||||||
return c.messagesLatest(topic)
|
messages, err := c.messagesLatest(topic)
|
||||||
|
return messages, false, err
|
||||||
} else if since.IsID() {
|
} else if since.IsID() {
|
||||||
return c.messagesSinceID(topic, since, scheduled)
|
return c.messagesSinceID(topic, since, scheduled, limit)
|
||||||
}
|
}
|
||||||
return c.messagesSinceTime(topic, since, scheduled)
|
return c.messagesSinceTime(topic, since, scheduled, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cache) messagesSinceTime(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) {
|
func (c *Cache) messagesSinceTime(topic string, since model.SinceMarker, scheduled bool, limit int) ([]*model.Message, bool, error) {
|
||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
var err error
|
var err error
|
||||||
rdb := c.db.ReadOnly()
|
rdb := c.db.ReadOnly()
|
||||||
if scheduled {
|
if scheduled {
|
||||||
rows, err = rdb.Query(c.queries.selectMessagesSinceTimeScheduled, topic, since.Time().Unix())
|
rows, err = rdb.Query(c.queries.selectMessagesSinceTimeScheduled, topic, since.Time().Unix(), limit+1)
|
||||||
} else {
|
} else {
|
||||||
rows, err = rdb.Query(c.queries.selectMessagesSinceTime, topic, since.Time().Unix())
|
rows, err = rdb.Query(c.queries.selectMessagesSinceTime, topic, since.Time().Unix(), limit+1)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
return readMessages(rows)
|
return readMessagesCapped(rows, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cache) messagesSinceID(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) {
|
func (c *Cache) messagesSinceID(topic string, since model.SinceMarker, scheduled bool, limit int) ([]*model.Message, bool, error) {
|
||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
var err error
|
var err error
|
||||||
rdb := c.db.ReadOnly()
|
rdb := c.db.ReadOnly()
|
||||||
if scheduled {
|
if scheduled {
|
||||||
rows, err = rdb.Query(c.queries.selectMessagesSinceIDScheduled, topic, since.ID())
|
rows, err = rdb.Query(c.queries.selectMessagesSinceIDScheduled, topic, since.ID(), limit+1)
|
||||||
} else {
|
} else {
|
||||||
rows, err = rdb.Query(c.queries.selectMessagesSinceID, topic, since.ID())
|
rows, err = rdb.Query(c.queries.selectMessagesSinceID, topic, since.ID(), limit+1)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
return readMessages(rows)
|
return readMessagesCapped(rows, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cache) messagesLatest(topic string) ([]*model.Message, error) {
|
func (c *Cache) messagesLatest(topic string) ([]*model.Message, error) {
|
||||||
@@ -458,6 +473,22 @@ func (c *Cache) processMessageBatches() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readMessagesCapped reads a newest-first result set that was queried with LIMIT limit+1. The
|
||||||
|
// extra row is how overflow is detected without a second COUNT query: if it is there, older
|
||||||
|
// messages exist beyond the cap. Rows are reversed into the oldest-first order callers expect.
|
||||||
|
func readMessagesCapped(rows *sql.Rows, limit int) ([]*model.Message, bool, error) {
|
||||||
|
messages, err := readMessages(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
truncated := len(messages) > limit
|
||||||
|
if truncated {
|
||||||
|
messages = messages[:limit]
|
||||||
|
}
|
||||||
|
slices.Reverse(messages)
|
||||||
|
return messages, truncated, nil
|
||||||
|
}
|
||||||
|
|
||||||
func readMessages(rows *sql.Rows) ([]*model.Message, error) {
|
func readMessages(rows *sql.Rows) ([]*model.Message, error) {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
messages := make([]*model.Message, 0)
|
messages := make([]*model.Message, 0)
|
||||||
|
|||||||
@@ -25,13 +25,15 @@ const (
|
|||||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
|
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
|
||||||
FROM message
|
FROM message
|
||||||
WHERE topic = $1 AND time >= $2 AND published = TRUE
|
WHERE topic = $1 AND time >= $2 AND published = TRUE
|
||||||
ORDER BY time, id
|
ORDER BY time DESC, id DESC
|
||||||
|
LIMIT $3
|
||||||
`
|
`
|
||||||
postgresSelectMessagesSinceTimeIncludeScheduledQuery = `
|
postgresSelectMessagesSinceTimeIncludeScheduledQuery = `
|
||||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
|
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
|
||||||
FROM message
|
FROM message
|
||||||
WHERE topic = $1 AND time >= $2
|
WHERE topic = $1 AND time >= $2
|
||||||
ORDER BY time, id
|
ORDER BY time DESC, id DESC
|
||||||
|
LIMIT $3
|
||||||
`
|
`
|
||||||
postgresSelectMessagesSinceIDQuery = `
|
postgresSelectMessagesSinceIDQuery = `
|
||||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
|
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
|
||||||
@@ -39,14 +41,16 @@ const (
|
|||||||
WHERE topic = $1
|
WHERE topic = $1
|
||||||
AND id > COALESCE((SELECT id FROM message WHERE mid = $2), 0)
|
AND id > COALESCE((SELECT id FROM message WHERE mid = $2), 0)
|
||||||
AND published = TRUE
|
AND published = TRUE
|
||||||
ORDER BY time, id
|
ORDER BY time DESC, id DESC
|
||||||
|
LIMIT $3
|
||||||
`
|
`
|
||||||
postgresSelectMessagesSinceIDIncludeScheduledQuery = `
|
postgresSelectMessagesSinceIDIncludeScheduledQuery = `
|
||||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
|
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
|
||||||
FROM message
|
FROM message
|
||||||
WHERE topic = $1
|
WHERE topic = $1
|
||||||
AND (id > COALESCE((SELECT id FROM message WHERE mid = $2), 0) OR published = FALSE)
|
AND (id > COALESCE((SELECT id FROM message WHERE mid = $2), 0) OR published = FALSE)
|
||||||
ORDER BY time, id
|
ORDER BY time DESC, id DESC
|
||||||
|
LIMIT $3
|
||||||
`
|
`
|
||||||
postgresSelectMessagesLatestQuery = `
|
postgresSelectMessagesLatestQuery = `
|
||||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
|
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
|
||||||
|
|||||||
@@ -31,25 +31,29 @@ const (
|
|||||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ? AND published = 1
|
WHERE topic = ? AND time >= ? AND published = 1
|
||||||
ORDER BY time, id
|
ORDER BY time DESC, id DESC
|
||||||
|
LIMIT ?
|
||||||
`
|
`
|
||||||
sqliteSelectMessagesSinceTimeIncludeScheduledQuery = `
|
sqliteSelectMessagesSinceTimeIncludeScheduledQuery = `
|
||||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ?
|
WHERE topic = ? AND time >= ?
|
||||||
ORDER BY time, id
|
ORDER BY time DESC, id DESC
|
||||||
|
LIMIT ?
|
||||||
`
|
`
|
||||||
sqliteSelectMessagesSinceIDQuery = `
|
sqliteSelectMessagesSinceIDQuery = `
|
||||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND id > COALESCE((SELECT id FROM messages WHERE mid = ?), 0) AND published = 1
|
WHERE topic = ? AND id > COALESCE((SELECT id FROM messages WHERE mid = ?), 0) AND published = 1
|
||||||
ORDER BY time, id
|
ORDER BY time DESC, id DESC
|
||||||
|
LIMIT ?
|
||||||
`
|
`
|
||||||
sqliteSelectMessagesSinceIDIncludeScheduledQuery = `
|
sqliteSelectMessagesSinceIDIncludeScheduledQuery = `
|
||||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND (id > COALESCE((SELECT id FROM messages WHERE mid = ?), 0) OR published = 0)
|
WHERE topic = ? AND (id > COALESCE((SELECT id FROM messages WHERE mid = ?), 0) OR published = 0)
|
||||||
ORDER BY time, id
|
ORDER BY time DESC, id DESC
|
||||||
|
LIMIT ?
|
||||||
`
|
`
|
||||||
sqliteSelectMessagesLatestQuery = `
|
sqliteSelectMessagesLatestQuery = `
|
||||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"heckel.io/ntfy/v2/message"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -67,6 +68,7 @@ func banWeight(err *errHTTP, weight int) string {
|
|||||||
// - various attachment limits
|
// - various attachment limits
|
||||||
const (
|
const (
|
||||||
DefaultMessageSizeLimit = 4096 // Bytes; note that FCM/APNS have a limit of ~4 KB for the entire message
|
DefaultMessageSizeLimit = 4096 // Bytes; note that FCM/APNS have a limit of ~4 KB for the entire message
|
||||||
|
DefaultMessagePollLimit = message.NoLimit // Uncapped by default; operators cap it to bound replay memory (limit x MessageSizeLimit)
|
||||||
DefaultTotalTopicLimit = 15000
|
DefaultTotalTopicLimit = 15000
|
||||||
DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB
|
DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB
|
||||||
DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB
|
DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB
|
||||||
@@ -174,6 +176,7 @@ type Config struct {
|
|||||||
MessageDelayMin time.Duration
|
MessageDelayMin time.Duration
|
||||||
MessageDelayMax time.Duration
|
MessageDelayMax time.Duration
|
||||||
MessageSizeLimit int
|
MessageSizeLimit int
|
||||||
|
MessagePollLimit int
|
||||||
TotalTopicLimit int
|
TotalTopicLimit int
|
||||||
TotalAttachmentSizeLimit int64
|
TotalAttachmentSizeLimit int64
|
||||||
VisitorSubscriptionLimit int
|
VisitorSubscriptionLimit int
|
||||||
@@ -282,6 +285,7 @@ func NewConfig() *Config {
|
|||||||
TwilioVerifyService: "",
|
TwilioVerifyService: "",
|
||||||
TwilioCallFormat: nil,
|
TwilioCallFormat: nil,
|
||||||
MessageSizeLimit: DefaultMessageSizeLimit,
|
MessageSizeLimit: DefaultMessageSizeLimit,
|
||||||
|
MessagePollLimit: DefaultMessagePollLimit,
|
||||||
MessageDelayMin: DefaultMessageDelayMin,
|
MessageDelayMin: DefaultMessageDelayMin,
|
||||||
MessageDelayMax: DefaultMessageDelayMax,
|
MessageDelayMax: DefaultMessageDelayMax,
|
||||||
TotalTopicLimit: DefaultTotalTopicLimit,
|
TotalTopicLimit: DefaultTotalTopicLimit,
|
||||||
|
|||||||
+15
-7
@@ -1474,7 +1474,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
|||||||
t.Keepalive()
|
t.Keepalive()
|
||||||
}
|
}
|
||||||
meterPollBandwidth = true
|
meterPollBandwidth = true
|
||||||
return s.sendOldMessages(topics, since, scheduled, v, sub)
|
return s.sendOldMessages(w, topics, since, scheduled, v, sub)
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -1490,7 +1490,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
|||||||
if err := sub(v, model.NewOpenMessage(topicsStr)); err != nil { // Send out open message
|
if err := sub(v, model.NewOpenMessage(topicsStr)); err != nil { // Send out open message
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil {
|
if err := s.sendOldMessages(w, topics, since, scheduled, v, sub); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
@@ -1625,7 +1625,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
|||||||
for _, t := range topics {
|
for _, t := range topics {
|
||||||
t.Keepalive()
|
t.Keepalive()
|
||||||
}
|
}
|
||||||
return s.sendOldMessages(topics, since, scheduled, v, sub)
|
return s.sendOldMessages(w, topics, since, scheduled, v, sub)
|
||||||
}
|
}
|
||||||
subscriberIDs := make([]int, 0)
|
subscriberIDs := make([]int, 0)
|
||||||
for _, t := range topics {
|
for _, t := range topics {
|
||||||
@@ -1639,7 +1639,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
|||||||
if err := sub(v, model.NewOpenMessage(topicsStr)); err != nil { // Send out open message
|
if err := sub(v, model.NewOpenMessage(topicsStr)); err != nil { // Send out open message
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil {
|
if err := s.sendOldMessages(w, topics, since, scheduled, v, sub); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = g.Wait()
|
err = g.Wait()
|
||||||
@@ -1734,21 +1734,29 @@ func (s *Server) setRateVisitors(r *http.Request, v *visitor, rateTopics []*topi
|
|||||||
|
|
||||||
// sendOldMessages selects old messages from the messageCache and calls sub for each of them. It uses since as the
|
// sendOldMessages selects old messages from the messageCache and calls sub for each of them. It uses since as the
|
||||||
// marker, returning only messages that are newer than the marker.
|
// marker, returning only messages that are newer than the marker.
|
||||||
func (s *Server) sendOldMessages(topics []*topic, since model.SinceMarker, scheduled bool, v *visitor, sub subscriber) error {
|
func (s *Server) sendOldMessages(w http.ResponseWriter, topics []*topic, since model.SinceMarker, scheduled bool, v *visitor, sub subscriber) error {
|
||||||
if since.IsNone() {
|
if since.IsNone() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
messages := make([]*model.Message, 0)
|
messages := make([]*model.Message, 0)
|
||||||
|
truncated := false
|
||||||
for _, t := range topics {
|
for _, t := range topics {
|
||||||
topicMessages, err := s.messageCache.Messages(t.ID, since, scheduled)
|
topicMessages, topicTruncated, err := s.messageCache.MessagesCapped(t.ID, since, scheduled, s.config.MessagePollLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
truncated = truncated || topicTruncated
|
||||||
messages = append(messages, topicMessages...)
|
messages = append(messages, topicMessages...)
|
||||||
}
|
}
|
||||||
sort.Slice(messages, func(i, j int) bool {
|
// Stable: Time has second granularity, so a multi-topic replay has many equal keys. An unstable
|
||||||
|
// sort reorders them and a topic's own messages come back out of publish order (#1297).
|
||||||
|
sort.SliceStable(messages, func(i, j int) bool {
|
||||||
return messages[i].Time < messages[j].Time
|
return messages[i].Time < messages[j].Time
|
||||||
})
|
})
|
||||||
|
// Must be set before the first message is written, or the header is already on the wire
|
||||||
|
if truncated && w != nil {
|
||||||
|
w.Header().Set("X-Messages-Truncated", "1")
|
||||||
|
}
|
||||||
for _, m := range messages {
|
for _, m := range messages {
|
||||||
if err := sub(v, m); err != nil {
|
if err := sub(v, m); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -327,6 +327,15 @@
|
|||||||
# - message-delay-limit defines the max delay of a message when using the "Delay" header.
|
# - message-delay-limit defines the max delay of a message when using the "Delay" header.
|
||||||
#
|
#
|
||||||
# message-size-limit: "4k"
|
# message-size-limit: "4k"
|
||||||
|
|
||||||
|
# Max number of cached messages returned per topic when the cache is replayed, i.e. a poll or a
|
||||||
|
# "since" request. A poll without a "since" cursor returns a topic's entire cache, which is
|
||||||
|
# unbounded in size. Uncapped by default, to preserve the historical behavior; set it to bound
|
||||||
|
# how large a single replay can get. The newest messages are kept, and a truncated response
|
||||||
|
# carries an "X-Messages-Truncated: 1" header. Together with message-size-limit this bounds the
|
||||||
|
# memory one replay can allocate: limit x message-size-limit (e.g. 5000 x 4096 = ~20 MB).
|
||||||
|
#
|
||||||
|
# message-poll-limit: 5000
|
||||||
# message-delay-limit: "3d"
|
# message-delay-limit: "3d"
|
||||||
|
|
||||||
# Rate limiting: Total number of topics before the server rejects new topics.
|
# Rate limiting: Total number of topics before the server rejects new topics.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -2810,6 +2811,77 @@ func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_PollOrderAcrossTopics(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
// Replaying several topics at once concatenates each topic's messages and then sorts the
|
||||||
|
// lot by Time, which has second granularity. That sort must not reorder messages that
|
||||||
|
// share a timestamp, or a topic's own messages come back out of publish order. See #1297.
|
||||||
|
//
|
||||||
|
// The messages have to straddle a second boundary: if every timestamp is identical the
|
||||||
|
// concatenation is already sorted and Go's pdqsort leaves it alone, hiding the bug.
|
||||||
|
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||||
|
|
||||||
|
const perBatch = 10
|
||||||
|
publish := func(batch int) {
|
||||||
|
for _, topic := range []string{"topicA", "topicB"} {
|
||||||
|
for i := 0; i < perBatch; i++ {
|
||||||
|
body := fmt.Sprintf("%s-%02d", topic, batch*perBatch+i)
|
||||||
|
require.Equal(t, 200, request(t, s, "PUT", "/"+topic, body, nil).Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publish(0)
|
||||||
|
time.Sleep(1100 * time.Millisecond) // Cross a second boundary, so Time is not all-equal
|
||||||
|
publish(1)
|
||||||
|
|
||||||
|
response := request(t, s, "GET", "/topicA,topicB/json?poll=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
messages := toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 4*perBatch, len(messages))
|
||||||
|
|
||||||
|
// Each topic's own messages must appear in publish order, whatever the interleaving
|
||||||
|
lastSeen := map[string]int{"topicA": -1, "topicB": -1}
|
||||||
|
for _, m := range messages {
|
||||||
|
topic, seqStr, found := strings.Cut(m.Message, "-")
|
||||||
|
require.True(t, found)
|
||||||
|
seq, err := strconv.Atoi(seqStr)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Greater(t, seq, lastSeen[topic], "%s came back out of publish order", m.Message)
|
||||||
|
lastSeen[topic] = seq
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PollMessageLimit(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
// A poll without "since" replays the whole cache, so the response is unbounded unless the
|
||||||
|
// query is capped. The cap keeps the NEWEST messages and advertises the truncation, since
|
||||||
|
// "since" only moves forward and a client cannot fetch what was dropped.
|
||||||
|
c := newTestConfig(t, databaseURL)
|
||||||
|
c.MessagePollLimit = 5
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
for i := 1; i <= 8; i++ {
|
||||||
|
require.Equal(t, 200, request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil).Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Equal(t, "1", response.Header().Get("X-Messages-Truncated"))
|
||||||
|
messages := toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 5, len(messages))
|
||||||
|
require.Equal(t, "message 4", messages[0].Message) // newest 5, oldest first
|
||||||
|
require.Equal(t, "message 8", messages[4].Message)
|
||||||
|
|
||||||
|
// A topic under the limit is served in full, with no truncation header
|
||||||
|
require.Equal(t, 200, request(t, s, "PUT", "/othertopic", "only message", nil).Code)
|
||||||
|
response = request(t, s, "GET", "/othertopic/json?poll=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Empty(t, response.Header().Get("X-Messages-Truncated"))
|
||||||
|
require.Equal(t, 1, len(toMessages(t, response.Body.String())))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PollBandwidthLimit(t *testing.T) {
|
func TestServer_PollBandwidthLimit(t *testing.T) {
|
||||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
// A poll without "since" replays the entire cache, so a topic that is cheap to fill is
|
// A poll without "since" replays the entire cache, so a topic that is cheap to fill is
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
go1.26.5
|
go1.27.0
|
||||||
|
|||||||
@@ -167,11 +167,13 @@ func (t *Template) Delims(left, right string) *Template {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Funcs adds the elements of the argument map to the template's function map.
|
// Funcs adds the elements of the argument map to the template's function map.
|
||||||
// It must be called before the template is parsed.
|
// Any function used in the template must be added before the template is
|
||||||
|
// parsed. Funcs may be called more than once, including after parsing (for
|
||||||
|
// example, after [Template.Clone]), to replace a function of the same name;
|
||||||
|
// the replacement is used when the template is executed.
|
||||||
// It panics if a value in the map is not a function with appropriate return
|
// It panics if a value in the map is not a function with appropriate return
|
||||||
// type or if the name cannot be used syntactically as a function in a template.
|
// type or if the name cannot be used syntactically as a function in a template.
|
||||||
// It is legal to overwrite elements of the map. The return value is the template,
|
// The return value is the template, so calls can be chained.
|
||||||
// so calls can be chained.
|
|
||||||
func (t *Template) Funcs(funcMap FuncMap) *Template {
|
func (t *Template) Funcs(funcMap FuncMap) *Template {
|
||||||
t.init()
|
t.init()
|
||||||
t.muFuncs.Lock()
|
t.muFuncs.Lock()
|
||||||
|
|||||||
Reference in New Issue
Block a user