Design a dependency DAG visualizer and a reactive state-sync layer, which is the core engine that makes tools like Airflow feel so fast and responsive.

Functional Requirements
The UI needs to answer questions like:
Which jobs are ready to run? Which jobs are blocked? Which jobs became runnable just now? Which downstream jobs are affected by a failure?
Instead of recalculating the entire workflow every few seconds, the propagation algorithm incrementally updates only the impacted portion of the graph.
The key challenge is:
How do you instantly update thousands of dependent jobs on a UI when one job changes state?
dag-monitoring-system/
├── producer-service
│ └── publishes job events
│
├── dag-service
│ ├── kafka consumer
│ ├── mongodb
│ └── rest apis
│
├── dashboard-ui
│ └── react
│
└── docker-compose
Non-Functional Requirements
Sequence Flow
State Propagation Algorithm
Current DAG
A
/ \
B C
\ /
D
A = RUNNING
B = WAITING
C = WAITING
D = WAITING
When node A state changes, the algorithm traverses only the descendants and check
{
"jobId":"A",
"status":"SUCCESS"
}
- Can B now run? - `allParentsSuccessful(B)?` - Yes
- Can C now run? - `allParentsSuccessful(C)?` - Yes
- Can D now run? - `allParentsSuccessful(D)?` - No
Dependency Resolution Engine
Incremental DAG Updates
Suppose dependency changes.
Old:
A → B
New:
A → C
Instead of rebuilding graph: Publish event.
{
"type":"DEPENDENCY_UPDATED",
"source":"A",
"target":"C"
}
State Sync Service updates:
graph.get(A).children.add(C);
graph.get(C).parents.add(A);
and broadcasts delta.
Delta-Based Synchronization
Never send full DAG.
Bad:
{
"5000 nodes..."
}
Good:
{
"type":"NODE_UPDATED",
"jobId":"A",
"status":"SUCCESS"
}
or
{
"type":"EDGE_ADDED",
"source":"A",
"target":"C"
}
UI updates only changed pieces.
WebSocket Push Layer
When state changes:
webSocket.send(
DAGDeltaEvent
)
Example:
{
"type":"NODE_UPDATED",
"id":"A",
"status":"SUCCESS"
}
No polling. UI updates instantly.
Design Rationale
The most important design choice is keeping the DAG in memory (and/or Redis) and propagating only deltas through Kafka and WebSockets. That turns a potentially expensive "reload the entire workflow graph" operation into a few milliseconds of incremental state updates, which is exactly how modern orchestration dashboards remain responsive at scale.
{
"jobId": "A",
"children": ["B", "C"],
"parents": []
}
{
"jobId": "D",
"children": [],
"parents": ["B","C"]
}
Why store both both children and parent array.
Tidal Producer
Recommended JSON structure for your Kafka events:
{
"eventId": "uuid-1234",
"timestamp": "2026-06-06T14:30:00Z",
"type": "DEPENDENCY_ADDED",
"jobId": "job-101",
"payload": {
"parentId": "job-99",
"childId": "job-101"
}
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JobEvent {
private String jobId;
private String jobName;
private EventType eventType;
private JobStatus status;
private List<String> dependencies;
private Instant timestamp;
}
producer.publish(
JobEvent.builder()
.jobId("ERCOT_001")
.jobName("ERCOT Outages")
.eventType(EventType.JOB_CREATED)
.status(JobStatus.PENDING)
.dependencies(List.of("LOAD_DATA"))
.timestamp(Instant.now())
.build()
);
Kafka Cluster
Crucial Design Choice: Use jobId as the Kafka Partition Key. This ensures that all
events for a specific job land in the same partition, guaranteeing the order of dependency
updates is preserved.
Backend Consumer
Your consumer service shouldn't just "process" messages, it should maintain an in-memory or persistent state (like a Graph DB or a simple Cache/SQL table).
-
Ingestion: The consumer listens to the tidal-events topic.
-
State Update: When a
DEPENDENCY_ADDEDevent arrives, the consumer performs an upsert in your database (add edge fromparentIdtochildId). -
API Response: When a user queries your API for a job's DAG, the service performs a fast read from your local database/cache (the "Collated State") rather than hitting Tidal.
@Component
@RequiredArgsConstructor
public class JobEventConsumer {
private final JobRepository repository;
@KafkaListener(
topics = "job-events",
groupId = "dag-service"
)
public void consume(JobEvent event) {
switch (event.getEventType()) {
case JOB_CREATED ->
createJob(event);
case JOB_STATUS_CHANGED ->
updateStatus(event);
case DEPENDENCY_UPDATED ->
updateDependencies(event);
}
}
private void createJob(JobEvent event) {
JobNode node = new JobNode();
node.setJobId(event.getJobId());
node.setJobName(event.getJobName());
node.setStatus(event.getStatus());
node.setDependencies(event.getDependencies());
repository.save(node);
}
private void updateStatus(JobEvent event) {
repository.findById(event.getJobId())
.ifPresent(job -> {
job.setStatus(event.getStatus());
repository.save(job);
});
}
private void updateDependencies(JobEvent event) {
repository.findById(event.getJobId())
.ifPresent(job -> {
job.setDependencies(event.getDependencies());
repository.save(job);
});
}
}