Skip to content

Compression scheduler: poll_running_jobs sleeps ~0.5s per in-flight job, delaying dispatch of new jobs #2387

Description

@goynam

Summary

The compression scheduler's main loop is single threaded and alternates between poll_running_jobs() and search_and_schedule_new_tasks(). poll_running_jobs() calls ResultHandle.get_result() serially for every in-flight job. For any job whose tasks are not complete yet, the call sleeps 0.5s (the celery default poll interval) before returning None, so one scheduler cycle takes roughly 0.5s x N for N in-flight jobs, and new PENDING jobs are not dispatched until the current cycle finishes.

On our production deployment with 150-190 jobs in flight, scheduler cycles take 75-95s and dispatch latency (creation_time -> start_time) is 140-330s. Jobs sit undispatched for 2-5 minutes while the celery queue is empty and workers are idle. The delay scales linearly with the number of concurrent jobs, so deployments with many datasets are hit hardest.

Environment

  • clp-package 0.12.0 lineage, CLP_S storage engine
  • CeleryTaskManager with Redis result backend, RabbitMQ broker
  • celery 5.6.3, kombu 5.6.2, redis-py 6.4.0
  • ~50 datasets ingesting concurrently, 300 celery workers
  • jobs_poll_delay: 0.1, max_concurrent_tasks_per_job: 0

Root cause

components/job-orchestration/job_orchestration/scheduler/compress/task_manager/celery_task_manager.py:

def get_result(self, timeout: float = 0.1) -> list[CompressionTaskResult] | None:
    try:
        results = self._celery_result.get(timeout=timeout)
        return [CompressionTaskResult.model_validate(res) for res in results]
    except celery.exceptions.TimeoutError:
        return None

timeout=0.1 does not bound the wall time. With celery 5.6.3 and the Redis result backend the call path is:

GroupResult.get(timeout=0.1) -> ResultSet.join_native (Redis has supports_native_join = True) -> iter_native -> AsyncBackendMixin._wait_for_pending -> BaseResultConsumer._wait_for_pending -> Redis on_wait_for_pending -> result._iter_meta() -> BaseKeyValueStoreBackend.get_many(ids, timeout=0.1, interval=0.5, max_iterations=1).

In celery/backends/base.py::get_many(), when any task in the group is not READY after the mget:

if timeout and iterations * interval >= timeout:   # 0 * 0.5 >= 0.1 is False on the first pass
    raise TimeoutError(f'Operation timed out ({timeout})')
if on_interval:
    on_interval()
time.sleep(interval)  # don't busy loop.
iterations += 1
if max_iterations and iterations >= max_iterations:
    break

The timeout check compares iterations * interval rather than elapsed time and runs before the sleep with iterations == 0, so it never fires on the first pass; the 0.5s sleep always happens. interval is the default from ResultSet.get() and is never overridden by the caller. After get_many returns, Drainer.drain_events_until raises socket.timeout, which _wait_for_pending converts to the celery.exceptions.TimeoutError that get_result() catches. Total cost per incomplete job is the 0.5s sleep plus one Redis mget plus a short pubsub drain, which matches the ~0.504s spacing in our scheduler logs.

compression_scheduler.py::poll_running_jobs() then does this serially:

for job_id, job in scheduled_jobs.items():
    returned_results = job.result_handle.get_result()
    if returned_results is None:
        continue

Cycle cost is about 0.5s per incomplete job. search_and_schedule_new_tasks() only runs between sweeps, so dispatch latency for a new job is 1-3 full cycles.

Evidence

  • Scheduler INFO logs show job completion processing spaced at almost exactly the poll interval: 05:21:43.446 -> 05:21:43.950 -> 05:21:45.214 -> 05:21:45.719 -> 05:21:46.224 (~0.50s per still-running job in between).
  • compression_jobs rows show TIMESTAMPDIFF(SECOND, creation_time, start_time) of 141/234/328s, stepping in ~95s increments, i.e. quantized by scheduler cycle length (190 in-flight jobs x 0.5s).
  • The RabbitMQ compression queue is empty and consumers are idle during these waits, so this is not worker capacity. No scheduler DB statement exceeded 1s in sampled processlist output.

Impact

  • Adds 2-5 minutes of ingestion latency to every job, regardless of job size.
  • Completion bookkeeping (SUCCEEDED status, duration) lags by the same amount, which skews job latency metrics.
  • The effect compounds during backlog catch-up: more jobs in flight means longer cycles means longer dispatch waits.
  • Configurations that produce smaller, more frequent jobs increase N and make the cycle proportionally worse.

Suggested fix

Check readiness before collecting results:

def get_result(self, timeout: float = 0.1) -> list[CompressionTaskResult] | None:
    if not self._celery_result.ready():
        return None
    results = self._celery_result.get(timeout=timeout)
    return [CompressionTaskResult.model_validate(res) for res in results]

GroupResult.ready() is a plain backend read with no sleep (sub-ms per task on Redis). The poll sweep over ~190 jobs drops from ~95s to milliseconds and dispatch latency collapses to roughly jobs_poll_delay. The get() after a successful ready() returns immediately. Passing interval=0.01 to .get() would also work, but ready() avoids raising and catching a TimeoutError per job on every cycle.

Running polling and dispatch on separate threads would remove the coupling entirely, but it needs a second DB connection (DbContext wraps a connection that is not thread safe), locking around the shared scheduled_jobs dict, and changes to the SIGTERM drain logic. With the ready() check the sweep is milliseconds, so I don't think that's warranted.

Note that when max_concurrent_tasks_per_job > 0, _dispatch_next_task_batch() is gated on the same sweep, so intra-job batch turnaround inherits the same latency. The ready() check fixes that path as well.

Repro

  1. Run enough concurrent compression jobs that 100+ are in RUNNING simultaneously (many datasets or long-running jobs).
  2. Observe creation_time -> start_time on newly inserted jobs grow linearly with the in-flight job count (~0.5s each) despite an empty broker queue and idle workers.
  3. Add the ready() check; dispatch latency drops to ~jobs_poll_delay.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions