oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
4 "context"
5 "errors"
6 "time"
7
8 "amadan.net/rastrillo/rastrillo/mail"
9)
10
11// ErrMailBusy is what a send returns when every in-flight slot of the
12// bounded sender is taken, or when the caller's budget was already
13// spent before the sender was called. No SMTP call was made, so it is
14// a DEFERRAL and never an attempt: a row that hits it goes back to the
15// queue with its attempts untouched (booking spec B22).
16var ErrMailBusy = errors.New("calendar: mail sender busy")
17
18// ErrMailTimeout is what a send returns when the underlying sender did
19// not come back inside the caller's budget. The send may still be in
20// progress and may still arrive; at-least-once holds.
21var ErrMailTimeout = errors.New("calendar: mail send timed out")
22
23// ErrMailRefused is EVERY other failure of the underlying sender, with
24// the sender's own text discarded at this boundary. rastrillo's SMTP
25// sender writes the recipient into its error and quotes a refused
26// subject; anything above this wrapper — rastrillo's sign-in handler
27// included, which logs what it is handed — must never see that.
28var ErrMailRefused = errors.New("calendar: mail refused")
29
30// Sender limits, as constants because they are policy.
31const (
32 maxSendsInFlight = 4
33 maxSendWait = 20 * time.Second
34)
35
36// boundedSender is the ONE wrapper every mail — reminders, sign-in,
37// booking — goes through (booking spec §6).
38//
39// It bounds two things and is honest about a third. At most
40// maxSendsInFlight sends run at once; a caller past that gets
41// ErrMailBusy at once. Each send waits at most min(maxSendWait, what is
42// left of the caller's context), then returns ErrMailTimeout. What it
43// cannot bound is the socket: rastrillo's mail.SMTP ignores its context
44// and sets no dial or protocol deadline, so an abandoned send holds a
45// goroutine and a connection until the kernel gives up. The semaphore
46// caps how many; the fix belongs upstream and is recorded as debt.
47type boundedSender struct {
48 next mail.Sender
49 slots chan struct{}
50 wait time.Duration
51}
52
53func newBoundedSender(next mail.Sender) *boundedSender {
54 return &boundedSender{next: next, slots: make(chan struct{}, maxSendsInFlight), wait: maxSendWait}
55}
56
57func (b *boundedSender) Send(ctx context.Context, to, subject, body string) error {
58 select {
59 case b.slots <- struct{}{}:
60 default:
61 return ErrMailBusy
62 }
63
64 wait := b.wait
65 if dl, ok := ctx.Deadline(); ok {
66 if left := time.Until(dl); left < wait {
67 wait = left
68 }
69 }
70 if wait <= 0 {
71 // The budget was gone before any sender was called. Nothing
72 // was attempted, so this is a DEFERRAL like busy, never a
73 // counted failure: a healthy relay must not lose an attempt
74 // to a slow claim in front of it.
75 <-b.slots
76 return ErrMailBusy
77 }
78
79 done := make(chan error, 1)
80 go func() {
81 // The slot is released when the underlying send RETURNS,
82 // however long after this call gave up on it. That is the
83 // bound on abandoned sends: at most maxSendsInFlight of them.
84 defer func() { <-b.slots }()
85 // A panicking sender is this send's failure, not the process's:
86 // this goroutine is outside every caller's recover (Astra's
87 // production-composition probe on #28 took the process down
88 // through it). done is buffered, so this never blocks.
89 defer func() {
90 if p := recover(); p != nil {
91 done <- ErrMailRefused
92 }
93 }()
94 done <- b.next.Send(ctx, to, subject, body)
95 }()
96
97 select {
98 case err := <-done:
99 if err != nil {
100 return ErrMailRefused
101 }
102 return nil
103 case <-time.After(wait):
104 return ErrMailTimeout
105 }
106}
107