carlos / platform Public

email events push: a failing app ends its turn; queued bodies do not travel

From re-review of the review fixes.

An app's turn ended only on an attempt with no HTTP answer, so a handler
that answered 500 after nine seconds held one of the eight slots for five
timeouts a pass while every other app waited on the pass. Any attempt that
does not land now ends the app's turn.

A row given up on because the app is not live, or the push is off, now
gives up on all of that app's rows in one UPDATE, rather than one row per
pass.

A rename or a move carried queued deliveries whose bodies still name the
old account or app, which an app checking its host correctly refuses —
eight retries and an abandon, or, for an app that does not check, a stale
id. They are dropped instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bill Horsman pushed by bill@keymail.dev 53aafa8593ab111a1dbcde02f1db974f5c5895e7 parent c2dc613
3 files changed, +62 −7
  • internal/console/mailevents.go +10 −0
  • internal/console/mailpush.go +24 −7
  • internal/console/mailpush_test.go +28 −0

CI passed — run details

diff --git a/internal/console/mailevents.go b/internal/console/mailevents.go
index 2895377fb..46874d64f 100644
--- a/internal/console/mailevents.go
+++ b/internal/console/mailevents.go
@@ -121,6 +121,16 @@ func carryMailApp(ctx context.Context, tx execer, fromSqid, fromApp, toSqid, toA
return err
}
}
+ if table == "mail_event_deliveries" {
+ // Not carried: a queued body names the account and app it was
+ // cut for, so after a move or a rename it names neither, and an
+ // app checking its host is right to refuse it. The final DELETE
+ // below drops them.
+ if _, err := tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE account = ? AND app = ?`, fromSqid, fromApp); err != nil {
+ return err
+ }
+ continue
+ }
if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET account = ?, app = ? WHERE account = ? AND app = ? AND `+
mailAppTimeColumn(table)+` >= ?`, toSqid, toApp, fromSqid, fromApp, createdAt); err != nil {
return err
diff --git a/internal/console/mailpush.go b/internal/console/mailpush.go
index b314899c6..2a8160153 100644
--- a/internal/console/mailpush.go
+++ b/internal/console/mailpush.go
@@ -391,6 +391,14 @@ SET attempts = ?, next_at = ?, last_attempt_at = ?, last_status = ?, last_error
return err
}
+// abandonMailEventDeliveries gives up on everything still queued for one
+// app, in one statement.
+func (s *Store) abandonMailEventDeliveries(ctx context.Context, acct, app, reason string, now time.Time) error {
+ _, err := s.db.ExecContext(ctx, `UPDATE mail_event_deliveries SET abandoned_at = ?, last_error = ?
+WHERE account = ? AND app = ? AND delivered_at = 0 AND abandoned_at = 0`, now.Unix(), reason, acct, app)
+ return err
+}
+
// pruneMailEventDeliveries drops every queued row past the retention window,
// delivered or not: the payload is a copy of the log's addresses and lives
// no longer than they do.
@@ -553,21 +561,30 @@ func (s *Server) mailPushPass(ctx context.Context, clock func() time.Time) error
return nil
}
-// deliverAppMailEvents sends one app's due rows in order. An attempt that got
-// no answer at all — a hang, a refused connection, a wake that never came —
-// ends the app's turn for this pass: the rest are no more likely to be
-// answered in the next ten seconds, and they keep their place for the next
-// pass rather than each burning a timeout now.
+// deliverAppMailEvents sends one app's due rows in order. Any attempt that
+// does not land ends the app's turn for this pass: an app that answers 500
+// after nine seconds is no more likely to take the next row than the last,
+// and each row it is offered holds one of mailPushApps for a timeout that
+// every other app waits on. The rest keep their place for the next pass.
+//
+// A row given up on because the app is no longer live, or the push is off,
+// gives up on all of that app's rows at once — there is nothing to wait for.
func (s *Server) deliverAppMailEvents(ctx context.Context, rows []mailEventDelivery, key ed25519.PrivateKey, clock func() time.Time) {
for _, d := range rows {
if ctx.Err() != nil {
return
}
status, failure, giveUp := s.deliverMailEvent(ctx, d, key, clock)
- if err := s.store.recordMailEventAttempt(ctx, d, status, failure, giveUp, clock()); err != nil {
+ if giveUp {
+ if err := s.store.abandonMailEventDeliveries(ctx, d.Account, d.App, failure, clock()); err != nil {
+ log.Printf("console: mail push: giving up on %s/%s: %v", d.Account, d.App, err)
+ }
+ return
+ }
+ if err := s.store.recordMailEventAttempt(ctx, d, status, failure, false, clock()); err != nil {
log.Printf("console: mail push: recording delivery %d for %s/%s: %v", d.ID, d.Account, d.App, err)
}
- if failure != "" && status == 0 {
+ if failure != "" {
return
}
}
diff --git a/internal/console/mailpush_test.go b/internal/console/mailpush_test.go
index cae75d897..00133fd84 100644
--- a/internal/console/mailpush_test.go
+++ b/internal/console/mailpush_test.go
@@ -426,6 +426,9 @@ func TestThePushFollowsTheAppAndNotItsName(t *testing.T) {
if hook, _ := fx.st.MailEventHookFor(ctx, release.OpsAccount, "jb-site"); hook != nil {
t.Error("the old name kept a push for whoever claims it next")
}
+ if n := queued(t, fx, "jb-web") + queued(t, fx, "jb-site"); n != 0 {
+ t.Errorf("a rename carried %d queued deliveries whose bodies name the old app", n)
+ }
if err := fx.st.TrashAppRow(OpsAccountID, "jb-web", time.Now()); err != nil {
t.Fatal(err)
@@ -440,3 +443,28 @@ func TestThePushFollowsTheAppAndNotItsName(t *testing.T) {
t.Errorf("a purged app's queue survived: %d rows", n)
}
}
+
+// An app that answers, but not with a 2xx, ends its turn at the first
+// failure: offering it the next row only holds a slot others wait on.
+func TestAFailingAppEndsItsTurnAtTheFirstFailure(t *testing.T) {
+ var mu sync.Mutex
+ hits := 0
+ fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ hits++
+ mu.Unlock()
+ w.WriteHeader(http.StatusInternalServerError)
+ })
+ ctx := context.Background()
+ now := time.Now()
+ for i := 0; i < mailPushPerApp; i++ {
+ id := "m-" + strconv.Itoa(i)
+ fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", id, now)), "sns-"+id)
+ }
+ fx.srv.mailPushPass(ctx, time.Now)
+ mu.Lock()
+ defer mu.Unlock()
+ if hits != 1 {
+ t.Errorf("a failing app was offered %d rows in one pass, want 1", hits)
+ }
+}