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.
@@ -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 to wire bytes (+Quicopt::Wire+), POST it, read the result back.
15
- # Transport is stdlib +net/http+, so the gem's only runtime dependency stays
16
- # +google-protobuf+. The request body is the versioned wire bytes; the response is
17
- # the service's result JSON (+status+ / +objective+ / +solution+ / a
18
- # ready-to-print +display+ / …). The first keyless call mints an API key, returned
19
- # in the +X-Quicopt-Api-Key+ response header; it is cached at
20
- # +$XDG_CACHE_HOME/quicopt/free_key+ and replayed as +Authorization: Bearer+ on
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 public Quicopt free-tier endpoint a +Client+ targets when no +base_url+
33
- # is given. Mirrors the Python and Julia clients' default, so every client
34
- # reaches the same server out of the box.
35
- DEFAULT_BASE_URL = "https://try.quicoptapi.pgi.fz-juelich.de"
36
-
37
- # The modelling front-end this client authors with, sent as the
38
- # +source_language+ call tag. It names the *front-end*, not the language — the
39
- # Ruby DSL is one front-end among the clients' several (+jump+, +pyomo+,
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+ when the class or outcome leaves them
46
- # undefined (an unconstrained heuristic, or no incumbent). +display+ is the
47
- # framed, ready-to-print summary the service renders for every backend alike —
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 class the service routed the model to (+LP+/+MILP+/+QUBO+/…), or +nil+
70
- # if it did not report one.
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 (+size_exceeded+,
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 overriding where the free key is cached. Point it at
104
- # durable storage in an environment whose home directory does not survive the
105
- # run (CI, containers), where the default location is wiped between sessions
106
- # and every run would otherwise mint a fresh key.
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+ — the public free tier
110
- # (+DEFAULT_BASE_URL+) unless another URL is given.
170
+ # A connection to a Quicopt service at +base_url+, by default
171
+ # <tt>DEFAULT_BASE_URL</tt>.
111
172
  #
112
- # The client holds the API key: pass a known one, or let the first keyless call
113
- # mint one — which is then cached on disk and reused by later runs, so one
114
- # caller keeps one key.
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: the wire bytes are opaque binary.
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; set on the first mint
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 free key is cached
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 api_key [String, nil] a known API key, or +nil+ to reuse the cached
144
- # free key (minting one on the first keyless call). A key passed here is
145
- # used as-is and never written to the cache, so authenticating with a key
146
- # you already hold cannot clobber the free key of whoever runs the code.
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 cache the free key;
149
- # defaults to +Client.default_key_path+
150
- # @param cache [Boolean] +false+ keeps the key in memory only, neither
151
- # reading nor writing the cache file
152
- def initialize(base_url = DEFAULT_BASE_URL, api_key = nil, timeout: 60.0,
153
- key_path: nil, cache: true)
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
- # lowered and encoded here — or a +Program+ / pre-encoded wire bytes if you
169
- # built them yourself
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
- def solve(model, project: nil, config: nil, gzip: false)
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), gzip: gzip))
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
- def submit(model, project: nil, config: nil, gzip: false)
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 +'+'+ — the form
265
- # the service's query parser round-trips unambiguously, and the form the
266
- # other clients send. Ruby's stock helpers (+URI.encode_www_form+,
267
- # +CGI.escape+, +URI.encode_www_form_component+) all emit +'+'+ here, so none
268
- # of them can be used.
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 = @timeout
366
- conn.read_timeout = @timeout
367
- conn.write_timeout = @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 wire bytes: pre-encoded bytes pass through, a +Program+ is
372
- # encoded, and a +Model+ is lowered then encoded — so a caller solves the
373
- # *model*, never a hand-built +Program+.
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, "cannot solve a #{model.class}: pass a Quicopt::Model, a Program, or wire bytes"
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 front-end that authored +model+, or +nil+ for a hand-built
384
- # +Program+ / pre-encoded bytes, which have no front-end to attribute.
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 metadata tags into the request config (sent as query
390
- # params): the front-end tag, unless the caller already set one, and the
391
- # optional project id. The model is unchanged — these ride the query string,
392
- # not the wire bytes.
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 (+queued+/+running+/+done+/+failed+) and its
457
- # log tail, as returned by the service
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-+not_done+ error, or once +timeout+ elapses
471
- def result(wait: true, timeout: 120.0, poll: 0.5)
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