Skip to content

[Bug] A recovered Batch job past its window with no execution metadata never finishes expiring #3074

Open

Description

@Jackmeson1

🐛 Describe the bug

An in-progress Batch job that is already past its completion window when the metadata service recovers it, and that has no execution metadata (status.execution is None), never finishes expiring. It stays in_progress with the expired condition set, and clients keep seeing in_progress.

On recovery, job_committed_handler (python/aibrix/aibrix/batch/batch_manager.py:554 at 99325e7) does not schedule overdue work and calls expire_job instead. For an in-progress job, expire_job (line 1184) persists the expired condition and leaves finalization to the live driver it then signals. A recovered job has no driver. The orphan cleanup path that would finish it runs only when job_expired.status.execution is not None (line 1258). Every later expiry pass from the scheduler then returns early, because the job is already IN_PROGRESS with EXPIRED (line 1215). Nothing else owns the job, so it never becomes terminal.

The same recovery with execution = {} (not None) does finish expired, and an overdue created job finalizes expired as expected (batch-api.rst:147 documents expiry after the window).

Steps to Reproduce

From python/aibrix at 99325e7, put this in tests/batch/ and run it with pytest:

test_overdue_recovery.py
import os
from datetime import datetime, timedelta, timezone

import pytest

os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")

from aibrix.batch.batch_manager import BatchManager
from aibrix.batch.job_entity import (
    BatchJob,
    BatchJobEndpoint,
    BatchJobSpec,
    BatchJobState,
    BatchJobStatus,
    CompletionWindow,
    ConditionType,
    ObjectMeta,
    TypeMeta,
)
from aibrix.context import InfrastructureContext
from aibrix.metadata.api.v1.batch import _batch_job_to_openai_response


class _Scheduler:
    def __init__(self):
        self.appended = []

    def append_job(self, job_id):
        self.appended.append(job_id)


def _overdue_in_progress_job(job_id, execution):
    created = datetime.now(timezone.utc) - timedelta(hours=25)
    job = BatchJob(
        typeMeta=TypeMeta(apiVersion="batch/v1", kind="Job"),
        metadata=ObjectMeta(
            name=f"job-{job_id}", namespace="default", uid=f"uid-{job_id}",
            creationTimestamp=created, resourceVersion=None, deletionTimestamp=None,
        ),
        spec=BatchJobSpec(
            input_file_id=f"input-{job_id}",
            endpoint=BatchJobEndpoint.CHAT_COMPLETIONS.value,
            completion_window=CompletionWindow.TWENTY_FOUR_HOURS.expires_at(),
        ),
        status=BatchJobStatus(
            jobID=job_id, state=BatchJobState.IN_PROGRESS,
            createdAt=created, inProgressAt=created,
        ),
    )
    job.status.request_counts.total = 1
    job.status.execution = execution
    return job


@pytest.mark.asyncio
@pytest.mark.parametrize("execution", [None, {}], ids=["execution-none", "execution-empty"])
async def test_recovered_overdue_in_progress_job_expires(execution):
    manager = BatchManager(InfrastructureContext())
    scheduler = _Scheduler()
    manager.set_scheduler(scheduler)
    job = _overdue_in_progress_job(f"overdue-{execution is None}", execution)

    await manager.job_committed_handler(job)  # recovery after a restart
    await manager.expire_job(job.job_id)  # the scheduler's next expiry pass

    assert scheduler.appended == []  # overdue work is not scheduled
    current = manager._done_jobs.get(job.job_id) or await manager.get_job(job.job_id)
    assert current.status.state == BatchJobState.FINALIZED, (
        f"state={current.status.state.value} condition={current.status.condition} "
        f"client={_batch_job_to_openai_response(current).status}"
    )
    assert current.status.condition == ConditionType.EXPIRED
    assert _batch_job_to_openai_response(current).status == "expired"

execution-empty passes. execution-none fails with:

AssertionError: state=in_progress condition=expired client=in_progress

The test calls the recovery handler and one more expiry pass directly; it does not restart a process against durable storage.

Expected behavior

A recovered job past its window finishes expired (state finalized, condition expired, client status expired), the same as when it has an empty execution record or was still created.

Environment

  • AIBrix version: main at 99325e7
  • Deployment environment: Batch API, metadata service recovery; reproduced in-process on Linux, Python 3.10.12
  • LLM(s) being used: none

Area

Batch

I don't plan to send a fix for this soon, so anyone is welcome to take it. A fix probably belongs in expire_job: route a recovered job that has no live driver and no execution reference through the existing stopped-job finalization, without stamping FINALIZED directly and so bypassing output finalization.

This report was prepared with AI assistance (Claude Code); the test above was run against main.

Activity

  1. github-actions commented on Oct 14, 2026

    @github-actions

    Thanks for contributing to AIBrix! Please review the contribution guide and make sure this issue contains enough context for maintainers to reproduce or evaluate it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions