iop/packages/go/streamgate/parallel_evaluation.go

306 lines
8.9 KiB
Go

package streamgate
import (
"context"
"errors"
"sync"
)
// EpochArbiter is invoked by the coordinator after all filter outcomes for a
// single epoch are collected into a stable EvaluationSet. The coordinator
// calls Arbitrate at most once per epoch and only when the complete outcome
// set is fully assembled.
type EpochArbiter interface {
Arbitrate(context.Context, EvidenceBatch, EvaluationSet) error
}
// GateCoordinatorOptions configures the coordinator's ingress behavior.
//
// MaxQueuedEpochs bounds the number of pending epoch jobs. Only the actively
// executing epoch is processed by the single worker; up to MaxQueuedEpochs
// jobs may queue behind it. Submitters beyond this bound block on send.
type GateCoordinatorOptions struct {
// MaxQueuedEpochs bounds pending epoch jobs. Zero means no queueing —
// only the actively executing epoch is allowed.
MaxQueuedEpochs int
}
// GateCoordinator drives immutable batch evaluation with an all-complete
// outcome barrier, single-flight worker processing, bounded backpressure, and
// structured cancellation.
//
// It owns exactly one worker goroutine that serializes epoch execution over
// a bounded jobs channel. Caller-supplied contexts are propagated into filter
// child contexts; filter evaluation timeouts are applied per filter. Raw
// evaluator errors and deadline overruns are normalized to stable, sanitized
// outcome codes with no raw error payload preserved.
type GateCoordinator struct {
hostCtx context.Context
arbiter EpochArbiter
jobs chan epochJob
closedCh chan struct{}
closeOnce sync.Once
closedOnce sync.Once
wg sync.WaitGroup
}
type epochJob struct {
ctx context.Context
batch EvidenceBatch
filters []EpochFilter
reply chan<- epochResult
}
type epochResult struct {
set EvaluationSet
err error
}
// NewGateCoordinator constructs a coordinator.
//
// hostCtx drives the worker's lifecycle: when hostCtx is cancelled the worker
// stops accepting work, all in-flight epochs terminate without invoking the
// arbiter, and any queued epochs are completed with a host-cancel error.
//
// arbiter must not be nil. MaxQueuedEpochs must be non-negative.
func NewGateCoordinator(
hostCtx context.Context,
options GateCoordinatorOptions,
arbiter EpochArbiter,
) (*GateCoordinator, error) {
if arbiter == nil {
return nil, errors.New("streamgate: new gate coordinator arbiter is required")
}
if options.MaxQueuedEpochs < 0 {
return nil, errors.New("streamgate: new gate coordinator max queued epochs must be non-negative")
}
c := &GateCoordinator{
hostCtx: hostCtx,
arbiter: arbiter,
jobs: make(chan epochJob, options.MaxQueuedEpochs),
closedCh: make(chan struct{}),
}
c.wg.Add(1)
go c.worker()
return c, nil
}
// worker runs exactly one epoch at a time, processing accepted jobs in FIFO
// order over the bounded jobs channel.
func (c *GateCoordinator) worker() {
defer c.wg.Done()
for {
select {
case job, ok := <-c.jobs:
if !ok {
return
}
c.runJob(job)
case <-c.hostCtx.Done():
// Close closedCh to unblock any submitters waiting on send.
c.closedOnce.Do(func() { close(c.closedCh) })
// Drain buffered jobs with a host-cancel error so their
// submitters' reply channels are not left hanging.
for {
select {
case job := <-c.jobs:
select {
case job.reply <- epochResult{err: c.hostCtx.Err()}:
default:
}
default:
return
}
}
}
}
}
func (c *GateCoordinator) runJob(job epochJob) {
// Skip cancelled jobs without invoking evaluator or arbiter.
if job.ctx.Err() != nil {
select {
case job.reply <- epochResult{err: job.ctx.Err()}:
default:
}
return
}
set, err := c.Evaluate(job.ctx, job.batch, job.filters)
select {
case job.reply <- epochResult{set: set, err: err}:
default:
}
}
// Evaluate fans out ready filters for a single epoch, collects all outcomes
// through an all-complete barrier, and invokes the arbiter exactly once when
// the stable EvaluationSet is ready.
//
// Non-ready filters contribute pre-evaluation outcomes via NormalizeOutcome();
// ready filters are dispatched to goroutines with per-filter timeouts and the
// raw evaluator error is normalized to filter_evaluation_error or
// filter_evaluation_deadline codes. The caller/host context is propagated
// into every filter child context; if it is cancelled before the complete set
// is assembled the arbiter is NOT invoked.
func (c *GateCoordinator) Evaluate(ctx context.Context, batch EvidenceBatch, filters []EpochFilter) (EvaluationSet, error) {
if err := batch.Validate(); err != nil {
return EvaluationSet{}, errors.New("streamgate: evaluate batch: " + err.Error())
}
// Snapshot capturedAt so that epoch-level consistency can be verified by
// downstream consumers/fixtures without exposing mutable batch internals.
_ = batch.CapturedAt()
// Partition filters into ready (subscribed + triggerReady) and non-ready.
type filterWork struct {
filter EpochFilter
outcomeCh chan FilterOutcome
}
var ready []filterWork
var nonReady []EpochFilter
for _, f := range filters {
if f.EvaluatedForEpoch() {
ch := make(chan FilterOutcome, 1)
ready = append(ready, filterWork{filter: f, outcomeCh: ch})
} else {
nonReady = append(nonReady, f)
}
}
if ctx.Err() != nil {
return EvaluationSet{}, ctx.Err()
}
// Fan out ready filters into goroutines with per-filter timeouts.
for _, w := range ready {
go func(w filterWork) {
defer close(w.outcomeCh)
filterCtx, cancel := context.WithTimeout(ctx, w.filter.EvaluationTimeout())
defer cancel()
decision, evalErr := w.filter.Evaluator().Evaluate(filterCtx, batch)
if filterCtx.Err() != nil {
oc, _ := NewFilterOutcomeEvaluationError("filter_evaluation_deadline")
w.outcomeCh <- oc
return
}
if evalErr != nil {
oc, _ := NewFilterOutcomeEvaluationError("filter_evaluation_error")
w.outcomeCh <- oc
return
}
oc, convErr := NewFilterOutcomeEvaluated(decision)
if convErr != nil {
oc, _ = NewFilterOutcomeEvaluationError("filter_evaluation_error")
w.outcomeCh <- oc
return
}
w.outcomeCh <- oc
}(w)
}
// Collect all ready filter outcomes through the all-complete barrier.
readyOutcomes := make([]FilterOutcome, 0, len(ready))
for _, w := range ready {
select {
case oc := <-w.outcomeCh:
readyOutcomes = append(readyOutcomes, oc)
case <-ctx.Done():
return EvaluationSet{}, ctx.Err()
case <-c.hostCtx.Done():
return EvaluationSet{}, c.hostCtx.Err()
}
}
// Build the stable complete outcome set.
var outcomes []EpochFilterOutcome
// Non-ready filters contribute pre-evaluation outcomes.
for _, f := range nonReady {
oc := f.NormalizeOutcome()
if oc.Kind() != "" {
o, err := NewEpochFilterOutcome(f.ID(), oc, f.EnforcedAs())
if err != nil {
return EvaluationSet{}, errors.New("streamgate: evaluate non-ready outcome: " + err.Error())
}
outcomes = append(outcomes, o)
}
}
// Ready filters contribute evaluated outcomes.
for i, w := range ready {
o, err := NewEpochFilterOutcome(w.filter.ID(), readyOutcomes[i], w.filter.EnforcedAs())
if err != nil {
return EvaluationSet{}, errors.New("streamgate: evaluate ready outcome: " + err.Error())
}
outcomes = append(outcomes, o)
}
// Construct the EvaluationSet (sorts by filter id ascending).
set, err := NewEvaluationSet(outcomes)
if err != nil {
return EvaluationSet{}, errors.New("streamgate: evaluate set: " + err.Error())
}
// Invoke the arbiter exactly once, only after the complete set is ready.
if arbErr := c.arbiter.Arbitrate(ctx, batch, set); arbErr != nil {
return set, errors.New("streamgate: evaluate arbiter: " + arbErr.Error())
}
return set, nil
}
// Submit enqueues an epoch evaluation job. The caller blocks until the job is
// accepted into the bounded queue, until the caller's context is cancelled,
// or until the coordinator is closed. The job is processed by the single
// worker in FIFO order.
func (c *GateCoordinator) Submit(ctx context.Context, batch EvidenceBatch, filters []EpochFilter) error {
if err := batch.Validate(); err != nil {
return errors.New("streamgate: submit batch: " + err.Error())
}
reply := make(chan epochResult, 1)
job := epochJob{
ctx: ctx,
batch: batch,
filters: filters,
reply: reply,
}
select {
case c.jobs <- job:
// Job accepted into the bounded queue.
case <-ctx.Done():
return ctx.Err()
case <-c.closedCh:
return errors.New("streamgate: submit rejected: coordinator closed")
}
select {
case res := <-reply:
return res.err
case <-ctx.Done():
return ctx.Err()
case <-c.closedCh:
return errors.New("streamgate: submit rejected: coordinator closed")
}
}
// Close terminates the coordinator and releases any blocked submitters.
// It is safe to call Close multiple times; only the first call performs the
// actual shutdown.
func (c *GateCoordinator) Close() error {
var firstErr error
c.closeOnce.Do(func() {
c.closedOnce.Do(func() { close(c.closedCh) })
close(c.jobs)
})
c.wg.Wait()
return firstErr
}