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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
}
},
"durable-workflow": {
"product-train": "2.2.8",
"product-train": "2.3.0",
"supported-server-versions": "2.5.0",
"worker-protocol-version": "1.19",
"control-plane-version": "2",
Expand Down
46 changes: 46 additions & 0 deletions docs/portal/operate/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,52 @@ Confirm the client's `taskQueue` exactly matches the worker's queue. Then confir

If the workflow scheduled an activity, repeat the same check for the activity type. A workflow worker can be healthy while the activity capacity it needs is absent.

## Inspect a durable service call

Use an operator credential in the namespace you are investigating. The
service catalog read methods return bounded pages and the original durable
call, including its deadline, attempts and terminal cause.

```php
$page = $client->listServiceCatalogCalls([
'scope' => 'caller',
'status_bucket' => 'failed',
'per_page' => 50,
]);
foreach ($page['data'] as $row) {
$call = $client->describeServiceCatalogCall($row['id']);
}
```

Browse registered endpoints, services and operations with
`listServiceCatalogEndpoints()`, `listServiceCatalogServices()` and
`listServiceCatalogOperations()`. Their corresponding
`describeServiceCatalogEndpoint()`, `describeServiceCatalogService()` and
`describeServiceCatalogOperation()` methods use catalog IDs. Lists accept
`page` and `per_page`. Filter services by `endpoint_id`, operations by
`endpoint_id` or `service_id`, and calls by those IDs or `operation_id`.

The methods require the backend's advertised `service_catalog_read_contract`
version 1. An absent or incompatible capability raises `ServerException` with
`reason: backend_capability_unavailable` and the required/advertised contract.
A discovery outage keeps its actual connection or backend failure. It does not
become an unsupported-capability message.

Foreign linked runs remain opaque references. Foreign input, output and failure
payload references are null, with `payloads_in_observer_namespace: false`.
Authorized payload references remain opaque runtime objects. A visible call
does not authorize browsing another namespace. See the
[Server read contract](https://github.com/durable-workflow/server/blob/main/docs/contracts/service-catalog-reads.md)
for scopes, filters, pagination and detail bounds.

For an authorized owner, call detail also includes `output_available` and the
decoded `output`. This keeps `false`, `0`, an empty string and `null` distinct
from a missing result. The original opaque reference remains available.
Downloads use the configured runtime and operator credential, verify size and
digest, and honor the client's `maxExternalPayloadBytes` limit. Foreign or
unverified output references are not fetched. Decoded values retain the SDK's
typed Avro binary and map adapters.

## A result wait timed out

`WorkflowHandle::result()` polls for a bounded caller duration. When it times out, the durable execution may still be running. Call `describe()` and inspect its status before starting anything again. Reuse the original workflow ID or an application idempotency key; do not create duplicates just because the client lost patience.
Expand Down
6 changes: 3 additions & 3 deletions docs/quickstart-contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
"schema_version": 2,
"package": {
"name": "durable-workflow/sdk",
"published_version": "2.2.8",
"composer_requirement": "2.2.8",
"onboarding_requirement": "^2.2"
"published_version": "2.3.0",
"composer_requirement": "2.3.0",
"onboarding_requirement": "^2.3"
},
"runtime_targets": {
"server": {
Expand Down
102 changes: 102 additions & 0 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,108 @@ public function clusterInfo(bool $includeDiagnostics = false): ClusterInfo
return ClusterInfo::fromArray($this->control('GET', $path));
}

/**
* @param array<string, int|string> $filters
* @return array<string, mixed>
*/
public function listServiceCatalogEndpoints(array $filters = []): array
{
return $this->serviceCatalogRead('/service-catalog/endpoints', $filters);
}

/** @return array<string, mixed> */
public function describeServiceCatalogEndpoint(string $endpointId): array
{
return $this->serviceCatalogRead('/service-catalog/endpoints', id: $endpointId);
}

/**
* @param array<string, int|string> $filters
* @return array<string, mixed>
*/
public function listServiceCatalogServices(array $filters = []): array
{
return $this->serviceCatalogRead('/service-catalog/services', $filters);
}

/** @return array<string, mixed> */
public function describeServiceCatalogService(string $serviceId): array
{
return $this->serviceCatalogRead('/service-catalog/services', id: $serviceId);
}

/**
* @param array<string, int|string> $filters
* @return array<string, mixed>
*/
public function listServiceCatalogOperations(array $filters = []): array
{
return $this->serviceCatalogRead('/service-catalog/operations', $filters);
}

/** @return array<string, mixed> */
public function describeServiceCatalogOperation(string $operationId): array
{
return $this->serviceCatalogRead('/service-catalog/operations', id: $operationId);
}

/**
* @param array<string, int|string> $filters
* @return array<string, mixed>
*/
public function listServiceCatalogCalls(array $filters = []): array
{
return $this->serviceCatalogRead('/service-catalog/calls', $filters);
}

/** @return array<string, mixed> */
public function describeServiceCatalogCall(string $callId): array
{
$response = $this->serviceCatalogRead('/service-catalog/calls', id: $callId);
if (($response['payloads_in_observer_namespace'] ?? null) === true) {
$response['output_available'] = is_array($response['output_envelope'] ?? null);
$response['output'] = $response['output_available']
? $this->decodedResponsePayload($response, 'output')
: null;
}

return $response;
}

/**
* @param array<string, int|string> $filters
* @return array<string, mixed>
*/
private function serviceCatalogRead(string $path, array $filters = [], ?string $id = null): array
{
if ($id !== null) {
if (trim($id) === '') {
throw new InvalidArgumentException('Catalog detail requires a non-empty ID.');
}
$path .= '/'.$this->segment($id);
}
$schema = 'durable-workflow.v2.service-catalog-read.contract';
$contract = $this->clusterInfo()->raw['service_catalog_read_contract'] ?? null;
if (!is_array($contract) || ($contract['schema'] ?? null) !== $schema || ($contract['version'] ?? null) !== 1) {
throw new ServerException(
'The connected backend does not advertise service catalog operator reads with contract version 1.',
501,
'backend_capability_unavailable',
[
'capability' => 'service_catalog_reads',
'required_contract' => $schema,
'required_contract_version' => 1,
'advertised_contract' => $contract,
],
);
}
if ($filters !== []) {
$path .= '?'.http_build_query($filters, '', '&', PHP_QUERY_RFC3986);
}

return $this->control('GET', $path);
}

/** Discover ordinary runtime capabilities using the worker credential. */
public function workerClusterInfo(): ClusterInfo
{
Expand Down
7 changes: 7 additions & 0 deletions src/Transport/RuntimePayloads.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ public function response(array $response, string $requestPath, bool $worker): ar
$paths = [...$paths, ...$this->historyPaths(['task', 'history_events']), ...$this->historyPaths(['history_events']),
...$this->exportPaths(['task', 'history_export'])];
} else {
if (preg_match('~\A/service-catalog/calls/[^/]+\z~', $path)
&& ($response['payloads_in_observer_namespace'] ?? null) === true
&& is_array($response['output_payload_reference'] ?? null)) {
// Keep the opaque reference while hydrating only the authorized
// terminal output of this exact call, never arbitrary metadata.
$response['output_envelope'] = ['codec' => 'avro', 'external_payload' => $response['output_payload_reference']];
}
foreach (['input_envelope', 'output_envelope', 'result_envelope'] as $field) {
$paths[] = [$field];
}
Expand Down
Loading
Loading