mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-11-03 18:05:44 +00:00
8204f03d25
* feat(server): push dispatch timings are configurable, and a bad value refuses startup (TASK-3939) Claude-Session: https://claude.ai/code/session_01D6M9onL7HhMovW8DZbs6f5 * docs(deployment): push gateway env vars, dispatch timings and the cleartext warning (TASK-3939) Claude-Session: https://claude.ai/code/session_01D6M9onL7HhMovW8DZbs6f5 * fix(server): the lease refusal names the cause, and an overflowing margin is refused (TASK-3939) SetPushDispatchConfig wraps errPushLeaseTooShort with the resolved lease, send timeout and margin; configurePush names both knobs and appends it. A margin within sendTimeout of MaxInt64 is refused before the sum can wrap. Whitespace-only values stay malformed and are now documented and tested; values are otherwise uncapped. Claude-Session: https://claude.ai/code/session_01D6M9onL7HhMovW8DZbs6f5
834 lines
28 KiB
Go
834 lines
28 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/notify"
|
|
"github.com/PerpetualSoftware/pad/internal/store"
|
|
)
|
|
|
|
// The push dispatcher (TASK-3939 part B1): claims settled user_notifications
|
|
// rows, runs every privacy gate (push_gates.go) on each, groups the survivors
|
|
// per (user, item), sends one content-free push per live device, and settles
|
|
// the rows. Part B2a added the 30/h cap and the summary push, the retry matrix
|
|
// (which results retry, skip or delete a device), and the expire and retention
|
|
// sweeps. Server startup (cmd/pad configurePush, TASK-3939 B2c) calls
|
|
// StartPushDispatch when PAD_PUSH_GATEWAY_URL is set, after applying the
|
|
// PAD_PUSH_DISPATCH_* timing knobs through SetPushDispatchConfig (TASK-3939
|
|
// part C); an unset knob keeps its default below.
|
|
//
|
|
// LEASE INVARIANT (r6-Q1): lease >= sendTimeout + leaseMargin. A claim is
|
|
// stamped with second precision (up to 1s behind the real clock), and a Send
|
|
// can take sendTimeout (notify.SendTimeout), so a lease shorter than that lets
|
|
// another instance reclaim a row mid-send and push it twice. It is enforced
|
|
// three ways: the setter refuses a violating config, all gateway I/O runs
|
|
// under a deadline of start+lease-leaseMargin, and a chunk only starts when
|
|
// sendTimeout is still left before that deadline (otherwise its rows are
|
|
// unclaimed, no attempt burned). Startup applies the operator's knobs through
|
|
// the setter, so a lease too short for sendTimeout (fixed at notify.SendTimeout,
|
|
// not configurable) plus the margin refuses startup (TASK-3939 part C).
|
|
//
|
|
// SENT BUT UNRECORDED (TASK-4032): a failed 'sent' record is not merely logged.
|
|
// The push went out, so the row would be reclaimed after the lease and pushed
|
|
// again; the ids are parked in-process, the record is retried at every tick
|
|
// start (gateway or not), parked rows are never re-sent and count toward the
|
|
// 30/h cap, and an entry is dropped once recorded or past pushExpireAfter.
|
|
// The set is in-process, so a restart or a second instance can still re-send a
|
|
// row once after its lease, which the client de-dupes by nid.
|
|
//
|
|
// Logging: fixed strings and counts only. A store error can carry a row id or
|
|
// a value, so it is never logged here.
|
|
const (
|
|
defaultPushInterval = 5 * time.Second
|
|
defaultPushSettle = 10 * time.Second
|
|
defaultPushLease = 60 * time.Second
|
|
defaultPushLeaseMargin = 15 * time.Second
|
|
|
|
pushSummaryAbove = 3 // more than this many groups for one user => one summary push
|
|
pushRateCap = 30 // rows pushed per user per pushRateWindow
|
|
pushRateWindow = time.Hour //
|
|
pushExpireAfter = time.Hour // a pending row older than this is noise, not a push
|
|
pushRetention = 7 * 24 * time.Hour // sent, skipped and expired rows are kept this long
|
|
pushPruneEvery = time.Hour // created_at is unindexed, so the prune is throttled
|
|
|
|
// pushUnrecordedCap bounds the parked set in size (TASK-4032). The age
|
|
// rule alone bounds it in time. At the cap the dispatcher stops claiming
|
|
// and sending (backpressure) rather than evicting: an evicted row is
|
|
// pending, and would be sent again once its lease lapsed.
|
|
pushUnrecordedCap = 1000
|
|
|
|
skipRateLimited = "rate_limited"
|
|
skipGatewayRejected = "gateway_rejected"
|
|
)
|
|
|
|
var errPushLeaseTooShort = errors.New("push dispatch: lease must be at least sendTimeout + leaseMargin")
|
|
|
|
// pushSender is the gateway as the dispatcher sees it; *notify.Gateway
|
|
// satisfies it, and tests substitute a fake.
|
|
type pushSender interface {
|
|
Send(ctx context.Context, msgs []notify.Message) ([]notify.Result, error)
|
|
}
|
|
|
|
type pushDispatchConfig struct {
|
|
mu sync.Mutex
|
|
interval time.Duration
|
|
settle time.Duration
|
|
lease time.Duration
|
|
leaseMargin time.Duration
|
|
sendTimeout time.Duration
|
|
sender pushSender // test seam; nil = the server's gateway
|
|
now func() time.Time // test seam; nil = time.Now
|
|
stop chan struct{}
|
|
running bool
|
|
// startedInterval is the interval the ticker was last created with, 0 if
|
|
// the loop never started (TASK-3939 part C: proves the knob reached Start).
|
|
startedInterval time.Duration
|
|
instanceID string
|
|
lastPrune time.Time // when the retention sweep last ran
|
|
// unrecorded holds rows the gateway accepted whose 'sent' record failed
|
|
// (TASK-4032); see parkUnrecorded.
|
|
unrecorded map[string]unrecordedRow
|
|
// unrecordedCapOverride replaces pushUnrecordedCap when > 0 (tests only).
|
|
unrecordedCapOverride int
|
|
// unrecordedFull is whether the last tick found the set at its cap, so
|
|
// the full state logs once on entering and once on leaving.
|
|
unrecordedFull bool
|
|
// tickMu serializes ticks for the life of the process: Stop does not wait
|
|
// for the tick in flight, so a later Start could otherwise run a second
|
|
// one beside it, and two ticks would each pass the cap check and send.
|
|
// Stop cancels the in-flight tick's context, so a waiter is never held
|
|
// longer than that tick's unwind.
|
|
tickMu sync.Mutex
|
|
// tick, when non-nil, replaces the interval ticker so a test drives passes.
|
|
tick <-chan time.Time
|
|
}
|
|
|
|
// unrecordedRow is a row whose push the gateway accepted but whose 'sent'
|
|
// record has not landed.
|
|
type unrecordedRow struct {
|
|
userID string
|
|
created time.Time // the notification's created_at; its expiry clock
|
|
}
|
|
|
|
type pushDispatchSettings struct {
|
|
interval, settle, lease, leaseMargin, sendTimeout time.Duration
|
|
instanceID string
|
|
}
|
|
|
|
// SetPushDispatchConfig overrides the timings; zero keeps the current value.
|
|
// Server startup calls it from the PAD_PUSH_DISPATCH_* knobs (cmd/pad
|
|
// configurePush, TASK-3939 part C) and passes 0 for sendTimeout, which has no
|
|
// knob because notify.SendTimeout is baked into the gateway's HTTP client.
|
|
// interval is read once, by StartPushDispatch (the ticker is fixed at Start,
|
|
// so a later change needs a restart); settle, lease, leaseMargin and
|
|
// sendTimeout are read on every tick.
|
|
// A combination violating the lease invariant is refused whole and nothing
|
|
// changes; the error wraps errPushLeaseTooShort and states the RESOLVED lease,
|
|
// send timeout and margin, so a caller can tell which knob caused it.
|
|
func (s *Server) SetPushDispatchConfig(interval, settle, lease, leaseMargin, sendTimeout time.Duration) error {
|
|
cur := s.resolvePushDispatchSettings()
|
|
pick := func(v, d time.Duration) time.Duration {
|
|
if v > 0 {
|
|
return v
|
|
}
|
|
return d
|
|
}
|
|
next := pushDispatchSettings{
|
|
interval: pick(interval, cur.interval), settle: pick(settle, cur.settle),
|
|
lease: pick(lease, cur.lease), leaseMargin: pick(leaseMargin, cur.leaseMargin),
|
|
sendTimeout: pick(sendTimeout, cur.sendTimeout),
|
|
}
|
|
// sendTimeout + leaseMargin wraps negative when the margin is within
|
|
// sendTimeout of MaxInt64 (ParseDuration accepts up to about 2562047h), and
|
|
// the wrapped sum would pass the comparison below. Such a margin exceeds
|
|
// every representable lease, so it is the same violation (TASK-3939 part C,
|
|
// ruling 37e3eafa). Every other use of these durations is time.Time.Add or
|
|
// NewTicker, which cannot wrap at this magnitude, so no cap is needed.
|
|
if next.leaseMargin > math.MaxInt64-next.sendTimeout || next.lease < next.sendTimeout+next.leaseMargin {
|
|
slog.Warn("push dispatch: config refused", "reason", "lease shorter than send timeout plus margin")
|
|
return fmt.Errorf("%w: lease %s is shorter than send timeout %s plus margin %s", errPushLeaseTooShort, next.lease, next.sendTimeout, next.leaseMargin)
|
|
}
|
|
d := &s.pushDispatch
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
d.interval, d.settle, d.lease, d.leaseMargin, d.sendTimeout = next.interval, next.settle, next.lease, next.leaseMargin, next.sendTimeout
|
|
return nil
|
|
}
|
|
|
|
// SetPushSender substitutes the gateway client (a test seam).
|
|
func (s *Server) SetPushSender(p pushSender) {
|
|
s.pushDispatch.mu.Lock()
|
|
defer s.pushDispatch.mu.Unlock()
|
|
s.pushDispatch.sender = p
|
|
}
|
|
|
|
// resolvePushDispatchSettings fills every unset knob and stores the result,
|
|
// so a tick reached directly never sees a zero (the outbox drain's lesson).
|
|
func (s *Server) resolvePushDispatchSettings() pushDispatchSettings {
|
|
d := &s.pushDispatch
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.interval <= 0 {
|
|
d.interval = defaultPushInterval
|
|
}
|
|
if d.settle <= 0 {
|
|
d.settle = defaultPushSettle
|
|
}
|
|
if d.sendTimeout <= 0 {
|
|
d.sendTimeout = notify.SendTimeout
|
|
}
|
|
if d.leaseMargin <= 0 {
|
|
d.leaseMargin = defaultPushLeaseMargin
|
|
}
|
|
if d.lease <= 0 {
|
|
d.lease = defaultPushLease
|
|
}
|
|
if d.instanceID == "" {
|
|
d.instanceID = uuid.NewString()
|
|
}
|
|
return pushDispatchSettings{d.interval, d.settle, d.lease, d.leaseMargin, d.sendTimeout, d.instanceID}
|
|
}
|
|
|
|
func (s *Server) pushNow() time.Time {
|
|
s.pushDispatch.mu.Lock()
|
|
now := s.pushDispatch.now
|
|
s.pushDispatch.mu.Unlock()
|
|
if now == nil {
|
|
return time.Now()
|
|
}
|
|
return now()
|
|
}
|
|
|
|
func (s *Server) pushSenderOrNil() pushSender {
|
|
s.pushDispatch.mu.Lock()
|
|
defer s.pushDispatch.mu.Unlock()
|
|
if s.pushDispatch.sender != nil {
|
|
return s.pushDispatch.sender
|
|
}
|
|
if s.pushGateway != nil {
|
|
return s.pushGateway
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// StartPushDispatch starts the dispatch loop. Idempotent; tracked on s.bg.
|
|
func (s *Server) StartPushDispatch() {
|
|
cfg := s.resolvePushDispatchSettings()
|
|
d := &s.pushDispatch
|
|
d.mu.Lock()
|
|
if d.running {
|
|
d.mu.Unlock()
|
|
return
|
|
}
|
|
d.stop = make(chan struct{})
|
|
d.running = true
|
|
stop, tick := d.stop, d.tick
|
|
d.startedInterval = cfg.interval
|
|
d.mu.Unlock()
|
|
|
|
s.bg.Add(1)
|
|
go func() {
|
|
defer s.bg.Done()
|
|
defer s.recoverSweeper("push-dispatch")
|
|
// A stop cancels the tick in flight, which unclaims what it holds.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go func() {
|
|
select {
|
|
case <-stop:
|
|
cancel()
|
|
case <-ctx.Done():
|
|
}
|
|
}()
|
|
var c <-chan time.Time
|
|
if tick != nil {
|
|
c = tick
|
|
} else {
|
|
t := time.NewTicker(cfg.interval)
|
|
defer t.Stop()
|
|
c = t.C
|
|
}
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
case <-c:
|
|
s.runPushDispatchTick(ctx)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// PushDispatchRunning reports whether the dispatch loop has been started and
|
|
// not stopped. Read-only; startup's wiring test reads it (TASK-3939 B2c).
|
|
func (s *Server) PushDispatchRunning() bool {
|
|
s.pushDispatch.mu.Lock()
|
|
defer s.pushDispatch.mu.Unlock()
|
|
return s.pushDispatch.running
|
|
}
|
|
|
|
// PushDispatchApplied is what the dispatcher will use (or is using). Interval
|
|
// is the stored setting; StartedInterval is the interval the ticker was
|
|
// actually created with, 0 if the loop never started.
|
|
type PushDispatchApplied struct {
|
|
Interval, Settle, Lease, LeaseMargin, StartedInterval time.Duration
|
|
}
|
|
|
|
// PushDispatchApplied reads the dispatcher's timings, filling defaults for
|
|
// any unset one. Read-only; startup's wiring test reads it to prove the knobs
|
|
// reached the dispatcher, and the interval before Start (TASK-3939 part C,
|
|
// ruling 77a8fae7). A test-only seam, read at single-threaded startup: it
|
|
// takes the lock twice (resolve, then startedInterval), so a concurrent
|
|
// reconfigure could give a mixed snapshot.
|
|
func (s *Server) PushDispatchApplied() PushDispatchApplied {
|
|
cur := s.resolvePushDispatchSettings()
|
|
s.pushDispatch.mu.Lock()
|
|
defer s.pushDispatch.mu.Unlock()
|
|
return PushDispatchApplied{cur.interval, cur.settle, cur.lease, cur.leaseMargin, s.pushDispatch.startedInterval}
|
|
}
|
|
|
|
// stopPushDispatch signals the loop to exit. Safe when it never started.
|
|
func (s *Server) stopPushDispatch() {
|
|
d := &s.pushDispatch
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if !d.running {
|
|
return
|
|
}
|
|
close(d.stop)
|
|
d.running = false
|
|
}
|
|
|
|
// pushUnit is the rows of one (user, item) group after gating, which become
|
|
// one push per live device. nid and kind are the newest row's.
|
|
type pushUnit struct {
|
|
rowIDs []string
|
|
nid string
|
|
kind string // notify.KindSummary for a collapsed unit
|
|
devices []store.PushDevice
|
|
}
|
|
|
|
// expireAndPrune is the tick's housekeeping, run before the claim so a stale
|
|
// row is never sent: pending rows older than pushExpireAfter expire, and rows
|
|
// settled longer ago than pushRetention are deleted at most hourly.
|
|
func (s *Server) expireAndPrune(cfg pushDispatchSettings, start time.Time) {
|
|
if _, err := s.store.ExpireStaleNotifications(start.Add(-pushExpireAfter), start.Add(-cfg.lease)); err != nil {
|
|
slog.Warn("push dispatch: housekeeping failed", "op", "expire")
|
|
}
|
|
d := &s.pushDispatch
|
|
d.mu.Lock()
|
|
prev := d.lastPrune
|
|
due := start.Sub(prev) >= pushPruneEvery
|
|
if due {
|
|
d.lastPrune = start
|
|
}
|
|
d.mu.Unlock()
|
|
if !due {
|
|
return
|
|
}
|
|
if _, err := s.store.PruneNotifications(start.Add(-pushRetention)); err != nil {
|
|
slog.Warn("push dispatch: housekeeping failed", "op", "prune")
|
|
d.mu.Lock()
|
|
d.lastPrune = prev
|
|
d.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// parkUnrecorded remembers rows the gateway delivered but whose 'sent' record
|
|
// failed (TASK-4032). A row left pending is reclaimed after the lease and
|
|
// pushed again, once per lease, until it expires; the set stops that
|
|
// in-process (the tick skips parked rows after the claim) and drives the
|
|
// record until it lands. meta supplies each row's user and created_at; an
|
|
// unknown row falls back to now. Nothing is ever evicted: an evicted row is
|
|
// still pending and would be re-sent. The size bound is held by the tick,
|
|
// which stops claiming and sending while the set is at its cap (see
|
|
// checkUnrecordedFull); one tick's sends can overshoot it by at most one
|
|
// batch.
|
|
func (s *Server) parkUnrecorded(ids []string, meta map[string]store.Notification, at time.Time) {
|
|
d := &s.pushDispatch
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.unrecorded == nil {
|
|
d.unrecorded = map[string]unrecordedRow{}
|
|
}
|
|
for _, id := range ids {
|
|
e := unrecordedRow{created: at}
|
|
if n, ok := meta[id]; ok {
|
|
e.userID = n.UserID
|
|
if t, err := time.Parse(time.RFC3339, n.CreatedAt); err == nil {
|
|
e.created = t
|
|
}
|
|
}
|
|
d.unrecorded[id] = e
|
|
}
|
|
}
|
|
|
|
// recordedPrefix is how many leading ids a failed RecordNotificationsSent
|
|
// still recorded: its chunks commit one by one, and an id left parked after
|
|
// its chunk landed counts twice against the rate cap (TASK-4032 codex r3 M1).
|
|
func recordedPrefix(err error) int {
|
|
var rse *store.RecordSentError
|
|
if errors.As(err, &rse) {
|
|
return rse.Landed
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// expireCutoff is the creation time before which a row is past
|
|
// pushExpireAfter. It is truncated to the second because the store compares
|
|
// RFC3339 seconds: the claim's bound (created_at >= cutoff) and the parked
|
|
// entry's drop rule (created before cutoff) are then exact complements, so no
|
|
// row is both claimable and droppable.
|
|
func expireCutoff(start time.Time) time.Time {
|
|
return start.Add(-pushExpireAfter).Truncate(time.Second)
|
|
}
|
|
|
|
// retryUnrecorded runs at the start of every tick, before the gateway check
|
|
// and the claim, so it needs no gateway: recording is database work. A record
|
|
// that returns no error clears every parked id, including one it changed no
|
|
// row for (that row was settled elsewhere, which is a fine outcome too). A
|
|
// failed one clears the ids in the chunks that committed before the failure
|
|
// and keeps the rest, except those whose notification is past
|
|
// pushExpireAfter (expireCutoff): the claim never selects a row created
|
|
// before that cutoff, whether or not the expiry sweep landed (TASK-4032 r5
|
|
// H-Q1), so nothing can re-send it, and dropping the entry is what keeps a
|
|
// database that stays down from growing the set. The ids are sorted so the
|
|
// chunking is deterministic.
|
|
func (s *Server) retryUnrecorded(start time.Time) {
|
|
d := &s.pushDispatch
|
|
d.mu.Lock()
|
|
ids := make([]string, 0, len(d.unrecorded))
|
|
for id := range d.unrecorded {
|
|
ids = append(ids, id)
|
|
}
|
|
d.mu.Unlock()
|
|
if len(ids) == 0 {
|
|
return
|
|
}
|
|
sort.Strings(ids)
|
|
_, err := s.store.RecordNotificationsSent(ids)
|
|
landed := len(ids)
|
|
if err != nil {
|
|
landed = recordedPrefix(err)
|
|
}
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
cutoff := expireCutoff(start)
|
|
for i, id := range ids {
|
|
if i < landed || d.unrecorded[id].created.Before(cutoff) {
|
|
delete(d.unrecorded, id)
|
|
}
|
|
}
|
|
if err != nil {
|
|
slog.Warn("push dispatch: settle failed", "op", "sent-retry")
|
|
}
|
|
}
|
|
|
|
// checkUnrecordedFull reports whether the parked set is at its cap, which
|
|
// holds the tick's claim and send. It logs one fixed-string warning when the
|
|
// state is entered and one when it is left, never per tick, with no ids and
|
|
// no error text.
|
|
func (s *Server) checkUnrecordedFull() bool {
|
|
d := &s.pushDispatch
|
|
d.mu.Lock()
|
|
limit := pushUnrecordedCap
|
|
if d.unrecordedCapOverride > 0 {
|
|
limit = d.unrecordedCapOverride
|
|
}
|
|
full := len(d.unrecorded) >= limit
|
|
changed := full != d.unrecordedFull
|
|
d.unrecordedFull = full
|
|
d.mu.Unlock()
|
|
if changed && full {
|
|
slog.Warn("push dispatch: unrecorded sends at the cap, holding claims and sends")
|
|
} else if changed {
|
|
slog.Warn("push dispatch: unrecorded sends below the cap, resuming")
|
|
}
|
|
return full
|
|
}
|
|
|
|
// unrecordedSnapshot returns the parked ids and each user's parked count. A
|
|
// parked row was delivered, so it counts toward that user's 30/h cap although
|
|
// the database does not show it as sent yet.
|
|
func (s *Server) unrecordedSnapshot() (ids map[string]bool, perUser map[string]int) {
|
|
d := &s.pushDispatch
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
ids, perUser = make(map[string]bool, len(d.unrecorded)), map[string]int{}
|
|
for id, e := range d.unrecorded {
|
|
ids[id] = true
|
|
perUser[e.userID]++
|
|
}
|
|
return
|
|
}
|
|
|
|
// rowNewer orders two rows by created_at, then kind priority, then id.
|
|
func rowNewer(a, b store.Notification) bool {
|
|
if a.CreatedAt != b.CreatedAt {
|
|
return a.CreatedAt > b.CreatedAt
|
|
}
|
|
if pa, pb := kindPriority[a.Kind], kindPriority[b.Kind]; pa != pb {
|
|
return pa > pb
|
|
}
|
|
return a.ID > b.ID
|
|
}
|
|
|
|
// tickWarn records which gateway failures a tick saw, so each logs once.
|
|
type tickWarn struct{ unavailable, badRequest bool }
|
|
|
|
// kindPriority breaks a created_at tie between rows of one group, so the kind
|
|
// of a mixed group is deterministic (ids are random).
|
|
var kindPriority = map[string]int{
|
|
notify.KindReminder: 0, notify.KindComment: 1, notify.KindAssigned: 2, notify.KindReply: 3, notify.KindMention: 4,
|
|
}
|
|
|
|
// runPushDispatchTick is one pass. ctx is the shutdown signal.
|
|
func (s *Server) runPushDispatchTick(ctx context.Context) {
|
|
s.pushDispatch.tickMu.Lock()
|
|
defer s.pushDispatch.tickMu.Unlock()
|
|
cfg := s.resolvePushDispatchSettings()
|
|
start := s.pushNow()
|
|
s.expireAndPrune(cfg, start)
|
|
s.retryUnrecorded(start)
|
|
if s.checkUnrecordedFull() {
|
|
return // pending rows wait, up to their expiry, rather than repeat
|
|
}
|
|
sender := s.pushSenderOrNil()
|
|
if sender == nil {
|
|
return
|
|
}
|
|
// Taken before the claim: parked rows are excluded from it, so they
|
|
// cannot occupy batch slots ahead of rows that still need a push.
|
|
parked, parkedPerUser := s.unrecordedSnapshot()
|
|
exclude := make([]string, 0, len(parked))
|
|
for id := range parked {
|
|
exclude = append(exclude, id)
|
|
}
|
|
// Fresh per tick and never kept: the store appends its own nonce, and
|
|
// every settle below uses Claim.Token.
|
|
token := "pd-" + cfg.instanceID + "-" + uuid.NewString()
|
|
claim, err := s.store.ClaimPendingNotifications(token, notify.MaxBatch, start.Add(-cfg.settle), start.Add(-cfg.lease), expireCutoff(start), exclude)
|
|
if err != nil {
|
|
slog.Error("push dispatch: claim failed", "op", "claim")
|
|
return
|
|
}
|
|
if len(claim.Rows) == 0 {
|
|
return
|
|
}
|
|
deadline := start.Add(cfg.lease - cfg.leaseMargin)
|
|
|
|
cache := newPushGateCache()
|
|
skips := map[string][]string{}
|
|
var unclaim []string
|
|
groups := map[string][]store.Notification{}
|
|
var order []string
|
|
meta := make(map[string]store.Notification, len(claim.Rows))
|
|
for _, n := range claim.Rows {
|
|
meta[n.ID] = n
|
|
if parked[n.ID] {
|
|
// Belt: the claim already excludes parked ids; a parked row that
|
|
// still arrives is never gated or sent. Left claimed.
|
|
continue
|
|
}
|
|
_, reason, gerr := s.notificationAccess(cache, n)
|
|
if gerr == nil && reason == "" {
|
|
reason, gerr = s.preferenceGates(cache, n, start)
|
|
}
|
|
switch {
|
|
case gerr != nil:
|
|
// Not a denial: never send, never record a skip, burn no attempt.
|
|
unclaim = append(unclaim, n.ID)
|
|
case reason != "":
|
|
skips[reason] = append(skips[reason], n.ID)
|
|
default:
|
|
key := n.UserID + "/" + n.ItemID
|
|
if _, seen := groups[key]; !seen {
|
|
order = append(order, key)
|
|
}
|
|
groups[key] = append(groups[key], n)
|
|
}
|
|
}
|
|
s.settleSkips(claim.Token, skips)
|
|
s.unclaim(claim.Token, unclaim)
|
|
|
|
// Per user, in claim order: more than pushSummaryAbove groups collapse
|
|
// into one summary unit (after gating, so only visible rows count), then
|
|
// the 30/h cap admits or rate-limits each unit. Rows from the same tick
|
|
// that were admitted count against the cap even if the send later fails.
|
|
var userOrder []string
|
|
byUser := map[string][]string{}
|
|
for _, key := range order {
|
|
u := groups[key][0].UserID
|
|
if _, seen := byUser[u]; !seen {
|
|
userOrder = append(userOrder, u)
|
|
}
|
|
byUser[u] = append(byUser[u], key)
|
|
}
|
|
var units []pushUnit
|
|
limited := map[string][]string{}
|
|
var capErr []string
|
|
for _, uid := range userOrder {
|
|
var userUnits []pushUnit
|
|
for _, key := range byUser[uid] {
|
|
rows := groups[key]
|
|
sort.SliceStable(rows, func(i, j int) bool { return rowNewer(rows[j], rows[i]) })
|
|
u := pushUnit{nid: rows[len(rows)-1].ID, kind: rows[len(rows)-1].Kind, devices: cache.devices[uid]}
|
|
for _, r := range rows {
|
|
u.rowIDs = append(u.rowIDs, r.ID)
|
|
}
|
|
userUnits = append(userUnits, u)
|
|
}
|
|
if len(userUnits) > pushSummaryAbove {
|
|
sum := pushUnit{kind: notify.KindSummary, devices: cache.devices[uid]}
|
|
var newest store.Notification
|
|
for _, key := range byUser[uid] {
|
|
for _, r := range groups[key] {
|
|
sum.rowIDs = append(sum.rowIDs, r.ID)
|
|
if newest.ID == "" || rowNewer(r, newest) {
|
|
newest = r
|
|
}
|
|
}
|
|
}
|
|
sum.nid = newest.ID
|
|
userUnits = []pushUnit{sum}
|
|
}
|
|
sent, cerr := s.store.CountNotificationsSentSince(uid, start.Add(-pushRateWindow))
|
|
sent += parkedPerUser[uid]
|
|
for _, u := range userUnits {
|
|
switch {
|
|
case cerr != nil:
|
|
capErr = append(capErr, u.rowIDs...)
|
|
case sent >= pushRateCap:
|
|
limited[skipRateLimited] = append(limited[skipRateLimited], u.rowIDs...)
|
|
default:
|
|
sent += len(u.rowIDs)
|
|
units = append(units, u)
|
|
}
|
|
}
|
|
}
|
|
s.settleSkips(claim.Token, limited)
|
|
s.unclaim(claim.Token, capErr)
|
|
|
|
var warn tickWarn
|
|
// Chunks hold whole units, so a unit's outcome is decided in one place.
|
|
for len(units) > 0 {
|
|
var chunk []pushUnit
|
|
msgs := 0
|
|
for len(units) > 0 {
|
|
n := min(len(units[0].devices), notify.MaxBatch)
|
|
if len(chunk) > 0 && msgs+n > notify.MaxBatch {
|
|
break
|
|
}
|
|
chunk = append(chunk, units[0])
|
|
units = units[1:]
|
|
msgs += n
|
|
}
|
|
if ctx.Err() != nil || deadline.Sub(s.pushNow()) < cfg.sendTimeout {
|
|
s.unclaim(claim.Token, unitRowIDs(append(chunk, units...)))
|
|
break // still emit this tick's warnings for chunks already sent
|
|
}
|
|
s.sendChunk(ctx, sender, claim.Token, chunk, deadline, meta, &warn)
|
|
}
|
|
if warn.unavailable {
|
|
slog.Warn("push dispatch: gateway unavailable, rows released")
|
|
}
|
|
if warn.badRequest {
|
|
slog.Warn("push dispatch: gateway refused a request, rows skipped")
|
|
}
|
|
}
|
|
|
|
func unitRowIDs(units []pushUnit) []string {
|
|
var ids []string
|
|
for _, u := range units {
|
|
ids = append(ids, u.rowIDs...)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// preferenceGates is G5/G6 and G7/G8 with their cached lookups.
|
|
func (s *Server) preferenceGates(c *pushGateCache, n store.Notification, at time.Time) (string, error) {
|
|
set, ok := c.settings[n.UserID]
|
|
if !ok {
|
|
var err error
|
|
if set, err = s.store.GetNotificationSettings(n.UserID); err != nil {
|
|
return "", err
|
|
}
|
|
c.settings[n.UserID] = set
|
|
}
|
|
if reason := kindEnabled(set, n); reason != "" {
|
|
return reason, nil
|
|
}
|
|
devs, ok := c.devices[n.UserID]
|
|
if !ok {
|
|
var err error
|
|
if devs, err = s.store.LivePushDevices(n.UserID, at); err != nil {
|
|
return "", err
|
|
}
|
|
c.devices[n.UserID] = devs
|
|
}
|
|
return deviceGate(n, devs), nil
|
|
}
|
|
|
|
func (s *Server) settleSkips(token string, skips map[string][]string) {
|
|
for reason, ids := range skips {
|
|
if _, err := s.store.MarkNotificationsSkipped(token, ids, reason); err != nil {
|
|
slog.Warn("push dispatch: settle failed", "op", "skip")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) unclaim(token string, ids []string) {
|
|
if len(ids) == 0 {
|
|
return
|
|
}
|
|
if _, err := s.store.UnclaimNotifications(token, ids); err != nil {
|
|
slog.Warn("push dispatch: settle failed", "op", "unclaim")
|
|
}
|
|
}
|
|
|
|
// sendChunk sends one chunk of whole units and settles it immediately. Per
|
|
// message: sent keeps the device; invalid_token deletes it (the only result
|
|
// that does, and only while the row still holds the token that was sent);
|
|
// rejected and retry keep it. Per unit: any device sent => sent (the others are
|
|
// not chased, so nothing duplicates); otherwise any retry (or a missing
|
|
// result) => release with a backoff; otherwise every device was terminal =>
|
|
// skipped gateway_rejected if any was rejected, else no_device. A whole request
|
|
// the gateway refused (ErrBadRequest) skips every unit with no attempt; any
|
|
// other whole-request error releases them.
|
|
func (s *Server) sendChunk(ctx context.Context, sender pushSender, token string, chunk []pushUnit, deadline time.Time, meta map[string]store.Notification, warn *tickWarn) {
|
|
// Drop rows no longer held (reclaimed, settled or deleted meanwhile).
|
|
all := unitRowIDs(chunk)
|
|
held, err := s.store.HeldNotifications(token, all)
|
|
if err != nil {
|
|
slog.Warn("push dispatch: settle failed", "op", "held")
|
|
s.unclaim(token, all)
|
|
return
|
|
}
|
|
heldSet := make(map[string]bool, len(held))
|
|
for _, id := range held {
|
|
heldSet[id] = true
|
|
}
|
|
type sentMsg struct {
|
|
unit int
|
|
dev store.PushDevice
|
|
}
|
|
var msgs []notify.Message
|
|
owner := map[string]sentMsg{} // message id -> unit index and device
|
|
var live []pushUnit
|
|
var noMsg []string // units none of whose devices could be given a message
|
|
for _, u := range chunk {
|
|
var ids []string
|
|
for _, id := range u.rowIDs {
|
|
if heldSet[id] {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
if len(ids) == 0 {
|
|
continue
|
|
}
|
|
u.rowIDs = ids
|
|
count := 1
|
|
if u.kind == notify.KindSummary {
|
|
count = len(ids)
|
|
}
|
|
first := len(msgs)
|
|
for _, d := range u.devices[:min(len(u.devices), notify.MaxBatch)] {
|
|
id := notify.BatchID(len(msgs))
|
|
m, ok := notify.NewMessage(id, d.Platform, d.Token, u.kind, u.nid, d.ConnectionID, count)
|
|
if !ok {
|
|
continue
|
|
}
|
|
owner[id] = sentMsg{len(live), d}
|
|
msgs = append(msgs, m)
|
|
}
|
|
if len(msgs) == first {
|
|
noMsg = append(noMsg, ids...)
|
|
continue
|
|
}
|
|
live = append(live, u)
|
|
}
|
|
if len(noMsg) > 0 {
|
|
s.settleSkips(token, map[string][]string{skipNoDevice: noMsg})
|
|
}
|
|
if len(msgs) == 0 {
|
|
s.unclaim(token, unitRowIDs(live))
|
|
return
|
|
}
|
|
|
|
sctx, cancel := context.WithDeadline(ctx, deadline)
|
|
results, serr := sender.Send(sctx, msgs)
|
|
cancel()
|
|
if serr != nil && ctx.Err() != nil {
|
|
s.unclaim(token, unitRowIDs(live)) // shutdown: no verdict, no attempt
|
|
return
|
|
}
|
|
if errors.Is(serr, notify.ErrBadRequest) {
|
|
warn.badRequest = true
|
|
s.settleSkips(token, map[string][]string{skipGatewayRejected: unitRowIDs(live)})
|
|
return
|
|
}
|
|
if serr != nil {
|
|
warn.unavailable = true
|
|
results = nil
|
|
}
|
|
type unitVerdict struct{ sent, retry, rejected bool }
|
|
verdict := make([]unitVerdict, len(live))
|
|
status := make(map[string]string, len(results))
|
|
for _, r := range results {
|
|
status[r.ID] = r.Status
|
|
}
|
|
for id, o := range owner {
|
|
v := &verdict[o.unit]
|
|
switch status[id] {
|
|
case notify.StatusSent:
|
|
v.sent = true
|
|
case notify.StatusInvalidToken:
|
|
if err := s.store.DeletePushDeviceIfCurrent(o.dev.ID, o.dev.Token, o.dev.RegSeq); err != nil {
|
|
slog.Warn("push dispatch: settle failed", "op", "delete")
|
|
}
|
|
case notify.StatusRejected:
|
|
v.rejected = true
|
|
default: // retry, an unknown status, or no result at all
|
|
v.retry = true
|
|
}
|
|
}
|
|
var sentIDs, releaseIDs []string
|
|
skips := map[string][]string{}
|
|
for i, u := range live {
|
|
switch v := verdict[i]; {
|
|
case v.sent:
|
|
sentIDs = append(sentIDs, u.rowIDs...)
|
|
case v.retry:
|
|
releaseIDs = append(releaseIDs, u.rowIDs...)
|
|
case v.rejected:
|
|
skips[skipGatewayRejected] = append(skips[skipGatewayRejected], u.rowIDs...)
|
|
default:
|
|
skips[skipNoDevice] = append(skips[skipNoDevice], u.rowIDs...)
|
|
}
|
|
}
|
|
if len(sentIDs) > 0 {
|
|
if _, err := s.store.RecordNotificationsSent(sentIDs); err != nil {
|
|
// Delivered but unrecorded: park, or the row is re-sent every lease.
|
|
// A tick claims at most notify.MaxBatch rows, inside one record
|
|
// chunk, so the prefix is 0 today; it is honoured anyway.
|
|
slog.Warn("push dispatch: settle failed", "op", "sent")
|
|
s.parkUnrecorded(sentIDs[recordedPrefix(err):], meta, s.pushNow())
|
|
}
|
|
}
|
|
s.settleSkips(token, skips)
|
|
if len(releaseIDs) > 0 {
|
|
if _, err := s.store.ReleaseNotificationsBackoff(token, releaseIDs); err != nil {
|
|
slog.Warn("push dispatch: settle failed", "op", "release")
|
|
}
|
|
}
|
|
}
|