flexiq 2.1.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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +302 -0
  4. data/lib/flexiq/backoff.rb +21 -0
  5. data/lib/flexiq/batch_item_result.rb +21 -0
  6. data/lib/flexiq/cbor/decoder.rb +169 -0
  7. data/lib/flexiq/cbor/encoder.rb +112 -0
  8. data/lib/flexiq/client.rb +196 -0
  9. data/lib/flexiq/deadline.rb +24 -0
  10. data/lib/flexiq/debounce.rb +20 -0
  11. data/lib/flexiq/edge_condition.rb +24 -0
  12. data/lib/flexiq/enqueue_options.rb +38 -0
  13. data/lib/flexiq/enqueue_request.rb +25 -0
  14. data/lib/flexiq/enqueue_result.rb +13 -0
  15. data/lib/flexiq/errors.rb +21 -0
  16. data/lib/flexiq/job.rb +60 -0
  17. data/lib/flexiq/job_page.rb +19 -0
  18. data/lib/flexiq/job_status.rb +41 -0
  19. data/lib/flexiq/job_transition.rb +25 -0
  20. data/lib/flexiq/payload.rb +55 -0
  21. data/lib/flexiq/queue_stats.rb +12 -0
  22. data/lib/flexiq/reason.rb +52 -0
  23. data/lib/flexiq/rpc_error.rb +117 -0
  24. data/lib/flexiq/task_error.rb +50 -0
  25. data/lib/flexiq/transition_kind.rb +27 -0
  26. data/lib/flexiq/transport.rb +216 -0
  27. data/lib/flexiq/version.rb +6 -0
  28. data/lib/flexiq/watch/by_ids.rb +33 -0
  29. data/lib/flexiq/watch/by_queue.rb +33 -0
  30. data/lib/flexiq/watch.rb +132 -0
  31. data/lib/flexiq/watch_items.rb +13 -0
  32. data/lib/flexiq/wire/bytes.rb +23 -0
  33. data/lib/flexiq/wire/duration.rb +41 -0
  34. data/lib/flexiq/wire/int64.rb +20 -0
  35. data/lib/flexiq/wire/path.rb +20 -0
  36. data/lib/flexiq/wire/sse.rb +91 -0
  37. data/lib/flexiq/wire/timestamp.rb +27 -0
  38. data/lib/flexiq/workflow_constructs.rb +66 -0
  39. data/lib/flexiq/workflow_edge.rb +21 -0
  40. data/lib/flexiq/workflow_graph.rb +50 -0
  41. data/lib/flexiq/workflow_node.rb +54 -0
  42. data/lib/flexiq/workflow_node_state.rb +17 -0
  43. data/lib/flexiq/workflow_node_status.rb +31 -0
  44. data/lib/flexiq/workflow_run.rb +24 -0
  45. data/lib/flexiq/workflow_run_view.rb +19 -0
  46. data/lib/flexiq/workflow_state.rb +31 -0
  47. data/lib/flexiq.rb +53 -0
  48. metadata +92 -0
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlexiQ
4
+ # The server answered and refused: a `google.rpc.Status` with its `ErrorInfo`.
5
+ #
6
+ # Raised when a call fails, and also carried, unraised, by a batch item that did not land.
7
+ # Branch on `#reason` (see FlexiQ::Reason); `#code` is a category, `#message` is prose.
8
+ class RPCError < Error
9
+ ERROR_INFO_TYPE = "type.googleapis.com/google.rpc.ErrorInfo"
10
+ RETRY_INFO_TYPE = "type.googleapis.com/google.rpc.RetryInfo"
11
+
12
+ # For a reply with no FlexiQ body (a proxy's 502, say): the code the HTTP status stands for.
13
+ CODE_FOR_HTTP_STATUS = {
14
+ 400 => "INVALID_ARGUMENT", 401 => "UNAUTHENTICATED", 403 => "PERMISSION_DENIED",
15
+ 404 => "NOT_FOUND", 409 => "ABORTED", 429 => "RESOURCE_EXHAUSTED", 499 => "CANCELLED",
16
+ 501 => "UNIMPLEMENTED", 503 => "UNAVAILABLE", 504 => "DEADLINE_EXCEEDED"
17
+ }.freeze
18
+ private_constant :CODE_FOR_HTTP_STATUS
19
+
20
+ # `google.rpc.Code` name, e.g. "RESOURCE_EXHAUSTED".
21
+ attr_reader :code
22
+ # HTTP status the JSON door answered with.
23
+ attr_reader :http_status
24
+ # ErrorInfo reason, or nil when the server sent none.
25
+ attr_reader :reason
26
+ # Per-reason facts; every value a String.
27
+ attr_reader :metadata
28
+ # Seconds the server asked the caller to wait (Rational), or nil.
29
+ attr_reader :retry_after
30
+
31
+ # Reads the `{"code", "status", "message", "details"}` object the JSON door renders.
32
+ def self.from_status(status, http_status: nil)
33
+ details = Array(status["details"])
34
+ info = details.find { |d| d.is_a?(Hash) && d["@type"] == ERROR_INFO_TYPE && d["domain"] == Reason::DOMAIN }
35
+ retry_info = details.find { |d| d.is_a?(Hash) && d["@type"] == RETRY_INFO_TYPE }
36
+ new(
37
+ status["message"].to_s,
38
+ code: status["status"] || "UNKNOWN",
39
+ http_status: Wire::Int64.load(status["code"]) || http_status,
40
+ reason: info&.fetch("reason", nil),
41
+ metadata: string_map(info&.fetch("metadata", nil)),
42
+ retry_after: retry_info && Wire::Duration.load(retry_info["retryDelay"])
43
+ )
44
+ end
45
+
46
+ # A refusal that arrived with no FlexiQ error body.
47
+ def self.from_http(http_status, body)
48
+ code = CODE_FOR_HTTP_STATUS.fetch(http_status) { http_status >= 500 ? "INTERNAL" : "UNKNOWN" }
49
+ new("HTTP #{http_status} with no FlexiQ error body: #{body.to_s[0, 200]}", code: code, http_status: http_status)
50
+ end
51
+
52
+ def self.string_map(value)
53
+ return {} unless value.is_a?(Hash)
54
+
55
+ value.each_with_object({}) { |(k, v), out| out[k.to_s] = v.to_s if v.is_a?(String) }.freeze
56
+ end
57
+ private_class_method :string_map
58
+
59
+ def initialize(message, code:, http_status: nil, reason: nil, metadata: {}, retry_after: nil)
60
+ @code = code
61
+ @http_status = http_status
62
+ @reason = reason
63
+ @metadata = metadata
64
+ @retry_after = retry_after
65
+ super(reason ? "#{reason} (#{code}): #{message}" : "#{code}: #{message}")
66
+ end
67
+
68
+ # Whether the condition clears on its own. Never a licence to resend a write blind:
69
+ # resend only with `unique_key` set, reusing the same value.
70
+ def retryable?
71
+ return Reason::TRANSIENT.include?(reason) if reason
72
+
73
+ %w[UNAVAILABLE DEADLINE_EXCEEDED].include?(code)
74
+ end
75
+
76
+ # A metadata value as an Integer; nil when absent or unreadable (a server bug, not a failure).
77
+ def metadata_integer(key) = Wire::Int64.load(metadata[key])
78
+
79
+ # 0-based position of the failing item when a whole EnqueueBatch failed on one item.
80
+ def batch_index = metadata_integer("index")
81
+
82
+ # `{queue:, pending:, cap:}` for a QUEUE_FULL carrying all three, else nil.
83
+ def queue_full
84
+ return nil unless reason == Reason::QUEUE_FULL
85
+
86
+ queue = metadata["queue"]
87
+ pending = metadata_integer("pending")
88
+ cap = metadata_integer("cap")
89
+ return nil if queue.nil? || pending.nil? || cap.nil?
90
+
91
+ { queue: queue, pending: pending, cap: cap }
92
+ end
93
+
94
+ # The cap on concurrent watches a WATCH_LIMIT credential is holding, else nil.
95
+ def watch_limit = reason == Reason::WATCH_LIMIT ? metadata_integer("cap") : nil
96
+
97
+ # The scope a SCOPE_DENIED credential lacked, else nil.
98
+ def scope = reason == Reason::SCOPE_DENIED ? metadata["scope"] : nil
99
+
100
+ # The graph node a `submit_workflow` refusal names (SCOPE_DENIED or
101
+ # WORKFLOW_CONSTRUCT_UNSUPPORTED), else nil.
102
+ def node = metadata["node"]
103
+
104
+ # `{node:, field:}` for a WORKFLOW_CONSTRUCT_UNSUPPORTED carrying both, else nil. `field` is
105
+ # "gate", "cache", "fan_out", "fan_in" or "sub_workflow", or one a newer server added.
106
+ # One node per refusal, even when several set a construct: clear them one call at a time.
107
+ def workflow_construct
108
+ return nil unless reason == Reason::WORKFLOW_CONSTRUCT_UNSUPPORTED
109
+
110
+ node = metadata["node"]
111
+ field = metadata["field"]
112
+ return nil if node.nil? || field.nil?
113
+
114
+ { node: node, field: field }
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module FlexiQ
6
+ # Why a job failed, read from `Job#error`.
7
+ #
8
+ # The canonical form is `{"errtype", "message", "traceback"}` JSON. Anything else — a timeout,
9
+ # an expiry, a worker-death recovery, a legacy error — is plain text by design, and comes back
10
+ # with `structured` false and the raw text as the message rather than as an exception.
11
+ TaskError = Data.define(:errtype, :message, :traceback, :structured, :raw) do
12
+ # The rule turns on `message` alone: a document with a string message is canonical, and a
13
+ # missing or mistyped sibling falls back to its default rather than losing the message.
14
+ def self.parse(raw)
15
+ fields = JSON.parse(raw)
16
+ message = fields["message"] if fields.is_a?(Hash)
17
+ return unstructured(raw) unless message.is_a?(String)
18
+
19
+ errtype = fields["errtype"]
20
+ new(
21
+ errtype: errtype.is_a?(String) ? errtype : "Error",
22
+ message: message,
23
+ traceback: frames(fields["traceback"]),
24
+ structured: true,
25
+ raw: raw
26
+ )
27
+ rescue JSON::ParserError
28
+ unstructured(raw)
29
+ end
30
+
31
+ # Writes the canonical document. Key order is part of the cross-SDK shape, and a Hash
32
+ # literal keeps it.
33
+ def self.encode(errtype, message, traceback = [])
34
+ JSON.generate({ "errtype" => errtype.to_s, "message" => message.to_s, "traceback" => traceback.map(&:to_s) })
35
+ end
36
+
37
+ def self.unstructured(raw)
38
+ new(errtype: nil, message: raw, traceback: [], structured: false, raw: raw)
39
+ end
40
+
41
+ def self.frames(value)
42
+ value.is_a?(Array) ? value.grep(String) : []
43
+ end
44
+ private_class_method :unstructured, :frames
45
+
46
+ alias_method :structured?, :structured
47
+
48
+ def to_s = errtype ? "#{errtype}: #{message}" : message
49
+ end
50
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlexiQ
4
+ # `JobTransitionKind` as symbols; an unknown kind stays raw. Finished-ness is
5
+ # JobTransition#terminal?, never the kind.
6
+ module TransitionKind
7
+ BY_WIRE_NAME = {
8
+ "JOB_TRANSITION_KIND_SNAPSHOT" => :snapshot,
9
+ "JOB_TRANSITION_KIND_ENQUEUED" => :enqueued,
10
+ "JOB_TRANSITION_KIND_STARTED" => :started,
11
+ "JOB_TRANSITION_KIND_COMPLETED" => :completed,
12
+ "JOB_TRANSITION_KIND_FAILED" => :failed,
13
+ "JOB_TRANSITION_KIND_RETRYING" => :retrying,
14
+ "JOB_TRANSITION_KIND_DEAD" => :dead,
15
+ "JOB_TRANSITION_KIND_CANCELLED" => :cancelled,
16
+ "JOB_TRANSITION_KIND_SLEEPING" => :sleeping
17
+ }.freeze
18
+
19
+ KNOWN = BY_WIRE_NAME.values.freeze
20
+
21
+ module_function
22
+
23
+ def load(value) = BY_WIRE_NAME.fetch(value, value)
24
+
25
+ def known?(kind) = KNOWN.include?(kind)
26
+ end
27
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+
8
+ module FlexiQ
9
+ # HTTP/JSON to the server's JSON door: bearer auth, TLS, and failure mapping.
10
+ #
11
+ # One keep-alive connection, serialised by a mutex. Never retries: a resent write may land
12
+ # twice, and only the caller (holding a `unique_key`) can make a resend safe.
13
+ class Transport
14
+ # Raised by Net::HTTP for anything short of an HTTP answer.
15
+ NETWORK_ERRORS = [
16
+ IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError, Net::HTTPBadResponse
17
+ ].freeze
18
+ private_constant :NETWORK_ERRORS
19
+
20
+ EVENT_STREAM = "text/event-stream"
21
+ private_constant :EVENT_STREAM
22
+
23
+ # `tls:` keys: :ca_file, :ca_path, :cert (OpenSSL::X509::Certificate), :key (OpenSSL::PKey).
24
+ # `watch_read_timeout`: silence before a watch counts as dropped; keep above the 15 s keepalive.
25
+ def initialize(url, token:, insecure: false, tls: {}, open_timeout: 5, read_timeout: 30,
26
+ write_timeout: 30, watch_read_timeout: 60, user_agent: nil)
27
+ @uri = parse_url(url, insecure)
28
+ @authorization = "Bearer #{validate_token(token)}".freeze
29
+ @user_agent = [user_agent, "flexiq-ruby/#{VERSION}"].compact.join(" ").freeze
30
+ @tls = tls
31
+ @open_timeout = open_timeout
32
+ @read_timeout = read_timeout
33
+ @write_timeout = write_timeout
34
+ @watch_read_timeout = watch_read_timeout
35
+ @http = build_http(read_timeout)
36
+ @lock = Mutex.new
37
+ end
38
+
39
+ # With a `deadline` (Deadline), the read runs on a connection of its own whose timeouts are
40
+ # capped by it, so the shared connection's settings are never touched.
41
+ def get(path, query = {}, deadline: nil)
42
+ request = Net::HTTP::Get.new(full_path(with_query(path, query)))
43
+ deadline ? perform_within(request, deadline) : perform(request)
44
+ end
45
+
46
+ # Yields an event stream's body in chunks until the server closes it. Own connection, so it
47
+ # never blocks unary calls. `deadline` (Deadline) caps every read.
48
+ def stream(path, query = {}, deadline: nil)
49
+ request = Net::HTTP::Get.new(full_path(with_query(path, query)))
50
+ authorize(request, EVENT_STREAM)
51
+ # Compression would buffer events.
52
+ request["Accept-Encoding"] = "identity"
53
+ http = build_http(read_timeout_until(deadline))
54
+ http.open_timeout = deadline.cap(@open_timeout) if deadline
55
+ # Older net-http resends an idempotent GET after a mid-body error, restarting the stream
56
+ # under the caller's parser. The watch decides when to reopen.
57
+ http.max_retries = 0
58
+ http.start do
59
+ http.request(request) do |response|
60
+ expect_event_stream(response)
61
+ response.read_body do |chunk|
62
+ http.read_timeout = read_timeout_until(deadline)
63
+ yield chunk
64
+ end
65
+ end
66
+ end
67
+ rescue *NETWORK_ERRORS => e
68
+ raise TransportError, "GET #{request.path}: #{e.class}: #{e.message}"
69
+ end
70
+
71
+ def post(path, body = nil)
72
+ request = Net::HTTP::Post.new(full_path(path))
73
+ request["Content-Type"] = "application/json"
74
+ request.body = body.nil? ? "" : JSON.generate(body)
75
+ perform(request)
76
+ end
77
+
78
+ def close
79
+ @lock.synchronize { @http.finish if @http.started? }
80
+ end
81
+
82
+ private
83
+
84
+ def parse_url(url, insecure)
85
+ uri = URI.parse(url.to_s)
86
+ raise ConfigurationError, "server URL must be http(s)://host[:port], got #{url.inspect}" unless uri.host
87
+ raise ConfigurationError, "server URL must not carry a query or fragment" if uri.query || uri.fragment
88
+
89
+ case uri.scheme
90
+ when "https" then uri
91
+ when "http"
92
+ return uri if insecure
93
+
94
+ raise ConfigurationError, "refusing to send a bearer token over plaintext http://; " \
95
+ "use https://, or pass insecure: true for a loopback or socket you trust"
96
+ else raise ConfigurationError, "unsupported URL scheme #{uri.scheme.inspect}"
97
+ end
98
+ rescue URI::InvalidURIError => e
99
+ raise ConfigurationError, "invalid server URL: #{e.message}"
100
+ end
101
+
102
+ # The token is opaque and never parsed; only a value that cannot form a header is refused.
103
+ def validate_token(token)
104
+ raise ConfigurationError, "a token is required" if token.nil? || token.to_s.strip.empty?
105
+ raise ConfigurationError, "a token cannot contain line breaks" if token.to_s.match?(/[\r\n]/)
106
+
107
+ token.to_s
108
+ end
109
+
110
+ def build_http(read_timeout)
111
+ http = Net::HTTP.new(@uri.host, @uri.port)
112
+ http.open_timeout = @open_timeout
113
+ http.read_timeout = read_timeout
114
+ http.write_timeout = @write_timeout
115
+ configure_tls(http, @tls) if @uri.scheme == "https"
116
+ http
117
+ end
118
+
119
+ def configure_tls(http, tls)
120
+ unknown = tls.keys - %i[ca_file ca_path cert key]
121
+ raise ConfigurationError, "unknown tls option(s): #{unknown.join(", ")}" unless unknown.empty?
122
+
123
+ http.use_ssl = true
124
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
125
+ http.verify_hostname = true
126
+ http.min_version = OpenSSL::SSL::TLS1_2_VERSION
127
+ http.ca_file = tls[:ca_file] if tls[:ca_file]
128
+ http.ca_path = tls[:ca_path] if tls[:ca_path]
129
+ http.cert = tls[:cert] if tls[:cert]
130
+ http.key = tls[:key] if tls[:key]
131
+ end
132
+
133
+ # A base URL may carry a path prefix, for a server mounted behind a proxy.
134
+ def full_path(path) = "#{@uri.path.chomp("/")}#{path}"
135
+
136
+ def with_query(path, query)
137
+ query = query.compact
138
+ query.empty? ? path : "#{path}?#{URI.encode_www_form(query)}"
139
+ end
140
+
141
+ def authorize(request, accept)
142
+ request["Authorization"] = @authorization
143
+ request["Accept"] = accept
144
+ request["User-Agent"] = @user_agent
145
+ end
146
+
147
+ def read_timeout_until(deadline) = deadline ? deadline.cap(@watch_read_timeout) : @watch_read_timeout
148
+
149
+ def perform(request)
150
+ authorize(request, "application/json")
151
+ response = @lock.synchronize do
152
+ @http.start unless @http.started?
153
+ @http.request(request)
154
+ end
155
+ interpret(response)
156
+ rescue *NETWORK_ERRORS => e
157
+ close_quietly
158
+ raise TransportError, "#{request.method} #{request.path}: #{e.class}: #{e.message}"
159
+ end
160
+
161
+ def perform_within(request, deadline)
162
+ authorize(request, "application/json")
163
+ http = build_http(deadline.cap(@read_timeout))
164
+ http.open_timeout = deadline.cap(@open_timeout)
165
+ http.max_retries = 0 # a resend would restart the clock the deadline bounds
166
+ interpret(http.start { http.request(request) })
167
+ rescue *NETWORK_ERRORS => e
168
+ raise TransportError, "#{request.method} #{request.path}: #{e.class}: #{e.message}"
169
+ end
170
+
171
+ def interpret(response)
172
+ status = response.code.to_i
173
+ refuse(response, status) unless status.between?(200, 299)
174
+
175
+ success_body(parse_json(response.body), status)
176
+ end
177
+
178
+ # A 2xx that is not an event stream came from something else, e.g. a proxy.
179
+ def expect_event_stream(response)
180
+ status = response.code.to_i
181
+ refuse(response, status) unless status.between?(200, 299)
182
+ return if response.content_type == EVENT_STREAM
183
+
184
+ raise TransportError, "HTTP #{status} answered #{response.content_type.inspect}, not #{EVENT_STREAM}"
185
+ end
186
+
187
+ def refuse(response, status)
188
+ body = parse_json(response.body)
189
+ error = body.is_a?(Hash) && body["error"].is_a?(Hash) ? body["error"] : nil
190
+ raise RPCError.from_status(error, http_status: status) if error
191
+
192
+ raise RPCError.from_http(status, response.body)
193
+ end
194
+
195
+ def success_body(body, status)
196
+ raise TransportError, "HTTP #{status} answered with a body that is not a JSON object" unless body.is_a?(Hash)
197
+
198
+ body
199
+ end
200
+
201
+ def parse_json(text)
202
+ return nil if text.nil? || text.empty?
203
+
204
+ JSON.parse(text)
205
+ rescue JSON::ParserError
206
+ nil
207
+ end
208
+
209
+ # A connection that failed mid-request cannot be reused; the next call reconnects.
210
+ def close_quietly
211
+ @lock.synchronize { @http.finish if @http.started? }
212
+ rescue IOError
213
+ nil
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlexiQ
4
+ # Mirrored from the workspace version by scripts/version.mjs; never hand-edit.
5
+ VERSION = "2.1.0"
6
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlexiQ
4
+ class Watch
5
+ # Id watch. Resumes by reopening on unfinished ids; each gets a fresh snapshot.
6
+ class ByIds < Watch
7
+ def initialize(transport, job_ids, deadline: nil)
8
+ super(transport, deadline: deadline)
9
+ @pending = Array(job_ids).map(&:to_s).uniq
10
+ raise ArgumentError, "watch_jobs needs at least one job id" if @pending.empty?
11
+ end
12
+
13
+ private
14
+
15
+ def query = { "jobIds" => @pending }
16
+
17
+ def finished? = @pending.empty?
18
+
19
+ # No cursor on an id watch, so an unknown arm is dropped.
20
+ def show?(item)
21
+ case item
22
+ when JobTransition
23
+ @pending.delete(item.job_id) if item.terminal?
24
+ true
25
+ when JobNotFound
26
+ @pending.delete(item.job_id)
27
+ true
28
+ else false
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlexiQ
4
+ class Watch
5
+ # Queue watch. Resumes from the last cursor of any item; an expired one yields a WatchGap
6
+ # and restarts from now.
7
+ class ByQueue < Watch
8
+ def initialize(transport, queue, resume_cursor)
9
+ super(transport)
10
+ @queue = queue.to_s
11
+ @cursor = resume_cursor.nil? || resume_cursor.empty? ? nil : resume_cursor
12
+ end
13
+
14
+ private
15
+
16
+ def query = { "queue" => @queue, "resumeCursor" => @cursor }
17
+
18
+ def show?(item)
19
+ @cursor = item.cursor if item.respond_to?(:cursor) && item.cursor
20
+ true
21
+ end
22
+
23
+ def recover(error, block)
24
+ return super unless error.reason == Reason::WATCH_CURSOR_EXPIRED && @cursor
25
+
26
+ lost = @cursor
27
+ @cursor = nil
28
+ hand_over(WatchGap.new(lost_cursor: lost), block)
29
+ :now
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module FlexiQ
6
+ # Internal: one watch kept alive across streams. Behind Client#watch_jobs / #watch_queue.
7
+ #
8
+ # Reopens a stream that drops for a reason that clears. Before the first item, every failure
9
+ # raises, so a bad URL or a token at its cap fails loudly instead of spinning.
10
+ class Watch
11
+ PATH = "/v1/jobs:watch"
12
+
13
+ # Contract: reopen on these.
14
+ REOPEN = [Reason::WATCH_OVERFLOW, Reason::SHUTTING_DOWN].freeze
15
+
16
+ # `deadline` (Deadline): raise WaitTimeoutError once it passes.
17
+ def initialize(transport, deadline: nil)
18
+ @transport = transport
19
+ @deadline = deadline
20
+ @backoff = Backoff.new
21
+ @opened = false
22
+ @caller_error = nil
23
+ end
24
+
25
+ # Yields items until finished; breaking out closes the stream.
26
+ def run(&block)
27
+ until finished?
28
+ check_deadline
29
+ pace = attempt(block)
30
+ @backoff.pause(@deadline) if pace == :later && !finished?
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # The query this watch opens its next stream with.
37
+ def query = raise NotImplementedError
38
+
39
+ def finished? = false
40
+
41
+ # Records an item; true when the caller should see it.
42
+ def show?(_item) = true
43
+
44
+ # How soon to reopen after `error`: :now, :later, or nil to raise it.
45
+ def recover(error, _block)
46
+ return :later if REOPEN.include?(error.reason)
47
+
48
+ @opened && error.retryable? ? :later : nil
49
+ end
50
+
51
+ # One stream, open to close. Returns how soon to open the next.
52
+ def attempt(block)
53
+ read(block)
54
+ :later # closed cleanly before the watch finished: the server ended it early
55
+ rescue RPCError => e
56
+ raise @caller_error if @caller_error
57
+
58
+ recover(e, block) || raise
59
+ rescue TransportError
60
+ # The transport wraps an IOError or SystemCallError the block raised; hand back the original.
61
+ raise @caller_error if @caller_error
62
+
63
+ check_deadline # a read cut short by the deadline is a timeout, not a drop
64
+ raise unless @opened
65
+
66
+ :later
67
+ end
68
+
69
+ def check_deadline
70
+ raise WaitTimeoutError, "gave up after #{@deadline.seconds}s" if @deadline&.expired?
71
+ end
72
+
73
+ def read(block)
74
+ parser = Wire::SSE::Parser.new
75
+ ended = false
76
+ @transport.stream(PATH, query, deadline: @deadline) do |chunk|
77
+ check_deadline # keepalives alone would keep a read alive past it
78
+ parser.feed(chunk) do |event|
79
+ case event.type
80
+ when "message" then deliver(item(event.data), block)
81
+ when "error" then raise failure(event.data)
82
+ when "end" then ended = true # the server closes next
83
+ end
84
+ end
85
+ end
86
+ raise TransportError, "the watch closed without an end or error event" unless ended
87
+ end
88
+
89
+ def deliver(item, block)
90
+ @opened = true
91
+ @backoff.reset
92
+ hand_over(item, block) if show?(item)
93
+ end
94
+
95
+ # Tags the block's own errors so they are never mistaken for the stream failing.
96
+ def hand_over(item, block)
97
+ block.call(item)
98
+ rescue StandardError => e
99
+ @caller_error = e
100
+ raise
101
+ end
102
+
103
+ # One WatchJobsResponse. An unknown arm reads as a checkpoint, keeping its cursor.
104
+ def item(data)
105
+ json = parse_json(data)
106
+ raise TransportError, "a watch item is not a JSON object: #{data[0, 200]}" unless json.is_a?(Hash)
107
+
108
+ cursor = json["cursor"].is_a?(String) && !json["cursor"].empty? ? json["cursor"] : nil
109
+ if json["transition"].is_a?(Hash)
110
+ JobTransition.from_json(json["transition"], cursor: cursor)
111
+ elsif json["notFoundJobId"].is_a?(String)
112
+ JobNotFound.new(job_id: json["notFoundJobId"])
113
+ else
114
+ WatchCheckpoint.new(cursor: cursor)
115
+ end
116
+ end
117
+
118
+ # An `error` event carries the facade's usual error body.
119
+ def failure(data)
120
+ body = parse_json(data)
121
+ return RPCError.from_status(body["error"]) if body.is_a?(Hash) && body["error"].is_a?(Hash)
122
+
123
+ TransportError.new("the watch failed with an unreadable error event: #{data[0, 200]}")
124
+ end
125
+
126
+ def parse_json(data)
127
+ JSON.parse(data)
128
+ rescue JSON::ParserError
129
+ nil
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlexiQ
4
+ # An id this token cannot see (missing or another namespace). Finished.
5
+ JobNotFound = Data.define(:job_id)
6
+
7
+ # A queue-watch position with nothing to report: the opening checkpoint or an unknown arm.
8
+ # Keep its cursor like any other.
9
+ WatchCheckpoint = Data.define(:cursor)
10
+
11
+ # The resume cursor expired; transitions after `lost_cursor` were missed.
12
+ WatchGap = Data.define(:lost_cursor)
13
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlexiQ
4
+ module Wire
5
+ # proto3 JSON `bytes`: base64. Written standard and padded; read in either alphabet,
6
+ # padding optional, as proto3 JSON allows.
7
+ module Bytes
8
+ module_function
9
+
10
+ def dump(bytes) = [bytes].pack("m0")
11
+
12
+ def load(text)
13
+ return nil if text.nil?
14
+
15
+ standard = text.tr("-_", "+/")
16
+ standard += "=" * (-standard.length % 4)
17
+ standard.unpack1("m0")
18
+ rescue ArgumentError
19
+ raise CodecError, "a bytes field is not valid base64"
20
+ end
21
+ end
22
+ end
23
+ end