Skip to content

Commit 975df6a

Browse files
authored
fix: remove SIGTERM re-enqueue and make heartbeat interval configurable (llm-d#536)
Remove the processor's SIGTERM re-enqueue logic (errShutdown handler) and delegate crash recovery entirely to the orphan reconciler. This resolves the TODO in the codebase and simplifies the shutdown path. Make heartbeat_interval configurable so dev-deploy can set it shorter than reconciler.interval, preventing the reconciler from falsely marking active jobs as orphaned during e2e tests. Update ProcessorGracefulShutdown and OrphanRecovery e2e tests to expect reconciler-driven failure instead of re-enqueue completion. Increase e2e test timeout to 20m for reconciler wait time. Fixes llm-d#532 Signed-off-by: Jooyeon Mok <jmok@redhat.com>
1 parent b0bea72 commit 975df6a

16 files changed

Lines changed: 209 additions & 245 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ test-e2e:
385385
@echo "Running E2E tests..."
386386
@OUT=$$(mktemp); \
387387
echo "Processor observability endpoint: auto-resolved by the e2e test helpers"; \
388-
cd test/e2e && $(GO) test -v -count=1 $(if $(TEST_RUN),-run $(TEST_RUN)) ./... 2>&1 | tee $$OUT; \
388+
cd test/e2e && $(GO) test -v -count=1 -timeout=20m $(if $(TEST_RUN),-run $(TEST_RUN)) ./... 2>&1 | tee $$OUT; \
389389
TEST_EXIT=$${PIPESTATUS[0]}; \
390390
PASS_COUNT=$$(grep -- '--- PASS:' $$OUT 2>/dev/null | wc -l | tr -d ' '); \
391391
FAIL_COUNT=$$(grep -- '--- FAIL:' $$OUT 2>/dev/null | wc -l | tr -d ' '); \

charts/batch-gateway/templates/processor-configmap.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ data:
2525
2626
terminate_on_observability_failure: {{ .Values.processor.config.terminateOnObservabilityFailure }}
2727
shutdown_timeout: {{ .Values.processor.config.shutdownTimeout | quote }}
28+
heartbeat_interval: {{ .Values.processor.config.heartbeatInterval | quote }}
2829
work_dir: {{ .Values.processor.config.workDir | quote }}
2930
3031
db_client:

charts/batch-gateway/values.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ processor:
344344
count: 12
345345
terminateOnObservabilityFailure: false
346346
shutdownTimeout: "30s"
347+
heartbeatInterval: "5m"
347348
workDir: "/var/lib/batch-gateway/processor"
348349
# REQUIRED: Configure exactly one of globalInferenceGateway or modelGateways.
349350
# The processor will fail to start if neither is set.

cmd/batch-processor/config.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ terminate_on_observability_failure: false
3434
# Shutdown timeout for processor (should be less than k8s terminationGracePeriodSeconds)
3535
shutdown_timeout: "30s"
3636

37+
# Heartbeat interval for in-flight entry refresh.
38+
# Must be shorter than the GC reconciler's interval so active jobs are not
39+
# mistaken for orphans. Default: 5m (matches GC default of 60m).
40+
heartbeat_interval: "5m"
41+
3742
# Work directory for processor
3843
work_dir: "/var/lib/batch-gateway/processor"
3944

internal/processor/config/config.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,14 @@ type ProcessorConfig struct {
167167
// FileClient holds configuration for the shared file storage client (fs or s3).
168168
FileClientCfg sharedcfg.FileClientConfig `yaml:"file_client"`
169169

170+
// HeartbeatInterval controls how often the processor refreshes its in-flight
171+
// entry for a running job. The orphan reconciler uses staleness (no heartbeat
172+
// for > reconciler interval) to detect abandoned jobs.
173+
// Must be shorter than the reconciler's interval so that active jobs are
174+
// never mistaken for orphans.
175+
// Zero means use the default (5 minutes).
176+
HeartbeatInterval time.Duration `yaml:"heartbeat_interval"`
177+
170178
// DispatchMode selects the inference dispatch backend.
171179
// "sync" (default): direct HTTP via InferenceClient.
172180
// "async": submit via llm-d-async producer, collect from result queue.
@@ -321,7 +329,8 @@ func NewConfig() *ProcessorConfig {
321329
DefaultOutputExpirationSeconds: 90 * 24 * 60 * 60, // 90 days
322330
ProgressTTLSeconds: 24 * 60 * 60, // 24 hours
323331

324-
DispatchMode: DispatchModeSync,
332+
HeartbeatInterval: 5 * time.Minute,
333+
DispatchMode: DispatchModeSync,
325334
AsyncDispatchConfig: AsyncDispatchConfig{
326335
ResultPollTimeout: 5 * time.Second,
327336
},
@@ -349,6 +358,9 @@ func (c *ProcessorConfig) Validate() error {
349358
if c.ShutdownTimeout <= 0 {
350359
return fmt.Errorf("shutdown_timeout must be > 0")
351360
}
361+
if c.HeartbeatInterval <= 0 {
362+
return fmt.Errorf("heartbeat_interval must be > 0")
363+
}
352364
if c.Addr == "" {
353365
return fmt.Errorf("addr cannot be empty")
354366
}

internal/processor/worker/errors.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ var (
3535
errRequestInputRead = errors.New("failed to read request from input file")
3636

3737
// errShutdown signals that the processor is shutting down (SIGTERM).
38-
// The job is re-enqueued so another worker can pick it up.
38+
// The job is left in its current non-terminal state for the orphan
39+
// reconciler to detect and transition to a terminal state.
3940
errShutdown = errors.New("processor shutting down")
4041

4142
// errFinalizeFailedOver signals that a terminal status transition (completed,

internal/processor/worker/executor.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,8 @@ func (ep *executionProgress) counts() *openai.BatchRequestCounts {
168168
// - SLO expired: (counts, errExpired) — undispatched drained as batch_expired
169169
// - User cancel: (counts, errCancelled) — undispatched drained as batch_cancelled
170170
// - System error: (counts, firstErr) — undispatched drained as batch_failed
171-
// - Pod shutdown: (counts, errShutdown) — caller re-enqueues; counts reflect work done
172-
// before SIGTERM, flush preserves partial output for startup recovery
171+
// - Pod shutdown: (counts, errShutdown) — job left for orphan reconciler; counts reflect
172+
// work done before SIGTERM
173173
//
174174
// requestAbortCtx controls the dispatch loop and all in-flight inference calls: cancelling it
175175
// stops dispatch and aborts in-flight requests. It is derived from sloCtx in runJob, so SLO
@@ -600,10 +600,10 @@ dispatch:
600600
default:
601601
if mainCtx.Err() != nil && (len(undispatched) > 0 || shutdownCancelled.Load() > 0) {
602602
// Pod shutdown (SIGTERM): main processor context is cancelled.
603-
// Do not drain here — the re-enqueued worker will process the
604-
// entire job from scratch. The undispatched check catches SIGTERM
605-
// arriving during dispatch; shutdownCancelled catches SIGTERM
606-
// cancelling already-dispatched in-flight requests.
603+
// Do not drain here — the job will be left for the orphan
604+
// reconciler to transition to a terminal state. The undispatched
605+
// check catches SIGTERM arriving during dispatch; shutdownCancelled
606+
// catches SIGTERM cancelling already-dispatched in-flight requests.
607607
returnErr = errShutdown
608608
} else if requestAbortCtx.Err() != nil && len(undispatched) > 0 {
609609
// Sibling model abort: requestAbortCtx was cancelled by another

internal/processor/worker/executor_test.go

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,8 +1089,8 @@ func TestProcessModel_ContextCancelledDuringDispatch(t *testing.T) {
10891089

10901090
// TestProcessModel_SIGTERMCancelsAllDispatched verifies that when SIGTERM arrives after all
10911091
// requests have been dispatched as goroutines, processModel returns errShutdown so the job
1092-
// is re-enqueued. This covers the case where undispatched is empty but in-flight requests
1093-
// were cancelled by context propagation.
1092+
// is left for the orphan reconciler. This covers the case where undispatched is empty but
1093+
// in-flight requests were cancelled by context propagation.
10941094
func TestProcessModel_SIGTERMCancelsAllDispatched(t *testing.T) {
10951095
cfg := config.NewConfig()
10961096
cfg.WorkDir = t.TempDir()
@@ -1142,8 +1142,8 @@ func TestProcessModel_SIGTERMCancelsAllDispatched(t *testing.T) {
11421142
//
11431143
// P1c regression: Before the fix, the drain-switch default checked ctx.Err() (which is
11441144
// requestAbortCtx), so a sibling abort looked like a pod shutdown and returned errShutdown.
1145-
// executeJob would then route the job to re-enqueue instead of failed-with-partial, breaking
1146-
// retry safety for batches with partial results. After the fix, the default checks mainCtx
1145+
// executeJob would then route the job to the errShutdown path instead of failed-with-partial,
1146+
// breaking retry safety for batches with partial results. After the fix, the default checks mainCtx
11471147
// (the main processor context), which is only cancelled on SIGTERM.
11481148
func TestProcessModel_SiblingAbort_ReturnsNil(t *testing.T) {
11491149
cfg := config.NewConfig()
@@ -1422,7 +1422,7 @@ func TestExecuteJob_CancelAfterAllRequestsComplete(t *testing.T) {
14221422
// TestExecuteJob_SIGTERMAfterAllComplete verifies that when all requests finish successfully
14231423
// and SIGTERM arrives before executeJob returns, the function returns nil (not errShutdown).
14241424
// This ensures the caller proceeds to finalizeJob (which uses a detached context) rather than
1425-
// re-enqueueing a fully-complete job.
1425+
// taking the errShutdown path (which leaves the job for the orphan reconciler).
14261426
func TestExecuteJob_SIGTERMAfterAllComplete(t *testing.T) {
14271427
cfg := config.NewConfig()
14281428
cfg.WorkDir = t.TempDir()
@@ -2105,7 +2105,7 @@ func TestHandleJobError_errCancelled(t *testing.T) {
21052105
}
21062106
}
21072107

2108-
func TestHandleJobError_Shutdown_ReEnqueues(t *testing.T) {
2108+
func TestHandleJobError_Shutdown_LeavesJobForReconciler(t *testing.T) {
21092109
cfg := config.NewConfig()
21102110
cfg.WorkDir = t.TempDir()
21112111

@@ -2118,8 +2118,6 @@ func TestHandleJobError_Shutdown_ReEnqueues(t *testing.T) {
21182118
BatchJob: &openai.Batch{BatchSpec: openai.BatchSpec{CreatedAt: time.Now().Add(-10 * time.Second).Unix()}},
21192119
}
21202120

2121-
beforeFailed := gatherHistogramSampleCount(t, "batch_job_e2e_latency_seconds", map[string]string{"status": "failed"})
2122-
21232121
ctx := testLoggerCtx(t)
21242122
env.p.handleJobError(ctx, &jobExecutionParams{
21252123
updater: env.updater,
@@ -2128,17 +2126,26 @@ func TestHandleJobError_Shutdown_ReEnqueues(t *testing.T) {
21282126
jobInfo: ji,
21292127
}, errShutdown)
21302128

2131-
afterFailed := gatherHistogramSampleCount(t, "batch_job_e2e_latency_seconds", map[string]string{"status": "failed"})
2132-
if delta := afterFailed - beforeFailed; delta != 0 {
2133-
t.Fatalf("E2E latency failed: delta=%d, want 0 (re-enqueue succeeded, not terminal)", delta)
2134-
}
2135-
2129+
// Job must NOT be re-enqueued — reconciler handles recovery.
21362130
tasks, err := env.pqClient.PQDequeue(ctx, 0, 10)
21372131
if err != nil {
21382132
t.Fatalf("PQDequeue: %v", err)
21392133
}
2140-
if len(tasks) == 0 {
2141-
t.Fatalf("expected re-enqueued task, got none")
2134+
if len(tasks) != 0 {
2135+
t.Fatalf("expected no re-enqueued tasks, got %d", len(tasks))
2136+
}
2137+
2138+
// Job status must remain in_progress (not transitioned by the processor).
2139+
items, _, _, err := env.dbClient.DBGet(ctx, &db.BatchQuery{BaseQuery: db.BaseQuery{IDs: []string{"job-ctx"}}}, true, 0, 1)
2140+
if err != nil || len(items) != 1 {
2141+
t.Fatalf("DBGet: err=%v len=%d", err, len(items))
2142+
}
2143+
var got openai.BatchStatusInfo
2144+
if err := json.Unmarshal(items[0].Status, &got); err != nil {
2145+
t.Fatalf("unmarshal status: %v", err)
2146+
}
2147+
if got.Status != openai.BatchStatusInProgress {
2148+
t.Fatalf("status = %s, want %s (unchanged, left for reconciler)", got.Status, openai.BatchStatusInProgress)
21422149
}
21432150
}
21442151

internal/processor/worker/job_runner.go

Lines changed: 11 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,9 @@ import (
4747
// Declared as var (not const) so tests can shorten it.
4848
var panicRecoveryTimeout = time.Minute
4949

50-
// heartbeatInterval controls how often the processor refreshes its in-flight
51-
// entry for a running job. The orphan reconciler uses staleness (no heartbeat
52-
// for > reconciler interval) to detect abandoned jobs.
53-
// Declared as var (not const) so tests can shorten it.
54-
var heartbeatInterval = 5 * time.Minute
50+
// defaultHeartbeatInterval is the fallback heartbeat interval used when
51+
// the config value is zero. Matches ProcessorConfig.HeartbeatInterval default.
52+
const defaultHeartbeatInterval = 5 * time.Minute
5553

5654
func (p *Processor) runJob(ctx context.Context, params *jobExecutionParams) {
5755
// Clean up in-flight entry on exit (first defer = last to run via LIFO),
@@ -323,35 +321,15 @@ func (p *Processor) handleJobError(ctx context.Context, params *jobExecutionPara
323321
}
324322

325323
case errors.Is(err, errShutdown):
326-
// SIGTERM received — re-enqueue so another worker can pick up the job.
327-
// Use a detached context because ctx is already cancelled.
324+
// SIGTERM received — leave the job in its current state for the orphan
325+
// reconciler to handle. The reconciler detects non-terminal jobs that
326+
// are not in the queue and have a stale (or missing) in-flight entry,
327+
// then transitions them to a terminal state (failed, cancelled, etc.).
328328
//
329-
// Known limitation: there is no way at SIGTERM time to distinguish a container
330-
// restart (emptyDir survives, startup recovery can upload partial output) from a
331-
// pod deletion (emptyDir destroyed, startup recovery cannot help). Re-enqueueing
332-
// is therefore unconditional, which introduces a known race: if this was a
333-
// container restart, startup recovery and the worker that picks up the re-enqueued
334-
// job compete — startup recovery may mark the job failed while another worker
335-
// runs it fresh.
336-
// This race is accepted as a known limit until orphan reconciliation is
337-
// implemented. Once it is, re-enqueue should be removed here and pod-deletion
338-
// recovery delegated to the reconciler. (TODO: orphan reconciliation task)
339-
if params.task != nil {
340-
bgCtx, bgSpan := uotel.DetachedContext(ctx, "re-enqueue")
341-
defer bgSpan.End()
342-
if enqErr := p.poller.enqueueOne(bgCtx, params.task); enqErr != nil {
343-
logger.Error(enqErr, "Failed to re-enqueue the job to the queue")
344-
// executeJob flushed partial output/error files to disk before returning
345-
// errShutdown. Upload them so the user can retrieve whatever completed
346-
// before SIGTERM, rather than losing those results silently.
347-
if failErr := p.handleFailed(bgCtx, params.updater, params.jobItem, params.requestCounts, params.jobInfo); failErr != nil {
348-
logger.Error(failErr, "Failed to mark job as failed after re-enqueue failure")
349-
}
350-
} else {
351-
metrics.RecordJobProcessed(metrics.ResultReEnqueued, metrics.ReasonSystemError)
352-
logger.V(logging.INFO).Info("Re-enqueued the job to the queue")
353-
}
354-
}
329+
// The in-flight entry is cleaned up by the defer at the top of runJob.
330+
// If the process is killed (SIGKILL) before that defer runs, the entry
331+
// remains and becomes stale, which the reconciler also handles.
332+
logger.V(logging.INFO).Info("SIGTERM received, leaving job for orphan reconciler")
355333

356334
default:
357335
if failErr := p.handleFailed(ctx, params.updater, params.jobItem, params.requestCounts, params.jobInfo); failErr != nil {

internal/processor/worker/job_runner_test.go

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -732,11 +732,10 @@ func TestRunJob_FinalizeFailedOver_PreservesFileIDsAndDoesNotCallHandleFailed(t
732732
}
733733
}
734734

735-
// TestHandleJobError_Shutdown_ReEnqueueFails_UploadsPartialOutput verifies that when
736-
// errShutdown triggers a re-enqueue that fails, the fallback uploads partial output files
737-
// and preserves their file IDs in the DB. Before this fix, the fallback called handleFailed
738-
// with nil counts and empty file IDs, losing already-flushed results.
739-
func TestHandleJobError_Shutdown_ReEnqueueFails_UploadsPartialOutput(t *testing.T) {
735+
// TestHandleJobError_Shutdown_LeavesJobInProgress verifies that errShutdown
736+
// does NOT re-enqueue or call handleFailed. The job stays in_progress for
737+
// the orphan reconciler to detect and transition to a terminal state.
738+
func TestHandleJobError_Shutdown_LeavesJobInProgress(t *testing.T) {
740739
ctx := testLoggerCtx(t)
741740

742741
cfg := config.NewConfig()
@@ -745,7 +744,7 @@ func TestHandleJobError_Shutdown_ReEnqueueFails_UploadsPartialOutput(t *testing.
745744

746745
dbClient := newMockBatchDBClient()
747746
statusClient := mockdb.NewMockBatchStatusClient()
748-
pqClient := &errPQClient{err: errors.New("queue unavailable")}
747+
pqClient := mockdb.NewMockBatchPriorityQueueClient()
749748

750749
p := mustNewProcessor(t, cfg, &clientset.Clientset{
751750
BatchDB: dbClient,
@@ -758,7 +757,7 @@ func TestHandleJobError_Shutdown_ReEnqueueFails_UploadsPartialOutput(t *testing.
758757
})
759758
p.poller = NewPoller(pqClient, dbClient)
760759

761-
jobID := "job-shutdown-enqueue-fail"
760+
jobID := "job-shutdown-no-reenqueue"
762761
tenantID := "tenant__tenantA"
763762

764763
jobItem := &db.BatchItem{
@@ -774,19 +773,6 @@ func TestHandleJobError_Shutdown_ReEnqueueFails_UploadsPartialOutput(t *testing.
774773
jobInfo := &batch_types.JobInfo{JobID: jobID, TenantID: tenantID}
775774
counts := &openai.BatchRequestCounts{Total: 5, Completed: 3, Failed: 2}
776775

777-
jobDir, _ := p.jobRootDir(jobID, tenantID)
778-
if err := os.MkdirAll(jobDir, 0o755); err != nil {
779-
t.Fatalf("MkdirAll: %v", err)
780-
}
781-
outputPath := filepath.Join(jobDir, "output.jsonl")
782-
if err := os.WriteFile(outputPath, []byte(`{"custom_id":"r1"}`+"\n"), 0o644); err != nil {
783-
t.Fatalf("WriteFile output: %v", err)
784-
}
785-
errorPath := filepath.Join(jobDir, "error.jsonl")
786-
if err := os.WriteFile(errorPath, []byte(`{"custom_id":"e1"}`+"\n"), 0o644); err != nil {
787-
t.Fatalf("WriteFile error: %v", err)
788-
}
789-
790776
updater := NewStatusUpdater(dbClient, statusClient, 86400)
791777
params := &jobExecutionParams{
792778
updater: updater,
@@ -806,14 +792,7 @@ func TestHandleJobError_Shutdown_ReEnqueueFails_UploadsPartialOutput(t *testing.
806792
if err := json.Unmarshal(items[0].Status, &got); err != nil {
807793
t.Fatalf("unmarshal: %v", err)
808794
}
809-
if got.Status != openai.BatchStatusFailed {
810-
t.Fatalf("status = %s, want failed", got.Status)
811-
}
812-
if got.RequestCounts.Total != 5 || got.RequestCounts.Completed != 3 || got.RequestCounts.Failed != 2 {
813-
t.Fatalf("request_counts = %+v, want {5,3,2}", got.RequestCounts)
814-
}
815-
// handleFailed uploads partial results when jobInfo is non-nil; at least one file ID should be present.
816-
if got.OutputFileID == nil && got.ErrorFileID == nil {
817-
t.Fatal("expected at least one file ID to be preserved from partial upload")
795+
if got.Status != openai.BatchStatusInProgress {
796+
t.Fatalf("status = %s, want in_progress (left for reconciler)", got.Status)
818797
}
819798
}

0 commit comments

Comments
 (0)