Building a Deployment Pipeline Engine with Rollback Support

Sequential stage advancement, rollback, cancellation, and deployment history — backed by PostgreSQL and Redis

By SysAdmin · Published 2026-05-27

Building a Deployment Pipeline Engine with Rollback Support

Jenkins, GitHub Actions, ArgoCD — every CI/CD system is built on the same core: a pipeline of stages that advance sequentially, with the ability to roll back when things go wrong. This problem asks you to build that engine from scratch.

1. The Problem

Design a deployment pipeline engine that supports:

Deployment States

PENDING → IN_PROGRESS → COMPLETED
              │
              ├→ ROLLED_BACK
              └→ CANCELLED

2. The Naïve Approach (and Why It Fails)

List<String> stages = List.of("build", "test", "deploy");
int currentStage = 0;

public void advance() {
    currentStage++;
    if (currentStage >= stages.size()) {
        System.out.println("Deployed!");
    }
}

This breaks because:

  1. No persistence — server restart loses deployment state mid-pipeline
  2. No concurrency guard — two deployments can run simultaneously, corrupting the pipeline
  3. No rollback — once you've advanced, there's no going back
  4. No history — you can't answer "what was the last deployment to staging?"

3. The Right Model

Two PostgreSQL tables + Redis for active deployment tracking:

CREATE TABLE dp_pipelines (
    name TEXT PRIMARY KEY,
    stages TEXT NOT NULL  -- pipe-delimited: "build|test|staging|deploy"
);

CREATE TABLE dp_deployments (
    id TEXT PRIMARY KEY,
    pipeline_name TEXT NOT NULL,
    version TEXT NOT NULL,
    current_stage TEXT,
    stage_index INT NOT NULL DEFAULT 0,
    total_stages INT NOT NULL,
    status TEXT NOT NULL DEFAULT 'IN_PROGRESS',
    started_at BIGINT NOT NULL,
    completed_at BIGINT,
    seq BIGINT NOT NULL  -- for ordering history
);

Redis key dp:active:{pipeline} stores the active deployment ID — enables O(1) conflict detection.

4. The Implementation, Walked Through

The Interface

public interface DeploymentPipelineContract {

    record DeploymentStatus(
        String id, String pipelineName, String version,
        String currentStage, String status,
        long startedAt, Long completedAt
    ) {}

    void createPipeline(String name, List<String> stages);
    String triggerDeployment(String pipelineName, String version);
    DeploymentStatus getDeploymentStatus(String deploymentId);
    boolean advanceStage(String deploymentId);
    boolean rollback(String deploymentId);
    List<DeploymentStatus> getHistory(String pipelineName, int limit);
    boolean cancelDeployment(String deploymentId);
}

💡 The DeploymentStatus record is a clean, immutable snapshot — perfect for API responses.

Triggering a Deployment — Exclusive Execution

public synchronized String triggerDeployment(String pipelineName, String version) {
    // 1. Verify pipeline exists
    Map<String, Object> pipeline = db.queryOne(
        "SELECT stages FROM dp_pipelines WHERE name = ?", pipelineName)
        .orElseThrow(() -> new IllegalArgumentException("Pipeline not found"));

    // 2. Check no active deployment
    List<Map<String, Object>> active = db.query(
        "SELECT id FROM dp_deployments WHERE pipeline_name = ? AND status = 'IN_PROGRESS'",
        pipelineName);
    if (!active.isEmpty()) {
        throw new IllegalStateException("Deployment already active");
    }

    // 3. Create deployment at first stage
    List<String> stages = decodeStages(pipeline.get("stages"));
    String id = UUID.randomUUID().toString();
    db.update("INSERT INTO dp_deployments (...) VALUES (...)",
        id, pipelineName, version, stages.get(0), stages.size(), now, seq);

    // 4. Track in Redis for fast conflict checks
    redis.set("dp:active:" + pipelineName, id);
    return id;
}

The synchronized keyword + DB query ensures exactly one deployment runs per pipeline.

Advancing Stages — The Pipeline Heart

public synchronized boolean advanceStage(String deploymentId) {
    Map<String, Object> d = db.queryOne(
        "SELECT * FROM dp_deployments WHERE id = ?", deploymentId).orElse(null);
    if (d == null || !"IN_PROGRESS".equals(d.get("status"))) return false;

    int stageIndex = (int) d.get("stage_index");
    int totalStages = (int) d.get("total_stages");
    int nextIndex = stageIndex + 1;

    if (nextIndex >= totalStages) {
        // Last stage → mark COMPLETED
        db.update("UPDATE dp_deployments SET status='COMPLETED', completed_at=? WHERE id=?",
            now, deploymentId);
        redis.del("dp:active:" + pipelineName);
    } else {
        // Move to next stage
        List<String> stages = getStages(pipelineName);
        db.update("UPDATE dp_deployments SET current_stage=?, stage_index=? WHERE id=?",
            stages.get(nextIndex), nextIndex, deploymentId);
    }
    return true;
}

💡 Key design decision: Advancing past the last stage transitions to COMPLETED automatically, and clears the Redis active key so the next deployment can start.

Rollback — One-Shot Abort

public synchronized boolean rollback(String deploymentId) {
    Map<String, Object> d = getDeployment(deploymentId);
    if (d == null || !"IN_PROGRESS".equals(d.get("status"))) return false;

    db.update("UPDATE dp_deployments SET status='ROLLED_BACK', completed_at=? WHERE id=?",
        now, deploymentId);
    redis.del("dp:active:" + pipelineName);
    return true;
}

Rollback doesn't walk stages backwards — it's a terminal state transition. In production, a "real" rollback would trigger a new deployment of the previous version.

Deployment History

public List<DeploymentStatus> getHistory(String pipelineName, int limit) {
    List<Map<String, Object>> rows = db.query(
        "SELECT * FROM dp_deployments WHERE pipeline_name = ? ORDER BY seq DESC LIMIT ?",
        pipelineName, limit);
    return rows.stream().map(this::toStatus).collect(Collectors.toList());
}

The seq column (Redis INCR) ensures globally unique ordering — even if two deployments have the same started_at timestamp.

5. Performance + Concurrency

6. What the Grader Checks

TestWhat It Verifies
testCreatePipelinePipeline with stages is created
testTriggerDeploymentDeployment starts at first stage with IN_PROGRESS
testAdvanceStagesSequential advancement through all stages
testAdvanceToCompletionFinal advance transitions to COMPLETED
testRollbackIN_PROGRESS → ROLLED_BACK transition
testRollbackNotInProgressCan't roll back COMPLETED/CANCELLED
testCancelDeploymentPENDING/IN_PROGRESS → CANCELLED
testExclusiveExecutionSecond trigger throws while first is active
testHistoryDeployments returned in reverse chronological order
testHistoryLimitLimit parameter respected
testCancelThenTriggerAfter cancellation, new deployment can start

7. Takeaways

  1. Exclusive execution is the hardest requirement. Without it, two deployments can race through stages simultaneously, leaving your production in an undefined state. The Redis active key + DB check double-guard pattern eliminates this.
  1. Rollback is a state transition, not a reversal. Real-world rollback doesn't "undo" stages — it marks the deployment as failed and allows a new deployment of the previous version. Modeling it as a terminal state keeps the state machine simple.
  1. Sequence numbers beat timestamps for ordering. Two deployments triggered in the same millisecond need deterministic ordering. A Redis INCR counter gives you a monotonically increasing sequence that never collides.

👉 Try it yourself: Deployment Pipeline on Cruscible