quicopt 0.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/.yardopts +9 -0
- data/LICENSE +201 -0
- data/README.md +205 -0
- data/lib/quicopt/client.rb +504 -0
- data/lib/quicopt/model.rb +627 -0
- data/lib/quicopt/modeler/v1/program_pb.rb +48 -0
- data/lib/quicopt/version.rb +9 -0
- data/lib/quicopt/wire.rb +179 -0
- data/lib/quicopt.rb +50 -0
- data/proto/quicopt/modeler/v1/program.proto +240 -0
- data/templates/default/fulldoc/html/css/common.css +474 -0
- data/templates/default/fulldoc/html/css/quicopt-icon-color.svg +244 -0
- data/templates/default/fulldoc/html/css/quicopt-icon-white.svg +235 -0
- data/templates/default/fulldoc/html/setup.rb +17 -0
- metadata +72 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: (c) 2026 Tim Bode, PGI-12, Forschungszentrum Jülich
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require "json"
|
|
6
|
+
require "net/http"
|
|
7
|
+
require "pathname"
|
|
8
|
+
require "stringio"
|
|
9
|
+
require "uri"
|
|
10
|
+
require "zlib"
|
|
11
|
+
|
|
12
|
+
# quicopt/client — talk to the Quicopt service over HTTP.
|
|
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.
|
|
23
|
+
#
|
|
24
|
+
# Two entry points mirror the two service endpoints:
|
|
25
|
+
#
|
|
26
|
+
# - +Client#solve+ — POST +/v1/solve+, block for the result (synchronous).
|
|
27
|
+
# - +Client#submit+ — POST +/v1/jobs+, return a +Job+ to poll.
|
|
28
|
+
#
|
|
29
|
+
# A non-2xx response raises +QuicoptError+, carrying the service's stable +reason+
|
|
30
|
+
# code and the framed +display+ text.
|
|
31
|
+
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.
|
|
41
|
+
SOURCE_LANGUAGE = "quicopt-ruby"
|
|
42
|
+
|
|
43
|
+
# A finished solve, parsed from the service's result JSON.
|
|
44
|
+
#
|
|
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.
|
|
49
|
+
Result = Data.define(:job_id, :status, :objective, :feasible, :solution,
|
|
50
|
+
:solve_time_seconds, :solver_data, :display) do
|
|
51
|
+
# Build a +Result+ from the service's decoded result JSON.
|
|
52
|
+
#
|
|
53
|
+
# Missing optional keys default rather than raise, so a partial result (e.g.
|
|
54
|
+
# a heuristic backend that reports no objective) still parses.
|
|
55
|
+
#
|
|
56
|
+
# @param body [Hash] the decoded result JSON object
|
|
57
|
+
# @return [Result]
|
|
58
|
+
def self.from_json(body)
|
|
59
|
+
new(job_id: body["job_id"].to_s,
|
|
60
|
+
status: body["status"].to_s,
|
|
61
|
+
objective: body["objective"],
|
|
62
|
+
feasible: body["feasible"],
|
|
63
|
+
solution: body["solution"] || {},
|
|
64
|
+
solve_time_seconds: body["solve_time_seconds"] || 0.0,
|
|
65
|
+
solver_data: body["solver_data"] || {},
|
|
66
|
+
display: body["display"].to_s)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The class the service routed the model to (+LP+/+MILP+/+QUBO+/…), or +nil+
|
|
70
|
+
# if it did not report one.
|
|
71
|
+
#
|
|
72
|
+
# @return [String, nil]
|
|
73
|
+
def model_class
|
|
74
|
+
solver_data["model_class"]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A non-2xx service response.
|
|
79
|
+
#
|
|
80
|
+
# +reason+ is the service's stable snake_case code (+size_exceeded+,
|
|
81
|
+
# +unsupported_model+, +quota_exhausted+, +not_done+, +too_many_inflight+,
|
|
82
|
+
# +queue_full+, +parse_error+, …) — match on it, not on the message. +display+
|
|
83
|
+
# is the framed text to print; +status_code+ the HTTP status.
|
|
84
|
+
class QuicoptError < Error
|
|
85
|
+
# @return [Integer] the HTTP status code
|
|
86
|
+
attr_reader :status_code
|
|
87
|
+
# @return [Hash] the decoded error body
|
|
88
|
+
attr_reader :body
|
|
89
|
+
# @return [String, nil] the stable machine-readable reason code
|
|
90
|
+
attr_reader :reason
|
|
91
|
+
# @return [String, nil] the server-rendered, ready-to-print message
|
|
92
|
+
attr_reader :display
|
|
93
|
+
|
|
94
|
+
def initialize(status_code, body)
|
|
95
|
+
@status_code = status_code
|
|
96
|
+
@body = body.is_a?(Hash) ? body : { "error" => body.to_s }
|
|
97
|
+
@reason = @body["reason"]
|
|
98
|
+
@display = @body["display"]
|
|
99
|
+
super(@body["error"] || "HTTP #{status_code}")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
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.
|
|
107
|
+
KEY_PATH_ENV = "QUICOPT_KEY_PATH"
|
|
108
|
+
|
|
109
|
+
# A connection to a Quicopt service at +base_url+ — the public free tier
|
|
110
|
+
# (+DEFAULT_BASE_URL+) unless another URL is given.
|
|
111
|
+
#
|
|
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.
|
|
115
|
+
#
|
|
116
|
+
# client = Quicopt::Client.new
|
|
117
|
+
# result = client.solve(model)
|
|
118
|
+
# puts result.display
|
|
119
|
+
# client.api_key # => the key the first call minted (and cached)
|
|
120
|
+
class Client
|
|
121
|
+
# The HTTP methods this client issues, and the request class for each.
|
|
122
|
+
REQUESTS = {
|
|
123
|
+
"GET" => Net::HTTP::Get,
|
|
124
|
+
"POST" => Net::HTTP::Post,
|
|
125
|
+
"DELETE" => Net::HTTP::Delete
|
|
126
|
+
}.freeze
|
|
127
|
+
|
|
128
|
+
# The content type of a request body: the wire bytes are opaque binary.
|
|
129
|
+
OCTET_STREAM = "application/octet-stream"
|
|
130
|
+
|
|
131
|
+
# @return [String] the service base URL, without a trailing slash
|
|
132
|
+
attr_reader :base_url
|
|
133
|
+
# @return [String, nil] the API key in use; set on the first mint
|
|
134
|
+
attr_accessor :api_key
|
|
135
|
+
# @return [Float] the per-request socket timeout, in seconds
|
|
136
|
+
attr_accessor :timeout
|
|
137
|
+
# @return [Pathname] where the free key is cached
|
|
138
|
+
attr_reader :key_path
|
|
139
|
+
|
|
140
|
+
# Bind a client to a service endpoint.
|
|
141
|
+
#
|
|
142
|
+
# @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.
|
|
147
|
+
# @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)
|
|
154
|
+
@base_url = base_url.to_s.sub(%r{/+\z}, "")
|
|
155
|
+
@timeout = timeout
|
|
156
|
+
@key_path = Pathname(key_path || Client.default_key_path).expand_path
|
|
157
|
+
@cache = cache
|
|
158
|
+
@explicit = !api_key.nil?
|
|
159
|
+
@api_key = @explicit ? api_key : (cache ? Client.read_key(@key_path) : nil)
|
|
160
|
+
# Only a key that came off disk may be discarded and re-minted on a 401 —
|
|
161
|
+
# see +request_raw+, which relies on this to bound re-minting to one per run.
|
|
162
|
+
@from_cache = !@api_key.nil? && !@explicit
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Solve +model+ synchronously, blocking until the result returns.
|
|
166
|
+
#
|
|
167
|
+
# @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
|
|
170
|
+
# @param project [String, nil] an optional project tag, so calls on one key
|
|
171
|
+
# can be invoiced per project. Sent as a query param, never baked into the
|
|
172
|
+
# model.
|
|
173
|
+
# @param config [Hash, nil] optional service parameters, sent as the query
|
|
174
|
+
# string
|
|
175
|
+
# @param gzip [Boolean] gzip-compress the request body
|
|
176
|
+
# @return [Result]
|
|
177
|
+
# @raise [QuicoptError] on a non-2xx response
|
|
178
|
+
def solve(model, project: nil, config: nil, gzip: false)
|
|
179
|
+
Result.from_json(request("POST", "/v1/solve", to_wire(model),
|
|
180
|
+
config: meta_config(model, project, config), gzip: gzip))
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Submit +model+ for asynchronous solving and return a handle to poll.
|
|
184
|
+
#
|
|
185
|
+
# Use this for the first call against a freshly-booted server: the worker
|
|
186
|
+
# warms up before claiming, which can time out a cold synchronous call.
|
|
187
|
+
#
|
|
188
|
+
# @return [Job] a handle to the queued job; call +Job#result+ to await it
|
|
189
|
+
# @raise [QuicoptError] on a non-2xx response
|
|
190
|
+
def submit(model, project: nil, config: nil, gzip: false)
|
|
191
|
+
body = request("POST", "/v1/jobs", to_wire(model),
|
|
192
|
+
config: meta_config(model, project, config), gzip: gzip)
|
|
193
|
+
remember(body["api_key"]) # /v1/jobs echoes a minted key in the 202 body too
|
|
194
|
+
Job.new(self, body["job_id"])
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Perform one HTTP request, retrying once if a *cached* key is rejected.
|
|
198
|
+
#
|
|
199
|
+
# A cache file can outlive the key it holds (the server was reset, the key
|
|
200
|
+
# was revoked, the file was copied from another machine), and a stale key
|
|
201
|
+
# 401s every call forever. So a 401 discards the cached key and retries
|
|
202
|
+
# once, keyless — which mints a fresh key and caches it.
|
|
203
|
+
#
|
|
204
|
+
# The retry is deliberately narrow: only a key read from disk is discarded,
|
|
205
|
+
# never one minted in this process and never a caller-supplied +api_key+. A
|
|
206
|
+
# run can therefore mint at most one key beyond the stale one, so a server
|
|
207
|
+
# that rejected a key it just issued can never turn into a mint loop.
|
|
208
|
+
#
|
|
209
|
+
# @return [String] the raw response body
|
|
210
|
+
# @raise [QuicoptError] on a non-2xx response
|
|
211
|
+
# @api private
|
|
212
|
+
def request_raw(method, path, data = nil, config: nil, gzip: false)
|
|
213
|
+
attempt(method, path, data, config: config, gzip: gzip)
|
|
214
|
+
rescue QuicoptError => e
|
|
215
|
+
raise unless e.status_code == 401 && @from_cache
|
|
216
|
+
|
|
217
|
+
discard_key
|
|
218
|
+
attempt(method, path, data, config: config, gzip: gzip)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Perform one HTTP request and return the raw response body.
|
|
222
|
+
#
|
|
223
|
+
# Sets the octet-stream content type (and gzip encoding, if requested) for a
|
|
224
|
+
# body, attaches the bearer key when known, and captures a freshly minted key
|
|
225
|
+
# off the response — including from an error response, since a 502/504 on a
|
|
226
|
+
# first call may still have minted one.
|
|
227
|
+
#
|
|
228
|
+
# @return [String] the raw response body
|
|
229
|
+
# @raise [QuicoptError] on a non-2xx response
|
|
230
|
+
# @api private
|
|
231
|
+
def attempt(method, path, data = nil, config: nil, gzip: false)
|
|
232
|
+
uri = URI.parse(@base_url + path + Client.query(config))
|
|
233
|
+
request_class = REQUESTS.fetch(method) { raise ArgumentError, "unsupported method #{method}" }
|
|
234
|
+
req = request_class.new(uri)
|
|
235
|
+
if data
|
|
236
|
+
req["Content-Type"] = OCTET_STREAM
|
|
237
|
+
if gzip
|
|
238
|
+
data = Client.gzip(data)
|
|
239
|
+
req["Content-Encoding"] = "gzip"
|
|
240
|
+
end
|
|
241
|
+
req.body = data
|
|
242
|
+
end
|
|
243
|
+
req["Authorization"] = "Bearer #{@api_key}" if @api_key
|
|
244
|
+
|
|
245
|
+
resp = http(uri).start { |conn| conn.request(req) }
|
|
246
|
+
capture_key(resp) # a 502/504 on a first call still minted a key
|
|
247
|
+
raise QuicoptError.new(resp.code.to_i, Client.decode(resp.body)) unless resp.is_a?(Net::HTTPSuccess)
|
|
248
|
+
|
|
249
|
+
resp.body.to_s
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Perform a request via +request_raw+ and parse its JSON body.
|
|
253
|
+
#
|
|
254
|
+
# @return [Hash] the parsed JSON object, or +{}+ for an empty body
|
|
255
|
+
# @raise [QuicoptError] on a non-2xx response
|
|
256
|
+
# @api private
|
|
257
|
+
def request(method, path, data = nil, **options)
|
|
258
|
+
raw = request_raw(method, path, data, **options)
|
|
259
|
+
raw.empty? ? {} : JSON.parse(raw)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Render a config mapping as a URL query-string suffix.
|
|
263
|
+
#
|
|
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.
|
|
269
|
+
#
|
|
270
|
+
# @param config [Hash, nil]
|
|
271
|
+
# @return [String] +"?k=v&…"+ if +config+ is non-empty, else +""+
|
|
272
|
+
# @api private
|
|
273
|
+
def self.query(config)
|
|
274
|
+
return "" if config.nil? || config.empty?
|
|
275
|
+
|
|
276
|
+
"?" + config.map { |k, v| "#{escape(k)}=#{escape(v)}" }.join("&")
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Percent-encode one query component per RFC 3986 (unreserved characters
|
|
280
|
+
# pass through; everything else is escaped byte-wise, UTF-8 included).
|
|
281
|
+
#
|
|
282
|
+
# @api private
|
|
283
|
+
def self.escape(value)
|
|
284
|
+
value.to_s.b.gsub(/[^A-Za-z0-9\-._~]/) { |c| format("%%%02X", c.ord) }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Gzip-compress a request body.
|
|
288
|
+
#
|
|
289
|
+
# @api private
|
|
290
|
+
def self.gzip(data)
|
|
291
|
+
io = StringIO.new(+"".b)
|
|
292
|
+
writer = Zlib::GzipWriter.new(io)
|
|
293
|
+
begin
|
|
294
|
+
writer.write(data)
|
|
295
|
+
ensure
|
|
296
|
+
writer.close
|
|
297
|
+
end
|
|
298
|
+
io.string
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Best-effort decode of a response body into a Hash.
|
|
302
|
+
#
|
|
303
|
+
# @return [Hash] the parsed JSON if it parses (or +{}+ when empty), else the
|
|
304
|
+
# body wrapped as +{"error" => text}+ so a non-JSON error still surfaces
|
|
305
|
+
# @api private
|
|
306
|
+
def self.decode(raw)
|
|
307
|
+
text = raw.to_s
|
|
308
|
+
return {} if text.empty?
|
|
309
|
+
|
|
310
|
+
parsed = JSON.parse(text)
|
|
311
|
+
parsed.is_a?(Hash) ? parsed : { "error" => text }
|
|
312
|
+
rescue JSON::ParserError
|
|
313
|
+
{ "error" => text }
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# The default location of the cached free key:
|
|
317
|
+
# +$XDG_CACHE_HOME/quicopt/free_key+, falling back to +~/.cache+ when the
|
|
318
|
+
# variable is unset, unless +KEY_PATH_ENV+ overrides the whole path.
|
|
319
|
+
#
|
|
320
|
+
# @return [Pathname]
|
|
321
|
+
def self.default_key_path
|
|
322
|
+
override = ENV[KEY_PATH_ENV]
|
|
323
|
+
return Pathname(override) unless override.nil? || override.empty?
|
|
324
|
+
|
|
325
|
+
root = ENV["XDG_CACHE_HOME"]
|
|
326
|
+
root = File.join(Dir.home, ".cache") if root.nil? || root.empty?
|
|
327
|
+
Pathname(root) + "quicopt" + "free_key"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Read the cached key. A missing or unreadable cache is not an error — it
|
|
331
|
+
# just means this caller has no key yet and the next call mints one.
|
|
332
|
+
#
|
|
333
|
+
# @return [String, nil] the cached key, or +nil+ if absent/unreadable/empty
|
|
334
|
+
def self.read_key(path)
|
|
335
|
+
key = path.read.strip
|
|
336
|
+
key.empty? ? nil : key
|
|
337
|
+
rescue SystemCallError, IOError
|
|
338
|
+
nil
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Persist +key+ at +path+, atomically and readable only by its owner.
|
|
342
|
+
#
|
|
343
|
+
# The key is a credential, so the file is +0600+; the write goes to a temp
|
|
344
|
+
# file in the same directory and is then renamed into place, so a crash or a
|
|
345
|
+
# concurrent writer can never leave a truncated key behind for the next run
|
|
346
|
+
# to send.
|
|
347
|
+
#
|
|
348
|
+
# @raise [SystemCallError] if the directory or file cannot be written
|
|
349
|
+
# (callers treat caching as best-effort and degrade to a warning)
|
|
350
|
+
def self.write_key(path, key)
|
|
351
|
+
path.dirname.mkpath
|
|
352
|
+
path.dirname.chmod(0o700)
|
|
353
|
+
tmp = path.dirname + ".free_key.#{Process.pid}"
|
|
354
|
+
tmp.open(File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |fh| fh.write(key) }
|
|
355
|
+
tmp.rename(path.to_s)
|
|
356
|
+
ensure
|
|
357
|
+
tmp&.unlink if tmp&.exist?
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
private
|
|
361
|
+
|
|
362
|
+
def http(uri)
|
|
363
|
+
conn = Net::HTTP.new(uri.host, uri.port)
|
|
364
|
+
conn.use_ssl = uri.scheme == "https"
|
|
365
|
+
conn.open_timeout = @timeout
|
|
366
|
+
conn.read_timeout = @timeout
|
|
367
|
+
conn.write_timeout = @timeout
|
|
368
|
+
conn
|
|
369
|
+
end
|
|
370
|
+
|
|
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+.
|
|
374
|
+
def to_wire(model)
|
|
375
|
+
case model
|
|
376
|
+
when Model, Wire::PB::Program then Wire.encode(model)
|
|
377
|
+
when String then model
|
|
378
|
+
else
|
|
379
|
+
raise TypeError, "cannot solve a #{model.class}: pass a Quicopt::Model, a Program, or wire bytes"
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
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.
|
|
385
|
+
def source_language(model)
|
|
386
|
+
SOURCE_LANGUAGE if model.is_a?(Model)
|
|
387
|
+
end
|
|
388
|
+
|
|
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)
|
|
394
|
+
meta = {}
|
|
395
|
+
(config || {}).each { |k, v| meta[k.to_s] = v }
|
|
396
|
+
src = source_language(model)
|
|
397
|
+
meta["source_language"] = src if src && !meta.key?("source_language")
|
|
398
|
+
meta["project_id"] = project if project
|
|
399
|
+
meta
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Remember a freshly minted API key from a response, if we have none yet. A
|
|
403
|
+
# key we already hold is never overwritten.
|
|
404
|
+
def capture_key(resp)
|
|
405
|
+
remember(resp["X-Quicopt-Api-Key"])
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Adopt +minted+ as this client's key and cache it for the next run.
|
|
409
|
+
#
|
|
410
|
+
# Persisting is best-effort: an unwritable cache (read-only home, a container
|
|
411
|
+
# without +$HOME+) warns rather than raising, since the solve itself
|
|
412
|
+
# succeeded and failing it over a cache miss would be worse than re-minting.
|
|
413
|
+
def remember(minted)
|
|
414
|
+
return if minted.nil? || minted.empty? || !(@api_key.nil? || @api_key.empty?)
|
|
415
|
+
|
|
416
|
+
@api_key = minted
|
|
417
|
+
return if @explicit || !@cache
|
|
418
|
+
|
|
419
|
+
begin
|
|
420
|
+
Client.write_key(@key_path, minted)
|
|
421
|
+
rescue SystemCallError, IOError => e
|
|
422
|
+
warn "quicopt: could not cache the free key at #{@key_path} (#{e.message}); " \
|
|
423
|
+
"every run will mint a new key — set $#{KEY_PATH_ENV} to a writable location"
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Drop the cached key the server just rejected, in memory and on disk. The
|
|
428
|
+
# next request goes out keyless and mints a replacement.
|
|
429
|
+
def discard_key
|
|
430
|
+
@api_key = nil
|
|
431
|
+
@from_cache = false
|
|
432
|
+
return unless @cache
|
|
433
|
+
|
|
434
|
+
begin
|
|
435
|
+
@key_path.unlink
|
|
436
|
+
rescue SystemCallError, IOError
|
|
437
|
+
nil # an unremovable cache still gets re-minted past
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# A handle to an asynchronous job. +result+ polls until it finishes.
|
|
443
|
+
class Job
|
|
444
|
+
# @return [Client] the client that submitted the job
|
|
445
|
+
attr_reader :client
|
|
446
|
+
# @return [String] the server-assigned job id
|
|
447
|
+
attr_reader :job_id
|
|
448
|
+
|
|
449
|
+
def initialize(client, job_id)
|
|
450
|
+
@client = client
|
|
451
|
+
@job_id = job_id
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Fetch the job's metadata and framed log tail.
|
|
455
|
+
#
|
|
456
|
+
# @return [Hash] the job state (+queued+/+running+/+done+/+failed+) and its
|
|
457
|
+
# log tail, as returned by the service
|
|
458
|
+
def status
|
|
459
|
+
@client.request("GET", "/v1/jobs/#{@job_id}")
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Fetch the job's result, optionally polling until it is ready.
|
|
463
|
+
#
|
|
464
|
+
# @param wait [Boolean] poll past the service's +not_done+ reason until the
|
|
465
|
+
# worker finishes; when +false+, fetch once
|
|
466
|
+
# @param timeout [Numeric] maximum time to poll, in seconds
|
|
467
|
+
# @param poll [Numeric] delay between polls, in seconds
|
|
468
|
+
# @return [Result]
|
|
469
|
+
# @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)
|
|
472
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
473
|
+
loop do
|
|
474
|
+
return Result.from_json(@client.request("GET", "/v1/jobs/#{@job_id}/result"))
|
|
475
|
+
rescue QuicoptError => e
|
|
476
|
+
raise if !wait || e.reason != "not_done"
|
|
477
|
+
raise if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
478
|
+
|
|
479
|
+
sleep(poll)
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Fetch the job's log — the same framed view as +display+, not a raw solver
|
|
484
|
+
# log.
|
|
485
|
+
#
|
|
486
|
+
# @return [String]
|
|
487
|
+
def log
|
|
488
|
+
@client.request_raw("GET", "/v1/jobs/#{@job_id}/log")
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Delete the job and its stored blob, result and log on the server.
|
|
492
|
+
#
|
|
493
|
+
# @return [nil]
|
|
494
|
+
def delete
|
|
495
|
+
@client.request_raw("DELETE", "/v1/jobs/#{@job_id}")
|
|
496
|
+
nil
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# @return [String] the job id, tagged with the class
|
|
500
|
+
def inspect
|
|
501
|
+
"#<Quicopt::Job #{@job_id}>"
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
end
|