Files
Warp/server/internal/pushserver/pushserver.go
T
tajniak81andClaude Opus 5.5 487e0f410b
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Phase 10: hardening and ops
Implements PLAN.md Phase 10.

Simplified Sliding Sync (MSC4186) is served at the unstable
org.matrix.simplified_msc3575 endpoint and advertised in /versions. Lists
are sorted by activity and filtered by DM, invite, encryption, room type,
space and tag; room subscriptions, required_state with wildcards, $ME and
$LAZY, and the to_device, e2ee, account_data, receipts and typing
extensions are supported. Per-connection state is kept in memory and an
unknown pos answers M_UNKNOWN_POS.

Threads and relations: m.relates_to is indexed on roomserver_events
(migration 0012), /relations (with recursion) and /threads are served, and
every event a timeline read returns carries its m.thread, m.replace and
m.reference summaries in unsigned.m.relations. Notifications carry a
thread ID (migration 0013): threaded receipts clear only their thread, and
sync reports unread_thread_notifications when the filter asks.

Spaces: /hierarchy walks a space breadth first, asking other servers about
rooms this one is not in through the new federation hierarchy endpoint;
room_summary describes one room. /search runs on FTS5 (sqlite, pocketbase)
or tsvector (postgres), bounded to what each user may see, with context,
profiles, state, grouping and paging.

The PocketBase driver is real: the sqlite schema and store on an embedded
PocketBase app's database, with media in its filesystem, local or S3
(database.pocketbase.s3). It runs the full storage test suite.

Ops: OpenTelemetry tracing over OTLP/HTTP (tracing.*), with server spans
per route, component spans, and trace propagation on federation, push and
application service calls. New metrics for persisted events, waiting
syncs, sliding sync connections and the database pool, with a Grafana
dashboard. deploy/ adds a Compose setup (PostgreSQL, Prometheus, Grafana,
Jaeger) and a Helm chart; cmd/warp-loadtest loads a server and reports
request and delivery latencies.

The load tester found a long-standing bug, fixed here: on a server where
no account data had been written, every incremental /sync re-sent the push
rules and returned at once, and an all-zero token was taken for an initial
sync, so long polls became busy loops.

The PostgreSQL migrations and search dialect compile but were not run
against a live PostgreSQL in this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:21:31 +02:00

253 lines
7.8 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"
"github.com/dariuszjaniak/warp/server/internal/tracing"
)
// 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, Transport: tracing.Transport(nil)}
}
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)
}
}
}