@@ -60,6 +60,7 @@ import (
6060 "github.com/google/uuid"
6161 "github.com/riverqueue/river"
6262 "go.opentelemetry.io/otel"
63+ "instant.dev/common/resourcestatus"
6364)
6465
6566// ExpiryReminderArgs is the River job payload — no fields, runs as a sweep.
@@ -69,20 +70,25 @@ type ExpiryReminderArgs struct{}
6970func (ExpiryReminderArgs ) Kind () string { return "expiry_reminder" }
7071
7172// reminderStage describes one bucket in the 12h/6h/1h schedule.
73+ //
74+ // As of the resourcestatus unification it is a thin wrapper over
75+ // instant.dev/common/resourcestatus.ExpiryStage — the 12h/6h/1h
76+ // thresholds, the index→reminder_index mapping, and the label→stage_label
77+ // mapping all live in the shared package, so api and worker can never
78+ // disagree on which window a resource falls in. The struct is retained
79+ // only so the existing `stage.index` / `stage.label` / `stage.stage`
80+ // call sites in Work() and emitAnonExpiryWarningAudit() stay unchanged.
7281type reminderStage struct {
73- index int // 1-based; matches reminder_index in the email
74- expiresWithin time. Duration // resource fires this stage when expires_at <= now + expiresWithin
75- label string // logging label, also flows into the email as stage_label
82+ stage resourcestatus. ExpiryStage
83+ index int // 1-based; matches reminder_index in the email
84+ label string // logging label, also flows into the email as stage_label
7685}
7786
78- // reminderSchedule is the canonical stage table. Ordered most-distant
79- // to most-imminent. selectStage picks the MOST IMMINENT window the
80- // resource currently falls in (time-bucket-first) — see P2-12 below.
81- var reminderSchedule = []reminderStage {
82- {index : 1 , expiresWithin : 12 * time .Hour , label : "stage_12h" },
83- {index : 2 , expiresWithin : 6 * time .Hour , label : "stage_6h" },
84- {index : 3 , expiresWithin : 1 * time .Hour , label : "stage_1h" },
85- }
87+ // outermostReminderWindow is the widest reminder window — anything past
88+ // it is too far from expiry to be a candidate for any stage. Derived
89+ // from the shared package's canonical 12h threshold (no second copy of
90+ // the duration here).
91+ const outermostReminderWindow = resourcestatus .ExpiryWindow12h
8692
8793// reminderCooldown is the minimum wall-clock gap between two
8894// dispatches on the same resource. Belt-and-braces — the CAS on
@@ -155,7 +161,7 @@ func (w *ExpiryReminderWorker) Work(ctx context.Context, job *river.Job[ExpiryRe
155161 // The outermost window is the largest stage threshold — anything
156162 // outside this window is too far from expiry to be a candidate
157163 // for any stage. Inner windows are checked per-stage below.
158- windowEnd := now .Add (reminderSchedule [ 0 ]. expiresWithin )
164+ windowEnd := now .Add (outermostReminderWindow )
159165 cooldownBefore := now .Add (- reminderCooldown )
160166
161167 // LEFT JOIN users u ... AND u.is_primary = true — the is_primary
@@ -314,72 +320,54 @@ func (w *ExpiryReminderWorker) Work(ctx context.Context, job *river.Job[ExpiryRe
314320
315321// selectStage picks the stage a row is currently eligible for.
316322//
317- // P2-12 (BugBash 2026-05-18): the stage is chosen by TIME BUCKET first,
318- // then gated on reminders_sent — NOT the other way round. The prior
319- // implementation required strict monotonic 0→1→2 progression
320- // (`reminders_sent == mustHaveSent`), so a resource created less than
321- // 6h before its TTL — already inside the (6h, 1h] or (1h, 0h] window
322- // from the very first sweep — still fired stage 1 because that was the
323- // only stage whose mustHaveSent matched reminders_sent=0. The email
324- // then claimed "12h to go" while only ~50 min actually remained:
325- // hours_remaining and stage_label disagreed.
323+ // The time-bucket classification is delegated to
324+ // resourcestatus.DeriveExpiryStage — the canonical, shared "most
325+ // imminent window wins" logic (the P2-12 BugBash fix now lives in the
326+ // common package, so api and worker can never disagree on the bucket).
327+ // selectStage layers the worker-specific reminders_sent CAS gate on top:
328+ // a stage already sent (reminders_sent >= stage index) is skipped.
326329//
327- // The fix: bucket the resource into the MOST IMMINENT stage window its
328- // time-to-expiry falls in (schedule is most-distant → most-imminent, so
329- // the last matching entry wins), then fire it only if reminders_sent is
330- // still below that stage's index. The CAS in Work() fast-forwards
331- // reminders_sent straight to stage.index, consuming any skipped earlier
332- // stages. Result: a short-TTL resource gets exactly one correctly
333- // labelled reminder ("1h to go"), not a mislabelled "12h" one.
330+ // P2-12 (BugBash 2026-05-18) recap: the stage is chosen by TIME BUCKET
331+ // first, then gated on reminders_sent — NOT the other way round. A
332+ // resource created less than 6h before its TTL is bucketed straight into
333+ // stage_6h / stage_1h; the CAS in Work() fast-forwards reminders_sent to
334+ // that stage's index, consuming the skipped earlier stages so exactly one
335+ // correctly labelled reminder fires.
334336//
335337// Examples:
336- // remaining 8h, reminders_sent 0 → stage 1 ("12h") — bucket (12h,6h]
337- // remaining 4h, reminders_sent 0 → stage 2 ("6h") — skips stage 1
338- // remaining 40m, reminders_sent 0 → stage 3 ("1h") — skips 1 and 2
339- // remaining 4h, reminders_sent 2 → no stage (already past stage 2)
338+ //
339+ // remaining 8h, reminders_sent 0 → stage 1 ("12h") — bucket (12h,6h]
340+ // remaining 4h, reminders_sent 0 → stage 2 ("6h") — skips stage 1
341+ // remaining 40m, reminders_sent 0 → stage 3 ("1h") — skips 1 and 2
342+ // remaining 4h, reminders_sent 2 → no stage (already past stage 2)
340343func selectStage (r expiryReminderRow , now time.Time ) (reminderStage , bool ) {
341- remaining := r .expiresAt .Sub (now )
342- if remaining <= 0 {
343- return reminderStage {}, false
344- }
345- var bucket reminderStage
346- found := false
347- for _ , s := range reminderSchedule {
348- if remaining <= s .expiresWithin {
349- // schedule is ordered most-distant → most-imminent, so
350- // later matches overwrite earlier ones — the final match
351- // is the tightest window the resource currently sits in.
352- bucket = s
353- found = true
354- }
355- }
356- if ! found {
357- // Outside even the widest (12h) window — too far from expiry.
344+ es := resourcestatus .DeriveExpiryStage (r .expiresAt , now )
345+ if ! es .IsWarning () {
346+ // ExpiryStageNone (too far out) or ExpiryStagePastTTL — no
347+ // reminder stage applies.
358348 return reminderStage {}, false
359349 }
350+ bucket := reminderStage {stage : es , index : es .Index (), label : es .Label ()}
360351 if r .remindersSent >= bucket .index {
361352 // This stage (or a later one) was already sent — nothing to do.
362353 return reminderStage {}, false
363354 }
364355 return bucket , true
365356}
366357
367- // hoursLeft rounds the gap up to whole hours, with a floor of 1 so
368- // the email never says "0 hours". The legacy worker had the same
369- // floor (it just always rendered the floor due to a Brevo template bug).
358+ // hoursLeft rounds the gap up to whole hours, with a floor of 1 so the
359+ // email never says "0 hours". Delegates to the shared
360+ // resourcestatus.HoursUntilExpiry — identical floor-of-1 / round-up
361+ // semantics, now shared so api and worker render the same number.
370362func hoursLeft (expires , now time.Time ) int {
371- delta := expires .Sub (now )
372- if delta <= time .Hour {
363+ h := resourcestatus .HoursUntilExpiry (expires , now )
364+ if h < 1 {
365+ // HoursUntilExpiry returns 0 for a past-TTL / zero expiry; the
366+ // reminder path only ever calls this for a future expiry, but
367+ // keep the floor of 1 so the email copy never regresses.
373368 return 1
374369 }
375- hours := int (delta .Hours ())
376- if delta - time .Duration (hours )* time .Hour > 0 {
377- hours ++
378- }
379- if hours < 1 {
380- hours = 1
381- }
382- return hours
370+ return h
383371}
384372
385373// emitAnonExpiryWarningAudit writes one anon.expiry_warning audit_log
0 commit comments