mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-16 16:37:55 +00:00
Refactor
This commit is contained in:
+10
-36
@@ -61,52 +61,26 @@ const (
|
|||||||
DefaultBatchLinger = 500 * time.Millisecond
|
DefaultBatchLinger = 500 * time.Millisecond
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config configures the cluster. It is assembled by the server from its own config, which keeps
|
|
||||||
// this package free of server types.
|
|
||||||
type Config struct {
|
|
||||||
Enabled bool // Master switch; when false, New returns the nop cluster
|
|
||||||
NodeID NodeID // Stable per-node identifier; required
|
|
||||||
AdvertiseURL string // Base URL peers use to reach this node's fan-out endpoint
|
|
||||||
Secret string // Shared secret authenticating node-to-node fan-out requests
|
|
||||||
HeartbeatInterval time.Duration // How often the node registry heartbeat is refreshed
|
|
||||||
NodeTTL time.Duration // Registry rows older than this do not count as live peers
|
|
||||||
BatchLinger time.Duration // How long messages wait in a peer queue to form a batch; 0 = send immediately
|
|
||||||
StateInterval time.Duration // How often the full subscription state is pushed to peers
|
|
||||||
MaxMessageBytes int64 // Upper bound for a single message on the wire (batch limits derive from this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeliverFunc hands a message received from a peer node to this node's local subscribers. The
|
|
||||||
// server supplies it, which inverts the dependency: this package never imports the server.
|
|
||||||
type DeliverFunc func(m *model.Message)
|
|
||||||
|
|
||||||
// TopicsFunc returns the topics that currently have at least one live subscriber, computed
|
|
||||||
// fresh on every call: membership is never tracked as a list, so topics "leave" simply by not
|
|
||||||
// appearing in the next snapshot. The server supplies it (same inversion as DeliverFunc).
|
|
||||||
type TopicsFunc func() []string
|
|
||||||
|
|
||||||
// Cluster fans published messages out to peer cluster nodes and receives their fan-out requests.
|
// Cluster fans published messages out to peer cluster nodes and receives their fan-out requests.
|
||||||
// Local delivery to a node's own subscribers still happens inline in the server; the cluster
|
// Local delivery to a node's own subscribers still happens inline in the server; the cluster
|
||||||
// only covers the cross-node hop.
|
// only covers the cross-node hop.
|
||||||
type Cluster interface {
|
type Cluster interface {
|
||||||
// The internal peer API (/v1/internal/*), served on the dedicated cluster listener. The
|
|
||||||
// nop cluster answers 404.
|
|
||||||
http.Handler
|
http.Handler
|
||||||
// Relay sends a locally published message on to the peer nodes that may have subscribers
|
// ForwardMessage sends a locally published message on to the peer nodes that may have subscribers
|
||||||
// for its topic (all of them, when subscription knowledge is missing or stale). It is
|
// for its topic (all of them, when subscription knowledge is missing or stale). It is
|
||||||
// fire-and-forget and must not block the caller's request path.
|
// fire-and-forget and must not block the caller's request path.
|
||||||
Relay(m *model.Message) error
|
ForwardMessage(m *model.Message) error
|
||||||
// AnnounceTopics tells peers that these topics just gained their first local subscriber,
|
// BroadcastState pushes a subscription-state delta to ALL peers (unlike ForwardMessage,
|
||||||
// closing the routing-knowledge window to ~one round trip. Nop in single-node mode.
|
// which routes), closing the routing-knowledge window to ~one round trip. Nop single-node.
|
||||||
AnnounceTopics(topics []string)
|
BroadcastState(state *State)
|
||||||
// IsLeader reports whether this node holds the cluster leader lock. Singleton background
|
// IsLeader reports whether this node holds the cluster leader lock. Singleton background
|
||||||
// jobs (e.g. the Firebase keepaliver) are gated on the leader.
|
// jobs (e.g. the Firebase keepaliver) are gated on the leader.
|
||||||
IsLeader() bool
|
IsLeader() bool
|
||||||
// TODO(T8): add Healthy() bool reflecting registration health (unhealthy when the last
|
// Healthy reports whether this node is fit to serve: its registry heartbeat is fresh
|
||||||
// successful Register is older than NodeTTL, i.e. when peers stop relaying to this node),
|
// enough (within NodeTTL) that peers still forward messages to it. Health checkers must
|
||||||
// so /v1/health can pull a DB-partitioned node out of DNS rotation. Needs a fail-open
|
// fail open (never pull ALL nodes): during a full database outage every node reports
|
||||||
// answer at the health checker first: during a full DB outage ALL nodes fail to register
|
// unhealthy while the mesh keeps delivering on stale peer caches.
|
||||||
// while the mesh keeps delivering on stale peer caches, and naively pulling every node
|
Healthy() bool
|
||||||
// would turn a control-plane blip into a total outage.
|
|
||||||
// Close stops the cluster and releases its resources.
|
// Close stops the cluster and releases its resources.
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func TestMesh_Soak(t *testing.T) {
|
|||||||
ticker := time.NewTicker(publishInterval)
|
ticker := time.NewTicker(publishInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for i := 0; i < messagesPerPublisher; i++ {
|
for i := 0; i < messagesPerPublisher; i++ {
|
||||||
require.Nil(t, mesh.Relay(model.NewDefaultMessage("mytopic", fmt.Sprintf("p%d-m%d", p, i))))
|
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", fmt.Sprintf("p%d-m%d", p, i))))
|
||||||
<-ticker.C
|
<-ticker.C
|
||||||
}
|
}
|
||||||
}(p)
|
}(p)
|
||||||
@@ -89,10 +89,10 @@ func TestMesh_Soak(t *testing.T) {
|
|||||||
total, requests, float64(total)/float64(requests), float64(total)/elapsed.Seconds())
|
total, requests, float64(total)/float64(requests), float64(total)/elapsed.Seconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
// BenchmarkRelay measures the publish-path cost of Relay: marshal + peer lookup (cached)
|
// 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;
|
// + enqueue. The peer never drains, so enqueued fragments are dropped once the queue fills;
|
||||||
// the benchmark measures the hot path, not HTTP delivery.
|
// the benchmark measures the hot path, not HTTP delivery.
|
||||||
func BenchmarkRelay(b *testing.B) {
|
func BenchmarkForwardMessage(b *testing.B) {
|
||||||
if os.Getenv("NTFY_TEST_DATABASE_URL") == "" {
|
if os.Getenv("NTFY_TEST_DATABASE_URL") == "" {
|
||||||
b.Skip("NTFY_TEST_DATABASE_URL not set")
|
b.Skip("NTFY_TEST_DATABASE_URL not set")
|
||||||
}
|
}
|
||||||
@@ -107,7 +107,7 @@ func BenchmarkRelay(b *testing.B) {
|
|||||||
m := model.NewDefaultMessage("mytopic", "benchmark message body of typical size for a push")
|
m := model.NewDefaultMessage("mytopic", "benchmark message body of typical size for a push")
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
if err := mesh.Relay(m); err != nil {
|
if err := mesh.ForwardMessage(m); err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+90
-42
@@ -37,22 +37,24 @@ const (
|
|||||||
// Each peer has its own bounded send queue and delivery worker, so a slow or wedged peer only
|
// Each peer has its own bounded send queue and delivery worker, so a slow or wedged peer only
|
||||||
// backs up (and eventually drops) its own queue and never delays delivery to healthy peers.
|
// backs up (and eventually drops) its own queue and never delays delivery to healthy peers.
|
||||||
type meshCluster struct {
|
type meshCluster struct {
|
||||||
conf *Config
|
conf *Config
|
||||||
deliver DeliverFunc
|
deliver DeliverFunc
|
||||||
topics TopicsFunc
|
topics TopicsFunc
|
||||||
registry *registry.Registry
|
registry *registry.Registry
|
||||||
leader *pg.Leader
|
leader *pg.Leader
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
mux *http.ServeMux // The internal peer API; Cluster is an http.Handler
|
mux *http.ServeMux // The internal peer API; Cluster is an http.Handler
|
||||||
queues map[NodeID]*peerQueue // per-peer send queues; reconciled against the registry
|
queues map[NodeID]*peerQueue // per-peer send queues; reconciled against the registry
|
||||||
closed bool // Guards against Relay spawning new workers after Close
|
closed bool // Guards against ForwardMessage spawning new workers after Close
|
||||||
states map[NodeID]*peerState // what each peer last told us (subscription knowledge)
|
states map[NodeID]*peerState // what each peer last told us (subscription knowledge)
|
||||||
lastStatePush time.Time // Only touched by the heartbeat goroutine
|
lastStatePush time.Time // Only touched by the heartbeat goroutine
|
||||||
ctx context.Context
|
knownPeers map[NodeID]string // Peers seen in the last reconcile, for join/leave logging
|
||||||
cancel context.CancelFunc
|
lastRegistered time.Time // Last successful registry heartbeat, for Healthy
|
||||||
wg sync.WaitGroup
|
ctx context.Context
|
||||||
mu sync.Mutex // Protects queues and closed
|
cancel context.CancelFunc
|
||||||
statesMu sync.Mutex // Protects states
|
wg sync.WaitGroup
|
||||||
|
mu sync.Mutex // Protects queues, closed, knownPeers and lastRegistered
|
||||||
|
statesMu sync.Mutex // Protects states
|
||||||
}
|
}
|
||||||
|
|
||||||
// newMeshCluster creates the mesh cluster: it sets up the registry schema, registers this node
|
// newMeshCluster creates the mesh cluster: it sets up the registry schema, registers this node
|
||||||
@@ -73,16 +75,19 @@ func newMeshCluster(conf *Config, pool *db.DB, deliver DeliverFunc, topics Topic
|
|||||||
}
|
}
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
c := &meshCluster{
|
c := &meshCluster{
|
||||||
conf: conf,
|
conf: conf,
|
||||||
deliver: deliver,
|
deliver: deliver,
|
||||||
topics: topics,
|
topics: topics,
|
||||||
registry: reg,
|
registry: reg,
|
||||||
leader: pg.NewLeader(pool.Primary(), pg.LeaderLockKey),
|
// Renews its lease on its own fixed cadence; see pg.Leader for the semantics
|
||||||
httpClient: &http.Client{Timeout: meshHTTPTimeout},
|
leader: pg.NewLeader(pool.Primary(), pg.LeaderLockKey, conf.LeaderRenewInterval),
|
||||||
queues: make(map[NodeID]*peerQueue),
|
httpClient: &http.Client{Timeout: meshHTTPTimeout},
|
||||||
states: make(map[NodeID]*peerState),
|
queues: make(map[NodeID]*peerQueue),
|
||||||
ctx: ctx,
|
lastRegistered: time.Now(), // The synchronous Register above just succeeded
|
||||||
cancel: cancel,
|
states: make(map[NodeID]*peerState),
|
||||||
|
knownPeers: make(map[NodeID]string),
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
}
|
}
|
||||||
c.mux = http.NewServeMux()
|
c.mux = http.NewServeMux()
|
||||||
c.mux.HandleFunc("POST "+MessagePath, c.authenticated(c.handleMessage))
|
c.mux.HandleFunc("POST "+MessagePath, c.authenticated(c.handleMessage))
|
||||||
@@ -147,16 +152,19 @@ func (c *meshCluster) heartbeatLoop() {
|
|||||||
// periodically push our subscription state to peers.
|
// periodically push our subscription state to peers.
|
||||||
//
|
//
|
||||||
// A node that cannot even register itself aborts the tick: the remaining database work would
|
// A node that cannot even register itself aborts the tick: the remaining database work would
|
||||||
// fail against the same database, and everything downstream degrades safely without it -- Relay
|
// fail against the same database, and everything downstream degrades safely without it -- ForwardMessage
|
||||||
// serves the stale peer cache on its own, and peers fall back to broadcasting to us once our
|
// serves the stale peer cache on its own, and peers fall back to broadcasting to us once our
|
||||||
// last pushed state expires.
|
// last pushed state expires.
|
||||||
func (c *meshCluster) heartbeat() error {
|
func (c *meshCluster) heartbeat() error {
|
||||||
// TODO(T8): record the last successful Register here to back the future Healthy() method
|
|
||||||
// (see the Cluster interface)
|
|
||||||
if err := c.registry.Register(); err != nil {
|
if err := c.registry.Register(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if c.leader.TryAcquire(c.ctx) {
|
c.mu.Lock()
|
||||||
|
c.lastRegistered = time.Now()
|
||||||
|
c.mu.Unlock()
|
||||||
|
// Effective leadership: pg.Leader's lease semantics guarantee a no-leader gap on
|
||||||
|
// failover, never two leaders
|
||||||
|
if c.leader.IsLeader() {
|
||||||
metrics.ClusterLeader.Set(1)
|
metrics.ClusterLeader.Set(1)
|
||||||
if err := c.registry.Prune(); err != nil {
|
if err := c.registry.Prune(); err != nil {
|
||||||
log.Tag(tag).Err(err).Warn("Failed to prune stale nodes") // Housekeeping only; not fatal for the tick
|
log.Tag(tag).Err(err).Warn("Failed to prune stale nodes") // Housekeeping only; not fatal for the tick
|
||||||
@@ -179,7 +187,7 @@ func (c *meshCluster) heartbeat() error {
|
|||||||
// reconcilePeers aligns this node's per-peer attachments with the live peer set: it retires the
|
// reconcilePeers aligns this node's per-peer attachments with the live peer set: it retires the
|
||||||
// queues (and workers) of peers that have left the registry or re-registered under a new
|
// queues (and workers) of peers that have left the registry or re-registered under a new
|
||||||
// advertise URL (the retired queue's remainder was headed for a dead address anyway), and prunes
|
// advertise URL (the retired queue's remainder was headed for a dead address anyway), and prunes
|
||||||
// the stale state of departed peers. New and replacement queues are created lazily by Relay, not
|
// the stale state of departed peers. New and replacement queues are created lazily by ForwardMessage, not
|
||||||
// here, so a freshly joined peer is reachable immediately.
|
// here, so a freshly joined peer is reachable immediately.
|
||||||
func (c *meshCluster) reconcilePeers(peers []*registry.Peer) {
|
func (c *meshCluster) reconcilePeers(peers []*registry.Peer) {
|
||||||
metrics.ClusterPeers.Set(float64(len(peers)))
|
metrics.ClusterPeers.Set(float64(len(peers)))
|
||||||
@@ -188,6 +196,18 @@ func (c *meshCluster) reconcilePeers(peers []*registry.Peer) {
|
|||||||
alive[NodeID(p.NodeID)] = p.AdvertiseURL
|
alive[NodeID(p.NodeID)] = p.AdvertiseURL
|
||||||
}
|
}
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
// Log joins and leaves (as seen through the up-to-NodeTTL-stale registry view)
|
||||||
|
for nodeID, url := range alive {
|
||||||
|
if _, ok := c.knownPeers[nodeID]; !ok {
|
||||||
|
log.Tag(tag).Info("Peer %s (%s) joined the cluster", nodeID, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for nodeID := range c.knownPeers {
|
||||||
|
if _, ok := alive[nodeID]; !ok {
|
||||||
|
log.Tag(tag).Info("Peer %s left the cluster", nodeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.knownPeers = alive
|
||||||
for nodeID, q := range c.queues {
|
for nodeID, q := range c.queues {
|
||||||
if url, ok := alive[nodeID]; !ok || q.advertiseURL != url {
|
if url, ok := alive[nodeID]; !ok || q.advertiseURL != url {
|
||||||
q.queue.Close() // Flushes the remainder; the worker exits when the queue is drained
|
q.queue.Close() // Flushes the remainder; the worker exits when the queue is drained
|
||||||
@@ -227,11 +247,11 @@ func (c *meshCluster) queueFor(p *registry.Peer) *peerQueue {
|
|||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relay enqueues the message for delivery to every live peer node that may have subscribers for
|
// ForwardMessage enqueues the message for delivery to every live peer node that may have subscribers for
|
||||||
// its topic (all of them, absent fresh knowledge). Delivery is fire-and-forget via each peer's
|
// its topic (all of them, absent fresh knowledge). Delivery is fire-and-forget via each peer's
|
||||||
// bounded batching queue; if a peer's queue is full the message is dropped for that peer
|
// bounded batching queue; if a peer's queue is full the message is dropped for that peer
|
||||||
// (subscribers reconnect and re-poll history from the database).
|
// (subscribers reconnect and re-poll history from the database).
|
||||||
func (c *meshCluster) Relay(msg *model.Message) error {
|
func (c *meshCluster) ForwardMessage(msg *model.Message) error {
|
||||||
peers, err := c.registry.Peers()
|
peers, err := c.registry.Peers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -243,7 +263,7 @@ func (c *meshCluster) Relay(msg *model.Message) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
metrics.ClusterMessagesRelayed.Inc()
|
metrics.ClusterMessagesForwarded.Inc()
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
if c.closed {
|
if c.closed {
|
||||||
@@ -254,11 +274,16 @@ func (c *meshCluster) Relay(msg *model.Message) error {
|
|||||||
// certain (no state, stale state) falls back to broadcasting
|
// certain (no state, stale state) falls back to broadcasting
|
||||||
if !c.mayNeed(NodeID(p.NodeID), msg.Topic) {
|
if !c.mayNeed(NodeID(p.NodeID), msg.Topic) {
|
||||||
metrics.ClusterRouteSkipped.Inc()
|
metrics.ClusterRouteSkipped.Inc()
|
||||||
|
if ev := log.Tag(tag); ev.IsTrace() {
|
||||||
|
ev.Trace("Skipping peer %s for message %s: no subscribers for topic %s", p.NodeID, msg.ID, msg.Topic)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !c.queueFor(p).queue.TryEnqueue(frag) {
|
if !c.queueFor(p).queue.TryEnqueue(frag) {
|
||||||
metrics.ClusterQueueDropped.Inc()
|
metrics.ClusterQueueDropped.Inc()
|
||||||
log.Tag(tag).Warn("Fan-out queue for peer %s full, dropping message %s", p.NodeID, msg.ID)
|
log.Tag(tag).Warn("Fan-out queue for peer %s full, dropping message %s", p.NodeID, msg.ID)
|
||||||
|
} else if ev := log.Tag(tag); ev.IsTrace() {
|
||||||
|
ev.Trace("Enqueued message %s (topic %s) for peer %s", msg.ID, msg.Topic, p.NodeID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -284,7 +309,9 @@ func (c *meshCluster) mayNeed(peer NodeID, topic string) bool {
|
|||||||
func (c *meshCluster) peerWorker(nodeID NodeID, q *peerQueue) {
|
func (c *meshCluster) peerWorker(nodeID NodeID, q *peerQueue) {
|
||||||
defer c.wg.Done()
|
defer c.wg.Done()
|
||||||
for frags := range q.queue.Dequeue() {
|
for frags := range q.queue.Dequeue() {
|
||||||
c.postToPeer(nodeID, messageURL(q.advertiseURL), contentTypeNDJSON, assembleMessageBody(frags))
|
body := assembleMessageBody(frags)
|
||||||
|
log.Tag(tag).Debug("Sending batch of %d message(s) (%d bytes) to peer %s", len(frags), len(body), nodeID)
|
||||||
|
c.postToPeer(nodeID, messageURL(q.advertiseURL), contentTypeNDJSON, body)
|
||||||
metrics.ClusterBatchesSent.Inc()
|
metrics.ClusterBatchesSent.Inc()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,13 +346,22 @@ func (c *meshCluster) postToPeer(nodeID NodeID, url, contentType string, payload
|
|||||||
|
|
||||||
// handleMessage receives a batch of peer messages (NDJSON) and streams them to local
|
// handleMessage receives a batch of peer messages (NDJSON) and streams them to local
|
||||||
// subscribers line by line, delivering each message as it is decoded.
|
// subscribers line by line, delivering each message as it is decoded.
|
||||||
func (c *meshCluster) handleMessage(_ NodeID, w http.ResponseWriter, r *http.Request) {
|
func (c *meshCluster) handleMessage(origin NodeID, w http.ResponseWriter, r *http.Request) {
|
||||||
// A batch can exceed its byte cap by one message, plus framing overhead
|
// A batch can exceed its byte cap by one message, plus framing overhead
|
||||||
maxBodyBytes := int64(batchMaxBytes) + c.conf.MaxMessageBytes + 1024
|
maxBodyBytes := int64(batchMaxBytes) + c.conf.MaxMessageBytes + 1024
|
||||||
if err := decodeMessageBody(io.LimitReader(r.Body, maxBodyBytes), int(c.conf.MaxMessageBytes), c.deliver); err != nil {
|
received := 0
|
||||||
|
deliver := func(m *model.Message) {
|
||||||
|
received++
|
||||||
|
if ev := log.Tag(tag); ev.IsTrace() {
|
||||||
|
ev.Trace("Delivering message %s (topic %s) from peer %s", m.ID, m.Topic, origin)
|
||||||
|
}
|
||||||
|
c.deliver(m)
|
||||||
|
}
|
||||||
|
if err := decodeMessageBody(io.LimitReader(r.Body, maxBodyBytes), int(c.conf.MaxMessageBytes), deliver); err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Tag(tag).Debug("Received batch of %d message(s) from peer %s", received, origin)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,6 +398,7 @@ func (c *meshCluster) applyTopicState(origin NodeID, topics *apiStateTopics) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c.states[origin] = &peerState{topics: filter, updatedAt: time.Now()}
|
c.states[origin] = &peerState{topics: filter, updatedAt: time.Now()}
|
||||||
|
log.Tag(tag).Debug("Received subscription state from peer %s (%d filter bytes)", origin, len(topics.Filter))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if state, ok := c.states[origin]; ok {
|
if state, ok := c.states[origin]; ok {
|
||||||
@@ -369,6 +406,7 @@ func (c *meshCluster) applyTopicState(origin NodeID, topics *apiStateTopics) err
|
|||||||
state.topics.Add(topic)
|
state.topics.Add(topic)
|
||||||
}
|
}
|
||||||
state.updatedAt = time.Now()
|
state.updatedAt = time.Now()
|
||||||
|
log.Tag(tag).Debug("Received %d announced topic(s) from peer %s", len(topics.Added), origin)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -394,27 +432,29 @@ func (c *meshCluster) pushState(peers []*registry.Peer) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Tag(tag).Debug("Pushing subscription state (%d topics, %d bytes) to %d peer(s)", len(topics), len(body), len(peers))
|
||||||
for _, p := range peers {
|
for _, p := range peers {
|
||||||
go c.postToPeer(NodeID(p.NodeID), stateURL(p.AdvertiseURL), contentTypeJSON, body)
|
go c.postToPeer(NodeID(p.NodeID), stateURL(p.AdvertiseURL), contentTypeJSON, body)
|
||||||
}
|
}
|
||||||
metrics.ClusterStatePushes.Inc()
|
metrics.ClusterStatePushes.Inc()
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnnounceTopics immediately tells all live peers that these topics gained their first local
|
// BroadcastState immediately tells all live peers that these topics gained their first local
|
||||||
// subscriber, shrinking the window in which a publisher could wrongly skip this node from a
|
// subscriber, shrinking the window in which a publisher could wrongly skip this node from a
|
||||||
// full state interval down to about one round trip.
|
// full state interval down to about one round trip.
|
||||||
func (c *meshCluster) AnnounceTopics(topics []string) {
|
func (c *meshCluster) BroadcastState(state *State) {
|
||||||
if len(topics) == 0 {
|
if len(state.AddedTopics) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
peers, err := c.registry.Peers()
|
peers, err := c.registry.Peers()
|
||||||
if err != nil || len(peers) == 0 {
|
if err != nil || len(peers) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
body, err := json.Marshal(&apiState{Topics: &apiStateTopics{Added: topics}})
|
body, err := json.Marshal(&apiState{Topics: &apiStateTopics{Added: state.AddedTopics}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Tag(tag).Debug("Broadcasting state (%d new topics) to %d peer(s)", len(state.AddedTopics), len(peers))
|
||||||
for _, p := range peers {
|
for _, p := range peers {
|
||||||
go c.postToPeer(NodeID(p.NodeID), stateURL(p.AdvertiseURL), contentTypeJSON, body)
|
go c.postToPeer(NodeID(p.NodeID), stateURL(p.AdvertiseURL), contentTypeJSON, body)
|
||||||
}
|
}
|
||||||
@@ -425,6 +465,14 @@ func (c *meshCluster) IsLeader() bool {
|
|||||||
return c.leader.IsLeader()
|
return c.leader.IsLeader()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Healthy reports whether this node's registry heartbeat is fresh enough that peers still
|
||||||
|
// forward messages to it (see the Cluster interface for the checker's fail-open duty).
|
||||||
|
func (c *meshCluster) Healthy() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return time.Since(c.lastRegistered) < c.conf.NodeTTL
|
||||||
|
}
|
||||||
|
|
||||||
// Close stops the mesh: it deregisters this node, releases leadership, stops all peer workers,
|
// Close stops the mesh: it deregisters this node, releases leadership, stops all peer workers,
|
||||||
// and waits for them to exit.
|
// and waits for them to exit.
|
||||||
func (c *meshCluster) Close() error {
|
func (c *meshCluster) Close() error {
|
||||||
@@ -444,7 +492,7 @@ func (c *meshCluster) Close() error {
|
|||||||
if err := c.registry.Deregister(); err != nil {
|
if err := c.registry.Deregister(); err != nil {
|
||||||
log.Tag(tag).Err(err).Warn("Failed to deregister node")
|
log.Tag(tag).Err(err).Warn("Failed to deregister node")
|
||||||
}
|
}
|
||||||
c.leader.Release()
|
c.leader.Close()
|
||||||
metrics.ClusterLeader.Set(0)
|
metrics.ClusterLeader.Set(0)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,14 +37,15 @@ func openTestPool(t testing.TB, dsn string) *db.DB {
|
|||||||
|
|
||||||
func newTestMeshConfig(nodeID, advertiseURL string) *Config {
|
func newTestMeshConfig(nodeID, advertiseURL string) *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
NodeID: NodeID(nodeID),
|
NodeID: NodeID(nodeID),
|
||||||
AdvertiseURL: advertiseURL,
|
AdvertiseURL: advertiseURL,
|
||||||
Secret: testSecret,
|
Secret: testSecret,
|
||||||
HeartbeatInterval: 100 * time.Millisecond,
|
HeartbeatInterval: 100 * time.Millisecond,
|
||||||
NodeTTL: time.Second, // Also the peer cache bound; short so fake peers registered mid-test are seen quickly
|
LeaderRenewInterval: 20 * time.Millisecond, // Lease duration 60ms, hold-off 120ms; keeps leadership tests fast
|
||||||
MaxMessageBytes: 1 << 20,
|
NodeTTL: time.Second, // Also the peer cache bound; short so fake peers registered mid-test are seen quickly
|
||||||
StateInterval: time.Minute, // Individual tests lower this to exercise state pushes
|
MaxMessageBytes: 1 << 20,
|
||||||
|
StateInterval: time.Minute, // Individual tests lower this to exercise state pushes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +93,7 @@ func TestMesh_CrossNodeDelivery(t *testing.T) {
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
defer meshA.Close()
|
defer meshA.Close()
|
||||||
msg := model.NewDefaultMessage("mytopic", "hello cross-node")
|
msg := model.NewDefaultMessage("mytopic", "hello cross-node")
|
||||||
require.Nil(t, meshA.Relay(msg))
|
require.Nil(t, meshA.ForwardMessage(msg))
|
||||||
waitFor(t, func() bool {
|
waitFor(t, func() bool {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
@@ -207,7 +208,7 @@ func TestMesh_SlowPeerIsolation(t *testing.T) {
|
|||||||
defer mesh.Close()
|
defer mesh.Close()
|
||||||
const n = 20
|
const n = 20
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
require.Nil(t, mesh.Relay(model.NewDefaultMessage("mytopic", fmt.Sprintf("message %d", i))))
|
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", fmt.Sprintf("message %d", i))))
|
||||||
}
|
}
|
||||||
waitFor(t, func() bool {
|
waitFor(t, func() bool {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
@@ -243,7 +244,7 @@ func TestMesh_BatchCoalescing(t *testing.T) {
|
|||||||
defer mesh.Close()
|
defer mesh.Close()
|
||||||
const n = 20
|
const n = 20
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
require.Nil(t, mesh.Relay(model.NewDefaultMessage("mytopic", fmt.Sprintf("message %d", i))))
|
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", fmt.Sprintf("message %d", i))))
|
||||||
}
|
}
|
||||||
waitFor(t, func() bool {
|
waitFor(t, func() bool {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
@@ -281,7 +282,7 @@ func TestMesh_DeadPeerRemovedAndRejoin(t *testing.T) {
|
|||||||
defer mesh.Close()
|
defer mesh.Close()
|
||||||
// The fake peer registers once and then "dies": its heartbeat is never refreshed
|
// The fake peer registers once and then "dies": its heartbeat is never refreshed
|
||||||
registerFakePeer(t, pool, "node-dead", srv.URL)
|
registerFakePeer(t, pool, "node-dead", srv.URL)
|
||||||
require.Nil(t, mesh.Relay(model.NewDefaultMessage("mytopic", "while alive")))
|
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", "while alive")))
|
||||||
waitFor(t, func() bool {
|
waitFor(t, func() bool {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
@@ -299,7 +300,7 @@ func TestMesh_DeadPeerRemovedAndRejoin(t *testing.T) {
|
|||||||
require.Nil(t, pool.QueryRow(`SELECT COUNT(*) FROM node_registry WHERE node_id = 'node-dead'`).Scan(&count))
|
require.Nil(t, pool.QueryRow(`SELECT COUNT(*) FROM node_registry WHERE node_id = 'node-dead'`).Scan(&count))
|
||||||
return count == 0
|
return count == 0
|
||||||
})
|
})
|
||||||
require.Nil(t, mesh.Relay(model.NewDefaultMessage("mytopic", "while dead")))
|
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", "while dead")))
|
||||||
time.Sleep(250 * time.Millisecond) // Give a wrong implementation time to deliver anyway
|
time.Sleep(250 * time.Millisecond) // Give a wrong implementation time to deliver anyway
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
require.Equal(t, 1, received) // Only the first message arrived
|
require.Equal(t, 1, received) // Only the first message arrived
|
||||||
@@ -308,15 +309,15 @@ func TestMesh_DeadPeerRemovedAndRejoin(t *testing.T) {
|
|||||||
// relay retries because the peer list is cached for up to the node TTL
|
// relay retries because the peer list is cached for up to the node TTL
|
||||||
registerFakePeer(t, pool, "node-dead", srv.URL)
|
registerFakePeer(t, pool, "node-dead", srv.URL)
|
||||||
waitFor(t, func() bool {
|
waitFor(t, func() bool {
|
||||||
require.Nil(t, mesh.Relay(model.NewDefaultMessage("mytopic", "after rejoin")))
|
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", "after rejoin")))
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
return received > 1
|
return received > 1
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMesh_RelayAfterClose(t *testing.T) {
|
func TestMesh_ForwardAfterClose(t *testing.T) {
|
||||||
// A Relay racing shutdown (e.g. an in-flight publish during server Stop) must not spawn
|
// A ForwardMessage racing shutdown (e.g. an in-flight publish during server Stop) must not spawn
|
||||||
// a new peer queue and worker after Close: the worker would never exit (its queue is never
|
// a new peer queue and worker after Close: the worker would never exit (its queue is never
|
||||||
// closed) and nothing waits for it.
|
// closed) and nothing waits for it.
|
||||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||||
@@ -325,7 +326,7 @@ func TestMesh_RelayAfterClose(t *testing.T) {
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
registerFakePeer(t, pool, "node-peer", "http://127.0.0.1:1")
|
registerFakePeer(t, pool, "node-peer", "http://127.0.0.1:1")
|
||||||
require.Nil(t, mesh.Close())
|
require.Nil(t, mesh.Close())
|
||||||
require.Nil(t, mesh.Relay(model.NewDefaultMessage("mytopic", "too late"))) // Dropped silently
|
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("mytopic", "too late"))) // Dropped silently
|
||||||
mesh.mu.Lock()
|
mesh.mu.Lock()
|
||||||
defer mesh.mu.Unlock()
|
defer mesh.mu.Unlock()
|
||||||
require.Empty(t, mesh.queues)
|
require.Empty(t, mesh.queues)
|
||||||
@@ -418,13 +419,13 @@ func TestMesh_RouteSkipsUnsubscribedPeer(t *testing.T) {
|
|||||||
rr := postState(mesh, "node-b", &apiState{Topics: &apiStateTopics{Filter: topicFilter(t, "subscribed-topic")}})
|
rr := postState(mesh, "node-b", &apiState{Topics: &apiStateTopics{Filter: topicFilter(t, "subscribed-topic")}})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
// A topic outside the peer's state is skipped
|
// A topic outside the peer's state is skipped
|
||||||
require.Nil(t, mesh.Relay(model.NewDefaultMessage("other-topic", "skipped")))
|
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("other-topic", "skipped")))
|
||||||
time.Sleep(300 * time.Millisecond) // Give a wrong implementation time to deliver anyway
|
time.Sleep(300 * time.Millisecond) // Give a wrong implementation time to deliver anyway
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
require.Equal(t, 0, received)
|
require.Equal(t, 0, received)
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
// A topic inside the peer's state is delivered
|
// A topic inside the peer's state is delivered
|
||||||
require.Nil(t, mesh.Relay(model.NewDefaultMessage("subscribed-topic", "delivered")))
|
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("subscribed-topic", "delivered")))
|
||||||
waitFor(t, func() bool {
|
waitFor(t, func() bool {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
@@ -457,7 +458,7 @@ func TestMesh_RouteBroadcastsOnStaleState(t *testing.T) {
|
|||||||
mesh.statesMu.Lock()
|
mesh.statesMu.Lock()
|
||||||
mesh.states["node-b"].updatedAt = time.Now().Add(-time.Hour)
|
mesh.states["node-b"].updatedAt = time.Now().Add(-time.Hour)
|
||||||
mesh.statesMu.Unlock()
|
mesh.statesMu.Unlock()
|
||||||
require.Nil(t, mesh.Relay(model.NewDefaultMessage("other-topic", "broadcast anyway")))
|
require.Nil(t, mesh.ForwardMessage(model.NewDefaultMessage("other-topic", "broadcast anyway")))
|
||||||
waitFor(t, func() bool {
|
waitFor(t, func() bool {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
@@ -534,7 +535,7 @@ func TestMesh_AnnounceClosesWindow(t *testing.T) {
|
|||||||
return ok
|
return ok
|
||||||
})
|
})
|
||||||
// Announcements merge into the baseline right away
|
// Announcements merge into the baseline right away
|
||||||
meshA.AnnounceTopics([]string{"fresh-topic"})
|
meshA.BroadcastState(&State{AddedTopics: []string{"fresh-topic"}})
|
||||||
waitFor(t, func() bool {
|
waitFor(t, func() bool {
|
||||||
meshB.statesMu.Lock()
|
meshB.statesMu.Lock()
|
||||||
defer meshB.statesMu.Unlock()
|
defer meshB.statesMu.Unlock()
|
||||||
@@ -570,3 +571,20 @@ func TestMesh_StateOfDepartedPeerPruned(t *testing.T) {
|
|||||||
mesh.statesMu.Unlock()
|
mesh.statesMu.Unlock()
|
||||||
require.False(t, ok)
|
require.False(t, ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMesh_HealthyReflectsRegistration(t *testing.T) {
|
||||||
|
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||||
|
pool := openTestPool(t, schemaDSN)
|
||||||
|
mesh, err := newMeshCluster(newTestMeshConfig("node-a", "http://127.0.0.1:1"), pool, nil, nil)
|
||||||
|
require.Nil(t, err)
|
||||||
|
defer mesh.Close()
|
||||||
|
require.True(t, mesh.Healthy()) // Registered synchronously at construction
|
||||||
|
// Stale heartbeat: peers stop forwarding to this node, so it must report unhealthy
|
||||||
|
mesh.mu.Lock()
|
||||||
|
mesh.lastRegistered = time.Now().Add(-2 * mesh.conf.NodeTTL)
|
||||||
|
mesh.mu.Unlock()
|
||||||
|
require.False(t, mesh.Healthy())
|
||||||
|
// A successful heartbeat restores health
|
||||||
|
require.Nil(t, mesh.heartbeat())
|
||||||
|
require.True(t, mesh.Healthy())
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,14 +11,16 @@ import (
|
|||||||
// no special-casing in single-node mode).
|
// no special-casing in single-node mode).
|
||||||
type nopCluster struct{}
|
type nopCluster struct{}
|
||||||
|
|
||||||
func (c *nopCluster) Relay(_ *model.Message) error { return nil }
|
func (c *nopCluster) ForwardMessage(_ *model.Message) error { return nil }
|
||||||
|
|
||||||
func (c *nopCluster) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
func (c *nopCluster) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *nopCluster) AnnounceTopics(_ []string) {}
|
func (c *nopCluster) BroadcastState(_ *State) {}
|
||||||
|
|
||||||
func (c *nopCluster) IsLeader() bool { return true }
|
func (c *nopCluster) IsLeader() bool { return true }
|
||||||
|
|
||||||
|
func (c *nopCluster) Healthy() bool { return true }
|
||||||
|
|
||||||
func (c *nopCluster) Close() error { return nil }
|
func (c *nopCluster) Close() error { return nil }
|
||||||
|
|||||||
@@ -67,9 +67,10 @@ func TestNop(t *testing.T) {
|
|||||||
b, err := New(&Config{}, nil, nil, nil) // not enabled -> nop cluster, no database required
|
b, err := New(&Config{}, nil, nil, nil) // not enabled -> nop cluster, no database required
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.IsType(t, &nopCluster{}, b)
|
require.IsType(t, &nopCluster{}, b)
|
||||||
require.Nil(t, b.Relay(model.NewDefaultMessage("mytopic", "hi")))
|
require.Nil(t, b.ForwardMessage(model.NewDefaultMessage("mytopic", "hi")))
|
||||||
// A single node is trivially the leader, so leader-gated jobs run without special-casing
|
// A single node is trivially the leader, so leader-gated jobs run without special-casing
|
||||||
require.True(t, b.IsLeader())
|
require.True(t, b.IsLeader())
|
||||||
|
require.True(t, b.Healthy())
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
b.ServeHTTP(rr, httptest.NewRequest("POST", MessagePath, nil))
|
b.ServeHTTP(rr, httptest.NewRequest("POST", MessagePath, nil))
|
||||||
require.Equal(t, 404, rr.Code)
|
require.Equal(t, 404, rr.Code)
|
||||||
|
|||||||
+31
-2
@@ -7,6 +7,35 @@ import (
|
|||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Config configures the cluster. It is assembled by the server from its own config, which keeps
|
||||||
|
// this package free of server types.
|
||||||
|
type Config struct {
|
||||||
|
Enabled bool // Master switch; when false, New returns the nop cluster
|
||||||
|
NodeID NodeID // Stable per-node identifier; required
|
||||||
|
AdvertiseURL string // Base URL peers use to reach this node's fan-out endpoint
|
||||||
|
Secret string // Shared secret authenticating node-to-node fan-out requests
|
||||||
|
HeartbeatInterval time.Duration // How often the node registry heartbeat is refreshed
|
||||||
|
NodeTTL time.Duration // Registry rows older than this do not count as live peers
|
||||||
|
BatchLinger time.Duration // How long messages wait in a peer queue to form a batch; 0 = send immediately
|
||||||
|
StateInterval time.Duration // How often the full subscription state is pushed to peers
|
||||||
|
MaxMessageBytes int64 // Upper bound for a single message on the wire (batch limits derive from this)
|
||||||
|
LeaderRenewInterval time.Duration // Overrides the leader lease renewal cadence; tests only, 0 = default
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliverFunc hands a message received from a peer node to this node's local subscribers. The
|
||||||
|
// server supplies it, which inverts the dependency: this package never imports the server.
|
||||||
|
type DeliverFunc func(m *model.Message)
|
||||||
|
|
||||||
|
// State is a subscription-state delta for Cluster.BroadcastState.
|
||||||
|
type State struct {
|
||||||
|
AddedTopics []string // Topics that just gained their first local subscriber on this node
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopicsFunc returns the topics that currently have at least one live subscriber, computed
|
||||||
|
// fresh on every call: membership is never tracked as a list, so topics "leave" simply by not
|
||||||
|
// appearing in the next snapshot. The server supplies it (same inversion as DeliverFunc).
|
||||||
|
type TopicsFunc func() []string
|
||||||
|
|
||||||
// apiMessage is one line of a message request body (NDJSON: one message per line; a single
|
// apiMessage is one line of a message request body (NDJSON: one message per line; a single
|
||||||
// message is just a one-line body). It carries the two fields that model.Message does not
|
// message is just a one-line body). It carries the two fields that model.Message does not
|
||||||
// serialize to JSON (Sender and User), which are needed to reconstruct the visitor on the
|
// serialize to JSON (Sender and User), which are needed to reconstruct the visitor on the
|
||||||
@@ -31,7 +60,7 @@ type apiStateTopics struct {
|
|||||||
Added []string `json:"added,omitempty"`
|
Added []string `json:"added,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// peerState is what a peer last told us about itself; Relay routes around peers whose
|
// peerState is what a peer last told us about itself; ForwardMessage routes around peers whose
|
||||||
// fresh state provably excludes a topic.
|
// fresh state provably excludes a topic.
|
||||||
type peerState struct {
|
type peerState struct {
|
||||||
topics *util.BloomFilter
|
topics *util.BloomFilter
|
||||||
@@ -40,7 +69,7 @@ type peerState struct {
|
|||||||
|
|
||||||
// peerQueue is the bounded, batching send queue for a single peer, pinned to the advertise URL
|
// peerQueue is the bounded, batching send queue for a single peer, pinned to the advertise URL
|
||||||
// the peer was created with: a peer re-registering under a different advertise URL is treated
|
// the peer was created with: a peer re-registering under a different advertise URL is treated
|
||||||
// as a replacement (reconcile retires the old queue; Relay creates a fresh one on demand).
|
// as a replacement (reconcile retires the old queue; ForwardMessage creates a fresh one on demand).
|
||||||
type peerQueue struct {
|
type peerQueue struct {
|
||||||
advertiseURL string
|
advertiseURL string
|
||||||
queue *util.LingerQueue[[]byte] // pre-marshaled apiMessage fragments
|
queue *util.LingerQueue[[]byte] // pre-marshaled apiMessage fragments
|
||||||
|
|||||||
+121
-41
@@ -4,80 +4,160 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"heckel.io/ntfy/v2/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
tagLeader = "leader"
|
||||||
|
|
||||||
tryAdvisoryLockQuery = `SELECT pg_try_advisory_lock($1)`
|
tryAdvisoryLockQuery = `SELECT pg_try_advisory_lock($1)`
|
||||||
advisoryUnlockQuery = `SELECT pg_advisory_unlock($1)`
|
advisoryUnlockQuery = `SELECT pg_advisory_unlock($1)`
|
||||||
|
|
||||||
|
defaultRenewInterval = 5 * time.Second
|
||||||
|
leaderMissedRenewals = 3
|
||||||
|
leaderHoldoffFactor = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
// Leader implements singleton-job leader election via a Postgres advisory lock held on a pinned
|
// Leader implements singleton-job leader election via a Postgres advisory lock held on a
|
||||||
// connection. The lock auto-releases if the holding connection dies, so a crashed leader is
|
// pinned connection. The lock auto-releases when the holding connection dies, so a crashed
|
||||||
// replaced without manual fencing or lease bookkeeping. Multiple independent leaderships can
|
// leader is replaced without manual fencing; distinct keys elect independently. The Leader
|
||||||
// coexist by using distinct lock keys.
|
// renews its lease on its own loop; callers only ask IsLeader and eventually Close.
|
||||||
|
//
|
||||||
|
// Holding the lock is not the same as believing to be the leader: IsLeader also requires a
|
||||||
|
// recent renewal (lease duration) and a completed hold-off after winning the lock. The
|
||||||
|
// hold-off outlasts the lease duration by construction, so on failover the old belief always
|
||||||
|
// expires before the new one begins: a short no-leader gap, never two leaders. Defaults:
|
||||||
|
// renew every 5s, lease duration 15s, hold-off 30s -> up to ~35s without a leader.
|
||||||
type Leader struct {
|
type Leader struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
key int64
|
key int64
|
||||||
conn *sql.Conn // holds the advisory lock while this process is leader
|
renewInterval time.Duration
|
||||||
held bool
|
conn *sql.Conn // holds the advisory lock while this process is leader
|
||||||
mu sync.Mutex // Protects conn and held
|
acquiredAt time.Time // When the lock was won (this tenure), for the hold-off
|
||||||
|
renewedAt time.Time // Last successful renewal, for the lease duration; zero = lock not held
|
||||||
|
cancel context.CancelFunc // Stops the renew loop and aborts its in-flight query on Close
|
||||||
|
closeOnce sync.Once
|
||||||
|
wg sync.WaitGroup
|
||||||
|
mu sync.Mutex // Protects conn, acquiredAt and renewedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLeader creates a Leader competing for the advisory lock identified by key. It does not
|
// NewLeader creates a Leader competing for the lock identified by key and starts its renew
|
||||||
// attempt to acquire the lock; call TryAcquire periodically.
|
// loop. renewInterval is for tests; pass 0 for the default.
|
||||||
func NewLeader(db *sql.DB, key int64) *Leader {
|
func NewLeader(db *sql.DB, key int64, renewInterval time.Duration) *Leader {
|
||||||
return &Leader{db: db, key: key}
|
if renewInterval <= 0 {
|
||||||
|
renewInterval = defaultRenewInterval
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
l := &Leader{
|
||||||
|
db: db,
|
||||||
|
key: key,
|
||||||
|
renewInterval: renewInterval,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
l.wg.Add(1)
|
||||||
|
go l.runAcquireOrRenewLoop(ctx)
|
||||||
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
// TryAcquire attempts to grab (or confirm) the advisory lock on a pinned connection, without
|
// IsLeader reports whether this process should act as the leader: lock held, lease renewed
|
||||||
// blocking. It returns whether this process is the leader after the attempt, i.e. it acquired
|
// recently, hold-off elapsed (see the Leader doc comment).
|
||||||
// the lock now or still holds it. It is meant to be called periodically; on a healthy leader it
|
func (l *Leader) IsLeader() bool {
|
||||||
// is a cheap ping, on a follower it retries the lock.
|
|
||||||
func (l *Leader) TryAcquire(ctx context.Context) bool {
|
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
conn, held := l.conn, l.held
|
defer l.mu.Unlock()
|
||||||
l.mu.Unlock()
|
leaseDuration := leaderMissedRenewals * l.renewInterval
|
||||||
if held {
|
holdoff := leaderHoldoffFactor * leaseDuration
|
||||||
if conn != nil && conn.PingContext(ctx) == nil {
|
return time.Since(l.renewedAt) < leaseDuration && time.Since(l.acquiredAt) >= holdoff
|
||||||
return true // Still leader, connection healthy
|
}
|
||||||
|
|
||||||
|
// Close stops competing for leadership and releases the lock. Idempotent.
|
||||||
|
func (l *Leader) Close() {
|
||||||
|
l.closeOnce.Do(func() {
|
||||||
|
l.cancel() // Also aborts an in-flight renewal query
|
||||||
|
l.wg.Wait()
|
||||||
|
if l.IsLeader() {
|
||||||
|
log.Tag(tagLeader).Info("Lost leadership: closed (lock key %d)", l.key)
|
||||||
}
|
}
|
||||||
l.Release() // Connection died; the lock is already gone, re-acquire below
|
l.release()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAcquireOrRenewLoop acquires or renews the lock every renewInterval until ctx is canceled
|
||||||
|
func (l *Leader) runAcquireOrRenewLoop(ctx context.Context) {
|
||||||
|
defer l.wg.Done()
|
||||||
|
ticker := time.NewTicker(l.renewInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
wasLeader := false
|
||||||
|
for {
|
||||||
|
attemptCtx, cancel := context.WithTimeout(ctx, l.renewInterval)
|
||||||
|
l.tryAcquireOrRenew(attemptCtx)
|
||||||
|
cancel()
|
||||||
|
if isLeader := l.IsLeader(); isLeader != wasLeader {
|
||||||
|
wasLeader = isLeader
|
||||||
|
if isLeader {
|
||||||
|
log.Tag(tagLeader).Info("Became leader (lock key %d)", l.key)
|
||||||
|
} else {
|
||||||
|
log.Tag(tagLeader).Info("Lost leadership (lock key %d)", l.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryAcquireOrRenew renews the lock on a healthy leader (a cheap ping) or retries acquiring
|
||||||
|
// it on a follower, on a pinned connection.
|
||||||
|
func (l *Leader) tryAcquireOrRenew(ctx context.Context) {
|
||||||
|
l.mu.Lock()
|
||||||
|
conn := l.conn
|
||||||
|
l.mu.Unlock()
|
||||||
|
if conn != nil {
|
||||||
|
if conn.PingContext(ctx) == nil {
|
||||||
|
// Still holding the lock, connection healthy: renew the lease
|
||||||
|
l.mu.Lock()
|
||||||
|
l.renewedAt = time.Now()
|
||||||
|
l.mu.Unlock()
|
||||||
|
log.Tag(tagLeader).Trace("Renewed leader lease (lock key %d)", l.key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Tag(tagLeader).Debug("Leader lock connection died, lock lost (lock key %d)", l.key)
|
||||||
|
l.release() // Connection died; the lock is already gone, re-acquire below
|
||||||
}
|
}
|
||||||
newConn, err := l.db.Conn(ctx)
|
newConn, err := l.db.Conn(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
log.Tag(tagLeader).Debug("Cannot get connection to compete for leader lock (lock key %d): %s", l.key, err.Error())
|
||||||
|
return
|
||||||
}
|
}
|
||||||
var acquired bool
|
var acquired bool
|
||||||
if err := newConn.QueryRowContext(ctx, tryAdvisoryLockQuery, l.key).Scan(&acquired); err != nil || !acquired {
|
if err := newConn.QueryRowContext(ctx, tryAdvisoryLockQuery, l.key).Scan(&acquired); err != nil || !acquired {
|
||||||
newConn.Close()
|
newConn.Close()
|
||||||
return false
|
log.Tag(tagLeader).Trace("Leader lock held elsewhere (lock key %d)", l.key)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
log.Tag(tagLeader).Debug("Acquired leader lock (lock key %d); leadership after the hold-off", l.key)
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
l.conn = newConn
|
l.conn = newConn
|
||||||
l.held = true
|
l.acquiredAt = time.Now()
|
||||||
|
l.renewedAt = l.acquiredAt
|
||||||
l.mu.Unlock()
|
l.mu.Unlock()
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release unlocks the advisory lock and returns the pinned connection to the pool.
|
// release unlocks the advisory lock and returns the pinned connection to the pool
|
||||||
func (l *Leader) Release() {
|
func (l *Leader) release() {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
conn := l.conn
|
conn := l.conn
|
||||||
l.conn = nil
|
l.conn = nil
|
||||||
l.held = false
|
l.renewedAt = time.Time{} // Zero revokes belief; without it, IsLeader would linger a lease duration
|
||||||
l.mu.Unlock()
|
l.mu.Unlock()
|
||||||
if conn != nil {
|
if conn != nil {
|
||||||
// Unlock explicitly before returning the connection to the pool: sql.Conn.Close() returns
|
// Unlock explicitly: sql.Conn.Close() returns the connection to the pool, so the
|
||||||
// the physical connection to the pool rather than closing it, so the session-scoped lock
|
// session-scoped lock would otherwise stay held
|
||||||
// would otherwise stay held.
|
|
||||||
conn.ExecContext(context.Background(), advisoryUnlockQuery, l.key)
|
conn.ExecContext(context.Background(), advisoryUnlockQuery, l.key)
|
||||||
conn.Close()
|
conn.Close()
|
||||||
|
log.Tag(tagLeader).Debug("Released leader lock (lock key %d)", l.key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsLeader reports whether this process currently holds the advisory lock.
|
|
||||||
func (l *Leader) IsLeader() bool {
|
|
||||||
l.mu.Lock()
|
|
||||||
defer l.mu.Unlock()
|
|
||||||
return l.held
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package pg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The lease logic is pure time arithmetic, so it is unit-tested here without a database; the
|
||||||
|
// external leader tests cover the loop end to end.
|
||||||
|
|
||||||
|
func TestLeader_Lease_HoldoffMeansNoLeaderRatherThanTwo(t *testing.T) {
|
||||||
|
// Freshly acquired lock: belief must wait out the hold-off
|
||||||
|
l := &Leader{renewInterval: 20 * time.Second} // Lease duration 1m, hold-off 2m
|
||||||
|
l.acquiredAt = time.Now()
|
||||||
|
l.renewedAt = l.acquiredAt
|
||||||
|
require.False(t, l.IsLeader())
|
||||||
|
// Once the hold-off has passed (and verification is fresh), belief begins
|
||||||
|
l.acquiredAt = time.Now().Add(-3 * time.Minute)
|
||||||
|
l.renewedAt = time.Now()
|
||||||
|
require.True(t, l.IsLeader())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLeader_Lease_ExpiredLeaseRevokesLeadership(t *testing.T) {
|
||||||
|
// A leader that cannot renew its lease (wedged process, long GC pause) must stop
|
||||||
|
// believing once the lease expires, even though the lock may still be held
|
||||||
|
l := &Leader{renewInterval: 20 * time.Second} // Lease duration 1m, hold-off 2m
|
||||||
|
l.acquiredAt = time.Now().Add(-time.Hour)
|
||||||
|
l.renewedAt = time.Now().Add(-2 * time.Minute) // Lease expired
|
||||||
|
require.False(t, l.IsLeader())
|
||||||
|
l.renewedAt = time.Now() // Fresh renewal restores belief
|
||||||
|
require.True(t, l.IsLeader())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLeader_Lease_ReleasedIsNeverLeader(t *testing.T) {
|
||||||
|
// release() zeroes renewedAt, which fails the lease check no matter how old the tenure
|
||||||
|
l := &Leader{renewInterval: 20 * time.Second} // Lease duration 1m, hold-off 2m
|
||||||
|
l.acquiredAt = time.Now().Add(-time.Hour)
|
||||||
|
require.False(t, l.IsLeader())
|
||||||
|
}
|
||||||
+52
-46
@@ -1,7 +1,6 @@
|
|||||||
package pg_test
|
package pg_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -10,33 +9,33 @@ import (
|
|||||||
dbtest "heckel.io/ntfy/v2/db/test"
|
dbtest "heckel.io/ntfy/v2/db/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const testRenewInterval = 20 * time.Millisecond // Lease duration 60ms, hold-off 120ms
|
||||||
|
|
||||||
func TestLeader_AcquireAndFailover(t *testing.T) {
|
func TestLeader_AcquireAndFailover(t *testing.T) {
|
||||||
testDB := dbtest.CreateTestPostgres(t) // skips if NTFY_TEST_DATABASE_URL is unset
|
testDB := dbtest.CreateTestPostgres(t) // skips if NTFY_TEST_DATABASE_URL is unset
|
||||||
const key = int64(42)
|
const key = int64(42)
|
||||||
ctx := context.Background()
|
l1 := pg.NewLeader(testDB.Primary(), key, testRenewInterval)
|
||||||
l1 := pg.NewLeader(testDB.Primary(), key)
|
defer l1.Close()
|
||||||
l2 := pg.NewLeader(testDB.Primary(), key)
|
// Belief follows the hold-off, it is never instant
|
||||||
defer l1.Release()
|
require.False(t, l1.IsLeader())
|
||||||
defer l2.Release()
|
waitForLeader(t, l1)
|
||||||
// First to try wins; the second stays follower
|
// A competitor never becomes leader while the leader lives
|
||||||
require.True(t, l1.TryAcquire(ctx))
|
l2 := pg.NewLeader(testDB.Primary(), key, testRenewInterval)
|
||||||
require.False(t, l2.TryAcquire(ctx))
|
defer l2.Close()
|
||||||
|
time.Sleep(300 * time.Millisecond) // Several verification rounds
|
||||||
|
require.False(t, l2.IsLeader())
|
||||||
require.True(t, l1.IsLeader())
|
require.True(t, l1.IsLeader())
|
||||||
require.False(t, l2.IsLeader())
|
// Close -> the follower takes over
|
||||||
// Repeated TryAcquire on the leader is a no-op ping and reports leadership
|
l1.Close()
|
||||||
require.True(t, l1.TryAcquire(ctx))
|
require.False(t, l1.IsLeader())
|
||||||
// Release -> the follower can take over
|
waitForLeader(t, l2)
|
||||||
l1.Release()
|
|
||||||
require.False(t, l1.IsLeader())
|
require.False(t, l1.IsLeader())
|
||||||
require.True(t, l2.TryAcquire(ctx))
|
|
||||||
require.True(t, l2.IsLeader())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLeader_ConnectionLossFailover(t *testing.T) {
|
func TestLeader_ConnectionLossFailover(t *testing.T) {
|
||||||
// A leader that dies without calling Release (crash, network loss) must not wedge the
|
// A crashed leader must not wedge the cluster: Postgres releases the session-scoped lock
|
||||||
// cluster: the advisory lock is session-scoped, so Postgres releases it when the pinned
|
// when the pinned connection dies (simulated by terminating the backend), and someone
|
||||||
// connection dies, and a follower can take over. Simulated by terminating the lock-holding
|
// re-acquires. Either node may win; the invariant is one leader eventually, never two.
|
||||||
// backend server-side.
|
|
||||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||||
hostA, err := pg.Open(schemaDSN)
|
hostA, err := pg.Open(schemaDSN)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -45,40 +44,47 @@ func TestLeader_ConnectionLossFailover(t *testing.T) {
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
defer hostB.DB.Close()
|
defer hostB.DB.Close()
|
||||||
const key = int64(43)
|
const key = int64(43)
|
||||||
ctx := context.Background()
|
l1 := pg.NewLeader(hostA.DB, key, testRenewInterval)
|
||||||
l1 := pg.NewLeader(hostA.DB, key)
|
defer l1.Close()
|
||||||
l2 := pg.NewLeader(hostB.DB, key)
|
waitForLeader(t, l1)
|
||||||
defer l1.Release()
|
l2 := pg.NewLeader(hostB.DB, key, testRenewInterval)
|
||||||
defer l2.Release()
|
defer l2.Close()
|
||||||
require.True(t, l1.TryAcquire(ctx))
|
|
||||||
require.False(t, l2.TryAcquire(ctx))
|
|
||||||
// Kill the backend holding the lock (advisory lock keys map to classid/objid)
|
// Kill the backend holding the lock (advisory lock keys map to classid/objid)
|
||||||
_, err = hostB.DB.Exec(`SELECT pg_terminate_backend(pid) FROM pg_locks WHERE locktype = 'advisory' AND objid = $1 AND granted`, key)
|
_, err = hostB.DB.Exec(`SELECT pg_terminate_backend(pid) FROM pg_locks WHERE locktype = 'advisory' AND objid = $1 AND granted`, key)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
// The lock is auto-released; the follower takes over
|
// Eventually exactly one leader again, and never two along the way
|
||||||
waitForCond(t, func() bool { return l2.TryAcquire(ctx) })
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
// The old leader discovers its dead connection on the next attempt and stays follower
|
for time.Now().Before(deadline) {
|
||||||
require.False(t, l1.TryAcquire(ctx))
|
leader1, leader2 := l1.IsLeader(), l2.IsLeader()
|
||||||
}
|
require.False(t, leader1 && leader2, "two leaders at once")
|
||||||
|
if leader1 != leader2 {
|
||||||
func waitForCond(t *testing.T, f func() bool) {
|
|
||||||
t.Helper()
|
|
||||||
for i := 0; i < 100; i++ {
|
|
||||||
if f() {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
time.Sleep(20 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
}
|
}
|
||||||
t.Fatal("timed out waiting for condition")
|
t.Fatal("no leader re-emerged after connection loss")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLeader_DistinctKeysAreIndependent(t *testing.T) {
|
func TestLeader_DistinctKeysAreIndependent(t *testing.T) {
|
||||||
testDB := dbtest.CreateTestPostgres(t)
|
testDB := dbtest.CreateTestPostgres(t)
|
||||||
ctx := context.Background()
|
l1 := pg.NewLeader(testDB.Primary(), 1, testRenewInterval)
|
||||||
l1 := pg.NewLeader(testDB.Primary(), 1)
|
defer l1.Close()
|
||||||
l2 := pg.NewLeader(testDB.Primary(), 2)
|
l2 := pg.NewLeader(testDB.Primary(), 2, testRenewInterval)
|
||||||
defer l1.Release()
|
defer l2.Close()
|
||||||
defer l2.Release()
|
// Different keys do not compete: both become effective leaders
|
||||||
require.True(t, l1.TryAcquire(ctx))
|
waitForLeader(t, l1)
|
||||||
require.True(t, l2.TryAcquire(ctx)) // Different keys do not compete
|
waitForLeader(t, l2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForLeader waits until the node believes it is the leader, or fails the test
|
||||||
|
func waitForLeader(t *testing.T, l *pg.Leader) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if l.IsLeader() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("node never became effective leader")
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -78,8 +78,8 @@ var (
|
|||||||
ClusterPeers = prometheus.NewGauge(prometheus.GaugeOpts{
|
ClusterPeers = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
Name: "ntfy_cluster_peers",
|
Name: "ntfy_cluster_peers",
|
||||||
})
|
})
|
||||||
ClusterMessagesRelayed = prometheus.NewCounter(prometheus.CounterOpts{
|
ClusterMessagesForwarded = prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "ntfy_cluster_messages_relayed_total",
|
Name: "ntfy_cluster_messages_forwarded_total",
|
||||||
})
|
})
|
||||||
ClusterSendErrors = prometheus.NewCounter(prometheus.CounterOpts{
|
ClusterSendErrors = prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "ntfy_cluster_send_errors_total",
|
Name: "ntfy_cluster_send_errors_total",
|
||||||
@@ -131,7 +131,7 @@ func init() {
|
|||||||
Topics,
|
Topics,
|
||||||
HTTPRequests,
|
HTTPRequests,
|
||||||
ClusterPeers,
|
ClusterPeers,
|
||||||
ClusterMessagesRelayed,
|
ClusterMessagesForwarded,
|
||||||
ClusterSendErrors,
|
ClusterSendErrors,
|
||||||
ClusterQueueDropped,
|
ClusterQueueDropped,
|
||||||
ClusterBatchesSent,
|
ClusterBatchesSent,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ var expectedMetricNames = []string{
|
|||||||
"ntfy_calls_made_success",
|
"ntfy_calls_made_success",
|
||||||
"ntfy_cluster_batches_sent_total",
|
"ntfy_cluster_batches_sent_total",
|
||||||
"ntfy_cluster_leader",
|
"ntfy_cluster_leader",
|
||||||
"ntfy_cluster_messages_relayed_total",
|
"ntfy_cluster_messages_forwarded_total",
|
||||||
"ntfy_cluster_messages_wasted_total",
|
"ntfy_cluster_messages_wasted_total",
|
||||||
"ntfy_cluster_peers",
|
"ntfy_cluster_peers",
|
||||||
"ntfy_cluster_queue_dropped_total",
|
"ntfy_cluster_queue_dropped_total",
|
||||||
|
|||||||
+17
-13
@@ -370,11 +370,14 @@ func New(conf *Config) (*Server, error) {
|
|||||||
// be reached from the outside even before any firewalling.
|
// be reached from the outside even before any firewalling.
|
||||||
func (s *Server) clusterHandler() http.Handler {
|
func (s *Server) clusterHandler() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
// TODO(T8): reflect s.cluster.Healthy() here (and in handleHealth) instead of a static
|
|
||||||
// response, so health checks can pull a node that lost its registry heartbeat
|
|
||||||
mux.HandleFunc(apiHealthPath, func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc(apiHealthPath, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
io.WriteString(w, `{"healthy":true}`+"\n")
|
if s.cluster.Healthy() {
|
||||||
|
io.WriteString(w, `{"healthy":true}`+"\n")
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
io.WriteString(w, `{"healthy":false}`+"\n")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
mux.Handle("/", s.cluster)
|
mux.Handle("/", s.cluster)
|
||||||
return mux
|
return mux
|
||||||
@@ -396,15 +399,15 @@ func (s *Server) liveTopics() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// topicAnnouncer returns the first-subscriber hook for a topic: it tells peer nodes right away
|
// topicAnnouncer returns the first-subscriber hook for a topic: it tells peer nodes right away
|
||||||
// that this node now wants messages for it (see Cluster.AnnounceTopics).
|
// that this node now wants messages for it (see Cluster.BroadcastState).
|
||||||
func (s *Server) topicAnnouncer(id string) func() {
|
func (s *Server) topicAnnouncer(id string) func() {
|
||||||
return func() {
|
return func() {
|
||||||
s.cluster.AnnounceTopics([]string{id})
|
s.cluster.BroadcastState(&cluster.State{AddedTopics: []string{id}})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// deliverFromBus delivers a message received from a peer node (via the cluster) to this
|
// deliverFromBus delivers a message received from a peer node (via the cluster) to this
|
||||||
// node's local subscribers. It is the receive-side counterpart to Cluster.Relay: local
|
// node's local subscribers. It is the receive-side counterpart to Cluster.ForwardMessage: local
|
||||||
// delivery and all global side effects (Firebase, email, web push, upstream) already ran on the
|
// delivery and all global side effects (Firebase, email, web push, upstream) already ran on the
|
||||||
// origin node, so this only publishes to the local topic, and never re-relays.
|
// origin node, so this only publishes to the local topic, and never re-relays.
|
||||||
func (s *Server) deliverFromBus(m *model.Message) {
|
func (s *Server) deliverFromBus(m *model.Message) {
|
||||||
@@ -829,12 +832,13 @@ func (s *Server) handleTopicAuth(w http.ResponseWriter, _ *http.Request, _ *visi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||||
// TODO(T8): in cluster mode, reflect s.cluster.Healthy() so DNS/LB health checks stop
|
// Unhealthy = the registry heartbeat went stale and peers stopped forwarding to this
|
||||||
// routing NEW clients to a node whose peers no longer relay to it (see Cluster interface)
|
// node; 503 lets status-code LB checks pull it (checkers must fail open, see cluster.Cluster)
|
||||||
response := &apiHealthResponse{
|
healthy := s.cluster.Healthy()
|
||||||
Healthy: true,
|
if !healthy {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, response)
|
return s.writeJSON(w, &apiHealthResponse{Healthy: healthy})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleMetrics returns Prometheus metrics. This endpoint is only called if enable-metrics is set,
|
// handleMetrics returns Prometheus metrics. This endpoint is only called if enable-metrics is set,
|
||||||
@@ -965,8 +969,8 @@ func (s *Server) dispatch(v *visitor, t *topic, m *model.Message, opts dispatchO
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Relay to peer cluster nodes, whose subscribers do not show up in this node's topics map
|
// ForwardMessage to peer cluster nodes, whose subscribers do not show up in this node's topics map
|
||||||
if err := s.cluster.Relay(m); err != nil {
|
if err := s.cluster.ForwardMessage(m); err != nil {
|
||||||
logvm(v, m).Err(err).Warn("Cluster: unable to relay message to peer nodes")
|
logvm(v, m).Err(err).Warn("Cluster: unable to relay message to peer nodes")
|
||||||
}
|
}
|
||||||
// Fire the requested side-effect targets
|
// Fire the requested side-effect targets
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/v2/cluster"
|
||||||
dbtest "heckel.io/ntfy/v2/db/test"
|
dbtest "heckel.io/ntfy/v2/db/test"
|
||||||
"heckel.io/ntfy/v2/model"
|
"heckel.io/ntfy/v2/model"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
@@ -20,13 +21,14 @@ import (
|
|||||||
// fakeCluster records relayed messages and topic announcements so tests can assert that every
|
// fakeCluster records relayed messages and topic announcements so tests can assert that every
|
||||||
// publish path passes through the cluster exactly once, and that subscription hooks fire.
|
// publish path passes through the cluster exactly once, and that subscription hooks fire.
|
||||||
type fakeCluster struct {
|
type fakeCluster struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
messages []*model.Message
|
messages []*model.Message
|
||||||
announced []string
|
announced []string
|
||||||
notLeader bool
|
notLeader bool
|
||||||
|
notHealthy bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *fakeCluster) Relay(m *model.Message) error {
|
func (b *fakeCluster) ForwardMessage(m *model.Message) error {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
b.messages = append(b.messages, m)
|
b.messages = append(b.messages, m)
|
||||||
@@ -35,10 +37,22 @@ func (b *fakeCluster) Relay(m *model.Message) error {
|
|||||||
|
|
||||||
func (b *fakeCluster) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {}
|
func (b *fakeCluster) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {}
|
||||||
|
|
||||||
func (b *fakeCluster) AnnounceTopics(topics []string) {
|
func (b *fakeCluster) BroadcastState(state *cluster.State) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
b.announced = append(b.announced, topics...)
|
b.announced = append(b.announced, state.AddedTopics...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *fakeCluster) Healthy() bool {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return !b.notHealthy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *fakeCluster) setHealthy(healthy bool) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
b.notHealthy = !healthy
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *fakeCluster) IsLeader() bool {
|
func (b *fakeCluster) IsLeader() bool {
|
||||||
@@ -67,7 +81,7 @@ func (b *fakeCluster) Announced() []string {
|
|||||||
return append([]string{}, b.announced...)
|
return append([]string{}, b.announced...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_Cluster_PublishRelaysOnce(t *testing.T) {
|
func TestServer_Cluster_PublishForwardsOnce(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t, ""))
|
s := newTestServer(t, newTestConfig(t, ""))
|
||||||
b := &fakeCluster{}
|
b := &fakeCluster{}
|
||||||
s.cluster = b
|
s.cluster = b
|
||||||
@@ -79,7 +93,7 @@ func TestServer_Cluster_PublishRelaysOnce(t *testing.T) {
|
|||||||
require.Equal(t, "hi there", messages[0].Message)
|
require.Equal(t, "hi there", messages[0].Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_Cluster_SyncEventRelays(t *testing.T) {
|
func TestServer_Cluster_SyncEventForwards(t *testing.T) {
|
||||||
// Account sync events are delivered via the user's st_... sync topic; without relaying
|
// Account sync events are delivered via the user's st_... sync topic; without relaying
|
||||||
// them, cross-device account sync silently breaks when a user's devices land on different
|
// them, cross-device account sync silently breaks when a user's devices land on different
|
||||||
// cluster nodes.
|
// cluster nodes.
|
||||||
@@ -316,3 +330,24 @@ func TestServer_Cluster_FirebaseKeepaliverOnlyOnLeader(t *testing.T) {
|
|||||||
cl.setLeader(true)
|
cl.setLeader(true)
|
||||||
waitFor(t, func() bool { return len(sender.Messages()) > 0 })
|
waitFor(t, func() bool { return len(sender.Messages()) > 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_Cluster_HealthReflectsCluster(t *testing.T) {
|
||||||
|
// A node whose registry heartbeat went stale no longer receives forwarded messages, so
|
||||||
|
// health checks must pull it from rotation (the fail-open policy lives in the checker)
|
||||||
|
s := newTestServer(t, newTestConfig(t, ""))
|
||||||
|
cl := &fakeCluster{}
|
||||||
|
s.cluster = cl
|
||||||
|
rr := request(t, s, "GET", "/v1/health", "", nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
require.Contains(t, rr.Body.String(), `"healthy":true`)
|
||||||
|
cl.setHealthy(false)
|
||||||
|
rr = request(t, s, "GET", "/v1/health", "", nil)
|
||||||
|
require.Equal(t, 503, rr.Code)
|
||||||
|
require.Contains(t, rr.Body.String(), `"healthy":false`)
|
||||||
|
// The cluster listener's health endpoint reflects the same state
|
||||||
|
rr2 := httptest.NewRecorder()
|
||||||
|
req, err := http.NewRequest("GET", "/v1/health", nil)
|
||||||
|
require.Nil(t, err)
|
||||||
|
s.clusterHandler().ServeHTTP(rr2, req)
|
||||||
|
require.Equal(t, 503, rr2.Code)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user