volcano-sdk 0.9.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +69 -0
  3. data/lib/volcano/client.rb +2 -1
  4. data/lib/volcano/durable.rb +136 -0
  5. data/lib/volcano/durable_models.rb +143 -0
  6. data/lib/volcano/generated/lib/volcano-generated/api/databases_api.rb +2 -2
  7. data/lib/volcano/generated/lib/volcano-generated/api/durable_functions_api.rb +1239 -0
  8. data/lib/volcano/generated/lib/volcano-generated/api/frontends_api.rb +2 -2
  9. data/lib/volcano/generated/lib/volcano-generated/api/functions_api.rb +18 -6
  10. data/lib/volcano/generated/lib/volcano-generated/api/o_auth_authentication_api.rb +2 -2
  11. data/lib/volcano/generated/lib/volcano-generated/api/projects_api.rb +76 -2
  12. data/lib/volcano/generated/lib/volcano-generated/models/create_database_request.rb +6 -8
  13. data/lib/volcano/generated/lib/volcano-generated/models/create_variable_request.rb +11 -1
  14. data/lib/volcano/generated/lib/volcano-generated/models/durable_execution.rb +358 -0
  15. data/lib/volcano/generated/lib/volcano-generated/models/durable_execution_error.rb +157 -0
  16. data/lib/volcano/generated/lib/volcano-generated/models/durable_execution_status.rb +45 -0
  17. data/lib/volcano/generated/lib/volcano-generated/models/durable_function.rb +479 -0
  18. data/lib/volcano/generated/lib/volcano-generated/models/durable_function_config.rb +193 -0
  19. data/lib/volcano/generated/lib/volcano-generated/models/function_kind.rb +40 -0
  20. data/lib/volcano/generated/lib/volcano-generated/models/function_runtime_option.rb +28 -1
  21. data/lib/volcano/generated/lib/volcano-generated/models/function_scheduler.rb +11 -1
  22. data/lib/volcano/generated/lib/volcano-generated/models/list_database_regions200_response_inner.rb +1 -1
  23. data/lib/volcano/generated/lib/volcano-generated/models/metric_usage_data.rb +1 -1
  24. data/lib/volcano/generated/lib/volcano-generated/models/paginated_durable_executions.rb +274 -0
  25. data/lib/volcano/generated/lib/volcano-generated/models/paginated_durable_functions.rb +274 -0
  26. data/lib/volcano/generated/lib/volcano-generated/models/project_config.rb +23 -1
  27. data/lib/volcano/generated/lib/volcano-generated/models/project_config_function.rb +47 -2
  28. data/lib/volcano/generated/lib/volcano-generated/models/project_config_variable.rb +11 -1
  29. data/lib/volcano/generated/lib/volcano-generated/models/project_deployment_resource.rb +14 -4
  30. data/lib/volcano/generated/lib/volcano-generated/models/project_health_resource.rb +14 -4
  31. data/lib/volcano/generated/lib/volcano-generated/models/replace_shared_variables_request.rb +237 -0
  32. data/lib/volcano/generated/lib/volcano-generated/models/update_variable_request.rb +11 -1
  33. data/lib/volcano/generated/lib/volcano-generated/models/variable.rb +11 -1
  34. data/lib/volcano/generated/lib/volcano-generated.rb +10 -0
  35. data/lib/volcano/generated_transport.rb +2 -1
  36. data/lib/volcano/generated_transport_durable.rb +50 -0
  37. data/lib/volcano/generated_transport_support.rb +1 -0
  38. data/lib/volcano/version.rb +1 -1
  39. data/lib/volcano.rb +2 -0
  40. metadata +14 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4fdce577eb0f97e2359db37774f29986e40c24d7d8b4bf64b67ccca4c96ea85
4
- data.tar.gz: 5bb422eb1a068e899aa6de617f2bfc4bf78128698009f384c37254c753ae3ea1
3
+ metadata.gz: ee82d4e1a777fcbca79d4ac464ae7ad87d8f0374fb6955cd1e61167a9c82641c
4
+ data.tar.gz: fd76dca669566978d588a0c0eb01c38b17166a621ae43f1a0f44862884083fb8
5
5
  SHA512:
6
- metadata.gz: b0cd140b083357a515a32e3f0dccbc5280519cbaa6f08f1881f745675b979193dd6bd1ddb9e6b415ac55cdabbdcfda44a1b157e97bc2c9f0f1370292ff377922
7
- data.tar.gz: 711a49849712a130ac0da5256bef2297428fd3fa2e69e91853842be89752b0e2406e66dc88c88fea78dea29bdfdfd6146701b4aa6a564638a24dad8c42904aab
6
+ metadata.gz: 4ce4ffb6d72500cfaae2736bede0a7438a1bf079a3c5c2e540ebf3bd737eb2157c46c8e08011b4d93a15deb900daf616fe076c64f3e29c3dfc0c08ba2e80acce
7
+ data.tar.gz: 8486d6ae8e2fb766e918610389ca94eb98d08446d01a5beebb7002b5a44448508a31681da6100a59ab5f62d4d5b0894fca9072ae09a33914d0c45f9d24f32f03
data/README.md CHANGED
@@ -495,6 +495,55 @@ Anonymous and service keys do not refresh.
495
495
 
496
496
  See the [functions guide](https://github.com/Kong/volcano-sdk-ruby/blob/main/docs/functions.md).
497
497
 
498
+ ### Start and follow a durable execution
499
+
500
+ ```ruby
501
+ project_id = "00000000-0000-4000-8000-000000000001"
502
+ handle = client.durable.start(
503
+ "charge-order",
504
+ { order_id: "order-9" },
505
+ execution_name: "order-9"
506
+ )
507
+
508
+ execution = client.durable.get(project_id, "charge-order", handle.id)
509
+ puts [execution.status, execution.terminal?, execution.result]
510
+
511
+ page = client.durable.list(project_id, "charge-order", status: "running")
512
+ puts [page.total, page.has_more]
513
+
514
+ client.durable.stop(project_id, "charge-order", handle.id)
515
+ ```
516
+
517
+ `start` takes a durable function's name or its id and returns a handle, never a
518
+ result: an execution can run for hours, so its result is read back with `get`.
519
+ It uses the same credential `invoke` does — the active user session, then a
520
+ configured service key, then the anonymous key — and is the only durable
521
+ operation an application credential may perform. An `execution_name` makes the
522
+ start idempotent: starting again under the same name returns the execution that
523
+ already exists rather than beginning a second one.
524
+
525
+ `get`, `list`, and `stop` are owner-scoped and require a platform token or a
526
+ configured service key, because an execution is addressed by its id alone. An
527
+ auth-user session from sign-in is not accepted. `get` carries the deeply
528
+ frozen `result` once the execution has succeeded, and `error` when it failed;
529
+ `result_expired` separates a result the platform has discarded from a function
530
+ that returned nothing. `terminal?` reports whether the execution has stopped
531
+ changing, and counts `unknown` — the status the platform writes for an outcome
532
+ it could not determine — as finished. `list` returns one page of executions,
533
+ most recent first, each carrying the status last observed rather than a live
534
+ one. `stop` is accepted rather than awaited: what it returns is the execution
535
+ read back after asking, often still `running`, so poll `get` to see it reach
536
+ `stopped`. Repeating a stop is safe.
537
+
538
+ This gem starts and follows durable executions; it cannot write the durable
539
+ function itself. Checkpointing a handler needs a durable authoring API, and
540
+ there is no Ruby one, so Volcano hosts durable functions on `nodejs22.x`,
541
+ `nodejs24.x`, `python3.13` and `python3.14`. Write the function in JavaScript
542
+ or Python and drive it from Ruby; the operations above are the whole surface a
543
+ caller needs.
544
+ Ruby remains a fully supported runtime for [standard
545
+ functions](https://volcano.dev/platform/functions/overview).
546
+
498
547
  ### Read project logs
499
548
 
500
549
  ```ruby
@@ -824,6 +873,26 @@ Connection callbacks receive immutable contexts and run outside protocol
824
873
  processing. Each registration returns an idempotent callable that stops future
825
874
  delivery.
826
875
 
876
+ ## Dependencies
877
+
878
+ Installing `volcano-sdk` pulls in eight gems, plus their own transitive
879
+ dependencies:
880
+
881
+ | Gem | Why |
882
+ | ------------------------------------------------------------ | ----------------------------------------------------------------------- |
883
+ | [`typhoeus`](https://rubygems.org/gems/typhoeus) | The HTTP library the generated REST client uses |
884
+ | [`logger`](https://rubygems.org/gems/logger) | Required by that client, and no longer a default gem |
885
+ | [`async`](https://rubygems.org/gems/async) | The reactor realtime runs its connection and delivery on |
886
+ | [`async-http`](https://rubygems.org/gems/async-http) | The endpoint realtime dials |
887
+ | [`async-websocket`](https://rubygems.org/gems/async-websocket) | The realtime transport itself |
888
+ | [`protocol-rack`](https://rubygems.org/gems/protocol-rack) | A requirement of `async-websocket`, pinned here so the version is ours |
889
+ | [`base64`](https://rubygems.org/gems/base64) | Required by the generated client, and no longer a default gem since Ruby 3.4 |
890
+ | [`json`](https://rubygems.org/gems/json) | Request and response encoding for the generated client |
891
+
892
+ There is no optional durable dependency, because this gem starts and follows
893
+ durable executions rather than writing them. See
894
+ [Start and follow a durable execution](#start-and-follow-a-durable-execution).
895
+
827
896
  ## Generated boundary
828
897
 
829
898
  The internal REST transport is generated from `openapi/openapi.yaml`, which
@@ -12,7 +12,7 @@ module Volcano
12
12
  end
13
13
  private_constant :SessionToken
14
14
 
15
- attr_reader :auth, :functions, :logs, :storage, :locks, :realtime
15
+ attr_reader :auth, :functions, :durable, :logs, :storage, :locks, :realtime
16
16
 
17
17
  def initialize(
18
18
  anon_key:,
@@ -112,6 +112,7 @@ module Volcano
112
112
  def initialize_facades(socket_factory, reconnect_delay)
113
113
  @auth = Auth.new(self, @transport, api_url: @api_url)
114
114
  @functions = Functions.new(self, @transport, api_url: @api_url)
115
+ @durable = Durable.new(self, @transport)
115
116
  @logs = Logs.new(self, @transport)
116
117
  @storage = Storage.new(self, @transport, api_url: @api_url, anon_key: @anon_key)
117
118
  @locks = Locks.new(self, @transport)
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Volcano
4
+ # Starts and follows executions of deployed durable functions.
5
+ class Durable
6
+ # What the platform accepts as an execution name, and the page size it
7
+ # serves at most. Mirrored from the wire contract so a refusal is the
8
+ # facade's rather than the generated client's.
9
+ EXECUTION_NAME = /\A[A-Za-z0-9._-]{1,255}\z/
10
+ MAX_PAGE_SIZE = 100
11
+ include DurableResponses
12
+
13
+ def initialize(client, transport)
14
+ @client = client
15
+ @transport = transport
16
+ end
17
+
18
+ # Starts an execution and returns its handle. A durable function is never
19
+ # invoked synchronously: it can outlive any request a caller could hold
20
+ # open, so its result is read back with #get.
21
+ #
22
+ # Starting is the only durable operation an application credential may
23
+ # perform, and it takes the token an invoke takes. Passing an
24
+ # +execution_name+ makes the start idempotent: starting again under the
25
+ # same name returns the execution that already exists rather than
26
+ # beginning a second one.
27
+ def start(function_name, payload = {}, execution_name: nil)
28
+ # A durable function's id is accepted here as well as its name, so the
29
+ # DNS-safe name pattern an invoke checks would reject half the input.
30
+ function_id = identifier(function_name, 'function_name')
31
+ name = execution_name.nil? ? nil : execution_name_argument(execution_name)
32
+ response = Transport.invoke do
33
+ @transport.start_durable_execution_from_application(
34
+ authorization: @client.function_token,
35
+ function_id: function_id, payload: payload.dup, execution_name: name
36
+ )
37
+ end
38
+ durable_execution(Transport.body(response, 202))
39
+ end
40
+
41
+ # Reads an execution, including its result once it has succeeded.
42
+ #
43
+ # Owner-scoped: an execution is addressed by its id alone and an anon key
44
+ # is held by everyone who loads the page, so this takes the project id and
45
+ # a platform token or service key. An auth-user session from sign-in is
46
+ # not enough. Poll it from a backend, not a browser.
47
+ def get(project_id, function_name, execution_id)
48
+ project, function_id, execution = execution_path(project_id, function_name, execution_id)
49
+ response = Transport.invoke do
50
+ @transport.get_durable_execution(
51
+ authorization: @client.session_token,
52
+ project_id: project, function_id: function_id, execution_id: execution
53
+ )
54
+ end
55
+ durable_execution(Transport.body(response, 200))
56
+ end
57
+
58
+ # Lists a durable function's executions, most recent first. Each entry
59
+ # carries the status the platform last observed rather than a live one;
60
+ # read a single execution for that. Owner-scoped, like #get.
61
+ def list(project_id, function_name, status: nil, page: nil, limit: nil)
62
+ project = identifier(project_id, 'project_id')
63
+ function_id = identifier(function_name, 'function_name')
64
+ validate_paging(page, limit)
65
+ response = Transport.invoke do
66
+ @transport.list_durable_executions(
67
+ authorization: @client.session_token, project_id: project, function_id: function_id,
68
+ options: { status: status, page: page, limit: limit }.compact
69
+ )
70
+ end
71
+ durable_execution_page(Transport.body(response, 200))
72
+ end
73
+
74
+ # Asks a running execution to stop. Accepted rather than awaited: what
75
+ # comes back is the execution read after asking, and it often still says
76
+ # +running+, so poll #get to see it reach +stopped+. Completed steps are
77
+ # not undone, and repeating a stop is safe — an execution that has already
78
+ # finished reports the state it settled in. Owner-scoped, like #get.
79
+ def stop(project_id, function_name, execution_id)
80
+ project, function_id, execution = execution_path(project_id, function_name, execution_id)
81
+ response = Transport.invoke do
82
+ @transport.stop_durable_execution(
83
+ authorization: @client.session_token,
84
+ project_id: project, function_id: function_id, execution_id: execution
85
+ )
86
+ end
87
+ durable_execution(Transport.body(response, 200))
88
+ end
89
+
90
+ private
91
+
92
+ def execution_path(project_id, function_name, execution_id)
93
+ [
94
+ identifier(project_id, 'project_id'),
95
+ identifier(function_name, 'function_name'),
96
+ identifier(execution_id, 'execution_id')
97
+ ]
98
+ end
99
+
100
+ # An empty path segment would address the collection instead of the
101
+ # execution, which is a different request rather than a failed one.
102
+ def identifier(value, field)
103
+ raise ArgumentError, "#{field} must be a non-empty String" unless present_string?(value)
104
+
105
+ value.strip.freeze
106
+ end
107
+
108
+ # The generated client validates this header against the same pattern and
109
+ # raises its own message, naming the operation and the option key it knows
110
+ # the header by. Checked here first so the caller reads a message about the
111
+ # argument they passed, the way Functions#invoke does for a function name.
112
+ def execution_name_argument(value)
113
+ name = identifier(value, 'execution_name')
114
+ unless EXECUTION_NAME.match?(name)
115
+ raise ArgumentError,
116
+ 'execution_name must be 1-255 characters of letters, numbers, dots, dashes or underscores'
117
+ end
118
+
119
+ name
120
+ end
121
+
122
+ # Same reason: the generated client enforces the page and limit bounds and
123
+ # answers with its own vocabulary.
124
+ def validate_paging(page, limit)
125
+ raise ArgumentError, 'page must be a positive Integer' unless page.nil? || positive_integer?(page)
126
+
127
+ return if limit.nil? || (positive_integer?(limit) && limit <= MAX_PAGE_SIZE)
128
+
129
+ raise ArgumentError, "limit must be an Integer between 1 and #{MAX_PAGE_SIZE}"
130
+ end
131
+
132
+ def positive_integer?(value)
133
+ value.is_a?(Integer) && value.positive?
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Volcano
6
+ DURABLE_EXECUTION_ATTRIBUTES = %i[
7
+ id function_id name status region created_at result result_expired error completed_at
8
+ ].freeze
9
+ private_constant :DURABLE_EXECUTION_ATTRIBUTES
10
+
11
+ # `unknown` is terminal, and the platform writes it itself for an execution
12
+ # whose outcome it could not determine. Code that switches on status has to
13
+ # treat it as finished, or it reads a finished execution as still running.
14
+ DURABLE_TERMINAL_STATUSES = %w[succeeded failed timed_out stopped unknown].freeze
15
+ private_constant :DURABLE_TERMINAL_STATUSES
16
+
17
+ # Why a failed or timed-out execution ended.
18
+ DurableExecutionError = Data.define(:type, :message) do
19
+ def initialize(type: nil, message: nil)
20
+ super(type: type&.dup&.freeze, message: message&.dup&.freeze)
21
+ end
22
+ end
23
+
24
+ # A durable execution, as the platform last observed it.
25
+ #
26
+ # `result` is absent while the execution is still running, and absent again
27
+ # once its retention lapses. That is not the same as a function that returned
28
+ # nothing, so read `result_expired` before concluding anything from a missing
29
+ # result.
30
+ DurableExecution = Data.define(*DURABLE_EXECUTION_ATTRIBUTES) do
31
+ def initialize(**attributes)
32
+ unknown = attributes.keys - DURABLE_EXECUTION_ATTRIBUTES
33
+ raise ArgumentError, "unknown keywords: #{unknown.join(', ')}" unless unknown.empty?
34
+
35
+ missing = DURABLE_EXECUTION_ATTRIBUTES.first(6) - attributes.keys
36
+ raise ArgumentError, "missing keywords: #{missing.join(', ')}" unless missing.empty?
37
+
38
+ values = DURABLE_EXECUTION_ATTRIBUTES.to_h do |name|
39
+ [name, immutable_value(attributes[name])]
40
+ end
41
+ super(**values)
42
+ end
43
+
44
+ # Reports whether the execution has stopped changing.
45
+ def terminal?
46
+ DURABLE_TERMINAL_STATUSES.include?(status)
47
+ end
48
+
49
+ private
50
+
51
+ # `result` is the function's own JSON, so it is frozen all the way down.
52
+ def immutable_value(value)
53
+ case value
54
+ when Hash then value.to_h { |key, item| [immutable_value(key), immutable_value(item)] }.freeze
55
+ when Array then value.map { |item| immutable_value(item) }.freeze
56
+ when Time, String then value.dup.freeze
57
+ else value
58
+ end
59
+ end
60
+ end
61
+
62
+ # One page of a durable function's executions, most recent first.
63
+ DurableExecutionPage = Data.define(:executions, :page, :limit, :total, :has_more) do
64
+ def initialize(executions:, page:, limit:, total:, has_more:)
65
+ super(
66
+ executions: executions.to_a.dup.freeze,
67
+ page: page, limit: limit, total: total, has_more: has_more
68
+ )
69
+ end
70
+ end
71
+
72
+ # Builds the public durable types from transport payloads.
73
+ module DurableResponses
74
+ INCOMPLETE_EXECUTION = 'Expected a complete durable execution'
75
+ INCOMPLETE_PAGE = 'Expected a complete durable execution page'
76
+ EXECUTION_FIELDS = %w[id function_id name status region].freeze
77
+ private_constant :INCOMPLETE_EXECUTION, :INCOMPLETE_PAGE, :EXECUTION_FIELDS
78
+
79
+ private
80
+
81
+ def durable_execution(payload)
82
+ values = execution_payload(payload)
83
+ DurableExecution.new(
84
+ id: values.fetch('id'), function_id: values.fetch('function_id'),
85
+ name: values.fetch('name'), status: values.fetch('status'),
86
+ region: values.fetch('region'), created_at: parse_time(values.fetch('created_at')),
87
+ result: values['result'], result_expired: values['result_expired'],
88
+ error: execution_error(values['error']), completed_at: parse_time(values['completed_at'])
89
+ )
90
+ end
91
+
92
+ def execution_payload(payload)
93
+ raise TypeError, INCOMPLETE_EXECUTION unless payload.is_a?(Hash)
94
+
95
+ complete = EXECUTION_FIELDS.all? { |field| present_string?(payload[field]) }
96
+ raise TypeError, INCOMPLETE_EXECUTION unless complete && payload['created_at']
97
+
98
+ payload
99
+ end
100
+
101
+ def execution_error(payload)
102
+ return if payload.nil?
103
+ raise TypeError, INCOMPLETE_EXECUTION unless payload.is_a?(Hash)
104
+
105
+ DurableExecutionError.new(type: payload['type'], message: payload['message'])
106
+ end
107
+
108
+ def durable_execution_page(payload)
109
+ data, has_more = page_payload(payload)
110
+ DurableExecutionPage.new(
111
+ executions: data.map { |entry| durable_execution(entry) }, has_more: has_more,
112
+ page: count(payload['page']), limit: count(payload['limit']), total: count(payload['total'])
113
+ )
114
+ end
115
+
116
+ # An empty page carries no executions at all rather than an empty array.
117
+ def page_payload(payload)
118
+ raise TypeError, INCOMPLETE_PAGE unless payload.is_a?(Hash)
119
+
120
+ data = payload['data'] || []
121
+ has_more = payload.fetch('has_more', false)
122
+ raise TypeError, INCOMPLETE_PAGE unless data.is_a?(Array) && [true, false].include?(has_more)
123
+
124
+ [data, has_more]
125
+ end
126
+
127
+ def count(value)
128
+ return 0 if value.nil?
129
+ raise TypeError, INCOMPLETE_PAGE unless value.is_a?(Integer)
130
+
131
+ value
132
+ end
133
+
134
+ def present_string?(value)
135
+ value.is_a?(String) && !value.strip.empty?
136
+ end
137
+
138
+ def parse_time(value)
139
+ value.is_a?(String) ? Time.iso8601(value) : value
140
+ end
141
+ end
142
+ private_constant :DurableResponses
143
+ end
@@ -428,7 +428,7 @@ module Volcano::Generated
428
428
  end
429
429
 
430
430
  # List platform-supported regions for database provisioning
431
- # Returns the regions enabled for database provisioning in this platform environment. This is a public endpoint that doesn't require authentication.
431
+ # Returns the regions enabled for database provisioning in this platform environment. These are the same regions offered for function deployment, and the only values the `region` field of a database accepts. This is a public endpoint that doesn't require authentication.
432
432
  # @param [Hash] opts the optional parameters
433
433
  # @return [Array<ListDatabaseRegions200ResponseInner>]
434
434
  def list_database_regions(opts = {})
@@ -437,7 +437,7 @@ module Volcano::Generated
437
437
  end
438
438
 
439
439
  # List platform-supported regions for database provisioning
440
- # Returns the regions enabled for database provisioning in this platform environment. This is a public endpoint that doesn&#39;t require authentication.
440
+ # Returns the regions enabled for database provisioning in this platform environment. These are the same regions offered for function deployment, and the only values the &#x60;region&#x60; field of a database accepts. This is a public endpoint that doesn&#39;t require authentication.
441
441
  # @param [Hash] opts the optional parameters
442
442
  # @return [Array<(Array<ListDatabaseRegions200ResponseInner>, Integer, Hash)>] Array<ListDatabaseRegions200ResponseInner> data, response status code and response headers
443
443
  def list_database_regions_with_http_info(opts = {})