feat: add configuration, logging, metrics, scheduler and storage foundation
- config: defaults -> JSON file -> NOEMA_* env with validation and redaction - observability: slog with secret-redacting handler; Prometheus registry with the metric set from the plan (events, workspace, memory, LLM, tools, queues) - scheduler: supervised Group and bounded Pool so no goroutine is unowned - storage: pgx pool with statement timeout, embedded migration runner (advisory lock, checksums, up/down/status), per-test schema helper - migrations/0001: append-only guard trigger, users, sessions, api tokens, recovery codes, audit log, settings - pkg/ids: UUIDv7 with monotonic counter
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
// Package scheduler owns every background goroutine in the daemon. Nothing
|
||||
// outside this package should use the go statement for long-lived work: that
|
||||
// rule is what makes "no unbounded goroutines" auditable.
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Group runs named goroutines bound to one context and waits for all of them.
|
||||
// Panics inside a task are recovered, logged and reported as errors so a
|
||||
// malformed input in one worker cannot take the daemon down.
|
||||
type Group struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
log *slog.Logger
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
errs []error
|
||||
active atomic.Int64
|
||||
}
|
||||
|
||||
// NewGroup creates a group whose tasks are cancelled when ctx ends or Stop is called.
|
||||
func NewGroup(ctx context.Context, log *slog.Logger) *Group {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
return &Group{ctx: ctx, cancel: cancel, log: log}
|
||||
}
|
||||
|
||||
// Context returns the group's context.
|
||||
func (g *Group) Context() context.Context { return g.ctx }
|
||||
|
||||
// Go starts fn as a supervised goroutine.
|
||||
func (g *Group) Go(name string, fn func(ctx context.Context) error) {
|
||||
g.wg.Add(1)
|
||||
g.active.Add(1)
|
||||
go func() {
|
||||
defer g.wg.Done()
|
||||
defer g.active.Add(-1)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
g.log.Error("task panicked", "task", name, "panic", r, "stack", string(debug.Stack()))
|
||||
g.record(errors.New("task " + name + " panicked"))
|
||||
}
|
||||
}()
|
||||
if err := fn(g.ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
g.log.Error("task failed", "task", name, "err", err)
|
||||
g.record(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Every runs fn on a fixed interval until the context ends. Each run is
|
||||
// recovered individually so a single failure does not stop the ticker.
|
||||
func (g *Group) Every(name string, interval time.Duration, fn func(ctx context.Context) error) {
|
||||
g.Go(name, func(ctx context.Context) error {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-t.C:
|
||||
g.safeRun(name, ctx, fn)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (g *Group) safeRun(name string, ctx context.Context, fn func(context.Context) error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
g.log.Error("periodic task panicked", "task", name, "panic", r, "stack", string(debug.Stack()))
|
||||
}
|
||||
}()
|
||||
if err := fn(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
g.log.Warn("periodic task failed", "task", name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Group) record(err error) {
|
||||
g.mu.Lock()
|
||||
g.errs = append(g.errs, err)
|
||||
g.mu.Unlock()
|
||||
}
|
||||
|
||||
// Active returns the number of running tasks.
|
||||
func (g *Group) Active() int64 { return g.active.Load() }
|
||||
|
||||
// Stop cancels the group and waits for all tasks, returning any errors.
|
||||
func (g *Group) Stop() error {
|
||||
g.cancel()
|
||||
g.wg.Wait()
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return errors.Join(g.errs...)
|
||||
}
|
||||
|
||||
// Wait blocks until all tasks have returned without cancelling them.
|
||||
func (g *Group) Wait() error {
|
||||
g.wg.Wait()
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return errors.Join(g.errs...)
|
||||
}
|
||||
|
||||
// Pool bounds concurrent execution of short tasks. Submit blocks when the pool
|
||||
// is full (backpressure) rather than queueing without limit.
|
||||
type Pool struct {
|
||||
sem chan struct{}
|
||||
busy atomic.Int64
|
||||
}
|
||||
|
||||
// NewPool creates a pool allowing size concurrent tasks.
|
||||
func NewPool(size int) *Pool {
|
||||
if size < 1 {
|
||||
size = 1
|
||||
}
|
||||
return &Pool{sem: make(chan struct{}, size)}
|
||||
}
|
||||
|
||||
// Run executes fn once a slot is free, or returns ctx.Err() while waiting.
|
||||
// It runs fn on the caller's goroutine; combine with Group.Go for async work.
|
||||
func (p *Pool) Run(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||
select {
|
||||
case p.sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
defer func() { <-p.sem }()
|
||||
p.busy.Add(1)
|
||||
defer p.busy.Add(-1)
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
// Busy returns the number of occupied slots.
|
||||
func (p *Pool) Busy() int64 { return p.busy.Load() }
|
||||
|
||||
// Size returns the pool capacity.
|
||||
func (p *Pool) Size() int { return cap(p.sem) }
|
||||
|
||||
// Parallel runs each task through the pool concurrently and waits for all of
|
||||
// them, collecting errors. It never starts more than the pool size at once.
|
||||
func Parallel(ctx context.Context, p *Pool, tasks []func(ctx context.Context) error) error {
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(tasks))
|
||||
for i, task := range tasks {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errs[i] = errors.New("parallel task panicked")
|
||||
}
|
||||
}()
|
||||
errs[i] = p.Run(ctx, task)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPoolBoundsConcurrency(t *testing.T) {
|
||||
p := NewPool(3)
|
||||
var cur, peak atomic.Int64
|
||||
tasks := make([]func(context.Context) error, 20)
|
||||
for i := range tasks {
|
||||
tasks[i] = func(ctx context.Context) error {
|
||||
n := cur.Add(1)
|
||||
for {
|
||||
old := peak.Load()
|
||||
if n <= old || peak.CompareAndSwap(old, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
cur.Add(-1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := Parallel(context.Background(), p, tasks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if peak.Load() > 3 {
|
||||
t.Fatalf("peak concurrency %d exceeded pool size", peak.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupRecoversPanicAndCancels(t *testing.T) {
|
||||
g := NewGroup(context.Background(), slog.New(slog.DiscardHandler))
|
||||
g.Go("boom", func(ctx context.Context) error { panic("x") })
|
||||
blocked := make(chan struct{})
|
||||
g.Go("waiter", func(ctx context.Context) error { close(blocked); <-ctx.Done(); return ctx.Err() })
|
||||
<-blocked
|
||||
err := g.Stop()
|
||||
if err == nil {
|
||||
t.Fatal("expected panic error")
|
||||
}
|
||||
if g.Active() != 0 {
|
||||
t.Fatal("tasks still active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolRunHonoursCancellation(t *testing.T) {
|
||||
p := NewPool(1)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
release := make(chan struct{})
|
||||
go p.Run(context.Background(), func(context.Context) error { <-release; return nil })
|
||||
time.Sleep(time.Millisecond)
|
||||
cancel()
|
||||
if err := p.Run(ctx, func(context.Context) error { return nil }); err != context.Canceled {
|
||||
t.Fatalf("expected cancellation, got %v", err)
|
||||
}
|
||||
close(release)
|
||||
}
|
||||
Reference in New Issue
Block a user