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 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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlexiQ
4
+ module Executor
5
+ # Monotonic seconds: deadlines and wall times must not move with the system clock.
6
+ module Clock
7
+ def self.now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
8
+ end
9
+ end
10
+ 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