Skip to content

Commit 8470c1a

Browse files
harshachgithub-actions[bot]yan-3005karanh37
authored
Add workflow sink functionality (#25279)
* Add sink tasks * Add sink tasks * Add sink tasks * Add Sync node for workflows * Update generated TypeScript types * Fix deadlock while reading from mysql * Address review comments * Add timeoutSecondsExpr to sink delegate * fix java checkstyle * fix tests * Address gitar comments --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Ram Narayan Balaji <ramnarayanb3005@gmail.com> Co-authored-by: Karan Hotchandani <33024356+karanh37@users.noreply.github.com> Co-authored-by: karanh37 <karanh37@gmail.com>
1 parent 3248115 commit 8470c1a

32 files changed

Lines changed: 5045 additions & 7 deletions

File tree

openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/Workflow.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
public class Workflow {
1212
public static final String INGESTION_PIPELINE_ID_VARIABLE = "ingestionPipelineId";
1313
public static final String RELATED_ENTITY_VARIABLE = "relatedEntity";
14+
public static final String ENTITY_LIST_VARIABLE = "entityList";
15+
public static final String BATCH_SINK_PROCESSED_VARIABLE = "batchSinkProcessed";
1416
public static final String RESULT_VARIABLE = "result";
1517
public static final String UPDATED_BY_VARIABLE = "updatedBy";
1618
public static final String STAGE_INSTANCE_STATE_ID_VARIABLE = "stageInstanceStateId";

openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowEventConsumer.java

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@
66
import static org.openmetadata.service.governance.workflows.Workflow.UPDATED_BY_VARIABLE;
77
import static org.openmetadata.service.governance.workflows.WorkflowVariableHandler.getNamespacedVariableName;
88

9+
import io.github.resilience4j.retry.Retry;
10+
import io.github.resilience4j.retry.RetryConfig;
11+
import java.time.Duration;
912
import java.util.HashMap;
1013
import java.util.List;
1114
import java.util.Map;
1215
import java.util.Set;
1316
import lombok.extern.slf4j.Slf4j;
17+
import org.apache.commons.lang3.exception.ExceptionUtils;
1418
import org.apache.commons.lang3.tuple.Pair;
1519
import org.openmetadata.schema.entity.events.EventSubscription;
1620
import org.openmetadata.schema.entity.events.SubscriptionDestination;
@@ -29,6 +33,15 @@
2933
@Slf4j
3034
public class WorkflowEventConsumer implements Destination<ChangeEvent> {
3135
public static final String GOVERNANCE_BOT = "governance-bot";
36+
37+
private static final RetryConfig RETRY_CONFIG =
38+
RetryConfig.custom()
39+
.maxAttempts(3)
40+
.waitDuration(Duration.ofMillis(100))
41+
.retryOnException(WorkflowEventConsumer::isTransientDatabaseError)
42+
.build();
43+
44+
private final Retry retry = Retry.of("workflow-event-consumer", RETRY_CONFIG);
3245
private final SubscriptionDestination subscriptionDestination;
3346
private final EventSubscription eventSubscription;
3447

@@ -160,8 +173,9 @@ public void sendMessage(ChangeEvent event, Set<Recipient> recipients)
160173
event.getUserName());
161174
}
162175

163-
LOG.debug("WorkflowEventConsumer - Triggering with signal: {}", signal);
164-
WorkflowHandler.getInstance().triggerWithSignal(signal, variables);
176+
Retry.decorateRunnable(
177+
retry, () -> WorkflowHandler.getInstance().triggerWithSignal(signal, variables))
178+
.run();
165179
}
166180
} catch (Exception exc) {
167181
LOG.error("WorkflowEventConsumer - Error processing event", exc);
@@ -176,6 +190,19 @@ public void sendMessage(ChangeEvent event, Set<Recipient> recipients)
176190
}
177191
}
178192

193+
private static boolean isTransientDatabaseError(Throwable e) {
194+
String rootCauseMessage = ExceptionUtils.getRootCauseMessage(e);
195+
if (rootCauseMessage == null) {
196+
return false;
197+
}
198+
String lowerMessage = rootCauseMessage.toLowerCase();
199+
return lowerMessage.contains("deadlock")
200+
|| lowerMessage.contains("lock wait timeout")
201+
|| lowerMessage.contains("try restarting transaction")
202+
|| lowerMessage.contains("updated by another transaction concurrently")
203+
|| lowerMessage.contains("optimisticlockingfailureexception");
204+
}
205+
179206
@Override
180207
public void sendTestMessage() throws EventPublisherException {}
181208

openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/NodeFactory.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import org.openmetadata.schema.governance.workflows.elements.nodes.automatedTask.SetEntityAttributeTaskDefinition;
1212
import org.openmetadata.schema.governance.workflows.elements.nodes.automatedTask.SetEntityCertificationTaskDefinition;
1313
import org.openmetadata.schema.governance.workflows.elements.nodes.automatedTask.SetGlossaryTermStatusTaskDefinition;
14+
import org.openmetadata.schema.governance.workflows.elements.nodes.automatedTask.SinkTaskDefinition;
1415
import org.openmetadata.schema.governance.workflows.elements.nodes.endEvent.EndEventDefinition;
1516
import org.openmetadata.schema.governance.workflows.elements.nodes.gateway.ParallelGatewayDefinition;
1617
import org.openmetadata.schema.governance.workflows.elements.nodes.startEvent.StartEventDefinition;
@@ -21,6 +22,7 @@
2122
import org.openmetadata.service.governance.workflows.elements.nodes.automatedTask.SetEntityAttributeTask;
2223
import org.openmetadata.service.governance.workflows.elements.nodes.automatedTask.SetEntityCertificationTask;
2324
import org.openmetadata.service.governance.workflows.elements.nodes.automatedTask.SetGlossaryTermStatusTask;
25+
import org.openmetadata.service.governance.workflows.elements.nodes.automatedTask.SinkTask;
2426
import org.openmetadata.service.governance.workflows.elements.nodes.automatedTask.createAndRunIngestionPipeline.CreateAndRunIngestionPipelineTask;
2527
import org.openmetadata.service.governance.workflows.elements.nodes.automatedTask.runApp.RunAppTask;
2628
import org.openmetadata.service.governance.workflows.elements.nodes.endEvent.EndEvent;
@@ -53,6 +55,7 @@ public static NodeInterface createNode(
5355
(DataCompletenessTaskDefinition) nodeDefinition, config);
5456
case PARALLEL_GATEWAY -> new ParallelGateway(
5557
(ParallelGatewayDefinition) nodeDefinition, config);
58+
case SINK_TASK -> new SinkTask((SinkTaskDefinition) nodeDefinition, config);
5659
default -> throw new IllegalArgumentException(
5760
"Unsupported node subtype: " + nodeDefinition.getSubType());
5861
};

openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/TriggerFactory.java

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
package org.openmetadata.service.governance.workflows.elements;
22

3+
import java.util.Map;
34
import org.openmetadata.schema.governance.workflows.TriggerType;
45
import org.openmetadata.schema.governance.workflows.WorkflowDefinition;
6+
import org.openmetadata.schema.governance.workflows.elements.NodeSubType;
7+
import org.openmetadata.schema.governance.workflows.elements.WorkflowNodeDefinitionInterface;
8+
import org.openmetadata.schema.governance.workflows.elements.nodes.automatedTask.SinkTaskDefinition;
59
import org.openmetadata.schema.governance.workflows.elements.triggers.EventBasedEntityTriggerDefinition;
610
import org.openmetadata.schema.governance.workflows.elements.triggers.NoOpTriggerDefinition;
711
import org.openmetadata.schema.governance.workflows.elements.triggers.PeriodicBatchEntityTriggerDefinition;
12+
import org.openmetadata.schema.utils.JsonUtils;
813
import org.openmetadata.service.governance.workflows.elements.triggers.EventBasedEntityTrigger;
914
import org.openmetadata.service.governance.workflows.elements.triggers.NoOpTrigger;
1015
import org.openmetadata.service.governance.workflows.elements.triggers.PeriodicBatchEntityTrigger;
@@ -23,10 +28,51 @@ public static TriggerInterface createTrigger(WorkflowDefinition workflow) {
2328
case PERIODIC_BATCH_ENTITY -> new PeriodicBatchEntityTrigger(
2429
workflow.getName(),
2530
triggerWorkflowId,
26-
(PeriodicBatchEntityTriggerDefinition) workflow.getTrigger());
31+
(PeriodicBatchEntityTriggerDefinition) workflow.getTrigger(),
32+
hasBatchModeNodes(workflow));
2733
};
2834
}
2935

36+
/**
37+
* Check if the workflow contains any nodes with batchMode enabled. When batch mode is detected,
38+
* the trigger should create a single workflow execution per batch instead of N parallel
39+
* executions (one per entity).
40+
*
41+
* <p>Note: Per the schema, batchMode defaults to true when not explicitly set. This ensures Git
42+
* sinks use single execution mode by default, preventing race conditions from parallel commits.
43+
*/
44+
private static boolean hasBatchModeNodes(WorkflowDefinition workflow) {
45+
if (workflow.getNodes() == null) {
46+
return false;
47+
}
48+
for (WorkflowNodeDefinitionInterface node : workflow.getNodes()) {
49+
if (node.getNodeSubType() == NodeSubType.SINK_TASK) {
50+
// Handle typed SinkTaskDefinition
51+
if (node instanceof SinkTaskDefinition sinkTask) {
52+
if (sinkTask.getConfig() != null) {
53+
// Schema default is true, so treat null as true
54+
Boolean batchMode = sinkTask.getConfig().getBatchMode();
55+
if (batchMode == null || batchMode) {
56+
return true;
57+
}
58+
}
59+
} else {
60+
// Fallback for Map-based config (e.g., from JSON deserialization)
61+
Object config = node.getConfig();
62+
if (config != null) {
63+
Map<String, Object> configMap = JsonUtils.getMap(config);
64+
Object batchMode = configMap.get("batchMode");
65+
// Schema default is true, so treat null/absent as true
66+
if (batchMode == null || Boolean.TRUE.equals(batchMode)) {
67+
return true;
68+
}
69+
}
70+
}
71+
}
72+
}
73+
return false;
74+
}
75+
3076
public static String getTriggerWorkflowId(String workflowFQN) {
3177
return String.format("%sTrigger", workflowFQN);
3278
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/*
2+
* Copyright 2024 Collate
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
* Unless required by applicable law or agreed to in writing, software
8+
* distributed under the License is distributed on an "AS IS" BASIS,
9+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
* See the License for the specific language governing permissions and
11+
* limitations under the License.
12+
*/
13+
14+
package org.openmetadata.service.governance.workflows.elements.nodes.automatedTask;
15+
16+
import static org.openmetadata.service.governance.workflows.Workflow.ENTITY_LIST_VARIABLE;
17+
import static org.openmetadata.service.governance.workflows.Workflow.GLOBAL_NAMESPACE;
18+
import static org.openmetadata.service.governance.workflows.Workflow.getFlowableElementId;
19+
20+
import java.util.HashMap;
21+
import java.util.Map;
22+
import org.flowable.bpmn.model.BoundaryEvent;
23+
import org.flowable.bpmn.model.BpmnModel;
24+
import org.flowable.bpmn.model.EndEvent;
25+
import org.flowable.bpmn.model.FieldExtension;
26+
import org.flowable.bpmn.model.Process;
27+
import org.flowable.bpmn.model.SequenceFlow;
28+
import org.flowable.bpmn.model.ServiceTask;
29+
import org.flowable.bpmn.model.StartEvent;
30+
import org.flowable.bpmn.model.SubProcess;
31+
import org.openmetadata.schema.governance.workflows.WorkflowConfiguration;
32+
import org.openmetadata.schema.governance.workflows.elements.nodes.automatedTask.SinkTaskDefinition;
33+
import org.openmetadata.schema.utils.JsonUtils;
34+
import org.openmetadata.service.governance.workflows.elements.NodeInterface;
35+
import org.openmetadata.service.governance.workflows.elements.nodes.automatedTask.sink.SinkTaskDelegate;
36+
import org.openmetadata.service.governance.workflows.flowable.builders.EndEventBuilder;
37+
import org.openmetadata.service.governance.workflows.flowable.builders.FieldExtensionBuilder;
38+
import org.openmetadata.service.governance.workflows.flowable.builders.ServiceTaskBuilder;
39+
import org.openmetadata.service.governance.workflows.flowable.builders.StartEventBuilder;
40+
import org.openmetadata.service.governance.workflows.flowable.builders.SubProcessBuilder;
41+
42+
/**
43+
* Workflow node that pushes entity data to external sink destinations.
44+
*
45+
* <p>This node integrates with the sink provider registry to support multiple sink types such as
46+
* Git repositories, webhooks, and HTTP endpoints.
47+
*/
48+
public class SinkTask implements NodeInterface {
49+
private final SubProcess subProcess;
50+
private final BoundaryEvent runtimeExceptionBoundaryEvent;
51+
52+
public SinkTask(SinkTaskDefinition nodeDefinition, WorkflowConfiguration config) {
53+
String subProcessId = nodeDefinition.getName();
54+
55+
SubProcess subProcess =
56+
new SubProcessBuilder().id(subProcessId).setAsync(true).exclusive(true).build();
57+
58+
StartEvent startEvent =
59+
new StartEventBuilder().id(getFlowableElementId(subProcessId, "startEvent")).build();
60+
61+
ServiceTask sinkTask = getSinkServiceTask(subProcessId, nodeDefinition);
62+
63+
EndEvent endEvent =
64+
new EndEventBuilder().id(getFlowableElementId(subProcessId, "endEvent")).build();
65+
66+
subProcess.addFlowElement(startEvent);
67+
subProcess.addFlowElement(sinkTask);
68+
subProcess.addFlowElement(endEvent);
69+
70+
subProcess.addFlowElement(new SequenceFlow(startEvent.getId(), sinkTask.getId()));
71+
subProcess.addFlowElement(new SequenceFlow(sinkTask.getId(), endEvent.getId()));
72+
73+
if (config.getStoreStageStatus()) {
74+
attachWorkflowInstanceStageListeners(subProcess);
75+
}
76+
77+
this.runtimeExceptionBoundaryEvent =
78+
getRuntimeExceptionBoundaryEvent(subProcess, config.getStoreStageStatus());
79+
this.subProcess = subProcess;
80+
}
81+
82+
@Override
83+
public BoundaryEvent getRuntimeExceptionBoundaryEvent() {
84+
return runtimeExceptionBoundaryEvent;
85+
}
86+
87+
private ServiceTask getSinkServiceTask(String subProcessId, SinkTaskDefinition nodeDefinition) {
88+
var taskConfig = nodeDefinition.getConfig();
89+
90+
FieldExtension sinkTypeExpr =
91+
new FieldExtensionBuilder()
92+
.fieldName("sinkTypeExpr")
93+
.fieldValue(taskConfig.getSinkType() != null ? taskConfig.getSinkType().value() : "")
94+
.build();
95+
96+
FieldExtension sinkConfigExpr =
97+
new FieldExtensionBuilder()
98+
.fieldName("sinkConfigExpr")
99+
.fieldValue(
100+
taskConfig.getSinkConfig() != null
101+
? JsonUtils.pojoToJson(taskConfig.getSinkConfig())
102+
: "{}")
103+
.build();
104+
105+
FieldExtension syncModeExpr =
106+
new FieldExtensionBuilder()
107+
.fieldName("syncModeExpr")
108+
.fieldValue(
109+
taskConfig.getSyncMode() != null ? taskConfig.getSyncMode().value() : "overwrite")
110+
.build();
111+
112+
FieldExtension outputFormatExpr =
113+
new FieldExtensionBuilder()
114+
.fieldName("outputFormatExpr")
115+
.fieldValue(
116+
taskConfig.getOutputFormat() != null
117+
? taskConfig.getOutputFormat().value()
118+
: "yaml")
119+
.build();
120+
121+
FieldExtension hierarchyConfigExpr =
122+
new FieldExtensionBuilder()
123+
.fieldName("hierarchyConfigExpr")
124+
.fieldValue(
125+
taskConfig.getHierarchyConfig() != null
126+
? JsonUtils.pojoToJson(taskConfig.getHierarchyConfig())
127+
: "{}")
128+
.build();
129+
130+
FieldExtension entityFilterExpr =
131+
new FieldExtensionBuilder()
132+
.fieldName("entityFilterExpr")
133+
.fieldValue(
134+
taskConfig.getEntityFilter() != null
135+
? JsonUtils.pojoToJson(taskConfig.getEntityFilter())
136+
: "{}")
137+
.build();
138+
139+
FieldExtension batchModeExpr =
140+
new FieldExtensionBuilder()
141+
.fieldName("batchModeExpr")
142+
.fieldValue(
143+
String.valueOf(
144+
taskConfig.getBatchMode() != null ? taskConfig.getBatchMode() : true))
145+
.build();
146+
147+
FieldExtension timeoutSecondsExpr =
148+
new FieldExtensionBuilder()
149+
.fieldName("timeoutSecondsExpr")
150+
.fieldValue(
151+
String.valueOf(
152+
taskConfig.getTimeoutSeconds() != null ? taskConfig.getTimeoutSeconds() : 300))
153+
.build();
154+
155+
// Build input namespace map, ensuring entityList is mapped to global namespace for batch mode
156+
Map<String, String> inputNamespaceMap = new HashMap<>();
157+
if (nodeDefinition.getInputNamespaceMap() != null) {
158+
@SuppressWarnings("unchecked")
159+
Map<String, String> converted =
160+
JsonUtils.convertValue(nodeDefinition.getInputNamespaceMap(), Map.class);
161+
if (converted != null) {
162+
inputNamespaceMap.putAll(converted);
163+
}
164+
}
165+
// Always include entityList mapping for batch sink support
166+
inputNamespaceMap.putIfAbsent(ENTITY_LIST_VARIABLE, GLOBAL_NAMESPACE);
167+
168+
FieldExtension inputNamespaceMapExpr =
169+
new FieldExtensionBuilder()
170+
.fieldName("inputNamespaceMapExpr")
171+
.fieldValue(JsonUtils.pojoToJson(inputNamespaceMap))
172+
.build();
173+
174+
return new ServiceTaskBuilder()
175+
.id(getFlowableElementId(subProcessId, "executeSink"))
176+
.implementation(SinkTaskDelegate.class.getName())
177+
.addFieldExtension(sinkTypeExpr)
178+
.addFieldExtension(sinkConfigExpr)
179+
.addFieldExtension(syncModeExpr)
180+
.addFieldExtension(outputFormatExpr)
181+
.addFieldExtension(hierarchyConfigExpr)
182+
.addFieldExtension(entityFilterExpr)
183+
.addFieldExtension(batchModeExpr)
184+
.addFieldExtension(timeoutSecondsExpr)
185+
.addFieldExtension(inputNamespaceMapExpr)
186+
.setAsync(true)
187+
.exclusive(true)
188+
.build();
189+
}
190+
191+
@Override
192+
public void addToWorkflow(BpmnModel model, Process process) {
193+
process.addFlowElement(subProcess);
194+
process.addFlowElement(runtimeExceptionBoundaryEvent);
195+
}
196+
}

0 commit comments

Comments
 (0)