Files
Warp/server/internal/pushserver/pushserver.go
T
tajniak81andClaude Opus 5.5 b507753172 Phase 8: push and notifications
Implements PLAN.md Phase 8. internal/pushrules is the rule language: the
spec's default ruleset, all six condition kinds, glob and word-boundary
matching, intentional mentions switching off the legacy body rules, and
rule ordering as Synapse has it. internal/pushserver keeps the rules,
pushers and notifications and talks to push gateways.

Push rules are the m.push_rules account data, written only by the push
server; reads keep the user's own rules and the enabled/actions of each
default and take the rest from the current defaults, so new default rules
reach every user without a migration. PUT account_data/m.push_rules is
refused with 405.

The push server listens on the roomserver's emit stage and evaluates each
event on its own worker for every local member and an invite's target.
Notifications and per-room unread counts are stored in migration 0010;
counts are a seventh sync token stream, so /sync reports
unread_notifications and picks up count changes made after the event or by
a receipt from another device. m.read and m.read.private receipts clear
the counts; leaving a room clears it.

Pushers (http kind only) push through a bounded sender pool with retries,
honour event_id_only, drop push keys the gateway rejects, and go away with
their device. GET /notifications pages the history. New config section
push: enabled, include_content, gateway_timeout, notification_retention.

Account data writes now wake the user's blocked syncs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:43:36 +02:00

252 lines
7.7 KiB
Go

// Package pushserver turns events into notifications: it keeps each user's
// push rules and pushers, evaluates every event against the rules of every
// local member of its room, records what notified whom with per-room unread
// counts, and pushes to the push gateways of the users' devices.
//
// It is PLAN.md Phase 8. The rule language is internal/pushrules; this
// package is everything around it that needs storage, rooms or a network.
//
// It hangs off the roomserver's emit stage like the federation queue does,
// and like the queue it does no work there: the emit stage runs with the
// room's write lock held, and evaluating an event needs the room's members,
// their display names, the power levels and a ruleset per member. So an
// event is put on a list and a worker of this package's own takes it from
// there. What that costs is a moment in which a sync can show a message
// whose notification has not been counted yet; the counts are a stream of
// their own in the sync token (see storage.PushStore), so the sync that
// follows the worker's write carries the corrected count.
package pushserver
import (
"context"
"errors"
"log/slog"
"net/http"
"sync"
"time"
"github.com/dariuszjaniak/warp/server/internal/pushrules"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Errors callers have to tell apart. The rule-language sentinels are
// re-exported so that a handler needs only this package.
var (
ErrNoSuchRule = pushrules.ErrNoSuchRule
ErrNoSuchAnchor = pushrules.ErrNoSuchAnchor
ErrInvalidRule = pushrules.ErrInvalidRule
// ErrInvalidPusher is a pusher a client may not register.
ErrInvalidPusher = errors.New("invalid pusher")
// ErrMissingPusherField is a pusher missing a field the spec requires.
ErrMissingPusherField = errors.New("missing pusher field")
)
// Accounts is the part of the userapi this package needs: push rules are
// the m.push_rules account data. *userapi.API satisfies it.
type Accounts interface {
AccountData(ctx context.Context, localpart, roomID, dataType string) ([]byte, error)
SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error
}
// Rooms is the part of the roomserver this package reads. *roomserver.API
// satisfies it.
type Rooms interface {
PushRoomFor(ctx context.Context, roomID string) (roomserver.PushRoom, error)
Event(ctx context.Context, roomID, userID, eventID string) (roomserver.ClientEvent, error)
EventPosition(ctx context.Context, roomID, userID, eventID string) (int64, error)
}
// Notifier wakes a user's blocked syncs. *syncapi.Notifier satisfies it.
type Notifier interface {
NotifyUser(userID string)
NotifyUsers(userIDs []string)
}
// API is the push server.
type API struct {
serverName string
store storage.PushStore
accounts Accounts
rooms Rooms
notifier Notifier
gateway *gateway
log *slog.Logger
now func() time.Time
// pushEnabled sends pushes to gateways. Notifications are counted
// either way: the counts are what a client shows, and are no less
// right on a server that does not push.
pushEnabled bool
// retention is how long a read notification is kept for GET
// /notifications before it is pruned.
retention time.Duration
// rules caches each user's merged ruleset. Every event is evaluated
// against the rules of every local member of its room, and reading
// and merging them from account data each time would put a query per
// member in front of every message. Every write goes through this
// package, which is what makes the cache safe in one process.
rulesMu sync.Mutex
rulesCache map[string]pushrules.Ruleset
writeLocks sync.Map // localpart -> *sync.Mutex
// The event list the emit stage appends to and the worker drains.
mu sync.Mutex
incoming []roomserver.Notification
busy bool
wake chan struct{}
idle *sync.Cond
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
// Options configure the component.
type Options struct {
ServerName string
Store storage.PushStore
Accounts Accounts
Rooms Rooms
// Notifier wakes the syncs a count change concerns. Nil wakes
// nobody, which only a test wants.
Notifier Notifier
// PushEnabled sends pushes to the pushers' gateways.
PushEnabled bool
// IncludeContent puts the event's content into a push. Off, a push
// carries only who sent what kind of event where, for operators who
// would rather not have message text pass through a third-party push
// service.
IncludeContent bool
// GatewayTimeout bounds one request to a push gateway.
GatewayTimeout time.Duration
// HTTPClient sends pushes; nil builds one. Tests pass their own.
HTTPClient *http.Client
// NotificationRetention is how long a read notification is kept.
NotificationRetention time.Duration
Logger *slog.Logger
Now func() time.Time
}
// Defaults.
const (
DefaultGatewayTimeout = 10 * time.Second
DefaultNotificationRetention = 30 * 24 * time.Hour
// pruneInterval is how often read notifications past their retention
// are deleted.
pruneInterval = time.Hour
)
// New builds the component and starts its worker. Close stops it.
func New(opts Options) (*API, error) {
switch {
case opts.ServerName == "":
return nil, errors.New("pushserver: ServerName must not be empty")
case opts.Store == nil:
return nil, errors.New("pushserver: Store must not be nil")
case opts.Accounts == nil:
return nil, errors.New("pushserver: Accounts must not be nil")
case opts.Rooms == nil:
return nil, errors.New("pushserver: Rooms must not be nil")
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
if opts.Now == nil {
opts.Now = time.Now
}
if opts.GatewayTimeout <= 0 {
opts.GatewayTimeout = DefaultGatewayTimeout
}
if opts.NotificationRetention <= 0 {
opts.NotificationRetention = DefaultNotificationRetention
}
if opts.HTTPClient == nil {
opts.HTTPClient = &http.Client{Timeout: opts.GatewayTimeout}
}
ctx, cancel := context.WithCancel(context.Background())
a := &API{
serverName: opts.ServerName,
store: opts.Store,
accounts: opts.Accounts,
rooms: opts.Rooms,
notifier: opts.Notifier,
log: opts.Logger,
now: opts.Now,
pushEnabled: opts.PushEnabled,
retention: opts.NotificationRetention,
rulesCache: map[string]pushrules.Ruleset{},
wake: make(chan struct{}, 1),
ctx: ctx,
cancel: cancel,
}
a.idle = sync.NewCond(&a.mu)
a.gateway = newGateway(gatewayOptions{
client: opts.HTTPClient,
includeContent: opts.IncludeContent,
rejected: a.pushKeyRejected,
log: opts.Logger,
})
a.wg.Add(2)
go a.work()
go a.prune()
a.gateway.start(ctx)
return a, nil
}
// Close stops the worker and the gateway senders. Events not yet
// evaluated are dropped: the emit stage is not durable, and a
// notification is not worth holding up a shutdown for.
func (a *API) Close() {
a.cancel()
a.mu.Lock()
a.idle.Broadcast()
a.mu.Unlock()
a.wg.Wait()
a.gateway.wait()
}
// notifyUsers wakes syncs, if there is anybody to wake them.
func (a *API) notifyUsers(userIDs ...string) {
if a.notifier == nil || len(userIDs) == 0 {
return
}
a.notifier.NotifyUsers(userIDs)
}
// prune deletes read notifications past their retention, hourly.
func (a *API) prune() {
defer a.wg.Done()
ticker := time.NewTicker(pruneInterval)
defer ticker.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-ticker.C:
}
cutoff := a.now().Add(-a.retention).UnixMilli()
n, err := a.store.PruneNotifications(a.ctx, cutoff)
switch {
case err != nil && a.ctx.Err() == nil:
a.log.Warn("pruning read notifications failed", "error", err)
case n > 0:
a.log.Debug("pruned read notifications", "count", n)
}
}
}