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>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
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"
|
||||
}
|
||||
Reference in New Issue
Block a user