mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
dbbbefda75
* feat(store): record a delivery by id, unclaim without an attempt, re-check holds (TASK-3939 B1) RecordNotificationsSent is token-free for pending|expired rows (r4-L4), UnclaimNotifications hands never-tried rows back without an attempt, HeldNotifications is the pre-send re-check. The claim error drops the row id (Q8), notify.SendTimeout is exported (Q4), and the store doc says SENT is the one deliberate exception to settle-under-the-claim (Q5). Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg * feat(server): the push dispatcher core and its privacy gates (TASK-3939 B1) Claims settled rows, runs membership, liveness, visibility, assignment, comment, settings, agent-activity and device gates on each (a skip carries a recorded reason and never reaches the gateway), groups survivors per (user, item), sends one content-free push per live device and settles per chunk. Lease >= sendTimeout + margin is enforced by the setter, the send deadline and a per-chunk time check. Not wired into cmd/pad. Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg * test(server): dispatcher gates, lease invariant and late-send recording (TASK-3939 B1) Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg * test(server): an unknown notification kind is skipped (TASK-3939 B1) Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg * fix(server): the dispatcher logs no ids, settles zero-message units, checks the comment's item and has a clock seam (TASK-3939 B1) C1 reword the store header (the dispatcher is wired in part B2). H1 split computeWatchAccessVisibility into a log-free core the dispatcher calls; the handler's wrapper logs exactly as before. H2 settle a unit with no message as a no_device skip. H4 a mention/comment/reply row's comment must be on the row's item and workspace. Add the unexported now seam. T3 comment on the nil user branch. Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU * test(store): settle primitives assert attempts, sent_at and span more than one id chunk (TASK-3939 B1) Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU * test(server): dispatcher fix round, clock-seam lease tests, every gate error, gate order, log leak, zero-message unit (TASK-3939 B1) Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU
446 lines
15 KiB
Go
446 lines
15 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/notify"
|
|
)
|
|
|
|
// TASK-3939 part B1: the dispatch core. Gate cases live in
|
|
// task3939b_push_gates_test.go.
|
|
|
|
func TestPushDispatch_SendsOnePerUnitAndSettlesSent(t *testing.T) {
|
|
needsBackends(t, func(t *testing.T, srv *Server) {
|
|
f := newPdx(t, srv)
|
|
f.device(f.bob, "tok-bob-2", 4*time.Hour)
|
|
other := f.newItem("Other", &f.bob.ID)
|
|
a1, a2 := f.row("comment"), f.row("comment", func(o *rowOpt) { o.age = 3 * time.Minute })
|
|
b := f.row("assigned", func(o *rowOpt) { o.item = other })
|
|
f.tick()
|
|
for _, id := range []string{a1, a2, b} {
|
|
if st := f.state(id); st.State != "sent" || st.SentAt == "" || st.Claim != "" {
|
|
t.Fatalf("row %s = %+v, want sent, stamped, unclaimed", id, st)
|
|
}
|
|
}
|
|
// two units (one per item) x two devices, in one request
|
|
if n := f.fake.callCount(); n != 1 {
|
|
t.Fatalf("%d Send calls, want 1", n)
|
|
}
|
|
msgs := f.fake.all()
|
|
if len(msgs) != 4 {
|
|
t.Fatalf("%d messages, want 2 units x 2 devices", len(msgs))
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, m := range msgs {
|
|
if seen[m.ID] {
|
|
t.Fatalf("duplicate message id %s", m.ID)
|
|
}
|
|
seen[m.ID] = true
|
|
}
|
|
nids := map[string]int{}
|
|
for _, m := range msgs {
|
|
nids[m.Data.NID]++
|
|
}
|
|
if nids[a1] != 2 || nids[b] != 2 || nids[a2] != 0 {
|
|
t.Fatalf("nids = %v: the item's two rows are one push carrying the newest row's id", nids)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestPushDispatch_NonSentResultsReleaseWithAnAttempt(t *testing.T) {
|
|
needsBackends(t, func(t *testing.T, srv *Server) {
|
|
f := newPdx(t, srv)
|
|
f.fake.fn = func(_ context.Context, msgs []notify.Message) ([]notify.Result, error) {
|
|
return allStatus(msgs, notify.StatusRetry), nil
|
|
}
|
|
id := f.row("comment")
|
|
f.tick()
|
|
if st := f.state(id); st.State != "pending" || st.Attempts != 1 || st.Claim != "" {
|
|
t.Fatalf("after retry: %+v, want pending, 1 attempt, unclaimed", st)
|
|
}
|
|
f.fake.fn = func(context.Context, []notify.Message) ([]notify.Result, error) { return nil, notify.ErrUnavailable }
|
|
f.tick()
|
|
if st := f.state(id); st.State != "pending" || st.Attempts != 2 {
|
|
t.Fatalf("after a failed request: %+v, want 2 attempts", st)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A unit with one device sent and another retried is sent: re-pushing would
|
|
// duplicate to the device that already has it.
|
|
func TestPushDispatch_OneDeviceSentIsSent(t *testing.T) {
|
|
needsBackends(t, func(t *testing.T, srv *Server) {
|
|
f := newPdx(t, srv)
|
|
f.device(f.bob, "tok-bob-2", 4*time.Hour)
|
|
f.fake.fn = func(_ context.Context, msgs []notify.Message) ([]notify.Result, error) {
|
|
out := allStatus(msgs, notify.StatusRetry)
|
|
out[0].Status = notify.StatusSent
|
|
return out, nil
|
|
}
|
|
id := f.row("comment")
|
|
f.tick()
|
|
if st := f.state(id); st.State != "sent" || st.Attempts != 0 {
|
|
t.Fatalf("row = %+v, want sent", st)
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- lease invariant (r6-Q1) ---
|
|
|
|
func TestPushDispatch_LeaseExceedsSendTimeout(t *testing.T) {
|
|
srv := testServer(t)
|
|
cfg := srv.resolvePushDispatchSettings()
|
|
if cfg.sendTimeout != notify.SendTimeout {
|
|
t.Fatalf("send timeout = %v, want notify.SendTimeout", cfg.sendTimeout)
|
|
}
|
|
if cfg.lease < notify.SendTimeout+cfg.leaseMargin {
|
|
t.Fatalf("default lease %v < send timeout %v + margin %v", cfg.lease, notify.SendTimeout, cfg.leaseMargin)
|
|
}
|
|
if got := notify.NewGateway("http://x.test", "s").HTTP.Timeout; got != notify.SendTimeout {
|
|
t.Fatalf("gateway client timeout = %v, want notify.SendTimeout", got)
|
|
}
|
|
for _, lease := range []time.Duration{20 * time.Second, notify.SendTimeout, cfg.sendTimeout + cfg.leaseMargin - time.Second} {
|
|
if err := srv.SetPushDispatchConfig(0, 0, lease, 0, 0); !errors.Is(err, errPushLeaseTooShort) {
|
|
t.Fatalf("lease %v: err = %v, want refused", lease, err)
|
|
}
|
|
}
|
|
if got := srv.resolvePushDispatchSettings().lease; got != cfg.lease {
|
|
t.Fatalf("a refused config changed the lease to %v", got)
|
|
}
|
|
if err := srv.SetPushDispatchConfig(0, 0, cfg.sendTimeout+cfg.leaseMargin, 0, 0); err != nil {
|
|
t.Fatalf("a lease exactly at the invariant was refused: %v", err)
|
|
}
|
|
// Raising the send timeout past the lease is the same violation.
|
|
if err := srv.SetPushDispatchConfig(0, 0, 0, 0, time.Hour); !errors.Is(err, errPushLeaseTooShort) {
|
|
t.Fatalf("send timeout raised past the lease: err = %v", err)
|
|
}
|
|
}
|
|
|
|
// The Send runs under the tick's deadline (start + lease - margin), so it ends
|
|
// before the claim lapses; while it runs, and while the tick still holds the
|
|
// row after the deadline fired, no other claimer can take the row. The clock
|
|
// is the dispatcher's seam: the tick "started" an hour ago, so its deadline is
|
|
// already past and the Send's context must already be done.
|
|
func TestPushDispatch_SlowSendNotReclaimedMidFlight(t *testing.T) {
|
|
needsBackends(t, func(t *testing.T, srv *Server) {
|
|
f := newPdx(t, srv)
|
|
must(t, srv.SetPushDispatchConfig(0, 0, 3*time.Second, time.Second, time.Second))
|
|
clk := f.clock(time.Now().Add(-time.Hour))
|
|
id := f.row("comment", func(o *rowOpt) { o.age = 3 * time.Hour })
|
|
compete := func(when string) {
|
|
c, err := srv.store.ClaimPendingNotifications("rival", 10, time.Now().Add(-10*time.Second), time.Now().Add(-3*time.Second))
|
|
if err != nil || len(c.Rows) != 0 {
|
|
t.Errorf("%s: a rival claimed %d rows (err %v)", when, len(c.Rows), err)
|
|
}
|
|
}
|
|
f.fake.fn = func(ctx context.Context, _ []notify.Message) ([]notify.Result, error) {
|
|
compete("mid-flight")
|
|
dl, ok := ctx.Deadline()
|
|
if !ok || !dl.Equal(clk.start.Add(2*time.Second)) {
|
|
t.Errorf("Send deadline = %v (set %v), want tick start + lease - margin = %v", dl, ok, clk.start.Add(2*time.Second))
|
|
}
|
|
if ctx.Err() == nil {
|
|
t.Error("the deadline had passed but the Send context is live: no deadline on the gateway call")
|
|
}
|
|
compete("after the deadline fired, tick still holding")
|
|
return nil, notify.ErrUnavailable
|
|
}
|
|
f.tick()
|
|
if n := f.fake.callCount(); n != 1 {
|
|
t.Errorf("%d Send calls while the row was held", n)
|
|
}
|
|
if st := f.state(id); st.State != "pending" || st.Attempts != 1 || st.Claim != "" {
|
|
t.Fatalf("released row = %+v", st)
|
|
}
|
|
c, err := srv.store.ClaimPendingNotifications("rival", 10, time.Now().Add(-10*time.Second), time.Now().Add(-3*time.Second))
|
|
if err != nil || len(c.Rows) != 1 {
|
|
t.Fatalf("after release the rival claimed %d rows (err %v)", len(c.Rows), err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// manyUnits gives bob 51 devices and two items with a row each: 102 messages,
|
|
// so two chunks of one unit each.
|
|
func manyUnits(f *pdx) (first, second string) {
|
|
for i := 0; i < 50; i++ {
|
|
f.device(f.bob, fmt.Sprintf("tok-many-%d-%d", f.n, i), 4*time.Hour)
|
|
}
|
|
other := f.newItem("Second", &f.bob.ID)
|
|
first = f.row("assigned", func(o *rowOpt) { o.age = 5 * time.Minute })
|
|
second = f.row("assigned", func(o *rowOpt) { o.item = other })
|
|
return
|
|
}
|
|
|
|
// A chunk starts only if a whole send timeout is left before the deadline;
|
|
// otherwise its rows are unclaimed with no attempt burned.
|
|
func TestPushDispatch_ChunkNotStartedWithoutTimeLeft(t *testing.T) {
|
|
needsBackends(t, func(t *testing.T, srv *Server) {
|
|
f := newPdx(t, srv)
|
|
must(t, srv.SetPushDispatchConfig(0, 0, 3*time.Second, time.Second, time.Second))
|
|
first, second := manyUnits(f)
|
|
clk := f.clock(time.Now())
|
|
f.fake.fn = func(_ context.Context, msgs []notify.Message) ([]notify.Result, error) {
|
|
clk.advance(1200 * time.Millisecond) // deadline (2s) minus this is under the 1s send timeout
|
|
return allStatus(msgs, notify.StatusSent), nil
|
|
}
|
|
f.tick()
|
|
if n := f.fake.callCount(); n != 1 {
|
|
t.Fatalf("%d Send calls, want only the first chunk", n)
|
|
}
|
|
if st := f.state(first); st.State != "sent" {
|
|
t.Fatalf("first = %+v", st)
|
|
}
|
|
if st := f.state(second); st.State != "pending" || st.Attempts != 0 || st.Claim != "" {
|
|
t.Fatalf("second = %+v, want pending, unclaimed, no attempt", st)
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- fresh token, settle under the claim token ---
|
|
|
|
func TestPushDispatch_FreshClaimTokenPerTick(t *testing.T) {
|
|
needsBackends(t, func(t *testing.T, srv *Server) {
|
|
f := newPdx(t, srv)
|
|
var prefixes []string
|
|
f.fake.fn = func(_ context.Context, msgs []notify.Message) ([]notify.Result, error) {
|
|
var tok string
|
|
row := srv.store.DB().QueryRow(`SELECT claim_token FROM user_notifications WHERE claim_token <> '' LIMIT 1`)
|
|
if err := row.Scan(&tok); err != nil {
|
|
t.Errorf("no claimed row mid-send: %v", err)
|
|
}
|
|
prefixes = append(prefixes, tok[:strings.LastIndex(tok, ".")])
|
|
return allStatus(msgs, notify.StatusRetry), nil
|
|
}
|
|
id := f.row("comment")
|
|
f.tick()
|
|
f.tick()
|
|
if len(prefixes) != 2 || prefixes[0] == prefixes[1] || !strings.HasPrefix(prefixes[0], "pd-") {
|
|
t.Fatalf("caller tokens = %v, want two different pd-... tokens", prefixes)
|
|
}
|
|
if st := f.state(id); st.Attempts != 2 {
|
|
t.Fatalf("row = %+v, want both ticks to have settled their release", st)
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- r4-L4: a send that outlives its lease ---
|
|
|
|
func parkedSend(f *pdx) (entered, release chan struct{}) {
|
|
entered, release = make(chan struct{}), make(chan struct{})
|
|
var once sync.Once
|
|
f.fake.fn = func(_ context.Context, msgs []notify.Message) ([]notify.Result, error) {
|
|
once.Do(func() { close(entered) })
|
|
<-release
|
|
return allStatus(msgs, notify.StatusSent), nil
|
|
}
|
|
return
|
|
}
|
|
|
|
func (f *pdx) lapse(ids ...string) {
|
|
f.t.Helper()
|
|
for _, id := range ids {
|
|
f.exec(`UPDATE user_notifications SET claimed_at = ? WHERE id = ?`, f.ts(2*time.Minute), id)
|
|
}
|
|
}
|
|
|
|
func TestPushDispatch_LateSentRecordedWhenReclaimed(t *testing.T) {
|
|
needsBackends(t, func(t *testing.T, srv *Server) {
|
|
f := newPdx(t, srv)
|
|
id := f.row("comment")
|
|
entered, release := parkedSend(f)
|
|
done := make(chan struct{})
|
|
go func() { f.tick(); close(done) }()
|
|
<-entered
|
|
f.lapse(id)
|
|
b, err := srv.store.ClaimPendingNotifications("B", 10, time.Now().Add(-10*time.Second), time.Now().Add(-time.Minute))
|
|
if err != nil || len(b.Rows) != 1 {
|
|
t.Fatalf("B reclaimed %d rows (%v)", len(b.Rows), err)
|
|
}
|
|
close(release)
|
|
<-done
|
|
if st := f.state(id); st.State != "sent" || st.SentAt == "" {
|
|
t.Fatalf("row = %+v: A's delivery must be recorded although B holds the row", st)
|
|
}
|
|
if n, _ := srv.store.MarkNotificationsSkipped(b.Token, []string{id}, "late"); n != 0 {
|
|
t.Fatal("B's settle changed a row recorded sent")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestPushDispatch_LateSentRecordedWhenExpired(t *testing.T) {
|
|
needsBackends(t, func(t *testing.T, srv *Server) {
|
|
f := newPdx(t, srv)
|
|
id := f.row("comment", func(o *rowOpt) { o.age = 2 * time.Minute })
|
|
entered, release := parkedSend(f)
|
|
done := make(chan struct{})
|
|
go func() { f.tick(); close(done) }()
|
|
<-entered
|
|
f.exec(`UPDATE user_notifications SET created_at = ? WHERE id = ?`, f.ts(2*time.Hour), id)
|
|
f.lapse(id)
|
|
if n, err := srv.store.ExpireStaleNotifications(time.Now().Add(-time.Hour), time.Now().Add(-time.Minute)); err != nil || n != 1 {
|
|
t.Fatalf("expire = %d, %v", n, err)
|
|
}
|
|
close(release)
|
|
<-done
|
|
if st := f.state(id); st.State != "sent" {
|
|
t.Fatalf("row = %+v: a delivered row must not read expired", st)
|
|
}
|
|
})
|
|
}
|
|
|
|
// The pre-send re-check: rows taken by another instance while an earlier
|
|
// chunk was in flight are dropped from the next one.
|
|
func TestPushDispatch_HeldRecheckDropsRowsTakenMidTick(t *testing.T) {
|
|
needsBackends(t, func(t *testing.T, srv *Server) {
|
|
f := newPdx(t, srv)
|
|
first, second := manyUnits(f)
|
|
entered, release := parkedSend(f)
|
|
done := make(chan struct{})
|
|
go func() { f.tick(); close(done) }()
|
|
<-entered
|
|
f.lapse(second)
|
|
b, err := srv.store.ClaimPendingNotifications("B", 10, time.Now().Add(-10*time.Second), time.Now().Add(-time.Minute))
|
|
if err != nil || len(b.Rows) != 1 || b.Rows[0].ID != second {
|
|
t.Fatalf("B claimed %v (%v), want the second row only", b.Rows, err)
|
|
}
|
|
close(release)
|
|
<-done
|
|
if n := f.fake.callCount(); n != 1 {
|
|
t.Fatalf("%d Send calls: the row B holds was sent again", n)
|
|
}
|
|
if st := f.state(first); st.State != "sent" {
|
|
t.Fatalf("first = %+v", st)
|
|
}
|
|
if st := f.state(second); st.State != "pending" || st.Claim != b.Token {
|
|
t.Fatalf("second = %+v, want still B's", st)
|
|
}
|
|
})
|
|
}
|
|
|
|
// 3946's supersede can DELETE a pending row the dispatcher has claimed. The
|
|
// settle that finds it gone is a no-op, not an error and not a log line.
|
|
func TestPushDispatch_RowDeletedMidSendIsNotAnError(t *testing.T) {
|
|
needsBackends(t, func(t *testing.T, srv *Server) {
|
|
f := newPdx(t, srv)
|
|
id := f.row("comment")
|
|
logs := pdCaptureLogs(t)
|
|
entered, release := parkedSend(f)
|
|
done := make(chan struct{})
|
|
go func() { f.tick(); close(done) }()
|
|
<-entered
|
|
f.exec(`DELETE FROM user_notifications WHERE id = ?`, id)
|
|
close(release)
|
|
<-done
|
|
if out := logs.String(); strings.Contains(out, "level=WARN") || strings.Contains(out, "level=ERROR") {
|
|
t.Fatalf("a deleted row logged: %s", out)
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- shutdown ---
|
|
|
|
func TestPushDispatch_ShutdownUnclaimsWithoutAnAttempt(t *testing.T) {
|
|
needsBackends(t, func(t *testing.T, srv *Server) {
|
|
f := newPdx(t, srv)
|
|
id := f.row("comment")
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
entered := make(chan struct{})
|
|
f.fake.fn = func(c context.Context, _ []notify.Message) ([]notify.Result, error) {
|
|
close(entered)
|
|
<-c.Done()
|
|
return nil, notify.ErrUnavailable
|
|
}
|
|
done := make(chan struct{})
|
|
go func() { srv.runPushDispatchTick(ctx); close(done) }()
|
|
<-entered
|
|
cancel()
|
|
<-done
|
|
if st := f.state(id); st.State != "pending" || st.Attempts != 0 || st.Claim != "" {
|
|
t.Fatalf("after shutdown mid-send: %+v, want pending, unclaimed, no attempt", st)
|
|
}
|
|
|
|
// and a tick that begins cancelled sends nothing
|
|
f.fake.fn = nil
|
|
before := f.fake.callCount()
|
|
srv.runPushDispatchTick(ctx)
|
|
if f.fake.callCount() != before {
|
|
t.Fatal("a cancelled tick sent")
|
|
}
|
|
if st := f.state(id); st.State != "pending" || st.Claim != "" {
|
|
t.Fatalf("cancelled tick left %+v", st)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestPushDispatch_LoopRunsTicksAndStops(t *testing.T) {
|
|
srv := testServer(t)
|
|
f := newPdx(t, srv)
|
|
tick := make(chan time.Time)
|
|
srv.pushDispatch.tick = tick
|
|
id := f.row("comment")
|
|
srv.StartPushDispatch()
|
|
srv.StartPushDispatch() // idempotent
|
|
tick <- time.Now()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for f.state(id).State != "sent" {
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("the loop never dispatched the row")
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
srv.stopPushDispatch()
|
|
srv.stopPushDispatch() // safe twice
|
|
}
|
|
|
|
// --- logging ---
|
|
|
|
func pdCaptureLogs(t *testing.T) *bytes.Buffer {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
prev := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&pdLogWriter{w: &buf}, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
|
return &buf
|
|
}
|
|
|
|
type pdLogWriter struct {
|
|
mu sync.Mutex
|
|
w *bytes.Buffer
|
|
}
|
|
|
|
func (l *pdLogWriter) Write(p []byte) (int, error) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return l.w.Write(p)
|
|
}
|
|
|
|
// A failed claim logs a fixed line: never the store's error text, which can
|
|
// echo a value, and never an id.
|
|
func TestPushDispatch_FailedClaimLogsNoStoreError(t *testing.T) {
|
|
needsBackends(t, func(t *testing.T, srv *Server) {
|
|
f := newPdx(t, srv)
|
|
id := f.row("comment")
|
|
logs := pdCaptureLogs(t)
|
|
f.exec(`ALTER TABLE user_notifications RENAME TO user_notifications_broken`)
|
|
defer f.exec(`ALTER TABLE user_notifications_broken RENAME TO user_notifications`)
|
|
f.tick()
|
|
out := logs.String()
|
|
if !strings.Contains(out, "claim failed") {
|
|
t.Fatalf("no claim failure logged: %q", out)
|
|
}
|
|
for _, leak := range []string{id, f.bob.ID, "user_notifications", "no such table", "does not exist", "claim notifications"} {
|
|
if strings.Contains(out, leak) {
|
|
t.Fatalf("log leaks %q: %s", leak, out)
|
|
}
|
|
}
|
|
})
|
|
}
|