diff --git a/README.md b/README.md index 0b0eac25..b62c3e8e 100644 --- a/README.md +++ b/README.md @@ -498,20 +498,56 @@ 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] @@ -519,29 +555,19 @@ 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 diff --git a/Steepfile.transport b/Steepfile.transport index dabd6e29..c6e0f3e0 100644 --- a/Steepfile.transport +++ b/Steepfile.transport @@ -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' diff --git a/docs/functions.md b/docs/functions.md index dbda0818..c07a5c2e 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -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"), @@ -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. diff --git a/lib/volcano/durable.rb b/lib/volcano/durable.rb index 1223eb50..7937f45d 100644 --- a/lib/volcano/durable.rb +++ b/lib/volcano/durable.rb @@ -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. @@ -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. @@ -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 @@ -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 @@ -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) diff --git a/lib/volcano/generated/README.md b/lib/volcano/generated/README.md index 6f696fae..19e377be 100644 --- a/lib/volcano/generated/README.md +++ b/lib/volcano/generated/README.md @@ -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 @@ -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 diff --git a/lib/volcano/generated/lib/volcano-generated/api/durable_functions_api.rb b/lib/volcano/generated/lib/volcano-generated/api/durable_functions_api.rb index 8513fde8..4d8ba3d3 100644 --- a/lib/volcano/generated/lib/volcano-generated/api/durable_functions_api.rb +++ b/lib/volcano/generated/lib/volcano-generated/api/durable_functions_api.rb @@ -739,6 +739,82 @@ def get_durable_execution_with_http_info(id, function_id, execution_id, opts = { return data, status_code, headers end + # Get a durable execution from an application + # Returns the execution's current state with the credential that started it, including its `result` once it has succeeded. Poll this to wait for an execution to finish. **With a service key:** any execution in the project when the key holds `*`, the default. A key scoped to a list without `*` reaches an execution whose `X-Volcano-Client-Token` the request carries, as an anon key does; the start returns that token. **With an auth user token:** an execution the user started, or one whose `X-Volcano-Client-Token` the request carries, of a durable function whose `visibility` is `authenticated` or `public`. **With an anon key:** an execution whose `X-Volcano-Client-Token` the request carries, of a function with `visibility: public`. Only a service key holding `*` reaches an execution that is still `pending`. Any other execution answers `404`, as a missing one does. The response is the one `GET /projects/{id}/durable-functions/{functionId}/executions/{executionId}` returns. It never carries `client_token`. + # @param function_id [String] Durable function ID, or its name within the project + # @param execution_id [String] Durable execution ID + # @param [Hash] opts the optional parameters + # @option opts [String] :x_volcano_client_token The `client_token` the execution's start returned. Lets an anon key, or a user who did not start the execution, reach it, and a service key scoped to a list without `*` read and stop it. + # @return [DurableExecution] + def get_durable_execution_from_application(function_id, execution_id, opts = {}) + data, _status_code, _headers = get_durable_execution_from_application_with_http_info(function_id, execution_id, opts) + data + end + + # Get a durable execution from an application + # Returns the execution's current state with the credential that started it, including its `result` once it has succeeded. Poll this to wait for an execution to finish. **With a service key:** any execution in the project when the key holds `*`, the default. A key scoped to a list without `*` reaches an execution whose `X-Volcano-Client-Token` the request carries, as an anon key does; the start returns that token. **With an auth user token:** an execution the user started, or one whose `X-Volcano-Client-Token` the request carries, of a durable function whose `visibility` is `authenticated` or `public`. **With an anon key:** an execution whose `X-Volcano-Client-Token` the request carries, of a function with `visibility: public`. Only a service key holding `*` reaches an execution that is still `pending`. Any other execution answers `404`, as a missing one does. The response is the one `GET /projects/{id}/durable-functions/{functionId}/executions/{executionId}` returns. It never carries `client_token`. + # @param function_id [String] Durable function ID, or its name within the project + # @param execution_id [String] Durable execution ID + # @param [Hash] opts the optional parameters + # @option opts [String] :x_volcano_client_token The `client_token` the execution's start returned. Lets an anon key, or a user who did not start the execution, reach it, and a service key scoped to a list without `*` read and stop it. + # @return [Array<(DurableExecution, Integer, Hash)>] DurableExecution data, response status code and response headers + def get_durable_execution_from_application_with_http_info(function_id, execution_id, opts = {}) + if @api_client.config.debugging + @api_client.config.logger.debug 'Calling API: DurableFunctionsApi.get_durable_execution_from_application ...' + end + # verify the required parameter 'function_id' is set + if @api_client.config.client_side_validation && function_id.nil? + fail ArgumentError, "Missing the required parameter 'function_id' when calling DurableFunctionsApi.get_durable_execution_from_application" + end + # verify the required parameter 'execution_id' is set + if @api_client.config.client_side_validation && execution_id.nil? + fail ArgumentError, "Missing the required parameter 'execution_id' when calling DurableFunctionsApi.get_durable_execution_from_application" + end + if @api_client.config.client_side_validation && !opts[:'x_volcano_client_token'].nil? && opts[:'x_volcano_client_token'].to_s.length > 128 + fail ArgumentError, 'invalid value for "opts[:"x_volcano_client_token"]" when calling DurableFunctionsApi.get_durable_execution_from_application, the character length must be smaller than or equal to 128.' + end + + # resource path + local_var_path = '/durable-functions/{functionId}/executions/{executionId}'.sub('{' + 'functionId' + '}', CGI.escape(function_id.to_s)).sub('{' + 'executionId' + '}', CGI.escape(execution_id.to_s)) + + # query parameters + query_params = opts[:query_params] || {} + + # header parameters + header_params = opts[:header_params] || {} + # HTTP header 'Accept' (if needed) + header_params['Accept'] = @api_client.select_header_accept(['application/json']) unless header_params['Accept'] + header_params[:'X-Volcano-Client-Token'] = opts[:'x_volcano_client_token'] if !opts[:'x_volcano_client_token'].nil? + + # form parameters + form_params = opts[:form_params] || {} + + # http body (model) + post_body = opts[:debug_body] + + # return_type + return_type = opts[:debug_return_type] || 'DurableExecution' + + # auth_names + auth_names = opts[:debug_auth_names] || ['ServiceRoleKey', 'AuthUserAccessToken', 'AnonKey'] + + new_options = opts.merge( + :operation => :"DurableFunctionsApi.get_durable_execution_from_application", + :header_params => header_params, + :query_params => query_params, + :form_params => form_params, + :body => post_body, + :auth_names => auth_names, + :return_type => return_type + ) + + data, status_code, headers = @api_client.call_api(:GET, local_var_path, new_options) + if @api_client.config.debugging + @api_client.config.logger.debug "API called: DurableFunctionsApi#get_durable_execution_from_application\nData: #{data.inspect}\nStatus code: #{status_code}\nHeaders: #{headers}" + end + return data, status_code, headers + end + # Get durable function by ID or name # @param id [String] Project ID # @param function_id [String] Durable function ID, or its name within the project @@ -1582,7 +1658,7 @@ def list_durable_messages_with_http_info(id, function_id, execution_id, opts = { # @param function_id [String] Durable function ID, or its name within the project # @param execution_id [String] Durable execution ID # @param [Hash] opts the optional parameters - # @option opts [String] :x_volcano_client_token The `client_token` the execution's start returned. Lets an anon key, or a user who did not start the execution, reach it. + # @option opts [String] :x_volcano_client_token The `client_token` the execution's start returned. Lets an anon key, or a user who did not start the execution, reach it, and a service key scoped to a list without `*` read and stop it. # @option opts [DurableMessageDirection] :direction Return only messages travelling this way. # @option opts [String] :name Return only messages with this name. # @option opts [Integer] :after Return only messages whose `seq` is greater than this. (default to 0) @@ -1598,7 +1674,7 @@ def list_durable_messages_from_application(function_id, execution_id, opts = {}) # @param function_id [String] Durable function ID, or its name within the project # @param execution_id [String] Durable execution ID # @param [Hash] opts the optional parameters - # @option opts [String] :x_volcano_client_token The `client_token` the execution's start returned. Lets an anon key, or a user who did not start the execution, reach it. + # @option opts [String] :x_volcano_client_token The `client_token` the execution's start returned. Lets an anon key, or a user who did not start the execution, reach it, and a service key scoped to a list without `*` read and stop it. # @option opts [DurableMessageDirection] :direction Return only messages travelling this way. # @option opts [String] :name Return only messages with this name. # @option opts [Integer] :after Return only messages whose `seq` is greater than this. (default to 0) @@ -2092,7 +2168,7 @@ def send_durable_message_with_http_info(id, function_id, execution_id, send_dura # @param send_durable_message_request [SendDurableMessageRequest] # @param [Hash] opts the optional parameters # @option opts [String] :idempotency_key Makes a send safe to retry. A repeat with a key the same sender has used on the execution returns the first message and sends nothing. Keys of different senders never collide. Up to 255 characters. - # @option opts [String] :x_volcano_client_token The `client_token` the execution's start returned. Lets an anon key, or a user who did not start the execution, reach it. + # @option opts [String] :x_volcano_client_token The `client_token` the execution's start returned. Lets an anon key, or a user who did not start the execution, reach it, and a service key scoped to a list without `*` read and stop it. # @return [DurableMessage] def send_durable_message_from_application(function_id, execution_id, send_durable_message_request, opts = {}) data, _status_code, _headers = send_durable_message_from_application_with_http_info(function_id, execution_id, send_durable_message_request, opts) @@ -2106,7 +2182,7 @@ def send_durable_message_from_application(function_id, execution_id, send_durabl # @param send_durable_message_request [SendDurableMessageRequest] # @param [Hash] opts the optional parameters # @option opts [String] :idempotency_key Makes a send safe to retry. A repeat with a key the same sender has used on the execution returns the first message and sends nothing. Keys of different senders never collide. Up to 255 characters. - # @option opts [String] :x_volcano_client_token The `client_token` the execution's start returned. Lets an anon key, or a user who did not start the execution, reach it. + # @option opts [String] :x_volcano_client_token The `client_token` the execution's start returned. Lets an anon key, or a user who did not start the execution, reach it, and a service key scoped to a list without `*` read and stop it. # @return [Array<(DurableMessage, Integer, Hash)>] DurableMessage data, response status code and response headers def send_durable_message_from_application_with_http_info(function_id, execution_id, send_durable_message_request, opts = {}) if @api_client.config.debugging @@ -2272,7 +2348,7 @@ def start_durable_execution_with_http_info(id, function_id, opts = {}) end # Start a durable execution from an application - # Starts an execution of a durable function using an application credential, and returns its handle. This is the durable counterpart of `POST /functions/{functionId}/invoke`, and it is the endpoint an application calls. Like that one, it is not project-scoped: an anon key, a service key and an auth user token each carry their own project. The project-scoped collection under `/projects/{id}/durable-functions/...` remains the owner's management surface. **With a service key:** any durable function in the project. **With an auth user token:** a durable function whose `visibility` is `authenticated` or `public`. **With an anon key:** requires the `functions.invoke` permission, and the function must have `visibility: public`. A `private` durable function answers every credential but a service key exactly as a missing one, with 404. Starting is all this endpoint does. Reading a result or stopping an execution requires the project owner's token, because an anon key is shared by everyone who loads the page and an execution is addressed by id alone. Send `X-Volcano-Execution-Name` to make the start idempotent: repeating a start with the same name returns the existing execution instead of beginning a second one. Each execution counts once against the project's durable execution allowance, however many times the start is retried under the same execution name, and the number in flight at once is capped by the plan. The operations the execution performs are counted against the durable operations allowance when it finishes. + # Starts an execution of a durable function using an application credential, and returns its handle. This is the durable counterpart of `POST /functions/{functionId}/invoke`, and it is the endpoint an application calls. Like that one, it is not project-scoped: an anon key, a service key and an auth user token each carry their own project. The project-scoped collection under `/projects/{id}/durable-functions/...` remains the owner's management surface. **With a service key:** any durable function in the project. **With an auth user token:** a durable function whose `visibility` is `authenticated` or `public`. **With an anon key:** requires the `functions.invoke` permission, and the function must have `visibility: public`. A `private` durable function answers every credential but a service key exactly as a missing one, with 404. The start returns a `client_token`. An anon key, or a user who did not start the execution, sends it as `X-Volcano-Client-Token` to read the execution, stop it, and reach its messages. A service key scoped to a list without `*` sends it to read and stop the execution. An anon key is shared by everyone who loads the page, so the id alone never reaches an execution. Listing executions takes the project owner's token or a project access token. Send `X-Volcano-Execution-Name` to make the start idempotent: repeating a start with the same name returns the existing execution instead of beginning a second one. Each execution counts once against the project's durable execution allowance, however many times the start is retried under the same execution name, and the number in flight at once is capped by the plan. The operations the execution performs are counted against the durable operations allowance when it finishes. # @param function_id [String] Durable function ID, or its name within the project # @param [Hash] opts the optional parameters # @option opts [String] :x_volcano_execution_name Idempotency key for this execution. Generated when omitted. A repeat under a name that already names a running execution returns that execution and is not charged again. Letters, digits, `-`, `_` and `.`, up to 255 characters. Anything else is rejected with `400`. @@ -2284,7 +2360,7 @@ def start_durable_execution_from_application(function_id, opts = {}) end # Start a durable execution from an application - # Starts an execution of a durable function using an application credential, and returns its handle. This is the durable counterpart of `POST /functions/{functionId}/invoke`, and it is the endpoint an application calls. Like that one, it is not project-scoped: an anon key, a service key and an auth user token each carry their own project. The project-scoped collection under `/projects/{id}/durable-functions/...` remains the owner's management surface. **With a service key:** any durable function in the project. **With an auth user token:** a durable function whose `visibility` is `authenticated` or `public`. **With an anon key:** requires the `functions.invoke` permission, and the function must have `visibility: public`. A `private` durable function answers every credential but a service key exactly as a missing one, with 404. Starting is all this endpoint does. Reading a result or stopping an execution requires the project owner's token, because an anon key is shared by everyone who loads the page and an execution is addressed by id alone. Send `X-Volcano-Execution-Name` to make the start idempotent: repeating a start with the same name returns the existing execution instead of beginning a second one. Each execution counts once against the project's durable execution allowance, however many times the start is retried under the same execution name, and the number in flight at once is capped by the plan. The operations the execution performs are counted against the durable operations allowance when it finishes. + # Starts an execution of a durable function using an application credential, and returns its handle. This is the durable counterpart of `POST /functions/{functionId}/invoke`, and it is the endpoint an application calls. Like that one, it is not project-scoped: an anon key, a service key and an auth user token each carry their own project. The project-scoped collection under `/projects/{id}/durable-functions/...` remains the owner's management surface. **With a service key:** any durable function in the project. **With an auth user token:** a durable function whose `visibility` is `authenticated` or `public`. **With an anon key:** requires the `functions.invoke` permission, and the function must have `visibility: public`. A `private` durable function answers every credential but a service key exactly as a missing one, with 404. The start returns a `client_token`. An anon key, or a user who did not start the execution, sends it as `X-Volcano-Client-Token` to read the execution, stop it, and reach its messages. A service key scoped to a list without `*` sends it to read and stop the execution. An anon key is shared by everyone who loads the page, so the id alone never reaches an execution. Listing executions takes the project owner's token or a project access token. Send `X-Volcano-Execution-Name` to make the start idempotent: repeating a start with the same name returns the existing execution instead of beginning a second one. Each execution counts once against the project's durable execution allowance, however many times the start is retried under the same execution name, and the number in flight at once is capped by the plan. The operations the execution performs are counted against the durable operations allowance when it finishes. # @param function_id [String] Durable function ID, or its name within the project # @param [Hash] opts the optional parameters # @option opts [String] :x_volcano_execution_name Idempotency key for this execution. Generated when omitted. A repeat under a name that already names a running execution returns that execution and is not charged again. Letters, digits, `-`, `_` and `.`, up to 255 characters. Anything else is rejected with `400`. @@ -2428,6 +2504,82 @@ def stop_durable_execution_with_http_info(id, function_id, execution_id, opts = return data, status_code, headers end + # Stop a durable execution from an application + # Cancels a running execution with the credential that started it. Its completed steps are not undone. Access follows the same rules as reading the execution. The call is accepted rather than awaited: the response reports the execution as it was read back and may still say `running`. Poll `GET /durable-functions/{functionId}/executions/{executionId}` to see it settle into `stopped`. Stopping an execution that already finished is not an error: the response carries the state it settled in. + # @param function_id [String] Durable function ID, or its name within the project + # @param execution_id [String] Durable execution ID + # @param [Hash] opts the optional parameters + # @option opts [String] :x_volcano_client_token The `client_token` the execution's start returned. Lets an anon key, or a user who did not start the execution, reach it, and a service key scoped to a list without `*` read and stop it. + # @return [DurableExecution] + def stop_durable_execution_from_application(function_id, execution_id, opts = {}) + data, _status_code, _headers = stop_durable_execution_from_application_with_http_info(function_id, execution_id, opts) + data + end + + # Stop a durable execution from an application + # Cancels a running execution with the credential that started it. Its completed steps are not undone. Access follows the same rules as reading the execution. The call is accepted rather than awaited: the response reports the execution as it was read back and may still say `running`. Poll `GET /durable-functions/{functionId}/executions/{executionId}` to see it settle into `stopped`. Stopping an execution that already finished is not an error: the response carries the state it settled in. + # @param function_id [String] Durable function ID, or its name within the project + # @param execution_id [String] Durable execution ID + # @param [Hash] opts the optional parameters + # @option opts [String] :x_volcano_client_token The `client_token` the execution's start returned. Lets an anon key, or a user who did not start the execution, reach it, and a service key scoped to a list without `*` read and stop it. + # @return [Array<(DurableExecution, Integer, Hash)>] DurableExecution data, response status code and response headers + def stop_durable_execution_from_application_with_http_info(function_id, execution_id, opts = {}) + if @api_client.config.debugging + @api_client.config.logger.debug 'Calling API: DurableFunctionsApi.stop_durable_execution_from_application ...' + end + # verify the required parameter 'function_id' is set + if @api_client.config.client_side_validation && function_id.nil? + fail ArgumentError, "Missing the required parameter 'function_id' when calling DurableFunctionsApi.stop_durable_execution_from_application" + end + # verify the required parameter 'execution_id' is set + if @api_client.config.client_side_validation && execution_id.nil? + fail ArgumentError, "Missing the required parameter 'execution_id' when calling DurableFunctionsApi.stop_durable_execution_from_application" + end + if @api_client.config.client_side_validation && !opts[:'x_volcano_client_token'].nil? && opts[:'x_volcano_client_token'].to_s.length > 128 + fail ArgumentError, 'invalid value for "opts[:"x_volcano_client_token"]" when calling DurableFunctionsApi.stop_durable_execution_from_application, the character length must be smaller than or equal to 128.' + end + + # resource path + local_var_path = '/durable-functions/{functionId}/executions/{executionId}/stop'.sub('{' + 'functionId' + '}', CGI.escape(function_id.to_s)).sub('{' + 'executionId' + '}', CGI.escape(execution_id.to_s)) + + # query parameters + query_params = opts[:query_params] || {} + + # header parameters + header_params = opts[:header_params] || {} + # HTTP header 'Accept' (if needed) + header_params['Accept'] = @api_client.select_header_accept(['application/json']) unless header_params['Accept'] + header_params[:'X-Volcano-Client-Token'] = opts[:'x_volcano_client_token'] if !opts[:'x_volcano_client_token'].nil? + + # form parameters + form_params = opts[:form_params] || {} + + # http body (model) + post_body = opts[:debug_body] + + # return_type + return_type = opts[:debug_return_type] || 'DurableExecution' + + # auth_names + auth_names = opts[:debug_auth_names] || ['ServiceRoleKey', 'AuthUserAccessToken', 'AnonKey'] + + new_options = opts.merge( + :operation => :"DurableFunctionsApi.stop_durable_execution_from_application", + :header_params => header_params, + :query_params => query_params, + :form_params => form_params, + :body => post_body, + :auth_names => auth_names, + :return_type => return_type + ) + + data, status_code, headers = @api_client.call_api(:POST, local_var_path, new_options) + if @api_client.config.debugging + @api_client.config.logger.debug "API called: DurableFunctionsApi#stop_durable_execution_from_application\nData: #{data.inspect}\nStatus code: #{status_code}\nHeaders: #{headers}" + end + return data, status_code, headers + end + # Update a durable function scheduler # @param id [String] Project ID # @param function_id [String] Durable function ID, or its name within the project diff --git a/lib/volcano/generated/lib/volcano-generated/models/durable_execution.rb b/lib/volcano/generated/lib/volcano-generated/models/durable_execution.rb index c671edb2..356333c2 100644 --- a/lib/volcano/generated/lib/volcano-generated/models/durable_execution.rb +++ b/lib/volcano/generated/lib/volcano-generated/models/durable_execution.rb @@ -27,7 +27,7 @@ class DurableExecution < ApiModelBase # Region the execution runs in. An execution is pinned to one region for its whole life because its checkpoints live there. attr_accessor :region - # Whatever the function returned, verbatim. Absent while the execution is still running, absent when the result was too large to return and was checkpointed instead, and absent once the retention period has lapsed. + # Whatever the function returned, verbatim. Absent while the execution is still running, absent when the result was too large to return and was checkpointed instead, and absent once the retention period has lapsed. A result Volcano has just read can still be returned for up to five minutes after that. attr_accessor :result # `true` when the execution is terminal but its result is no longer retained, which distinguishes a discarded result from an empty one. Shortly after that the execution itself is dropped and reads answer `404`. A result that was checkpointed rather than returned leaves this unset, so it reads like a function that returned nothing. @@ -40,7 +40,7 @@ class DurableExecution < ApiModelBase # Present once the execution has reached a terminal status. attr_accessor :completed_at - # Lets a browser read and send the execution's messages. Returned only by the start that created the execution, never by a repeat under the same name or by a read. Send it as `X-Volcano-Client-Token`. + # Lets a browser read the execution, stop it, and read and send its messages, and a service key scoped to a list without `*` read and stop it. Returned only by the start whose input the execution runs, never by a repeat under the same name or by a read. Send it as `X-Volcano-Client-Token`. attr_accessor :client_token class EnumAttributeValidator diff --git a/lib/volcano/generated_transport.rb b/lib/volcano/generated_transport.rb index 67060ae8..641ee5f4 100644 --- a/lib/volcano/generated_transport.rb +++ b/lib/volcano/generated_transport.rb @@ -29,6 +29,7 @@ module Volcano require_relative 'generated_transport_logs' require_relative 'generated_transport_functions' require_relative 'generated_transport_durable' + require_relative 'generated_transport_durable_application' require_relative 'generated_transport_durable_messages' require_relative 'generated_transport_sandbox' require_relative 'generated_transport_storage' diff --git a/lib/volcano/generated_transport_durable_application.rb b/lib/volcano/generated_transport_durable_application.rb new file mode 100644 index 00000000..c13a4460 --- /dev/null +++ b/lib/volcano/generated_transport_durable_application.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Volcano + # Durable execution reads and stops on the application route, which take the + # credential a start takes and, where it needs one, the execution's client + # token. + class GeneratedTransport + def get_durable_execution_from_application(authorization:, function_id:, execution_id:, client_token:) + invoke do + apis = @api_factory.call(authorization) + data, status, headers = apis.durable.get_durable_execution_from_application_with_http_info( + function_id, execution_id, { x_volcano_client_token: client_token }.compact + ) + response(data, status, headers) + end + end + + def stop_durable_execution_from_application(authorization:, function_id:, execution_id:, client_token:) + invoke do + apis = @api_factory.call(authorization) + data, status, headers = apis.durable.stop_durable_execution_from_application_with_http_info( + function_id, execution_id, { x_volcano_client_token: client_token }.compact + ) + response(data, status, headers) + end + end + end +end diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 78f3bd4c..f0e902d2 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -4376,10 +4376,13 @@ paths: A `private` durable function answers every credential but a service key exactly as a missing one, with 404. - Starting is all this endpoint does. Reading a result or stopping an - execution requires the project owner's token, because an anon key is - shared by everyone who loads the page and an execution is addressed by - id alone. + The start returns a `client_token`. An anon key, or a user who did not + start the execution, sends it as `X-Volcano-Client-Token` to read the + execution, stop it, and reach its messages. A service key scoped to a + list without `*` sends it to read and stop the execution. An anon key is shared by + everyone who loads the page, so the id alone never reaches an + execution. Listing executions takes the project owner's token or a + project access token. Send `X-Volcano-Execution-Name` to make the start idempotent: repeating a start with the same name returns the existing execution instead of @@ -4462,7 +4465,9 @@ paths: description: | Function is not deployed yet, or has no deployed region. Also returned when two starts under the same execution name raced and - both released it, which is retryable as it stands. + both released it, which is retryable as it stands, and with `code` + `execution_name_held` when an unfinished start this caller may not + resume holds the execution name, which needs a name of its own. content: application/json: schema: @@ -4496,6 +4501,200 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /durable-functions/{functionId}/executions/{executionId}: + get: + tags: + - Durable Functions + summary: Get a durable execution from an application + description: | + Returns the execution's current state with the credential that started + it, including its `result` once it has succeeded. Poll this to wait for + an execution to finish. + + **With a service key:** any execution in the project when the key holds + `*`, the default. A key scoped to a list without `*` reaches an + execution whose `X-Volcano-Client-Token` the request carries, as an + anon key does; the start returns that token. + + **With an auth user token:** an execution the user started, or one + whose `X-Volcano-Client-Token` the request carries, of a durable + function whose `visibility` is `authenticated` or `public`. + + **With an anon key:** an execution whose `X-Volcano-Client-Token` the + request carries, of a function with `visibility: public`. + + Only a service key holding `*` reaches an execution that is still + `pending`. Any other execution answers `404`, as a missing one does. + + The response is the one `GET + /projects/{id}/durable-functions/{functionId}/executions/{executionId}` + returns. It never carries `client_token`. + operationId: getDurableExecutionFromApplication + security: + - AnonKey: [] + - ServiceRoleKey: [] + - AuthUserAccessToken: [] + parameters: + - $ref: '#/components/parameters/DurableFunctionId' + - $ref: '#/components/parameters/DurableExecutionId' + - $ref: '#/components/parameters/DurableClientToken' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/DurableExecution' + '401': + description: Missing or invalid credential + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: | + The anon key lacks `functions.invoke`, the service key is scoped + without it, or the function is `authenticated` and the request + carries an anon key. Also returned when the request's origin is + refused by the project's CORS policy. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: | + Durable function or execution not found, or the credential cannot + reach the execution. A `private` function answers every credential + but a service key this way. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + description: | + The function's or the project's function rate limit, which reads + share with invocations and starts, or the project is over its + bandwidth cap. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + description: | + Durable execution is not available in this environment, the plan + terms for the request could not be read, or reads of the + execution's state in its region are throttled. The first is not one + to retry in a loop; the others are transient, and a throttled read + carries `Retry-After`. + headers: + Retry-After: + description: | + Seconds to wait before reading again. Sent only when reads in + the execution's region are throttled. + schema: + type: integer + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /durable-functions/{functionId}/executions/{executionId}/stop: + post: + tags: + - Durable Functions + summary: Stop a durable execution from an application + description: | + Cancels a running execution with the credential that started it. Its + completed steps are not undone. + + Access follows the same rules as reading the execution. + + The call is accepted rather than awaited: the response reports the + execution as it was read back and may still say `running`. Poll + `GET /durable-functions/{functionId}/executions/{executionId}` to see + it settle into `stopped`. + + Stopping an execution that already finished is not an error: the + response carries the state it settled in. + operationId: stopDurableExecutionFromApplication + security: + - AnonKey: [] + - ServiceRoleKey: [] + - AuthUserAccessToken: [] + parameters: + - $ref: '#/components/parameters/DurableFunctionId' + - $ref: '#/components/parameters/DurableExecutionId' + - $ref: '#/components/parameters/DurableClientToken' + responses: + '200': + description: | + Stop accepted. The body is the execution as it was read back, which + may still report `running`. + content: + application/json: + schema: + $ref: '#/components/schemas/DurableExecution' + '401': + description: Missing or invalid credential + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: | + The anon key lacks `functions.invoke`, the service key is scoped + without it, or the function is `authenticated` and the request + carries an anon key. Also returned when the request's origin is + refused by the project's CORS policy. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: | + Durable function or execution not found, or the credential cannot + reach the execution. A `private` function answers every credential + but a service key this way. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: | + The execution has not started yet. Only a service key holding `*` + can reach an execution in that state. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + description: | + The function's or the project's function rate limit, which stops + share with invocations and starts, or the project is over its + bandwidth cap. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + description: | + Durable execution is not available in this environment, the plan + terms for the request could not be read, or the execution's state + could not be read back because reads in its region are throttled. + The first is not one to retry in a loop; the others are transient, + and a throttled read carries `Retry-After`. A stop answered with a + throttle may already have been sent, so read the execution before + stopping it again. + headers: + Retry-After: + description: | + Seconds to wait before reading again. Sent only when reads in + the execution's region are throttled. + schema: + type: integer + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /durable-functions/{functionId}/executions/{executionId}/messages: post: tags: @@ -16343,7 +16542,8 @@ components: required: false description: | The `client_token` the execution's start returned. Lets an anon key, - or a user who did not start the execution, reach it. + or a user who did not start the execution, reach it, and a service key + scoped to a list without `*` read and stop it. schema: type: string maxLength: 128 @@ -21008,7 +21208,8 @@ components: Whatever the function returned, verbatim. Absent while the execution is still running, absent when the result was too large to return and was checkpointed instead, and absent once the retention period has - lapsed. + lapsed. A result Volcano has just read can still be returned for up + to five minutes after that. result_expired: type: boolean description: | @@ -21031,9 +21232,11 @@ components: client_token: type: string description: | - Lets a browser read and send the execution's messages. Returned only - by the start that created the execution, never by a repeat under the - same name or by a read. Send it as `X-Volcano-Client-Token`. + Lets a browser read the execution, stop it, and read and send its + messages, and a service key scoped to a list without `*` read and + stop it. Returned only by the start whose input the execution runs, + never by a repeat under the same name or by a read. Send it as + `X-Volcano-Client-Token`. required: - id - function_id diff --git a/sig/core_facades.rbs b/sig/core_facades.rbs index 31eba082..16ef2bf3 100644 --- a/sig/core_facades.rbs +++ b/sig/core_facades.rbs @@ -98,6 +98,8 @@ module Volcano attr_reader messages: DurableMessages def start: (String function_name, ?sdk_payload payload, ?execution_name: String?) -> DurableExecution + def get_from_application: (String function_name, String execution_id, ?client_token: String?) -> DurableExecution + def stop_from_application: (String function_name, String execution_id, ?client_token: String?) -> DurableExecution def get: (String project_id, String function_name, String execution_id) -> DurableExecution def list: (String project_id, String function_name, ?status: String?, ?page: Integer?, ?limit: Integer?) -> DurableExecutionPage def stop: (String project_id, String function_name, String execution_id) -> DurableExecution diff --git a/sig_dev/core_durable.rbs b/sig_dev/core_durable.rbs index 44294407..df3d9db6 100644 --- a/sig_dev/core_durable.rbs +++ b/sig_dev/core_durable.rbs @@ -9,6 +9,8 @@ module Volcano interface _DurableTransport def start_durable_execution_from_application: (authorization: String, function_id: String, payload: sdk_payload, execution_name: String?) -> Transport::Response + def get_durable_execution_from_application: (authorization: String, function_id: String, execution_id: String, client_token: String?) -> Transport::Response + def stop_durable_execution_from_application: (authorization: String, function_id: String, execution_id: String, client_token: String?) -> Transport::Response def get_durable_execution: (authorization: String, project_id: String, function_id: String, execution_id: String) -> Transport::Response def list_durable_executions: (authorization: String, project_id: String, function_id: String, options: Hash[Symbol, String | Integer]) -> Transport::Response def stop_durable_execution: (authorization: String, project_id: String, function_id: String, execution_id: String) -> Transport::Response @@ -127,6 +129,7 @@ module Volcano class Durable include DurableArguments + include DurableMessageArguments include DurableResponses @client: _DurableClient @transport: _DurableTransport @@ -138,6 +141,7 @@ module Volcano def initialize: (_DurableClient client, _DurableTransport & _DurableApprovalsTransport & _DurableMessagesTransport transport, Realtime realtime) -> void private def execution_path: (String project_id, String function_name, String execution_id) -> [String, String, String] + private def application_target: (Object? function_name, Object? execution_id, Object? client_token) -> [String, String, String?] private def execution_name_argument: (Object? value) -> String end diff --git a/sig_dev/generated_client_boundary.rbs b/sig_dev/generated_client_boundary.rbs index 4e0d9622..dab66691 100644 --- a/sig_dev/generated_client_boundary.rbs +++ b/sig_dev/generated_client_boundary.rbs @@ -192,6 +192,8 @@ module Volcano def list_durable_executions_with_http_info: (String id, String function_id, ?api_options opts) -> api_result def start_durable_execution_from_application_with_http_info: (String function_id, ?api_options opts) -> api_result def stop_durable_execution_with_http_info: (String id, String function_id, String execution_id, ?api_options opts) -> api_result + def get_durable_execution_from_application_with_http_info: (String function_id, String execution_id, ?api_options opts) -> api_result + def stop_durable_execution_from_application_with_http_info: (String function_id, String execution_id, ?api_options opts) -> api_result def list_durable_approvals_with_http_info: (String id, ?api_options opts) -> api_result def get_durable_approval_with_http_info: (String id, String approval_id, ?api_options opts) -> api_result def get_durable_approval_stats_with_http_info: (String id, ?api_options opts) -> api_result diff --git a/sig_dev/generated_transport_runtime.rbs b/sig_dev/generated_transport_runtime.rbs index 51c431db..8a0b15df 100644 --- a/sig_dev/generated_transport_runtime.rbs +++ b/sig_dev/generated_transport_runtime.rbs @@ -113,6 +113,10 @@ module Volcano class GeneratedTransport def start_durable_execution_from_application: (authorization: String, function_id: String, payload: sdk_payload, ?execution_name: String?) -> Transport::Response + def get_durable_execution_from_application: (authorization: String, function_id: String, execution_id: String, client_token: String?) -> Transport::Response + + def stop_durable_execution_from_application: (authorization: String, function_id: String, execution_id: String, client_token: String?) -> Transport::Response + def get_durable_execution: (authorization: String, project_id: String, function_id: String, execution_id: String) -> Transport::Response def list_durable_executions: (authorization: String, project_id: String, function_id: String, options: Hash[Symbol, String | Integer]) -> Transport::Response diff --git a/spec/support/generated_api/fake_durable_api.rb b/spec/support/generated_api/fake_durable_api.rb index f72d6ad6..25b06c3c 100644 --- a/spec/support/generated_api/fake_durable_api.rb +++ b/spec/support/generated_api/fake_durable_api.rb @@ -31,6 +31,16 @@ def stop_durable_execution_with_http_info(project_id, function_id, execution_id) [FakeGeneratedModel.new(execution.merge(status: 'stopped')), 200, {}] end + def get_durable_execution_from_application_with_http_info(function_id, execution_id, options = {}) + @calls << [:get_from_application, function_id, execution_id, options] + [FakeGeneratedModel.new(execution), 200, {}] + end + + def stop_durable_execution_from_application_with_http_info(function_id, execution_id, options = {}) + @calls << [:stop_from_application, function_id, execution_id, options] + [FakeGeneratedModel.new(execution.merge(status: 'stopped')), 200, {}] + end + private def execution diff --git a/spec/volcano/client_durable_spec.rb b/spec/volcano/client_durable_spec.rb index bfa3b750..a129d885 100644 --- a/spec/volcano/client_durable_spec.rb +++ b/spec/volcano/client_durable_spec.rb @@ -12,6 +12,7 @@ %i[ start_durable_execution_from_application get_durable_execution list_durable_executions stop_durable_execution + get_durable_execution_from_application stop_durable_execution_from_application ].each do |operation| fake.define_singleton_method(operation) do |**arguments| call_log << [operation, arguments] @@ -220,6 +221,111 @@ def transport_response(status, body) expect(again.terminal?).to be(true) end + it 'reads an execution from the application with the credential start uses', :aggregate_failures do + responses[:get_durable_execution_from_application] = transport_response( + 200, + execution(status: 'succeeded', completed_at: '2026-09-14T12:04:00Z', result: { 'charge' => 'charge-1' }) + ) + + result = client.durable.get_from_application('charge-order', 'execution-1') + + expect(result).to have_attributes( + id: 'execution-1', status: 'succeeded', completed_at: Time.utc(2026, 9, 14, 12, 4), client_token: nil + ) + expect(result.result).to eq('charge' => 'charge-1').and be_frozen + expect(calls).to eq( + [[ + :get_durable_execution_from_application, + { authorization: 'service-key', function_id: 'charge-order', execution_id: 'execution-1', client_token: nil } + ]] + ) + end + + it 'reads an execution with an anon key and its client token' do + responses[:get_durable_execution_from_application] = transport_response(200, execution(status: 'running')) + public_client = described_class.new(anon_key: 'anon-key', _transport: transport) + + public_client.durable.get_from_application('charge-order', 'execution-1', client_token: 'client-token') + + expect(calls.first.last).to eq( + authorization: 'anon-key', function_id: 'charge-order', execution_id: 'execution-1', + client_token: 'client-token' + ) + end + + it 'stops an execution from the application and returns it as read back', :aggregate_failures do + responses[:stop_durable_execution_from_application] = transport_response(200, execution(status: 'running')) + public_client = described_class.new(anon_key: 'anon-key', _transport: transport) + + result = public_client.durable.stop_from_application('charge-order', 'execution-1', client_token: 'client-token') + + expect(result).to have_attributes(id: 'execution-1', status: 'running') + expect(result.terminal?).to be(false) + expect(calls).to eq( + [[ + :stop_durable_execution_from_application, + { authorization: 'anon-key', function_id: 'charge-order', execution_id: 'execution-1', + client_token: 'client-token' } + ]] + ) + end + + it 'prefers a session over the service key for application reads and stops' do + responses[:get_durable_execution_from_application] = transport_response(200, execution(status: 'running')) + responses[:stop_durable_execution_from_application] = transport_response(200, execution(status: 'stopped')) + + owner.durable.get_from_application('charge-order', 'execution-1') + owner.durable.stop_from_application('charge-order', 'execution-1') + + expect(calls.map { |_, arguments| arguments.fetch(:authorization) }).to eq(%w[access-token access-token]) + end + + it 'raises for an execution the application credential cannot reach' do + responses[:get_durable_execution_from_application] = transport_response( + 404, 'error' => 'durable execution not found' + ) + + expect { client.durable.get_from_application('charge-order', 'execution-1', client_token: 'stale') } + .to raise_error(Volcano::Error::NotFoundError, 'durable execution not found') do |error| + expect(error).to have_attributes(status: 404, code: nil) + end + end + + it 'raises when an application stop reaches no execution' do + responses[:stop_durable_execution_from_application] = transport_response( + 404, 'error' => 'durable execution not found' + ) + + expect { client.durable.stop_from_application('charge-order', 'execution-1') } + .to raise_error(Volcano::Error::NotFoundError, 'durable execution not found') + end + + it 'raises for an application stop of an execution that has not started' do + responses[:stop_durable_execution_from_application] = transport_response( + 409, 'error' => 'durable execution was never started' + ) + + expect { client.durable.stop_from_application('charge-order', 'execution-1') } + .to raise_error(Volcano::Error::ConflictError) { |error| expect(error.status).to eq(409) } + end + + it 'refuses application arguments before transport', :aggregate_failures do + ['', ' '].each do |blank| + expect { client.durable.get_from_application(blank, 'execution-1') } + .to raise_error(ArgumentError, /function_name/) + expect { client.durable.stop_from_application('charge-order', blank) } + .to raise_error(ArgumentError, /execution_id/) + end + ['', 'token with é', 'x' * 129, 42].each do |token| + expect { client.durable.get_from_application('charge-order', 'execution-1', client_token: token) } + .to raise_error(ArgumentError, /client_token must be 1 to 128 printable ASCII characters/) + expect { client.durable.stop_from_application('charge-order', 'execution-1', client_token: token) } + .to raise_error(ArgumentError, /client_token must be 1 to 128 printable ASCII characters/) + end + + expect(calls).to be_empty + end + it 'rejects blank path segments before transport', :aggregate_failures do ['', ' '].each do |blank| expect { client.durable.start(blank) }.to raise_error(ArgumentError, /function_name/) diff --git a/spec/volcano/client_generated_transport_spec.rb b/spec/volcano/client_generated_transport_spec.rb index 1ce1ce50..7624a78c 100644 --- a/spec/volcano/client_generated_transport_spec.rb +++ b/spec/volcano/client_generated_transport_spec.rb @@ -1212,6 +1212,39 @@ def download_transport(tempfile) expect(apis.durable.calls.last.first).to eq(:stop) end + it 'reads a durable execution on the application route with its client token', :aggregate_failures do + response = transport.get_durable_execution_from_application( + authorization: 'anon-key', function_id: 'charge-order', + execution_id: '00000000-0000-4000-8000-000000000041', client_token: 'client-token' + ) + + expect(response).to have_attributes(status: 200) + expect(response.body).to include('id' => '00000000-0000-4000-8000-000000000041', 'status' => 'running') + expect(authorizations.last).to eq('anon-key') + expect(apis.durable.calls.last).to eq( + [ + :get_from_application, 'charge-order', '00000000-0000-4000-8000-000000000041', + { x_volcano_client_token: 'client-token' } + ] + ) + end + + it 'stops a durable execution on the application route without a client token', :aggregate_failures do + response = transport.stop_durable_execution_from_application( + authorization: 'service-key', function_id: 'charge-order', + execution_id: '00000000-0000-4000-8000-000000000041', client_token: nil + ) + + # Omitted rather than sent as nil: a session, or a service key holding + # `*`, reaches the execution without one. + expect(response).to have_attributes(status: 200) + expect(response.body).to include('status' => 'stopped') + expect(authorizations.last).to eq('service-key') + expect(apis.durable.calls.last).to eq( + [:stop_from_application, 'charge-order', '00000000-0000-4000-8000-000000000041', {}] + ) + end + it 'rejects a non-string email-change acknowledgement' do apis.authentication.email_change_body = 42 diff --git a/spec/volcano/durable_application_http_spec.rb b/spec/volcano/durable_application_http_spec.rb new file mode 100644 index 00000000..e705ba89 --- /dev/null +++ b/spec/volcano/durable_application_http_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +# Reading and stopping an execution on the application route, through the +# generated client down to the request it builds. +RSpec.describe Volcano::Durable do + let(:client) { Volcano::Client.new(anon_key: 'anon', api_url: 'https://api.test.volcano.dev') } + let(:requests) { [] } + let(:execution_id) { '00000000-0000-4000-8000-000000000061' } + let(:route) { "https://api.test.volcano.dev/durable-functions/charge-order/executions/#{execution_id}" } + + def execution(**overrides) + { + 'id' => execution_id, 'function_id' => '00000000-0000-4000-8000-000000000062', 'name' => 'order-9', + 'status' => 'running', 'region' => 'aws-us-east-1', 'created_at' => '2026-10-12T09:00:00Z' + }.merge(overrides.transform_keys(&:to_s)) + end + + def respond(payload, code: 200) + response = Typhoeus::Response.new( + code: code, return_code: :ok, headers: { 'Content-Type' => 'application/json' }, body: JSON.generate(payload) + ) + allow(Typhoeus::Request).to receive(:new) do |url, options| + requests << [url, options] + instance_double(Typhoeus::Request, run: response, options: {}) + end + end + + def sent + url, options = requests.fetch(0) + [url, options.fetch(:method), options.fetch(:params), options[:body]] + end + + def header(name) + requests.fetch(0).last.fetch(:headers).transform_keys(&:to_s)[name] + end + + it 'reads an execution with the anon key and its client token', :aggregate_failures do + respond(execution(status: 'succeeded', completed_at: '2026-10-12T09:01:00Z', result: { 'charge' => 'charge-1' })) + + result = client.durable.get_from_application('charge-order', execution_id, client_token: 'client-token') + + expect(sent).to eq([route, :get, {}, nil]) + expect([header('Authorization'), header('X-Volcano-Client-Token')]).to eq(['Bearer anon', 'client-token']) + expect(result).to have_attributes( + id: execution_id, status: 'succeeded', completed_at: Time.utc(2026, 10, 12, 9, 1), client_token: nil + ) + expect(result.result).to eq('charge' => 'charge-1') + end + + it 'stops an execution with the anon key and its client token', :aggregate_failures do + respond(execution) + + result = client.durable.stop_from_application('charge-order', execution_id, client_token: 'client-token') + + expect(sent).to eq(["#{route}/stop", :post, {}, nil]) + expect([header('Authorization'), header('X-Volcano-Client-Token')]).to eq(['Bearer anon', 'client-token']) + expect(result).to have_attributes(id: execution_id, status: 'running') + end + + it 'sends no client token when none is given', :aggregate_failures do + client.auth.current_session = Volcano::Session.new(access_token: 'user-token', refresh_token: 'r', user_id: 'u') + respond(execution) + client.durable.get_from_application('charge-order', execution_id) + respond(execution(status: 'stopped')) + client.durable.stop_from_application('charge-order', execution_id) + + expect(requests.map { |url, options| [url, options.fetch(:method)] }).to eq( + [[route, :get], ["#{route}/stop", :post]] + ) + requests.map(&:last).each do |options| + headers = options.fetch(:headers).transform_keys(&:to_s) + expect(headers['Authorization']).to eq('Bearer user-token') + expect(headers).not_to have_key('X-Volcano-Client-Token') + end + end + + it 'sends the service key when there is no session' do + service_client = Volcano::Client.new( + anon_key: 'anon', service_key: 'service', api_url: 'https://api.test.volcano.dev' + ) + respond(execution) + + service_client.durable.get_from_application('charge-order', execution_id) + + expect(header('Authorization')).to eq('Bearer service') + end + + # A service key scoped to a list without `*` reaches an execution only with + # its client token, so the token goes out with a service key too. + it 'sends the client token with the service key', :aggregate_failures do + service_client = Volcano::Client.new( + anon_key: 'anon', service_key: 'service', api_url: 'https://api.test.volcano.dev' + ) + respond(execution) + service_client.durable.get_from_application('charge-order', execution_id, client_token: 'client-token') + respond(execution(status: 'stopped')) + service_client.durable.stop_from_application('charge-order', execution_id, client_token: 'client-token') + + expect(requests.map { |url, options| [url, options.fetch(:method)] }).to eq( + [[route, :get], ["#{route}/stop", :post]] + ) + requests.map(&:last).each do |options| + headers = options.fetch(:headers).transform_keys(&:to_s) + expect(headers.values_at('Authorization', 'X-Volcano-Client-Token')).to eq(['Bearer service', 'client-token']) + end + end + + { get_from_application: :get, stop_from_application: :post }.each do |operation, method| + it "raises NotFoundError when #{operation} reaches no execution", :aggregate_failures do + respond({ 'error' => 'durable execution not found' }, code: 404) + + expect { client.durable.public_send(operation, 'charge-order', execution_id, client_token: 'stale') } + .to raise_error(Volcano::Error::NotFoundError, 'durable execution not found') do |error| + expect(error).to have_attributes(status: 404, code: nil) + end + expect(sent.first(2)).to eq([operation == :get_from_application ? route : "#{route}/stop", method]) + end + end + + it 'raises ConflictError when a stop reaches an execution that has not started' do + respond({ 'error' => 'durable execution was never started' }, code: 409) + + expect { client.durable.stop_from_application('charge-order', execution_id, client_token: 'client-token') } + .to raise_error(Volcano::Error::ConflictError, 'durable execution was never started') do |error| + expect(error).to have_attributes(status: 409, code: nil) + end + end +end diff --git a/spec/volcano_generation_spec.rb b/spec/volcano_generation_spec.rb index 98ececaa..dfc6edd5 100644 --- a/spec/volcano_generation_spec.rb +++ b/spec/volcano_generation_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Volcano do let(:root) { File.expand_path('..', __dir__) } - let(:openapi_sha256) { '7fec2090c1f4f4455b490a50ce2c92ef7ba82a1a71cfdb021ccc14adf5e1def8' } + let(:openapi_sha256) { '168cf780648fdb7878275bd8e596091341a90bed137e7871e546fbf11b1bf340' } it 'accepts current and legacy plan names in project responses' do project = described_class.const_get(:Generated).const_get(:Project) @@ -82,6 +82,8 @@ 'list_durable_approvals', 'approve_durable_approval', 'deny_durable_approval', + 'get_durable_execution_from_application', + 'stop_durable_execution_from_application', 'send_durable_message_from_application', 'list_durable_messages_from_application', 'send_durable_message', diff --git a/tests/types/core_facades.rb b/tests/types/core_facades.rb index 52d17d6b..71f0ff6b 100644 --- a/tests/types/core_facades.rb +++ b/tests/types/core_facades.rb @@ -25,6 +25,12 @@ def typed_timestamp_calls(client) client.logs.search('project', resource: { id: 'worker' }, start_time: Time.utc(2026), end_time: Time.utc(2026, 2)) end +# @type method typed_application_execution_calls: (Volcano::Client) -> Volcano::DurableExecution +def typed_application_execution_calls(client) + client.durable.get_from_application('worker', 'execution', client_token: 'token').result + client.durable.stop_from_application('worker', 'execution') +end + # @type method typed_approval_calls: (Volcano::Client) -> Volcano::DurableApproval def typed_approval_calls(client) approvals = client.durable.approvals diff --git a/tests/types_invalid/core_facades.rb b/tests/types_invalid/core_facades.rb index 6587b801..c12fd3f6 100644 --- a/tests/types_invalid/core_facades.rb +++ b/tests/types_invalid/core_facades.rb @@ -5,6 +5,7 @@ client.database('app').from(12) client.functions.invoke(17) client.durable.list(12, 'worker') +client.durable.get_from_application('worker', 'execution', client_token: 42) client.durable.approvals.list('project', page: '2') client.durable.approvals.approve('project', 'approval', comment: 42) client.durable.messages.send_message('approvals', 'execution', :decision)