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:
- Named pipelines with ordered stages (e.g.
build → test → staging → deploy) - Sequential stage advancement — stages cannot be skipped
- Rollback — revert to a known-good state at any point during deployment
- Cancellation — abort pending or in-progress deployments
- Exclusive execution — only one deployment per pipeline can be IN_PROGRESS at a time
- Deployment history — ordered by most recent first
- Backed by PostgreSQL (pipelines, deployments) and Redis (active deployment tracking)
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:
- No persistence — server restart loses deployment state mid-pipeline
- No concurrency guard — two deployments can run simultaneously, corrupting the pipeline
- No rollback — once you've advanced, there's no going back
- 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
DeploymentStatusrecord 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
synchronizedmethods prevent concurrent stage advances or double-trigger- Redis active tracking gives O(1) "is anything running?" checks
- PostgreSQL as source of truth survives Redis restarts — the
status='IN_PROGRESS'query is the authoritative check - History queries are efficient with an index on
(pipeline_name, seq DESC)
6. What the Grader Checks
| Test | What It Verifies |
|---|---|
testCreatePipeline | Pipeline with stages is created |
testTriggerDeployment | Deployment starts at first stage with IN_PROGRESS |
testAdvanceStages | Sequential advancement through all stages |
testAdvanceToCompletion | Final advance transitions to COMPLETED |
testRollback | IN_PROGRESS → ROLLED_BACK transition |
testRollbackNotInProgress | Can't roll back COMPLETED/CANCELLED |
testCancelDeployment | PENDING/IN_PROGRESS → CANCELLED |
testExclusiveExecution | Second trigger throws while first is active |
testHistory | Deployments returned in reverse chronological order |
testHistoryLimit | Limit parameter respected |
testCancelThenTrigger | After cancellation, new deployment can start |
7. Takeaways
- 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.
- 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.
- Sequence numbers beat timestamps for ordering. Two deployments triggered in the same millisecond need deterministic ordering. A Redis
INCRcounter gives you a monotonically increasing sequence that never collides.
👉 Try it yourself: Deployment Pipeline on Cruscible