Commit 29548d6557

29548d6557f45c2b5a36a3570713ce06f8e020c5

parent: c9cf3e5403

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 09:23 UTC

store: the push queue, swept like the mail queue

One row per device per notice, so a retry to one device does not
resend to another. EnqueuePush writes nothing when the account has
push off or no devices. [retention] push caps the table.

Ref #89
cmd/gitbayd/main.go +2 −2
@@ -515,9 +515,9 @@ func sweep(ctx context.Context, st *store.Store, cfg config.Config) {
515515 tick = d
516516 }
517517 }
518 audit, events, deliveries, mail := cfg.Retention.Durations()
518 audit, events, deliveries, mail, push := cfg.Retention.Durations()
519519 r := store.Retention{Audit: audit, Events: events,
520 WebhookDeliveries: deliveries, Mail: mail}
520 WebhookDeliveries: deliveries, Mail: mail, Push: push}
521521 t := time.NewTicker(tick)
522522 defer t.Stop()
523523 for {
internal/config/config.go +5 −3
@@ -125,10 +125,12 @@ type Retention struct {
125125 WebhookDeliveries string `toml:"webhook_deliveries"`
126126 // Mail is the outbound queue: rows already sent or given up on.
127127 Mail string `toml:"mail"`
128 // Push is the outbound device queue: rows already sent or given up on.
129 Push string `toml:"push"`
128130}
129131
130// Durations parses the four, mapping each to zero when unset or bad.
131func (r Retention) Durations() (audit, events, deliveries, mail time.Duration) {
132// Durations parses the five, mapping each to zero when unset or bad.
133func (r Retention) Durations() (audit, events, deliveries, mail, push time.Duration) {
132134 parse := func(s string) time.Duration {
133135 d, err := time.ParseDuration(s)
134136 if err != nil || d < 0 {
@@ -136,7 +138,7 @@ func (r Retention) Durations() (audit, events, deliveries, mail time.Duration) {
136138 }
137139 return d
138140 }
139 return parse(r.Audit), parse(r.Events), parse(r.WebhookDeliveries), parse(r.Mail)
141 return parse(r.Audit), parse(r.Events), parse(r.WebhookDeliveries), parse(r.Mail), parse(r.Push)
140142}
141143
142144// LFS stores large-file objects content-addressed under Root (default
internal/store/push.go +78
@@ -3,6 +3,7 @@ package store
33import (
44 "database/sql"
55 "errors"
6 "time"
67)
78
89// PushDevice is one Apple device an account has registered. Token is the
@@ -90,3 +91,80 @@ func (s *Store) SetPushEnabled(userID int64, on bool) error {
9091 _, err := s.DB.Exec("UPDATE users SET notify_push = ? WHERE id = ?", v, userID)
9192 return err
9293}
94
95// QueuedPush is one pending push, joined to the token it is bound for so
96// the drainer needs one query rather than two.
97type QueuedPush struct {
98 ID int64
99 DeviceID int64
100 Token string
101 Title string
102 Body string
103 Path string
104 Attempts int
105}
106
107// EnqueuePush writes one row per registered device, and nothing when the
108// account has push off or no devices — the same shape as
109// ActivityMailAddress returning "" when notify_mail is off. Mute, watch
110// and actor-exclusion are already settled by NotifyRecipients before a
111// caller reaches here.
112func (s *Store) EnqueuePush(userID int64, title, body, path string) error {
113 on, err := s.PushEnabled(userID)
114 if err != nil || !on {
115 return err
116 }
117 _, err = s.DB.Exec(`
118 INSERT INTO push_queue (device_id, title, body, path)
119 SELECT id, ?, ?, ? FROM push_devices WHERE user_id = ?`,
120 title, body, path, userID)
121 return err
122}
123
124func (s *Store) DuePush(limit int) ([]QueuedPush, error) {
125 rows, err := s.DB.Query(`
126 SELECT q.id, q.device_id, d.token, q.title, q.body, q.path, q.attempts
127 FROM push_queue q JOIN push_devices d ON d.id = q.device_id
128 WHERE q.sent_at IS NULL AND q.failed_at IS NULL
129 AND (q.next_attempt_at IS NULL OR q.next_attempt_at <= ?)
130 ORDER BY q.id LIMIT ?`, fmtTime(time.Now()), limit)
131 if err != nil {
132 return nil, err
133 }
134 defer rows.Close()
135 var out []QueuedPush
136 for rows.Next() {
137 var p QueuedPush
138 if err := rows.Scan(&p.ID, &p.DeviceID, &p.Token, &p.Title, &p.Body, &p.Path, &p.Attempts); err != nil {
139 return nil, err
140 }
141 out = append(out, p)
142 }
143 return out, rows.Err()
144}
145
146func (s *Store) MarkPushSent(id int64) error {
147 _, err := s.DB.Exec(
148 "UPDATE push_queue SET sent_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), attempts = attempts + 1 WHERE id = ?", id)
149 return err
150}
151
152func (s *Store) MarkPushFailed(id int64, errMsg string, nextAt *time.Time) error {
153 if nextAt == nil {
154 _, err := s.DB.Exec(
155 "UPDATE push_queue SET failed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), attempts = attempts + 1, last_error = ? WHERE id = ?",
156 errMsg, id)
157 return err
158 }
159 _, err := s.DB.Exec(
160 "UPDATE push_queue SET attempts = attempts + 1, last_error = ?, next_attempt_at = ? WHERE id = ?",
161 errMsg, fmtTime(*nextAt), id)
162 return err
163}
164
165// DeletePushDeviceByToken drops a device Apple has told us is gone. The
166// queue rows cascade, so nothing is left retrying at a dead token.
167func (s *Store) DeletePushDeviceByToken(token string) error {
168 _, err := s.DB.Exec("DELETE FROM push_devices WHERE token = ?", token)
169 return err
170}
internal/store/push_test.go +95 −1
@@ -1,6 +1,9 @@
11package store
22
3import "testing"
3import (
4 "testing"
5 "time"
6)
47
58func pushFixture(t *testing.T) *Store {
69 t.Helper()
@@ -92,3 +95,94 @@ func TestPushEnabledDefaultsOn(t *testing.T) {
9295 t.Fatal("SetPushEnabled(false) did not stick")
9396 }
9497}
98
99func TestEnqueuePush(t *testing.T) {
100 s := pushFixture(t)
101 uid, err := s.CreateUser("alice", false)
102 if err != nil {
103 t.Fatal(err)
104 }
105 s.AddPushDevice(uid, "tok-a", "iphone")
106 s.AddPushDevice(uid, "tok-b", "ipad")
107
108 // One row per device, so a retry to the phone does not resend to the
109 // iPad.
110 if err := s.EnqueuePush(uid, "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12"); err != nil {
111 t.Fatalf("EnqueuePush: %v", err)
112 }
113 due, err := s.DuePush(20)
114 if err != nil {
115 t.Fatalf("DuePush: %v", err)
116 }
117 if len(due) != 2 {
118 t.Fatalf("want a row per device, got %d", len(due))
119 }
120 if due[0].Token == "" || due[0].Body != "cmc opened issue #12" {
121 t.Fatalf("got %+v", due[0])
122 }
123
124 // Sent rows stop being due.
125 if err := s.MarkPushSent(due[0].ID); err != nil {
126 t.Fatalf("MarkPushSent: %v", err)
127 }
128 if due, _ := s.DuePush(20); len(due) != 1 {
129 t.Fatalf("sent row still due")
130 }
131
132 // A failure with a next attempt in the future is not due yet.
133 next := time.Now().Add(time.Hour)
134 if err := s.MarkPushFailed(due[1].ID, "503", &next); err != nil {
135 t.Fatalf("MarkPushFailed: %v", err)
136 }
137 if due, _ := s.DuePush(20); len(due) != 0 {
138 t.Fatalf("backed-off row is due too early")
139 }
140}
141
142func TestEnqueuePushRespectsSettingAndDevices(t *testing.T) {
143 s := pushFixture(t)
144 uid, err := s.CreateUser("alice", false)
145 if err != nil {
146 t.Fatal(err)
147 }
148
149 // No devices: nothing queued, no error.
150 if err := s.EnqueuePush(uid, "t", "b", "p"); err != nil {
151 t.Fatalf("EnqueuePush with no devices: %v", err)
152 }
153 if due, _ := s.DuePush(20); len(due) != 0 {
154 t.Fatalf("queued for an account with no devices")
155 }
156
157 // Setting off: nothing queued.
158 s.AddPushDevice(uid, "tok-a", "iphone")
159 s.SetPushEnabled(uid, false)
160 if err := s.EnqueuePush(uid, "t", "b", "p"); err != nil {
161 t.Fatalf("EnqueuePush with push off: %v", err)
162 }
163 if due, _ := s.DuePush(20); len(due) != 0 {
164 t.Fatalf("queued with notify_push off")
165 }
166}
167
168func TestDeletePushDeviceByTokenTakesItsQueue(t *testing.T) {
169 s := pushFixture(t)
170 uid, err := s.CreateUser("alice", false)
171 if err != nil {
172 t.Fatal(err)
173 }
174 s.AddPushDevice(uid, "tok-a", "iphone")
175 s.EnqueuePush(uid, "t", "b", "p")
176
177 if err := s.DeletePushDeviceByToken("tok-a"); err != nil {
178 t.Fatalf("DeletePushDeviceByToken: %v", err)
179 }
180 if d, _ := s.PushDevices(uid); len(d) != 0 {
181 t.Fatalf("device survived")
182 }
183 // push_queue.device_id is ON DELETE CASCADE, so the queued rows go
184 // with it rather than being retried at a dead token forever.
185 if due, _ := s.DuePush(20); len(due) != 0 {
186 t.Fatalf("queued rows outlived their device")
187 }
188}
internal/store/retention.go +2
@@ -32,6 +32,7 @@ type Retention struct {
3232 Events time.Duration
3333 WebhookDeliveries time.Duration
3434 Mail time.Duration
35 Push time.Duration
3536}
3637
3738// Sweep deletes expired sessions and tokens, then the rows older than
@@ -79,6 +80,7 @@ func (s *Store) Sweep(r Retention, now time.Time) (Swept, error) {
7980 SELECT 1 FROM webhook_deliveries d
8081 WHERE d.event_id = events.id AND d.delivered_at IS NULL AND d.failed_at IS NULL)`, r.Events},
8182 {"notifications", "created_at < ? AND (sent_at IS NOT NULL OR failed_at IS NOT NULL)", r.Mail},
83 {"push_queue", "created_at < ? AND (sent_at IS NOT NULL OR failed_at IS NOT NULL)", r.Push},
8284 }
8385 for _, a := range aged {
8486 if a.keep <= 0 {