diff --git a/mutation.go b/mutation.go new file mode 100644 index 0000000..1b12ac4 --- /dev/null +++ b/mutation.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "errors" + "fmt" +) + +// Mutation — typed intent to mutate state. Every write to the store goes through +// this. Validate() runs WITHOUT holding any application lock; it inspects the +// current state for preconditions. Apply() is called with an acquired LockRegion +// and is the only path that mutates state. +// +// AffectedAuthors() is used for read_state bookkeeping and mention targeting. +type Mutation interface { + Validate(s *Store) error + Apply(s *Store, lock LockRegion) error + AffectedAuthors() []string + Kind() string // for logging and VOPR transition recording +} + +// LockScope — the granularity the controller chose for this mutation. +type LockScope int + +const ( + ScopeCoarse LockScope = iota // per-author queue + ScopeMedium // per-author queue + per-post sub-queue + ScopeFine // per-mutation lock, no queueing +) + +func (s LockScope) String() string { + switch s { + case ScopeCoarse: + return "coarse" + case ScopeMedium: + return "medium" + case ScopeFine: + return "fine" + default: + return "unknown" + } +} + +// LockRegion — opaque handle to an acquired lock. The mutation's Apply method +// receives this so it can hold the lock for the duration of the apply and +// release it explicitly if needed. +type LockRegion interface { + Scope() LockScope + Release() +} + +// ErrValidation — sentinel for precondition failures. Distinct from runtime +// errors so callers can map to HTTP 400 vs 500. +var ErrValidation = errors.New("validation failed") + +// ValidationError — typed validation failure with a message for the caller. +type ValidationError struct { + Field string + Message string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("validation: %s: %s", e.Field, e.Message) +} + +func (e *ValidationError) Unwrap() error { return ErrValidation } + +// Submit — the canonical entry point. The lock controller decides the scope +// based on observed contention; we acquire the lock, then call Validate, then +// Apply. (Validate can also be called standalone for dry-runs.) +// +// On Validate failure, the lock is released and no state changes. +// On Apply failure, the lock is released and state is left consistent (Apply +// is expected to be transactional; if it can't be, that's a bug). +func Submit(ctx context.Context, s *Store, m Mutation) error { + lock := s.lockCtrl.Acquire(s, m) + defer lock.Release() + if err := m.Validate(s); err != nil { + return err + } + if err := m.Apply(s, lock); err != nil { + return err + } + s.bumpFlush() + return nil +} + +// ValidateOnly — dry-run. No lock, no state change. Useful for "can I post?" +// preflight checks from agents. +func ValidateOnly(s *Store, m Mutation) error { + return m.Validate(s) +} + +func (s *Store) bumpFlush() { + select { + case s.flushCh <- struct{}{}: + default: + } +}