Skip to main content
DevOps · lesson 3 of 8
DevOps intermediate Lesson 3 of 8

Also part of: GitHub Actions Jenkins

CI/CD Pipeline Patterns: Sequential, Parallel, and Staged

Measure the real time cost of a sequential CI pipeline versus a parallel one, then reproduce the dependency bug naive parallelism introduces and fix it with a build gate.

By SkillByExample · Published

devops ci-cd pipelines

Two pipelines, same three stages, run two different ways — one nine seconds slower, one silently broken.

Same stages, sequential vs. parallel

Three independent CI stages — lint, unit tests, integration tests — each simulated with a sleep to stand in for real work:

# .scratch/lint.py
import time
time.sleep(2)
print(f"lint stage: 2.0s")
# .scratch/run_sequential.py
import subprocess
import time

PY = "py"
stages = [".scratch/lint.py", ".scratch/unit.py", ".scratch/integration.py"]

start = time.time()
for stage in stages:
    subprocess.run([PY, stage], check=True)
print(f"\nTotal sequential pipeline time: {time.time() - start:.1f}s")
$ py run_sequential.py
lint stage: 2.0s
unit stage: 3.0s
integration stage: 4.0s

Total sequential pipeline time: 10.0s

Now launch all three at once with Popen instead of waiting on each subprocess.run in turn:

# .scratch/run_parallel.py
import subprocess
import time

PY = "py"
stages = [".scratch/lint.py", ".scratch/unit.py", ".scratch/integration.py"]

start = time.time()
procs = [subprocess.Popen([PY, stage], stdout=subprocess.PIPE, text=True) for stage in stages]
for p in procs:
    out, _ = p.communicate()
    print(out.strip())
print(f"\nTotal parallel pipeline time: {time.time() - start:.1f}s")
$ py run_parallel.py
lint stage: 2.0s
unit stage: 3.0s
integration stage: 4.0s

Total parallel pipeline time: 4.2s

Sequential pays for every stage: 2+3+4 = 9s of work plus overhead, landing at 10.0s. Parallel only pays for the slowest one — integration’s 4s — plus a little process-launch overhead, landing at 4.2s. This is the entire case for parallelizing independent CI stages: total time collapses toward max(stage times) instead of sum(stage times).

The bug parallelism introduces

Lint, unit, and integration don’t share any state, so running them together is safe. But a real pipeline usually has a stage that produces something another stage consumes — a build step writing an artifact that a test step then reads:

# .scratch/build.py
import time
time.sleep(2)
with open(".scratch/artifact.txt", "w") as f:
    f.write("built-artifact-v1")
print("build stage: wrote artifact.txt")
# .scratch/test_stage.py
with open(".scratch/artifact.txt") as f:
    contents = f.read()
print(f"test stage: read artifact contents = {contents!r}")

Launch both the same naive way — Popen for each, no ordering:

# .scratch/run_naive_parallel.py
import subprocess

PY = "py"
stages = [".scratch/build.py", ".scratch/test_stage.py"]
procs = [subprocess.Popen([PY, s], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) for s in stages]
for p, s in zip(procs, stages):
    out, err = p.communicate()
    print(f"--- {s} (exit {p.returncode}) ---")
    if out:
        print(out.strip())
    if err:
        print(err.strip())
$ py run_naive_parallel.py
--- .scratch/build.py (exit 0) ---
build stage: wrote artifact.txt
--- .scratch/test_stage.py (exit 1) ---
Traceback (most recent call last):
  File "run_naive_parallel.py", line 1, in <module>
    with open(".scratch/artifact.txt") as f:
         ~~~~^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: '.scratch/artifact.txt'

The test stage started at the same instant as the build stage, tried to open a file the build stage’s 2-second sleep hadn’t written yet, and crashed. Both processes launched in parallel successfully — the bug isn’t a crash in either script on its own, it’s the ordering assumption that parallel launch throws away. This is exactly why “just parallelize everything” is unsafe advice for CI pipelines: it only works between stages with no shared state.

The fix: a build gate, then a parallel fan-out

Run the build stage to completion first — a gate — and only start the dependent stages once it succeeds. Stages that depend on nothing but the gate’s output can still run in parallel with each other:

# .scratch/run_staged_pipeline.py
import os
import subprocess
import time

PY = "py"

if os.path.exists(".scratch/artifact.txt"):
    os.remove(".scratch/artifact.txt")

start = time.time()

# Gate: build must finish before any test stage starts
subprocess.run([PY, ".scratch/build.py"], check=True)

# Fan-out: test stages only need the artifact, not each other
procs = [subprocess.Popen([PY, ".scratch/test_stage.py"], stdout=subprocess.PIPE, text=True) for _ in range(3)]
for p in procs:
    out, _ = p.communicate()
    print(out.strip())

print(f"\nTotal staged pipeline time: {time.time() - start:.1f}s")
$ py run_staged_pipeline.py
build stage: wrote artifact.txt
test stage: read artifact contents = 'built-artifact-v1'
test stage: read artifact contents = 'built-artifact-v1'
test stage: read artifact contents = 'built-artifact-v1'

Total staged pipeline time: 2.2s

All three test-stage copies now read the real artifact instead of racing ahead of it, and the pipeline still isn’t sequential end-to-end — the three consumers of the artifact still run concurrently with each other, they just all wait on the one thing they actually depend on. This is what needs: in GitHub Actions and stages: in GitLab CI are for: expressing which stages must gate which, so the scheduler can parallelize everything else.

Practice

1. What happens if `test_stage.py` runs three times in the naive full-parallel script instead of once?

Modify run_naive_parallel.py’s stages list to [".scratch/build.py", ".scratch/test_stage.py", ".scratch/test_stage.py", ".scratch/test_stage.py"] and re-run it. All three test_stage.py copies start at the same instant as build.py, so all three race ahead of the write and all three fail with the same FileNotFoundError — the bug doesn’t average out or partially succeed, every consumer that starts before the gate finishes fails the same way.

2. Add a fourth independent stage ("security scan", 1s) to the parallel pipeline. What's the new total time?
# .scratch/security.py
import time
time.sleep(1)
print("security stage: 1.0s")

Add ".scratch/security.py" to run_parallel.py’s stages list and re-run. The total stays at roughly 4.2s — the same as before adding it. The slowest stage is still integration’s 4s; a 1s stage running alongside it is free as long as there’s a free process slot, which is the core benefit of fanning independent work out instead of appending it to a sequential list.

3. What if the build stage itself fails? Does `run_staged_pipeline.py` still try to run the test stages?

Change build.py to raise SystemExit(1) before writing the artifact, then re-run run_staged_pipeline.py. Because the build is invoked with subprocess.run([...], check=True), a non-zero exit raises CalledProcessError immediately and the script stops before reaching the Popen fan-out loop — the test stages never start. That check=True is what makes the gate an actual gate instead of just an ordering suggestion.

4. Two gated groups: build A feeds tests A1/A2, and separately build B feeds test B1. Can both build gates run in parallel with each other?

Yes — the two build-then-test chains have no dependency on each other, only build A gates A1/A2 and build B gates B1. Launch both builds with Popen at the same time, then once each build’s own communicate() returns, fan out that build’s dependent test stages. The pipeline becomes two independent staged sub-pipelines running concurrently, each internally gated.

5. Rewrite `run_staged_pipeline.py` so a failed test stage doesn't stop the other test stages from finishing.

The current loop already does this by accident: Popen starts all three test processes before the loop calls communicate() on any of them, so one process’s later failure doesn’t block the others from being launched. To also report every stage’s outcome instead of stopping at the first bad one, collect (process, output, returncode) for all three in the loop before checking returncode on any of them, then report failures after every process has been drained.

Frequently Asked Questions

Why not just run every CI stage in parallel for maximum speed?
Because some stages depend on artifacts other stages produce. A test stage that reads a build's output will fail or silently read stale data if it starts before the build finishes. Parallelism is only safe between stages that don't share state — you still need a gate wherever a real dependency exists.
What is a build gate in a CI/CD pipeline?
A build gate is a stage that must finish successfully before any dependent stage starts. It turns a pipeline into a small DAG instead of a flat list: one gated stage runs first, then everything that only depends on its output can fan out in parallel.
How much faster is a parallel pipeline than a sequential one?
It's bounded by the slowest stage, not the sum of all stages. Three stages taking 2s, 3s, and 4s run in 9s sequentially but about 4s in parallel — because the pipeline only finishes when its slowest independent stage does.
Is this the same as GitHub Actions' 'needs' keyword or GitLab CI's 'stages'?
Same idea, different syntax. Both features exist specifically to express this build-then-test dependency: run one job, wait for it to succeed, then fan out into parallel jobs that consume its output. The scripts here reproduce the underlying reason that keyword exists using nothing but subprocess.
Why did the test stage fail with FileNotFoundError instead of reading an empty file?
Because the build stage hadn't created the file at all yet when the test stage tried to open it. A naive full-parallel launch starts every process at the same instant; the build stage's 2-second sleep means the test stage's read races ahead of the write by a wide margin.