Skip to content

Commit a170d15

Browse files
committed
Address review: stable-API glue, workflow version gate, generic command tunnel
- The generated glue now calls the stable workflow.management Ballerina API (getWorkflowMetadata / executeManagementCommand) instead of importing workflow.management.rest — no module that reads as an HTTP server enters the user's build (companion change: ballerina-platform/module-ballerina-workflow#94 moved the dispatcher into workflow.management). - The glue is only generated when the package's resolved ballerina/workflow dependency is 0.9.0 or later, where those APIs first shipped; older workflow versions build exactly as before. - The command plumbing is generic (command_tunnel.bal): payload parsing, deadline handling, at-most-once execution with result replay, and the result envelope are shared infrastructure — a new tunneled command kind only adds a ControlAction arm selecting its executor. Types renamed accordingly (TunneledCommandPayload/Result, CommandIdentity), and the workflow-specific file keeps only the metadata provider, executor registration, and capability advertisement. - Redelivered commandIds are now reserved atomically before execution, so concurrent heartbeat rounds cannot execute the same command twice; an executor result without an int httpStatus is reported FAILED with a diagnostic instead of a silent COMPLETED/500.
1 parent 16e8932 commit a170d15

6 files changed

Lines changed: 265 additions & 200 deletions

File tree

ballerina/command_tunnel.bal

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved.
2+
//
3+
// WSO2 LLC. licenses this file to you under the Apache License,
4+
// Version 2.0 (the "License"); you may not use this file except
5+
// in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing,
11+
// software distributed under the License is distributed on an
12+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
13+
// KIND, either express or implied. See the License for the
14+
// specific language governing permissions and limitations
15+
// under the License.
16+
17+
import ballerina/log;
18+
19+
// ================================================================================
20+
// COMMAND TUNNEL
21+
// ================================================================================
22+
// Generic plumbing for control commands the ICP tunnels through heartbeat
23+
// responses to be executed in-process: payload parsing, at-most-once execution
24+
// with result replay for redelivered commands, and the {commandId, httpStatus,
25+
// body} result envelope posted back on POST /icp/commandResult. Nothing here is
26+
// specific to any command kind — a new tunneled ControlAction only needs an
27+
// executor (see handleTunneledCommand in main.bal, where actions are mapped to
28+
// their executors).
29+
30+
# Executes one tunneled command's operation. Takes the operation request
31+
# (`{operation, params, identity}`) and returns `{httpStatus, body}` exactly as the
32+
# corresponding management API would have responded.
33+
public type TunneledCommandExecutor isolated function (map<json> command) returns map<json>|error;
34+
35+
// Outcomes of recently executed commands, kept so a redelivered commandId (e.g. its
36+
// result was lost after execution) replays the stored result instead of executing the
37+
// operation twice — this is what makes tunneled mutations safe against duplicate
38+
// delivery. A commandId is reserved atomically before execution, so concurrent
39+
// heartbeat rounds delivering the same command cannot both execute it.
40+
// Insertion-ordered FIFO eviction; one record so a single lock covers all structures
41+
// (a lock statement may access only one isolated module-level variable).
42+
const int PROCESSED_COMMAND_CACHE_CAPACITY = 64;
43+
44+
type ProcessedCommandCache record {|
45+
map<TunneledCommandResult> results = {};
46+
map<boolean> inFlight = {};
47+
string[] insertionOrder = [];
48+
|};
49+
50+
isolated ProcessedCommandCache processedCommands = {};
51+
52+
// Atomically claims a commandId for execution. Returns the stored result when the
53+
// command was already executed (replay it), `true` when this caller now owns the
54+
// execution, and `false` when another round is executing it right now (post nothing —
55+
// the owning execution will).
56+
isolated function reserveOrReplay(string commandId) returns TunneledCommandResult|boolean {
57+
lock {
58+
TunneledCommandResult? cached = processedCommands.results[commandId];
59+
if cached is TunneledCommandResult {
60+
return cached.clone();
61+
}
62+
if processedCommands.inFlight.hasKey(commandId) {
63+
return false;
64+
}
65+
processedCommands.inFlight[commandId] = true;
66+
return true;
67+
}
68+
}
69+
70+
isolated function storeCommandResult(TunneledCommandResult result) {
71+
lock {
72+
_ = processedCommands.inFlight.removeIfHasKey(result.commandId);
73+
if processedCommands.results.hasKey(result.commandId) {
74+
return;
75+
}
76+
if processedCommands.insertionOrder.length() >= PROCESSED_COMMAND_CACHE_CAPACITY {
77+
string evicted = processedCommands.insertionOrder.shift();
78+
_ = processedCommands.results.removeIfHasKey(evicted);
79+
}
80+
processedCommands.insertionOrder.push(result.commandId);
81+
processedCommands.results[result.commandId] = result.clone();
82+
}
83+
}
84+
85+
# Executes one tunneled command with at-most-once semantics. Never panics or returns
86+
# an error: every executed outcome — including "not accepted" and executor failures —
87+
# becomes a result the ICP can deliver to the waiting caller.
88+
#
89+
# + payload - The command payload from the control command
90+
# + executor - The executor for this command kind, or `()` when none is registered
91+
# + accepted - Whether this runtime currently accepts this command kind (its opt-in
92+
# configuration); `false` yields a FAILED/403 result
93+
# + return - The result to post to `POST /icp/commandResult`, or `()` when another
94+
# round is executing the same commandId right now (nothing to post)
95+
isolated function executeTunneledCommand(TunneledCommandPayload payload,
96+
TunneledCommandExecutor? executor, boolean accepted) returns TunneledCommandResult? {
97+
TunneledCommandResult|boolean reservation = reserveOrReplay(payload.commandId);
98+
if reservation is TunneledCommandResult {
99+
log:printInfo(string `Replaying stored result for redelivered command: ${payload.commandId}`);
100+
return reservation;
101+
}
102+
if !reservation {
103+
log:printInfo(string `Skipping command already executing in another round: ${payload.commandId}`);
104+
return ();
105+
}
106+
107+
TunneledCommandResult result;
108+
if executor is () || !accepted {
109+
// The matching capability is only advertised while both hold, so this is a
110+
// server-side gating bug or a config change since the last heartbeat.
111+
result = {
112+
runtimeId: currentRuntimeId,
113+
commandId: payload.commandId,
114+
status: "FAILED",
115+
httpStatus: 403,
116+
body: {"error": {"message": "Commands of this kind are not accepted by this runtime"}}
117+
};
118+
} else {
119+
map<json> command = {
120+
operation: payload.operation,
121+
params: payload.params,
122+
identity: {userId: payload.identity.userId, roles: payload.identity.roles}
123+
};
124+
map<json>|error outcome = executor(command);
125+
if outcome is error {
126+
log:printError(string `Tunneled command execution failed: ${payload.commandId}`, outcome);
127+
result = {
128+
runtimeId: currentRuntimeId,
129+
commandId: payload.commandId,
130+
status: "FAILED",
131+
httpStatus: 500,
132+
body: {"error": {"message": outcome.message()}}
133+
};
134+
} else {
135+
json httpStatus = outcome["httpStatus"];
136+
if httpStatus is int {
137+
result = {
138+
runtimeId: currentRuntimeId,
139+
commandId: payload.commandId,
140+
status: "COMPLETED",
141+
httpStatus: httpStatus,
142+
body: outcome["body"]
143+
};
144+
} else {
145+
log:printError(string `Tunneled command returned an unexpected result shape: ${payload.commandId}`);
146+
result = {
147+
runtimeId: currentRuntimeId,
148+
commandId: payload.commandId,
149+
status: "FAILED",
150+
httpStatus: 500,
151+
body: {"error": {"message": "Unexpected command result shape"}}
152+
};
153+
}
154+
}
155+
}
156+
storeCommandResult(result);
157+
return result;
158+
}

ballerina/icp_client.bal

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,9 @@ public client class IcpClient {
4949
return heartbeatResponse;
5050
}
5151

52-
// Posts the result of a tunneled workflow management command. Outbound-only, like
53-
// heartbeats — the ICP correlates it to the waiting request via the commandId.
54-
isolated remote function sendCommandResult(WorkflowCommandResult result) returns error? {
52+
// Posts the result of a tunneled command. Outbound-only, like heartbeats — the
53+
// ICP correlates it to the waiting request via the commandId.
54+
isolated remote function sendCommandResult(TunneledCommandResult result) returns error? {
5555
http:Request request = new;
5656
request.setHeader(http:AUTH_HEADER, string `${http:AUTH_SCHEME_BEARER} ${check generateJwtToken()}`);
5757
request.setPayload(result.toJson());

ballerina/main.bal

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ public class HeartbeatJob {
9999

100100
# Executes the heartbeat job: one heartbeat round, plus bounded follow-up rounds
101101
# while the server is actively tunneling work. A follow-up happens immediately
102-
# after executing a workflow command (its result may already have unblocked the
102+
# after executing a tunneled command (its result may already have unblocked the
103103
# next queued command) or after the server's `nextHeartbeatInSeconds` boost hint
104104
# (sent while a user is actively working with workflow views). Follow-ups stop
105105
# once their accumulated delay would exceed one regular interval, so a tick never
@@ -168,8 +168,8 @@ public class HeartbeatJob {
168168
}
169169
self.supportedHeartbeatFields = newSupportedHeartbeatFields;
170170
log:printDebug("Heartbeat acknowledged by ICP server");
171-
boolean executedWorkflowCommand = self.handleControlCommands(heartbeatResponse.commands);
172-
if executedWorkflowCommand {
171+
boolean processedTunneledCommand = self.handleControlCommands(heartbeatResponse.commands);
172+
if processedTunneledCommand {
173173
// Fetch the next queued command right away — the posted result has likely
174174
// unblocked the ICP-side caller already.
175175
return 0;
@@ -184,15 +184,15 @@ public class HeartbeatJob {
184184
# Handles the control commands delivered in a heartbeat response.
185185
#
186186
# + commands - The commands from the response
187-
# + return - `true` when at least one tunneled workflow command was processed,
188-
# so the caller can immediately fetch the next queued command
187+
# + return - `true` when at least one tunneled command was processed, so the
188+
# caller can immediately fetch the next queued command
189189
function handleControlCommands(ControlCommand[] commands) returns boolean {
190190
if commands.length() == 0 {
191191
return false;
192192
}
193193

194194
boolean artifactsChanged = false;
195-
boolean workflowCommandProcessed = false;
195+
boolean tunneledCommandProcessed = false;
196196
foreach ControlCommand command in commands {
197197
log:printInfo(string `Handling control command: ${command.toJsonString()}`);
198198
command.status = PENDING;
@@ -201,8 +201,8 @@ public class HeartbeatJob {
201201
error? result = ();
202202
match command.action {
203203
WORKFLOW_MGMT => {
204-
workflowCommandProcessed = true;
205-
result = self.handleWorkflowCommand(command.payload ?: "");
204+
tunneledCommandProcessed = true;
205+
result = self.handleTunneledCommand(command);
206206
}
207207
START|STOP => {
208208
string artifactName = command.targetArtifact.name;
@@ -263,38 +263,50 @@ public class HeartbeatJob {
263263
Heartbeat|error newHeartbeat = getHeartbeat(self.supportedHeartbeatFields);
264264
if newHeartbeat is error {
265265
log:printError("Failed to create full heartbeat after control command", newHeartbeat);
266-
return workflowCommandProcessed;
266+
return tunneledCommandProcessed;
267267
}
268268
self.heartbeat = newHeartbeat;
269269
}
270-
return workflowCommandProcessed;
270+
return tunneledCommandProcessed;
271271
}
272272

273-
# Executes one tunneled workflow management command and posts its result to the
274-
# ICP. A command past its deadline is dropped unexecuted — the ICP-side caller
275-
# has already timed out, and executing (or replying) then would be wasted work
276-
# or, for mutations, an unwanted late effect.
273+
# Executes one tunneled command and posts its result to the ICP. A command past
274+
# its deadline is dropped unexecuted — the ICP-side caller has already timed
275+
# out, and executing (or replying) then would be wasted work or, for mutations,
276+
# an unwanted late effect. The command's action selects the executor; adding a
277+
# new tunneled command kind means adding an arm to that match.
277278
#
278-
# + rawPayload - The command's JSON payload string
279+
# + command - The tunneled control command
279280
# + return - An error when the payload is unusable or the result could not be
280281
# delivered (the command's status is reported FAILED then)
281-
function handleWorkflowCommand(string rawPayload) returns error? {
282+
function handleTunneledCommand(ControlCommand command) returns error? {
283+
string rawPayload = command.payload ?: "";
282284
if rawPayload == "" {
283-
return error("Missing payload for WORKFLOW_MGMT command");
285+
return error(string `Missing payload for ${command.action} command`);
284286
}
285-
WorkflowCommandPayload payload = check rawPayload.fromJsonStringWithType();
287+
TunneledCommandPayload payload = check rawPayload.fromJsonStringWithType();
286288

287289
string? deadline = payload?.deadline;
288290
if deadline is string {
289291
time:Utc|time:Error deadlineTime = time:utcFromString(deadline);
290292
if deadlineTime is time:Utc && time:utcDiffSeconds(deadlineTime, time:utcNow()) < 0d {
291-
log:printWarn(string `Dropping expired workflow command ${payload.commandId} ` +
293+
log:printWarn(string `Dropping expired command ${payload.commandId} ` +
292294
string `(deadline ${deadline})`);
293295
return;
294296
}
295297
}
296298

297-
WorkflowCommandResult result = executeWorkflowCommand(payload);
298-
check self.icpClient->sendCommandResult(result);
299+
TunneledCommandExecutor? executor = ();
300+
boolean accepted = false;
301+
match command.action {
302+
WORKFLOW_MGMT => {
303+
executor = workflowExecutor();
304+
accepted = enableWorkflowManagement;
305+
}
306+
}
307+
TunneledCommandResult? result = executeTunneledCommand(payload, executor, accepted);
308+
if result is TunneledCommandResult {
309+
check self.icpClient->sendCommandResult(result);
310+
}
299311
}
300312
}

ballerina/types.bal

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -157,47 +157,47 @@ public enum ControlAction {
157157
STOP,
158158
SET_LOGGER_LEVEL,
159159
// A tunneled workflow management operation (list/start workflows, complete human
160-
// tasks, ...). The server must only send this to runtimes that advertised the
161-
// "workflowCommands" capability — older bridges fail record binding on unknown
162-
// actions. The command's `payload` is a WorkflowCommandPayload JSON string; the
163-
// result is posted back on POST /icp/commandResult.
160+
// tasks, ...), executed in-process via the command tunnel (command_tunnel.bal).
161+
// The ICP only sends this to runtimes that advertised the "workflowCommands"
162+
// capability. The command's `payload` is a TunneledCommandPayload JSON string;
163+
// the result is posted back on POST /icp/commandResult.
164164
WORKFLOW_MGMT
165165
};
166166

167-
# The JSON carried in a `WORKFLOW_MGMT` control command's `payload`.
167+
# The JSON carried in a tunneled control command's `payload`.
168168
#
169169
# + commandId - Correlation ID; the result is posted back under this ID
170-
# + operation - Dot-qualified management operation name (e.g. `humanTasks.complete`)
171-
# + params - Operation parameters, keyed like the management REST API's query/path/body
170+
# + operation - Dot-qualified operation name (e.g. `humanTasks.complete`)
171+
# + params - Operation parameters, keyed like the management API's query/path/body
172172
# + identity - The end user the ICP executes this on behalf of
173173
# + deadline - ISO-8601 instant after which the command is dropped unexecuted
174-
public type WorkflowCommandPayload record {|
174+
public type TunneledCommandPayload record {|
175175
string commandId;
176176
string operation;
177177
map<json> params = {};
178-
WorkflowCommandIdentity identity = {};
178+
CommandIdentity identity = {};
179179
string deadline?;
180180
|};
181181

182-
# The caller identity a tunneled workflow command executes on behalf of. Same
183-
# semantics as the management REST API's `x-user-id` / `x-user-roles` headers.
182+
# The caller identity a tunneled command executes on behalf of. Same semantics as
183+
# the management API's `x-user-id` / `x-user-roles` headers.
184184
#
185185
# + userId - The user ID, or `()` when unknown
186186
# + roles - The caller's roles; empty means "no roles"
187-
public type WorkflowCommandIdentity record {|
187+
public type CommandIdentity record {|
188188
string? userId = ();
189189
string[] roles = [];
190190
|};
191191

192-
# The outcome of a tunneled workflow command, posted to `POST /icp/commandResult`.
192+
# The outcome of a tunneled command, posted to `POST /icp/commandResult`.
193193
#
194194
# + runtimeId - This runtime's ID
195195
# + commandId - The command's correlation ID
196196
# + status - `COMPLETED` when the operation executed (regardless of its HTTP-level
197197
# outcome), `FAILED` when it could not be executed at all
198-
# + httpStatus - The status code the management REST API would have returned
199-
# + body - The response body, byte-identical to the management REST API's
200-
public type WorkflowCommandResult record {|
198+
# + httpStatus - The status code the corresponding management API would have returned
199+
# + body - The response body, byte-identical to the management API's
200+
public type TunneledCommandResult record {|
201201
string runtimeId;
202202
string commandId;
203203
string status;

0 commit comments

Comments
 (0)