posts/locks.go

224 lines
6.6 KiB
Go

package main
import (
"sync"
"time"
)
// LockController — the adaptive lock-region controller. Per-author state
// (queue depth, hold-time p99, throughput) drives scope transitions with
// hysteresis. Symmetric for agents and humans (no special-casing by kind).
type LockController struct {
mu sync.Mutex
perAuthor map[string]*authorLockState
clock func() time.Time
// Hysteresis thresholds. Tuned from VOPR results (#14).
coarseToMediumAt int // queue depth
mediumToFineAt int // queue depth
fineToMediumBelow int // queue depth
mediumToCoarseBelow int // queue depth
rampUpWindow time.Duration // time above threshold before promoting
rampDownWindow time.Duration // time below threshold before demoting
tickInterval time.Duration
}
type authorLockState struct {
scope LockScope
queueDepth int
holdTimeP99 time.Duration
throughputEPS float64
scopeSince time.Time
aboveSince time.Time // first time we crossed a promotion threshold
belowSince time.Time // first time we crossed a demotion threshold
// The actual locks for each scope.
coarseMu *authorCoarseLock
mediumMu *authorMediumLock
}
type authorCoarseLock struct {
sem chan struct{} // size 1; one mutation per author at a time at Coarse
}
type authorMediumLock struct {
perPost map[string]chan struct{} // post_id -> sem (size 1)
mu sync.Mutex
}
// NewLockController — production defaults. Tweak in tests.
func NewLockController() *LockController {
return &LockController{
perAuthor: make(map[string]*authorLockState),
clock: func() time.Time { return time.Now().UTC() },
coarseToMediumAt: 50,
mediumToFineAt: 200,
fineToMediumBelow: 100,
mediumToCoarseBelow: 10,
rampUpWindow: 5 * time.Second,
rampDownWindow: 30 * time.Second,
tickInterval: time.Second,
}
}
func (c *LockController) getOrCreate(authorID string) *authorLockState {
c.mu.Lock()
defer c.mu.Unlock()
st, ok := c.perAuthor[authorID]
if !ok {
st = &authorLockState{
scope: ScopeCoarse,
scopeSince: c.clock(),
coarseMu: &authorCoarseLock{sem: make(chan struct{}, 1)},
mediumMu: &authorMediumLock{perPost: make(map[string]chan struct{})},
}
c.perAuthor[authorID] = st
}
return st
}
// Acquire — pick the right scope (possibly updating it), then acquire the lock.
// Returns a LockRegion that the caller MUST Release.
func (c *LockController) Acquire(s *Store, m Mutation) LockRegion {
authors := m.AffectedAuthors()
if len(authors) == 0 {
// No author attribution: use a synthetic __system__ author. Per-author
// coarse lock, just like anyone else. In v0 this should never happen
// (every mutation has an author), but we don't want a global lock
// that becomes a serialization bottleneck.
authors = []string{"__system__"}
}
primary := authors[0]
st := c.getOrCreate(primary)
st.queueDepth++ // rough — incremented on every submit, decremented on release
_ = primary
scope := c.chooseScope(st)
st.scope = scope
switch scope {
case ScopeCoarse:
return acquireAuthorCoarse(c, st)
case ScopeMedium:
// For Medium, the mediumLock is per-(author, post). Pick the first
// post_id from the mutation's AffectedPosts if available. For now, use
// a per-author queue (the per-post sub-queue is a future enhancement
// wired into specific mutations).
return acquireAuthorCoarse(c, st) // treat as Coarse for the per-post refinement
case ScopeFine:
// No global lock in v0; per-author coarse. Real Fine would mean
// "every mutation is its own lock" — that's just sync.Mutex, not a
// queued region. Placeholder for v0.5.
return acquireAuthorCoarse(c, st)
default:
return acquireAuthorCoarse(c, st)
}
}
// chooseScope — apply hysteresis. Reads observed state, returns the scope
// the controller would choose for the next mutation.
func (c *LockController) chooseScope(st *authorLockState) LockScope {
now := c.clock()
switch st.scope {
case ScopeCoarse:
if st.queueDepth > c.coarseToMediumAt {
if st.aboveSince.IsZero() {
st.aboveSince = now
}
if now.Sub(st.aboveSince) > c.rampUpWindow {
st.aboveSince = time.Time{}
return ScopeMedium
}
} else {
st.aboveSince = time.Time{}
}
case ScopeMedium:
if st.queueDepth > c.mediumToFineAt {
if st.aboveSince.IsZero() {
st.aboveSince = now
}
if now.Sub(st.aboveSince) > c.rampUpWindow {
st.aboveSince = time.Time{}
return ScopeFine
}
} else if st.queueDepth < c.mediumToCoarseBelow {
if st.belowSince.IsZero() {
st.belowSince = now
}
if now.Sub(st.belowSince) > c.rampDownWindow {
st.belowSince = time.Time{}
return ScopeCoarse
}
} else {
st.aboveSince = time.Time{}
st.belowSince = time.Time{}
}
case ScopeFine:
if st.queueDepth < c.fineToMediumBelow {
if st.belowSince.IsZero() {
st.belowSince = now
}
if now.Sub(st.belowSince) > c.rampDownWindow {
st.belowSince = time.Time{}
return ScopeMedium
}
} else {
st.belowSince = time.Time{}
}
}
return st.scope
}
// Tick — runs every tickInterval, updates per-author metrics. Caller wires
// this into the flush goroutine or a separate background goroutine.
func (c *LockController) Tick() {
c.mu.Lock()
defer c.mu.Unlock()
for _, st := range c.perAuthor {
// Decay queue depth: if we haven't seen new submissions, depth drops.
// Real implementation would compute this from observed submit rate.
if st.queueDepth > 0 {
st.queueDepth = st.queueDepth * 9 / 10 // exponential decay
}
}
}
// --- LockRegion implementations ---
type authorCoarseRegion struct {
st *authorLockState
c *LockController
}
func (r *authorCoarseRegion) Scope() LockScope { return r.st.scope }
func (r *authorCoarseRegion) Release() {
<-r.st.coarseMu.sem
c := r.c
c.mu.Lock()
if r.st.queueDepth > 0 {
r.st.queueDepth--
}
c.mu.Unlock()
}
func acquireAuthorCoarse(c *LockController, st *authorLockState) LockRegion {
st.coarseMu.sem <- struct{}{}
return &authorCoarseRegion{c: c, st: st}
}
// globalFineRegion kept for the AcquireForTest path; not used in production.
type globalFineRegion struct{}
func (globalFineRegion) Scope() LockScope { return ScopeFine }
func (globalFineRegion) Release() {}
// AcquireForTest — public hook for tests to set a specific scope and skip the
// controller's hysteresis. Useful for unit tests of medium-lock behavior.
func (c *LockController) AcquireForTest(authorID string, scope LockScope) LockRegion {
st := c.getOrCreate(authorID)
st.scope = scope
switch scope {
case ScopeCoarse:
return acquireAuthorCoarse(c, st)
default:
return acquireAuthorCoarse(c, st)
}
}