فهرست منبع

feat: a form can answer at once instead of holding the browser

ExecuteWorkflow is a plain synchronous RPC: the server call runs the
workflow to completion (or until the client deadline lapses) before
returning anything, regardless of wait_for_completion - that field only
controls whether the response carries a result. So immediate mode
dispatches the run from a detached thread instead of waiting on it: the
browser gets the thank-you page right away, and the run proceeds in the
background. The scheduler slot claimed before dispatch is still released
correctly, the same way a scheduled dispatch's is - via the runner's
execution.completed/failed/cancelled/waiting report to
/api/v1/internal/execution-event.

wait mode is unchanged and still blocks for the workflow's own
respond-to-webhook response.
fszontagh 1 ماه پیش
والد
کامیت
a69fa316b7
2فایلهای تغییر یافته به همراه85 افزوده شده و 2 حذف شده
  1. 63 2
      src/webserver/api/webhook_controller.cpp
  2. 22 0
      tests/nodes/form-immediate.json

+ 63 - 2
src/webserver/api/webhook_controller.cpp

@@ -10,6 +10,7 @@
 #include <optional>
 #include <sstream>
 #include <iomanip>
+#include <thread>
 
 namespace smartbotic::webserver::api {
 
@@ -85,6 +86,9 @@ void WebhookController::handleWebhook(const httplib::Request& req, httplib::Resp
     // which the method-to-node-type mapping below cannot express, so it is
     // matched first.
     nlohmann::json form_trigger_data;
+    std::string form_response_mode = "immediate";
+    std::string form_config_title;
+    std::string form_response_message = "Thanks, your answer was received.";
     auto form_node = findFormNode(workflow);
     if (form_node.has_value()) {
         const auto form_config = form_node->value("config", nlohmann::json::object());
@@ -108,6 +112,9 @@ void WebhookController::handleWebhook(const httplib::Request& req, httplib::Resp
                 return;
             }
             form_trigger_data = form_data;  // consumed below, in place of the JSON body
+            form_response_mode = form_config.value("responseMode", "immediate");
+            form_config_title = form_config.value("title", "");
+            form_response_message = form_config.value("responseMessage", form_response_message);
         }
     }
 
@@ -219,8 +226,62 @@ void WebhookController::handleWebhook(const httplib::Request& req, httplib::Resp
     // Fired by the outside world, so it runs the published version.
     grpc_req.set_use_published(true);
     grpc_req.set_trigger_data(trigger_data.dump());
-    grpc_req.set_wait_for_completion(true);  // Wait for webhook response
-    grpc_req.set_timeout_ms(30000);  // 30 second timeout
+    // A form set to answer immediately must not hold the browser for the run.
+    // The slot claimed above is still released correctly: the runner reports
+    // execution.completed/failed/cancelled/waiting to
+    // /api/v1/internal/execution-event, and execution_controller.cpp:632
+    // releases it there - the same path fire-and-forget scheduled dispatch
+    // relies on today.
+    const bool wait_for_run = form_trigger_data.is_null() || form_response_mode != "immediate";
+    grpc_req.set_wait_for_completion(wait_for_run);
+    grpc_req.set_timeout_ms(wait_for_run ? 30000 : 0);  // 30 second timeout when waiting
+
+    if (!wait_for_run) {
+        // ExecuteWorkflow is a plain synchronous RPC: the server call runs the
+        // workflow to completion (or until the client's deadline lapses) before
+        // it returns anything, regardless of wait_for_completion - that field
+        // only controls whether the response carries a result. Holding this
+        // handler's thread on that call would hold the browser for the run no
+        // matter how the request is configured, which is exactly what
+        // "immediate" promises not to do. So the call is moved to a detached
+        // thread: the browser gets the thank-you page right away, and the run
+        // proceeds in the background. The scheduler slot claimed above is
+        // still released correctly, the same way a scheduled dispatch's is -
+        // via the runner's execution.completed/failed/cancelled/waiting
+        // report to /api/v1/internal/execution-event.
+        std::thread([this, channel, workflow_id, execution_id, grpc_req]() {
+            auto bg_stub = proto::RunnerService::NewStub(channel);
+            proto::ExecuteWorkflowResponse bg_res;
+            grpc::ClientContext bg_ctx;
+            // Nobody is waiting on this response, so the deadline only needs
+            // to bound a runner that never answers at all.
+            bg_ctx.set_deadline(std::chrono::system_clock::now() + std::chrono::hours(1));
+
+            auto bg_status = bg_stub->ExecuteWorkflow(&bg_ctx, grpc_req, &bg_res);
+
+            if (!bg_status.ok()) {
+                if (bg_status.error_code() != grpc::StatusCode::DEADLINE_EXCEEDED) {
+                    scheduler_.notifyExecutionFinished(workflow_id, execution_id);
+                }
+                LOG_ERROR("Immediate-mode webhook execution failed for workflow {}: {}",
+                          workflow_id, bg_status.error_message());
+                return;
+            }
+
+            if (bg_res.execution_id() != execution_id) {
+                LOG_WARN("Immediate-mode webhook run came back as {} but was dispatched as {} - "
+                         "moving the schedule slot",
+                         bg_res.execution_id(), execution_id);
+                scheduler_.notifyExecutionFinished(workflow_id, execution_id);
+                scheduler_.notifyExecutionStarted(workflow_id, bg_res.execution_id());
+            }
+        }).detach();
+
+        res.status = 200;
+        res.set_content(form_renderer::renderMessage(form_config_title, form_response_message),
+                        "text/html; charset=utf-8");
+        return;
+    }
 
     proto::ExecuteWorkflowResponse grpc_res;
     grpc::ClientContext grpc_ctx;

+ 22 - 0
tests/nodes/form-immediate.json

@@ -0,0 +1,22 @@
+{
+  "name": "verify-form-immediate",
+  "nodes": [
+    {"id": "n1", "name": "Form", "type": "form-trigger", "position": {"x": 0, "y": 0},
+     "config": {"title": "Slow job", "responseMode": "immediate",
+                "responseMessage": "Started, check back later.",
+                "fields": [{"name": "note", "label": "Note", "type": "text"}]}},
+    {"id": "n2", "name": "Slow", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "const end = Date.now() + 40000; while (Date.now() < end) {} return { done: true };",
+                "timeout": 60}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"}
+  ],
+  "http": {
+    "method": "POST",
+    "path": "/webhook/{workflowId}",
+    "multipart": {"fields": {"note": "hello"}, "files": {}},
+    "expectStatus": 200,
+    "expectBodyContains": ["Started, check back later."]
+  }
+}