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.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +53 -4
  3. data/lib/volcano/client.rb +1 -1
  4. data/lib/volcano/durable.rb +9 -4
  5. data/lib/volcano/durable_approval_models.rb +1 -1
  6. data/lib/volcano/durable_message_arguments.rb +49 -0
  7. data/lib/volcano/durable_message_backlog.rb +38 -0
  8. data/lib/volcano/durable_message_catch_ups.rb +112 -0
  9. data/lib/volcano/durable_message_models.rb +62 -0
  10. data/lib/volcano/durable_message_responses.rb +107 -0
  11. data/lib/volcano/durable_message_session.rb +72 -0
  12. data/lib/volcano/durable_message_stream.rb +176 -0
  13. data/lib/volcano/durable_messages.rb +217 -0
  14. data/lib/volcano/durable_models.rb +32 -3
  15. data/lib/volcano/generated/lib/volcano-generated/api/auth_admin_api.rb +2 -2
  16. data/lib/volcano/generated/lib/volcano-generated/api/auth_configuration_api.rb +2 -2
  17. data/lib/volcano/generated/lib/volcano-generated/api/autofix_api.rb +222 -0
  18. data/lib/volcano/generated/lib/volcano-generated/api/durable_functions_api.rb +725 -0
  19. data/lib/volcano/generated/lib/volcano-generated/api/frontends_api.rb +2 -2
  20. data/lib/volcano/generated/lib/volcano-generated/api/issues_api.rb +254 -0
  21. data/lib/volcano/generated/lib/volcano-generated/api/sandboxes_api.rb +20 -20
  22. data/lib/volcano/generated/lib/volcano-generated/api/system_api.rb +57 -0
  23. data/lib/volcano/generated/lib/volcano-generated/api/variable_environments_api.rb +373 -0
  24. data/lib/volcano/generated/lib/volcano-generated/api/verified_domains_api.rb +2 -2
  25. data/lib/volcano/generated/lib/volcano-generated/models/auth_config.rb +46 -4
  26. data/lib/volcano/generated/lib/volcano-generated/models/auth_insights_series_point.rb +110 -2
  27. data/lib/volcano/generated/lib/volcano-generated/models/autofix_model.rb +45 -0
  28. data/lib/volcano/generated/lib/volcano-generated/models/autofix_provider.rb +40 -0
  29. data/lib/volcano/generated/lib/volcano-generated/models/autofix_settings.rb +385 -0
  30. data/lib/volcano/generated/lib/volcano-generated/models/autofix_stage.rb +40 -0
  31. data/lib/volcano/generated/lib/volcano-generated/models/capability.rb +214 -0
  32. data/lib/volcano/generated/lib/volcano-generated/models/capability_list.rb +166 -0
  33. data/lib/volcano/generated/lib/volcano-generated/models/capability_status.rb +40 -0
  34. data/lib/volcano/generated/lib/volcano-generated/models/create_sandbox_session_request.rb +16 -53
  35. data/lib/volcano/generated/lib/volcano-generated/models/create_variable_environment_request.rb +176 -0
  36. data/lib/volcano/generated/lib/volcano-generated/models/durable_execution.rb +14 -4
  37. data/lib/volcano/generated/lib/volcano-generated/models/durable_message.rb +396 -0
  38. data/lib/volcano/generated/lib/volcano-generated/models/durable_message_direction.rb +40 -0
  39. data/lib/volcano/generated/lib/volcano-generated/models/durable_message_list.rb +194 -0
  40. data/lib/volcano/generated/lib/volcano-generated/models/durable_message_sender.rb +203 -0
  41. data/lib/volcano/generated/lib/volcano-generated/models/durable_message_session_registration.rb +165 -0
  42. data/lib/volcano/generated/lib/volcano-generated/models/durable_message_status.rb +43 -0
  43. data/lib/volcano/generated/lib/volcano-generated/models/durable_message_wait.rb +278 -0
  44. data/lib/volcano/generated/lib/volcano-generated/models/durable_message_wait_list.rb +167 -0
  45. data/lib/volcano/generated/lib/volcano-generated/models/durable_message_wait_registration.rb +225 -0
  46. data/lib/volcano/generated/lib/volcano-generated/models/durable_message_wait_status.rb +43 -0
  47. data/lib/volcano/generated/lib/volcano-generated/models/frontend.rb +11 -1
  48. data/lib/volcano/generated/lib/volcano-generated/models/frontend_custom_domain_conflict_error.rb +1 -1
  49. data/lib/volcano/generated/lib/volcano-generated/models/frontend_custom_domain_response.rb +25 -2
  50. data/lib/volcano/generated/lib/volcano-generated/models/issue.rb +493 -0
  51. data/lib/volcano/generated/lib/volcano-generated/models/issue_status.rb +41 -0
  52. data/lib/volcano/generated/lib/volcano-generated/models/metric_usage_data.rb +1 -1
  53. data/lib/volcano/generated/lib/volcano-generated/models/open_durable_message_session_request.rb +228 -0
  54. data/lib/volcano/generated/lib/volcano-generated/models/paginated_issues.rb +230 -0
  55. data/lib/volcano/generated/lib/volcano-generated/models/project_config.rb +10 -1
  56. data/lib/volcano/generated/lib/volcano-generated/models/project_config_autofix.rb +206 -0
  57. data/lib/volcano/generated/lib/volcano-generated/models/project_config_custom_domain.rb +1 -1
  58. data/lib/volcano/generated/lib/volcano-generated/models/project_config_sandbox.rb +12 -50
  59. data/lib/volcano/generated/lib/volcano-generated/models/project_frontend_custom_domain.rb +25 -2
  60. data/lib/volcano/generated/lib/volcano-generated/models/publish_durable_message_request.rb +205 -0
  61. data/lib/volcano/generated/lib/volcano-generated/models/register_durable_message_wait_request.rb +284 -0
  62. data/lib/volcano/generated/lib/volcano-generated/models/rename_variable_environment_request.rb +176 -0
  63. data/lib/volcano/generated/lib/volcano-generated/models/request_durable_approval_request.rb +1 -1
  64. data/lib/volcano/generated/lib/volcano-generated/models/sandbox_command_request.rb +11 -12
  65. data/lib/volcano/generated/lib/volcano-generated/models/sandbox_execution_request.rb +51 -13
  66. data/lib/volcano/generated/lib/volcano-generated/models/sandbox_session.rb +4 -30
  67. data/lib/volcano/generated/lib/volcano-generated/models/send_durable_message_request.rb +205 -0
  68. data/lib/volcano/generated/lib/volcano-generated/models/update_auth_config_request.rb +79 -1
  69. data/lib/volcano/generated/lib/volcano-generated/models/update_autofix_settings_request.rb +273 -0
  70. data/lib/volcano/generated/lib/volcano-generated/models/update_issue_request.rb +186 -0
  71. data/lib/volcano/generated/lib/volcano-generated/models/variable_environment.rb +325 -0
  72. data/lib/volcano/generated/lib/volcano-generated/models/variable_environment_list.rb +175 -0
  73. data/lib/volcano/generated/lib/volcano-generated.rb +34 -0
  74. data/lib/volcano/generated_transport.rb +1 -0
  75. data/lib/volcano/generated_transport_durable_messages.rb +57 -0
  76. data/lib/volcano/realtime/channel_callbacks.rb +5 -1
  77. data/lib/volcano/realtime/channel_lifecycle.rb +4 -1
  78. data/lib/volcano/realtime/durable_channel.rb +47 -0
  79. data/lib/volcano/realtime/lifecycle.rb +37 -1
  80. data/lib/volcano/realtime/protocol.rb +7 -3
  81. data/lib/volcano/realtime/protocol_dispatch.rb +1 -1
  82. data/lib/volcano/realtime/protocol_io.rb +7 -4
  83. data/lib/volcano/realtime/protocol_lifecycle.rb +7 -0
  84. data/lib/volcano/realtime/protocol_recovery.rb +23 -12
  85. data/lib/volcano/realtime.rb +42 -8
  86. data/lib/volcano/redaction.rb +1 -1
  87. data/lib/volcano/version.rb +1 -1
  88. data/lib/volcano.rb +8 -0
  89. data/sig/core_facades.rbs +15 -0
  90. data/sig/durable_message_records.rbs +68 -0
  91. data/sig/durable_records.rbs +17 -9
  92. metadata +46 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e228fb6138aee4fa4f6bce0f810386d08c3023ed23b3b98ccaf733ba9f1921a6
4
- data.tar.gz: df06c47b64ac87c6d44a7a110d7355ea41fe25354aef328cfdd8b246a1375307
3
+ metadata.gz: a17106ab631dea9f34c8efbe196bd1636ceb1dd80f2751f3c93570f925176418
4
+ data.tar.gz: 0d8853a55cadc7f86e34b7c6bf39e751b0474d4f878f9732ed013f6b5715794f
5
5
  SHA512:
6
- metadata.gz: 1fd53ec98301db0ea75e0e947558664be0fd04a10982279d7eaf71c90f15493d7205c086cd7c11d7038d25d1c68e953fe26253f58fb4cc44df08a72b81968636
7
- data.tar.gz: 77a02c19da00001b7103e300dc4666f2ac2f1fa339bcc28dd87782d5a4965729b4bd1d67c9486da5a6ed14c4b24b5a93efdaf1feb2010a55a44af5edeadadd51
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 the only durable
526
- operation an application credential may perform. An `execution_name` makes the
527
- start idempotent: starting again under the same name returns the execution that
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
@@ -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
@@ -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
- def initialize(client, transport)
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 is the only durable operation an application credential may
26
- # perform, and it takes the token an invoke takes. Passing an
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 approval records with too many fields for an
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