package refusalcap import ( "context" "encoding/json" "log/slog" "sync" "time" "tug/internal/platform/origin" "tug/internal/platform/ratelimit" "tug/internal/storage" ) const ( ActionRowsDropped = "audit.rows_dropped" DroppedSubject = "audit" FlushEvery = time.Minute stopTimeout = 5 * time.Second ) var ( RefusalRate = ratelimit.Rate{Burst: 20, Every: 3 * time.Second} SuccessRate = ratelimit.Rate{Burst: 60, Every: time.Second} ) type Appender interface { AppendAudit(context.Context, storage.AppendAuditInput) (storage.AuditEvent, error) } type bucket uint8 const ( refusals bucket = iota successes ) type Cap struct { store Appender logger *slog.Logger buckets *ratelimit.Table[bucket] mu sync.Mutex dropped map[string]int } func New(store Appender, now func() time.Time, logger *slog.Logger) *Cap { if now == nil { now = time.Now } if logger == nil { logger = slog.Default() } return &Cap{store: store, logger: logger, buckets: ratelimit.New[bucket](2, now), dropped: map[string]int{}} } func (c *Cap) Refused(ctx context.Context, input storage.AppendAuditInput) { c.write(ctx, refusals, RefusalRate, input) } func (c *Cap) Succeeded(ctx context.Context, input storage.AppendAuditInput) { c.write(ctx, successes, SuccessRate, input) } func (c *Cap) write(ctx context.Context, b bucket, rate ratelimit.Rate, input storage.AppendAuditInput) { if _, ok := c.buckets.Take(b, rate); !ok { c.mu.Lock() c.dropped[input.Action]++ c.mu.Unlock() return } if _, err := c.store.AppendAudit(ctx, input); err != nil { c.logger.Error("audit row not written", "action", input.Action, "err", err) } } func (c *Cap) Flush(ctx context.Context) error { c.mu.Lock() counts := c.dropped if len(counts) == 0 { c.mu.Unlock() return nil } c.dropped = map[string]int{} c.mu.Unlock() detail, err := json.Marshal(counts) if err == nil { _, err = c.store.AppendAudit(origin.With(ctx, origin.Client{}), storage.AppendAuditInput{Actor: "system", Action: ActionRowsDropped, Subject: DroppedSubject, Detail: string(detail)}) } if err != nil { c.mu.Lock() for action, n := range counts { c.dropped[action] += n } c.mu.Unlock() } return err } func (c *Cap) Run(ctx context.Context) { tick := time.NewTicker(FlushEvery) defer tick.Stop() for { select { case <-ctx.Done(): stop, cancel := context.WithTimeout(context.WithoutCancel(ctx), stopTimeout) defer cancel() if err := c.Flush(stop); err != nil { c.logger.Error("audit overflow row not written", "err", err) } return case <-tick.C: if err := c.Flush(ctx); err != nil { c.logger.Error("audit overflow row not written", "err", err) } } } }