Pipeline Scheduling and Concurrency#
Overview#
DevLake's pipeline scheduler manages how many data-collection pipelines run concurrently and in what order they are dequeued. The core loop lives in RunPipelineInQueue inside backend/server/services/pipeline.go. It combines three complementary mechanisms:
- Global concurrency cap β
PIPELINE_MAX_PARALLEL+ a weighted Go semaphore limits the total number of simultaneously running pipelines. - Label-based mutual exclusion β pipelines tagged with the same
parallel/<group>label are never run at the same time, regardless of available slots. - Priority ordering β pipelines carry a numeric
Priorityfield; higher values are scheduled first (added in PR #8534), with a follow-up fix for starvation fromparallel/label interaction (PR #8568).
Concurrency: PIPELINE_MAX_PARALLEL and the Semaphore#
PIPELINE_MAX_PARALLEL is read at startup in pipelineServiceInit(). Its default value is 1 , meaning pipelines run serially by default. Special cases :
- Negative β immediate panic.
0β treated as10000(effectively unlimited), with a warning logged.
If the CONSUME_PIPELINES env var is true, the service starts a goroutine running RunPipelineInQueue(pipelineMaxParallel).
Inside RunPipelineInQueue, a semaphore.NewWeighted (from golang.org/x/sync/semaphore) is initialized with pipelineMaxParallel as its capacity. The main loop :
- Acquires one semaphore unit (
sema.Acquire(..., 1)) β blocks until a slot is free. - Polls
dequeuePipelineevery second until a runnable pipeline is found. - Spawns a goroutine that executes the pipeline and calls
sema.Release(1)on completion viadefer.
This design means the semaphore limits the number of pipelines in flight, not the number of goroutines waiting.
Label-Based Mutual Exclusion: parallel/ Prefix#
Pipelines can carry arbitrary labels stored in _devlake_pipeline_labels. Labels whose names start with parallel/ are treated as concurrency groups: two pipelines sharing the same parallel/<group> label are never scheduled simultaneously, even if global semaphore slots are available.
How it works in dequeuePipeline:
- The scheduler maintains a
runningParallelLabels []stringslice β the union of allparallel/-prefixed labels from currently executing pipelines . - The dequeue query does a LEFT JOIN on
_devlake_pipeline_labelsfor rows whosename LIKE 'parallel/%'ANDname IN runningParallelLabels, then usesHAVING count(...)=0to skip any pipeline that shares a label with a running peer . - When a pipeline starts, its
parallel/labels are appended torunningParallelLabelsunder a mutex . They are removed viadeferwhen the pipeline finishes .
Labels are persisted to _devlake_pipeline_labels during CreateDbPipeline.
Use case: Assign parallel/project-a to all pipelines for a given project to guarantee they never overlap, while still allowing pipelines for other projects to run concurrently.
Priority-Based Scheduling#
PR #8534 introduced a Priority int field on both the Pipeline and Blueprint models. Higher values mean higher priority. The default is 0.
How priority flows:
- A Blueprint's
Priorityis copied toNewPipeline.PriorityincreatePipelineByBlueprint()inblueprint.go. CreateDbPipelineinpipeline_helper.gopersists it to_devlake_pipelines.- A database migration (
20250813_add_pipeline_priority.go) adds theprioritycolumn .
Scheduler change: The dequeuePipeline query's ORDER BY was changed from id ASC to priority DESC, id ASC β within the same priority tier, pipelines are still FIFO by insertion order .
Starvation Fix (PR #8568)#
A subtle bug emerged when parallel/ labels interact with priority: if the highest-priority pipeline shares a parallel/ label with an already-running (lower-priority) pipeline, the query would skip it and fall back to a lower-priority pipeline β indefinitely .
Fix: dequeuePipeline now performs a two-step query :
- Find
top_priority:SELECT MAX(priority)among all queued pipelines (statusesCREATED,RERUN,RESUME). - Dequeue within that tier: Add
WHERE priority = top_priorityto the existing label-exclusion query, ordered only byid ASC.
This ensures the scheduler always considers the highest-priority tier first. If every pipeline in that tier is currently label-blocked, the scheduler waits rather than skipping to a lower-priority tier β preserving strict priority ordering while still respecting label mutex semantics .
Key Files and References#
| File | Relevance |
|---|---|
backend/server/services/pipeline.go | RunPipelineInQueue, dequeuePipeline, semaphore setup, parallel label tracking |
backend/server/services/pipeline_helper.go | CreateDbPipeline, label row creation in _devlake_pipeline_labels |
backend/server/services/blueprint.go | Priority propagation from Blueprint β Pipeline |
backend/core/models/pipeline.go | Pipeline struct with Priority, Labels, Status fields |
backend/core/models/blueprint.go | Blueprint struct with Priority field |
env.example | PIPELINE_MAX_PARALLEL=1 default |
| PR #8534 | Added priority field + scheduler ordering |
| PR #8568 | Fixed priority starvation with parallel/ labels |