volcano-sdk 0.34.0 → 0.35.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.
- checksums.yaml +4 -4
- data/README.md +53 -4
- data/lib/volcano/client.rb +1 -1
- data/lib/volcano/durable.rb +9 -4
- data/lib/volcano/durable_approval_models.rb +1 -1
- data/lib/volcano/durable_message_arguments.rb +49 -0
- data/lib/volcano/durable_message_backlog.rb +38 -0
- data/lib/volcano/durable_message_catch_ups.rb +112 -0
- data/lib/volcano/durable_message_models.rb +62 -0
- data/lib/volcano/durable_message_responses.rb +107 -0
- data/lib/volcano/durable_message_session.rb +72 -0
- data/lib/volcano/durable_message_stream.rb +176 -0
- data/lib/volcano/durable_messages.rb +217 -0
- data/lib/volcano/durable_models.rb +32 -3
- data/lib/volcano/generated/lib/volcano-generated/api/auth_admin_api.rb +2 -2
- data/lib/volcano/generated/lib/volcano-generated/api/auth_configuration_api.rb +2 -2
- data/lib/volcano/generated/lib/volcano-generated/api/autofix_api.rb +222 -0
- data/lib/volcano/generated/lib/volcano-generated/api/durable_functions_api.rb +725 -0
- data/lib/volcano/generated/lib/volcano-generated/api/frontends_api.rb +2 -2
- data/lib/volcano/generated/lib/volcano-generated/api/issues_api.rb +254 -0
- data/lib/volcano/generated/lib/volcano-generated/api/sandboxes_api.rb +20 -20
- data/lib/volcano/generated/lib/volcano-generated/api/system_api.rb +57 -0
- data/lib/volcano/generated/lib/volcano-generated/api/variable_environments_api.rb +373 -0
- data/lib/volcano/generated/lib/volcano-generated/api/verified_domains_api.rb +2 -2
- data/lib/volcano/generated/lib/volcano-generated/models/auth_config.rb +46 -4
- data/lib/volcano/generated/lib/volcano-generated/models/auth_insights_series_point.rb +110 -2
- data/lib/volcano/generated/lib/volcano-generated/models/autofix_model.rb +45 -0
- data/lib/volcano/generated/lib/volcano-generated/models/autofix_provider.rb +40 -0
- data/lib/volcano/generated/lib/volcano-generated/models/autofix_settings.rb +385 -0
- data/lib/volcano/generated/lib/volcano-generated/models/autofix_stage.rb +40 -0
- data/lib/volcano/generated/lib/volcano-generated/models/capability.rb +214 -0
- data/lib/volcano/generated/lib/volcano-generated/models/capability_list.rb +166 -0
- data/lib/volcano/generated/lib/volcano-generated/models/capability_status.rb +40 -0
- data/lib/volcano/generated/lib/volcano-generated/models/create_sandbox_session_request.rb +16 -53
- data/lib/volcano/generated/lib/volcano-generated/models/create_variable_environment_request.rb +176 -0
- data/lib/volcano/generated/lib/volcano-generated/models/durable_execution.rb +14 -4
- data/lib/volcano/generated/lib/volcano-generated/models/durable_message.rb +396 -0
- data/lib/volcano/generated/lib/volcano-generated/models/durable_message_direction.rb +40 -0
- data/lib/volcano/generated/lib/volcano-generated/models/durable_message_list.rb +194 -0
- data/lib/volcano/generated/lib/volcano-generated/models/durable_message_sender.rb +203 -0
- data/lib/volcano/generated/lib/volcano-generated/models/durable_message_session_registration.rb +165 -0
- data/lib/volcano/generated/lib/volcano-generated/models/durable_message_status.rb +43 -0
- data/lib/volcano/generated/lib/volcano-generated/models/durable_message_wait.rb +278 -0
- data/lib/volcano/generated/lib/volcano-generated/models/durable_message_wait_list.rb +167 -0
- data/lib/volcano/generated/lib/volcano-generated/models/durable_message_wait_registration.rb +225 -0
- data/lib/volcano/generated/lib/volcano-generated/models/durable_message_wait_status.rb +43 -0
- data/lib/volcano/generated/lib/volcano-generated/models/frontend.rb +11 -1
- data/lib/volcano/generated/lib/volcano-generated/models/frontend_custom_domain_conflict_error.rb +1 -1
- data/lib/volcano/generated/lib/volcano-generated/models/frontend_custom_domain_response.rb +25 -2
- data/lib/volcano/generated/lib/volcano-generated/models/issue.rb +493 -0
- data/lib/volcano/generated/lib/volcano-generated/models/issue_status.rb +41 -0
- data/lib/volcano/generated/lib/volcano-generated/models/metric_usage_data.rb +1 -1
- data/lib/volcano/generated/lib/volcano-generated/models/open_durable_message_session_request.rb +228 -0
- data/lib/volcano/generated/lib/volcano-generated/models/paginated_issues.rb +230 -0
- data/lib/volcano/generated/lib/volcano-generated/models/project_config.rb +10 -1
- data/lib/volcano/generated/lib/volcano-generated/models/project_config_autofix.rb +206 -0
- data/lib/volcano/generated/lib/volcano-generated/models/project_config_custom_domain.rb +1 -1
- data/lib/volcano/generated/lib/volcano-generated/models/project_config_sandbox.rb +12 -50
- data/lib/volcano/generated/lib/volcano-generated/models/project_frontend_custom_domain.rb +25 -2
- data/lib/volcano/generated/lib/volcano-generated/models/publish_durable_message_request.rb +205 -0
- data/lib/volcano/generated/lib/volcano-generated/models/register_durable_message_wait_request.rb +284 -0
- data/lib/volcano/generated/lib/volcano-generated/models/rename_variable_environment_request.rb +176 -0
- data/lib/volcano/generated/lib/volcano-generated/models/request_durable_approval_request.rb +1 -1
- data/lib/volcano/generated/lib/volcano-generated/models/sandbox_command_request.rb +11 -12
- data/lib/volcano/generated/lib/volcano-generated/models/sandbox_execution_request.rb +51 -13
- data/lib/volcano/generated/lib/volcano-generated/models/sandbox_session.rb +4 -30
- data/lib/volcano/generated/lib/volcano-generated/models/send_durable_message_request.rb +205 -0
- data/lib/volcano/generated/lib/volcano-generated/models/update_auth_config_request.rb +79 -1
- data/lib/volcano/generated/lib/volcano-generated/models/update_autofix_settings_request.rb +273 -0
- data/lib/volcano/generated/lib/volcano-generated/models/update_issue_request.rb +186 -0
- data/lib/volcano/generated/lib/volcano-generated/models/variable_environment.rb +325 -0
- data/lib/volcano/generated/lib/volcano-generated/models/variable_environment_list.rb +175 -0
- data/lib/volcano/generated/lib/volcano-generated.rb +34 -0
- data/lib/volcano/generated_transport.rb +1 -0
- data/lib/volcano/generated_transport_durable_messages.rb +57 -0
- data/lib/volcano/realtime/channel_callbacks.rb +5 -1
- data/lib/volcano/realtime/channel_lifecycle.rb +4 -1
- data/lib/volcano/realtime/durable_channel.rb +47 -0
- data/lib/volcano/realtime/lifecycle.rb +37 -1
- data/lib/volcano/realtime/protocol.rb +7 -3
- data/lib/volcano/realtime/protocol_dispatch.rb +1 -1
- data/lib/volcano/realtime/protocol_io.rb +7 -4
- data/lib/volcano/realtime/protocol_lifecycle.rb +7 -0
- data/lib/volcano/realtime/protocol_recovery.rb +23 -12
- data/lib/volcano/realtime.rb +42 -8
- data/lib/volcano/redaction.rb +1 -1
- data/lib/volcano/version.rb +1 -1
- data/lib/volcano.rb +8 -0
- data/sig/core_facades.rbs +15 -0
- data/sig/durable_message_records.rbs +68 -0
- data/sig/durable_records.rbs +17 -9
- metadata +46 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a17106ab631dea9f34c8efbe196bd1636ceb1dd80f2751f3c93570f925176418
|
|
4
|
+
data.tar.gz: 0d8853a55cadc7f86e34b7c6bf39e751b0474d4f878f9732ed013f6b5715794f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a2d0f334b4dd8b5482e26b9146e66ad44c97af567e0db6a12124f0a715033bd8e0c9307cbd75bab5b1ee1c6866f47212b999f1a6c237297b749b6803912331c8
|
|
7
|
+
data.tar.gz: fdfa815814d401cd36bccfb6bfc1179e9222d0792cc993529d3cd16246942cf5c5d1afaf25c84fb7ea8f642551cb494b394b7884dcba64fe4a8c72946de2ce2e
|
data/README.md
CHANGED
|
@@ -522,10 +522,11 @@ owner_client.durable.stop(project_id, "charge-order", handle.id)
|
|
|
522
522
|
`start` takes a durable function's name or its id and returns a handle, never a
|
|
523
523
|
result: an execution can run for up to 366 days, so its result is read back with `get`.
|
|
524
524
|
It uses the same credential `invoke` does — the active user session, then a
|
|
525
|
-
configured service key, then the anonymous key — and is
|
|
526
|
-
operation an application credential may perform. An
|
|
527
|
-
start idempotent: starting again under the same name
|
|
528
|
-
already exists rather than beginning a second one.
|
|
525
|
+
configured service key, then the anonymous key — and is, with `messages`, the
|
|
526
|
+
only durable operation an application credential may perform. An
|
|
527
|
+
`execution_name` makes the start idempotent: starting again under the same name
|
|
528
|
+
returns the execution that already exists rather than beginning a second one.
|
|
529
|
+
Only the start that creates the execution returns its `client_token`.
|
|
529
530
|
|
|
530
531
|
`get`, `list`, and `stop` are owner-scoped and require the project owner's
|
|
531
532
|
platform user token, because an execution is addressed by its id alone.
|
|
@@ -601,6 +602,54 @@ expired or cancelled approval, raises `Volcano::Error::ConflictError` (HTTP
|
|
|
601
602
|
`Volcano::Error::NotFoundError`. A `comment` is optional, up to 2,000
|
|
602
603
|
characters.
|
|
603
604
|
|
|
605
|
+
### Message a durable execution
|
|
606
|
+
|
|
607
|
+
A durable function written in JavaScript or Python can wait for a named
|
|
608
|
+
message, and publish messages of its own. Ruby sends them, lists them, and
|
|
609
|
+
follows what the execution publishes:
|
|
610
|
+
|
|
611
|
+
```ruby
|
|
612
|
+
require "async"
|
|
613
|
+
|
|
614
|
+
handle = client.durable.start("onboarding", { account_id: "account-7" })
|
|
615
|
+
messages = client.durable.messages
|
|
616
|
+
|
|
617
|
+
messages.send_message("onboarding", handle.id, "documents-uploaded", { count: 3 },
|
|
618
|
+
idempotency_key: "upload-42")
|
|
619
|
+
|
|
620
|
+
page = messages.list("onboarding", handle.id, direction: "out", after: 0)
|
|
621
|
+
puts page.messages.map(&:name), page.has_more
|
|
622
|
+
|
|
623
|
+
Async do
|
|
624
|
+
subscription = messages.subscribe("onboarding", handle.id, client_token: handle.client_token) do |message|
|
|
625
|
+
puts [message.seq, message.name, message.data]
|
|
626
|
+
end
|
|
627
|
+
# ...
|
|
628
|
+
subscription.unsubscribe
|
|
629
|
+
ensure
|
|
630
|
+
client.realtime.disconnect
|
|
631
|
+
end.wait
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
A sent message waits until the execution waits for its name. Repeating a send
|
|
635
|
+
with the same `idempotency_key` returns the stored message; sending to an
|
|
636
|
+
execution that has ended raises `Volcano::Error::ConflictError` with `code`
|
|
637
|
+
`execution_not_running`. `subscribe` delivers each published message once, in
|
|
638
|
+
`seq` order, one block call at a time: those published before the call first,
|
|
639
|
+
then live ones, and after a reconnect or a lost live message the ones it
|
|
640
|
+
missed. It needs a signed-in session and an Async reactor; a client with only a
|
|
641
|
+
client token or the service key raises `Volcano::Error::AuthenticationError`.
|
|
642
|
+
Signing out, signing in again, or setting another session ends the
|
|
643
|
+
subscription; a token refresh does not. So does a reconnect the server refuses
|
|
644
|
+
to restore it on, as when the execution is no longer visible to the session.
|
|
645
|
+
|
|
646
|
+
The service key reaches every execution. A signed-in user reaches the executions
|
|
647
|
+
they started; the anon key, or another user, passes the execution's
|
|
648
|
+
`client_token:`, which only the creating start returns. Pass `project_id:` to
|
|
649
|
+
`send_message`, `list`, or `subscribe` to use the owner's route with a platform
|
|
650
|
+
user token or a project access token; `subscribe` reads back over it. See the
|
|
651
|
+
[functions guide](https://github.com/Kong/volcano-sdk-ruby/blob/main/docs/functions.md#message-a-durable-execution).
|
|
652
|
+
|
|
604
653
|
### Read project logs
|
|
605
654
|
|
|
606
655
|
```ruby
|
data/lib/volcano/client.rb
CHANGED
|
@@ -118,7 +118,6 @@ module Volcano
|
|
|
118
118
|
def initialize_facades(socket_factory, reconnect_delay)
|
|
119
119
|
@auth = Auth.new(self, @transport, api_url: @api_url)
|
|
120
120
|
@functions = Functions.new(self, @transport, api_url: @api_url)
|
|
121
|
-
@durable = Durable.new(self, @transport)
|
|
122
121
|
@logs = Logs.new(self, @transport)
|
|
123
122
|
@storage = Storage.new(self, @transport, api_url: @api_url, anon_key: @anon_key)
|
|
124
123
|
@locks = Locks.new(self, @transport)
|
|
@@ -129,6 +128,7 @@ module Volcano
|
|
|
129
128
|
socket_factory: socket_factory,
|
|
130
129
|
reconnect_delay: reconnect_delay
|
|
131
130
|
)
|
|
131
|
+
@durable = Durable.new(self, @transport, @realtime)
|
|
132
132
|
end
|
|
133
133
|
end
|
|
134
134
|
end
|
data/lib/volcano/durable.rb
CHANGED
|
@@ -12,21 +12,26 @@ module Volcano
|
|
|
12
12
|
# Lists, reads, and decides the approvals durable workflows request.
|
|
13
13
|
attr_reader :approvals
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# Sends to, lists, and follows the messages of durable executions.
|
|
16
|
+
attr_reader :messages
|
|
17
|
+
|
|
18
|
+
def initialize(client, transport, realtime)
|
|
16
19
|
@client = client
|
|
17
20
|
@transport = transport
|
|
18
21
|
@approvals = DurableApprovals.new(client, transport)
|
|
22
|
+
@messages = DurableMessages.new(client, transport, realtime)
|
|
19
23
|
end
|
|
20
24
|
|
|
21
25
|
# Starts an execution and returns its handle. A durable function is never
|
|
22
26
|
# invoked synchronously: it can outlive any request a caller could hold
|
|
23
27
|
# open, so its result is read back with #get.
|
|
24
28
|
#
|
|
25
|
-
# Starting
|
|
26
|
-
#
|
|
29
|
+
# Starting takes the token an invoke takes, and is, with #messages, the
|
|
30
|
+
# only durable operation an application credential may perform. Passing an
|
|
27
31
|
# +execution_name+ makes the start idempotent: starting again under the
|
|
28
32
|
# same name returns the execution that already exists rather than
|
|
29
|
-
# beginning a second one.
|
|
33
|
+
# beginning a second one. Only the start that created the execution
|
|
34
|
+
# returns its +client_token+.
|
|
30
35
|
def start(function_name, payload = {}, execution_name: nil)
|
|
31
36
|
# A durable function's id is accepted here as well as its name, so the
|
|
32
37
|
# DNS-safe name pattern an invoke checks would reject half the input.
|
|
@@ -12,7 +12,7 @@ module Volcano
|
|
|
12
12
|
].freeze
|
|
13
13
|
private_constant :DURABLE_APPROVAL_ATTRIBUTES, :DURABLE_APPROVAL_STATS_ATTRIBUTES
|
|
14
14
|
|
|
15
|
-
# Checks keywords for the
|
|
15
|
+
# Checks keywords for the durable records with too many fields for an
|
|
16
16
|
# explicit keyword list, and owns immutable copies of their values.
|
|
17
17
|
module DurableApprovalAttributes
|
|
18
18
|
def self.capture(attributes, names, optional:)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Volcano
|
|
4
|
+
# Checks durable message arguments before the generated client does, for the
|
|
5
|
+
# reason DurableArguments gives.
|
|
6
|
+
module DurableMessageArguments
|
|
7
|
+
MESSAGE_NAME = /\A[ -~]{1,237}\z/
|
|
8
|
+
HEADER_VALUE = /\A[ -~]+\z/
|
|
9
|
+
MAX_IDEMPOTENCY_KEY_LENGTH = 255
|
|
10
|
+
MAX_CLIENT_TOKEN_LENGTH = 128
|
|
11
|
+
MAX_LIST_LIMIT = 500
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def known_options(options, known)
|
|
16
|
+
unknown = options.keys - known
|
|
17
|
+
raise ArgumentError, "unknown keywords: #{unknown.join(', ')}" unless unknown.empty?
|
|
18
|
+
|
|
19
|
+
options
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def message_name(value)
|
|
23
|
+
return value.dup.freeze if value.is_a?(String) && MESSAGE_NAME.match?(value)
|
|
24
|
+
|
|
25
|
+
raise ArgumentError, 'name must be 1 to 237 printable ASCII characters'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Sent as a header, which carries printable ASCII only.
|
|
29
|
+
def bounded_text(value, field, limit)
|
|
30
|
+
return if value.nil?
|
|
31
|
+
return value.dup.freeze if value.is_a?(String) && HEADER_VALUE.match?(value) && value.length <= limit
|
|
32
|
+
|
|
33
|
+
raise ArgumentError, "#{field} must be 1 to #{limit} printable ASCII characters"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def after_argument(value)
|
|
37
|
+
return value if value.nil? || (value.is_a?(Integer) && !value.negative?)
|
|
38
|
+
|
|
39
|
+
raise ArgumentError, 'after must be a non-negative Integer'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def limit_argument(value)
|
|
43
|
+
return value if value.nil? || (value.is_a?(Integer) && value.between?(1, MAX_LIST_LIMIT))
|
|
44
|
+
|
|
45
|
+
raise ArgumentError, "limit must be an Integer between 1 and #{MAX_LIST_LIMIT}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
private_constant :DurableMessageArguments
|
|
49
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Volcano
|
|
4
|
+
# The live messages a durable message stream holds until the ones before
|
|
5
|
+
# them are delivered. Past LIMIT the oldest is dropped. Each one held after a
|
|
6
|
+
# dropped one names a predecessor at or past it, so it waits for a read that
|
|
7
|
+
# lists the dropped one.
|
|
8
|
+
class DurableMessageBacklog
|
|
9
|
+
LIMIT = 128
|
|
10
|
+
private_constant :LIMIT
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@held = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def empty? = @held.empty?
|
|
17
|
+
|
|
18
|
+
def clear = @held.clear
|
|
19
|
+
|
|
20
|
+
def hold(held)
|
|
21
|
+
@held << held
|
|
22
|
+
@held.delete(oldest) if @held.size > LIMIT
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Takes the oldest held message if the one its broadcast names as the one
|
|
27
|
+
# before it is delivered.
|
|
28
|
+
def take_following(last_seq)
|
|
29
|
+
held = oldest
|
|
30
|
+
@held.delete(held) if held && held.prev_seq <= last_seq
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def oldest = @held.min_by { |candidate| candidate.message.seq }
|
|
36
|
+
end
|
|
37
|
+
private_constant :DurableMessageBacklog
|
|
38
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Volcano
|
|
4
|
+
# Runs a durable message stream's own work one job at a time: the catch-ups
|
|
5
|
+
# it starts itself, the release of the live messages it holds, and the wait
|
|
6
|
+
# before retrying a catch-up that failed, half a second doubling to 30
|
|
7
|
+
# seconds. It runs in a task of its own rather than the realtime
|
|
8
|
+
# connection's, which a reconnect stops midway.
|
|
9
|
+
class DurableMessageCatchUps
|
|
10
|
+
FIRST_RETRY_SECONDS = 0.5
|
|
11
|
+
MAX_RETRY_SECONDS = 30.0
|
|
12
|
+
private_constant :FIRST_RETRY_SECONDS, :MAX_RETRY_SECONDS
|
|
13
|
+
|
|
14
|
+
# +read+ runs a catch-up that was asked for, +release+ delivers the held
|
|
15
|
+
# live messages, +due+ asks for a catch-up once a retry's wait is over, and
|
|
16
|
+
# +on_failure+ hears about a job that failed.
|
|
17
|
+
def initialize(read, release, due, on_failure)
|
|
18
|
+
@read = read
|
|
19
|
+
@release = release
|
|
20
|
+
@due = due
|
|
21
|
+
@on_failure = on_failure
|
|
22
|
+
@backoff = FIRST_RETRY_SECONDS
|
|
23
|
+
@retry_delay = @task = @wake = nil
|
|
24
|
+
@pending = @releasing = @closed = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A transient child of the caller's task, so call it from the task that
|
|
28
|
+
# subscribes. Stopping that task, as a disconnect stops a realtime
|
|
29
|
+
# callback's, does not stop a transient child, and one that ends hands it
|
|
30
|
+
# to its parent.
|
|
31
|
+
def start
|
|
32
|
+
@task ||= Async::Task.current.async(transient: true) { work }
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def request
|
|
37
|
+
@pending = true
|
|
38
|
+
wake.signal
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def release
|
|
43
|
+
@releasing = true
|
|
44
|
+
wake.signal
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Whether a catch-up asked for has yet to start reading.
|
|
49
|
+
def pending? = @pending
|
|
50
|
+
|
|
51
|
+
def retry_later
|
|
52
|
+
return if @closed
|
|
53
|
+
|
|
54
|
+
@retry_delay = @backoff
|
|
55
|
+
@backoff = [@backoff * 2, MAX_RETRY_SECONDS].min
|
|
56
|
+
wake.signal
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def cancel_retry = @retry_delay = nil
|
|
61
|
+
|
|
62
|
+
def caught_up = @backoff = FIRST_RETRY_SECONDS
|
|
63
|
+
|
|
64
|
+
# Not stopped: the catch-up it is running may be what closed it.
|
|
65
|
+
def close
|
|
66
|
+
@closed = true
|
|
67
|
+
wake.signal
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def work
|
|
74
|
+
until @closed
|
|
75
|
+
job = next_job
|
|
76
|
+
job ? run(job) : await_request
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A catch-up goes first: it releases the held messages itself.
|
|
81
|
+
def next_job
|
|
82
|
+
if @pending
|
|
83
|
+
@pending = false
|
|
84
|
+
@read
|
|
85
|
+
elsif @releasing
|
|
86
|
+
@releasing = false
|
|
87
|
+
@release
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def run(job)
|
|
92
|
+
job.call
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
@on_failure.call(e)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def await_request
|
|
98
|
+
delay = @retry_delay
|
|
99
|
+
return wake.wait unless delay
|
|
100
|
+
|
|
101
|
+
Async::Task.current.with_timeout(delay) { wake.wait }
|
|
102
|
+
rescue Async::TimeoutError
|
|
103
|
+
@due.call
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def wake
|
|
107
|
+
require 'async/notification'
|
|
108
|
+
@wake ||= Async::Notification.new
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
private_constant :DurableMessageCatchUps
|
|
112
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Volcano
|
|
4
|
+
DURABLE_MESSAGE_ATTRIBUTES = %i[
|
|
5
|
+
id execution_id direction seq name data sender status sent_at delivered_at
|
|
6
|
+
].freeze
|
|
7
|
+
private_constant :DURABLE_MESSAGE_ATTRIBUTES
|
|
8
|
+
|
|
9
|
+
# Who sent a durable message. `type` is the credential it was sent with:
|
|
10
|
+
# `service_key`, `user`, `anon`, `project` for the owner or a project access
|
|
11
|
+
# token, or `execution` for a message the execution published. `user_id` is
|
|
12
|
+
# nil unless `type` is `user`.
|
|
13
|
+
DurableMessageSender = Data.define(:type, :user_id)
|
|
14
|
+
|
|
15
|
+
# Reopens the generated record for checked initialization.
|
|
16
|
+
class DurableMessageSender
|
|
17
|
+
# @dynamic type, user_id, members, with, to_h, deconstruct, deconstruct_keys
|
|
18
|
+
# @dynamic self.[], self.members
|
|
19
|
+
def initialize(type:, user_id: nil)
|
|
20
|
+
type = type.dup.freeze
|
|
21
|
+
user_id = user_id&.dup&.freeze
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A message sent to a durable execution (`direction` `in`), or one it
|
|
27
|
+
# published (`out`).
|
|
28
|
+
#
|
|
29
|
+
# `seq` orders an execution's messages in both directions. It increases with
|
|
30
|
+
# every message but can skip numbers; list the ones after it with `after:`.
|
|
31
|
+
# `delivered_at` is nil until the execution receives the message, and for
|
|
32
|
+
# `out` messages. `data` is frozen all the way down.
|
|
33
|
+
DurableMessage = Data.define(*DURABLE_MESSAGE_ATTRIBUTES)
|
|
34
|
+
|
|
35
|
+
# Reopens the generated record for checked initialization.
|
|
36
|
+
class DurableMessage
|
|
37
|
+
# @dynamic id, execution_id, direction, seq, name, data, sender, status, sent_at
|
|
38
|
+
# @dynamic delivered_at, members, with, to_h, deconstruct, deconstruct_keys
|
|
39
|
+
# @dynamic self.[], self.members
|
|
40
|
+
def initialize(**attributes)
|
|
41
|
+
values = DurableApprovalAttributes.capture(
|
|
42
|
+
attributes, DURABLE_MESSAGE_ATTRIBUTES, optional: %i[data delivered_at]
|
|
43
|
+
)
|
|
44
|
+
attributes = values
|
|
45
|
+
super
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Messages after a `seq`, oldest first. `has_more` says whether more follow;
|
|
50
|
+
# read them with `after:` set to the last message's `seq`.
|
|
51
|
+
DurableMessagePage = Data.define(:messages, :has_more)
|
|
52
|
+
|
|
53
|
+
# Reopens the generated record for checked initialization.
|
|
54
|
+
class DurableMessagePage
|
|
55
|
+
# @dynamic messages, has_more, members, with, to_h, deconstruct, deconstruct_keys
|
|
56
|
+
# @dynamic self.[], self.members
|
|
57
|
+
def initialize(messages:, has_more:)
|
|
58
|
+
messages = messages.to_a.dup.freeze
|
|
59
|
+
super
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module Volcano
|
|
6
|
+
# Builds durable messages and message pages from transport payloads.
|
|
7
|
+
module DurableMessageResponses
|
|
8
|
+
INCOMPLETE_MESSAGE = 'Expected a complete durable message'
|
|
9
|
+
INCOMPLETE_PAGE = 'Expected a complete durable message page'
|
|
10
|
+
DIRECTIONS = %w[in out].freeze
|
|
11
|
+
private_constant :INCOMPLETE_MESSAGE, :INCOMPLETE_PAGE, :DIRECTIONS
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def durable_message(payload)
|
|
16
|
+
values = message_object(payload, INCOMPLETE_MESSAGE)
|
|
17
|
+
id, execution_id, name = message_labels(values)
|
|
18
|
+
direction, status = message_states(values)
|
|
19
|
+
DurableMessage.new(
|
|
20
|
+
id: id, execution_id: execution_id, direction: direction, seq: message_seq(values['seq']), name: name,
|
|
21
|
+
data: message_data(values['data']), sender: message_sender(values['sender']), status: status,
|
|
22
|
+
sent_at: message_time(values['sent_at']), delivered_at: optional_message_time(values['delivered_at'])
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def message_labels(values)
|
|
27
|
+
[message_text(values['id']), message_text(values['execution_id']), message_text(values['name'])]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The stream filters on direction, so it has to be one it knows. A status
|
|
31
|
+
# added later reads as written, as an execution's does.
|
|
32
|
+
def message_states(values)
|
|
33
|
+
[message_choice(values['direction'], DIRECTIONS), message_text(values['status'])]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The seq a broadcast says the execution published before it, or 0, naming
|
|
37
|
+
# no gap, when it has none.
|
|
38
|
+
def broadcast_prev_seq(payload)
|
|
39
|
+
prev_seq = message_object(payload, INCOMPLETE_MESSAGE)['prev_seq']
|
|
40
|
+
prev_seq.is_a?(Integer) ? prev_seq : 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def durable_message_page(payload)
|
|
44
|
+
values = message_object(payload, INCOMPLETE_PAGE)
|
|
45
|
+
data = values['data']
|
|
46
|
+
raise TypeError, INCOMPLETE_PAGE unless data.is_a?(Array)
|
|
47
|
+
|
|
48
|
+
messages = data.map { |entry| durable_message(entry) }
|
|
49
|
+
DurableMessagePage.new(messages: messages, has_more: page_flag(values['has_more']))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def page_flag(value)
|
|
53
|
+
return value if value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
|
54
|
+
|
|
55
|
+
raise TypeError, INCOMPLETE_PAGE
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def message_object(payload, message)
|
|
59
|
+
raise TypeError, message unless payload.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
payload
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def message_text(value)
|
|
65
|
+
raise TypeError, INCOMPLETE_MESSAGE unless value.is_a?(String) && !value.empty?
|
|
66
|
+
|
|
67
|
+
value
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def message_choice(value, choices)
|
|
71
|
+
choices.find { |choice| choice == value } || raise(TypeError, INCOMPLETE_MESSAGE)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def message_seq(value)
|
|
75
|
+
raise TypeError, INCOMPLETE_MESSAGE unless value.is_a?(Integer) && value.positive?
|
|
76
|
+
|
|
77
|
+
value
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def message_data(value)
|
|
81
|
+
Transport.json_value(value)
|
|
82
|
+
rescue TypeError
|
|
83
|
+
raise TypeError, INCOMPLETE_MESSAGE, cause: nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def message_sender(payload)
|
|
87
|
+
values = message_object(payload, INCOMPLETE_MESSAGE)
|
|
88
|
+
user_id = values['user_id']
|
|
89
|
+
raise TypeError, INCOMPLETE_MESSAGE unless user_id.nil? || user_id.is_a?(String)
|
|
90
|
+
|
|
91
|
+
DurableMessageSender.new(type: message_text(values['type']), user_id: user_id)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def message_time(value)
|
|
95
|
+
raise TypeError, INCOMPLETE_MESSAGE unless value.is_a?(String)
|
|
96
|
+
|
|
97
|
+
Time.iso8601(value)
|
|
98
|
+
rescue ArgumentError
|
|
99
|
+
raise TypeError, INCOMPLETE_MESSAGE, cause: nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def optional_message_time(value)
|
|
103
|
+
value.nil? ? nil : message_time(value)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
private_constant :DurableMessageResponses
|
|
107
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Volcano
|
|
4
|
+
# Binds a subscription to the session signed in when it opened, as Postgres
|
|
5
|
+
# delivery does. Signing out, signing in again, or setting another session
|
|
6
|
+
# ends it; refreshing its token does not. Once ended, the stream reads and
|
|
7
|
+
# delivers nothing more, rather than another user's messages, and the
|
|
8
|
+
# subscription leaves the execution's channel.
|
|
9
|
+
#
|
|
10
|
+
# It follows the channel for the stream: a reconnect restores the channel
|
|
11
|
+
# only while the session is the one that subscribed.
|
|
12
|
+
class DurableMessageSession
|
|
13
|
+
CHANGED = "The session that subscribed to the execution's messages has changed; " \
|
|
14
|
+
'the subscription has ended'
|
|
15
|
+
private_constant :CHANGED
|
|
16
|
+
|
|
17
|
+
def initialize(realtime)
|
|
18
|
+
@realtime = realtime
|
|
19
|
+
_, @lineage, = realtime.__send__(:capture_session_binding)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A stream that delivers to +handler+ and reads with +list_after+ while
|
|
23
|
+
# the session is unchanged, and raises Error::SessionChangedError after.
|
|
24
|
+
def stream(handler, list_after, on_failure)
|
|
25
|
+
@stream = DurableMessageStream.new(bind(handler), bind_read(list_after), on_failure)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Ends +subscription+ once the session changes. Called before the
|
|
29
|
+
# subscription subscribes, so before anything checks the session.
|
|
30
|
+
def end_with(subscription)
|
|
31
|
+
@subscription = subscription
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Asked before a reconnect restores the channel.
|
|
35
|
+
def restoring = require_session
|
|
36
|
+
|
|
37
|
+
# Told when the server refuses to restore the channel for good.
|
|
38
|
+
def refused = @subscription.unsubscribe
|
|
39
|
+
|
|
40
|
+
def resubscribed = @stream.catch_up_in_background
|
|
41
|
+
def dropped = @stream.catch_up_in_background
|
|
42
|
+
def close = @stream.close
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def bind(callable)
|
|
47
|
+
lambda do |argument|
|
|
48
|
+
require_session
|
|
49
|
+
callable.call(argument)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A page read while the session changed is not delivered, even an empty
|
|
54
|
+
# one, which delivers nothing that would check it.
|
|
55
|
+
def bind_read(list_after)
|
|
56
|
+
lambda do |after|
|
|
57
|
+
require_session
|
|
58
|
+
list_after.call(after).tap { require_session }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def require_session
|
|
63
|
+
_, lineage, session = @realtime.__send__(:capture_session_binding)
|
|
64
|
+
return if session && lineage == @lineage
|
|
65
|
+
|
|
66
|
+
@stream.close
|
|
67
|
+
@subscription.unsubscribe
|
|
68
|
+
raise Error::SessionChangedError, CHANGED
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
private_constant :DurableMessageSession
|
|
72
|
+
end
|