mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-11 06:15:21 +00:00
134 lines
4.4 KiB
Go
134 lines
4.4 KiB
Go
package cluster
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
dbtest "heckel.io/ntfy/v2/db/test"
|
|
"heckel.io/ntfy/v2/model"
|
|
)
|
|
|
|
// TestMesh_Soak floods the mesh with concurrent publishers and asserts exact delivery: every
|
|
// message reaches the peer exactly once, nothing is dropped, and batching keeps the request
|
|
// count far below the message count. Skipped unless NTFY_TEST_SOAK is set (it takes a few
|
|
// seconds and is meant for pre-deploy verification, not the regular suite).
|
|
func TestMesh_Soak(t *testing.T) {
|
|
if os.Getenv("NTFY_TEST_SOAK") == "" {
|
|
t.Skip("NTFY_TEST_SOAK not set")
|
|
}
|
|
// ~1000 msg/s aggregate (10x the ntfy.sh peak of ~88 msg/s): each publisher paces itself to
|
|
// 100 msg/s. Unthrottled publishing intentionally overruns the bounded per-peer queue (load
|
|
// shedding by design), so a zero-drop assertion only holds below the drain ceiling.
|
|
const (
|
|
publishers = 10
|
|
messagesPerPublisher = 300
|
|
publishInterval = 10 * time.Millisecond
|
|
total = publishers * messagesPerPublisher
|
|
)
|
|
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
|
pool := openTestPool(t, schemaDSN)
|
|
var mu sync.Mutex
|
|
received := make(map[string]int, total) // message body -> count, to catch duplicates
|
|
requests := 0
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
require.Nil(t, err)
|
|
messages, err := unmarshalMessageBody(body, 1<<20)
|
|
require.Nil(t, err)
|
|
mu.Lock()
|
|
requests++
|
|
for _, m := range messages {
|
|
received[m.Message]++
|
|
}
|
|
mu.Unlock()
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
conf := newTestMeshConfig("node-a", "http://127.0.0.1:1")
|
|
conf.BatchLinger = 50 * time.Millisecond
|
|
conf.NodeTTL = time.Minute // The fake peer never heartbeats; liveness is not under test here
|
|
registerFakePeer(t, pool, "node-peer", srv.URL)
|
|
mesh, err := newMeshCluster(conf, pool, nil, nil)
|
|
require.Nil(t, err)
|
|
defer mesh.Close()
|
|
start := time.Now()
|
|
var wg sync.WaitGroup
|
|
for p := 0; p < publishers; p++ {
|
|
wg.Add(1)
|
|
go func(p int) {
|
|
defer wg.Done()
|
|
ticker := time.NewTicker(publishInterval)
|
|
defer ticker.Stop()
|
|
for i := 0; i < messagesPerPublisher; i++ {
|
|
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", fmt.Sprintf("p%d-m%d", p, i))))
|
|
<-ticker.C
|
|
}
|
|
}(p)
|
|
}
|
|
wg.Wait()
|
|
waitFor(t, func() bool {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return len(received) == total
|
|
})
|
|
elapsed := time.Since(start)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
for body, count := range received {
|
|
require.Equalf(t, 1, count, "message %s delivered %d times", body, count)
|
|
}
|
|
require.Less(t, requests, total/10, "expected strong batching under load")
|
|
t.Logf("soak: %d messages, %d requests (%.1f msgs/request), %.0f msgs/s",
|
|
total, requests, float64(total)/float64(requests), float64(total)/elapsed.Seconds())
|
|
}
|
|
|
|
// BenchmarkForwardMessage measures the publish-path cost of ForwardMessage: marshal + peer lookup (cached)
|
|
// + enqueue. The peer never drains, so enqueued fragments are dropped once the queue fills;
|
|
// the benchmark measures the hot path, not HTTP delivery.
|
|
func BenchmarkForwardMessage(b *testing.B) {
|
|
if os.Getenv("NTFY_TEST_DATABASE_URL") == "" {
|
|
b.Skip("NTFY_TEST_DATABASE_URL not set")
|
|
}
|
|
schemaDSN := dbtest.CreateTestPostgresSchema(b)
|
|
pool := openTestPool(b, schemaDSN)
|
|
conf := newTestMeshConfig("node-a", "http://127.0.0.1:1")
|
|
conf.BatchLinger = time.Minute // Never flush; we measure enqueue only
|
|
mesh, err := newMeshCluster(conf, pool, nil, nil)
|
|
require.Nil(b, err)
|
|
defer mesh.Close()
|
|
registerFakePeer(b, pool, "node-peer", "http://127.0.0.1:1")
|
|
m := model.NewDefaultMessage("mytopic", "benchmark message body of typical size for a push")
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
if err := mesh.ForwardMessage(m); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// BenchmarkDecodeFanout measures the receive-path cost of decoding a 100-message NDJSON body.
|
|
func BenchmarkDecodeFanout(b *testing.B) {
|
|
frags := make([][]byte, 100)
|
|
for i := range frags {
|
|
frag, err := marshalMessage(model.NewDefaultMessage("mytopic", fmt.Sprintf("benchmark message %d", i)))
|
|
require.Nil(b, err)
|
|
frags[i] = frag
|
|
}
|
|
body := assembleMessageBody(frags)
|
|
b.SetBytes(int64(len(body)))
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
messages, err := unmarshalMessageBody(body, 1<<20)
|
|
if err != nil || len(messages) != 100 {
|
|
b.Fatal("decode failed")
|
|
}
|
|
}
|
|
}
|