224 lines
6.6 KiB
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)
|
|
}
|
|
}
|