flexiq-executor 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 +124 -0
- data/lib/flexiq/executor/backoff.rb +26 -0
- data/lib/flexiq/executor/clock.rb +10 -0
- data/lib/flexiq/executor/config.rb +89 -0
- data/lib/flexiq/executor/errors.rb +45 -0
- data/lib/flexiq/executor/job.rb +94 -0
- data/lib/flexiq/executor/outbox.rb +124 -0
- data/lib/flexiq/executor/outcome.rb +68 -0
- data/lib/flexiq/executor/session.rb +230 -0
- data/lib/flexiq/executor/slots.rb +59 -0
- data/lib/flexiq/executor/v1/executor_service_pb.rb +49 -0
- data/lib/flexiq/executor/v1/executor_service_services_pb.rb +139 -0
- data/lib/flexiq/executor/version.rb +8 -0
- data/lib/flexiq/executor/worker.rb +134 -0
- data/lib/flexiq/executor.rb +24 -0
- metadata +118 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0b8ca3b245c729d6976dd32d3ade3a3dee78e3fb7b0fc3420bae5d5dd019bd4c
|
|
4
|
+
data.tar.gz: ec20c0917a4be4f285099103c0438816ce00541c111d2890d264af00e629ffcb
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 90aaacc72f5576f6ca1616881cd14e7c2b326504a1fdc7c044c2f848bf3b93f99ce428bb695e438f19d3385beb86135aa6038c11fce5da40e78df71a8b9eaa2c
|
|
7
|
+
data.tar.gz: bf43349dbc5d6b306be85e483e8ff001ccf55458e8a2f6d93013c58bfc780a20f910dcd534774383ea722c8c96578e1039ea8db11787b35bbdeb2f6081ad0a91
|
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,124 @@
|
|
|
1
|
+
# FlexiQ Ruby executor
|
|
2
|
+
|
|
3
|
+
A Ruby client for the **executor door** of a running
|
|
4
|
+
[`flexiq-server`](../../crates/flexiq-server): attach over gRPC, run the jobs the scheduler
|
|
5
|
+
dispatches, and settle each one with its result or its error.
|
|
6
|
+
|
|
7
|
+
It is the other half of the [`flexiq`](../ruby) producer gem. The two are separate gems because
|
|
8
|
+
they are separate doors behind separate scopes: an `execute` token cannot enqueue, a `produce`
|
|
9
|
+
token cannot attach, and this door has no HTTP binding, so it needs the `grpc` gem's native
|
|
10
|
+
extension where the producer gem needs nothing at all.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Ruby 3.3 or newer. Neither gem is on RubyGems yet; take both from this repository with Bundler:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# Gemfile
|
|
18
|
+
git "https://github.com/ByteVeda/flexiq", glob: "sdks/{ruby,ruby-executor}/*.gemspec" do
|
|
19
|
+
gem "flexiq"
|
|
20
|
+
gem "flexiq-executor"
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`grpc` and `google-protobuf` ship precompiled for Linux and macOS, so nothing is compiled on
|
|
25
|
+
install.
|
|
26
|
+
|
|
27
|
+
## Quickstart
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
require "flexiq/executor"
|
|
31
|
+
|
|
32
|
+
worker = FlexiQ::Executor::Worker.new("flexiq.internal:50051",
|
|
33
|
+
token: ENV.fetch("FLEXIQ_EXECUTE_TOKEN"),
|
|
34
|
+
id: "billing-1",
|
|
35
|
+
slots: 8)
|
|
36
|
+
|
|
37
|
+
worker.handle("billing.charge") do |job|
|
|
38
|
+
order = job.args.first # positional arguments, decoded from the payload
|
|
39
|
+
job.progress(50)
|
|
40
|
+
job.log("info", "charging", extra: { "order" => order["id"] })
|
|
41
|
+
{ "receipt" => Gateway.charge(order) } # the job's result
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
trap("TERM") { worker.stop }
|
|
45
|
+
worker.run
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`run` blocks. It attaches, dispatches each job to its handler on a thread of its own, and
|
|
49
|
+
reconnects when the scheduler rotates the stream, which it does every half hour by default
|
|
50
|
+
(`FLEXIQ_GRPC_EXECUTOR_STREAM_MAX_AGE`): a gRPC stream cannot be load-balanced once it has
|
|
51
|
+
started. A rotation is not a failure and costs no job in flight. `run` returns when the
|
|
52
|
+
scheduler sends `shutdown` or `stop` is called; it raises `FlexiQ::Executor::AttachError` for a
|
|
53
|
+
refusal that reconnecting cannot fix, such as a bad token or a protocol version mismatch.
|
|
54
|
+
|
|
55
|
+
TLS is on by default and uses the system roots. Pass `credentials:` (a
|
|
56
|
+
`GRPC::Core::ChannelCredentials`) for a private CA, or `insecure: true` for loopback only.
|
|
57
|
+
|
|
58
|
+
## What a handler returns
|
|
59
|
+
|
|
60
|
+
- **A value** is a success. It is encoded with the producer gem's CBOR codec, so a producer reads
|
|
61
|
+
it back with `FlexiQ::Payload.decode_result`. `nil` means the task returned nothing.
|
|
62
|
+
- **An exception** is a failure, written to `Job#error` as
|
|
63
|
+
`{"errtype","message","traceback"}` JSON. It is retried while retries remain.
|
|
64
|
+
- **`FlexiQ::Executor::Fatal`** fails the job without a retry. Whether to retry is the
|
|
65
|
+
executor's decision: only it can see the exception. Raised inside a `rescue`, the error names
|
|
66
|
+
the original exception.
|
|
67
|
+
|
|
68
|
+
## Cancellation and timeouts
|
|
69
|
+
|
|
70
|
+
Ruby cannot stop a thread safely, so both are cooperative. `job.check!` raises
|
|
71
|
+
`FlexiQ::Executor::Cancelled` once the scheduler asked for the job to stop, and
|
|
72
|
+
`FlexiQ::Executor::TimeoutError` once its timeout has passed; a long handler calls it between
|
|
73
|
+
units of work. A handler that lets `Cancelled` propagate settles the job as cancelled. One that
|
|
74
|
+
raises after its timeout settles it as a timed-out failure, with the same message the server
|
|
75
|
+
writes for a timeout it reaps itself.
|
|
76
|
+
|
|
77
|
+
`job.cancelled?`, `job.timed_out?` and `job.deadline` (monotonic seconds) are there for handlers
|
|
78
|
+
that check on their own terms.
|
|
79
|
+
|
|
80
|
+
## Settings
|
|
81
|
+
|
|
82
|
+
| Keyword | Default | |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `token:` | required | carries the `execute` scope |
|
|
85
|
+
| `id:` | `ruby-<host>-<pid>` | unique among attached executors; a second attach under one id is refused, and retried with backoff |
|
|
86
|
+
| `slots:` | processor count | jobs run at once |
|
|
87
|
+
| `credentials:` / `insecure:` | system roots / `false` | transport security |
|
|
88
|
+
| `max_message_bytes:` | 68 MiB | the door's own ceiling; gRPC's 4 MiB default fails on the first large job |
|
|
89
|
+
| `handshake_timeout:` | 10 | seconds to wait for `hello_ack` |
|
|
90
|
+
| `heartbeat_interval:` | 5 | seconds between heartbeats |
|
|
91
|
+
| `shutdown_drain:` | 30 | seconds running jobs get to finish when draining |
|
|
92
|
+
| `backoff_min:` / `backoff_max:` | 0.25 / 30 | reconnect delay bounds, in seconds |
|
|
93
|
+
| `logger:` | `$stderr` at INFO | any `Logger` |
|
|
94
|
+
|
|
95
|
+
## Forking
|
|
96
|
+
|
|
97
|
+
Run the worker as a process of its own. The `grpc` gem is not fork-safe: `Worker.new` opens a
|
|
98
|
+
gRPC channel, and once a process has done that, a child it forks cannot open one at all. The
|
|
99
|
+
child's `Worker.new` raises `RuntimeError` ("grpc cannot be used before and after forking...").
|
|
100
|
+
`GRPC_ENABLE_FORK_SUPPORT=1` does not help here, because `GRPC.prefork` refuses while a
|
|
101
|
+
bidirectional stream is open, and the attach stream is one.
|
|
102
|
+
|
|
103
|
+
Requiring the gem in a parent is fine; constructing the worker is not. Under a preforking host
|
|
104
|
+
(Puma in cluster mode, Unicorn, a forking job runner), build and `run` the worker in each child
|
|
105
|
+
after the fork, never in an initializer that runs before it.
|
|
106
|
+
|
|
107
|
+
## What this gem does not do
|
|
108
|
+
|
|
109
|
+
- **Durable steps.** The `steps` capability is not advertised, so the scheduler sends no step
|
|
110
|
+
frames. It fails rather than degrades, so it will land as its own feature.
|
|
111
|
+
- **Enqueue.** The executor door has no enqueue-shaped RPC. A task that fans out uses the
|
|
112
|
+
`flexiq` gem with a `produce` token of its own.
|
|
113
|
+
|
|
114
|
+
The wire contract this implements is
|
|
115
|
+
[`contracts/REMOTE_SDK_CONTRACT.md`](../../contracts/REMOTE_SDK_CONTRACT.md).
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
bundle install
|
|
121
|
+
bundle exec rake # RuboCop and the unit suite, against an in-process scheduler
|
|
122
|
+
bundle exec rake e2e # against a real flexiq-server; see test/e2e/support/server.rb
|
|
123
|
+
buf generate # regenerate lib/flexiq/executor/v1 after a contracts/proto edit
|
|
124
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlexiQ
|
|
4
|
+
module Executor
|
|
5
|
+
# Reconnect delay: `min` doubling to `max`, with full jitter so a fleet the scheduler dropped
|
|
6
|
+
# together does not return together. `reset` after an attach succeeds.
|
|
7
|
+
class Backoff
|
|
8
|
+
def initialize(min, max, random: Random.new)
|
|
9
|
+
@min = min.to_f
|
|
10
|
+
@max = max.to_f
|
|
11
|
+
@random = random
|
|
12
|
+
reset
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def reset
|
|
16
|
+
@ceiling = @min
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def next_delay
|
|
20
|
+
delay = @min + (@random.rand * (@ceiling - @min))
|
|
21
|
+
@ceiling = [@ceiling * 2, @max].min
|
|
22
|
+
delay
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
require "logger"
|
|
5
|
+
require "socket"
|
|
6
|
+
|
|
7
|
+
module FlexiQ
|
|
8
|
+
module Executor
|
|
9
|
+
# The executor door's message ceiling: the 64 MiB a payload may be, plus 4 MiB of envelope.
|
|
10
|
+
# Leaving gRPC's 4 MiB default attaches cleanly and fails on the first large job.
|
|
11
|
+
MAX_MESSAGE_BYTES = 68 * 1024 * 1024
|
|
12
|
+
|
|
13
|
+
# The attach protocol this client speaks; `hello_ack` must answer the same number.
|
|
14
|
+
PROTOCOL_VERSION = 1
|
|
15
|
+
|
|
16
|
+
# `hello.slots` is a uint32.
|
|
17
|
+
MAX_SLOTS = 0xFFFF_FFFF
|
|
18
|
+
|
|
19
|
+
CAP_SIDE_CHANNEL = "side_channel"
|
|
20
|
+
CAP_LEASE = "lease"
|
|
21
|
+
|
|
22
|
+
# Capabilities this client implements. `steps` is absent: durable steps fail rather than
|
|
23
|
+
# degrade, so advertising them without an implementation would be a lie the scheduler acts on.
|
|
24
|
+
CAPABILITIES = [CAP_SIDE_CHANNEL, CAP_LEASE].freeze
|
|
25
|
+
|
|
26
|
+
# Settings for a Worker; see Worker.new for what each one means.
|
|
27
|
+
Config = Data.define(
|
|
28
|
+
:token, :id, :slots, :insecure, :credentials, :max_message_bytes, :user_agent, :sdk, :version,
|
|
29
|
+
:handshake_timeout, :heartbeat_interval, :shutdown_drain, :backoff_min, :backoff_max, :logger
|
|
30
|
+
) do
|
|
31
|
+
def self.default_id = "ruby-#{Socket.gethostname}-#{Process.pid}"
|
|
32
|
+
|
|
33
|
+
def self.default_logger = Logger.new($stderr, progname: "flexiq-executor", level: Logger::INFO)
|
|
34
|
+
|
|
35
|
+
def initialize(token:, id: Config.default_id, slots: Etc.nprocessors, insecure: false, credentials: nil,
|
|
36
|
+
max_message_bytes: MAX_MESSAGE_BYTES, user_agent: nil, sdk: "ruby", version: VERSION,
|
|
37
|
+
handshake_timeout: 10, heartbeat_interval: 5, shutdown_drain: 30,
|
|
38
|
+
backoff_min: 0.25, backoff_max: 30, logger: Config.default_logger)
|
|
39
|
+
super
|
|
40
|
+
validate!
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def bearer = "Bearer #{token}"
|
|
44
|
+
|
|
45
|
+
# What the channel is opened with: TLS unless the caller opted out, and the raised limit
|
|
46
|
+
# in both directions.
|
|
47
|
+
def channel_credentials
|
|
48
|
+
return :this_channel_is_insecure if insecure
|
|
49
|
+
|
|
50
|
+
credentials || GRPC::Core::ChannelCredentials.new
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def channel_args
|
|
54
|
+
args = { "grpc.max_send_message_length" => max_message_bytes,
|
|
55
|
+
"grpc.max_receive_message_length" => max_message_bytes }
|
|
56
|
+
agent = [user_agent, "flexiq-ruby-executor/#{VERSION}"].compact.join(" ")
|
|
57
|
+
args.merge("grpc.primary_user_agent" => agent)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def validate!
|
|
63
|
+
validate_token!
|
|
64
|
+
raise ConfigurationError, "executor id must not be empty" if id.to_s.empty?
|
|
65
|
+
unless slots.is_a?(Integer) && slots.between?(1, MAX_SLOTS)
|
|
66
|
+
raise ConfigurationError, "slots must be between 1 and #{MAX_SLOTS}"
|
|
67
|
+
end
|
|
68
|
+
raise ConfigurationError, "max_message_bytes must be positive" unless max_message_bytes.to_i.positive?
|
|
69
|
+
|
|
70
|
+
validate_durations!
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_token!
|
|
74
|
+
raise ConfigurationError, "no token: every call to the executor door carries one" if token.to_s.empty?
|
|
75
|
+
raise ConfigurationError, "token must be a String" unless token.is_a?(String)
|
|
76
|
+
raise ConfigurationError, "token must not contain line breaks" if token.match?(/[\r\n]/)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_durations!
|
|
80
|
+
unless [handshake_timeout, heartbeat_interval, shutdown_drain].all? { |value| value.to_f.positive? }
|
|
81
|
+
raise ConfigurationError, "handshake, heartbeat and drain durations must be positive"
|
|
82
|
+
end
|
|
83
|
+
return if backoff_min.to_f.positive? && backoff_max.to_f >= backoff_min.to_f
|
|
84
|
+
|
|
85
|
+
raise ConfigurationError, "reconnect backoff must be positive and non-decreasing"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlexiQ
|
|
4
|
+
module Executor
|
|
5
|
+
# Raise from a handler to fail the job without a retry. Only the executor can see the
|
|
6
|
+
# exception, so whether to retry is its decision, not the scheduler's.
|
|
7
|
+
#
|
|
8
|
+
# Raised with a cause (`raise Fatal, "…"` inside a `rescue`), the job's error names the cause.
|
|
9
|
+
class Fatal < StandardError; end
|
|
10
|
+
|
|
11
|
+
# Raised by Job#check! once the scheduler asked for the job to stop. A handler that lets it
|
|
12
|
+
# propagate settles the job as cancelled rather than failed.
|
|
13
|
+
class Cancelled < StandardError; end
|
|
14
|
+
|
|
15
|
+
# Raised by Job#check! once the job's timeout has passed. The scheduler never tells an
|
|
16
|
+
# attached executor about a timeout, so this is enforced here, cooperatively.
|
|
17
|
+
class TimeoutError < StandardError; end
|
|
18
|
+
|
|
19
|
+
# The scheduler refused the attach, or the stream failed.
|
|
20
|
+
#
|
|
21
|
+
# `permanent?` is true where reconnecting would only be refused again: a bad credential, a
|
|
22
|
+
# missing scope, a protocol version mismatch. ALREADY_EXISTS is not among them: a reconnect
|
|
23
|
+
# can race the scheduler noticing this executor's previous stream is gone.
|
|
24
|
+
class AttachError < FlexiQ::Error
|
|
25
|
+
PERMANENT_CODES = [
|
|
26
|
+
GRPC::Core::StatusCodes::FAILED_PRECONDITION,
|
|
27
|
+
GRPC::Core::StatusCodes::UNAUTHENTICATED,
|
|
28
|
+
GRPC::Core::StatusCodes::PERMISSION_DENIED
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
attr_reader :code, :details
|
|
32
|
+
|
|
33
|
+
def initialize(code, details, permanent: PERMANENT_CODES.include?(code))
|
|
34
|
+
@code = code
|
|
35
|
+
@details = details
|
|
36
|
+
@permanent = permanent
|
|
37
|
+
super("flexiq: attach refused (code #{code}): #{details}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def permanent? = @permanent
|
|
41
|
+
|
|
42
|
+
def self.from(status) = new(status.code, status.details)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module FlexiQ
|
|
6
|
+
module Executor
|
|
7
|
+
# One dispatched job, as a handler sees it.
|
|
8
|
+
#
|
|
9
|
+
# Cancellation and timeouts are cooperative: Ruby cannot stop a thread safely, so a long
|
|
10
|
+
# handler calls `check!` between units of work, or reads `cancelled?` and `deadline` itself.
|
|
11
|
+
class Job
|
|
12
|
+
LOG_LEVELS = %w[info warn error result].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :id, :task_name, :queue, :namespace, :retry_count, :max_retries, :timeout,
|
|
15
|
+
:disabled_middleware, :metadata, :payload, :lease, :deadline
|
|
16
|
+
|
|
17
|
+
# `side` (an Outbox) receives progress and log frames; nil when the scheduler did not
|
|
18
|
+
# acknowledge `side_channel`, which makes both calls no-ops.
|
|
19
|
+
def initialize(frame, side: nil, now: Clock.now)
|
|
20
|
+
@id = frame.id
|
|
21
|
+
@task_name = frame.task_name
|
|
22
|
+
@queue = frame.queue
|
|
23
|
+
@namespace = frame.has_namespace? ? frame.namespace : nil
|
|
24
|
+
@retry_count = frame.retry_count
|
|
25
|
+
@max_retries = frame.max_retries
|
|
26
|
+
@timeout = seconds(frame.timeout)
|
|
27
|
+
@disabled_middleware = frame.disabled_middleware.to_a.freeze
|
|
28
|
+
@metadata = frame.has_metadata? ? frame.metadata : nil
|
|
29
|
+
@payload = frame.payload.b.freeze
|
|
30
|
+
# Echoed on every frame about this attempt, never inspected or built here.
|
|
31
|
+
@lease = frame.has_lease? && !frame.lease.empty? ? frame.lease.b.freeze : nil
|
|
32
|
+
@deadline = @timeout ? now + @timeout : nil
|
|
33
|
+
@side = side
|
|
34
|
+
@cancel_requested = false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Positional arguments, decoded from the payload on first use.
|
|
38
|
+
def args = call[0]
|
|
39
|
+
|
|
40
|
+
# Keyword arguments as a Hash with String keys, decoded from the payload on first use.
|
|
41
|
+
def kwargs = call[1]
|
|
42
|
+
|
|
43
|
+
def cancelled? = @cancel_requested
|
|
44
|
+
|
|
45
|
+
def timed_out? = !@deadline.nil? && Clock.now >= @deadline
|
|
46
|
+
|
|
47
|
+
# Raises Cancelled once the scheduler asked for this job to stop, TimeoutError once its
|
|
48
|
+
# timeout has passed.
|
|
49
|
+
def check!
|
|
50
|
+
raise Cancelled, "job #{id} was cancelled" if cancelled?
|
|
51
|
+
raise TimeoutError, "job #{id} timed out after #{(timeout * 1000).round}ms" if timed_out?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Reports progress, clamped to 0..100. A no-op without the side channel.
|
|
55
|
+
def progress(percent)
|
|
56
|
+
return unless @side
|
|
57
|
+
|
|
58
|
+
frame = V1::ProgressFrame.new(job_id: id, progress: percent.to_i.clamp(0, 100), lease: lease)
|
|
59
|
+
@side.push_side(id, V1::AttachRequest.new(progress: frame), progress: true)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Writes a task log line. `extra` is any JSON-encodable value.
|
|
63
|
+
def log(level, message, extra: nil)
|
|
64
|
+
level = level.to_s
|
|
65
|
+
raise ArgumentError, "log level must be one of #{LOG_LEVELS.join(", ")}" unless LOG_LEVELS.include?(level)
|
|
66
|
+
|
|
67
|
+
# Encoded before the side-channel check, so a bad value fails the same way either way.
|
|
68
|
+
encoded = extra.nil? ? nil : JSON.generate(extra)
|
|
69
|
+
return unless @side
|
|
70
|
+
|
|
71
|
+
frame = V1::TaskLogFrame.new(job_id: id, task_name: task_name, level: level, message: message.to_s,
|
|
72
|
+
extra: encoded&.b, lease: lease)
|
|
73
|
+
@side.push_side(id, V1::AttachRequest.new(task_log: frame), progress: false)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Publishes a partial result: a log line at level `result` whose value lives in `extra`.
|
|
77
|
+
def publish(value) = log("result", "", extra: value)
|
|
78
|
+
|
|
79
|
+
# Called by the session when a `cancel` frame names this job.
|
|
80
|
+
def request_cancel! = (@cancel_requested = true)
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def call = (@call ||= FlexiQ::Payload.decode_call(payload))
|
|
85
|
+
|
|
86
|
+
def seconds(duration)
|
|
87
|
+
return nil if duration.nil?
|
|
88
|
+
|
|
89
|
+
value = duration.seconds + Rational(duration.nanos, 1_000_000_000)
|
|
90
|
+
value.positive? ? value : nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlexiQ
|
|
4
|
+
module Executor
|
|
5
|
+
# Every frame bound for the attach stream, handed to gRPC through one Enumerator.
|
|
6
|
+
#
|
|
7
|
+
# grpc-ruby drains a bidi request Enumerator on a single thread of its own, so the stream has
|
|
8
|
+
# exactly one writer. Two queues feed it:
|
|
9
|
+
#
|
|
10
|
+
# * settling frames (and `hello`) are never dropped;
|
|
11
|
+
# * side-channel frames are bounded: a job's progress coalesces to its latest value, and past
|
|
12
|
+
# the limit the oldest frame is dropped. Telemetry must not hold back a result.
|
|
13
|
+
#
|
|
14
|
+
# A job's pending side-channel frames are promoted ahead of its settling frame: the scheduler
|
|
15
|
+
# drops a log line that lands after the job it names has settled.
|
|
16
|
+
class Outbox
|
|
17
|
+
SIDE_LIMIT = 256
|
|
18
|
+
# How often an idle writer checks whether the stream has ended under it.
|
|
19
|
+
IDLE_POLL = 0.05
|
|
20
|
+
|
|
21
|
+
Entry = Struct.new(:job_id, :progress, :request)
|
|
22
|
+
private_constant :Entry
|
|
23
|
+
|
|
24
|
+
attr_reader :dropped
|
|
25
|
+
|
|
26
|
+
def initialize
|
|
27
|
+
@mutex = Mutex.new
|
|
28
|
+
@changed = ConditionVariable.new
|
|
29
|
+
@settles = []
|
|
30
|
+
@side = []
|
|
31
|
+
@dropped = 0
|
|
32
|
+
@closed = false
|
|
33
|
+
@drained = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def push(request)
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
@settles << request
|
|
39
|
+
@changed.broadcast
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def push_side(job_id, request, progress:)
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
entry = progress && @side.find { |queued| queued.progress && queued.job_id == job_id }
|
|
46
|
+
if entry
|
|
47
|
+
entry.request = request
|
|
48
|
+
else
|
|
49
|
+
@side << Entry.new(job_id, progress, request)
|
|
50
|
+
@dropped += 1 if @side.length > SIDE_LIMIT && @side.shift
|
|
51
|
+
end
|
|
52
|
+
@changed.broadcast
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Moves a job's pending side-channel frames onto the settling queue, in order.
|
|
57
|
+
def promote(job_id)
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
taken, @side = @side.partition { |entry| entry.job_id == job_id }
|
|
60
|
+
@settles.concat(taken.map(&:request))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# No more frames will be pushed: the writer sends what is queued, then ends the request
|
|
65
|
+
# stream, which half-closes the call.
|
|
66
|
+
def close
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
@closed = true
|
|
69
|
+
@changed.broadcast
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Waits until the writer has sent everything and ended, up to `budget` seconds.
|
|
74
|
+
def await_drained(budget)
|
|
75
|
+
deadline = Clock.now + budget
|
|
76
|
+
@mutex.synchronize do
|
|
77
|
+
until @drained
|
|
78
|
+
remaining = deadline - Clock.now
|
|
79
|
+
return false unless remaining.positive?
|
|
80
|
+
|
|
81
|
+
@changed.wait(@mutex, remaining)
|
|
82
|
+
end
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The request stream. `ended` is polled while idle: grpc-ruby does not finish reading a
|
|
88
|
+
# call until its request Enumerator finishes, so a stream the scheduler ended (a rotation)
|
|
89
|
+
# would otherwise wait on a writer with nothing to write.
|
|
90
|
+
def frames(ended)
|
|
91
|
+
Enumerator.new do |out|
|
|
92
|
+
while (request = next_frame(ended))
|
|
93
|
+
out << request
|
|
94
|
+
end
|
|
95
|
+
ensure
|
|
96
|
+
mark_drained
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def next_frame(ended)
|
|
103
|
+
@mutex.synchronize do
|
|
104
|
+
loop do
|
|
105
|
+
return nil if ended.call
|
|
106
|
+
|
|
107
|
+
request = @settles.shift || @side.shift&.request
|
|
108
|
+
return request if request
|
|
109
|
+
return nil if @closed
|
|
110
|
+
|
|
111
|
+
@changed.wait(@mutex, IDLE_POLL)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def mark_drained
|
|
117
|
+
@mutex.synchronize do
|
|
118
|
+
@drained = true
|
|
119
|
+
@changed.broadcast
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlexiQ
|
|
4
|
+
module Executor
|
|
5
|
+
# How a handler ended, and the one settling frame that reports it.
|
|
6
|
+
#
|
|
7
|
+
# `value` is what the handler returned and `error` what it raised; exactly one is meaningful.
|
|
8
|
+
# `timed_out` and `cancelled` are read off the job when the handler ends.
|
|
9
|
+
Outcome = Data.define(:value, :error, :wall, :timed_out, :cancelled) do
|
|
10
|
+
# The settling frame, with the job's lease echoed when the dispatch carried one.
|
|
11
|
+
def frame(job)
|
|
12
|
+
return success(job) if error.nil?
|
|
13
|
+
return Outcome.timeout_failure(job, wall) if timed_out
|
|
14
|
+
return cancelled_frame(job) if cancelled && error.is_a?(Cancelled)
|
|
15
|
+
|
|
16
|
+
errtype, message, traceback = Outcome.describe(error)
|
|
17
|
+
Outcome.failure(job, TaskError.encode(errtype, message, traceback), should_retry: !error.is_a?(Fatal),
|
|
18
|
+
wall: wall)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The error text core writes for a timeout it reaps itself, so both read alike.
|
|
22
|
+
def self.timeout_failure(job, wall)
|
|
23
|
+
message = "job timed out after #{(job.timeout * 1000).round}ms"
|
|
24
|
+
failure(job, TaskError.encode("TimeoutError", message), should_retry: true, wall: wall, timed_out: true)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.failure(job, error_json, should_retry:, wall: nil, timed_out: false)
|
|
28
|
+
V1::AttachRequest.new(failure: V1::FailureFrame.new(
|
|
29
|
+
job_id: job.id, task_name: job.task_name, error: error_json,
|
|
30
|
+
retry_count: job.retry_count, max_retries: job.max_retries,
|
|
31
|
+
wall_time: wall && duration(wall), should_retry: should_retry, timed_out: timed_out, lease: job.lease
|
|
32
|
+
))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A Fatal raised with a cause reports the cause: Fatal is the retry decision, not the error.
|
|
36
|
+
def self.describe(error)
|
|
37
|
+
error = error.cause if error.is_a?(Fatal) && error.cause
|
|
38
|
+
[error.class.name || "Error", error.message.to_s, error.backtrace || []]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.duration(seconds)
|
|
42
|
+
whole = seconds.floor
|
|
43
|
+
Google::Protobuf::Duration.new(seconds: whole, nanos: ((seconds - whole) * 1_000_000_000).round)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def success(job)
|
|
49
|
+
result = value.nil? ? nil : FlexiQ::Payload.encode_result(value)
|
|
50
|
+
V1::AttachRequest.new(success: V1::SuccessFrame.new(
|
|
51
|
+
job_id: job.id, task_name: job.task_name, result: result,
|
|
52
|
+
wall_time: Outcome.duration(wall), lease: job.lease
|
|
53
|
+
))
|
|
54
|
+
rescue CodecError => e
|
|
55
|
+
# The handler's side effects already happened; a retry would repeat them and fail the
|
|
56
|
+
# same way, so this is fatal.
|
|
57
|
+
message = "task #{job.task_name.inspect} returned a value that does not encode: #{e.message}"
|
|
58
|
+
Outcome.failure(job, TaskError.encode("ResultEncodeError", message), should_retry: false, wall: wall)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def cancelled_frame(job)
|
|
62
|
+
V1::AttachRequest.new(cancelled: V1::CancelledFrame.new(
|
|
63
|
+
job_id: job.id, task_name: job.task_name, wall_time: Outcome.duration(wall), lease: job.lease
|
|
64
|
+
))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|