Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/Http/Controllers/Api/BridgeAdapterController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Support\AvroPayloadEnvelopeResolver;
use App\Support\BridgeAdapterOutcomeContract;
use App\Support\ControlPlaneProtocol;
use App\Support\ControlPlaneRequestContract;
use App\Support\NamespaceExternalPayloadStorage;
use App\Support\NamespaceWorkflowScope;
use App\Support\TaskQueueRoutingGate;
Expand Down Expand Up @@ -332,7 +333,7 @@ private function updateWorkflow(
'workflow_id' => ['required', 'string', 'max:128', 'regex:/^[a-zA-Z0-9._:-]+$/'],
'update_name' => ['required', 'string', 'max:255'],
'wait_for' => ['nullable', 'string', 'in:accepted,completed'],
'wait_timeout_seconds' => ['nullable', 'integer', 'min:1'],
'wait_timeout_seconds' => ['nullable', 'integer', 'min:1', 'max:'.ControlPlaneRequestContract::UPDATE_WAIT_TIMEOUT_SECONDS_MAX],
]);

if ($targetValidator->fails()) {
Expand Down
4 changes: 4 additions & 0 deletions app/Http/Controllers/Api/ServiceCatalogController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use App\Support\SearchAttributeValueValidator;
use App\Support\ServiceCallAdmission;
use App\Support\ServiceCallBoundary;
use App\Support\ServiceOperationUpdateWait;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
Expand Down Expand Up @@ -656,6 +657,9 @@ public function executeOperation(
],
]);

// An update-bound operation hands its wait to the update admission.
ServiceOperationUpdateWait::assertWithinCeiling($operation, $validated['wait_timeout_seconds'] ?? null);

try {
$validated['payload_codec'] = PayloadCodecContract::canonicalize(
$validated['payload_codec'] ?? null,
Expand Down
9 changes: 9 additions & 0 deletions app/Http/Controllers/Api/WorkerController.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
use App\Support\SearchAttributeValueValidator;
use App\Support\ServiceCallBoundary;
use App\Support\ServiceModeTimerDispatcher;
use App\Support\ServiceOperationUpdateWait;
use App\Support\StreamClosedException;
use App\Support\StreamErroredException;
use App\Support\StreamFullException;
Expand Down Expand Up @@ -3195,6 +3196,14 @@ private function authorizeServiceOperationCommands(Request $request, string $nam
]);
abort_unless($principal !== null && app(AuthProvider::class)->authorize($principal, 'execute_operation', $resource), 403);

// An update-bound operation hands its wait to the update admission.
ServiceOperationUpdateWait::assertWithinCeiling(
ServiceOperationUpdateWait::operation($targetNamespace, (string) $command['endpoint_name'],
(string) $command['service_name'], (string) $command['operation_name']),
$command['wait_timeout_seconds'] ?? null,
"commands.{$index}.wait_timeout_seconds",
);

if (isset($command['search_attributes'])) {
$this->searchAttributeValues->validateForNamespace(
$targetNamespace,
Expand Down
3 changes: 2 additions & 1 deletion app/Http/Controllers/Api/WorkflowController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use App\Http\Middleware\Authenticate;
use App\Support\AvroPayloadEnvelopeResolver;
use App\Support\ControlPlaneProtocol;
use App\Support\ControlPlaneRequestContract;
use App\Support\ControlPlaneResponseContract;
use App\Support\ControlPlaneResultMapper;
use App\Support\ExternalPayloadEnvelopeService;
Expand Down Expand Up @@ -750,7 +751,7 @@ public function update(Request $request, string $workflowId, string $updateName)
'input' => ['nullable', 'array'],
'request_id' => ['nullable', 'string', 'max:255'],
'wait_for' => ['nullable', 'string', 'in:accepted,completed'],
'wait_timeout_seconds' => ['nullable', 'integer', 'min:1'],
'wait_timeout_seconds' => ['nullable', 'integer', 'min:1', 'max:'.ControlPlaneRequestContract::UPDATE_WAIT_TIMEOUT_SECONDS_MAX],
]);

$this->rejectLegacyUpdateFields($request);
Expand Down
2 changes: 2 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
use App\Support\ServiceModeBusDispatcher;
use App\Support\SharedServiceBoundaryPolicy;
use App\Support\StoragePressure;
use App\Support\UpdateWaitConfiguration;
use App\Support\ValidatedExternalWorkflowUpdateAdmission;
use App\Support\WorkflowMemoRollingCompatibility;
use App\Support\WorkflowObservability;
Expand Down Expand Up @@ -204,6 +205,7 @@ public function boot(): void

WorkflowTaskLeaseConfiguration::apply();
ActivityTaskLeaseConfiguration::apply();
UpdateWaitConfiguration::apply();
WorkflowMemoRollingCompatibility::register();

config([
Expand Down
11 changes: 11 additions & 0 deletions app/Support/ControlPlaneRequestContract.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ final class ControlPlaneRequestContract

public const VERSION = 1;

/**
* The longest `wait_timeout_seconds` an update caller may request. A
* completion wait holds the request open, so the server bounds it; a
* longer request is refused before the update is admitted.
*/
public const UPDATE_WAIT_TIMEOUT_SECONDS_MAX = 60;

/**
* @return array{
* schema: string,
Expand Down Expand Up @@ -91,6 +98,10 @@ public static function manifest(): array
'wait_for' => [
'canonical_values' => ['accepted', 'completed'],
],
'wait_timeout_seconds' => [
'minimum' => 1,
'maximum' => self::UPDATE_WAIT_TIMEOUT_SECONDS_MAX,
],
],
'removed_fields' => [
'wait_policy' => 'Use wait_for.',
Expand Down
15 changes: 15 additions & 0 deletions app/Support/ServerWorkflowControlPlane.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Support;

use App\Contracts\RuntimeSignalControlPlane;
use Illuminate\Validation\ValidationException;
use Throwable;
use Workflow\Serializers\Serializer;
use Workflow\V2\CommandContext;
Expand Down Expand Up @@ -126,6 +127,20 @@ public function query(string $instanceId, string $name, array $options = []): ar

public function update(string $instanceId, string $name, array $options = []): array
{
// Every update admission waits at most the update ceiling, whichever
// route reached it (Server #775). The routes refuse a longer wait
// with their own validation first; this is the boundary a service
// operation's update binding reaches after its catalog lookup.
$seconds = $options['wait_timeout_seconds'] ?? null;
if (is_numeric($seconds) && (int) $seconds > ControlPlaneRequestContract::UPDATE_WAIT_TIMEOUT_SECONDS_MAX) {
throw ValidationException::withMessages([
'wait_timeout_seconds' => [sprintf(
'The wait timeout seconds field must not be greater than %d.',
ControlPlaneRequestContract::UPDATE_WAIT_TIMEOUT_SECONDS_MAX,
)],
]);
}

return $this->mutations->run(
fn (): array => $this->inner->update($instanceId, $name, $options),
);
Expand Down
80 changes: 80 additions & 0 deletions app/Support/ServiceOperationUpdateWait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

namespace App\Support;

use Illuminate\Support\Facades\Validator;
use Workflow\V2\Models\WorkflowService;
use Workflow\V2\Models\WorkflowServiceEndpoint;
use Workflow\V2\Models\WorkflowServiceOperation;

/**
* The update wait ceiling on service operations bound to a workflow update
* (Server #775).
*
* A service operation's own `wait_timeout_seconds` contract (up to 86400 on
* the execute route) is unchanged. When the operation's binding is a workflow
* update, though, the service control plane hands that wait to the update
* admission, so it is refused above the update ceiling before the operation
* runs, as the update route refuses it.
*/
final class ServiceOperationUpdateWait
{
public static function bindsWorkflowUpdate(?WorkflowServiceOperation $operation): bool
{
return $operation !== null
&& in_array(strtolower(trim((string) $operation->handler_binding_kind)), ['workflow_update', 'update_workflow'], true);
}

/**
* Refuses a wait above the ceiling for an update-bound operation, with the
* validation message the update route gives. `$path` is the dotted
* attribute the caller sent the field as.
*/
public static function assertWithinCeiling(?WorkflowServiceOperation $operation, mixed $seconds, string $path = 'wait_timeout_seconds'): void
{
if ($seconds === null || ! self::bindsWorkflowUpdate($operation)) {
return;
}

$data = [];
data_set($data, $path, $seconds);

// A nested command field is named as workflow-task completion
// validation names its wildcard fields, underscores kept.
Validator::make($data, [
$path => ['integer', 'max:'.ControlPlaneRequestContract::UPDATE_WAIT_TIMEOUT_SECONDS_MAX],
], [], str_contains($path, '.') ? [$path => $path] : [])->validate();
}

/**
* The catalog operation a workflow-task `start_service_operation`
* command names, in its target namespace.
*/
public static function operation(string $namespace, string $endpointName, string $serviceName, string $operationName): ?WorkflowServiceOperation
{
$endpoint = WorkflowServiceEndpoint::query()
->where('namespace', $namespace)
->where('endpoint_name', strtolower(trim($endpointName)))
->first();

if ($endpoint === null) {
return null;
}

$service = WorkflowService::query()
->where('namespace', $namespace)
->where('workflow_service_endpoint_id', $endpoint->id)
->where('service_name', strtolower(trim($serviceName)))
->first();

if ($service === null) {
return null;
}

return WorkflowServiceOperation::query()
->where('namespace', $namespace)
->where('workflow_service_id', $service->id)
->where('operation_name', strtolower(trim($operationName)))
->first();
}
}
30 changes: 30 additions & 0 deletions app/Support/UpdateWaitConfiguration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace App\Support;

use Workflow\V2\Support\UpdateWaitPolicy;

/**
* Bounds the configured update completion wait (Server #775).
*
* A completion wait holds the request open. A caller's own
* `wait_timeout_seconds` above the ceiling is refused at the route; the
* operator's default (DW_V2_UPDATE_WAIT_COMPLETION_TIMEOUT_SECONDS), used
* when the request omits the field, is clamped to the same ceiling.
*/
final class UpdateWaitConfiguration
{
public const CONFIG_KEY = 'workflows.v2.update_wait.completion_timeout_seconds';

public static function apply(): int
{
$effective = min(
UpdateWaitPolicy::completionTimeoutSeconds(),
ControlPlaneRequestContract::UPDATE_WAIT_TIMEOUT_SECONDS_MAX,
);

config([self::CONFIG_KEY => $effective]);

return $effective;
}
}
7 changes: 6 additions & 1 deletion docs/rust-server-port.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,12 @@ any other unknown path or method answers PHP's own 404 or 405
- Durable workflow stream items and close.
- Service endpoints. (The service catalog reads from Server 2.11 are served:
native never holds catalog rows, so every list is PHP's empty page and every
identity its not-found answer; the `service-catalog` fixture checks 37 cases.)
identity its not-found answer; the `service-catalog` fixture checks 37 cases.
Native neither executes service operations nor accepts the workflow-task
`start_service_operation` command, which it refuses as
`unsupported_workflow_command`. When it does, an operation bound to a
workflow update must refuse a `wait_timeout_seconds` above the update
ceiling of 60 before it runs, as PHP does (Server #775).)
- Bridge adapters.
- (Caller-indexed Nexus operation reads are served, with the
`nexus-operations` fixture. Native writes no service calls and takeover
Expand Down
14 changes: 13 additions & 1 deletion docs/server-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,18 @@ requests the retained completion result. This PHP admission correction is
tracked in [#493](https://github.com/durable-workflow/server/issues/493); older
affected releases can return HTTP 500 when a key is reused for another name.

A completion wait holds the request open, so the server bounds it. An update's
`wait_timeout_seconds` must be an integer from 1 to 60, on the update route and
on bridge `update_workflow` targets. A larger value is refused with HTTP 422
(`validation_failed`) before the update is admitted, and nothing is recorded.
When the field is absent, the wait lasts
`DW_V2_UPDATE_WAIT_COMPLETION_TIMEOUT_SECONDS`, which is also capped at 60. If the update is still
incomplete when the wait ends, the response reports `wait_timed_out: true`;
the update itself continues, and its status can be read later. The control-plane
request contract publishes the bound as
`operations.update.fields.wait_timeout_seconds.maximum`
([#775](https://github.com/durable-workflow/server/issues/775)).

Workflow starts fail closed with `409` / `reason: "task_queue_draining"`
when the requested task queue has been explicitly drained and no active worker
cohort remains to claim new work. The response includes
Expand Down Expand Up @@ -1905,7 +1917,7 @@ inside the package's `config/workflows.php` via
| `DW_V2_HISTORY_FAN_OUT_WARNING_THRESHOLD` | `160` | Largest recorded parallel group size at which history pressure becomes `approaching`. |
| `DW_V2_HISTORY_EXPORT_SIGNING_KEY` | (unset) | Optional HMAC key authenticating history export archives. |
| `DW_V2_HISTORY_EXPORT_SIGNING_KEY_ID` | (unset) | Optional key identifier recorded alongside signed exports. |
| `DW_V2_UPDATE_WAIT_COMPLETION_TIMEOUT_SECONDS` | `10` | Seconds the server waits for an update to reach a terminal stage. |
| `DW_V2_UPDATE_WAIT_COMPLETION_TIMEOUT_SECONDS` | `10` | Seconds the server waits for an update to reach a terminal stage when the request omits `wait_timeout_seconds`. Values above 60 are treated as 60; a request may ask for 1 to 60 seconds. |
| `DW_V2_UPDATE_WAIT_POLL_INTERVAL_MS` | `50` | Milliseconds between update-stage polls. |
| `DW_V2_GUARDRAILS_BOOT` | `warn` | Boot-time structural guardrail mode: `warn`, `fail`, or `silent`. |
| `DW_V2_LIMIT_PENDING_ACTIVITIES` | `2000` | Package-level pending-activity ceiling per run. |
Expand Down
11 changes: 10 additions & 1 deletion rust/src/config_audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,14 @@ where
}
}

/// The longest update completion wait, whether a caller asks for it or the
/// operator configures the default (Server #775,
/// `ControlPlaneRequestContract::UPDATE_WAIT_TIMEOUT_SECONDS_MAX`).
pub const UPDATE_WAIT_TIMEOUT_SECONDS_MAX: i64 = 60;

/// `UpdateWaitPolicy::completionTimeoutSeconds`: the package casts the
/// setting with `(int)`, then takes at least one second; unset means 10.
/// The server's `UpdateWaitConfiguration` caps it at 60.
pub fn update_wait_completion_timeout_seconds<F>(lookup: F) -> i64
where
F: Fn(&str) -> Option<String>,
Expand All @@ -221,7 +227,7 @@ where
"DW_V2_UPDATE_WAIT_COMPLETION_TIMEOUT_SECONDS",
Some("WORKFLOW_V2_UPDATE_WAIT_COMPLETION_TIMEOUT_SECONDS"),
)
.map_or(10, |value| value.int().max(1))
.map_or(10, |value| value.int().clamp(1, UPDATE_WAIT_TIMEOUT_SECONDS_MAX))
}

/// `server.update_validation`: how long an update request waits for its
Expand Down Expand Up @@ -916,6 +922,9 @@ mod tests {
assert_eq!(read(&[("DW_V2_UPDATE_WAIT_COMPLETION_TIMEOUT_SECONDS", "0")]), 1);
assert_eq!(read(&[("DW_V2_UPDATE_WAIT_COMPLETION_TIMEOUT_SECONDS", "never")]), 1);
assert_eq!(read(&[("DW_V2_UPDATE_WAIT_COMPLETION_TIMEOUT_SECONDS", "null")]), 10);
assert_eq!(read(&[("DW_V2_UPDATE_WAIT_COMPLETION_TIMEOUT_SECONDS", "60")]), 60);
assert_eq!(read(&[("DW_V2_UPDATE_WAIT_COMPLETION_TIMEOUT_SECONDS", "61")]), 60);
assert_eq!(read(&[("WORKFLOW_V2_UPDATE_WAIT_COMPLETION_TIMEOUT_SECONDS", "600")]), 60);
}

#[test]
Expand Down
12 changes: 11 additions & 1 deletion rust/src/runtime/command_requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@ const SIGNAL_RULES: &[(&str, &[Rule])] = &[
("dry_run", &[Nullable, Boolean]),
];

/// `ControlPlaneRequestContract::UPDATE_WAIT_TIMEOUT_SECONDS_MAX`: the
/// longest completion wait an update caller may ask for (Server #775). A
/// longer request is refused before the update is admitted.
pub(super) const UPDATE_WAIT_TIMEOUT_SECONDS_MAX: i64 = crate::config_audit::UPDATE_WAIT_TIMEOUT_SECONDS_MAX;

const UPDATE_RULES: &[(&str, &[Rule])] = &[
("input", &[Nullable, Array]),
("request_id", &[Nullable, Rule::String, Max(255)]),
("wait_for", &[Nullable, Rule::String, In(&["accepted", "completed"])]),
("wait_timeout_seconds", &[Nullable, Integer, Min(1)]),
("wait_timeout_seconds", &[Nullable, Integer, Min(1), Max(UPDATE_WAIT_TIMEOUT_SECONDS_MAX)]),
];

fn defaults(_: &str, _: Rule) -> Option<&'static str> {
Expand Down Expand Up @@ -80,6 +85,11 @@ mod tests {
json!({"wait_for":["The selected wait for is invalid."],
"wait_timeout_seconds":["The wait timeout seconds field must be at least 1."]})
);
assert!(update(&json!({"wait_for":"completed","wait_timeout_seconds":UPDATE_WAIT_TIMEOUT_SECONDS_MAX})).is_ok());
assert_eq!(
errors(update(&json!({"wait_for":"completed","wait_timeout_seconds":UPDATE_WAIT_TIMEOUT_SECONDS_MAX + 1}))),
json!({"wait_timeout_seconds":["The wait timeout seconds field must not be greater than 60."]})
);
assert_eq!(
errors(update(&json!({"wait_policy":"accepted"}))),
json!({"wait_policy":["The wait_policy field is no longer supported. Use wait_for."]})
Expand Down
3 changes: 2 additions & 1 deletion rust/src/runtime/updates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,8 @@ where
let rules: &[(&str, &[Rule])] = &[
("request_id", &[Rule::Nullable, Rule::String, Rule::Max(255)]),
("wait_for", &[Rule::Nullable, Rule::String, Rule::In(&["accepted", "completed"])]),
("wait_timeout_seconds", &[Rule::Nullable, Rule::Integer, Rule::Min(1)]),
("wait_timeout_seconds", &[Rule::Nullable, Rule::Integer, Rule::Min(1),
Rule::Max(super::command_requests::UPDATE_WAIT_TIMEOUT_SECONDS_MAX)]),
];
if let Some(error) = failure_messages(rule_errors(&request, rules, |_, _| None)) {
return Err(error);
Expand Down
3 changes: 2 additions & 1 deletion rust/src/runtime/visibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ pub(super) fn request_contract() -> Value {
json!({"schema":"durable-workflow.v2.control-plane-request.contract", "version":1,
"operations":{"list":{"fields":{"status":{"canonical_values":["running","completed","failed"],
"rejected_aliases":{"cancelled":"failed","terminated":"failed","pending":"running","waiting":"running"}}}},
"update":{"fields":{"wait_for":{"canonical_values":["accepted","completed"]}}}}})
"update":{"fields":{"wait_for":{"canonical_values":["accepted","completed"]},
"wait_timeout_seconds":{"minimum":1,"maximum":super::command_requests::UPDATE_WAIT_TIMEOUT_SECONDS_MAX}}}}})
}

fn response(body: Value) -> Value {
Expand Down
Loading
Loading