Commit 6a187ab6f5
Verified · cmc
cmd/gitbayd/main.go +12
| @@ -30,6 +30,7 @@ import ( | ||
| 30 | 30 | "gitbay.org/gitbay/internal/httpd" |
| 31 | 31 | "gitbay.org/gitbay/internal/mirror" |
| 32 | 32 | "gitbay.org/gitbay/internal/notify" |
| 33 | "gitbay.org/gitbay/internal/push" | |
| 33 | 34 | "gitbay.org/gitbay/internal/sshd" |
| 34 | 35 | "gitbay.org/gitbay/internal/store" |
| 35 | 36 | "gitbay.org/gitbay/internal/toolpath" |
| @@ -176,6 +177,17 @@ func serveCmd() *cobra.Command { | ||
| 176 | 177 | if cfg.Mail.SMTPHost != "" { |
| 177 | 178 | go notify.New(st, cfg, retryBase).Run(whCtx) |
| 178 | 179 | } |
| 180 | if cfg.Push.Enabled { | |
| 181 | p, err := push.New(st, cfg.Push, retryBase) | |
| 182 | if err != nil { | |
| 183 | // Config validation already parsed the key, so this | |
| 184 | // is not a misconfiguration; fail loudly rather than | |
| 185 | // running with a silent delivery route. | |
| 186 | slog.Error("push: starting deliverer", "err", err) | |
| 187 | } else { | |
| 188 | go p.Run(whCtx) | |
| 189 | } | |
| 190 | } | |
| 179 | 191 | go mirror.New(st, cfg).Run(whCtx) |
| 180 | 192 | if d := cfg.Registration.PendingExpiryDuration(); d > 0 { |
| 181 | 193 | go reapPending(whCtx, st, d) |
internal/push/push.go added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | package push | |
| 2 | ||
| 3 | import ( | |
| 4 | "context" | |
| 5 | "log/slog" | |
| 6 | "time" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/config" | |
| 9 | "gitbay.org/gitbay/internal/store" | |
| 10 | ) | |
| 11 | ||
| 12 | // DefaultMaxAttempts matches the mailer's: a flaky APNs delays a | |
| 13 | // notification rather than losing it, up to a point. | |
| 14 | const DefaultMaxAttempts = 5 | |
| 15 | ||
| 16 | type Deliverer struct { | |
| 17 | St *store.Store | |
| 18 | Cl *Client | |
| 19 | RetryBase time.Duration | |
| 20 | MaxAttempts int | |
| 21 | } | |
| 22 | ||
| 23 | func New(st *store.Store, cfg config.Push, retryBase time.Duration) (*Deliverer, error) { | |
| 24 | cl, err := NewClient(cfg) | |
| 25 | if err != nil { | |
| 26 | return nil, err | |
| 27 | } | |
| 28 | return &Deliverer{St: st, Cl: cl, RetryBase: retryBase, MaxAttempts: DefaultMaxAttempts}, nil | |
| 29 | } | |
| 30 | ||
| 31 | // Run drains the push queue until ctx is done. | |
| 32 | func (d *Deliverer) Run(ctx context.Context) { | |
| 33 | tick := time.NewTicker(2 * time.Second) | |
| 34 | defer tick.Stop() | |
| 35 | for { | |
| 36 | select { | |
| 37 | case <-ctx.Done(): | |
| 38 | return | |
| 39 | case <-tick.C: | |
| 40 | d.drain(ctx) | |
| 41 | } | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | func (d *Deliverer) drain(ctx context.Context) { | |
| 46 | due, err := d.St.DuePush(20) | |
| 47 | if err != nil { | |
| 48 | slog.Error("push: listing due", "err", err) | |
| 49 | return | |
| 50 | } | |
| 51 | for _, q := range due { | |
| 52 | res, after, sendErr := d.Cl.Send(ctx, q.Token, q.Title, q.Body, q.Path) | |
| 53 | msg := "" | |
| 54 | if sendErr != nil { | |
| 55 | msg = sendErr.Error() | |
| 56 | } | |
| 57 | switch res { | |
| 58 | case resultSent: | |
| 59 | d.St.MarkPushSent(q.ID) | |
| 60 | case resultReap: | |
| 61 | // The queued rows cascade with the device. | |
| 62 | if err := d.St.DeletePushDeviceByToken(q.Token); err != nil { | |
| 63 | slog.Error("push: reaping device", "device", q.DeviceID, "err", err) | |
| 64 | } | |
| 65 | case resultRetry: | |
| 66 | attempt := q.Attempts + 1 | |
| 67 | if attempt >= d.MaxAttempts { | |
| 68 | d.St.MarkPushFailed(q.ID, msg, nil) | |
| 69 | // The device id, never the token. | |
| 70 | slog.Warn("push dead-lettered", | |
| 71 | "push", q.ID, "device", q.DeviceID, "attempts", attempt, "err", msg) | |
| 72 | continue | |
| 73 | } | |
| 74 | wait := after | |
| 75 | if wait == 0 { | |
| 76 | wait = d.RetryBase << (attempt - 1) | |
| 77 | } | |
| 78 | next := time.Now().Add(wait) | |
| 79 | d.St.MarkPushFailed(q.ID, msg, &next) | |
| 80 | default: // resultDead | |
| 81 | d.St.MarkPushFailed(q.ID, msg, nil) | |
| 82 | slog.Warn("push rejected", "push", q.ID, "device", q.DeviceID, "err", msg) | |
| 83 | } | |
| 84 | } | |
| 85 | } | |
internal/push/push_test.go added +112
| @@ -0,0 +1,112 @@ | ||
| 1 | package push | |
| 2 | ||
| 3 | import ( | |
| 4 | "context" | |
| 5 | "net/http" | |
| 6 | "testing" | |
| 7 | "time" | |
| 8 | ||
| 9 | "gitbay.org/gitbay/internal/store" | |
| 10 | ) | |
| 11 | ||
| 12 | // testStoreWithQueuedPush opens an in-memory store, creates a user with | |
| 13 | // push enabled, registers one device and enqueues one push for it. | |
| 14 | func testStoreWithQueuedPush(t *testing.T, token string) *store.Store { | |
| 15 | t.Helper() | |
| 16 | st, err := store.Open(":memory:") | |
| 17 | if err != nil { | |
| 18 | t.Fatal(err) | |
| 19 | } | |
| 20 | t.Cleanup(func() { st.Close() }) | |
| 21 | if err := st.MigrateUp(); err != nil { | |
| 22 | t.Fatal(err) | |
| 23 | } | |
| 24 | uid, err := st.CreateUser("alice", false) | |
| 25 | if err != nil { | |
| 26 | t.Fatal(err) | |
| 27 | } | |
| 28 | if err := st.SetPushEnabled(uid, true); err != nil { | |
| 29 | t.Fatal(err) | |
| 30 | } | |
| 31 | if _, err := st.AddPushDevice(uid, token, "iphone"); err != nil { | |
| 32 | t.Fatal(err) | |
| 33 | } | |
| 34 | if err := st.EnqueuePush(uid, "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12"); err != nil { | |
| 35 | t.Fatal(err) | |
| 36 | } | |
| 37 | return st | |
| 38 | } | |
| 39 | ||
| 40 | // countPushDevices counts the test user's own devices. testStoreWithQueuedPush | |
| 41 | // always creates "alice", so looking her up here keeps the helper's signature | |
| 42 | // matching the brief's test code, which passes no user id. | |
| 43 | func countPushDevices(t *testing.T, st *store.Store) int { | |
| 44 | t.Helper() | |
| 45 | u, err := st.UserByUsername("alice") | |
| 46 | if err != nil { | |
| 47 | t.Fatal(err) | |
| 48 | } | |
| 49 | devices, err := st.PushDevices(u.ID) | |
| 50 | if err != nil { | |
| 51 | t.Fatal(err) | |
| 52 | } | |
| 53 | return len(devices) | |
| 54 | } | |
| 55 | ||
| 56 | func TestDrainSendsAndMarks(t *testing.T) { | |
| 57 | var hits int | |
| 58 | c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) { | |
| 59 | hits++ | |
| 60 | w.WriteHeader(200) | |
| 61 | }) | |
| 62 | st := testStoreWithQueuedPush(t, "tok-a") | |
| 63 | d := &Deliverer{St: st, Cl: c, RetryBase: time.Millisecond, MaxAttempts: 5} | |
| 64 | ||
| 65 | d.drain(context.Background()) | |
| 66 | ||
| 67 | if hits != 1 { | |
| 68 | t.Fatalf("sent %d times, want 1", hits) | |
| 69 | } | |
| 70 | if due, _ := st.DuePush(20); len(due) != 0 { | |
| 71 | t.Fatalf("row still due after a 200") | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | func TestDrainReapsADeadToken(t *testing.T) { | |
| 76 | c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) { | |
| 77 | w.WriteHeader(410) | |
| 78 | w.Write([]byte(`{"reason":"Unregistered"}`)) | |
| 79 | }) | |
| 80 | st := testStoreWithQueuedPush(t, "tok-a") | |
| 81 | d := &Deliverer{St: st, Cl: c, RetryBase: time.Millisecond, MaxAttempts: 5} | |
| 82 | ||
| 83 | d.drain(context.Background()) | |
| 84 | ||
| 85 | if due, _ := st.DuePush(20); len(due) != 0 { | |
| 86 | t.Fatalf("queue survived the reap") | |
| 87 | } | |
| 88 | // The device is gone, not merely its queue row. | |
| 89 | if n := countPushDevices(t, st); n != 0 { | |
| 90 | t.Fatalf("%d devices left after 410", n) | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | func TestDrainBacksOffThenDeadLetters(t *testing.T) { | |
| 95 | c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) { | |
| 96 | w.WriteHeader(503) | |
| 97 | }) | |
| 98 | st := testStoreWithQueuedPush(t, "tok-a") | |
| 99 | d := &Deliverer{St: st, Cl: c, RetryBase: time.Nanosecond, MaxAttempts: 3} | |
| 100 | ||
| 101 | // Three passes: two back off, the third gives up. | |
| 102 | for i := 0; i < 3; i++ { | |
| 103 | d.drain(context.Background()) | |
| 104 | } | |
| 105 | if due, _ := st.DuePush(20); len(due) != 0 { | |
| 106 | t.Fatalf("row still due after MaxAttempts") | |
| 107 | } | |
| 108 | // A transient failure must not take the device with it. | |
| 109 | if n := countPushDevices(t, st); n != 1 { | |
| 110 | t.Fatalf("device reaped on a 503") | |
| 111 | } | |
| 112 | } | |