Raw
  1package refusalcap
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"log/slog"
  7	"sync"
  8	"time"
  9
 10	"tug/internal/platform/origin"
 11	"tug/internal/platform/ratelimit"
 12	"tug/internal/storage"
 13)
 14
 15const (
 16	ActionRowsDropped = "audit.rows_dropped"
 17	DroppedSubject    = "audit"
 18	FlushEvery        = time.Minute
 19	stopTimeout       = 5 * time.Second
 20)
 21
 22var (
 23	RefusalRate = ratelimit.Rate{Burst: 20, Every: 3 * time.Second}
 24	SuccessRate = ratelimit.Rate{Burst: 60, Every: time.Second}
 25)
 26
 27type Appender interface {
 28	AppendAudit(context.Context, storage.AppendAuditInput) (storage.AuditEvent, error)
 29}
 30
 31type bucket uint8
 32
 33const (
 34	refusals bucket = iota
 35	successes
 36)
 37
 38type Cap struct {
 39	store   Appender
 40	logger  *slog.Logger
 41	buckets *ratelimit.Table[bucket]
 42	mu      sync.Mutex
 43	dropped map[string]int
 44}
 45
 46func New(store Appender, now func() time.Time, logger *slog.Logger) *Cap {
 47	if now == nil {
 48		now = time.Now
 49	}
 50	if logger == nil {
 51		logger = slog.Default()
 52	}
 53	return &Cap{store: store, logger: logger, buckets: ratelimit.New[bucket](2, now), dropped: map[string]int{}}
 54}
 55
 56func (c *Cap) Refused(ctx context.Context, input storage.AppendAuditInput) {
 57	c.write(ctx, refusals, RefusalRate, input)
 58}
 59
 60func (c *Cap) Succeeded(ctx context.Context, input storage.AppendAuditInput) {
 61	c.write(ctx, successes, SuccessRate, input)
 62}
 63
 64func (c *Cap) write(ctx context.Context, b bucket, rate ratelimit.Rate, input storage.AppendAuditInput) {
 65	if _, ok := c.buckets.Take(b, rate); !ok {
 66		c.mu.Lock()
 67		c.dropped[input.Action]++
 68		c.mu.Unlock()
 69		return
 70	}
 71	if _, err := c.store.AppendAudit(ctx, input); err != nil {
 72		c.logger.Error("audit row not written", "action", input.Action, "err", err)
 73	}
 74}
 75
 76func (c *Cap) Flush(ctx context.Context) error {
 77	c.mu.Lock()
 78	counts := c.dropped
 79	if len(counts) == 0 {
 80		c.mu.Unlock()
 81		return nil
 82	}
 83	c.dropped = map[string]int{}
 84	c.mu.Unlock()
 85	detail, err := json.Marshal(counts)
 86	if err == nil {
 87		_, err = c.store.AppendAudit(origin.With(ctx, origin.Client{}), storage.AppendAuditInput{Actor: "system", Action: ActionRowsDropped, Subject: DroppedSubject, Detail: string(detail)})
 88	}
 89	if err != nil {
 90		c.mu.Lock()
 91		for action, n := range counts {
 92			c.dropped[action] += n
 93		}
 94		c.mu.Unlock()
 95	}
 96	return err
 97}
 98
 99func (c *Cap) Run(ctx context.Context) {
100	tick := time.NewTicker(FlushEvery)
101	defer tick.Stop()
102	for {
103		select {
104		case <-ctx.Done():
105			stop, cancel := context.WithTimeout(context.WithoutCancel(ctx), stopTimeout)
106			defer cancel()
107			if err := c.Flush(stop); err != nil {
108				c.logger.Error("audit overflow row not written", "err", err)
109			}
110			return
111		case <-tick.C:
112			if err := c.Flush(ctx); err != nil {
113				c.logger.Error("audit overflow row not written", "err", err)
114			}
115		}
116	}
117}