quicopt 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.yardopts +2 -1
- data/README.md +269 -48
- data/lib/quicopt/client.rb +219 -91
- data/lib/quicopt/indicators.rb +236 -0
- data/lib/quicopt/model.rb +463 -67
- data/lib/quicopt/modeler/v1/program_pb.rb +10 -1
- data/lib/quicopt/structured.rb +438 -0
- data/lib/quicopt/version.rb +1 -1
- data/lib/quicopt/wire.rb +173 -37
- data/lib/quicopt.rb +8 -7
- data/proto/quicopt/modeler/v1/program.proto +160 -6
- metadata +12 -7
data/lib/quicopt/client.rb
CHANGED
|
@@ -11,15 +11,13 @@ require "zlib"
|
|
|
11
11
|
|
|
12
12
|
# quicopt/client — talk to the Quicopt service over HTTP.
|
|
13
13
|
#
|
|
14
|
-
# Encode a model
|
|
15
|
-
#
|
|
16
|
-
# +google-protobuf+. The request body is the
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
# every later call — including from a later *process*, so one caller keeps one key
|
|
22
|
-
# rather than minting a fresh one per run.
|
|
14
|
+
# Encode a model (+Quicopt::Wire+), POST it, read the result back. Requests go
|
|
15
|
+
# out over stdlib +net/http+, so the gem's only runtime dependency stays
|
|
16
|
+
# +google-protobuf+. The request body is the encoded model; the response is the
|
|
17
|
+
# service's result JSON (+status+ / +objective+ / +solution+ / a ready-to-print
|
|
18
|
+
# +display+ / …). Every request carries your API key as
|
|
19
|
+
# <tt>Authorization: Bearer</tt>. Register at https://quicopt.com/signup to
|
|
20
|
+
# receive one by email.
|
|
23
21
|
#
|
|
24
22
|
# Two entry points mirror the two service endpoints:
|
|
25
23
|
#
|
|
@@ -28,26 +26,50 @@ require "zlib"
|
|
|
28
26
|
#
|
|
29
27
|
# A non-2xx response raises +QuicoptError+, carrying the service's stable +reason+
|
|
30
28
|
# code and the framed +display+ text.
|
|
29
|
+
#
|
|
30
|
+
# The service decides how long a solve may run unless the call passes
|
|
31
|
+
# +time_limit:+, which rides the query string like +project:+. The service grants
|
|
32
|
+
# at most its own maximum and reports both numbers in +solver_data+; the client
|
|
33
|
+
# warns when it granted less, and waits on the socket (or polls) long enough for
|
|
34
|
+
# the time it asked for.
|
|
31
35
|
module Quicopt
|
|
32
|
-
# The
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
#
|
|
38
|
-
# +
|
|
39
|
-
#
|
|
40
|
-
# +pulp+, +mathopt+), so it carries its own name here.
|
|
36
|
+
# The Quicopt service a +Client+ targets when no +base_url+ is given. The
|
|
37
|
+
# Python, Julia and R clients default to the same address.
|
|
38
|
+
DEFAULT_BASE_URL = "https://quicoptapi.pgi.fz-juelich.de"
|
|
39
|
+
|
|
40
|
+
# The modelling library a model was written in, sent as the +source_language+
|
|
41
|
+
# call tag. It names the *library*, not the programming language — the service
|
|
42
|
+
# also sees +jump+, +pyomo+, +pulp+ and +mathopt+ — so this gem's own DSL
|
|
43
|
+
# carries its own name here.
|
|
41
44
|
SOURCE_LANGUAGE = "quicopt-ruby"
|
|
42
45
|
|
|
46
|
+
# How long a request may take beyond the solve itself — waiting in the queue,
|
|
47
|
+
# reading the model, writing the answer — before the client stops waiting on a
|
|
48
|
+
# solve whose time limit it set.
|
|
49
|
+
WAIT_MARGIN = 60.0
|
|
50
|
+
|
|
51
|
+
# The arrangement the service found for one permutation (see
|
|
52
|
+
# +Model#permutation+), both ways round and counted from 0: +item_at[k]+ is the
|
|
53
|
+
# item in slot +k+ — for a route, the stops in the order visited — and
|
|
54
|
+
# +slot_of[i]+ the slot of item +i+.
|
|
55
|
+
Arrangement = Data.define(:item_at, :slot_of)
|
|
56
|
+
|
|
57
|
+
# The assignment the service found for one partition (see +Model#partition+),
|
|
58
|
+
# parts counted from 0: +part_of[i]+ is the part of item +i+ and +size_of[b]+
|
|
59
|
+
# the number of items in part +b+.
|
|
60
|
+
Grouping = Data.define(:part_of, :size_of)
|
|
61
|
+
|
|
43
62
|
# A finished solve, parsed from the service's result JSON.
|
|
44
63
|
#
|
|
45
|
-
# +objective+ and +feasible+ are +nil+
|
|
46
|
-
#
|
|
47
|
-
# framed, ready-to-print summary the service renders
|
|
48
|
-
# print it rather than formatting your own.
|
|
64
|
+
# +objective+ and +feasible+ are +nil+ where the outcome leaves them undefined —
|
|
65
|
+
# a model with nothing to feasibility-check, or a run that found no solution.
|
|
66
|
+
# +display+ is the framed, ready-to-print summary the service renders the same
|
|
67
|
+
# way for every kind of model — print it rather than formatting your own.
|
|
68
|
+
# +structures+ holds the +Arrangement+ of each permutation and the +Grouping+
|
|
69
|
+
# of each partition the model declares, by name, and is empty for a model with
|
|
70
|
+
# none.
|
|
49
71
|
Result = Data.define(:job_id, :status, :objective, :feasible, :solution,
|
|
50
|
-
:solve_time_seconds, :solver_data, :display) do
|
|
72
|
+
:solve_time_seconds, :solver_data, :display, :structures) do
|
|
51
73
|
# Build a +Result+ from the service's decoded result JSON.
|
|
52
74
|
#
|
|
53
75
|
# Missing optional keys default rather than raise, so a partial result (e.g.
|
|
@@ -63,11 +85,45 @@ module Quicopt
|
|
|
63
85
|
solution: body["solution"] || {},
|
|
64
86
|
solve_time_seconds: body["solve_time_seconds"] || 0.0,
|
|
65
87
|
solver_data: body["solver_data"] || {},
|
|
66
|
-
display: body["display"].to_s
|
|
88
|
+
display: body["display"].to_s,
|
|
89
|
+
structures: (body["structures"] || {}).transform_values { |s| arrangement(s) })
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# One structure's value from the result JSON, where items, slots and parts
|
|
93
|
+
# count from 1: a permutation's +Arrangement+, a partition's +Grouping+, or
|
|
94
|
+
# +nil+ when none came back.
|
|
95
|
+
#
|
|
96
|
+
# @api private
|
|
97
|
+
def self.arrangement(json)
|
|
98
|
+
return nil if json.nil?
|
|
99
|
+
if json.key?("part_of")
|
|
100
|
+
return Grouping.new(part_of: json["part_of"].map { |b| b.to_i - 1 },
|
|
101
|
+
size_of: json["size_of"].map(&:to_i))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
Arrangement.new(item_at: json["item_at"].map { |i| i.to_i - 1 },
|
|
105
|
+
slot_of: json["slot_of"].map { |k| k.to_i - 1 })
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Warn when the service ran this solve for less time than its +time_limit+
|
|
109
|
+
# asked for. The service grants at most its maximum and reports what was
|
|
110
|
+
# asked and what it granted side by side in +solver_data+.
|
|
111
|
+
#
|
|
112
|
+
# @return [Result] +self+
|
|
113
|
+
# @api private
|
|
114
|
+
def warn_if_lowered
|
|
115
|
+
asked = solver_data["time_limit_s"]
|
|
116
|
+
granted = solver_data["time_budget_s"]
|
|
117
|
+
if asked && granted && granted < asked
|
|
118
|
+
warn "quicopt: the service ran this solve for #{Format.number(granted.to_f)} s, not the " \
|
|
119
|
+
"#{Format.number(asked.to_f)} s asked for: #{Format.number(granted.to_f)} s is the most it grants"
|
|
120
|
+
end
|
|
121
|
+
self
|
|
67
122
|
end
|
|
68
123
|
|
|
69
|
-
# The
|
|
70
|
-
# if it did not report
|
|
124
|
+
# The kind of model the service recognised
|
|
125
|
+
# (<tt>LP</tt>/<tt>MILP</tt>/<tt>QUBO</tt>/…), or +nil+ if it did not report
|
|
126
|
+
# one.
|
|
71
127
|
#
|
|
72
128
|
# @return [String, nil]
|
|
73
129
|
def model_class
|
|
@@ -77,7 +133,7 @@ module Quicopt
|
|
|
77
133
|
|
|
78
134
|
# A non-2xx service response.
|
|
79
135
|
#
|
|
80
|
-
# +reason+ is the service's stable snake_case code (
|
|
136
|
+
# +reason+ is the service's stable snake_case code (<tt>size_exceeded</tt>,
|
|
81
137
|
# +unsupported_model+, +quota_exhausted+, +not_done+, +too_many_inflight+,
|
|
82
138
|
# +queue_full+, +parse_error+, …) — match on it, not on the message. +display+
|
|
83
139
|
# is the framed text to print; +status_code+ the HTTP status.
|
|
@@ -100,23 +156,27 @@ module Quicopt
|
|
|
100
156
|
end
|
|
101
157
|
end
|
|
102
158
|
|
|
103
|
-
# Environment variable
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
|
|
159
|
+
# Environment variable a +Client+ reads its API key from when none is passed.
|
|
160
|
+
# It keeps the key out of your code.
|
|
161
|
+
API_KEY_ENV = "QUICOPT_API_KEY"
|
|
162
|
+
|
|
163
|
+
# Environment variable overriding where the client saves a key the server
|
|
164
|
+
# issued itself. A server that gives a caller without a key a new one on the
|
|
165
|
+
# first call has the client save that key and reuse it in later runs. Point the
|
|
166
|
+
# variable at durable storage where the home directory does not survive the
|
|
167
|
+
# run (CI, containers).
|
|
107
168
|
KEY_PATH_ENV = "QUICOPT_KEY_PATH"
|
|
108
169
|
|
|
109
|
-
# A connection to a Quicopt service at +base_url
|
|
110
|
-
#
|
|
170
|
+
# A connection to a Quicopt service at +base_url+, by default
|
|
171
|
+
# <tt>DEFAULT_BASE_URL</tt>.
|
|
111
172
|
#
|
|
112
|
-
# The client holds
|
|
113
|
-
#
|
|
114
|
-
#
|
|
173
|
+
# The client holds your API key: the one passed as +api_key:+, else the one in
|
|
174
|
+
# the environment variable <tt>QUICOPT_API_KEY</tt>. Register at
|
|
175
|
+
# https://quicopt.com/signup to receive a key by email.
|
|
115
176
|
#
|
|
116
|
-
# client = Quicopt::Client.new
|
|
177
|
+
# client = Quicopt::Client.new(api_key: "…") # the key from your sign-up email
|
|
117
178
|
# result = client.solve(model)
|
|
118
179
|
# puts result.display
|
|
119
|
-
# client.api_key # => the key the first call minted (and cached)
|
|
120
180
|
class Client
|
|
121
181
|
# The HTTP methods this client issues, and the request class for each.
|
|
122
182
|
REQUESTS = {
|
|
@@ -125,36 +185,43 @@ module Quicopt
|
|
|
125
185
|
"DELETE" => Net::HTTP::Delete
|
|
126
186
|
}.freeze
|
|
127
187
|
|
|
128
|
-
# The content type of a request body:
|
|
188
|
+
# The content type of a request body: an encoded model is opaque binary.
|
|
129
189
|
OCTET_STREAM = "application/octet-stream"
|
|
130
190
|
|
|
131
191
|
# @return [String] the service base URL, without a trailing slash
|
|
132
192
|
attr_reader :base_url
|
|
133
|
-
# @return [String, nil] the API key in use
|
|
193
|
+
# @return [String, nil] the API key in use
|
|
134
194
|
attr_accessor :api_key
|
|
135
195
|
# @return [Float] the per-request socket timeout, in seconds
|
|
136
196
|
attr_accessor :timeout
|
|
137
|
-
# @return [Pathname] where the
|
|
197
|
+
# @return [Pathname] where a key the server issued is saved
|
|
138
198
|
attr_reader :key_path
|
|
139
199
|
|
|
140
200
|
# Bind a client to a service endpoint.
|
|
141
201
|
#
|
|
142
202
|
# @param base_url [String] the service base URL; a trailing slash is stripped
|
|
143
|
-
# @param
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
203
|
+
# @param key [String, nil] your API key, given as the second argument; the
|
|
204
|
+
# same as +api_key:+
|
|
205
|
+
# @param api_key [String, nil] your API key. When +nil+, the client reads the
|
|
206
|
+
# key from the environment variable <tt>QUICOPT_API_KEY</tt>, and failing
|
|
207
|
+
# that uses a key saved by an earlier run (see +key_path:+). A key passed
|
|
208
|
+
# here or read from the environment is used as-is and never saved.
|
|
147
209
|
# @param timeout [Numeric] the per-request socket timeout, in seconds
|
|
148
|
-
# @param key_path [String, Pathname, nil] where to
|
|
149
|
-
# defaults to
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
|
|
153
|
-
|
|
210
|
+
# @param key_path [String, Pathname, nil] where to save a key the server
|
|
211
|
+
# issued itself, on a first call without a key; defaults to
|
|
212
|
+
# +Client.default_key_path+
|
|
213
|
+
# @param cache [Boolean] +false+ keeps an issued key in memory only, neither
|
|
214
|
+
# reading nor writing the file
|
|
215
|
+
def initialize(base_url = DEFAULT_BASE_URL, key = nil, api_key: key,
|
|
216
|
+
timeout: 60.0, key_path: nil, cache: true)
|
|
154
217
|
@base_url = base_url.to_s.sub(%r{/+\z}, "")
|
|
155
218
|
@timeout = timeout
|
|
156
219
|
@key_path = Pathname(key_path || Client.default_key_path).expand_path
|
|
157
220
|
@cache = cache
|
|
221
|
+
api_key = Client.env_key if api_key.nil?
|
|
222
|
+
# A key the caller holds — passed, or set in the environment — is never
|
|
223
|
+
# written to the cache, so it cannot clobber the issued key of whoever runs
|
|
224
|
+
# the code, and is never discarded on a 401.
|
|
158
225
|
@explicit = !api_key.nil?
|
|
159
226
|
@api_key = @explicit ? api_key : (cache ? Client.read_key(@key_path) : nil)
|
|
160
227
|
# Only a key that came off disk may be discarded and re-minted on a 401 —
|
|
@@ -165,19 +232,35 @@ module Quicopt
|
|
|
165
232
|
# Solve +model+ synchronously, blocking until the result returns.
|
|
166
233
|
#
|
|
167
234
|
# @param model [Quicopt::Model, Quicopt::Wire::PB::Program, String] a model —
|
|
168
|
-
#
|
|
169
|
-
#
|
|
235
|
+
# encoded here — or a +Program+ / already-encoded bytes if you built them
|
|
236
|
+
# yourself
|
|
170
237
|
# @param project [String, nil] an optional project tag, so calls on one key
|
|
171
238
|
# can be invoiced per project. Sent as a query param, never baked into the
|
|
172
239
|
# model.
|
|
240
|
+
# @param time_limit [Numeric, nil] how many seconds the solve may run, in
|
|
241
|
+
# place of the time the service would choose. A solver that proves its
|
|
242
|
+
# answer sooner stops sooner; a search, which cannot prove it is done, may
|
|
243
|
+
# stop sooner too, once it stops finding better answers. The service grants
|
|
244
|
+
# at most its maximum (120 s on the free tier), and a warning says so when
|
|
245
|
+
# it granted less. The client waits for the answer at least this long plus
|
|
246
|
+
# a minute.
|
|
173
247
|
# @param config [Hash, nil] optional service parameters, sent as the query
|
|
174
248
|
# string
|
|
175
249
|
# @param gzip [Boolean] gzip-compress the request body
|
|
176
250
|
# @return [Result]
|
|
177
251
|
# @raise [QuicoptError] on a non-2xx response
|
|
178
|
-
|
|
252
|
+
# @raise [TypeError] if +time_limit+ is not a number
|
|
253
|
+
# @raise [ArgumentError] if +time_limit+ is not positive and finite
|
|
254
|
+
#
|
|
255
|
+
# @example Give a search half a minute
|
|
256
|
+
# result = client.solve(m, time_limit: 30)
|
|
257
|
+
# result.solver_data["time_budget_s"] # => 30.0
|
|
258
|
+
def solve(model, project: nil, time_limit: nil, config: nil, gzip: false)
|
|
259
|
+
time_limit = Client.check_time_limit(time_limit)
|
|
260
|
+
wait = time_limit.nil? ? @timeout : [@timeout, time_limit + WAIT_MARGIN].max
|
|
179
261
|
Result.from_json(request("POST", "/v1/solve", to_wire(model),
|
|
180
|
-
config: meta_config(model, project, config),
|
|
262
|
+
config: meta_config(model, project, time_limit, config),
|
|
263
|
+
gzip: gzip, timeout: wait)).warn_if_lowered
|
|
181
264
|
end
|
|
182
265
|
|
|
183
266
|
# Submit +model+ for asynchronous solving and return a handle to poll.
|
|
@@ -185,13 +268,39 @@ module Quicopt
|
|
|
185
268
|
# Use this for the first call against a freshly-booted server: the worker
|
|
186
269
|
# warms up before claiming, which can time out a cold synchronous call.
|
|
187
270
|
#
|
|
271
|
+
# +time_limit+ works as for +solve+; +Job#result+ then polls at least that
|
|
272
|
+
# long plus a minute by default.
|
|
273
|
+
#
|
|
188
274
|
# @return [Job] a handle to the queued job; call +Job#result+ to await it
|
|
189
275
|
# @raise [QuicoptError] on a non-2xx response
|
|
190
|
-
|
|
276
|
+
# @raise [TypeError] if +time_limit+ is not a number
|
|
277
|
+
# @raise [ArgumentError] if +time_limit+ is not positive and finite
|
|
278
|
+
def submit(model, project: nil, time_limit: nil, config: nil, gzip: false)
|
|
279
|
+
time_limit = Client.check_time_limit(time_limit)
|
|
191
280
|
body = request("POST", "/v1/jobs", to_wire(model),
|
|
192
|
-
config: meta_config(model, project, config), gzip: gzip)
|
|
281
|
+
config: meta_config(model, project, time_limit, config), gzip: gzip)
|
|
193
282
|
remember(body["api_key"]) # /v1/jobs echoes a minted key in the 202 body too
|
|
194
|
-
Job.new(self, body["job_id"])
|
|
283
|
+
Job.new(self, body["job_id"], time_limit)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Check a time limit before it is sent.
|
|
287
|
+
#
|
|
288
|
+
# @param time_limit [Numeric, nil] seconds, or +nil+ to leave the choice to
|
|
289
|
+
# the service
|
|
290
|
+
# @return [Float, nil]
|
|
291
|
+
# @raise [TypeError] if it is not a number
|
|
292
|
+
# @raise [ArgumentError] if it is not positive and finite
|
|
293
|
+
# @api private
|
|
294
|
+
def self.check_time_limit(time_limit)
|
|
295
|
+
return nil if time_limit.nil?
|
|
296
|
+
raise TypeError, "time_limit is a number of seconds, got #{time_limit.inspect}" unless time_limit.is_a?(Numeric)
|
|
297
|
+
|
|
298
|
+
seconds = time_limit.to_f
|
|
299
|
+
unless seconds.finite? && seconds.positive?
|
|
300
|
+
raise ArgumentError, "time_limit is a positive, finite number of seconds, got #{time_limit}"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
seconds
|
|
195
304
|
end
|
|
196
305
|
|
|
197
306
|
# Perform one HTTP request, retrying once if a *cached* key is rejected.
|
|
@@ -209,13 +318,13 @@ module Quicopt
|
|
|
209
318
|
# @return [String] the raw response body
|
|
210
319
|
# @raise [QuicoptError] on a non-2xx response
|
|
211
320
|
# @api private
|
|
212
|
-
def request_raw(method, path, data = nil, config: nil, gzip: false)
|
|
213
|
-
attempt(method, path, data, config: config, gzip: gzip)
|
|
321
|
+
def request_raw(method, path, data = nil, config: nil, gzip: false, timeout: nil)
|
|
322
|
+
attempt(method, path, data, config: config, gzip: gzip, timeout: timeout)
|
|
214
323
|
rescue QuicoptError => e
|
|
215
324
|
raise unless e.status_code == 401 && @from_cache
|
|
216
325
|
|
|
217
326
|
discard_key
|
|
218
|
-
attempt(method, path, data, config: config, gzip: gzip)
|
|
327
|
+
attempt(method, path, data, config: config, gzip: gzip, timeout: timeout)
|
|
219
328
|
end
|
|
220
329
|
|
|
221
330
|
# Perform one HTTP request and return the raw response body.
|
|
@@ -223,12 +332,13 @@ module Quicopt
|
|
|
223
332
|
# Sets the octet-stream content type (and gzip encoding, if requested) for a
|
|
224
333
|
# body, attaches the bearer key when known, and captures a freshly minted key
|
|
225
334
|
# off the response — including from an error response, since a 502/504 on a
|
|
226
|
-
# first call may still have minted one.
|
|
335
|
+
# first call may still have minted one. +timeout+ replaces the client's socket
|
|
336
|
+
# timeout for this one request.
|
|
227
337
|
#
|
|
228
338
|
# @return [String] the raw response body
|
|
229
339
|
# @raise [QuicoptError] on a non-2xx response
|
|
230
340
|
# @api private
|
|
231
|
-
def attempt(method, path, data = nil, config: nil, gzip: false)
|
|
341
|
+
def attempt(method, path, data = nil, config: nil, gzip: false, timeout: nil)
|
|
232
342
|
uri = URI.parse(@base_url + path + Client.query(config))
|
|
233
343
|
request_class = REQUESTS.fetch(method) { raise ArgumentError, "unsupported method #{method}" }
|
|
234
344
|
req = request_class.new(uri)
|
|
@@ -242,7 +352,7 @@ module Quicopt
|
|
|
242
352
|
end
|
|
243
353
|
req["Authorization"] = "Bearer #{@api_key}" if @api_key
|
|
244
354
|
|
|
245
|
-
resp = http(uri).start { |conn| conn.request(req) }
|
|
355
|
+
resp = http(uri, timeout || @timeout).start { |conn| conn.request(req) }
|
|
246
356
|
capture_key(resp) # a 502/504 on a first call still minted a key
|
|
247
357
|
raise QuicoptError.new(resp.code.to_i, Client.decode(resp.body)) unless resp.is_a?(Net::HTTPSuccess)
|
|
248
358
|
|
|
@@ -261,11 +371,12 @@ module Quicopt
|
|
|
261
371
|
|
|
262
372
|
# Render a config mapping as a URL query-string suffix.
|
|
263
373
|
#
|
|
264
|
-
# Escapes per RFC 3986, so a space becomes +%20+ and never
|
|
265
|
-
# the service's query parser round-trips unambiguously, and the
|
|
266
|
-
# other clients send. Ruby's stock helpers
|
|
267
|
-
# +CGI.escape+,
|
|
268
|
-
#
|
|
374
|
+
# Escapes per RFC 3986, so a space becomes +%20+ and never <tt>'+'</tt> —
|
|
375
|
+
# the form the service's query parser round-trips unambiguously, and the
|
|
376
|
+
# form the other clients send. Ruby's stock helpers
|
|
377
|
+
# (<tt>URI.encode_www_form</tt>, +CGI.escape+,
|
|
378
|
+
# +URI.encode_www_form_component+) all emit <tt>'+'</tt> here, so none of
|
|
379
|
+
# them can be used.
|
|
269
380
|
#
|
|
270
381
|
# @param config [Hash, nil]
|
|
271
382
|
# @return [String] +"?k=v&…"+ if +config+ is non-empty, else +""+
|
|
@@ -313,6 +424,15 @@ module Quicopt
|
|
|
313
424
|
{ "error" => text }
|
|
314
425
|
end
|
|
315
426
|
|
|
427
|
+
# The API key set in the environment variable +API_KEY_ENV+, stripped of
|
|
428
|
+
# surrounding whitespace; +nil+ when the variable is unset or blank.
|
|
429
|
+
#
|
|
430
|
+
# @return [String, nil]
|
|
431
|
+
def self.env_key
|
|
432
|
+
key = ENV.fetch(API_KEY_ENV, "").strip
|
|
433
|
+
key.empty? ? nil : key
|
|
434
|
+
end
|
|
435
|
+
|
|
316
436
|
# The default location of the cached free key:
|
|
317
437
|
# +$XDG_CACHE_HOME/quicopt/free_key+, falling back to +~/.cache+ when the
|
|
318
438
|
# variable is unset, unless +KEY_PATH_ENV+ overrides the whole path.
|
|
@@ -359,43 +479,45 @@ module Quicopt
|
|
|
359
479
|
|
|
360
480
|
private
|
|
361
481
|
|
|
362
|
-
def http(uri)
|
|
482
|
+
def http(uri, timeout)
|
|
363
483
|
conn = Net::HTTP.new(uri.host, uri.port)
|
|
364
484
|
conn.use_ssl = uri.scheme == "https"
|
|
365
|
-
conn.open_timeout =
|
|
366
|
-
conn.read_timeout =
|
|
367
|
-
conn.write_timeout =
|
|
485
|
+
conn.open_timeout = timeout
|
|
486
|
+
conn.read_timeout = timeout
|
|
487
|
+
conn.write_timeout = timeout
|
|
368
488
|
conn
|
|
369
489
|
end
|
|
370
490
|
|
|
371
|
-
# Coerce to
|
|
372
|
-
#
|
|
373
|
-
#
|
|
491
|
+
# Coerce to encoded bytes: bytes pass through, a +Program+ is encoded, and a
|
|
492
|
+
# +Model+ becomes a +Program+ and is encoded — so a caller solves the *model*,
|
|
493
|
+
# never a hand-built +Program+.
|
|
374
494
|
def to_wire(model)
|
|
375
495
|
case model
|
|
376
496
|
when Model, Wire::PB::Program then Wire.encode(model)
|
|
377
497
|
when String then model
|
|
378
498
|
else
|
|
379
|
-
raise TypeError,
|
|
499
|
+
raise TypeError,
|
|
500
|
+
"cannot solve a #{model.class}: pass a Quicopt::Model, a Program, or encoded bytes"
|
|
380
501
|
end
|
|
381
502
|
end
|
|
382
503
|
|
|
383
|
-
# The modelling
|
|
384
|
-
# +Program+ /
|
|
504
|
+
# The modelling library that wrote +model+, or +nil+ for a hand-built
|
|
505
|
+
# +Program+ / raw bytes, which have none to attribute.
|
|
385
506
|
def source_language(model)
|
|
386
507
|
SOURCE_LANGUAGE if model.is_a?(Model)
|
|
387
508
|
end
|
|
388
509
|
|
|
389
|
-
# Merge the per-call
|
|
390
|
-
#
|
|
391
|
-
# optional
|
|
392
|
-
# not the
|
|
393
|
-
def meta_config(model, project, config)
|
|
510
|
+
# Merge the per-call tags into the request config (sent as query params): the
|
|
511
|
+
# modelling-library tag, unless the caller already set one, the optional
|
|
512
|
+
# project id and the optional time limit. The model is unchanged — these ride
|
|
513
|
+
# the query string, not the encoded model.
|
|
514
|
+
def meta_config(model, project, time_limit, config)
|
|
394
515
|
meta = {}
|
|
395
516
|
(config || {}).each { |k, v| meta[k.to_s] = v }
|
|
396
517
|
src = source_language(model)
|
|
397
518
|
meta["source_language"] = src if src && !meta.key?("source_language")
|
|
398
519
|
meta["project_id"] = project if project
|
|
520
|
+
meta["time_limit"] = time_limit if time_limit
|
|
399
521
|
meta
|
|
400
522
|
end
|
|
401
523
|
|
|
@@ -445,16 +567,20 @@ module Quicopt
|
|
|
445
567
|
attr_reader :client
|
|
446
568
|
# @return [String] the server-assigned job id
|
|
447
569
|
attr_reader :job_id
|
|
570
|
+
# @return [Float, nil] the time limit the job was submitted with, if any
|
|
571
|
+
attr_reader :time_limit
|
|
448
572
|
|
|
449
|
-
def initialize(client, job_id)
|
|
573
|
+
def initialize(client, job_id, time_limit = nil)
|
|
450
574
|
@client = client
|
|
451
575
|
@job_id = job_id
|
|
576
|
+
@time_limit = time_limit
|
|
452
577
|
end
|
|
453
578
|
|
|
454
579
|
# Fetch the job's metadata and framed log tail.
|
|
455
580
|
#
|
|
456
|
-
# @return [Hash] the job state
|
|
457
|
-
#
|
|
581
|
+
# @return [Hash] the job state
|
|
582
|
+
# (<tt>queued</tt>/<tt>running</tt>/<tt>done</tt>/<tt>failed</tt>) and
|
|
583
|
+
# its log tail, as returned by the service
|
|
458
584
|
def status
|
|
459
585
|
@client.request("GET", "/v1/jobs/#{@job_id}")
|
|
460
586
|
end
|
|
@@ -463,15 +589,17 @@ module Quicopt
|
|
|
463
589
|
#
|
|
464
590
|
# @param wait [Boolean] poll past the service's +not_done+ reason until the
|
|
465
591
|
# worker finishes; when +false+, fetch once
|
|
466
|
-
# @param timeout [Numeric] maximum time to poll, in seconds
|
|
592
|
+
# @param timeout [Numeric, nil] maximum time to poll, in seconds. Defaults to
|
|
593
|
+
# 120 s, or the job's +time_limit+ plus a minute when that is longer.
|
|
467
594
|
# @param poll [Numeric] delay between polls, in seconds
|
|
468
595
|
# @return [Result]
|
|
469
596
|
# @raise [QuicoptError] if +wait+ is false and the job is not yet done, on any
|
|
470
|
-
# non
|
|
471
|
-
def result(wait: true, timeout:
|
|
597
|
+
# non-<tt>not_done</tt> error, or once +timeout+ elapses
|
|
598
|
+
def result(wait: true, timeout: nil, poll: 0.5)
|
|
599
|
+
timeout ||= @time_limit.nil? ? 120.0 : [120.0, @time_limit + WAIT_MARGIN].max
|
|
472
600
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
473
601
|
loop do
|
|
474
|
-
return Result.from_json(@client.request("GET", "/v1/jobs/#{@job_id}/result"))
|
|
602
|
+
return Result.from_json(@client.request("GET", "/v1/jobs/#{@job_id}/result")).warn_if_lowered
|
|
475
603
|
rescue QuicoptError => e
|
|
476
604
|
raise if !wait || e.reason != "not_done"
|
|
477
605
|
raise if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|