Files
Warp/server/internal/storage/sqlstore/sync.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

599 lines
19 KiB
Go

package sqlstore
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Sync is the shared SQL implementation of storage.SyncStore: the streams a
// sync response carries that are not room events.
//
// Like the other component stores it is written once with `?` placeholders
// and rebound per driver. Every stream here follows the same shape — a
// counter row handed out inside the writing transaction, and a stream_pos
// column on the row it stamps — so that a reader can ask any of them "what
// changed after position N" with one index lookup.
type Sync struct {
db *sql.DB
rebind func(string) string
isUnique func(error) bool
}
// NewSync builds a sync store.
func NewSync(db *sql.DB, rebind func(string) string, isUnique func(error) bool) *Sync {
if rebind == nil {
rebind = func(query string) string { return query }
}
if isUnique == nil {
isUnique = func(error) bool { return false }
}
return &Sync{db: db, rebind: rebind, isUnique: isUnique}
}
// maxToDeviceBatch bounds how many to-device messages one sync carries. A
// device that has been offline for a week can have a long queue, and the
// rest of it is still there on the next sync.
const maxToDeviceBatch = 100
// q rebinds a statement for this driver.
func (s *Sync) q(query string) string { return s.rebind(query) }
// tx runs fn in a transaction, rolling back on any error.
func (s *Sync) tx(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
if err := fn(tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
const selectStreamPositionsSQL = `
SELECT name, value FROM sync_sequences`
// StreamPositions implements storage.SyncStore.
//
// The PDU position comes from the events table and the rest from the
// counter rows, and both are read here so that a caller gets one consistent
// set. The counters are read rather than the rows they stamp because a
// stream whose latest row was deleted — a drained to-device queue — must
// not appear to have gone backwards.
func (s *Sync) StreamPositions(ctx context.Context) (storage.StreamPositions, error) {
var out storage.StreamPositions
err := s.db.QueryRowContext(ctx, s.q(selectMaxStreamOrderingSQL)).Scan(&out.PDU)
if err != nil {
return storage.StreamPositions{}, fmt.Errorf("read the top of the event stream: %w", err)
}
rows, err := s.db.QueryContext(ctx, s.q(selectStreamPositionsSQL))
if err != nil {
return storage.StreamPositions{}, fmt.Errorf("read the stream positions: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
name string
value int64
)
if err := rows.Scan(&name, &value); err != nil {
return storage.StreamPositions{}, fmt.Errorf("read the stream positions: %w", err)
}
switch name {
case seqReceipt:
out.Receipt = value
case seqToDevice:
out.ToDevice = value
case seqAccountData:
out.AccountData = value
case seqPresence:
out.Presence = value
}
}
if err := rows.Err(); err != nil {
return storage.StreamPositions{}, fmt.Errorf("read the stream positions: %w", err)
}
// The device list stream is the keyserver's, and is read here all the
// same, for the same reason the event stream is: a token has to name
// one moment in every stream it carries.
out.DeviceList, err = currentSequence(ctx, s.db, s.q, keySequences, seqDeviceList)
if err != nil {
return storage.StreamPositions{}, err
}
// And the push store's notification counts, for the same reason.
out.Notification, err = currentSequence(ctx, s.db, s.q, pushSequences, seqNotification)
if err != nil {
return storage.StreamPositions{}, err
}
return out, nil
}
const (
selectReceiptPositionSQL = `
SELECT e.stream_ordering FROM roomserver_events e
WHERE e.room_id = ? AND e.event_id = ?`
selectCurrentReceiptSQL = `
SELECT r.event_id, e.stream_ordering, r.stream_pos
FROM sync_receipts r
LEFT JOIN roomserver_events e ON e.event_id = r.event_id
WHERE r.room_id = ? AND r.user_id = ? AND r.receipt_type = ? AND r.thread_id = ?`
upsertReceiptSQL = `
INSERT INTO sync_receipts
(room_id, user_id, receipt_type, thread_id, event_id, ts_ms, stream_pos)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (room_id, user_id, receipt_type, thread_id) DO UPDATE SET
event_id = excluded.event_id,
ts_ms = excluded.ts_ms,
stream_pos = excluded.stream_pos`
)
// SetReceipt implements storage.SyncStore.
//
// A receipt that would move backwards is dropped. Two clients of one user
// read a room at different speeds, and letting the slower one win would
// make the unread marker jump backwards on the other — so the comparison is
// on the position of the event being acknowledged, not on when the receipt
// arrived.
func (s *Sync) SetReceipt(ctx context.Context, r storage.Receipt) (int64, bool, error) {
var (
pos int64
moved bool
)
err := s.tx(ctx, func(tx *sql.Tx) error {
var target int64
err := tx.QueryRowContext(ctx, s.q(selectReceiptPositionSQL), r.RoomID, r.EventID).Scan(&target)
switch {
case errors.Is(err, sql.ErrNoRows):
return fmt.Errorf("event %q: %w", r.EventID, storage.ErrNoSuchEvent)
case err != nil:
return fmt.Errorf("locate the event a receipt names: %w", err)
}
var (
haveEventID string
havePos sql.NullInt64
haveStream int64
)
err = tx.QueryRowContext(ctx, s.q(selectCurrentReceiptSQL),
r.RoomID, r.UserID, r.ReceiptType, r.ThreadID).Scan(&haveEventID, &havePos, &haveStream)
switch {
case err == nil:
// A receipt whose event has since been purged has no position
// to compare against, and the new one supersedes it.
if havePos.Valid && havePos.Int64 >= target {
pos, moved = haveStream, false
return nil
}
case !errors.Is(err, sql.ErrNoRows):
return fmt.Errorf("read the current receipt: %w", err)
}
if pos, err = nextSequence(ctx, tx, s.q, syncSequences, seqReceipt); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, s.q(upsertReceiptSQL),
r.RoomID, r.UserID, r.ReceiptType, r.ThreadID, r.EventID, r.TimestampMS, pos); err != nil {
return fmt.Errorf("write the receipt: %w", err)
}
moved = true
return nil
})
if err != nil {
return 0, false, fmt.Errorf("set receipt in %q: %w", r.RoomID, err)
}
return pos, moved, nil
}
const receiptColumns = `
room_id, user_id, receipt_type, thread_id, event_id, ts_ms, stream_pos`
func scanReceipts(rows *sql.Rows) ([]storage.Receipt, error) {
defer func() { _ = rows.Close() }()
var out []storage.Receipt
for rows.Next() {
var r storage.Receipt
if err := rows.Scan(&r.RoomID, &r.UserID, &r.ReceiptType, &r.ThreadID,
&r.EventID, &r.TimestampMS, &r.StreamPos); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// Receipts implements storage.SyncStore.
func (s *Sync) Receipts(ctx context.Context, roomIDs []string, from, to int64) ([]storage.Receipt, error) {
if len(roomIDs) == 0 {
return nil, nil
}
query := `SELECT ` + receiptColumns + ` FROM sync_receipts
WHERE room_id IN (` + placeholders(len(roomIDs)) + `) AND stream_pos > ?`
args := make([]any, 0, len(roomIDs)+2)
for _, id := range roomIDs {
args = append(args, id)
}
args = append(args, from)
if to > 0 {
query += ` AND stream_pos <= ?`
args = append(args, to)
}
query += ` ORDER BY stream_pos`
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
if err != nil {
return nil, fmt.Errorf("read receipts: %w", err)
}
receipts, err := scanReceipts(rows)
if err != nil {
return nil, fmt.Errorf("read receipts: %w", err)
}
return receipts, nil
}
// ReceiptsForUser implements storage.SyncStore.
func (s *Sync) ReceiptsForUser(ctx context.Context, userID string, roomIDs []string) ([]storage.Receipt, error) {
if len(roomIDs) == 0 {
return nil, nil
}
query := `SELECT ` + receiptColumns + ` FROM sync_receipts
WHERE user_id = ? AND room_id IN (` + placeholders(len(roomIDs)) + `)
ORDER BY stream_pos`
args := make([]any, 0, len(roomIDs)+1)
args = append(args, userID)
for _, id := range roomIDs {
args = append(args, id)
}
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
if err != nil {
return nil, fmt.Errorf("read the receipts of %q: %w", userID, err)
}
receipts, err := scanReceipts(rows)
if err != nil {
return nil, fmt.Errorf("read the receipts of %q: %w", userID, err)
}
return receipts, nil
}
const insertToDeviceSQL = `
INSERT INTO sync_to_device
(stream_pos, localpart, device_id, sender, event_type, content, sent_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?)`
// SendToDevice implements storage.SyncStore.
//
// The whole batch shares one transaction but takes a position each: a
// message to two of a user's devices is two independent deliveries, and one
// device acknowledging its copy must not drop the other's.
func (s *Sync) SendToDevice(ctx context.Context, msgs []storage.NewToDeviceMessage) (int64, error) {
var top int64
err := s.tx(ctx, func(tx *sql.Tx) error {
if len(msgs) == 0 {
var err error
top, err = currentSequence(ctx, tx, s.q, syncSequences, seqToDevice)
return err
}
for _, m := range msgs {
pos, err := nextSequence(ctx, tx, s.q, syncSequences, seqToDevice)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, s.q(insertToDeviceSQL),
pos, m.Localpart, m.DeviceID, m.Sender, m.Type, string(m.Content), m.SentAtMS); err != nil {
return fmt.Errorf("queue a to-device message for %s/%s: %w", m.Localpart, m.DeviceID, err)
}
top = pos
}
return nil
})
if err != nil {
return 0, fmt.Errorf("send to-device messages: %w", err)
}
return top, nil
}
const selectToDeviceSQL = `
SELECT stream_pos, localpart, device_id, sender, event_type, content, sent_at_ms
FROM sync_to_device
WHERE localpart = ? AND device_id = ? AND stream_pos > ?`
// ToDeviceMessages implements storage.SyncStore.
func (s *Sync) ToDeviceMessages(ctx context.Context, localpart, deviceID string, from, to int64, limit int) ([]storage.ToDeviceMessage, error) {
if limit <= 0 || limit > maxToDeviceBatch {
limit = maxToDeviceBatch
}
query := selectToDeviceSQL
args := []any{localpart, deviceID, from}
if to > 0 {
query += ` AND stream_pos <= ?`
args = append(args, to)
}
query += ` ORDER BY stream_pos LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
if err != nil {
return nil, fmt.Errorf("read to-device messages for %s/%s: %w", localpart, deviceID, err)
}
defer func() { _ = rows.Close() }()
var out []storage.ToDeviceMessage
for rows.Next() {
var (
m storage.ToDeviceMessage
content string
)
if err := rows.Scan(&m.StreamPos, &m.Localpart, &m.DeviceID,
&m.Sender, &m.Type, &content, &m.SentAtMS); err != nil {
return nil, fmt.Errorf("read to-device messages for %s/%s: %w", localpart, deviceID, err)
}
m.Content = []byte(content)
out = append(out, m)
}
return out, rows.Err()
}
const deleteToDeviceSQL = `
DELETE FROM sync_to_device
WHERE localpart = ? AND device_id = ? AND stream_pos <= ?`
// DeleteToDeviceMessages implements storage.SyncStore.
func (s *Sync) DeleteToDeviceMessages(ctx context.Context, localpart, deviceID string, pos int64) error {
if pos <= 0 {
return nil
}
if _, err := s.db.ExecContext(ctx, s.q(deleteToDeviceSQL), localpart, deviceID, pos); err != nil {
return fmt.Errorf("delete to-device messages for %s/%s: %w", localpart, deviceID, err)
}
return nil
}
const (
upsertPresenceSQL = `
INSERT INTO sync_presence (user_id, presence, status_msg, last_active_ms, stream_pos)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (user_id) DO UPDATE SET
presence = excluded.presence,
status_msg = excluded.status_msg,
last_active_ms = excluded.last_active_ms,
stream_pos = excluded.stream_pos`
touchPresenceSQL = `
UPDATE sync_presence SET last_active_ms = ? WHERE user_id = ?`
)
// SetPresence implements storage.SyncStore.
//
// A write that changes nothing a peer would notice refreshes the
// last-active timestamp in place and leaves the stream where it was. Every
// /sync is such a write — a client says it is still online by syncing — and
// moving the stream each time would wake every peer's long poll, which
// would sync, which would move it again.
func (s *Sync) SetPresence(ctx context.Context, p storage.PresenceState) (int64, bool, error) {
var (
pos int64
moved bool
)
err := s.tx(ctx, func(tx *sql.Tx) error {
var existing storage.PresenceState
err := tx.QueryRowContext(ctx, s.q(selectPresenceSQL), p.UserID).Scan(
&existing.UserID, &existing.Presence, &existing.StatusMsg,
&existing.LastActiveMS, &existing.StreamPos)
switch {
case err == nil:
if existing.Presence == p.Presence && existing.StatusMsg == p.StatusMsg {
pos = existing.StreamPos
if _, err := tx.ExecContext(ctx, s.q(touchPresenceSQL),
p.LastActiveMS, p.UserID); err != nil {
return fmt.Errorf("refresh the last-active timestamp: %w", err)
}
return nil
}
case !errors.Is(err, sql.ErrNoRows):
return fmt.Errorf("read the current presence: %w", err)
}
if pos, err = nextSequence(ctx, tx, s.q, syncSequences, seqPresence); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, s.q(upsertPresenceSQL),
p.UserID, p.Presence, p.StatusMsg, p.LastActiveMS, pos); err != nil {
return fmt.Errorf("write presence: %w", err)
}
moved = true
return nil
})
if err != nil {
return 0, false, fmt.Errorf("set the presence of %q: %w", p.UserID, err)
}
return pos, moved, nil
}
const presenceColumns = `user_id, presence, status_msg, last_active_ms, stream_pos`
const selectPresenceSQL = `
SELECT ` + presenceColumns + ` FROM sync_presence WHERE user_id = ?`
// Presence implements storage.SyncStore.
func (s *Sync) Presence(ctx context.Context, userID string) (storage.PresenceState, error) {
var p storage.PresenceState
err := s.db.QueryRowContext(ctx, s.q(selectPresenceSQL), userID).
Scan(&p.UserID, &p.Presence, &p.StatusMsg, &p.LastActiveMS, &p.StreamPos)
switch {
case errors.Is(err, sql.ErrNoRows):
return storage.PresenceState{}, fmt.Errorf("presence of %q: %w", userID, storage.ErrNoSuchUser)
case err != nil:
return storage.PresenceState{}, fmt.Errorf("read the presence of %q: %w", userID, err)
}
return p, nil
}
// PresenceForUsers implements storage.SyncStore.
func (s *Sync) PresenceForUsers(ctx context.Context, userIDs []string, from, to int64) ([]storage.PresenceState, error) {
if len(userIDs) == 0 {
return nil, nil
}
query := `SELECT ` + presenceColumns + ` FROM sync_presence
WHERE user_id IN (` + placeholders(len(userIDs)) + `) AND stream_pos > ?`
args := make([]any, 0, len(userIDs)+2)
for _, id := range userIDs {
args = append(args, id)
}
args = append(args, from)
if to > 0 {
query += ` AND stream_pos <= ?`
args = append(args, to)
}
query += ` ORDER BY stream_pos`
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
if err != nil {
return nil, fmt.Errorf("read presence: %w", err)
}
defer func() { _ = rows.Close() }()
var out []storage.PresenceState
for rows.Next() {
var p storage.PresenceState
if err := rows.Scan(&p.UserID, &p.Presence, &p.StatusMsg, &p.LastActiveMS, &p.StreamPos); err != nil {
return nil, fmt.Errorf("read presence: %w", err)
}
out = append(out, p)
}
return out, rows.Err()
}
const selectAccountDataChangesSQL = `
SELECT room_id, data_type, stream_pos FROM user_account_data
WHERE localpart = ? AND stream_pos > ?`
// AccountDataChanges implements storage.SyncStore.
func (s *Sync) AccountDataChanges(ctx context.Context, localpart string, from, to int64) ([]storage.AccountDataEntry, error) {
query := selectAccountDataChangesSQL
args := []any{localpart, from}
if to > 0 {
query += ` AND stream_pos <= ?`
args = append(args, to)
}
query += ` ORDER BY stream_pos`
return s.accountData(ctx, localpart, query, args)
}
const selectAllAccountDataSQL = `
SELECT room_id, data_type, stream_pos FROM user_account_data
WHERE localpart = ?`
// AccountDataAt implements storage.SyncStore.
func (s *Sync) AccountDataAt(ctx context.Context, localpart string, to int64) ([]storage.AccountDataEntry, error) {
query := selectAllAccountDataSQL
args := []any{localpart}
if to > 0 {
query += ` AND stream_pos <= ?`
args = append(args, to)
}
query += ` ORDER BY room_id, data_type`
return s.accountData(ctx, localpart, query, args)
}
func (s *Sync) accountData(ctx context.Context, localpart, query string, args []any) ([]storage.AccountDataEntry, error) {
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
if err != nil {
return nil, fmt.Errorf("read the account data of %q: %w", localpart, err)
}
defer func() { _ = rows.Close() }()
var out []storage.AccountDataEntry
for rows.Next() {
var e storage.AccountDataEntry
if err := rows.Scan(&e.RoomID, &e.DataType, &e.StreamPos); err != nil {
return nil, fmt.Errorf("read the account data of %q: %w", localpart, err)
}
out = append(out, e)
}
return out, rows.Err()
}
const insertFilterSQL = `
INSERT INTO sync_filters (localpart, filter_id, filter_json, created_at_ms)
VALUES (?, ?, ?, ?)`
// SaveFilter implements storage.SyncStore.
//
// The ID is random rather than a counter. The spec says only that it is
// opaque, and a counter would be a per-user one: two users would both have
// a filter "1", so a filter ID leaking into a URL or a log would say which
// user it belonged to only by accident of context.
func (s *Sync) SaveFilter(ctx context.Context, localpart string, filter []byte, createdAtMS int64) (string, error) {
for attempt := range 3 {
filterID, err := newFilterID()
if err != nil {
return "", err
}
_, err = s.db.ExecContext(ctx, s.q(insertFilterSQL),
localpart, filterID, string(filter), createdAtMS)
switch {
case err == nil:
return filterID, nil
case s.isUnique(err) && attempt < 2:
// 96 bits of randomness collided, or the caller's own ID did.
// Either way the answer is another ID.
continue
default:
return "", fmt.Errorf("save a filter for %q: %w", localpart, err)
}
}
return "", fmt.Errorf("save a filter for %q: could not find an unused filter ID", localpart)
}
// newFilterID mints a filter ID: 12 random bytes, URL-safe base64.
func newFilterID() (string, error) {
var raw [12]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", fmt.Errorf("generate a filter ID: %w", err)
}
return base64.RawURLEncoding.EncodeToString(raw[:]), nil
}
const selectFilterSQL = `
SELECT filter_json FROM sync_filters WHERE localpart = ? AND filter_id = ?`
// Filter implements storage.SyncStore.
func (s *Sync) Filter(ctx context.Context, localpart, filterID string) ([]byte, error) {
var body string
err := s.db.QueryRowContext(ctx, s.q(selectFilterSQL), localpart, filterID).Scan(&body)
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, fmt.Errorf("filter %q: %w", filterID, storage.ErrNoSuchFilter)
case err != nil:
return nil, fmt.Errorf("read filter %q: %w", filterID, err)
}
return []byte(body), nil
}
// Compile-time proof that the shared implementation satisfies the interface.
var _ storage.SyncStore = (*Sync)(nil)