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
76 changes: 51 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -498,50 +498,76 @@ See the [functions guide](https://github.com/Kong/volcano-sdk-ruby/blob/main/doc
### Start and follow a durable execution

```ruby
project_id = "00000000-0000-4000-8000-000000000001"
handle = client.durable.start(
"charge-order",
{ order_id: "order-9" },
execution_name: "order-9"
)
token = handle.client_token

execution = client.durable.get_from_application("charge-order", handle.id, client_token: token)
puts [execution.status, execution.terminal?, execution.result]

client.durable.stop_from_application("charge-order", handle.id, client_token: token)
```

`start` takes a durable function's name or its id and returns a handle, never a
result: an execution can run for up to 366 days, so its result is read back with
`get_from_application`. `start`, `get_from_application`, and
`stop_from_application` use the same credential `invoke` does — the active user
session, then a configured service key, then the anonymous key. An
`execution_name` makes the start idempotent: starting again under the same name
returns the execution that already exists rather than beginning a second one.
Only the start that creates the execution returns its `client_token`; reads
never return it.

A service key holding `*`, the default, reads and stops any execution of the
project. A signed-in user reaches the executions they started without the
client token. An anonymous key, a user who did not start the execution, or a
service key scoped to a list without `*` passes `client_token:`. An anonymous
key or a service key scoped without `*` also needs `functions.invoke`; without it
the call raises `Volcano::Error::PermissionDeniedError`. The function's
visibility must also admit the credential: an anonymous key on an
`authenticated` function raises `Volcano::Error::PermissionDeniedError`, even
with the token, and a `private` function raises `Volcano::Error::NotFoundError`
for every credential but a service key. An execution the credential cannot
reach raises `Volcano::Error::NotFoundError`, as a missing one does, and so does
one still `pending` unless the credential is a service key holding `*`. A stop
answered with a `503` `Volcano::Error::ServerError` may already have been sent,
so read the execution before stopping it again.

Tooling that manages a project reads, lists, and stops executions with the
owner-scoped `get`, `list`, and `stop`, which reach any execution without a
client token:

```ruby
project_id = "00000000-0000-4000-8000-000000000001"
owner_client = Volcano::Client.new(
api_url: "https://api.volcano.dev",
anon_key: ENV.fetch("VOLCANO_ANON_KEY"),
access_token: ENV.fetch("VOLCANO_PLATFORM_TOKEN")
)
execution = owner_client.durable.get(project_id, "charge-order", handle.id)
puts [execution.status, execution.terminal?, execution.result]

page = owner_client.durable.list(project_id, "charge-order", status: "running")
puts [page.total, page.has_more]

owner_client.durable.stop(project_id, "charge-order", handle.id)
```

`start` takes a durable function's name or its id and returns a handle, never a
result: an execution can run for up to 366 days, so its result is read back with `get`.
It uses the same credential `invoke` does — the active user session, then a
configured service key, then the anonymous key — and is, with `messages`, the
only durable operation an application credential may perform. An
`execution_name` makes the start idempotent: starting again under the same name
returns the execution that already exists rather than beginning a second one.
Only the start that creates the execution returns its `client_token`.

`get`, `list`, and `stop` are owner-scoped and require the project owner's
platform user token or a project access token, because an execution is
addressed by its id alone. A `read_only` project access token can read and list
but not stop. Auth-user sessions, anonymous keys, and service keys are not
accepted. `get` carries the deeply
frozen `result` once the execution has succeeded, and `error` when it failed;
`result_expired` separates a result the platform has discarded from a function
that returned nothing. `terminal?` reports whether the execution has stopped
changing, and counts `unknown` — the status the platform writes for an outcome
it could not determine — as finished. `list` returns one page of executions,
most recent first, each carrying the status last observed rather than a live
one. `stop` is accepted rather than awaited: what it returns is the execution
read back after asking, often still `running`, so poll `get` to see it reach
`stopped`. Repeating a stop is safe.
They require the project owner's platform user token or a project access token.
A `read_only` project access token can read and list but not stop. Auth-user
sessions, anonymous keys, and service keys are not accepted.

Either read carries the deeply frozen `result` once the execution has succeeded,
and `error` when it failed; `result_expired` separates a result the platform has
discarded from a function that returned nothing. `terminal?` reports whether the
execution has stopped changing, and counts `unknown` — the status the platform
writes for an outcome it could not determine — as finished. `list` returns one
page of executions, most recent first, each carrying the status last observed
rather than a live one. A stop is accepted rather than awaited: what it returns
is the execution read back after asking, often still `running`, so poll a read
to see it reach `stopped`. Repeating a stop is safe.

This gem starts and follows durable executions; it cannot write the durable
function itself. Checkpointing a handler needs a durable authoring API, and
Expand Down
1 change: 1 addition & 0 deletions Steepfile.transport
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ target :client_transport do
check 'lib/volcano/generated_transport_confirmation.rb'
check 'lib/volcano/generated_transport_database.rb'
check 'lib/volcano/generated_transport_durable.rb'
check 'lib/volcano/generated_transport_durable_application.rb'
check 'lib/volcano/generated_transport_durable_messages.rb'
check 'lib/volcano/generated_transport_email_change.rb'
check 'lib/volcano/generated_transport_functions.rb'
Expand Down
18 changes: 15 additions & 3 deletions docs/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,21 @@ handle = client.durable.start(
{ order_id: "order-9" },
execution_name: "order-9"
)
token = handle.client_token

execution = client.durable.get_from_application("charge-order", handle.id, client_token: token)
client.durable.stop_from_application("charge-order", handle.id, client_token: token)
```

`start`, `get_from_application`, and `stop_from_application` use the same credential as `functions.invoke`: the active session, then a service key, then the anonymous key. An execution name makes a start idempotent. The start that creates an execution returns its `client_token`; a repeated start returns nil there. Inspecting the execution masks the token.

A service key holding `*`, the default, reads and stops any execution of the project. A signed-in user reaches the executions they started without the client token. An anonymous key, a user who did not start the execution, or a service key scoped to a list without `*` passes `client_token:`. An anonymous key or a service key scoped without `*` also needs `functions.invoke`; without it the call raises `Volcano::Error::PermissionDeniedError`. The function's visibility must also admit the credential: an anonymous key on an `authenticated` function raises `Volcano::Error::PermissionDeniedError`, even with the token, and a `private` function raises `Volcano::Error::NotFoundError` for every credential but a service key. An execution the credential cannot reach raises `Volcano::Error::NotFoundError`, as a missing one does, and so does one still `pending` unless the credential is a service key holding `*`. Reads never return `client_token`, so keep the one the start returned.

`stop_from_application` and `stop` return after the stop request is accepted, often while the execution still reports `running`; poll the matching read until `terminal?` is true. Stopping an execution that already finished returns the state it settled in. A `stop_from_application` answered with a `503` `Volcano::Error::ServerError` may already have been sent, so read the execution before stopping it again.

Tooling that manages a project uses the owner-scoped `get`, `list`, and `stop`, which reach any execution without a client token:

```ruby
owner_client = Volcano::Client.new(
anon_key: ENV.fetch("VOLCANO_ANON_KEY"),
api_url: ENV.fetch("VOLCANO_API_URL", "https://api.volcano.dev"),
Expand All @@ -83,9 +97,7 @@ page = owner_client.durable.list(project_id, "charge-order", status: "running")
owner_client.durable.stop(project_id, "charge-order", handle.id)
```

`start` accepts the same active session, service key, or anonymous key as `functions.invoke`. With `messages`, it is the only durable operation available to application credentials. An execution name makes a start idempotent. The start that creates an execution returns its `client_token`; a repeated start returns nil there. Inspecting the execution masks the token.

`get`, `list`, and `stop` are owner-scoped. Call them from a trusted backend with the project owner's platform user token or a project access token; a `read_only` token can read and list but not stop. Auth-user sessions, anonymous keys, and service keys are not accepted. `stop` returns after the stop request is accepted, so poll `get` until `terminal?` is true.
Call them from a trusted backend with the project owner's platform user token or a project access token; a `read_only` token can read and list but not stop. Auth-user sessions, anonymous keys, and service keys are not accepted.

`list` returns executions and pagination metadata. A successful response must include `data`, `page`, `limit`, `total`, and `has_more` with the API's declared types. Missing or malformed fields raise `TypeError`; a complete response with `data: []` returns an empty execution list.

Expand Down
74 changes: 62 additions & 12 deletions lib/volcano/durable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class Durable
# contract so a refusal is the facade's rather than the generated client's.
EXECUTION_NAME = /\A[A-Za-z0-9._-]{1,255}\z/
include DurableArguments
include DurableMessageArguments
include DurableResponses

# Lists, reads, and decides the approvals durable workflows request.
Expand All @@ -24,14 +25,14 @@ def initialize(client, transport, realtime)

# Starts an execution and returns its handle. A durable function is never
# invoked synchronously: it can outlive any request a caller could hold
# open, so its result is read back with #get.
# open, so its result is read back with #get_from_application or #get.
#
# Starting takes the token an invoke takes, and is, with #messages, the
# only durable operation an application credential may perform. Passing an
# +execution_name+ makes the start idempotent: starting again under the
# same name returns the execution that already exists rather than
# beginning a second one. Only the start that created the execution
# returns its +client_token+.
# Starting takes the token an invoke takes. Passing an +execution_name+
# makes the start idempotent: starting again under the same name returns
# the execution that already exists rather than beginning a second one.
# Only the start that created the execution returns its +client_token+;
# keep it where #get_from_application, #stop_from_application, and
# #messages need it.
def start(function_name, payload = {}, execution_name: nil)
# A durable function's id is accepted here as well as its name, so the
# DNS-safe name pattern an invoke checks would reject half the input.
Expand All @@ -47,12 +48,55 @@ def start(function_name, payload = {}, execution_name: nil)
durable_execution(Transport.body(response, 202))
end

# Reads an execution with the credential #start uses, including its result
# once it has succeeded. The function's visibility must admit the
# credential. A service key holding `*`, the default, reaches any
# execution of the project. A signed-in user reaches the executions they
# started. An anon key, a user who did not start the execution, or a
# service key scoped to a list without `*` passes the +client_token+ its
# start returned; it is sent with whichever credential is in use. An anon
# key or a service key scoped without `*` also needs `functions.invoke`;
# without it the call raises Error::PermissionDeniedError. An anon key on
# an +authenticated+ function raises Error::PermissionDeniedError too, even
# with the token, and a +private+ function raises Error::NotFoundError for
# every credential but a service key. An execution the credential cannot
# reach, or one still +pending+ for any credential but a service key
# holding `*`, raises Error::NotFoundError, as a missing one does.
def get_from_application(function_name, execution_id, client_token: nil)
function_id, execution, token = application_target(function_name, execution_id, client_token)
response = Transport.invoke do
@transport.get_durable_execution_from_application(
authorization: @client.function_token,
function_id: function_id, execution_id: execution, client_token: token
)
end
durable_execution(Transport.body(response, 200))
end

# Asks a running execution to stop, with the credential and +client_token+
# #get_from_application takes. Accepted rather than awaited, as #stop is:
# poll #get_from_application to see it reach +stopped+. Only a service key
# holding `*` reaches an execution still +pending+, and stopping one raises
# Error::ConflictError. A stop answered with a 503 Error::ServerError may
# already have been sent, so read the execution before stopping again.
def stop_from_application(function_name, execution_id, client_token: nil)
function_id, execution, token = application_target(function_name, execution_id, client_token)
response = Transport.invoke do
@transport.stop_durable_execution_from_application(
authorization: @client.function_token,
function_id: function_id, execution_id: execution, client_token: token
)
end
durable_execution(Transport.body(response, 200))
end

# Reads an execution, including its result once it has succeeded.
#
# Owner-scoped: an execution is addressed by its id alone, so this takes
# the project id and the project owner's platform user token or a project
# access token. Auth-user sessions, anon keys, and service keys are not
# accepted. Poll it from a backend, not a browser.
# Owner-scoped: this takes the project id and the project owner's platform
# user token or a project access token, and reaches any execution of the
# project without a client token. Auth-user sessions, anon keys, and
# service keys are not accepted; an application reads its executions with
# #get_from_application instead.
def get(project_id, function_name, execution_id)
project, function_id, execution = execution_path(project_id, function_name, execution_id)
response = Transport.invoke do
Expand Down Expand Up @@ -85,7 +129,8 @@ def list(project_id, function_name, status: nil, page: nil, limit: nil)
# comes back is the execution read after asking, and it often still says
# +running+, so poll #get to see it reach +stopped+. Completed steps are
# not undone, and repeating a stop is safe — an execution that has already
# finished reports the state it settled in. Owner-scoped, like #get.
# finished reports the state it settled in. Owner-scoped, like #get; an
# application stops its executions with #stop_from_application.
def stop(project_id, function_name, execution_id)
project, function_id, execution = execution_path(project_id, function_name, execution_id)
response = Transport.invoke do
Expand All @@ -107,6 +152,11 @@ def execution_path(project_id, function_name, execution_id)
]
end

def application_target(function_name, execution_id, client_token)
[identifier(function_name, 'function_name'), identifier(execution_id, 'execution_id'),
bounded_text(client_token, 'client_token', MAX_CLIENT_TOKEN_LENGTH)]
end

def execution_name_argument(value)
name = identifier(value, 'execution_name')
unless EXECUTION_NAME.match?(name)
Expand Down
2 changes: 2 additions & 0 deletions lib/volcano/generated/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ Class | Method | HTTP request | Description
*Volcano::Generated::DurableFunctionsApi* | [**get_durable_approval**](docs/DurableFunctionsApi.md#get_durable_approval) | **GET** /projects/{id}/durable-approvals/{approvalId} | Get a durable approval
*Volcano::Generated::DurableFunctionsApi* | [**get_durable_approval_stats**](docs/DurableFunctionsApi.md#get_durable_approval_stats) | **GET** /projects/{id}/durable-approvals/stats | Get durable approval statistics
*Volcano::Generated::DurableFunctionsApi* | [**get_durable_execution**](docs/DurableFunctionsApi.md#get_durable_execution) | **GET** /projects/{id}/durable-functions/{functionId}/executions/{executionId} | Get a durable execution
*Volcano::Generated::DurableFunctionsApi* | [**get_durable_execution_from_application**](docs/DurableFunctionsApi.md#get_durable_execution_from_application) | **GET** /durable-functions/{functionId}/executions/{executionId} | Get a durable execution from an application
*Volcano::Generated::DurableFunctionsApi* | [**get_durable_function**](docs/DurableFunctionsApi.md#get_durable_function) | **GET** /projects/{id}/durable-functions/{functionId} | Get durable function by ID or name
*Volcano::Generated::DurableFunctionsApi* | [**get_durable_function_scheduler**](docs/DurableFunctionsApi.md#get_durable_function_scheduler) | **GET** /projects/{id}/durable-functions/{functionId}/schedulers/{schedulerId} | Get a durable function scheduler
*Volcano::Generated::DurableFunctionsApi* | [**list_durable_approvals**](docs/DurableFunctionsApi.md#list_durable_approvals) | **GET** /projects/{id}/durable-approvals | List durable approvals
Expand All @@ -223,6 +224,7 @@ Class | Method | HTTP request | Description
*Volcano::Generated::DurableFunctionsApi* | [**start_durable_execution**](docs/DurableFunctionsApi.md#start_durable_execution) | **POST** /projects/{id}/durable-functions/{functionId}/executions | Start a durable execution
*Volcano::Generated::DurableFunctionsApi* | [**start_durable_execution_from_application**](docs/DurableFunctionsApi.md#start_durable_execution_from_application) | **POST** /durable-functions/{functionId}/executions | Start a durable execution from an application
*Volcano::Generated::DurableFunctionsApi* | [**stop_durable_execution**](docs/DurableFunctionsApi.md#stop_durable_execution) | **POST** /projects/{id}/durable-functions/{functionId}/executions/{executionId}/stop | Stop a durable execution
*Volcano::Generated::DurableFunctionsApi* | [**stop_durable_execution_from_application**](docs/DurableFunctionsApi.md#stop_durable_execution_from_application) | **POST** /durable-functions/{functionId}/executions/{executionId}/stop | Stop a durable execution from an application
*Volcano::Generated::DurableFunctionsApi* | [**update_durable_function_scheduler**](docs/DurableFunctionsApi.md#update_durable_function_scheduler) | **PATCH** /projects/{id}/durable-functions/{functionId}/schedulers/{schedulerId} | Update a durable function scheduler
*Volcano::Generated::FrontendsApi* | [**create_frontend**](docs/FrontendsApi.md#create_frontend) | **POST** /projects/{id}/frontends | Create a new frontend deployment
*Volcano::Generated::FrontendsApi* | [**create_frontend_custom_domain**](docs/FrontendsApi.md#create_frontend_custom_domain) | **POST** /projects/{id}/frontends/{frontendId}/domain | Configure frontend custom domain
Expand Down
Loading
Loading