Skip to content

Commit 8907560

Browse files
[cross-repo from server#244] Conformance: run signals/queries against current published artifacts (#597)
1 parent cd3923a commit 8907560

14 files changed

Lines changed: 1461 additions & 73 deletions

docs/api-stability.md

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ surface has *which* machine-readable spec, *what format* the spec uses,
2121
- <https://durable-workflow.github.io/docs/2.0/platform-protocol-specs>
2222
- machine-readable mirror: `platform_protocol_specs` in
2323
`GET /api/cluster/info`, schema
24-
`durable-workflow.v2.platform-protocol-specs.catalog`, version `1`.
24+
`durable-workflow.v2.platform-protocol-specs.catalog`, version `13`.
2525
- in-process source: `Workflow\V2\Support\PlatformProtocolSpecs`, which
2626
the standalone server re-exports verbatim.
2727

@@ -179,6 +179,7 @@ Laravel queue runner:
179179

180180
- `Workflow\V2\Worker\WorkerProtocolClient`
181181
- `Workflow\V2\Worker\WorkflowFiberRunner`
182+
- `Workflow\V2\Worker\WorkflowQueryTaskExecutor`
182183
- `Workflow\V2\Worker\WorkflowStep`
183184

184185
These classes are covered by the same semver rules as the server-facing
@@ -197,13 +198,20 @@ history and has no terminal outcome yet, the step contains no commands; the
197198
worker must wait for a later history payload instead of duplicating the
198199
schedule command.
199200
`WorkerProtocolClient` defaults to the standalone server worker API:
200-
registration, heartbeat, workflow-task polling/history/complete/fail, and
201-
activity-task polling/heartbeat/complete/fail all use `POST /api/worker/...`
202-
with the worker-protocol headers. Standalone `poll*` methods return leased
203-
tasks from the server's `task` envelope; the client caches the returned
204-
lease fields so follow-up history, heartbeat, complete, and fail calls can
205-
send the required `lease_owner`, `workflow_task_attempt`, and
206-
`activity_attempt_id` values.
201+
registration, heartbeat, workflow-task polling/history/complete/fail,
202+
activity-task polling/heartbeat/complete/fail, and query-task
203+
polling/complete/fail all use `POST /api/worker/...` with the
204+
worker-protocol headers. Standalone `poll*` methods return leased tasks from
205+
the server's `task` envelope; the client caches the returned lease fields so
206+
follow-up history, heartbeat, complete, and fail calls can send the required
207+
`lease_owner`, `workflow_task_attempt`, `activity_attempt_id`, and
208+
`query_task_attempt` values.
209+
`WorkflowQueryTaskExecutor` is the stable PHP worker shim for the
210+
`query_tasks` capability. It accepts the server-routed query task envelope,
211+
replays the supplied history export in query mode, validates query targets
212+
and arguments against the same contract as `WorkflowStub::queryWithArguments`,
213+
and returns either an encoded result envelope or a typed query failure for
214+
`WorkerProtocolClient` to complete or fail the query task.
207215
Embedded package installs that need the `/webhooks` bridge contract must opt
208216
into embedded bridge mode explicitly; in that mode `poll*` methods return
209217
ready task opportunities as `tasks` lists and workers explicitly claim a task

docs/architecture/query-and-live-debug.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,52 @@ the caller (for example `'run'` versus `'instance-current'`) so
164164
downstream clients can distinguish a query against a specific
165165
run from a query against the current run for an instance.
166166

167+
## Worker-routed query tasks
168+
169+
Standalone workers MAY execute queries through the worker-plane
170+
query task protocol instead of in the caller's HTTP process. This
171+
surface is part of the same non-durable query contract and is
172+
advertised by `Workflow\V2\Support\WorkerProtocolVersion::describe()`
173+
under `query_task_verbs` and `query_tasks`.
174+
175+
Workers that can execute server-routed queries advertise the
176+
`query_tasks` worker capability when registering. The server may then
177+
lease query work through:
178+
179+
- `POST /api/worker/query-tasks/poll`
180+
- `POST /api/worker/query-tasks/{query_task_id}/complete`
181+
- `POST /api/worker/query-tasks/{query_task_id}/fail`
182+
183+
The poll request names `worker_id` and `task_queue`. A successful
184+
poll leases at most one query task and returns `poll_status =
185+
'leased'`; an empty long-poll returns `poll_status = 'empty'` with
186+
no task. Query task long-poll timeout semantics are the same
187+
clamped `WorkerProtocolVersion::longPollSemantics()` used by
188+
workflow and activity task polling.
189+
190+
Each leased query task carries `query_task_id`,
191+
`query_task_attempt`, `lease_owner`, `workflow_id`, `run_id`,
192+
`query_name`, payload codec information, query arguments, and either
193+
a `history_export` snapshot or enough history fields for the worker
194+
to reconstruct one. The worker replays committed history with
195+
commands suppressed, invokes the declared query method, and then
196+
returns exactly one terminal query-task outcome.
197+
198+
Completion requires `lease_owner`, `query_task_attempt`, `result`,
199+
and optionally `result_envelope` with `codec`, `blob`, or
200+
`external_storage`. Failure requires `lease_owner`,
201+
`query_task_attempt`, and `failure` with `message` plus optional
202+
`reason`, `type`, `stack_trace`, and `validation_errors`; known SDK
203+
reasons include `rejected_unknown_query`, `invalid_query_arguments`,
204+
and `query_rejected`.
205+
206+
Completing or failing a query task resolves the waiting query
207+
request only. It MUST NOT append a workflow history event, create a
208+
workflow command, dispatch a wake notification for workflow work, or
209+
mutate the run. Adding, removing, or reshaping any query-task verb,
210+
field, or outcome is a worker protocol change and requires a
211+
`WorkerProtocolVersion::VERSION` bump.
212+
167213
## Non-durability guarantees
168214

169215
A successful query MUST leave zero durable state behind.

src/V2/Support/PlatformProtocolSpecs.php

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ final class PlatformProtocolSpecs
3838
{
3939
public const SCHEMA = 'durable-workflow.v2.platform-protocol-specs.catalog';
4040

41-
public const VERSION = 12;
41+
public const VERSION = 13;
4242

4343
public const AUTHORITY_URL = 'https://durable-workflow.github.io/docs/2.0/platform-protocol-specs';
4444

@@ -227,7 +227,7 @@ private static function specs(): array
227227
'spec_path' => 'static/platform-protocol-specs/control-plane-api.openapi.yaml',
228228
],
229229
'worker_protocol_api' => [
230-
'description' => 'OpenAPI specification for the worker-plane HTTP+JSON API: register, poll, heartbeat, worker-session lifecycle, complete, and fail for workflow and activity tasks. The companion AsyncAPI document `worker_protocol_stream` describes the long-poll and lease-renewal semantics.',
230+
'description' => 'OpenAPI specification for the worker-plane HTTP+JSON API: register, poll, heartbeat, worker-session lifecycle, complete, and fail for workflow, activity, and query tasks. Query-task routes are lease-fenced request/response work and are advertised through the `query_tasks` worker capability. The companion AsyncAPI document `worker_protocol_stream` describes the long-poll and lease-renewal semantics.',
231231
'format' => self::FORMAT_OPENAPI,
232232
'spec_id' => 'durable-workflow.v2.worker-protocol-api',
233233
'surface_family' => 'worker_protocol',
@@ -253,6 +253,18 @@ private static function specs(): array
253253
'schema_authority' => 'App\\Http\\Controllers\\Api\\WorkerController task completion/failure actions',
254254
'version_authority' => 'Workflow\\V2\\Support\\WorkerProtocolVersion::VERSION',
255255
],
256+
[
257+
'name' => 'worker_query_task_poll_request',
258+
'owner_repo' => 'durable-workflow/server',
259+
'schema_authority' => 'App\\Http\\Controllers\\Api\\WorkerController::pollQueryTasks and App\\Support\\WorkflowQueryTaskBroker::poll',
260+
'version_authority' => 'Workflow\\V2\\Support\\WorkerProtocolVersion::VERSION',
261+
],
262+
[
263+
'name' => 'worker_query_task_result',
264+
'owner_repo' => 'durable-workflow/server',
265+
'schema_authority' => 'App\\Http\\Controllers\\Api\\WorkerController::completeQueryTask/failQueryTask and App\\Support\\WorkflowQueryTaskBroker terminal outcomes',
266+
'version_authority' => 'Workflow\\V2\\Support\\WorkerProtocolVersion::VERSION',
267+
],
256268
[
257269
'name' => 'external_task_input_contract',
258270
'owner_repo' => 'durable-workflow/server',
@@ -269,7 +281,7 @@ private static function specs(): array
269281
'evolution_rule' => self::EVOLUTION_ADDITIVE_MINOR_BREAKING_MAJOR,
270282
'breaking_change_release' => 'major',
271283
'discovery_endpoint' => 'GET /api/cluster/info -> worker_protocol',
272-
'conformance_test' => 'durable-workflow/server: tests/Feature/WorkerProtocolContractTest.php, tests/Feature/WorkerProtocolSuccessContractTest.php, tests/Feature/WorkerProtocolOwnershipErrorContractTest.php, tests/Feature/WorkerProtocolVersionCoverageTest.php, tests/Feature/WorkflowWorkerProtocolTest.php, and tests/Feature/ActivityWorkerProtocolTest.php',
284+
'conformance_test' => 'durable-workflow/server: tests/Feature/WorkerProtocolContractTest.php, tests/Feature/WorkerProtocolSuccessContractTest.php, tests/Feature/WorkerProtocolOwnershipErrorContractTest.php, tests/Feature/WorkerProtocolVersionCoverageTest.php, tests/Feature/WorkflowWorkerProtocolTest.php, tests/Feature/ActivityWorkerProtocolTest.php, and tests/Feature/WorkflowQueryTaskBrokerTest.php; durable-workflow/workflow: tests/Unit/V2/WorkerProtocolClientTest.php and tests/Unit/V2/WorkflowQueryTaskExecutorTest.php',
273285
'status' => self::STATUS_PUBLISHED,
274286
'spec_path' => 'static/platform-protocol-specs/worker-protocol-api.openapi.yaml',
275287
],

src/V2/Support/SurfaceStabilityContract.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ private static function surfaceFamilies(): array
177177
'notes' => 'Per-route version is governed by the `control_plane.request_contract` and `control_plane.response.contract` schema/version pairs published from `/api/cluster/info`. The top-level server `version` is build identity, not the client compatibility authority.',
178178
],
179179
'worker_protocol' => [
180-
'description' => 'Worker-plane HTTP API used by external SDK workers to register, poll, heartbeat, complete, and fail workflow and activity tasks.',
180+
'description' => 'Worker-plane HTTP API used by external SDK workers to register, poll, heartbeat, complete, and fail workflow, activity, and query tasks.',
181181
'stability_level' => self::STABILITY_STABLE,
182182
'authority_manifest' => 'worker_protocol',
183183
'requires_protocol_header' => 'X-Durable-Workflow-Protocol-Version',

src/V2/Support/WorkerProtocolVersion.php

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,14 @@ final class WorkerProtocolVersion
2929
* pagination semantics). Bump the minor for additive changes (new
3030
* optional fields, new non-terminal command types).
3131
*/
32-
public const VERSION = '1.5';
32+
public const VERSION = '1.6';
33+
34+
/**
35+
* Worker registration capability for server-routed workflow query
36+
* tasks. Workers that advertise this capability may receive query work
37+
* through the query task poll/complete/fail endpoints.
38+
*/
39+
public const CAPABILITY_QUERY_TASKS = 'query_tasks';
3340

3441
/**
3542
* Stable fail-closed reason a worker or server must return when it
@@ -128,6 +135,31 @@ public static function activityTaskVerbs(): array
128135
return ['poll', 'claim', 'claimStatus', 'complete', 'fail', 'status', 'heartbeat'];
129136
}
130137

138+
/**
139+
* Query task verbs exposed by the standalone worker protocol.
140+
*
141+
* Query tasks are server-routed, lease-fenced request/response work
142+
* items. They replay committed history in a worker process, return a
143+
* query result or typed failure to the waiting caller, and never append
144+
* workflow history.
145+
*
146+
* @return list<string>
147+
*/
148+
public static function queryTaskVerbs(): array
149+
{
150+
return ['poll', 'complete', 'fail'];
151+
}
152+
153+
/**
154+
* Worker registration capabilities with protocol-defined semantics.
155+
*
156+
* @return list<string>
157+
*/
158+
public static function workerCapabilities(): array
159+
{
160+
return [self::CAPABILITY_QUERY_TASKS];
161+
}
162+
131163
/**
132164
* Non-terminal command types that an external worker may return
133165
* from a workflow task completion.
@@ -238,6 +270,8 @@ public static function taskQueuePriorityFairnessSemantics(): array
238270
* version: string,
239271
* workflow_task_verbs: list<string>,
240272
* activity_task_verbs: list<string>,
273+
* query_task_verbs: list<string>,
274+
* worker_capabilities: list<string>,
241275
* non_terminal_command_types: list<string>,
242276
* terminal_command_types: list<string>,
243277
* history_pagination: array{default_page_size: int, max_page_size: int},
@@ -247,6 +281,7 @@ public static function taskQueuePriorityFairnessSemantics(): array
247281
* worker_session_verbs: list<string>,
248282
* sticky_execution: array<string, mixed>,
249283
* worker_sessions: array<string, mixed>,
284+
* query_tasks: array<string, mixed>,
250285
* invocable_carrier: array<string, mixed>,
251286
* task_queue_priority_fairness: array<string, mixed>,
252287
* }
@@ -257,6 +292,8 @@ public static function describe(): array
257292
'version' => self::VERSION,
258293
'workflow_task_verbs' => self::workflowTaskVerbs(),
259294
'activity_task_verbs' => self::activityTaskVerbs(),
295+
'query_task_verbs' => self::queryTaskVerbs(),
296+
'worker_capabilities' => self::workerCapabilities(),
260297
'non_terminal_command_types' => self::nonTerminalCommandTypes(),
261298
'terminal_command_types' => self::terminalCommandTypes(),
262299
'history_pagination' => [
@@ -272,6 +309,7 @@ public static function describe(): array
272309
'worker_session_verbs' => self::workerSessionVerbs(),
273310
'sticky_execution' => StickyExecution::describe(),
274311
'worker_sessions' => self::workerSessionSemantics(),
312+
'query_tasks' => self::queryTaskSemantics(),
275313
'payload_codecs_universal' => CodecRegistry::universal(),
276314
'payload_codecs_engine_specific' => CodecRegistry::engineSpecific(),
277315
'unsupported_payload_codec_reason' => self::REASON_UNSUPPORTED_PAYLOAD_CODEC,
@@ -280,6 +318,86 @@ public static function describe(): array
280318
];
281319
}
282320

321+
/**
322+
* Published server-routed workflow query task contract.
323+
*
324+
* @return array<string, mixed>
325+
*/
326+
public static function queryTaskSemantics(): array
327+
{
328+
return [
329+
'feature' => self::CAPABILITY_QUERY_TASKS,
330+
'minimum_protocol_version' => self::VERSION,
331+
'worker_capability' => self::CAPABILITY_QUERY_TASKS,
332+
'verbs' => self::queryTaskVerbs(),
333+
'path_prefix' => '/api/worker/query-tasks',
334+
'endpoints' => [
335+
'poll' => [
336+
'method' => 'POST',
337+
'path' => '/api/worker/query-tasks/poll',
338+
'request_fields' => ['worker_id', 'task_queue'],
339+
'response_fields' => ['task', 'poll_status'],
340+
],
341+
'complete' => [
342+
'method' => 'POST',
343+
'path' => '/api/worker/query-tasks/{query_task_id}/complete',
344+
'request_fields' => [
345+
'lease_owner',
346+
'query_task_attempt',
347+
'result',
348+
'result_envelope',
349+
],
350+
],
351+
'fail' => [
352+
'method' => 'POST',
353+
'path' => '/api/worker/query-tasks/{query_task_id}/fail',
354+
'request_fields' => ['lease_owner', 'query_task_attempt', 'failure'],
355+
],
356+
],
357+
'poll' => [
358+
'leases_on_return' => true,
359+
'long_poll' => self::longPollSemantics(),
360+
'empty_response_poll_status' => 'empty',
361+
'requires_registered_worker' => true,
362+
'requires_worker_capability' => self::CAPABILITY_QUERY_TASKS,
363+
],
364+
'task_fields' => [
365+
'query_task_id',
366+
'query_task_attempt',
367+
'lease_owner',
368+
'workflow_id',
369+
'run_id',
370+
'task_queue',
371+
'workflow_type',
372+
'workflow_class',
373+
'query_name',
374+
'query_arguments',
375+
'payload_codec',
376+
'history_export',
377+
'history_events',
378+
],
379+
'completion' => [
380+
'requires_lease_owner' => true,
381+
'requires_query_task_attempt' => true,
382+
'result_envelope_fields' => ['codec', 'blob', 'external_storage'],
383+
'terminal_for_query_task' => true,
384+
],
385+
'failure' => [
386+
'requires_lease_owner' => true,
387+
'requires_query_task_attempt' => true,
388+
'failure_fields' => ['message', 'reason', 'type', 'stack_trace', 'validation_errors'],
389+
'known_reasons' => ['rejected_unknown_query', 'invalid_query_arguments', 'query_rejected'],
390+
'terminal_for_query_task' => true,
391+
],
392+
'durability' => [
393+
'history_event_appended' => false,
394+
'workflow_command_created' => false,
395+
'result_resolves_waiting_query_request' => true,
396+
'query_replay_must_suppress_commands' => true,
397+
],
398+
];
399+
}
400+
283401
/**
284402
* Published invocable HTTP carrier wire-protocol contract.
285403
*

0 commit comments

Comments
 (0)