Add intake worker
Subsystem to monitor IMAP mailbox for new messages. Introduces: - intake: worker that uses IDLE or polling to detect new emails. - imap: client wrapper for connection management and IMAP commands. - filter: logic for IMAP search and sender allow-list. - tracker: concurrency control to prevent processing the same UID twice. - backoff: for handling connection retries with jitter.
This commit is contained in:
94
internal/backoff/backoff.go
Normal file
94
internal/backoff/backoff.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Package backoff implements exponential backoff with jitter for retry logic.
|
||||
package backoff
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds the configuration for a Backoff instance.
|
||||
type Config struct {
|
||||
// Initial is the starting upper bound for the backoff duration.
|
||||
// Must be > 0.
|
||||
Initial time.Duration `yaml:"initial"`
|
||||
// Max is the absolute maximum upper bound for the backoff duration.
|
||||
// Must be >= Initial.
|
||||
Max time.Duration `yaml:"max"`
|
||||
// RNG is an optional source of randomness.
|
||||
// If nil, the global math/rand/v2 source is used.
|
||||
RNG *rand.Rand `yaml:"-"`
|
||||
}
|
||||
|
||||
// Validate checks that the configuration values are sensible.
|
||||
func (cfg Config) Validate() error {
|
||||
if cfg.Initial <= 0 {
|
||||
return fmt.Errorf("invalid initial backoff: %v", cfg.Initial)
|
||||
}
|
||||
if cfg.Max < cfg.Initial {
|
||||
return fmt.Errorf("invalid max duration: %v", cfg.Max)
|
||||
}
|
||||
// MaxInt64 will overflow when calculating jitter.
|
||||
if cfg.Max == math.MaxInt64 {
|
||||
return fmt.Errorf("max duration cannot be MaxInt64")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Backoff implements exponential backoff with full jitter.
|
||||
// It is NOT safe for concurrent use.
|
||||
type Backoff struct {
|
||||
initial time.Duration
|
||||
max time.Duration
|
||||
current time.Duration
|
||||
rng *rand.Rand
|
||||
}
|
||||
|
||||
// New creates a new Backoff instance with the provided configuration.
|
||||
func New(cfg Config) (*Backoff, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %v", err)
|
||||
}
|
||||
|
||||
return &Backoff{
|
||||
initial: cfg.Initial,
|
||||
max: cfg.Max,
|
||||
current: cfg.Initial,
|
||||
rng: cfg.RNG,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Next returns the next backoff delay.
|
||||
func (b *Backoff) Next() time.Duration {
|
||||
limit := b.current
|
||||
|
||||
// Update state for the next call.
|
||||
if b.current >= b.max {
|
||||
b.current = b.max
|
||||
} else if b.current > b.max/2 {
|
||||
// If doubling would exceed max, just clamp to max.
|
||||
b.current = b.max
|
||||
} else {
|
||||
b.current *= 2
|
||||
}
|
||||
|
||||
// Calculate jitter; return random in [0, limit].
|
||||
// Int64N(n) returns values in [0, n).
|
||||
// For [0, limit], we use limit + 1.
|
||||
var jitter int64
|
||||
if b.rng != nil {
|
||||
jitter = b.rng.Int64N(int64(limit) + 1)
|
||||
} else {
|
||||
jitter = rand.Int64N(int64(limit) + 1)
|
||||
}
|
||||
|
||||
return time.Duration(jitter)
|
||||
}
|
||||
|
||||
// Reset resets the current backoff cap to the initial value.
|
||||
// This should be called after a successful operation to restart
|
||||
// the backoff sequence for future retries.
|
||||
func (b *Backoff) Reset() {
|
||||
b.current = b.initial
|
||||
}
|
||||
196
internal/backoff/backoff_test.go
Normal file
196
internal/backoff/backoff_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package backoff
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
var tests = []struct {
|
||||
name string
|
||||
cfg Config
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "initial zero",
|
||||
cfg: Config{Initial: 0, Max: time.Second},
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "initial negative",
|
||||
cfg: Config{Initial: -1, Max: time.Second},
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "max less than initial",
|
||||
cfg: Config{
|
||||
Initial: time.Second,
|
||||
Max: 500 * time.Millisecond,
|
||||
},
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "max equals maxint64",
|
||||
cfg: Config{
|
||||
Initial: time.Second,
|
||||
Max: math.MaxInt64,
|
||||
},
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "valid",
|
||||
cfg: Config{
|
||||
Initial: time.Second,
|
||||
Max: time.Second,
|
||||
},
|
||||
shouldError: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.cfg.Validate()
|
||||
if tt.shouldError && err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !tt.shouldError && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewInvalidConfig(t *testing.T) {
|
||||
_, err := New(Config{Initial: 0, Max: time.Second})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid config") {
|
||||
t.Fatalf("expected invalid config error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDistribution uses a fixed seed to check the logic flow and jitter.
|
||||
func TestDistribution(t *testing.T) {
|
||||
// RNG with seed (1, 2)
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
|
||||
initial := 100 * time.Millisecond
|
||||
maxDur := 400 * time.Millisecond
|
||||
|
||||
b, _ := New(Config{
|
||||
Initial: initial,
|
||||
Max: maxDur,
|
||||
RNG: rng,
|
||||
})
|
||||
|
||||
// Generate reference numbers using the same seed to predict outcomes.
|
||||
refRng := rand.New(rand.NewPCG(1, 2))
|
||||
|
||||
expectedCaps := []time.Duration{
|
||||
100 * time.Millisecond,
|
||||
200 * time.Millisecond,
|
||||
400 * time.Millisecond,
|
||||
400 * time.Millisecond, // Clamped
|
||||
}
|
||||
|
||||
for i, cap := range expectedCaps {
|
||||
// Expect Next to pick rand(0, cap).
|
||||
expected := time.Duration(refRng.Int64N(int64(cap) + 1))
|
||||
got := b.Next()
|
||||
|
||||
if got != expected {
|
||||
t.Errorf(
|
||||
"step %d: expected %v, got %v (cap was %v)",
|
||||
i, expected, got, cap,
|
||||
)
|
||||
}
|
||||
if got > cap {
|
||||
t.Errorf(
|
||||
"step %d: got %v greater than cap %v",
|
||||
i, got, cap,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOverflowProtection ensures that large Max values do not cause overflow
|
||||
// during the doubling phase.
|
||||
func TestOverflowProtection(t *testing.T) {
|
||||
// Use a Max that is near MaxInt64.
|
||||
max := time.Duration(math.MaxInt64 - 1)
|
||||
start := max / 4
|
||||
|
||||
b, err := New(Config{
|
||||
Initial: start,
|
||||
Max: max,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Current = start.
|
||||
// Next returns [0, start]. state becomes start*2 (max/2).
|
||||
_ = b.Next()
|
||||
|
||||
// 2. Current = max/2.
|
||||
// Next returns [0, max/2].
|
||||
_ = b.Next()
|
||||
|
||||
// 3. Current = max.
|
||||
// Next returns [0, max].
|
||||
// Logic check: current >= max. state stays max.
|
||||
val := b.Next()
|
||||
if val < 0 {
|
||||
t.Errorf("got negative duration %v, likely overflow", val)
|
||||
}
|
||||
|
||||
// Verify clamp.
|
||||
// No panic implies success.
|
||||
for range 5 {
|
||||
b.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRNG(t *testing.T) {
|
||||
b, err := New(Config{
|
||||
Initial: time.Millisecond,
|
||||
Max: 10 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New failed: %v", err)
|
||||
}
|
||||
got := b.Next()
|
||||
if got < 0 || got > time.Millisecond {
|
||||
t.Errorf("out of bounds: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReset(t *testing.T) {
|
||||
b, _ := New(Config{
|
||||
Initial: 10 * time.Millisecond,
|
||||
Max: 100 * time.Millisecond,
|
||||
})
|
||||
|
||||
// Advance state
|
||||
b.Next() // 10
|
||||
b.Next() // 20
|
||||
b.Next() // 40
|
||||
|
||||
b.Reset()
|
||||
|
||||
// Verify that the cap is reset by checking the bounds of the next
|
||||
// call. If reset works, the next call is bounded by Initial (10ms).
|
||||
for range 10 {
|
||||
got := b.Next()
|
||||
if got > 10*time.Millisecond {
|
||||
t.Fatalf(
|
||||
"call after Reset returned %v; expected <= 10ms",
|
||||
got,
|
||||
)
|
||||
}
|
||||
b.Reset()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user