Skip to content

Commit 73baa57

Browse files
committed
fix(jobs): initialize the queue table inside worker processes
Every worker process died immediately on startup with KeyError: 'jobs', so no job was ever leased. The blocking process_file endpoint then polled a job nobody would run until its 1800s timeout, which is what hung the docling integration job for the full 300s client timeout. SqlStore learns a table's schema only by declaring it via create_table(); the server does that at startup, but a spawned worker is a fresh process with empty SQLAlchemy metadata, so its first fetch_all() raised on the table name. The worker now calls queue.initialize() before entering the lease loop. create_table() is idempotent, so this attaches to the server's existing table rather than recreating it. This was invisible because the crash handler logged exc_info=True, which the structlog logger renders as a literal field rather than a traceback, reducing a dead worker pool to one context-free line. The handler now formats the traceback into the message, since the parent process only ever sees an exit code. Verified end-to-end against a real spawned worker with file_processors=inline::pypdf: the deprecated blocking endpoint returns chunks, and the async submit/poll/list endpoints reach completed. Signed-off-by: Charlie Doern <cdoern@redhat.com>
1 parent e666431 commit 73baa57

2 files changed

Lines changed: 42 additions & 2 deletions

File tree

src/ogx/core/jobs/worker.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import multiprocessing as mp
2121
import os
2222
import socket
23+
import traceback
2324
from multiprocessing.synchronize import Event as EventType
2425

2526
from pydantic import BaseModel, Field
@@ -103,6 +104,10 @@ async def _run_worker(config: WorkerConfig, stop_event: EventType) -> None:
103104
register_sqlstore_backends(config.backends)
104105
store = await get_system_sqlstore(SqlStoreReference(backend=config.jobs_backend, table_name=config.jobs_table))
105106
queue = JobQueue(store, config.jobs_table, config.lease_ttl_seconds)
107+
# The table already exists (the server created it), but SqlStore only learns a
108+
# table's schema by declaring it, and this is a fresh process with empty
109+
# metadata. Without this every query raises KeyError on the table name.
110+
await queue.initialize()
106111

107112
impls: dict[str, object] = {}
108113
for descriptor in config.descriptors:
@@ -129,8 +134,10 @@ def _worker_main(config: WorkerConfig, stop_event: EventType) -> None:
129134
"""Process entrypoint (must be module-level for the spawn start method)."""
130135
try:
131136
asyncio.run(_run_worker(config, stop_event))
132-
except Exception:
133-
logger.error("Worker process crashed", exc_info=True)
137+
except Exception as e:
138+
# The traceback is formatted into the message because this is the only
139+
# record of a child's death: the parent sees an exit code, not the error.
140+
logger.error("Worker process crashed", error=str(e), traceback=traceback.format_exc())
134141

135142

136143
class WorkerPool:

tests/unit/core/jobs/test_queue.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88

99
import time
1010

11+
import pytest
12+
1113
from ogx.core.jobs.queue import JobQueue
14+
from ogx.core.storage.datatypes import SqliteSqlStoreConfig
15+
from ogx.core.storage.sqlstore.sqlalchemy_sqlstore import SqlAlchemySqlStoreImpl
1216
from ogx_api.common.job_types import JobStatus
1317

1418

@@ -150,3 +154,32 @@ async def test_list_filters_by_api(queue: JobQueue):
150154

151155
listed = await queue.list(api="file_processors")
152156
assert {r.job_id for r in listed} == {first.job_id, second.job_id}
157+
158+
159+
async def test_second_process_must_initialize_before_querying(tmp_path):
160+
"""A worker attaches to the server's existing table with its own empty metadata.
161+
162+
SqlStore learns a table's schema only by declaring it, so a fresh process must
163+
call initialize() before any query — otherwise every operation raises KeyError
164+
on the table name. This is what the worker loop relies on at startup.
165+
"""
166+
db_path = str(tmp_path / "shared-jobs.db")
167+
168+
server_store = SqlAlchemySqlStoreImpl(SqliteSqlStoreConfig(db_path=db_path))
169+
server_queue = JobQueue(server_store, table_name="jobs", lease_ttl_seconds=60)
170+
await server_queue.initialize()
171+
record = await _enqueue(server_queue)
172+
173+
# A distinct store over the same DB stands in for the worker process.
174+
worker_store = SqlAlchemySqlStoreImpl(SqliteSqlStoreConfig(db_path=db_path))
175+
worker_queue = JobQueue(worker_store, table_name="jobs", lease_ttl_seconds=60)
176+
with pytest.raises(KeyError):
177+
await worker_queue.lease("worker-A")
178+
179+
await worker_queue.initialize()
180+
leased = await worker_queue.lease("worker-A")
181+
assert leased is not None
182+
assert leased.job_id == record.job_id
183+
184+
await server_store.shutdown()
185+
await worker_store.shutdown()

0 commit comments

Comments
 (0)