flexiq 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +302 -0
- data/lib/flexiq/backoff.rb +21 -0
- data/lib/flexiq/batch_item_result.rb +21 -0
- data/lib/flexiq/cbor/decoder.rb +169 -0
- data/lib/flexiq/cbor/encoder.rb +112 -0
- data/lib/flexiq/client.rb +196 -0
- data/lib/flexiq/deadline.rb +24 -0
- data/lib/flexiq/debounce.rb +20 -0
- data/lib/flexiq/edge_condition.rb +24 -0
- data/lib/flexiq/enqueue_options.rb +38 -0
- data/lib/flexiq/enqueue_request.rb +25 -0
- data/lib/flexiq/enqueue_result.rb +13 -0
- data/lib/flexiq/errors.rb +21 -0
- data/lib/flexiq/job.rb +60 -0
- data/lib/flexiq/job_page.rb +19 -0
- data/lib/flexiq/job_status.rb +41 -0
- data/lib/flexiq/job_transition.rb +25 -0
- data/lib/flexiq/payload.rb +55 -0
- data/lib/flexiq/queue_stats.rb +12 -0
- data/lib/flexiq/reason.rb +52 -0
- data/lib/flexiq/rpc_error.rb +117 -0
- data/lib/flexiq/task_error.rb +50 -0
- data/lib/flexiq/transition_kind.rb +27 -0
- data/lib/flexiq/transport.rb +216 -0
- data/lib/flexiq/version.rb +6 -0
- data/lib/flexiq/watch/by_ids.rb +33 -0
- data/lib/flexiq/watch/by_queue.rb +33 -0
- data/lib/flexiq/watch.rb +132 -0
- data/lib/flexiq/watch_items.rb +13 -0
- data/lib/flexiq/wire/bytes.rb +23 -0
- data/lib/flexiq/wire/duration.rb +41 -0
- data/lib/flexiq/wire/int64.rb +20 -0
- data/lib/flexiq/wire/path.rb +20 -0
- data/lib/flexiq/wire/sse.rb +91 -0
- data/lib/flexiq/wire/timestamp.rb +27 -0
- data/lib/flexiq/workflow_constructs.rb +66 -0
- data/lib/flexiq/workflow_edge.rb +21 -0
- data/lib/flexiq/workflow_graph.rb +50 -0
- data/lib/flexiq/workflow_node.rb +54 -0
- data/lib/flexiq/workflow_node_state.rb +17 -0
- data/lib/flexiq/workflow_node_status.rb +31 -0
- data/lib/flexiq/workflow_run.rb +24 -0
- data/lib/flexiq/workflow_run_view.rb +19 -0
- data/lib/flexiq/workflow_state.rb +31 -0
- data/lib/flexiq.rb +53 -0
- metadata +92 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9e98266383975c9d410b23e1691b7494c4f1a25c91288d5aabf0a51969da59ed
|
|
4
|
+
data.tar.gz: e95eba4c46c659286fb6aa58f3a4524004e5c2d995db6b1b168ff74195bbcea8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 388d485f608c7bf541bb4463f5568f5be19fa70dfafbe173206c11d00cad1199e8a4ff51b1f4d45ea7a9706517aa8e2d2baa75e9b7e00a4db59cbd5a74a63a86
|
|
7
|
+
data.tar.gz: 0d26e5abe9642fb86c45a525695ae4bd17dd0d291f0fa8c757c27797144bacbdd429fab627297bf2641e59598cbb1b3c8a178bdb4a4468a0991d4eafaa088da8
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pratyush Sharma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# FlexiQ Ruby client
|
|
2
|
+
|
|
3
|
+
A Ruby client for the **producer door** of a running
|
|
4
|
+
[`flexiq-server`](../../crates/flexiq-server): submit work, read it back, wait for it, cancel it,
|
|
5
|
+
count it, and submit static workflows.
|
|
6
|
+
No database credential, no native extension, no gRPC toolchain — it speaks the server's JSON
|
|
7
|
+
facade with the Ruby standard library alone.
|
|
8
|
+
|
|
9
|
+
It is a producer client, not an SDK: it **cannot execute tasks**. It submits work that
|
|
10
|
+
somebody else's workers drain. To run tasks in Ruby, use the companion
|
|
11
|
+
[`flexiq-executor`](../ruby-executor) gem, which attaches to the executor door over gRPC.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Ruby 3.3 or newer. The gem is not on RubyGems yet; take it from this repository with Bundler:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# Gemfile
|
|
19
|
+
gem "flexiq", git: "https://github.com/ByteVeda/flexiq", glob: "sdks/ruby/*.gemspec"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quickstart
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
require "flexiq"
|
|
26
|
+
|
|
27
|
+
client = FlexiQ::Client.new("https://flexiq.internal:50051", token: ENV.fetch("FLEXIQ_TOKEN"))
|
|
28
|
+
|
|
29
|
+
result = client.enqueue("orders.send_receipt",
|
|
30
|
+
args: [{ "order_id" => "ord-0001" }],
|
|
31
|
+
queue: "emails",
|
|
32
|
+
unique_key: "receipt-ord-0001")
|
|
33
|
+
job = client.get_job(result.job.id, include_result: true)
|
|
34
|
+
job.status # => :pending, :running, :complete, :failed, :dead or :cancelled
|
|
35
|
+
job.terminal? # => false until it finishes for good
|
|
36
|
+
|
|
37
|
+
client.cancel_job(job.id)
|
|
38
|
+
client.queue_stats("emails").pending
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The URL is the address the server's `FLEXIQ_GRPC_LISTEN` binds (these examples use port `50051`);
|
|
42
|
+
the JSON facade shares that listener.
|
|
43
|
+
|
|
44
|
+
## Surface
|
|
45
|
+
|
|
46
|
+
| Method | Route | Notes |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `enqueue(task, args:, kwargs:, **options)` | `POST /v1/jobs` | Options are `FlexiQ::EnqueueOptions` fields. |
|
|
49
|
+
| `enqueue_batch(requests)` | `POST /v1/jobs:batchEnqueue` | One `BatchItemResult` per item. Not atomic. |
|
|
50
|
+
| `get_job(id, include_payload:, include_result:)` | `GET /v1/jobs/{id}` | Payload and result only on request. |
|
|
51
|
+
| `list_jobs(status:, queue:, task_name:, page_size:, page_token:)` | `GET /v1/jobs` | One `JobPage`, newest first. See [Listing](#listing). |
|
|
52
|
+
| `each_job(status:, queue:, task_name:, page_size:)` | `GET /v1/jobs` | An `Enumerator` over every page. |
|
|
53
|
+
| `watch_jobs(ids)` | `GET /v1/jobs:watch` (SSE) | Yields each job's transitions until all are finished. See [Waiting](#waiting). |
|
|
54
|
+
| `watch_queue(queue = nil, resume_cursor:)` | `GET /v1/jobs:watch` (SSE) | Yields a queue's live transitions until you break. |
|
|
55
|
+
| `wait(id, timeout:)` | `GET /v1/jobs:watch`, then `GET /v1/jobs/{id}` | The finished `Job`, with its result. |
|
|
56
|
+
| `cancel_job(id)` | `POST /v1/jobs/{id}:cancel` | Idempotent. |
|
|
57
|
+
| `queue_stats(queue = nil)` | `GET /v1/queues/{queue}/stats`, `GET /v1/stats` | `nil` counts the whole namespace. |
|
|
58
|
+
| `submit_workflow(name, graph, params_json:)` | `POST /v1/workflows` | The run id. Static graphs only; not idempotent. See [Workflows](#workflows). |
|
|
59
|
+
| `get_workflow_run(run_id)` | `GET /v1/workflows/{run_id}` | A `WorkflowRunView`: the run and every node. |
|
|
60
|
+
|
|
61
|
+
Enqueue options: `queue`, `priority`, `max_retries`, `scheduled_at` (`Time`), `timeout`
|
|
62
|
+
(seconds), `unique_key`, `metadata`, `notes`, `depends_on`, `expires_at` (`Time`),
|
|
63
|
+
`result_ttl` (seconds), `debounce` (`FlexiQ::Debounce` or a Hash of its fields).
|
|
64
|
+
|
|
65
|
+
The namespace is the token's, fixed when an operator minted it. No call can name another, and a
|
|
66
|
+
job in another namespace reads as `JOB_NOT_FOUND`. If enqueues succeed and nothing ever runs
|
|
67
|
+
them, check the namespace first.
|
|
68
|
+
|
|
69
|
+
## Listing
|
|
70
|
+
|
|
71
|
+
`list_jobs` reads one page, newest first. Every filter is optional, and `status:` takes the
|
|
72
|
+
`FlexiQ::JobStatus` symbols (`:pending`, `:failed`, …). Rows never carry payload or result; read
|
|
73
|
+
one job with `get_job` for those.
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
page = client.list_jobs(queue: "emails", status: :failed, page_size: 100)
|
|
77
|
+
page.jobs # => [FlexiQ::Job, ...]
|
|
78
|
+
page.next_page_token # => pass back as page_token: for the next page; nil on the last
|
|
79
|
+
|
|
80
|
+
client.each_job(queue: "emails", status: :failed).first(500) # follows the cursor for you
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`next_page_token` is opaque. Pass it back unchanged and read nothing out of it; a page token the
|
|
84
|
+
server did not issue is refused with `RPCError` reason `INVALID_REQUEST`. `page_size` defaults to
|
|
85
|
+
the server's choice (50) and is capped (500), so trust `last_page?`, not the row count.
|
|
86
|
+
|
|
87
|
+
A credential whose `produce` or `read` grant is narrowed must name what the grant narrows: a
|
|
88
|
+
`queue:` it reaches for a queue-narrowed grant, a `task_name:` it reaches for a task-narrowed one,
|
|
89
|
+
and both when the grant narrows both. Otherwise `list_jobs` is refused with `SCOPE_DENIED`. A
|
|
90
|
+
listing is never filtered down to the rows the credential can see.
|
|
91
|
+
|
|
92
|
+
## Waiting
|
|
93
|
+
|
|
94
|
+
Watch a job instead of polling `get_job`. `wait` blocks until the job is finished and reads it
|
|
95
|
+
back with its result:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
job = client.wait(job_id, timeout: 60) # raises FlexiQ::WaitTimeoutError after 60 s; nil = no limit
|
|
99
|
+
job.status # => :complete, :dead or :cancelled
|
|
100
|
+
job.decode_result
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`watch_jobs` yields every step on the way. Each id opens with a `:snapshot` of its current
|
|
104
|
+
state (or a `FlexiQ::JobNotFound`), then live transitions follow, and the call returns once
|
|
105
|
+
every job is finished:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
client.watch_jobs([a, b]) do |item|
|
|
109
|
+
case item
|
|
110
|
+
in FlexiQ::JobTransition => t then puts "#{t.job_id} #{t.kind} -> #{t.status}"
|
|
111
|
+
in FlexiQ::JobNotFound => n then puts "#{n.job_id} not visible to this token"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Decide that a job is finished by `JobTransition#terminal?`, never by its status: `:failed` is
|
|
117
|
+
not final while a retry or a dead-letter is still to come.
|
|
118
|
+
|
|
119
|
+
`watch_queue` follows every job in a queue until you `break`. It has no snapshot. It opens with a
|
|
120
|
+
`FlexiQ::WatchCheckpoint`, and then yields one `JobTransition` per live change. Every item
|
|
121
|
+
carries a `cursor`; keep the last one, checkpoints included, and pass it back as
|
|
122
|
+
`resume_cursor:` to replay what you missed:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
client.watch_queue("emails", resume_cursor: saved) do |item|
|
|
126
|
+
case item
|
|
127
|
+
in FlexiQ::WatchGap then reconcile_with(client.list_jobs(queue: "emails"))
|
|
128
|
+
in FlexiQ::JobTransition => t then handle(t)
|
|
129
|
+
else nil
|
|
130
|
+
end
|
|
131
|
+
saved = item.cursor if item.respond_to?(:cursor)
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Watches keep themselves alive:
|
|
136
|
+
|
|
137
|
+
- **Dropped connection, `WATCH_OVERFLOW` or `SHUTTING_DOWN`:** the watch reopens after a
|
|
138
|
+
backoff. An id watch reopens on the jobs that are not finished yet, each with a fresh
|
|
139
|
+
snapshot. A queue watch resumes from its last cursor.
|
|
140
|
+
- **Expired cursor** (`WATCH_CURSOR_EXPIRED`): the server keeps a bounded window, per process.
|
|
141
|
+
The queue watch yields a `FlexiQ::WatchGap` and starts again from now. The transitions in the
|
|
142
|
+
gap are gone; `list_jobs` reads current state, not history.
|
|
143
|
+
- **Failure before the first item:** it raises. This covers a wrong URL, a missing `read`
|
|
144
|
+
scope, and a token already at its cap of concurrent watches (`WATCH_LIMIT`;
|
|
145
|
+
`RPCError#watch_limit` is the cap).
|
|
146
|
+
|
|
147
|
+
A queue watch only sees transitions that the server process it reached handles itself. Each
|
|
148
|
+
watch holds a connection of its own, so other calls on the same client are never blocked.
|
|
149
|
+
`watch_read_timeout:` (default 60 s) is how long a watch may stay silent before it counts as
|
|
150
|
+
dropped. The server sends a keepalive every 15 s.
|
|
151
|
+
|
|
152
|
+
## Workflows
|
|
153
|
+
|
|
154
|
+
A workflow is a graph of steps. Submitting one enqueues a job per node, chained by the edges, and
|
|
155
|
+
any worker with workflow tracking enabled advances the run, not only whoever submitted it.
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
graph = FlexiQ::WorkflowGraph.new(
|
|
159
|
+
nodes: [
|
|
160
|
+
FlexiQ::WorkflowNode.new(name: "charge", task_name: "orders.charge", args: [order], queue: "payments"),
|
|
161
|
+
FlexiQ::WorkflowNode.new(name: "ship", task_name: "orders.ship", args: [order],
|
|
162
|
+
condition: :on_success, timeout: 30, compensate: "orders.unship")
|
|
163
|
+
],
|
|
164
|
+
edges: [%w[charge ship]] # or FlexiQ::WorkflowEdge.new(from: "charge", to: "ship")
|
|
165
|
+
)
|
|
166
|
+
run_id = client.submit_workflow("checkout", graph, params_json: '{"order":"ord-0001"}')
|
|
167
|
+
|
|
168
|
+
view = client.get_workflow_run(run_id)
|
|
169
|
+
view.run.state # => :pending, :running, :completed, :failed, … (FlexiQ::WorkflowState)
|
|
170
|
+
view.run.terminal?
|
|
171
|
+
view.node("ship").status # => :pending, :running, :completed, … (FlexiQ::WorkflowNodeStatus)
|
|
172
|
+
view.node("ship").job_id # read it with get_job
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
A node takes the arguments `enqueue` does and sends the same `raw` bytes, plus `queue`,
|
|
176
|
+
`max_retries`, `timeout` (seconds), `priority`, `condition` (`:on_success`, `:on_failure`,
|
|
177
|
+
`:always`) and `compensate`. A Hash of a node's fields works wherever a node does. The graph is
|
|
178
|
+
checked before the call: at least one node, unique names, and no edge to an undeclared node.
|
|
179
|
+
|
|
180
|
+
**This door advances static graphs only.** A node setting `gate`, `cache`, `fan_out`, `fan_in`
|
|
181
|
+
or `sub_workflow` is refused before anything is written; only a live SDK process can drive those.
|
|
182
|
+
The refusal names one node, so a graph with several clears them one call at a time:
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
begin
|
|
186
|
+
client.submit_workflow("review", graph)
|
|
187
|
+
rescue FlexiQ::RPCError => e
|
|
188
|
+
raise unless e.reason == FlexiQ::Reason::WORKFLOW_CONSTRUCT_UNSUPPORTED
|
|
189
|
+
|
|
190
|
+
e.workflow_construct # => { node: "review", field: "gate" }
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Every submission is version 1 of `name`. Resubmitting a name with a different graph is refused
|
|
195
|
+
with `INVALID_ARGUMENT`; submit a changed graph under a new name.
|
|
196
|
+
|
|
197
|
+
**`submit_workflow` is not idempotent**, and a workflow has no `unique_key`. A run cannot be found
|
|
198
|
+
by name, so a call that raised `TransportError` or an ambiguous `RPCError` (`UNAVAILABLE`,
|
|
199
|
+
`DEADLINE_EXCEEDED`, `CANCELLED`) may have submitted a run whose id is lost. A retry submits a
|
|
200
|
+
second run. Never retry it blind.
|
|
201
|
+
|
|
202
|
+
A narrowed `produce` grant is checked on every node, as `enqueue` would check it, with an empty
|
|
203
|
+
queue counted as `default`. The first node outside the grant refuses the whole graph:
|
|
204
|
+
`SCOPE_DENIED`, with `RPCError#node` naming that node. A node's `compensate` task must be reached
|
|
205
|
+
on every queue. `get_workflow_run` answers `NOT_FOUND` unless the grant reaches every node's job.
|
|
206
|
+
|
|
207
|
+
## Credentials and TLS
|
|
208
|
+
|
|
209
|
+
Every call sends `Authorization: Bearer <token>`. The token is opaque; the client never parses it.
|
|
210
|
+
|
|
211
|
+
`https://` is the default and verifies the server's certificate. Point at a private CA, or
|
|
212
|
+
present a client certificate for mTLS, with `tls:`:
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
FlexiQ::Client.new(url, token: token, tls: {
|
|
216
|
+
ca_file: "/etc/flexiq/ca.pem",
|
|
217
|
+
cert: OpenSSL::X509::Certificate.new(File.read("client.pem")),
|
|
218
|
+
key: OpenSSL::PKey.read(File.read("client.key"))
|
|
219
|
+
})
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`http://` is refused unless you pass `insecure: true`, which is for a loopback listener you
|
|
223
|
+
trust. A bearer token on a plaintext network hop can be replayed by anything that sees it.
|
|
224
|
+
|
|
225
|
+
Other keywords: `open_timeout`, `read_timeout`, `write_timeout`, `watch_read_timeout` (seconds)
|
|
226
|
+
and `user_agent`.
|
|
227
|
+
|
|
228
|
+
## Errors
|
|
229
|
+
|
|
230
|
+
Every error is a `FlexiQ::Error`:
|
|
231
|
+
|
|
232
|
+
| Class | Means |
|
|
233
|
+
|---|---|
|
|
234
|
+
| `FlexiQ::RPCError` | The server refused. Branch on `#reason` (`FlexiQ::Reason::QUEUE_FULL`, …), never on the message. |
|
|
235
|
+
| `FlexiQ::TransportError` | No answer arrived: connect, TLS, timeout, or a body that was not FlexiQ's. |
|
|
236
|
+
| `FlexiQ::ConfigurationError` | The client was built with settings it will not honour. |
|
|
237
|
+
| `FlexiQ::CodecError` | A value could not be encoded into, or decoded from, a payload. |
|
|
238
|
+
| `FlexiQ::WaitTimeoutError` | `wait` ran out of time. The job is untouched. |
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
begin
|
|
242
|
+
client.enqueue("orders.charge", args: [order], queue: "payments", unique_key: order["id"])
|
|
243
|
+
rescue FlexiQ::RPCError => e
|
|
244
|
+
case e.reason
|
|
245
|
+
when FlexiQ::Reason::QUEUE_FULL then sleep(e.retry_after || 1) # e.queue_full => {queue:, pending:, cap:}
|
|
246
|
+
when FlexiQ::Reason::SCOPE_DENIED then raise "token lacks #{e.scope}"
|
|
247
|
+
else raise
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`RPCError#retryable?` says whether the condition clears on its own. It never says a write is
|
|
253
|
+
safe to resend.
|
|
254
|
+
|
|
255
|
+
## Retries
|
|
256
|
+
|
|
257
|
+
The client never retries a call; only watches reopen themselves, since a watch writes nothing.
|
|
258
|
+
A write that raised `TransportError`, or `RPCError` with code
|
|
259
|
+
`UNAVAILABLE`, `DEADLINE_EXCEEDED` or `CANCELLED`, may have landed before the connection dropped,
|
|
260
|
+
and nothing on the wire says which. Resend a write only with `unique_key` set, reusing the same
|
|
261
|
+
value. `unique_key` dedupes against the *active* job only — once that job finishes the key is
|
|
262
|
+
free — so keep the total retry window shorter than the job's own life.
|
|
263
|
+
|
|
264
|
+
## Payloads
|
|
265
|
+
|
|
266
|
+
Arguments travel as the `raw` CBOR envelope this gem writes itself — tag byte `0x02`, then
|
|
267
|
+
`[args, kwargs]` — held to the contract's three rules: definite-length containers,
|
|
268
|
+
shortest-form integers and 64-bit floats. The bytes are therefore the same ones any other FlexiQ
|
|
269
|
+
runtime writes for the same call, which is what keeps cross-runtime `auto:` idempotency keys
|
|
270
|
+
equal. Hash keys keep their insertion order.
|
|
271
|
+
|
|
272
|
+
| Ruby | CBOR |
|
|
273
|
+
|---|---|
|
|
274
|
+
| `nil`, `true`, `false` | simple values |
|
|
275
|
+
| `Integer` (64-bit range) | integer |
|
|
276
|
+
| `Float` | 64-bit float |
|
|
277
|
+
| `String` | text — or a byte string when its encoding is `Encoding::BINARY` |
|
|
278
|
+
| `Symbol` | text |
|
|
279
|
+
| `Array`, `Hash` | array, map |
|
|
280
|
+
|
|
281
|
+
Prefer one Hash argument (`args: [{...}]`): it binds cleanly in every runtime's handlers.
|
|
282
|
+
|
|
283
|
+
Reading back: `job.decode_payload` returns `[args, kwargs]`, `job.decode_result` the task's
|
|
284
|
+
return value, and `job.task_error` a `FlexiQ::TaskError` — structured when the failure carried the
|
|
285
|
+
canonical JSON, otherwise the raw text verbatim.
|
|
286
|
+
|
|
287
|
+
## Development
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
bin/setup # bundle install
|
|
291
|
+
bundle exec rake # rubocop + unit suite (includes the wire-vector conformance test)
|
|
292
|
+
bundle exec rake e2e # against a real server; build one first:
|
|
293
|
+
# cargo build -p flexiq-server --features grpc
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The e2e suite runs `FLEXIQ_SERVER_BIN`, or else `target/{debug,release}/flexiq-server`.
|
|
297
|
+
|
|
298
|
+
## Contract
|
|
299
|
+
|
|
300
|
+
[`contracts/REMOTE_SDK_CONTRACT.md`](../../contracts/REMOTE_SDK_CONTRACT.md) is the rule this
|
|
301
|
+
client implements, and [`contracts/wire-vectors.json`](../../contracts/wire-vectors.json) is the
|
|
302
|
+
conformance bar its suite asserts.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlexiQ
|
|
4
|
+
# Retry delay: 0.25 s doubling to 5 s; `reset` after a success.
|
|
5
|
+
class Backoff
|
|
6
|
+
START = 0.25
|
|
7
|
+
CAP = 5.0
|
|
8
|
+
|
|
9
|
+
def initialize = reset
|
|
10
|
+
|
|
11
|
+
def reset
|
|
12
|
+
@delay = START
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Sleeps the current delay, cut short by `deadline` (Deadline) when given.
|
|
16
|
+
def pause(deadline = nil)
|
|
17
|
+
sleep(deadline ? deadline.cap(@delay) : @delay)
|
|
18
|
+
@delay = [@delay * 2, CAP].min
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlexiQ
|
|
4
|
+
# One item of an EnqueueBatch: either `enqueued` (an EnqueueResult, durable) or `error`
|
|
5
|
+
# (an unraised RPCError — that item alone did not land). A batch is never atomic.
|
|
6
|
+
#
|
|
7
|
+
# Both nil means a newer server answered with an outcome this build does not know.
|
|
8
|
+
BatchItemResult = Data.define(:index, :enqueued, :error) do
|
|
9
|
+
def self.from_json(json, index)
|
|
10
|
+
new(
|
|
11
|
+
index: index,
|
|
12
|
+
enqueued: json["enqueued"] && EnqueueResult.from_json(json["enqueued"]),
|
|
13
|
+
error: json["error"] && RPCError.from_status(json["error"])
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def enqueued? = !enqueued.nil?
|
|
18
|
+
|
|
19
|
+
def failed? = !error.nil?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlexiQ
|
|
4
|
+
module CBOR
|
|
5
|
+
# A CBOR tag this client has no meaning for, kept so the value is not lost.
|
|
6
|
+
Tagged = Data.define(:tag, :value)
|
|
7
|
+
|
|
8
|
+
# RFC 8949 reader. Accepts every form the encoder is forbidden to write —
|
|
9
|
+
# indefinite lengths, non-shortest integers, half and single floats — because a
|
|
10
|
+
# payload written before those rules, or by another writer, still has to run.
|
|
11
|
+
class Decoder
|
|
12
|
+
# Nesting cap, so hostile input cannot exhaust the stack.
|
|
13
|
+
MAX_DEPTH = 512
|
|
14
|
+
BREAK = Object.new.freeze
|
|
15
|
+
private_constant :BREAK
|
|
16
|
+
|
|
17
|
+
# Decodes exactly one CBOR item; trailing bytes are an error.
|
|
18
|
+
def self.decode(bytes)
|
|
19
|
+
decoder = new(bytes)
|
|
20
|
+
value = decoder.read_value(0)
|
|
21
|
+
decoder.finish
|
|
22
|
+
value
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(bytes)
|
|
26
|
+
@bytes = bytes.b
|
|
27
|
+
@pos = 0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def finish
|
|
31
|
+
return if @pos == @bytes.bytesize
|
|
32
|
+
|
|
33
|
+
raise CodecError, "#{@bytes.bytesize - @pos} trailing byte(s) after the CBOR item"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# One complete item; a break code is only legal where an indefinite container reads it.
|
|
37
|
+
def read_value(depth)
|
|
38
|
+
value = read_item(depth)
|
|
39
|
+
raise CodecError, "unexpected CBOR break code" if value.equal?(BREAK)
|
|
40
|
+
|
|
41
|
+
value
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def read_item(depth)
|
|
47
|
+
raise CodecError, "CBOR nesting deeper than #{MAX_DEPTH}" if depth > MAX_DEPTH
|
|
48
|
+
|
|
49
|
+
initial = take_byte
|
|
50
|
+
major = initial >> 5
|
|
51
|
+
info = initial & 0x1F
|
|
52
|
+
case major
|
|
53
|
+
when 0 then read_argument(info)
|
|
54
|
+
when 1 then -1 - read_argument(info)
|
|
55
|
+
when 2 then read_string(info, Encoding::BINARY, depth)
|
|
56
|
+
when 3 then read_string(info, Encoding::UTF_8, depth)
|
|
57
|
+
when 4 then read_array(info, depth)
|
|
58
|
+
when 5 then read_map(info, depth)
|
|
59
|
+
when 6 then Tagged.new(tag: read_argument(info), value: read_value(depth + 1))
|
|
60
|
+
else read_simple(info)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def take_byte
|
|
65
|
+
raise CodecError, "CBOR input ended early" if @pos >= @bytes.bytesize
|
|
66
|
+
|
|
67
|
+
byte = @bytes.getbyte(@pos)
|
|
68
|
+
@pos += 1
|
|
69
|
+
byte
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def take(count)
|
|
73
|
+
raise CodecError, "CBOR input ended early" if @pos + count > @bytes.bytesize
|
|
74
|
+
|
|
75
|
+
slice = @bytes.byteslice(@pos, count)
|
|
76
|
+
@pos += count
|
|
77
|
+
slice
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def read_argument(info)
|
|
81
|
+
case info
|
|
82
|
+
when 0..23 then info
|
|
83
|
+
when 24 then take(1).unpack1("C")
|
|
84
|
+
when 25 then take(2).unpack1("n")
|
|
85
|
+
when 26 then take(4).unpack1("N")
|
|
86
|
+
when 27 then take(8).unpack1("Q>")
|
|
87
|
+
else raise CodecError, "invalid CBOR argument encoding #{info}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def indefinite?(info) = info == 31
|
|
92
|
+
|
|
93
|
+
def read_string(info, encoding, depth)
|
|
94
|
+
raw = indefinite?(info) ? read_chunks(encoding, depth) : take(read_argument(info))
|
|
95
|
+
text = raw.dup.force_encoding(encoding)
|
|
96
|
+
raise CodecError, "CBOR text string is not valid UTF-8" unless text.valid_encoding?
|
|
97
|
+
|
|
98
|
+
text
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# An indefinite string is a run of definite chunks of the same major type.
|
|
102
|
+
def read_chunks(encoding, depth)
|
|
103
|
+
out = +"".b
|
|
104
|
+
loop do
|
|
105
|
+
chunk = read_item(depth + 1)
|
|
106
|
+
break if chunk.equal?(BREAK)
|
|
107
|
+
raise CodecError, "indefinite CBOR string holds a chunk of another type" unless chunk.is_a?(String)
|
|
108
|
+
raise CodecError, "indefinite CBOR string mixes text and bytes" unless chunk.encoding == encoding
|
|
109
|
+
|
|
110
|
+
out << chunk.b
|
|
111
|
+
end
|
|
112
|
+
out
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def read_array(info, depth)
|
|
116
|
+
return Array.new(read_argument(info)) { read_value(depth + 1) } unless indefinite?(info)
|
|
117
|
+
|
|
118
|
+
items = []
|
|
119
|
+
loop do
|
|
120
|
+
item = read_item(depth + 1)
|
|
121
|
+
break if item.equal?(BREAK)
|
|
122
|
+
|
|
123
|
+
items << item
|
|
124
|
+
end
|
|
125
|
+
items
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def read_map(info, depth)
|
|
129
|
+
map = {}
|
|
130
|
+
if indefinite?(info)
|
|
131
|
+
loop do
|
|
132
|
+
key = read_item(depth + 1)
|
|
133
|
+
break if key.equal?(BREAK)
|
|
134
|
+
|
|
135
|
+
map[key] = read_value(depth + 1)
|
|
136
|
+
end
|
|
137
|
+
else
|
|
138
|
+
read_argument(info).times { map[read_value(depth + 1)] = read_value(depth + 1) }
|
|
139
|
+
end
|
|
140
|
+
map
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def read_simple(info)
|
|
144
|
+
case info
|
|
145
|
+
when 20 then false
|
|
146
|
+
when 21 then true
|
|
147
|
+
when 22, 23 then nil
|
|
148
|
+
when 25 then half_float(take(2).unpack1("n"))
|
|
149
|
+
when 26 then take(4).unpack1("g")
|
|
150
|
+
when 27 then take(8).unpack1("G")
|
|
151
|
+
when 31 then BREAK
|
|
152
|
+
else raise CodecError, "unsupported CBOR simple value #{info}"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# IEEE 754 binary16 has no pack directive; RFC 8949 appendix D spells the decode.
|
|
157
|
+
def half_float(bits)
|
|
158
|
+
exponent = (bits >> 10) & 0x1F
|
|
159
|
+
mantissa = bits & 0x3FF
|
|
160
|
+
magnitude =
|
|
161
|
+
if exponent.zero? then Math.ldexp(mantissa, -24)
|
|
162
|
+
elsif exponent == 31 then mantissa.zero? ? Float::INFINITY : Float::NAN
|
|
163
|
+
else Math.ldexp(mantissa + 1024, exponent - 25)
|
|
164
|
+
end
|
|
165
|
+
bits.anybits?(0x8000) ? -magnitude : magnitude
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlexiQ
|
|
4
|
+
module CBOR
|
|
5
|
+
# RFC 8949 writer held to the three rules the wire contract pins:
|
|
6
|
+
# definite-length containers, shortest-form integers, 64-bit finite floats.
|
|
7
|
+
#
|
|
8
|
+
# Each rule moves bytes without moving meaning, and the `auto:` idempotency key
|
|
9
|
+
# hashes those bytes — so a drift here silently stops cross-runtime dedupe.
|
|
10
|
+
module Encoder
|
|
11
|
+
UINT64_MAX = (2**64) - 1
|
|
12
|
+
NEGATIVE_MIN = -(2**64)
|
|
13
|
+
|
|
14
|
+
# Preferred serialization for the non-finite floats; the contract leaves their width free.
|
|
15
|
+
POSITIVE_INFINITY = "\xF9\x7C\x00".b.freeze
|
|
16
|
+
NEGATIVE_INFINITY = "\xF9\xFC\x00".b.freeze
|
|
17
|
+
NAN = "\xF9\x7E\x00".b.freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Encodes one Ruby value into a binary String.
|
|
22
|
+
#
|
|
23
|
+
# A String tagged `Encoding::BINARY` is a CBOR byte string; any other String is text.
|
|
24
|
+
# Symbols are text. Hash keys keep insertion order: the vectors pin unsorted keys.
|
|
25
|
+
def encode(value)
|
|
26
|
+
write(+"".b, value)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def write(out, value)
|
|
30
|
+
case value
|
|
31
|
+
when nil then out << "\xF6".b
|
|
32
|
+
when true then out << "\xF5".b
|
|
33
|
+
when false then out << "\xF4".b
|
|
34
|
+
when Integer then write_integer(out, value)
|
|
35
|
+
when Float then write_float(out, value)
|
|
36
|
+
when Symbol then write_text(out, value.name)
|
|
37
|
+
when String then write_string(out, value)
|
|
38
|
+
when Array then write_array(out, value)
|
|
39
|
+
when Hash then write_map(out, value)
|
|
40
|
+
else raise CodecError, "cannot encode #{value.class} as CBOR"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def write_integer(out, value)
|
|
45
|
+
unless value.between?(NEGATIVE_MIN, UINT64_MAX)
|
|
46
|
+
raise CodecError, "integer #{value} is outside CBOR's 64-bit range"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
value.negative? ? write_head(out, 1, -1 - value) : write_head(out, 0, value)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def write_float(out, value)
|
|
53
|
+
return out << NAN if value.nan?
|
|
54
|
+
return out << (value.positive? ? POSITIVE_INFINITY : NEGATIVE_INFINITY) if value.infinite?
|
|
55
|
+
|
|
56
|
+
out << "\xFB".b << [value].pack("G")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def write_string(out, value)
|
|
60
|
+
return write_bytes(out, value) if value.encoding == Encoding::BINARY
|
|
61
|
+
|
|
62
|
+
write_text(out, value)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def write_bytes(out, value)
|
|
66
|
+
write_head(out, 2, value.bytesize)
|
|
67
|
+
out << value
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def write_text(out, value)
|
|
71
|
+
text = value.encoding == Encoding::UTF_8 ? value : value.encode(Encoding::UTF_8)
|
|
72
|
+
raise CodecError, "text is not valid UTF-8: #{text.inspect}" unless text.valid_encoding?
|
|
73
|
+
|
|
74
|
+
write_head(out, 3, text.bytesize)
|
|
75
|
+
out << text.b
|
|
76
|
+
rescue EncodingError => e
|
|
77
|
+
raise CodecError, "text cannot be converted to UTF-8: #{e.message}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def write_array(out, value)
|
|
81
|
+
write_head(out, 4, value.length)
|
|
82
|
+
value.each { |item| write(out, item) }
|
|
83
|
+
out
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def write_map(out, value)
|
|
87
|
+
write_head(out, 5, value.length)
|
|
88
|
+
value.each do |key, item|
|
|
89
|
+
write(out, key)
|
|
90
|
+
write(out, item)
|
|
91
|
+
end
|
|
92
|
+
out
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Major type in the top three bits, then the argument in its shortest form.
|
|
96
|
+
def write_head(out, major, argument)
|
|
97
|
+
prefix = major << 5
|
|
98
|
+
if argument < 24
|
|
99
|
+
out << (prefix | argument).chr
|
|
100
|
+
elsif argument <= 0xFF
|
|
101
|
+
out << (prefix | 24).chr << [argument].pack("C")
|
|
102
|
+
elsif argument <= 0xFFFF
|
|
103
|
+
out << (prefix | 25).chr << [argument].pack("n")
|
|
104
|
+
elsif argument <= 0xFFFF_FFFF
|
|
105
|
+
out << (prefix | 26).chr << [argument].pack("N")
|
|
106
|
+
else
|
|
107
|
+
out << (prefix | 27).chr << [argument].pack("Q>")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|