Make poll intervals and operational timeouts configurable
ci / test (push) Successful in 47s
ci / docker (push) Failing after 30s

New env vars: UNLOAD_POLL_INTERVAL, HISTORY_POLL_INTERVAL, PROBE_TIMEOUT,
FREE_TIMEOUT, WARM_TIMEOUT, SHUTDOWN_TIMEOUT, PROMPT_CAPTURE_LIMIT.
Defaults unchanged; invalid values fail fast at startup.
This commit is contained in:
mram
2026-09-20 19:21:25 +02:00
parent 0f950f3134
commit cf9be55a6c
4 changed files with 125 additions and 35 deletions
+7
View File
@@ -43,6 +43,13 @@ startup.
| `WARM_MODEL` | _(empty)_ | Model to reload after an image job (off by default) | | `WARM_MODEL` | _(empty)_ | Model to reload after an image job (off by default) |
| `LOG_LEVEL` | `info` | `debug` logs every lock transition | | `LOG_LEVEL` | `info` | `debug` logs every lock transition |
| `LOG_FORMAT` | `text` | `json` for structured JSON logs | | `LOG_FORMAT` | `text` | `json` for structured JSON logs |
| `UNLOAD_POLL_INTERVAL` | `500ms` | `/api/ps` poll interval while unloading |
| `HISTORY_POLL_INTERVAL` | `1s` | `/history/<id>` poll interval while a job runs |
| `PROBE_TIMEOUT` | `5s` | Startup probe of both upstreams |
| `FREE_TIMEOUT` | `30s` | `POST /free` call after an image job |
| `WARM_TIMEOUT` | `2m` | Warm-model reload after an image job |
| `SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown on SIGINT/SIGTERM |
| `PROMPT_CAPTURE_LIMIT` | `65536` | Bytes of the `/prompt` response buffered to find `prompt_id` (pass-through is unaffected) |
## Observability ## Observability
+11 -2
View File
@@ -81,12 +81,14 @@ ComfyUI listener (`:8188` → `COMFY_URL`):
2. Unload Ollama: `GET /api/ps`; for each model `POST /api/generate 2. Unload Ollama: `GET /api/ps`; for each model `POST /api/generate
{"model":M,"keep_alive":0}`; if that returns non-2xx (embedding-only {"model":M,"keep_alive":0}`; if that returns non-2xx (embedding-only
models), `POST /api/embed {"model":M,"input":"x","keep_alive":0}`. Poll models), `POST /api/embed {"model":M,"input":"x","keep_alive":0}`. Poll
`/api/ps` every 500 ms until empty or `UNLOAD_TIMEOUT`. On timeout: log and `/api/ps` every `UNLOAD_POLL_INTERVAL` (default 500 ms) until empty or
`UNLOAD_TIMEOUT`. On timeout: log and
continue (degrade, don't fail the user's request). continue (degrade, don't fail the user's request).
3. Forward the original request body to ComfyUI `/prompt`, return status, 3. Forward the original request body to ComfyUI `/prompt`, return status,
headers and body to the caller unchanged, flush. headers and body to the caller unchanged, flush.
4. If the response is 200 and contains `prompt_id`: in a goroutine, poll 4. If the response is 200 and contains `prompt_id`: in a goroutine, poll
`GET /history/<prompt_id>` every 1 s until the entry has `GET /history/<prompt_id>` every `HISTORY_POLL_INTERVAL` (default 1 s)
until the entry has
`status.completed == true`, `status.status_str == "error"`, or `status.completed == true`, `status.status_str == "error"`, or
`JOB_TIMEOUT`. Then `POST /free {"unload_models":true,"free_memory":true}`. `JOB_TIMEOUT`. Then `POST /free {"unload_models":true,"free_memory":true}`.
Then release the image lock. Then release the image lock.
@@ -111,6 +113,13 @@ load time. Off by default.
| `LLM_WAIT_TIMEOUT` | `10m` | max time an LLM request waits for the lock before 503 | | `LLM_WAIT_TIMEOUT` | `10m` | max time an LLM request waits for the lock before 503 |
| `WARM_MODEL` | `` | optional model to reload after an image job | | `WARM_MODEL` | `` | optional model to reload after an image job |
| `LOG_LEVEL` | `info` | `debug` logs every lock transition | | `LOG_LEVEL` | `info` | `debug` logs every lock transition |
| `UNLOAD_POLL_INTERVAL` | `500ms` | `/api/ps` poll interval while unloading |
| `HISTORY_POLL_INTERVAL` | `1s` | `/history/<id>` poll interval while a job runs |
| `PROBE_TIMEOUT` | `5s` | startup probe of both upstreams |
| `FREE_TIMEOUT` | `30s` | `POST /free` call after an image job |
| `WARM_TIMEOUT` | `2m` | warm-model reload after an image job |
| `SHUTDOWN_TIMEOUT` | `10s` | graceful shutdown on SIGINT/SIGTERM |
| `PROMPT_CAPTURE_LIMIT` | `65536` | bytes of the `/prompt` response buffered to find `prompt_id` (pass-through is unaffected) |
Startup fails fast on unparsable values. Both upstreams are probed once at Startup fails fast on unparsable values. Both upstreams are probed once at
start (`/api/version`, `/system_stats`); failure is logged, not fatal. start (`/api/version`, `/system_stats`); failure is logged, not fatal.
+39 -2
View File
@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strconv"
"strings" "strings"
"syscall" "syscall"
"time" "time"
@@ -32,6 +33,15 @@ type config struct {
unloadTimeout time.Duration unloadTimeout time.Duration
jobTimeout time.Duration jobTimeout time.Duration
llmWaitTimeout time.Duration llmWaitTimeout time.Duration
unloadPollInterval time.Duration
historyPollInterval time.Duration
probeTimeout time.Duration
freeTimeout time.Duration
warmTimeout time.Duration
shutdownTimeout time.Duration
promptCaptureLimit int64
warmModel string warmModel string
logLevel slog.Level logLevel slog.Level
logJSON bool logJSON bool
@@ -59,6 +69,15 @@ func loadConfig(getenv func(string) string) (config, error) {
unloadTimeout: time.Minute, unloadTimeout: time.Minute,
jobTimeout: 15 * time.Minute, jobTimeout: 15 * time.Minute,
llmWaitTimeout: 10 * time.Minute, llmWaitTimeout: 10 * time.Minute,
unloadPollInterval: 500 * time.Millisecond,
historyPollInterval: time.Second,
probeTimeout: 5 * time.Second,
freeTimeout: 30 * time.Second,
warmTimeout: 2 * time.Minute,
shutdownTimeout: 10 * time.Second,
promptCaptureLimit: 64 * 1024,
logLevel: slog.LevelInfo, logLevel: slog.LevelInfo,
} }
for _, e := range []struct { for _, e := range []struct {
@@ -82,11 +101,24 @@ func loadConfig(getenv func(string) string) (config, error) {
{"UNLOAD_TIMEOUT", &cfg.unloadTimeout}, {"UNLOAD_TIMEOUT", &cfg.unloadTimeout},
{"JOB_TIMEOUT", &cfg.jobTimeout}, {"JOB_TIMEOUT", &cfg.jobTimeout},
{"LLM_WAIT_TIMEOUT", &cfg.llmWaitTimeout}, {"LLM_WAIT_TIMEOUT", &cfg.llmWaitTimeout},
{"UNLOAD_POLL_INTERVAL", &cfg.unloadPollInterval},
{"HISTORY_POLL_INTERVAL", &cfg.historyPollInterval},
{"PROBE_TIMEOUT", &cfg.probeTimeout},
{"FREE_TIMEOUT", &cfg.freeTimeout},
{"WARM_TIMEOUT", &cfg.warmTimeout},
{"SHUTDOWN_TIMEOUT", &cfg.shutdownTimeout},
} { } {
if err := envDuration(getenv, e.name, e.dst); err != nil { if err := envDuration(getenv, e.name, e.dst); err != nil {
return cfg, err return cfg, err
} }
} }
if v := getenv("PROMPT_CAPTURE_LIMIT"); v != "" {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n < 0 {
return cfg, fmt.Errorf("PROMPT_CAPTURE_LIMIT: must be a non-negative integer (bytes)")
}
cfg.promptCaptureLimit = n
}
if v := getenv("LOG_LEVEL"); v != "" { if v := getenv("LOG_LEVEL"); v != "" {
var level slog.Level var level slog.Level
if err := level.UnmarshalText([]byte(v)); err != nil { if err := level.UnmarshalText([]byte(v)); err != nil {
@@ -149,6 +181,11 @@ func main() {
LLMWaitTimeout: cfg.llmWaitTimeout, LLMWaitTimeout: cfg.llmWaitTimeout,
UnloadTimeout: cfg.unloadTimeout, UnloadTimeout: cfg.unloadTimeout,
JobTimeout: cfg.jobTimeout, JobTimeout: cfg.jobTimeout,
UnloadPollInterval: cfg.unloadPollInterval,
HistoryPollInterval: cfg.historyPollInterval,
FreeTimeout: cfg.freeTimeout,
WarmTimeout: cfg.warmTimeout,
PromptCaptureLimit: cfg.promptCaptureLimit,
WarmModel: cfg.warmModel, WarmModel: cfg.warmModel,
}) })
if err != nil { if err != nil {
@@ -157,7 +194,7 @@ func main() {
} }
// Probe both upstreams once; failure is logged, not fatal. // Probe both upstreams once; failure is logged, not fatal.
probeCtx, probeCancel := context.WithTimeout(context.Background(), 5*time.Second) probeCtx, probeCancel := context.WithTimeout(context.Background(), cfg.probeTimeout)
if err := ollamaClient.Probe(probeCtx); err != nil { if err := ollamaClient.Probe(probeCtx); err != nil {
log.Warn("ollama probe failed", "url", cfg.ollamaURL, "err", err) log.Warn("ollama probe failed", "url", cfg.ollamaURL, "err", err)
} }
@@ -186,7 +223,7 @@ func main() {
log.Info("shutting down") log.Info("shutting down")
} }
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), cfg.shutdownTimeout)
defer shutdownCancel() defer shutdownCancel()
ollamaSrv.Shutdown(shutdownCtx) ollamaSrv.Shutdown(shutdownCtx)
comfySrv.Shutdown(shutdownCtx) comfySrv.Shutdown(shutdownCtx)
+46 -9
View File
@@ -21,10 +21,10 @@ import (
"gpu-turnstile/internal/ollama" "gpu-turnstile/internal/ollama"
) )
// captureLimit bounds how much of a /prompt response body is buffered while // defaultCaptureLimit bounds how much of a /prompt response body is
// looking for prompt_id. The body still passes through to the client // buffered while looking for prompt_id. The body still passes through to
// unchanged regardless of size. // the client unchanged regardless of size.
const captureLimit = 64 * 1024 const defaultCaptureLimit = 64 * 1024
// Config wires a Server. // Config wires a Server.
type Config struct { type Config struct {
@@ -40,6 +40,18 @@ type Config struct {
LLMWaitTimeout time.Duration LLMWaitTimeout time.Duration
UnloadTimeout time.Duration UnloadTimeout time.Duration
JobTimeout time.Duration JobTimeout time.Duration
// UnloadPollInterval and HistoryPollInterval override the clients'
// /api/ps and /history poll intervals when > 0.
UnloadPollInterval time.Duration
HistoryPollInterval time.Duration
// FreeTimeout and WarmTimeout bound the /free call and the warm-model
// reload; zero selects the defaults.
FreeTimeout time.Duration
WarmTimeout time.Duration
// PromptCaptureLimit overrides defaultCaptureLimit when > 0.
PromptCaptureLimit int64
WarmModel string WarmModel string
} }
@@ -47,6 +59,9 @@ type Config struct {
type Server struct { type Server struct {
cfg Config cfg Config
log *slog.Logger log *slog.Logger
freeTimeout time.Duration
warmTimeout time.Duration
captureLimit int64
ollamaProxy *httputil.ReverseProxy ollamaProxy *httputil.ReverseProxy
comfyProxy *httputil.ReverseProxy comfyProxy *httputil.ReverseProxy
@@ -66,9 +81,30 @@ func New(cfg Config) (*Server, error) {
if log == nil { if log == nil {
log = slog.Default() log = slog.Default()
} }
if cfg.UnloadPollInterval > 0 {
cfg.Ollama.PollInterval = cfg.UnloadPollInterval
}
if cfg.HistoryPollInterval > 0 {
cfg.Comfy.PollInterval = cfg.HistoryPollInterval
}
freeTimeout := cfg.FreeTimeout
if freeTimeout <= 0 {
freeTimeout = 30 * time.Second
}
warmTimeout := cfg.WarmTimeout
if warmTimeout <= 0 {
warmTimeout = 2 * time.Minute
}
captureLimit := cfg.PromptCaptureLimit
if captureLimit <= 0 {
captureLimit = defaultCaptureLimit
}
return &Server{ return &Server{
cfg: cfg, cfg: cfg,
log: log, log: log,
freeTimeout: freeTimeout,
warmTimeout: warmTimeout,
captureLimit: captureLimit,
ollamaProxy: newReverseProxy(ollamaURL, log.With("upstream", "ollama")), ollamaProxy: newReverseProxy(ollamaURL, log.With("upstream", "ollama")),
comfyProxy: newReverseProxy(comfyURL, log.With("upstream", "comfy")), comfyProxy: newReverseProxy(comfyURL, log.With("upstream", "comfy")),
}, nil }, nil
@@ -170,11 +206,12 @@ func (s *Server) ComfyHandler() http.Handler {
} }
// captureWriter passes the response through unchanged while recording the // captureWriter passes the response through unchanged while recording the
// status code and the first captureLimit bytes of the body. // status code and the first limit bytes of the body.
type captureWriter struct { type captureWriter struct {
http.ResponseWriter http.ResponseWriter
status int status int
buf bytes.Buffer buf bytes.Buffer
limit int64
} }
func (w *captureWriter) WriteHeader(code int) { func (w *captureWriter) WriteHeader(code int) {
@@ -183,7 +220,7 @@ func (w *captureWriter) WriteHeader(code int) {
} }
func (w *captureWriter) Write(p []byte) (int, error) { func (w *captureWriter) Write(p []byte) (int, error) {
if w.buf.Len() < captureLimit { if int64(w.buf.Len()) < w.limit {
w.buf.Write(p) w.buf.Write(p)
} }
return w.ResponseWriter.Write(p) return w.ResponseWriter.Write(p)
@@ -228,7 +265,7 @@ func (s *Server) handlePrompt(w http.ResponseWriter, r *http.Request) {
log.Info("ollama models unloaded", "seconds", elapsed.Seconds()) log.Info("ollama models unloaded", "seconds", elapsed.Seconds())
} }
cw := &captureWriter{ResponseWriter: w, status: http.StatusOK} cw := &captureWriter{ResponseWriter: w, status: http.StatusOK, limit: s.captureLimit}
s.comfyProxy.ServeHTTP(cw, r) s.comfyProxy.ServeHTTP(cw, r)
var accepted struct { var accepted struct {
@@ -262,7 +299,7 @@ func (s *Server) finishImageJob(promptID string) {
log.Info("image job completed") log.Info("image job completed")
} }
freeCtx, freeCancel := context.WithTimeout(context.Background(), 30*time.Second) freeCtx, freeCancel := context.WithTimeout(context.Background(), s.freeTimeout)
if err := s.cfg.Comfy.Free(freeCtx); err != nil { if err := s.cfg.Comfy.Free(freeCtx); err != nil {
log.Warn("failed to free ComfyUI models", "err", err) log.Warn("failed to free ComfyUI models", "err", err)
} }
@@ -273,7 +310,7 @@ func (s *Server) finishImageJob(promptID string) {
if s.cfg.WarmModel != "" { if s.cfg.WarmModel != "" {
if state, _, _ := s.cfg.Lock.Snapshot(); state == lock.StateIdle { if state, _, _ := s.cfg.Lock.Snapshot(); state == lock.StateIdle {
wctx, wcancel := context.WithTimeout(context.Background(), 2*time.Minute) wctx, wcancel := context.WithTimeout(context.Background(), s.warmTimeout)
if err := s.cfg.Ollama.Warm(wctx, s.cfg.WarmModel); err != nil { if err := s.cfg.Ollama.Warm(wctx, s.cfg.WarmModel); err != nil {
log.Warn("warm model reload failed", "model", s.cfg.WarmModel, "err", err) log.Warn("warm model reload failed", "model", s.cfg.WarmModel, "err", err)
} else { } else {