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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +302 -0
- data/lib/flexiq/backoff.rb +21 -0
- data/lib/flexiq/batch_item_result.rb +21 -0
- data/lib/flexiq/cbor/decoder.rb +169 -0
- data/lib/flexiq/cbor/encoder.rb +112 -0
- data/lib/flexiq/client.rb +196 -0
- data/lib/flexiq/deadline.rb +24 -0
- data/lib/flexiq/debounce.rb +20 -0
- data/lib/flexiq/edge_condition.rb +24 -0
- data/lib/flexiq/enqueue_options.rb +38 -0
- data/lib/flexiq/enqueue_request.rb +25 -0
- data/lib/flexiq/enqueue_result.rb +13 -0
- data/lib/flexiq/errors.rb +21 -0
- data/lib/flexiq/job.rb +60 -0
- data/lib/flexiq/job_page.rb +19 -0
- data/lib/flexiq/job_status.rb +41 -0
- data/lib/flexiq/job_transition.rb +25 -0
- data/lib/flexiq/payload.rb +55 -0
- data/lib/flexiq/queue_stats.rb +12 -0
- data/lib/flexiq/reason.rb +52 -0
- data/lib/flexiq/rpc_error.rb +117 -0
- data/lib/flexiq/task_error.rb +50 -0
- data/lib/flexiq/transition_kind.rb +27 -0
- data/lib/flexiq/transport.rb +216 -0
- data/lib/flexiq/version.rb +6 -0
- data/lib/flexiq/watch/by_ids.rb +33 -0
- data/lib/flexiq/watch/by_queue.rb +33 -0
- data/lib/flexiq/watch.rb +132 -0
- data/lib/flexiq/watch_items.rb +13 -0
- data/lib/flexiq/wire/bytes.rb +23 -0
- data/lib/flexiq/wire/duration.rb +41 -0
- data/lib/flexiq/wire/int64.rb +20 -0
- data/lib/flexiq/wire/path.rb +20 -0
- data/lib/flexiq/wire/sse.rb +91 -0
- data/lib/flexiq/wire/timestamp.rb +27 -0
- data/lib/flexiq/workflow_constructs.rb +66 -0
- data/lib/flexiq/workflow_edge.rb +21 -0
- data/lib/flexiq/workflow_graph.rb +50 -0
- data/lib/flexiq/workflow_node.rb +54 -0
- data/lib/flexiq/workflow_node_state.rb +17 -0
- data/lib/flexiq/workflow_node_status.rb +31 -0
- data/lib/flexiq/workflow_run.rb +24 -0
- data/lib/flexiq/workflow_run_view.rb +19 -0
- data/lib/flexiq/workflow_state.rb +31 -0
- data/lib/flexiq.rb +53 -0
- 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,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
|
data/lib/flexiq/watch.rb
ADDED
|
@@ -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
|