bynn-decisionnode 0.4.1
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/CHANGELOG.md +128 -0
- data/LICENSE +21 -0
- data/README.md +908 -0
- data/lib/decisionnode/batches.rb +246 -0
- data/lib/decisionnode/boosters.rb +692 -0
- data/lib/decisionnode/checks.rb +722 -0
- data/lib/decisionnode/client.rb +1100 -0
- data/lib/decisionnode/collections.rb +535 -0
- data/lib/decisionnode/document.rb +179 -0
- data/lib/decisionnode/errors.rb +138 -0
- data/lib/decisionnode/image.rb +194 -0
- data/lib/decisionnode/image_header.rb +121 -0
- data/lib/decisionnode/keys.rb +383 -0
- data/lib/decisionnode/questions.rb +135 -0
- data/lib/decisionnode/results.rb +539 -0
- data/lib/decisionnode/session.rb +286 -0
- data/lib/decisionnode/session_stream.rb +255 -0
- data/lib/decisionnode/supervisor.rb +107 -0
- data/lib/decisionnode/transport.rb +249 -0
- data/lib/decisionnode/uploads.rb +422 -0
- data/lib/decisionnode/version.rb +9 -0
- data/lib/decisionnode/video.rb +128 -0
- data/lib/decisionnode.rb +39 -0
- metadata +85 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
class DecisionNode
|
|
8
|
+
# A response as the client sees it: the status, the headers with lower-case
|
|
9
|
+
# names, and the body text. A transport that can follow redirects sets `url`
|
|
10
|
+
# to the address the response came from; the client refuses a response from
|
|
11
|
+
# any other address than the one it asked for.
|
|
12
|
+
HTTPResponse = Struct.new(:status, :headers, :body, :url)
|
|
13
|
+
|
|
14
|
+
# The default transport: Net::HTTP with a pool of kept-alive connections per
|
|
15
|
+
# host, safe to share between threads, so repeated calls skip the TCP and
|
|
16
|
+
# TLS handshakes. It never follows a redirect.
|
|
17
|
+
#
|
|
18
|
+
# DecisionNode::NetHTTPTransport.new(
|
|
19
|
+
# proxy: "http://proxy.example.com:3128",
|
|
20
|
+
# net_http: { ca_file: "/etc/ssl/certs/company-ca.pem" }
|
|
21
|
+
# )
|
|
22
|
+
#
|
|
23
|
+
# max_idle:: idle connections kept per host (default 16).
|
|
24
|
+
# keep_alive_timeout:: seconds an idle connection is reused for (default 15).
|
|
25
|
+
# proxy:: a proxy URL, else the proxy environment variables Net::HTTP reads.
|
|
26
|
+
# net_http:: Net::HTTP settings applied to every connection, by setter name
|
|
27
|
+
# (ca_file, ca_path, cert_store, cert, key, ...). Settings that
|
|
28
|
+
# could turn TLS or its certificate checks off are refused with
|
|
29
|
+
# ArgumentError: use_ssl (the transport sets it from the URL),
|
|
30
|
+
# verify_mode other than VERIFY_PEER, verify_hostname false and
|
|
31
|
+
# verify_callback. For an https URL TLS with peer and hostname
|
|
32
|
+
# checks is set again after the caller's settings.
|
|
33
|
+
#
|
|
34
|
+
# Any object with the same `call` method can be passed to the client as
|
|
35
|
+
# `http_client:`. It returns an HTTPResponse and raises
|
|
36
|
+
# DecisionNode::TimeoutError or DecisionNode::ConnectionError when no response
|
|
37
|
+
# arrives. Methods are GET, POST, PUT (an image upload to the storage URL the
|
|
38
|
+
# API returned, with exactly the headers given) and DELETE. It must never follow a redirect and must say so by answering false
|
|
39
|
+
# to `follow_redirects?`; the client refuses any other transport. A response
|
|
40
|
+
# whose `url` differs from the requested one is an error.
|
|
41
|
+
class NetHTTPTransport
|
|
42
|
+
TIMEOUT_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, ::Timeout::Error].freeze
|
|
43
|
+
CONNECTION_ERRORS = [
|
|
44
|
+
SystemCallError, IOError, SocketError, EOFError, OpenSSL::SSL::SSLError,
|
|
45
|
+
Net::HTTPBadResponse, Net::ProtocolError
|
|
46
|
+
].freeze
|
|
47
|
+
|
|
48
|
+
def initialize(max_idle: 16, keep_alive_timeout: 15, proxy: nil, net_http: {})
|
|
49
|
+
@max_idle = max_idle
|
|
50
|
+
@keep_alive_timeout = keep_alive_timeout
|
|
51
|
+
@proxy = proxy.nil? ? nil : URI.parse(proxy.to_s)
|
|
52
|
+
@net_http = net_http.to_h { |k, v| [:"#{k.to_s.delete_suffix('=')}=", v] }
|
|
53
|
+
@net_http.each do |setter, value|
|
|
54
|
+
raise ArgumentError, "net_http: Net::HTTP has no setting #{setter.to_s.chomp('=')}" unless Net::HTTP.method_defined?(setter)
|
|
55
|
+
|
|
56
|
+
check_tls_setting(setter, value)
|
|
57
|
+
end
|
|
58
|
+
@idle = Hash.new { |h, k| h[k] = [] }
|
|
59
|
+
@lock = Mutex.new
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def call(method, url, headers, body, timeout)
|
|
63
|
+
uri = URI.parse(url)
|
|
64
|
+
http = checkout(uri, timeout)
|
|
65
|
+
keep = false
|
|
66
|
+
begin
|
|
67
|
+
request = build_request(method, uri, headers, body)
|
|
68
|
+
response = http.request(request)
|
|
69
|
+
result = HTTPResponse.new(response.code.to_i, lower_headers(response), response.body.to_s)
|
|
70
|
+
keep = !response["connection"].to_s.casecmp?("close")
|
|
71
|
+
result
|
|
72
|
+
rescue *TIMEOUT_ERRORS => e
|
|
73
|
+
raise DecisionNode::TimeoutError, "no response within #{timeout} s (#{e.class})"
|
|
74
|
+
rescue *CONNECTION_ERRORS => e
|
|
75
|
+
raise DecisionNode::ConnectionError, "connection error: #{e.message} (#{e.class})"
|
|
76
|
+
ensure
|
|
77
|
+
keep ? checkin(uri, http) : discard(http)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Like #call, but a 2xx body is handed to the block in chunks as it
|
|
82
|
+
# arrives, each read bounded by `timeout`, and the returned response has
|
|
83
|
+
# an empty body. Any other status is read whole. Used for batch results.
|
|
84
|
+
def stream(method, url, headers, timeout, &on_chunk)
|
|
85
|
+
uri = URI.parse(url)
|
|
86
|
+
http = checkout(uri, timeout)
|
|
87
|
+
keep = false
|
|
88
|
+
begin
|
|
89
|
+
result = nil
|
|
90
|
+
http.request(build_request(method, uri, headers, nil)) do |response|
|
|
91
|
+
status = response.code.to_i
|
|
92
|
+
if (200..299).cover?(status)
|
|
93
|
+
response.read_body { |chunk| on_chunk.call(chunk) }
|
|
94
|
+
result = HTTPResponse.new(status, lower_headers(response), "")
|
|
95
|
+
else
|
|
96
|
+
result = HTTPResponse.new(status, lower_headers(response), response.read_body.to_s)
|
|
97
|
+
end
|
|
98
|
+
keep = !response["connection"].to_s.casecmp?("close")
|
|
99
|
+
end
|
|
100
|
+
result
|
|
101
|
+
rescue *TIMEOUT_ERRORS => e
|
|
102
|
+
raise DecisionNode::TimeoutError, "no response within #{timeout} s (#{e.class})"
|
|
103
|
+
rescue *CONNECTION_ERRORS => e
|
|
104
|
+
raise DecisionNode::ConnectionError, "connection error: #{e.message} (#{e.class})"
|
|
105
|
+
ensure
|
|
106
|
+
keep ? checkin(uri, http) : discard(http)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# What a session's socket takes from this transport: the proxy (:env when
|
|
111
|
+
# none was given, so the environment's applies as it does here) and the
|
|
112
|
+
# `net_http:` settings that apply to TLS.
|
|
113
|
+
def session_options
|
|
114
|
+
tls = @net_http.each_with_object({}) { |(setter, value), h| h[setter.to_s.chomp("=")] = value }
|
|
115
|
+
{ proxy: @proxy || :env, tls: tls.select { |name, _| DecisionNode::SessionStream::TLS_SETTINGS.include?(name) } }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Net::HTTP never follows a redirect on its own.
|
|
119
|
+
def follow_redirects?
|
|
120
|
+
false
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Closes every idle connection.
|
|
124
|
+
def close
|
|
125
|
+
conns = @lock.synchronize do
|
|
126
|
+
all = @idle.values.flatten
|
|
127
|
+
@idle.clear
|
|
128
|
+
all
|
|
129
|
+
end
|
|
130
|
+
conns.each { |c| discard(c) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def key_for(uri)
|
|
136
|
+
[uri.scheme, uri.host, uri.port]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def checkout(uri, timeout)
|
|
140
|
+
http = @lock.synchronize { @idle[key_for(uri)].pop }
|
|
141
|
+
http ||= new_http(uri)
|
|
142
|
+
http.open_timeout = timeout
|
|
143
|
+
http.read_timeout = timeout
|
|
144
|
+
http.write_timeout = timeout
|
|
145
|
+
http.ssl_timeout = timeout if http.use_ssl?
|
|
146
|
+
begin
|
|
147
|
+
http.start unless http.started?
|
|
148
|
+
rescue *TIMEOUT_ERRORS => e
|
|
149
|
+
raise DecisionNode::TimeoutError, "no connection within #{timeout} s (#{e.class})"
|
|
150
|
+
rescue *CONNECTION_ERRORS => e
|
|
151
|
+
raise DecisionNode::ConnectionError, "cannot connect to #{uri.host}: #{e.message} (#{e.class})"
|
|
152
|
+
end
|
|
153
|
+
http
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def new_http(uri)
|
|
157
|
+
http =
|
|
158
|
+
if @proxy
|
|
159
|
+
Net::HTTP.new(uri.host, uri.port, @proxy.host, @proxy.port, @proxy.user, @proxy.password)
|
|
160
|
+
else
|
|
161
|
+
Net::HTTP.new(uri.host, uri.port)
|
|
162
|
+
end
|
|
163
|
+
@net_http.each { |setter, value| http.public_send(setter, value) }
|
|
164
|
+
# Set after the caller's settings, so none of them can turn TLS off.
|
|
165
|
+
if uri.scheme == "https"
|
|
166
|
+
http.use_ssl = true
|
|
167
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
168
|
+
http.verify_hostname = true
|
|
169
|
+
http.verify_callback = nil
|
|
170
|
+
else
|
|
171
|
+
http.use_ssl = false
|
|
172
|
+
end
|
|
173
|
+
http.max_retries = 0 # retries are the client's, so every attempt is counted
|
|
174
|
+
http.keep_alive_timeout = @keep_alive_timeout
|
|
175
|
+
http
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def check_tls_setting(setter, value)
|
|
179
|
+
name = setter.to_s.chomp("=")
|
|
180
|
+
case name
|
|
181
|
+
when "use_ssl"
|
|
182
|
+
raise ArgumentError, "net_http: use_ssl is set from the URL and cannot be given"
|
|
183
|
+
when "verify_mode"
|
|
184
|
+
return if value == OpenSSL::SSL::VERIFY_PEER
|
|
185
|
+
|
|
186
|
+
raise ArgumentError, "net_http: verify_mode must be OpenSSL::SSL::VERIFY_PEER; certificate checks cannot be turned off"
|
|
187
|
+
when "verify_hostname"
|
|
188
|
+
return if value == true
|
|
189
|
+
|
|
190
|
+
raise ArgumentError, "net_http: verify_hostname cannot be turned off"
|
|
191
|
+
when "verify_callback"
|
|
192
|
+
raise ArgumentError, "net_http: verify_callback cannot be given; it could accept any certificate"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def checkin(uri, http)
|
|
197
|
+
overflow = @lock.synchronize do
|
|
198
|
+
list = @idle[key_for(uri)]
|
|
199
|
+
if list.size < @max_idle
|
|
200
|
+
list.push(http)
|
|
201
|
+
nil
|
|
202
|
+
else
|
|
203
|
+
http
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
discard(overflow) if overflow
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def discard(http)
|
|
210
|
+
http.finish if http&.started?
|
|
211
|
+
rescue StandardError
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def build_request(method, uri, headers, body)
|
|
216
|
+
path = uri.request_uri
|
|
217
|
+
request =
|
|
218
|
+
case method
|
|
219
|
+
when "GET" then Net::HTTP::Get.new(path)
|
|
220
|
+
when "POST" then Net::HTTP::Post.new(path)
|
|
221
|
+
when "PATCH" then Net::HTTP::Patch.new(path)
|
|
222
|
+
when "PUT" then bare(Net::HTTP::Put.new(path))
|
|
223
|
+
when "DELETE" then Net::HTTP::Delete.new(path)
|
|
224
|
+
else raise ArgumentError, "unsupported method #{method}"
|
|
225
|
+
end
|
|
226
|
+
headers.each { |name, value| request[name] = value }
|
|
227
|
+
if body
|
|
228
|
+
request.body = body
|
|
229
|
+
elsif request.request_body_permitted?
|
|
230
|
+
# A POST without a body (finalize, cancel) goes out with Content-Length 0
|
|
231
|
+
# and no Content-Type, rather than the form type Net::HTTP would add.
|
|
232
|
+
request.content_length = 0
|
|
233
|
+
request.define_singleton_method(:request_body_permitted?) { false }
|
|
234
|
+
end
|
|
235
|
+
request
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# A storage PUT carries exactly the headers it is given: none of the
|
|
239
|
+
# defaults Net::HTTP adds on its own.
|
|
240
|
+
def bare(request)
|
|
241
|
+
%w[accept accept-encoding user-agent].each { |name| request.delete(name) }
|
|
242
|
+
request
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def lower_headers(response)
|
|
246
|
+
response.each_header.each_with_object({}) { |(k, v), h| h[k.downcase] = v }
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
class DecisionNode
|
|
7
|
+
# The client's memory of the images it uploaded: by the SHA-256 of the bytes
|
|
8
|
+
# and the media type, the upload id and when it stops being usable. An entry
|
|
9
|
+
# is used until 5 minutes before the upload's `expires_at`; at most 256
|
|
10
|
+
# entries, the least recently used out first. Never on disk, never shared
|
|
11
|
+
# between clients.
|
|
12
|
+
class UploadCache
|
|
13
|
+
MAX_ENTRIES = 256
|
|
14
|
+
# Seconds before `expires_at` from which an entry is no longer used.
|
|
15
|
+
MARGIN = 300
|
|
16
|
+
|
|
17
|
+
Entry = Struct.new(:upload_id, :expires_at)
|
|
18
|
+
|
|
19
|
+
def initialize(max_entries: MAX_ENTRIES, clock: -> { Time.now.to_f })
|
|
20
|
+
@max = max_entries
|
|
21
|
+
@clock = clock
|
|
22
|
+
@entries = {}
|
|
23
|
+
@lock = Mutex.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def empty?
|
|
27
|
+
@entries.empty?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def size
|
|
31
|
+
@entries.size
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The live upload id for these bytes, or nil.
|
|
35
|
+
def get(key)
|
|
36
|
+
@lock.synchronize do
|
|
37
|
+
entry = @entries.delete(key)
|
|
38
|
+
return nil if entry.nil? || @clock.call >= entry.expires_at - MARGIN
|
|
39
|
+
|
|
40
|
+
@entries[key] = entry # most recently used last
|
|
41
|
+
entry.upload_id
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def put(key, upload_id, expires_at)
|
|
46
|
+
@lock.synchronize do
|
|
47
|
+
@entries.delete(key)
|
|
48
|
+
@entries[key] = Entry.new(upload_id, expires_at)
|
|
49
|
+
@entries.delete(@entries.first.first) while @entries.size > @max
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Forgets every entry naming one of these upload ids.
|
|
54
|
+
def drop(upload_ids)
|
|
55
|
+
@lock.synchronize { @entries.delete_if { |_, e| upload_ids.include?(e.upload_id) } }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def clear
|
|
59
|
+
@lock.synchronize { @entries.clear }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Image preparation and uploads, mixed into the client.
|
|
64
|
+
module Uploads
|
|
65
|
+
# A `400` with one of these error types names an upload that can no longer
|
|
66
|
+
# be used: an upload the SDK placed is uploaded again and the request sent
|
|
67
|
+
# once more.
|
|
68
|
+
STALE_UPLOAD = %w[upload_expired upload_consumed upload_not_found upload_incomplete].freeze
|
|
69
|
+
|
|
70
|
+
# An upload id of the API's length (36 characters), naming an image that
|
|
71
|
+
# would be uploaded when a body is measured before anything is sent.
|
|
72
|
+
UPLOAD_ID_PLACEHOLDER = "upl_#{'0' * 32}".freeze
|
|
73
|
+
private_constant :UPLOAD_ID_PLACEHOLDER
|
|
74
|
+
|
|
75
|
+
# Uploads every image, whatever its size, and returns one upload id per
|
|
76
|
+
# image (from the cache when the same bytes were uploaded before). URL
|
|
77
|
+
# images and upload ids cannot be uploaded and raise ArgumentError.
|
|
78
|
+
#
|
|
79
|
+
# Sends one `POST /v1/uploads` for the images not in the cache, then one
|
|
80
|
+
# `PUT` per image, in parallel. A call takes 1 to 16 images; more raise
|
|
81
|
+
# the API's BadRequestError before anything is sent. An id names the
|
|
82
|
+
# upload for an hour, in any image slot: `Image.new("receipt", upload_id: id)`.
|
|
83
|
+
def upload(images)
|
|
84
|
+
raise ArgumentError, "images must be an Array of DecisionNode::Image" unless images.is_a?(Array)
|
|
85
|
+
|
|
86
|
+
images.each do |img|
|
|
87
|
+
next if img.is_a?(Image) && img.local?
|
|
88
|
+
|
|
89
|
+
raise ArgumentError, "upload takes images made from a path, bytes, base64 or an IO"
|
|
90
|
+
end
|
|
91
|
+
if images.size > Checks::MAX_IMAGES
|
|
92
|
+
raise BadRequestError.new("files: 1 to #{Checks::MAX_IMAGES} files, got #{images.size}.", error_type: "api_usage_error")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
Checks.images!(images, limits: limits_for(nil))
|
|
96
|
+
ids = images.map { |img| @uploads.get(cache_key(img)) }
|
|
97
|
+
missing = images.each_index.reject { |i| ids[i] }
|
|
98
|
+
fresh = missing.empty? ? [] : upload_images(missing.map { |i| images[i] })
|
|
99
|
+
missing.each_with_index { |i, j| ids[i] = fresh[j] }
|
|
100
|
+
ids
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Uploads 1 to 16 documents for knowledge bases, whatever their type and
|
|
104
|
+
# size, and returns one upload id each, in order, for a later
|
|
105
|
+
# `Document.new(upload_id: id)`. A text document is uploaded as
|
|
106
|
+
# text/markdown, or text/plain for format "text". A document given by its
|
|
107
|
+
# upload id raises ArgumentError. Each file is checked before anything is
|
|
108
|
+
# sent, as the API checks it (at most 52,428,800 bytes, not empty, one of
|
|
109
|
+
# Document::CONTENT_TYPES), and nothing is cached: an upload is consumed by
|
|
110
|
+
# the item it makes. An upload not named within its hour expires.
|
|
111
|
+
def upload_documents(documents)
|
|
112
|
+
raise ArgumentError, "documents must be an Array of DecisionNode::Document" unless documents.is_a?(Array)
|
|
113
|
+
|
|
114
|
+
documents.each do |doc|
|
|
115
|
+
next if doc.is_a?(Document) && !doc.upload_id
|
|
116
|
+
|
|
117
|
+
raise ArgumentError, "upload_documents takes documents made from a text, a path, bytes or an IO"
|
|
118
|
+
end
|
|
119
|
+
unless documents.size.between?(1, Checks::MAX_IMAGES)
|
|
120
|
+
raise BadRequestError.new("files: 1 to #{Checks::MAX_IMAGES} files, got #{documents.size}.",
|
|
121
|
+
error_type: "api_usage_error")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
upload_knowledge(documents.each_with_index.map { |doc, i| document_file(doc, i) })
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# A document's bytes as a knowledge file: a text as its UTF-8 bytes,
|
|
130
|
+
# typed by its format.
|
|
131
|
+
def document_file(doc, index)
|
|
132
|
+
return doc.load(index) unless doc.text?
|
|
133
|
+
|
|
134
|
+
unless doc.format.nil? || Document::FORMATS.include?(doc.format)
|
|
135
|
+
raise Checks.invalid([Checks.entry("literal_error", %w[body format], "Input should be 'markdown' or 'text'",
|
|
136
|
+
doc.format)])
|
|
137
|
+
end
|
|
138
|
+
type = doc.format == "text" ? "text/plain" : "text/markdown"
|
|
139
|
+
Document.new(data: doc.text.encode(Encoding::UTF_8), content_type: type).load(index)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# One presign for the files with the knowledge purpose, then one PUT each
|
|
143
|
+
# (in parallel), each with the document timeout. Returns the upload ids.
|
|
144
|
+
def upload_knowledge(loaded)
|
|
145
|
+
files = loaded.map do |f|
|
|
146
|
+
file = { "purpose" => "knowledge", "content_type" => f.content_type, "byte_size" => f.data.bytesize,
|
|
147
|
+
"checksum" => [f.md5].pack("m0"), "sha256" => [f.sha256].pack("m0") }
|
|
148
|
+
file["filename"] = f.filename unless f.filename.nil?
|
|
149
|
+
file
|
|
150
|
+
end
|
|
151
|
+
targets, valid_until = presign(files)
|
|
152
|
+
if targets.size != loaded.size
|
|
153
|
+
raise ServerError.new("the upload answer holds #{targets.size} uploads for #{loaded.size} files")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
put_all(loaded, files, targets, valid_until, timeout: @document_upload_timeout)
|
|
157
|
+
targets.map { |t| t["upload_id"] }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The per-attempt timeout of a document write: 20 s with `wait` unless
|
|
161
|
+
# the caller set one; nil (the client's) otherwise.
|
|
162
|
+
def write_timeout(wait)
|
|
163
|
+
wait && !@timeout_given ? WAIT_TIMEOUT : nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def cache_key(img)
|
|
167
|
+
[img.sha256, img.media_type]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Forgets uploads the API called stale, so the next image_wires uploads
|
|
171
|
+
# those images again.
|
|
172
|
+
def drop_uploads(upload_ids)
|
|
173
|
+
@uploads.drop(upload_ids)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The wire forms of a request's images and the upload ids the SDK placed in
|
|
177
|
+
# them. URLs and upload ids go as given; a cached upload by its id whatever
|
|
178
|
+
# its size; the other local images inline when all the local images total
|
|
179
|
+
# under the inline threshold, else uploaded. With project: true nothing is
|
|
180
|
+
# uploaded: an image that would be is named by UPLOAD_ID_PLACEHOLDER.
|
|
181
|
+
def image_wires(images, project: false)
|
|
182
|
+
wires = Array.new(images.size)
|
|
183
|
+
placed = []
|
|
184
|
+
pending = []
|
|
185
|
+
total = 0
|
|
186
|
+
images.each_with_index do |img, i|
|
|
187
|
+
unless img.is_a?(Image)
|
|
188
|
+
wires[i] = img
|
|
189
|
+
next
|
|
190
|
+
end
|
|
191
|
+
unless img.local?
|
|
192
|
+
wires[i] = img.to_h
|
|
193
|
+
next
|
|
194
|
+
end
|
|
195
|
+
total += img.bytesize
|
|
196
|
+
hit = @uploads.empty? ? nil : @uploads.get(cache_key(img))
|
|
197
|
+
if hit
|
|
198
|
+
wires[i] = img.upload_wire(hit)
|
|
199
|
+
placed << hit
|
|
200
|
+
else
|
|
201
|
+
pending << i
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
return [wires, placed] if pending.empty?
|
|
205
|
+
|
|
206
|
+
if total >= @inline_threshold
|
|
207
|
+
ids = project ? [UPLOAD_ID_PLACEHOLDER] * pending.size : upload_images(pending.map { |i| images[i] })
|
|
208
|
+
pending.each_with_index do |i, j|
|
|
209
|
+
wires[i] = images[i].upload_wire(ids[j])
|
|
210
|
+
placed << ids[j]
|
|
211
|
+
end
|
|
212
|
+
else
|
|
213
|
+
pending.each { |i| wires[i] = images[i].to_h }
|
|
214
|
+
end
|
|
215
|
+
[wires, placed]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# One presign for the distinct images, one PUT each (in parallel), then the
|
|
219
|
+
# cache. Returns one upload id per image, in order. Bytes another thread is
|
|
220
|
+
# uploading at the same moment are waited for, not uploaded twice.
|
|
221
|
+
def upload_images(images)
|
|
222
|
+
distinct = images.uniq { |img| cache_key(img) }
|
|
223
|
+
mine, theirs = claim(distinct)
|
|
224
|
+
by_key = {}
|
|
225
|
+
begin
|
|
226
|
+
transfer(mine).each { |key, id| by_key[key] = id } unless mine.empty?
|
|
227
|
+
ensure
|
|
228
|
+
release(mine, by_key)
|
|
229
|
+
end
|
|
230
|
+
theirs.each { |key, pending| by_key[key] = pending.value }
|
|
231
|
+
images.map { |img| by_key[cache_key(img)] }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# The upload of one set of bytes by another thread.
|
|
235
|
+
class Pending
|
|
236
|
+
def initialize
|
|
237
|
+
@lock = Mutex.new
|
|
238
|
+
@done = ConditionVariable.new
|
|
239
|
+
@state = nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def resolve(id)
|
|
243
|
+
settle([:ok, id])
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def reject
|
|
247
|
+
settle([:failed])
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# The upload id, or the upload's failure raised.
|
|
251
|
+
def value
|
|
252
|
+
@lock.synchronize do
|
|
253
|
+
@done.wait(@lock) while @state.nil?
|
|
254
|
+
raise DecisionNodeError.new("the upload of the same image by another call failed", error_type: "upload_failed") if @state.first == :failed
|
|
255
|
+
|
|
256
|
+
@state.last
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
private
|
|
261
|
+
|
|
262
|
+
def settle(state)
|
|
263
|
+
@lock.synchronize do
|
|
264
|
+
@state = state
|
|
265
|
+
@done.broadcast
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
private_constant :Pending
|
|
270
|
+
|
|
271
|
+
# Splits the images into those this call uploads and those another call is
|
|
272
|
+
# uploading now.
|
|
273
|
+
def claim(images)
|
|
274
|
+
mine = []
|
|
275
|
+
theirs = {}
|
|
276
|
+
@inflight_lock.synchronize do
|
|
277
|
+
images.each do |img|
|
|
278
|
+
key = cache_key(img)
|
|
279
|
+
if @inflight.key?(key)
|
|
280
|
+
theirs[key] = @inflight[key]
|
|
281
|
+
else
|
|
282
|
+
@inflight[key] = Pending.new
|
|
283
|
+
mine << img
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
[mine, theirs]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def release(images, by_key)
|
|
291
|
+
pendings = @inflight_lock.synchronize { images.map { |img| @inflight.delete(cache_key(img)) } }
|
|
292
|
+
images.zip(pendings).each do |img, pending|
|
|
293
|
+
id = by_key[cache_key(img)]
|
|
294
|
+
id ? pending.resolve(id) : pending.reject
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# The presign and the PUTs; returns cache key to upload id.
|
|
299
|
+
def transfer(images)
|
|
300
|
+
files = images.map do |img|
|
|
301
|
+
{ "content_type" => img.media_type, "byte_size" => img.bytesize,
|
|
302
|
+
"checksum" => [img.md5].pack("m0"), "sha256" => [img.sha256].pack("m0") }
|
|
303
|
+
end
|
|
304
|
+
targets, valid_until = presign(files)
|
|
305
|
+
if targets.size != images.size
|
|
306
|
+
raise ServerError.new("the upload answer holds #{targets.size} uploads for #{images.size} files")
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
put_all(images, files, targets, valid_until)
|
|
310
|
+
images.each_with_index.to_h do |img, i|
|
|
311
|
+
target = targets[i]
|
|
312
|
+
@uploads.put(cache_key(img), target["upload_id"], expiry(target["expires_at"]))
|
|
313
|
+
[cache_key(img), target["upload_id"]]
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def presign(files)
|
|
318
|
+
body, = request("POST", "/v1/uploads", { "files" => files })
|
|
319
|
+
uploads = body.is_a?(Hash) ? body["uploads"] : nil
|
|
320
|
+
unless uploads.is_a?(Array) && uploads.all? { |u| u.is_a?(Hash) && u["upload_id"].is_a?(String) && u["url"].is_a?(String) }
|
|
321
|
+
raise ServerError.new("the upload answer has no uploads list", body: body)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
uploads.each { |u| storage_uri(u["url"]) } # every URL is checked before any byte is sent
|
|
325
|
+
ttl = body["expires_in"].is_a?(Numeric) ? body["expires_in"] : 300
|
|
326
|
+
[uploads, @clock.call + ttl] # the URLs live expires_in seconds, by the client's clock
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# The PUTs, in parallel when there are several. The first failure is raised
|
|
330
|
+
# once every PUT has ended.
|
|
331
|
+
def put_all(images, files, targets, valid_until, timeout: @upload_timeout)
|
|
332
|
+
if images.size == 1
|
|
333
|
+
put_object(images[0], files[0], targets[0], valid_until, timeout: timeout)
|
|
334
|
+
return
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
errors = Array.new(images.size)
|
|
338
|
+
threads = images.each_index.map do |i|
|
|
339
|
+
Thread.new do
|
|
340
|
+
put_object(images[i], files[i], targets[i], valid_until, timeout: timeout)
|
|
341
|
+
rescue StandardError => e
|
|
342
|
+
errors[i] = e
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
threads.each(&:join)
|
|
346
|
+
first = errors.compact.first
|
|
347
|
+
raise first if first
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# One PUT to the storage URL with exactly the returned headers and the
|
|
351
|
+
# bytes: no key, none of the client's headers, no redirect followed. Each
|
|
352
|
+
# attempt has the client's upload_timeout, apart from the API timeout.
|
|
353
|
+
# Retried on 5xx and connection errors as an API call is; a 412 on a retry
|
|
354
|
+
# means an earlier attempt stored the object; a 403 after the URL's time
|
|
355
|
+
# is up presigns again, once. On success `target` names the stored upload.
|
|
356
|
+
# A document's PUT has its own timeout.
|
|
357
|
+
def put_object(image, file, target, valid_until, represigned: false, timeout: @upload_timeout)
|
|
358
|
+
url = target["url"]
|
|
359
|
+
headers = (target["headers"] || {}).to_h { |k, v| [k.to_s, v.to_s] }
|
|
360
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
361
|
+
attempt = 0
|
|
362
|
+
loop do
|
|
363
|
+
response = begin
|
|
364
|
+
attempt_once("PUT", url, headers, image.data, timeout: timeout)
|
|
365
|
+
rescue TimeoutError, ConnectionError => e
|
|
366
|
+
log("PUT", "storage", e.class.name.split("::").last, nil, started, attempt)
|
|
367
|
+
raise if attempt >= @max_retries
|
|
368
|
+
|
|
369
|
+
@sleeper.call(backoff(attempt))
|
|
370
|
+
attempt += 1
|
|
371
|
+
next
|
|
372
|
+
end
|
|
373
|
+
log("PUT", "storage", response.status, nil, started, attempt)
|
|
374
|
+
status = response.status
|
|
375
|
+
return if (200..299).cover?(status) || (status == 412 && attempt.positive?)
|
|
376
|
+
|
|
377
|
+
if status >= 500 && attempt < @max_retries
|
|
378
|
+
@sleeper.call(retry_wait(response.headers) || backoff(attempt))
|
|
379
|
+
attempt += 1
|
|
380
|
+
next
|
|
381
|
+
end
|
|
382
|
+
if status == 403 && !represigned && @clock.call >= valid_until
|
|
383
|
+
fresh, fresh_until = presign([file])
|
|
384
|
+
put_object(image, file, fresh.first, fresh_until, represigned: true, timeout: timeout)
|
|
385
|
+
target.replace(fresh.first)
|
|
386
|
+
return
|
|
387
|
+
end
|
|
388
|
+
raise storage_error(response)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def storage_error(response)
|
|
393
|
+
code = response.body.to_s[%r{<Code>([^<]*)</Code>}, 1]
|
|
394
|
+
DecisionNodeError.new("the upload to storage failed with HTTP #{response.status}",
|
|
395
|
+
status: response.status, error_type: "upload_failed", reason: code,
|
|
396
|
+
headers: response.headers)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# A storage URL must be https; plain http only on a loopback host.
|
|
400
|
+
def storage_uri(url)
|
|
401
|
+
uri = URI.parse(url)
|
|
402
|
+
host = uri.host.to_s.delete_prefix("[").delete_suffix("]").downcase
|
|
403
|
+
return uri if uri.scheme&.downcase == "https" && !host.empty?
|
|
404
|
+
return uri if uri.scheme&.downcase == "http" && LOOPBACK_HOSTS.include?(host)
|
|
405
|
+
|
|
406
|
+
raise DecisionNodeError.new("the storage URL must use https, got #{uri.scheme}://#{uri.host}",
|
|
407
|
+
error_type: "upload_failed")
|
|
408
|
+
rescue URI::InvalidURIError
|
|
409
|
+
raise DecisionNodeError.new("the storage URL is not a valid URL", error_type: "upload_failed")
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def expiry(text)
|
|
413
|
+
Time.iso8601(text.to_s).to_f
|
|
414
|
+
rescue ArgumentError
|
|
415
|
+
begin
|
|
416
|
+
Time.httpdate(text.to_s).to_f
|
|
417
|
+
rescue ArgumentError
|
|
418
|
+
Time.now.to_f + UploadCache::MARGIN + 60 # unknown: usable for a minute
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
end
|