issue 800: fix review round 3 finding, a true sliding window instead of a session quota
mesh/merge-gate head c2cd2e1eb15e merged into trunk 789b0a9474c8 — pass: builds mesh-mail, messenger, new: modules/mesh-mail-notifier → novox; no bus step;…
mesh/repo-check head c2cd2e1eb15e merged into trunk 789b0a9474c8 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery delivered
mesh/delivery-group group feat/mail-seats delivered: every member is delivered
mesh/merge-gate head c2cd2e1eb15e merged into trunk 789b0a9474c8 — pass: builds mesh-mail, messenger, new: modules/mesh-mail-notifier → novox; no bus step;…
mesh/repo-check head c2cd2e1eb15e merged into trunk 789b0a9474c8 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery delivered
mesh/delivery-group group feat/mail-seats delivered: every member is delivered
A session that only reset after a quiet minute kept counting, and so folding, a steady trickle of mail forever even with no burst (one mail every 50s). N is now the account's qualifying mail arrived in the trailing 60 seconds, counted fresh on every arrival; a fold holds only its own overflow and the notice names just that count, not the whole history.
This commit is contained in:
@@ -4,12 +4,17 @@ package main
|
|||||||
// issue 800 part B). It hears mesh-mail's added events on its own account (runs-as, ADR 0376 §2) and shows each
|
// issue 800 part B). It hears mesh-mail's added events on its own account (runs-as, ADR 0376 §2) and shows each
|
||||||
// new inbox mail at most once, through node-notifier.send: quiet during a first sync (mesh-mail already says
|
// new inbox mail at most once, through node-notifier.send: quiet during a first sync (mesh-mail already says
|
||||||
// nothing then, imap.go ~387), for mail from the account's own address, for mail already marked read (\Seen),
|
// nothing then, imap.go ~387), for mail from the account's own address, for mail already marked read (\Seen),
|
||||||
// and for anything outside the inbox. Of one account's qualifying mail, a mail is shown at once when fewer
|
// and for anything outside the inbox. For each new qualifying mail of an account, arriving at t, N counts that
|
||||||
// than FoldOver of that account's mails were shown in the Window before it; otherwise it is folded and shown,
|
// account's qualifying mail — shown or folded alike, each counted once — that arrived in the 60 seconds up to
|
||||||
// with the other mail its session folds, once Window has passed since the first of them — the window slides
|
// and including t: N <= FoldOver is shown at once; N > FoldOver is folded. The first folded mail opens the
|
||||||
// on the gap between mails, not on time elapsed since the session's first one, so a session already past its
|
// account's fold; every folded mail joins it; the fold is shown, as one notice naming how many it holds and
|
||||||
// quota stays folding for as long as mail keeps arriving within Window of the last (novox/hq ADR 0378 §9, the
|
// the latest sender, once Window has passed since its own first mail — after which a new fold may open. The
|
||||||
// lead's reading, review rounds 1 and 2 of issue 800).
|
// count is a true sliding window of arrivals, not a per-session quota that only resets after a quiet Window:
|
||||||
|
// review round 2's reading kept a session's count alive, and so folding, for as long as any gap stayed under
|
||||||
|
// Window, which folds a steady trickle (one mail every 50s, say) forever even though no burst ever happened;
|
||||||
|
// review round 3 of issue 800 replaces that reading with this one. Nothing pending — a folded mail awaiting
|
||||||
|
// its fold's deadline, or a failed send awaiting its retry — is ever dropped (novox/hq ADR 0378 §9, review
|
||||||
|
// round 3 of issue 800, which replaces the round 1 and round 2 readings).
|
||||||
//
|
//
|
||||||
// Nothing here names Betterbird (ADR 0379 §3).
|
// Nothing here names Betterbird (ADR 0379 §3).
|
||||||
|
|
||||||
@@ -31,14 +36,11 @@ const SeenFlag = "\\Seen"
|
|||||||
// that window is folded into a single notice (novox/hq ADR 0378 §9: "more than three").
|
// that window is folded into a single notice (novox/hq ADR 0378 §9: "more than three").
|
||||||
const FoldOver = 3
|
const FoldOver = 3
|
||||||
|
|
||||||
// Window is the sliding minute ADR 0378 §9 judges a burst over. It slides on the gap between one qualifying
|
// Window is the trailing 60 seconds ADR 0378 §9 counts a mail's N over (every qualifying mail of the account,
|
||||||
// mail and the next, not on time elapsed since a session's first mail (review round 2 of issue 800): a mail
|
// shown or folded alike, that arrived in the 60 seconds up to and including this one), and the time a fold
|
||||||
// is shown at once when fewer than FoldOver of the account's mails were shown in the Window before it; past
|
// waits, from its own first mail, before it is shown (review round 3 of issue 800, which replaces the round 1
|
||||||
// that, it is folded and shown, with whatever else its session folds, once Window has passed since the first
|
// and round 2 readings: the count is a true sliding window of arrivals, not a per-session quota that stays
|
||||||
// of them. A session's count never resets while mail keeps arriving within Window of the last one, however
|
// alive, and so keeps folding, for as long as any gap between mails stays under Window).
|
||||||
// long the session has run; it is only ever replaced once idle for a full Window with nothing left pending
|
|
||||||
// (review round 2 of issue 800, BLOCKING: a buffer is never replaced while it holds an unsent mail). There is
|
|
||||||
// no separate quiet-gap timer beside this (review round 1 of issue 800: BurstWindow goes).
|
|
||||||
const Window = time.Minute
|
const Window = time.Minute
|
||||||
|
|
||||||
// RetryBackoff is how long the notifier waits, after an account's notice fails to send, before it tries that
|
// RetryBackoff is how long the notifier waits, after an account's notice fails to send, before it tries that
|
||||||
@@ -147,16 +149,14 @@ type AccountStatus struct {
|
|||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// accountBuffer is one account's sliding session: it stays open, and its count never resets, as long as a
|
// accountBuffer is one account's state: history is every qualifying mail's arrival time, kept only for the
|
||||||
// qualifying mail keeps arriving within Window of the previous one (review round 2 of issue 800). The first
|
// trailing Window, that N is counted against (review round 3 of issue 800); immediate holds mail waiting to be
|
||||||
// FoldOver mails of a session are shown one by one as soon as Flush runs; the rest, from the fourth onward,
|
// shown one by one at the next Flush; folded holds the account's current fold (its first FoldOver mails within
|
||||||
// are folded and shown as one notice once Window has passed since the first of them, whether or not the
|
// Window are never folded, so a fold only ever holds the overflow from the fourth qualifying mail of its own
|
||||||
// session itself is still open.
|
// window onward), due as one notice once Window has passed since foldStart, its first mail's arrival.
|
||||||
type accountBuffer struct {
|
type accountBuffer struct {
|
||||||
lastArrival time.Time // the latest qualifying mail's arrival: a gap of Window since this lets a fresh session open
|
history []time.Time
|
||||||
foldStart time.Time // when this session's first folded mail arrived; zero while nothing is folded
|
foldStart time.Time // when the current fold's first mail arrived; zero while nothing is folded
|
||||||
count int // every qualifying mail this session has counted, shown or folded alike (bullet 3: the
|
|
||||||
// fold notice names this total, not just the overflow)
|
|
||||||
immediate []EventBody
|
immediate []EventBody
|
||||||
folded []EventBody
|
folded []EventBody
|
||||||
}
|
}
|
||||||
@@ -229,23 +229,28 @@ func (n *Notifier) Add(e EventBody) error {
|
|||||||
}
|
}
|
||||||
now := n.Now()
|
now := n.Now()
|
||||||
b := n.pending[e.Account]
|
b := n.pending[e.Account]
|
||||||
// A session stays open, and its count never resets, as long as a qualifying mail keeps arriving within
|
if b == nil {
|
||||||
// Window of the last one — even once more than Window has passed since the session's own first mail
|
|
||||||
// (the sliding minute, review round 2 of issue 800). It is replaced only once it is both idle for a full
|
|
||||||
// Window and fully flushed: nothing pending is ever discarded to make room for a fresh session (BLOCKING:
|
|
||||||
// a buffer is never replaced while it holds an unsent mail — a failed send waiting for its retry, or a
|
|
||||||
// folded mail waiting for its minute's end).
|
|
||||||
if b == nil || (now.Sub(b.lastArrival) >= Window && len(b.immediate) == 0 && len(b.folded) == 0) {
|
|
||||||
b = &accountBuffer{}
|
b = &accountBuffer{}
|
||||||
n.pending[e.Account] = b
|
n.pending[e.Account] = b
|
||||||
}
|
}
|
||||||
b.lastArrival = now
|
// N is every qualifying mail of this account — shown or folded alike, each counted once — that arrived in
|
||||||
b.count++
|
// the 60 seconds up to and including now: a true sliding window over arrivals, not a per-session quota that
|
||||||
|
// stays alive, and so keeps folding, for as long as any gap stays under Window (review round 3 of issue
|
||||||
|
// 800, which replaces the round 1 and round 2 readings; nothing in this count depends on whether an earlier
|
||||||
|
// mail has been shown yet).
|
||||||
|
cutoff := now.Add(-Window)
|
||||||
|
history := make([]time.Time, 0, len(b.history)+1)
|
||||||
|
for _, at := range b.history {
|
||||||
|
if !at.Before(cutoff) {
|
||||||
|
history = append(history, at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.history = append(history, now)
|
||||||
n.buffered[handle] = true
|
n.buffered[handle] = true
|
||||||
if b.count <= FoldOver {
|
if len(b.history) <= FoldOver {
|
||||||
b.immediate = append(b.immediate, e)
|
b.immediate = append(b.immediate, e)
|
||||||
} else {
|
} else {
|
||||||
if b.foldStart.IsZero() {
|
if len(b.folded) == 0 {
|
||||||
b.foldStart = now
|
b.foldStart = now
|
||||||
}
|
}
|
||||||
b.folded = append(b.folded, e)
|
b.folded = append(b.folded, e)
|
||||||
@@ -320,10 +325,10 @@ func (n *Notifier) Flush() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// flushAccount ships one account's ready notices (its immediate mail, and its folded mail once Window has
|
// flushAccount ships one account's ready notices (its immediate mail, and its fold once Window has passed
|
||||||
// passed since the first of them), and keeps whatever fails to send pending, under cooldown, for the next
|
// since the fold's own first mail), and keeps whatever fails to send pending, under cooldown, for the next
|
||||||
// Flush. Nothing is deleted before it is shown (review round 1 BLOCKING 2), and nothing is replaced, or lost,
|
// Flush. Nothing is deleted before it is shown (review round 1 BLOCKING 2), and nothing pending is ever lost
|
||||||
// while it is still pending (review round 2 BLOCKING; see Add).
|
// (review round 2 BLOCKING, kept under review round 3's rule; see Add).
|
||||||
func (n *Notifier) flushAccount(account string, now time.Time) {
|
func (n *Notifier) flushAccount(account string, now time.Time) {
|
||||||
n.mu.Lock()
|
n.mu.Lock()
|
||||||
if cd, ok := n.cooldown[account]; ok && now.Before(cd) {
|
if cd, ok := n.cooldown[account]; ok && now.Before(cd) {
|
||||||
@@ -336,19 +341,15 @@ func (n *Notifier) flushAccount(account string, now time.Time) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
immediate := append([]EventBody(nil), b.immediate...)
|
immediate := append([]EventBody(nil), b.immediate...)
|
||||||
// The fold group is due Window after its own first mail — independent of whether the session itself is
|
// The fold is due Window after its own first mail (review round 3 of issue 800).
|
||||||
// still open (review round 2 of issue 800: the window slides on gaps, so a session can still be taking in
|
foldDue := len(b.folded) > 0 && now.Sub(b.foldStart) >= Window
|
||||||
// new mail well past its first fold's deadline).
|
|
||||||
foldDue := !b.foldStart.IsZero() && now.Sub(b.foldStart) >= Window
|
|
||||||
var fold []EventBody
|
var fold []EventBody
|
||||||
foldCount := b.count // every mail of the account this session has counted (bullet 3), not just the fold's own
|
|
||||||
if foldDue {
|
if foldDue {
|
||||||
fold = append([]EventBody(nil), b.folded...)
|
fold = append([]EventBody(nil), b.folded...)
|
||||||
}
|
}
|
||||||
n.mu.Unlock()
|
n.mu.Unlock()
|
||||||
|
|
||||||
if len(immediate) == 0 && len(fold) == 0 {
|
if len(immediate) == 0 && len(fold) == 0 {
|
||||||
n.closeIfIdle(account, b, now)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,7 +364,7 @@ func (n *Notifier) flushAccount(account string, now time.Time) {
|
|||||||
}
|
}
|
||||||
foldSent := false
|
foldSent := false
|
||||||
if failErr == nil && len(fold) > 0 {
|
if failErr == nil && len(fold) > 0 {
|
||||||
if err := n.show(n.foldNotice(account, fold, foldCount)); err != nil {
|
if err := n.show(n.foldNotice(account, fold, len(fold))); err != nil {
|
||||||
failErr = err
|
failErr = err
|
||||||
} else {
|
} else {
|
||||||
foldSent = true
|
foldSent = true
|
||||||
@@ -376,19 +377,13 @@ func (n *Notifier) flushAccount(account string, now time.Time) {
|
|||||||
if foldSent {
|
if foldSent {
|
||||||
cur.folded = cur.folded[len(fold):]
|
cur.folded = cur.folded[len(fold):]
|
||||||
if len(cur.folded) == 0 {
|
if len(cur.folded) == 0 {
|
||||||
cur.foldStart = time.Time{}
|
cur.foldStart = time.Time{} // nothing left folded: a new fold may open (review round 3 of issue 800)
|
||||||
} else {
|
} else {
|
||||||
// More mail folded in while this batch was being sent: its own Window starts now, counted from
|
// More mail folded in while this batch was being sent: its own Window starts now, counted from
|
||||||
// when it was first noticed. Nothing of it is lost either way.
|
// when it was first noticed. Nothing of it is lost either way.
|
||||||
cur.foldStart = now
|
cur.foldStart = now
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The session itself — its lastArrival and count — is only ever dropped once it has gone idle for a
|
|
||||||
// full Window with nothing left pending (review round 2 of issue 800, BLOCKING: a buffer is never
|
|
||||||
// replaced, or its unsent mail lost, while it still holds one).
|
|
||||||
if now.Sub(cur.lastArrival) >= Window && len(cur.immediate) == 0 && len(cur.folded) == 0 {
|
|
||||||
delete(n.pending, account)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if failErr != nil {
|
if failErr != nil {
|
||||||
n.cooldown[account] = now.Add(RetryBackoff)
|
n.cooldown[account] = now.Add(RetryBackoff)
|
||||||
@@ -400,17 +395,6 @@ func (n *Notifier) flushAccount(account string, now time.Time) {
|
|||||||
n.mu.Unlock()
|
n.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// closeIfIdle drops an account's session once it has gone idle for a full Window with nothing left pending:
|
|
||||||
// the next qualifying mail then opens a fresh session with a fresh quota (see Add).
|
|
||||||
func (n *Notifier) closeIfIdle(account string, b *accountBuffer, now time.Time) {
|
|
||||||
n.mu.Lock()
|
|
||||||
defer n.mu.Unlock()
|
|
||||||
if cur := n.pending[account]; cur == b && now.Sub(cur.lastArrival) >= Window &&
|
|
||||||
len(cur.immediate) == 0 && len(cur.folded) == 0 {
|
|
||||||
delete(n.pending, account)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordFailureLocked keeps an account's failure in the Store (mu held by the caller, released around the
|
// recordFailureLocked keeps an account's failure in the Store (mu held by the caller, released around the
|
||||||
// Store calls since they may reach the bus): its count, its last error, and since when, so a later Flush and
|
// Store calls since they may reach the bus): its count, its last error, and since when, so a later Flush and
|
||||||
// the health tool agree on how long it has been failing.
|
// the health tool agree on how long it has been failing.
|
||||||
@@ -452,17 +436,17 @@ func (n *Notifier) singleNotice(account string, e EventBody) Notice {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// foldNotice is one notice for everything an account's session folded — its fourth qualifying mail onward —
|
// foldNotice is one notice for everything the account's current fold holds — its qualifying mail from the
|
||||||
// naming the latest sender and how many mail the account has had in total this session, not only the
|
// fourth within some trailing 60 seconds onward — naming how many mail that fold holds (not the account's
|
||||||
// overflow (novox/hq ADR 0378 §9: "more than three new mails from one account within a minute become one
|
// whole history of arrivals) and the latest sender (novox/hq ADR 0378 §9, review round 3 of issue 800, which
|
||||||
// notice that says how many and names the latest sender"; review round 2 of issue 800, bullet 3: the count is
|
// replaces the round 1 and round 2 readings: "<k> more new mail(s) in <account>, latest from <sender of the
|
||||||
// every mail of the account in that minute, "mail" for one and "mails" for more).
|
// last>", "mail" for one and "mails" for more).
|
||||||
func (n *Notifier) foldNotice(account string, events []EventBody, count int) Notice {
|
func (n *Notifier) foldNotice(account string, events []EventBody, count int) Notice {
|
||||||
latest := events[len(events)-1]
|
latest := events[len(events)-1]
|
||||||
notice := Notice{
|
notice := Notice{
|
||||||
Account: account,
|
Account: account,
|
||||||
Args: map[string]any{
|
Args: map[string]any{
|
||||||
"summary": strconv.Itoa(count) + " new " + mailWord(count) + " in " + account,
|
"summary": strconv.Itoa(count) + " more new " + mailWord(count) + " in " + account,
|
||||||
"body": "latest from " + latest.sender(),
|
"body": "latest from " + latest.sender(),
|
||||||
"app_name": "mesh-mail",
|
"app_name": "mesh-mail",
|
||||||
},
|
},
|
||||||
@@ -474,7 +458,8 @@ func (n *Notifier) foldNotice(account string, events []EventBody, count int) Not
|
|||||||
return notice
|
return notice
|
||||||
}
|
}
|
||||||
|
|
||||||
// mailWord says "mail" for a count of one, "mails" for anything else (review round 2 of issue 800, bullet 3).
|
// mailWord says "mail" for a count of one, "mails" for anything else (review round 2 of issue 800, bullet 3;
|
||||||
|
// kept by review round 3).
|
||||||
func mailWord(count int) string {
|
func mailWord(count int) string {
|
||||||
if count == 1 {
|
if count == 1 {
|
||||||
return "mail"
|
return "mail"
|
||||||
@@ -531,7 +516,7 @@ func (n *Notifier) Status() (map[string]any, error) {
|
|||||||
"rules": []string{
|
"rules": []string{
|
||||||
"it shows each new inbox mail at most once, through node-notifier.send on this machine (novox/hq ADR 0378 §7)",
|
"it shows each new inbox mail at most once, through node-notifier.send on this machine (novox/hq ADR 0378 §7)",
|
||||||
"quiet during a first sync, for mail from the account's own address, for mail already marked read, and for anything outside the inbox",
|
"quiet during a first sync, for mail from the account's own address, for mail already marked read, and for anything outside the inbox",
|
||||||
"of one account's qualifying mail within a sliding minute, the first three are shown one by one; the rest of that minute is folded into one notice naming the count and the latest sender",
|
"of one account's qualifying mail, one arriving when fewer than three of the account's mails fell in the 60 seconds before it is shown at once; the rest is folded and shown, as one notice naming how many it holds and the latest sender, 60 seconds after the fold's own first mail",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if knownErr != nil {
|
if knownErr != nil {
|
||||||
|
|||||||
@@ -210,9 +210,9 @@ func TestAFailedSendIsRetriedWithoutLosingItOrHoldingUpOtherAccounts(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestABurstShowsTheFirstThreeAtOnceAndFoldsTheRestAtTheWindowsEnd is the review's third test (BLOCKING 3,
|
// TestABurstShowsTheFirstThreeAtOnceAndFoldsTheRestAtTheWindowsEnd is the review's third test (BLOCKING 3,
|
||||||
// ADR 0378 §9, the lead's reading): the first three of an account's qualifying mail within a minute are shown
|
// ADR 0378 §9, the lead's reading, the fold notice's wording updated by review round 3 of issue 800): the
|
||||||
// one by one as Flush runs; the fourth onward is folded and shown as one notice once the window — a minute
|
// first three of an account's qualifying mail within a minute are shown one by one as Flush runs; the fourth
|
||||||
// from the first of them — is over.
|
// onward is folded and shown as one notice once the window — a minute from the first of them — is over.
|
||||||
func TestABurstShowsTheFirstThreeAtOnceAndFoldsTheRestAtTheWindowsEnd(t *testing.T) {
|
func TestABurstShowsTheFirstThreeAtOnceAndFoldsTheRestAtTheWindowsEnd(t *testing.T) {
|
||||||
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
||||||
store := newFakeStore()
|
store := newFakeStore()
|
||||||
@@ -246,8 +246,8 @@ func TestABurstShowsTheFirstThreeAtOnceAndFoldsTheRestAtTheWindowsEnd(t *testing
|
|||||||
if len(sent) != 4 {
|
if len(sent) != 4 {
|
||||||
t.Fatalf("after the window ended, sent %d notice(s), want 4 (the folded fourth mail too): %+v", len(sent), sent)
|
t.Fatalf("after the window ended, sent %d notice(s), want 4 (the folded fourth mail too): %+v", len(sent), sent)
|
||||||
}
|
}
|
||||||
if sent[3]["summary"] != "4 new mails in home" || !strings.Contains(sent[3]["body"].(string), "Sender 4") {
|
if sent[3]["summary"] != "1 more new mail in home" || !strings.Contains(sent[3]["body"].(string), "Sender 4") {
|
||||||
t.Errorf("the fold notice is %+v, want it to count every mail of the session (4), naming Sender 4", sent[3])
|
t.Errorf("the fold notice is %+v, want it to count only what the fold holds (1), naming Sender 4", sent[3])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,8 +298,8 @@ func TestARedeliveredEventIsNeverShownOrCountedTwice(t *testing.T) {
|
|||||||
if len(sent) != 4 {
|
if len(sent) != 4 {
|
||||||
t.Fatalf("sent %d notice(s), want 4 (one, mail 2, mail 3, and one fold notice): %+v", len(sent), sent)
|
t.Fatalf("sent %d notice(s), want 4 (one, mail 2, mail 3, and one fold notice): %+v", len(sent), sent)
|
||||||
}
|
}
|
||||||
if sent[3]["summary"] != "4 new mails in home" {
|
if sent[3]["summary"] != "1 more new mail in home" {
|
||||||
t.Errorf("the fold notice is %+v, want it to count the redelivered fourth mail once, not twice, among the session's 4", sent[3])
|
t.Errorf("the fold notice is %+v, want it to count the redelivered fourth mail once, not twice, among the fold's 1", sent[3])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,53 +450,15 @@ func TestABufferIsNeverReplacedWhileItHoldsAnUnsentMail(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAFoldedMailIsNeverLostBetweenTheWindowsEndAndTheNextFlush is review round 2 of issue 800's second test
|
// TestTheFoldFollowsATrueSlidingWindowOfArrivalsNotASessionQuota is review round 3 of issue 800's first test,
|
||||||
// for the same BLOCKING finding: no failure here, but the fourth mail is folded, its minute ends, and a new
|
// replacing round 2's (which contradicted round 3's rule): N, for a mail arriving at t, is every qualifying
|
||||||
// mail arrives before the next Flush. The folded mail is still shown, never lost.
|
// mail of the account — shown or folded alike — that arrived in the 60 seconds up to and including t. Mails at
|
||||||
func TestAFoldedMailIsNeverLostBetweenTheWindowsEndAndTheNextFlush(t *testing.T) {
|
// 0, 50, 55, 70, 75 and 80 seconds: at 70s the preceding 60 seconds (from 10s) hold 50, 55 and 70 themselves
|
||||||
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
// (0s has aged out), N=3, so 70 is still shown at once, not folded; at 75s the preceding 60 seconds (from 15s)
|
||||||
store := newFakeStore()
|
// still hold 50 too, so it, 55, 70 and 75 themselves make N=4, and 75 folds; 80 folds with it (its own
|
||||||
var sent []map[string]any
|
// preceding 60 seconds hold 50, 55, 70, 75 and 80, N=5). The fold is due 60 seconds after its own first mail
|
||||||
n := newTestNotifier(t, clock, store, &sent)
|
// (75s), at 135s, as one notice naming only what it holds (2) and the latest sender (80s's).
|
||||||
|
func TestTheFoldFollowsATrueSlidingWindowOfArrivalsNotASessionQuota(t *testing.T) {
|
||||||
account := "home"
|
|
||||||
for i := 1; i <= 4; i++ {
|
|
||||||
if err := n.Add(mail(account, i, fmt.Sprintf("m%d", i), fmt.Sprintf("Mail %d", i), fmt.Sprintf("Sender %d", i))); err != nil {
|
|
||||||
t.Fatalf("Add mail %d: %v", i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := n.Flush(); err != nil {
|
|
||||||
t.Fatalf("Flush: %v", err)
|
|
||||||
}
|
|
||||||
if len(sent) != 3 {
|
|
||||||
t.Fatalf("sent %d notice(s), want 3 shown one by one, the fourth folded, not yet due: %+v", len(sent), sent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The fold's minute is over (a Window since mail 4, its first folded mail), but Flush has not run again
|
|
||||||
// yet: a new mail arrives in that gap.
|
|
||||||
clock.Advance(Window + time.Second)
|
|
||||||
if err := n.Add(mail(account, 5, "m5", "Mail 5", "Sender 5")); err != nil {
|
|
||||||
t.Fatalf("Add mail 5: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := n.Flush(); err != nil {
|
|
||||||
t.Fatalf("Flush: %v", err)
|
|
||||||
}
|
|
||||||
if len(sent) != 4 {
|
|
||||||
t.Fatalf("sent %d notice(s), want 4 (one fold notice carrying both mail 4 and mail 5): %+v", len(sent), sent)
|
|
||||||
}
|
|
||||||
if sent[3]["summary"] != "5 new mails in home" || !strings.Contains(sent[3]["body"].(string), "Sender 5") {
|
|
||||||
t.Errorf("the fold notice is %+v, want it to count all 5 of the session, naming Sender 5 (the latest)", sent[3])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTheWindowSlidesOnGapsNotOnElapsedTimeSinceItsFirstMail is review round 2 of issue 800's test for the
|
|
||||||
// sliding minute: a mail is shown at once when fewer than three of the account's mails were shown in the 60
|
|
||||||
// seconds before it; otherwise it is folded and shown with the rest 60 seconds after the first of them. Mails
|
|
||||||
// at 0, 50, 55, 70, 75 and 80 seconds: 0, 50 and 55 are each within 60s of fewer than 3 prior mails, so each
|
|
||||||
// is shown; 70 is not (50 and 55 both fall in its preceding 60s, and the session is already past its quota),
|
|
||||||
// so it and everything after it, within the same ongoing session, fold into one notice.
|
|
||||||
func TestTheWindowSlidesOnGapsNotOnElapsedTimeSinceItsFirstMail(t *testing.T) {
|
|
||||||
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
||||||
store := newFakeStore()
|
store := newFakeStore()
|
||||||
var sent []map[string]any
|
var sent []map[string]any
|
||||||
@@ -513,24 +475,95 @@ func TestTheWindowSlidesOnGapsNotOnElapsedTimeSinceItsFirstMail(t *testing.T) {
|
|||||||
t.Fatalf("Flush at mail %d: %v", i+1, err)
|
t.Fatalf("Flush at mail %d: %v", i+1, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(sent) != 3 {
|
if len(sent) != 4 {
|
||||||
t.Fatalf("sent %d notice(s) before the fold's window ends, want 3 shown one by one (0s, 50s, 55s): %+v", len(sent), sent)
|
t.Fatalf("sent %d notice(s), want 4 shown one by one (0s, 50s, 55s, 70s): %+v", len(sent), sent)
|
||||||
}
|
}
|
||||||
for i, want := range []string{"Mail 1", "Mail 2", "Mail 3"} {
|
for i, want := range []string{"Mail 1", "Mail 2", "Mail 3", "Mail 4"} {
|
||||||
if sent[i]["summary"] != want {
|
if sent[i]["summary"] != want {
|
||||||
t.Errorf("sent[%d] = %+v, want summary %q", i, sent[i], want)
|
t.Errorf("sent[%d] = %+v, want summary %q", i, sent[i], want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The fold group's own window — a minute from its first mail, at 70s — ends at 130s.
|
// The fold's own window — a minute from its first mail, 75s — ends at 135s.
|
||||||
|
clock.Advance(55 * time.Second) // now at 80s + 55s = 135s
|
||||||
|
if err := n.Flush(); err != nil {
|
||||||
|
t.Fatalf("Flush: %v", err)
|
||||||
|
}
|
||||||
|
if len(sent) != 5 {
|
||||||
|
t.Fatalf("sent %d notice(s), want 5 (75s and 80s folded into one, at 135s): %+v", len(sent), sent)
|
||||||
|
}
|
||||||
|
if sent[4]["summary"] != "2 more new mails in home" || !strings.Contains(sent[4]["body"].(string), "Sender 6") {
|
||||||
|
t.Errorf("the fold notice is %+v, want it to count only what it holds (2), naming Sender 6 (80s, the latest)", sent[4])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestASteadyTrickleNeverFoldsForever is review round 3 of issue 800's second test: the bug the round found
|
||||||
|
// (a session that only resets after a quiet minute folds a steady stream forever, even without a burst). One
|
||||||
|
// mail every 50 seconds, 20 of them: each one's preceding 60 seconds holds only the one before it, so N never
|
||||||
|
// exceeds 2, and all 20 are shown at once — none ever fold.
|
||||||
|
func TestASteadyTrickleNeverFoldsForever(t *testing.T) {
|
||||||
|
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
||||||
|
store := newFakeStore()
|
||||||
|
var sent []map[string]any
|
||||||
|
n := newTestNotifier(t, clock, store, &sent)
|
||||||
|
|
||||||
|
account := "home"
|
||||||
|
for i := 1; i <= 20; i++ {
|
||||||
|
if i > 1 {
|
||||||
clock.Advance(50 * time.Second)
|
clock.Advance(50 * time.Second)
|
||||||
|
}
|
||||||
|
if err := n.Add(mail(account, i, fmt.Sprintf("m%d", i), fmt.Sprintf("Mail %d", i), fmt.Sprintf("Sender %d", i))); err != nil {
|
||||||
|
t.Fatalf("Add mail %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if err := n.Flush(); err != nil {
|
||||||
|
t.Fatalf("Flush at mail %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if len(sent) != i {
|
||||||
|
t.Fatalf("after mail %d, sent %d notice(s), want %d: a steady trickle must never fold: %+v", i, len(sent), i, sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, got := range sent {
|
||||||
|
want := fmt.Sprintf("Mail %d", i+1)
|
||||||
|
if got["summary"] != want {
|
||||||
|
t.Errorf("sent[%d] = %+v, want summary %q", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFiveMailsWithinTenSecondsFoldAfterThree is review round 3 of issue 800's third test: five mails within
|
||||||
|
// ten seconds — the first three shown at once, the fourth opens a fold, the fifth joins it, and the fold is
|
||||||
|
// shown 60 seconds after the fourth (the fold's own first mail) as one notice.
|
||||||
|
func TestFiveMailsWithinTenSecondsFoldAfterThree(t *testing.T) {
|
||||||
|
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
||||||
|
store := newFakeStore()
|
||||||
|
var sent []map[string]any
|
||||||
|
n := newTestNotifier(t, clock, store, &sent)
|
||||||
|
|
||||||
|
account := "home"
|
||||||
|
for i := 1; i <= 5; i++ {
|
||||||
|
if i > 1 {
|
||||||
|
clock.Advance(2 * time.Second)
|
||||||
|
}
|
||||||
|
if err := n.Add(mail(account, i, fmt.Sprintf("m%d", i), fmt.Sprintf("Mail %d", i), fmt.Sprintf("Sender %d", i))); err != nil {
|
||||||
|
t.Fatalf("Add mail %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if err := n.Flush(); err != nil {
|
||||||
|
t.Fatalf("Flush at mail %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(sent) != 3 {
|
||||||
|
t.Fatalf("sent %d notice(s), want 3 shown one by one, the fourth and fifth folded, not yet due: %+v", len(sent), sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fold is due 60 seconds after its own first mail (the fourth, at 6s).
|
||||||
|
clock.Advance(Window)
|
||||||
if err := n.Flush(); err != nil {
|
if err := n.Flush(); err != nil {
|
||||||
t.Fatalf("Flush: %v", err)
|
t.Fatalf("Flush: %v", err)
|
||||||
}
|
}
|
||||||
if len(sent) != 4 {
|
if len(sent) != 4 {
|
||||||
t.Fatalf("sent %d notice(s), want 4 (70s, 75s and 80s folded into one, at 130s): %+v", len(sent), sent)
|
t.Fatalf("sent %d notice(s), want 4 (one fold notice carrying the fourth and fifth): %+v", len(sent), sent)
|
||||||
}
|
}
|
||||||
if sent[3]["summary"] != "6 new mails in home" || !strings.Contains(sent[3]["body"].(string), "Sender 6") {
|
if sent[3]["summary"] != "2 more new mails in home" || !strings.Contains(sent[3]["body"].(string), "Sender 5") {
|
||||||
t.Errorf("the fold notice is %+v, want it to count all 6 of the session, naming Sender 6 (80s, the latest)", sent[3])
|
t.Errorf("the fold notice is %+v, want it to count only what it holds (2), naming Sender 5 (the latest)", sent[3])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user