Files
sift/internal/api/batch.go
T
kyleandornith-1.5-35b-a3b 72e0828360 api: NewBatchRunner — the batch fetch adapter, retry on busy, outcome metrics
Wire the batch runner to fetchTarget and feed the sift_batch_* metrics.
New internal/api/batch.go: NewBatchRunner overwrites cfg.Hooks (Job, Target,
JobSeconds, Depth, Inflight → s.Prom) and sets the runner's FetchFunc to
batchFetch, which tags the context as a batch fetch, runs under requestDeadline,
and maps fetchTarget's errors to what the runner retries (429/503 →
ErrRetryLater) and what the metrics classify (batchOutcome). Added the Batch
field to Server (nil → task 07 answers 503 "batch disabled") and, in
extractPDF, charged the PDF pool to c.Name+"/batch" under the batch mark so a
batch never takes a caller's interactive slot while the single-flight key is
unchanged. No HTTP endpoints.

Co-Authored-By: ornith-1.5-35b-a3b <ornith-1.5-35b-a3b@llama.cpp.invalid>
2026-09-26 22:43:28 -07:00

114 lines
3.2 KiB
Go

package api
import (
"context"
"errors"
"fmt"
"strings"
"time"
"git.wntrmute.dev/kyle/sift/internal/auth"
"git.wntrmute.dev/kyle/sift/internal/batch"
"git.wntrmute.dev/kyle/sift/internal/dcache"
"git.wntrmute.dev/kyle/sift/internal/fetch"
)
// batchCtxKey marks a context as a batch fetch so extractPDF charges the PDF pool to the
// caller's "/batch" slot instead of its interactive one.
type batchCtxKey struct{}
func isBatchFetch(ctx context.Context) bool {
ok, _ := ctx.Value(batchCtxKey{}).(bool)
return ok
}
// NewBatchRunner builds the runner that serve (task 07) and the tests use. Its FetchFunc is
// s.batchFetch and its Hooks feed s.Prom; any cfg.Hooks from the caller is overwritten.
func (s *Server) NewBatchRunner(db *dcache.DB, cfg batch.Config) *batch.Runner {
cfg.Hooks = batch.Hooks{
Job: func(outcome string) {
s.Prom.ObserveBatchJob(outcome)
},
Target: func(err error, d time.Duration, n int) {
s.Prom.ObserveBatchTarget(batchOutcome(err))
s.Prom.ObserveBatchTargetSeconds(d)
if err == nil {
s.Prom.ObserveBatchResultBytes(n)
}
},
JobSeconds: func(d time.Duration) {
s.Prom.ObserveBatchJobSeconds(d)
},
Depth: func(live int) {
s.Prom.SetBatchQueueDepth(live)
},
Inflight: func(n int) {
s.Prom.SetBatchTargetsInflight(n)
},
}
return batch.New(db, s.batchFetch, cfg, s.Log)
}
// batchFetch is the runner's FetchFunc: one target through s.fetchTarget under the request
// deadline, tagged as a batch fetch. Its errors map to what the runner and the metrics need.
func (s *Server) batchFetch(ctx context.Context, c *auth.Caller, t batch.Target) (batch.Result, error) {
ctx, cancel := context.WithTimeout(ctx, requestDeadline)
defer cancel()
ctx = context.WithValue(ctx, batchCtxKey{}, true)
res, err := s.fetchTarget(ctx, c, t)
if err != nil {
var fe *fetch.Error
if errors.As(err, &fe) {
switch fe.Status {
case 429, 503:
return batch.Result{}, fmt.Errorf("%s: %w", fe.Msg, batch.ErrRetryLater)
}
if fe.Status == 502 {
fe.Status = 500
fe.Msg = "internal error"
}
if fe.Status == 403 {
s.blockN.Add(1)
}
return batch.Result{}, &batch.TargetError{Status: fe.Status, Msg: fe.Msg, Hint: fe.Hint}
}
s.Log.Error("internal", "caller", c.Name, "host", "-", "err", s.logErr(c, err))
return batch.Result{}, &batch.TargetError{Status: 500, Msg: "internal error"}
}
return res, nil
}
// batchOutcome classifies a finished target's error into the sift_batch_targets_total label:
// a nil error is ok, a retry-later the runner gave up on is busy, a TargetError by its status,
// and anything else upstream_5xx so a raw string never becomes a label.
func batchOutcome(err error) string {
switch {
case err == nil:
return "ok"
case errors.Is(err, batch.ErrRetryLater):
return "busy"
}
var te *batch.TargetError
if errors.As(err, &te) {
switch te.Status {
case 403:
return "blocked"
case 429:
return "busy"
case 504:
return "timeout"
case 415:
return "unsupported"
case 422:
if strings.HasPrefix(te.Msg, "document too long") {
return "too_long"
}
return "upstream_4xx"
}
if te.Status >= 400 && te.Status < 500 {
return "upstream_4xx"
}
}
return "upstream_5xx"
}