Files
2026-09-11 06:14:38 +02:00

693 lines
17 KiB
Go

package batch
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/example/ollama-fair-gateway/internal/config"
"github.com/example/ollama-fair-gateway/internal/state"
)
const (
StateQueued = "queued"
StateRunning = "running"
StatePaused = "paused"
StatePausing = "pausing"
StateCancelling = "cancelling"
StateCompleted = "completed"
StateFailed = "failed"
StateCancelled = "cancelled"
)
var (
ErrDisabled = errors.New("durable batch jobs are disabled")
ErrNotFound = errors.New("batch job not found")
ErrInvalidState = errors.New("batch job state does not allow this operation")
ErrFull = errors.New("batch job retention store is full")
ErrInputTooLarge = errors.New("batch input exceeds configured max_input_bytes")
)
// IdentitySnapshot preserves the authenticated metadata required to re-run a
// durable request through the normal gateway authorization/routing pipeline.
// It intentionally contains no bearer/API-key secret.
type IdentitySnapshot struct {
Tenant string `json:"tenant"`
Subject string `json:"subject"`
Actor string `json:"actor"`
Application string `json:"application,omitempty"`
AuthType string `json:"auth_type"`
ClientIP string `json:"client_ip,omitempty"`
Scopes []string `json:"scopes,omitempty"`
ModelACLSet bool `json:"model_acl_set,omitempty"`
ModelAccess config.ModelAccessRule `json:"model_access,omitempty"`
}
type Job struct {
ID string `json:"id"`
Identity IdentitySnapshot `json:"identity"`
Path string `json:"path"`
Model string `json:"model,omitempty"`
ServiceClass string `json:"service_class"`
InputRef string `json:"input_ref"`
OutputRef string `json:"output_ref,omitempty"`
State string `json:"state"`
Attempts int `json:"attempts"`
HTTPStatus int `json:"http_status,omitempty"`
ResponseContentType string `json:"response_content_type,omitempty"`
ExecutionRequestID string `json:"execution_request_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Error string `json:"error,omitempty"`
}
type RunResult struct {
HTTPStatus int
ResponseContentType string
RequestID string
Error string
}
type Runner func(context.Context, Job, io.Reader, io.Writer) RunResult
type snapshot struct {
Version int `json:"version"`
Jobs map[string]Job `json:"jobs"`
}
type Manager struct {
mu sync.Mutex
cfg config.BatchJobsConfig
file state.AtomicJSON
dir string
jobs map[string]Job
cancel map[string]context.CancelCauseFunc
wake chan struct{}
active int
runner Runner
ctx context.Context
now func() time.Time
wg sync.WaitGroup
}
var (
errPause = errors.New("batch pause requested")
errCancel = errors.New("batch cancel requested")
)
func New(cfg config.BatchJobsConfig, metadataPath, dir string) (*Manager, error) {
m := &Manager{cfg: cfg, file: state.AtomicJSON{Path: metadataPath, Mode: 0600}, dir: dir, jobs: map[string]Job{}, cancel: map[string]context.CancelCauseFunc{}, wake: make(chan struct{}, 1), now: time.Now}
if !cfg.Enabled {
return m, nil
}
if err := os.MkdirAll(filepath.Join(dir, "input"), 0750); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Join(dir, "output"), 0750); err != nil {
return nil, err
}
var snap snapshot
if err := m.file.Load(&snap); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
} else {
if snap.Version != 1 {
return nil, fmt.Errorf("unsupported batch metadata version %d", snap.Version)
}
if snap.Jobs != nil {
m.jobs = snap.Jobs
}
}
changed := m.recoverLocked()
changed = m.pruneLocked(m.now().UTC()) || changed
if changed {
if err := m.saveLocked(); err != nil {
return nil, err
}
}
return m, nil
}
func (m *Manager) Enabled() bool { return m != nil && m.cfg.Enabled }
func (m *Manager) Start(ctx context.Context, runner Runner) {
if !m.Enabled() || runner == nil {
return
}
m.mu.Lock()
m.ctx = ctx
m.runner = runner
m.mu.Unlock()
go m.loop(ctx)
m.signal()
}
// Wait blocks until all currently running batch attempts have returned. It is
// intended for graceful shutdown after the root context has been cancelled, so
// no new attempts can be dispatched while waiting. Each finishing attempt
// persists its final restart-safe state before it releases the wait group.
func (m *Manager) Wait(ctx context.Context) error {
if !m.Enabled() {
return nil
}
done := make(chan struct{})
go func() {
m.wg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (m *Manager) Create(id IdentitySnapshot, path, model string, body []byte) (Job, error) {
if !m.Enabled() {
return Job{}, ErrDisabled
}
if id.Tenant == "" || id.Actor == "" {
return Job{}, errors.New("batch identity requires tenant and actor")
}
if path == "" || !strings.HasPrefix(path, "/") {
return Job{}, errors.New("batch path must be an absolute gateway path")
}
if int64(len(body)) > m.cfg.MaxInputBytes {
return Job{}, ErrInputTooLarge
}
if len(body) == 0 {
return Job{}, errors.New("batch input body is empty")
}
m.mu.Lock()
defer m.mu.Unlock()
now := m.now().UTC()
m.pruneLocked(now)
if len(m.jobs) >= m.cfg.MaxJobs {
return Job{}, ErrFull
}
jobID, err := newID()
if err != nil {
return Job{}, err
}
inputRef := filepath.ToSlash(filepath.Join("input", jobID+".json"))
if err := m.writeInputLocked(inputRef, body); err != nil {
return Job{}, err
}
j := Job{ID: jobID, Identity: id, Path: path, Model: model, ServiceClass: "batch", InputRef: inputRef, State: StateQueued, CreatedAt: now, UpdatedAt: now}
m.jobs[j.ID] = j
if err := m.saveLocked(); err != nil {
delete(m.jobs, j.ID)
_ = os.Remove(m.refPath(inputRef))
return Job{}, err
}
m.signal()
return cloneJob(j), nil
}
func (m *Manager) List(tenant, actor string, all bool) []Job {
if !m.Enabled() {
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
if m.pruneLocked(m.now().UTC()) {
_ = m.saveLocked()
}
out := make([]Job, 0, len(m.jobs))
for _, j := range m.jobs {
if all || (j.Identity.Tenant == tenant && j.Identity.Actor == actor) {
out = append(out, cloneJob(j))
}
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out
}
func (m *Manager) Get(id, tenant, actor string, all bool) (Job, bool) {
if !m.Enabled() {
return Job{}, false
}
m.mu.Lock()
defer m.mu.Unlock()
j, ok := m.jobs[id]
if !ok || (!all && (j.Identity.Tenant != tenant || j.Identity.Actor != actor)) {
return Job{}, false
}
return cloneJob(j), true
}
func (m *Manager) Pause(id, tenant, actor string, all bool) (Job, error) {
return m.control(id, tenant, actor, all, "pause")
}
func (m *Manager) Resume(id, tenant, actor string, all bool) (Job, error) {
return m.control(id, tenant, actor, all, "resume")
}
func (m *Manager) Cancel(id, tenant, actor string, all bool) (Job, error) {
return m.control(id, tenant, actor, all, "cancel")
}
func (m *Manager) control(id, tenant, actor string, all bool, action string) (Job, error) {
if !m.Enabled() {
return Job{}, ErrDisabled
}
m.mu.Lock()
j, ok := m.jobs[id]
if !ok || (!all && (j.Identity.Tenant != tenant || j.Identity.Actor != actor)) {
m.mu.Unlock()
return Job{}, ErrNotFound
}
now := m.now().UTC()
var cancel context.CancelCauseFunc
switch action {
case "pause":
switch j.State {
case StateQueued:
j.State = StatePaused
case StateRunning:
j.State = StatePausing
cancel = m.cancel[id]
case StatePaused, StatePausing:
// idempotent
default:
m.mu.Unlock()
return Job{}, ErrInvalidState
}
case "resume":
if j.State != StatePaused {
m.mu.Unlock()
return Job{}, ErrInvalidState
}
j.State = StateQueued
j.Error = ""
j.HTTPStatus = 0
j.ResponseContentType = ""
j.ExecutionRequestID = ""
j.OutputRef = ""
j.StartedAt = nil
j.FinishedAt = nil
case "cancel":
switch j.State {
case StateQueued, StatePaused:
j.State = StateCancelled
j.FinishedAt = ptrTime(now)
case StateRunning, StatePausing:
j.State = StateCancelling
cancel = m.cancel[id]
case StateCancelling, StateCancelled:
// idempotent
default:
m.mu.Unlock()
return Job{}, ErrInvalidState
}
default:
m.mu.Unlock()
return Job{}, errors.New("unknown batch control action")
}
j.UpdatedAt = now
m.jobs[id] = j
if err := m.saveLocked(); err != nil {
m.mu.Unlock()
return Job{}, err
}
m.mu.Unlock()
if cancel != nil {
if action == "pause" {
cancel(errPause)
} else {
cancel(errCancel)
}
}
if action == "resume" {
m.signal()
}
return cloneJob(j), nil
}
func (m *Manager) OpenOutput(id, tenant, actor string, all bool) (*os.File, Job, error) {
if !m.Enabled() {
return nil, Job{}, ErrDisabled
}
m.mu.Lock()
j, ok := m.jobs[id]
if !ok || (!all && (j.Identity.Tenant != tenant || j.Identity.Actor != actor)) {
m.mu.Unlock()
return nil, Job{}, ErrNotFound
}
ref := j.OutputRef
m.mu.Unlock()
if ref == "" {
return nil, cloneJob(j), os.ErrNotExist
}
f, err := os.Open(m.refPath(ref))
return f, cloneJob(j), err
}
func (m *Manager) Compact() error {
if !m.Enabled() {
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
m.pruneLocked(m.now().UTC())
return m.saveLocked()
}
func (m *Manager) loop(ctx context.Context) {
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-m.wake:
m.dispatch(ctx)
case <-t.C:
m.mu.Lock()
changed := m.pruneLocked(m.now().UTC())
if changed {
_ = m.saveLocked()
}
m.mu.Unlock()
m.dispatch(ctx)
}
}
}
func (m *Manager) dispatch(root context.Context) {
for {
m.mu.Lock()
if m.runner == nil || m.active >= m.cfg.MaxConcurrent || root.Err() != nil {
m.mu.Unlock()
return
}
var chosen *Job
for _, j := range m.jobs {
if j.State != StateQueued {
continue
}
if chosen == nil || j.CreatedAt.Before(chosen.CreatedAt) {
jc := j
chosen = &jc
}
}
if chosen == nil {
m.mu.Unlock()
return
}
now := m.now().UTC()
j := *chosen
j.State = StateRunning
j.Attempts++
j.StartedAt = ptrTime(now)
j.FinishedAt = nil
j.UpdatedAt = now
j.Error = ""
m.jobs[j.ID] = j
ctx, cancel := context.WithCancelCause(root)
m.cancel[j.ID] = cancel
m.active++
if err := m.saveLocked(); err != nil {
m.active--
delete(m.cancel, j.ID)
j.State = StateQueued
m.jobs[j.ID] = j
m.mu.Unlock()
return
}
runner := m.runner
m.wg.Add(1)
m.mu.Unlock()
go func() {
defer m.wg.Done()
m.runOne(ctx, j, runner)
}()
}
}
func (m *Manager) runOne(ctx context.Context, j Job, runner Runner) {
input, err := os.Open(m.refPath(j.InputRef))
if err != nil {
m.finishRun(j.ID, RunResult{Error: "open input: " + err.Error()}, "", true)
return
}
defer input.Close()
outDir := filepath.Join(m.dir, "output")
tmp, err := os.CreateTemp(outDir, ".batch-output-*.tmp")
if err != nil {
m.finishRun(j.ID, RunResult{Error: "create output: " + err.Error()}, "", true)
return
}
tmpPath := tmp.Name()
if err := tmp.Chmod(0600); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpPath)
m.finishRun(j.ID, RunResult{Error: "chmod output: " + err.Error()}, "", true)
return
}
res := runner(ctx, cloneJob(j), input, tmp)
if err := tmp.Sync(); err != nil && res.Error == "" {
res.Error = "sync output: " + err.Error()
}
if err := tmp.Close(); err != nil && res.Error == "" {
res.Error = "close output: " + err.Error()
}
m.finishRun(j.ID, res, tmpPath, false)
}
func (m *Manager) finishRun(id string, res RunResult, tmpPath string, setupFailure bool) {
m.mu.Lock()
defer m.mu.Unlock()
j, ok := m.jobs[id]
if !ok {
if tmpPath != "" {
_ = os.Remove(tmpPath)
}
return
}
delete(m.cancel, id)
if m.active > 0 {
m.active--
}
now := m.now().UTC()
j.UpdatedAt = now
j.HTTPStatus = res.HTTPStatus
j.ResponseContentType = res.ResponseContentType
j.ExecutionRequestID = res.RequestID
switch {
case j.State == StateCancelling:
if tmpPath != "" {
_ = os.Remove(tmpPath)
}
j.State = StateCancelled
j.FinishedAt = ptrTime(now)
j.Error = "cancelled"
case j.State == StatePausing:
if tmpPath != "" {
_ = os.Remove(tmpPath)
}
j.State = StatePaused
j.StartedAt = nil
j.Error = "paused"
case m.ctx != nil && m.ctx.Err() != nil:
if tmpPath != "" {
_ = os.Remove(tmpPath)
}
j.State = StateQueued
j.StartedAt = nil
j.Error = "interrupted by gateway shutdown; queued for retry"
case setupFailure:
j.State = StateFailed
j.FinishedAt = ptrTime(now)
j.Error = res.Error
default:
finalRef := filepath.ToSlash(filepath.Join("output", id+".response"))
finalPath := m.refPath(finalRef)
_ = os.Remove(finalPath)
if tmpPath != "" {
if err := os.Rename(tmpPath, finalPath); err != nil {
j.State = StateFailed
j.FinishedAt = ptrTime(now)
j.Error = "commit output: " + err.Error()
break
}
j.OutputRef = finalRef
}
if res.Error != "" || res.HTTPStatus < 200 || res.HTTPStatus >= 400 {
j.State = StateFailed
j.Error = res.Error
if j.Error == "" && res.HTTPStatus != 0 {
j.Error = fmt.Sprintf("gateway HTTP %d", res.HTTPStatus)
}
} else {
j.State = StateCompleted
j.Error = ""
}
j.FinishedAt = ptrTime(now)
}
m.jobs[id] = j
_ = m.saveLocked()
m.signal()
}
func (m *Manager) recoverLocked() bool {
changed := false
now := m.now().UTC()
for id, j := range m.jobs {
switch j.State {
case StateRunning:
j.State = StateQueued
j.StartedAt = nil
j.Error = "recovered after gateway restart; queued for retry"
j.UpdatedAt = now
m.jobs[id] = j
changed = true
case StatePausing:
j.State = StatePaused
j.StartedAt = nil
j.Error = "pause recovered after gateway restart"
j.UpdatedAt = now
m.jobs[id] = j
changed = true
case StateCancelling:
j.State = StateCancelled
j.FinishedAt = ptrTime(now)
j.Error = "cancel recovered after gateway restart"
j.UpdatedAt = now
m.jobs[id] = j
changed = true
}
}
return changed
}
func (m *Manager) pruneLocked(now time.Time) bool {
if m.cfg.Retention.Value() <= 0 {
return false
}
changed := false
for id, j := range m.jobs {
if !isTerminal(j.State) || j.FinishedAt == nil || now.Sub(*j.FinishedAt) < m.cfg.Retention.Value() {
continue
}
_ = os.Remove(m.refPath(j.InputRef))
if j.OutputRef != "" {
_ = os.Remove(m.refPath(j.OutputRef))
}
delete(m.jobs, id)
changed = true
}
return changed
}
func (m *Manager) saveLocked() error {
copyMap := make(map[string]Job, len(m.jobs))
for id, j := range m.jobs {
copyMap[id] = cloneJob(j)
}
return m.file.Save(snapshot{Version: 1, Jobs: copyMap})
}
func (m *Manager) writeInputLocked(ref string, body []byte) error {
path := m.refPath(ref)
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0750); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".batch-input-*.tmp")
if err != nil {
return err
}
name := tmp.Name()
ok := false
defer func() {
_ = tmp.Close()
if !ok {
_ = os.Remove(name)
}
}()
if err := tmp.Chmod(0600); err != nil {
return err
}
if _, err := tmp.Write(body); err != nil {
return err
}
if err := tmp.Sync(); err != nil {
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(name, path); err != nil {
return err
}
ok = true
return nil
}
func (m *Manager) refPath(ref string) string {
ref = filepath.Clean(filepath.FromSlash(ref))
if ref == "." || filepath.IsAbs(ref) || ref == ".." || strings.HasPrefix(ref, ".."+string(filepath.Separator)) {
return filepath.Join(m.dir, "invalid-ref")
}
return filepath.Join(m.dir, ref)
}
func (m *Manager) signal() {
select {
case m.wake <- struct{}{}:
default:
}
}
func isTerminal(state string) bool {
switch state {
case StateCompleted, StateFailed, StateCancelled:
return true
default:
return false
}
}
func newID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "batch_" + hex.EncodeToString(b), nil
}
func cloneJob(j Job) Job {
j.Identity.Scopes = append([]string(nil), j.Identity.Scopes...)
j.Identity.ModelAccess.AllowedModels = append([]string(nil), j.Identity.ModelAccess.AllowedModels...)
j.Identity.ModelAccess.DeniedModels = append([]string(nil), j.Identity.ModelAccess.DeniedModels...)
if j.StartedAt != nil {
t := *j.StartedAt
j.StartedAt = &t
}
if j.FinishedAt != nil {
t := *j.FinishedAt
j.FinishedAt = &t
}
return j
}
func ptrTime(t time.Time) *time.Time { return &t }