feat(mutation): Mutation interface, Submit(), Validate/Apply split
This commit is contained in:
parent
1728780aed
commit
bfe56bbb65
1 changed files with 99 additions and 0 deletions
99
mutation.go
Normal file
99
mutation.go
Normal file
|
|
@ -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:
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue