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
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "English"
|
|
4
|
+
|
|
5
|
+
module FlexiQ
|
|
6
|
+
module Executor
|
|
7
|
+
# One attach: a bidirectional stream from `hello` to its end, and the jobs it dispatched.
|
|
8
|
+
#
|
|
9
|
+
# `run` returns how the stream ended. A Worker reconnects after `:rotated` and `:error`, and
|
|
10
|
+
# stops after `:shutdown` and `:stopped`.
|
|
11
|
+
class Session
|
|
12
|
+
SESSION_METADATA_KEY = "flexiq-attach-session-bin"
|
|
13
|
+
# How long the writer gets to send what is queued once the session stops taking work.
|
|
14
|
+
FLUSH_BUDGET = 5
|
|
15
|
+
# How long a half-closed stream waits for the scheduler to end it before it is cancelled.
|
|
16
|
+
CLOSE_GRACE = 5
|
|
17
|
+
|
|
18
|
+
Ending = Data.define(:reason, :attached, :error)
|
|
19
|
+
|
|
20
|
+
def initialize(config, stub, handlers, slots)
|
|
21
|
+
@config = config
|
|
22
|
+
@log = config.logger
|
|
23
|
+
@stub = stub
|
|
24
|
+
@handlers = handlers
|
|
25
|
+
@slots = slots
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
@running = {}
|
|
28
|
+
@warned = {}
|
|
29
|
+
@acked = Queue.new
|
|
30
|
+
@heartbeat_stop = Queue.new
|
|
31
|
+
@done = Queue.new
|
|
32
|
+
@outbox = Outbox.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Attaches and serves the stream until it ends. Never raises for a stream failure: the
|
|
36
|
+
# failure comes back in the Ending.
|
|
37
|
+
def run
|
|
38
|
+
@slots.resume
|
|
39
|
+
@outbox.push(hello)
|
|
40
|
+
@op = @stub.attach(@outbox.frames(-> { @finished || !@op&.status.nil? }),
|
|
41
|
+
metadata: metadata, return_op: true)
|
|
42
|
+
watch_handshake
|
|
43
|
+
@op.execute.each { |response| receive(response) }
|
|
44
|
+
ending(@reason || :rotated)
|
|
45
|
+
rescue AttachError => e
|
|
46
|
+
ending(@reason || :error, e)
|
|
47
|
+
rescue GRPC::BadStatus => e
|
|
48
|
+
ending(@reason || :error, @refusal || AttachError.from(e))
|
|
49
|
+
ensure
|
|
50
|
+
teardown
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Stops taking work, lets running jobs finish within the drain budget, flushes their
|
|
54
|
+
# results and half-closes the stream. Idempotent; returns at once.
|
|
55
|
+
def begin_drain(reason)
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
return if @drainer
|
|
58
|
+
|
|
59
|
+
@reason ||= reason
|
|
60
|
+
@drainer = Thread.new { drain }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def metadata = { "authorization" => @config.bearer }
|
|
67
|
+
|
|
68
|
+
def hello
|
|
69
|
+
V1::AttachRequest.new(hello: V1::HelloFrame.new(
|
|
70
|
+
executor_id: @config.id, sdk: @config.sdk, version: @config.version, tasks: @handlers.keys.sort,
|
|
71
|
+
slots: @config.slots, protocol_version: PROTOCOL_VERSION, capabilities: CAPABILITIES
|
|
72
|
+
))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A `Heartbeat` that overtakes `hello_ack` is read as the handshake and refuses the attach,
|
|
76
|
+
# so nothing else is sent until the acknowledgement arrives, and only so long for it.
|
|
77
|
+
def watch_handshake
|
|
78
|
+
Thread.new do
|
|
79
|
+
# nil is the timeout; true or false means the handshake already resolved.
|
|
80
|
+
next unless @acked.pop(timeout: @config.handshake_timeout).nil?
|
|
81
|
+
|
|
82
|
+
@refusal = AttachError.new(GRPC::Core::StatusCodes::DEADLINE_EXCEEDED,
|
|
83
|
+
"no hello acknowledgement within #{@config.handshake_timeout}s",
|
|
84
|
+
permanent: false)
|
|
85
|
+
@op.cancel
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def receive(response)
|
|
90
|
+
frame = response.frame
|
|
91
|
+
return handshake(response.hello_ack) if frame == :hello_ack && !@attached
|
|
92
|
+
|
|
93
|
+
unless @attached
|
|
94
|
+
raise AttachError.new(GRPC::Core::StatusCodes::FAILED_PRECONDITION,
|
|
95
|
+
"the scheduler's first frame was not a hello acknowledgement", permanent: true)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
case frame
|
|
99
|
+
when :job then dispatch(response.job)
|
|
100
|
+
when :cancel then cancel(response.cancel.job_id)
|
|
101
|
+
when :shutdown then begin_drain(:shutdown)
|
|
102
|
+
when :hello_ack then once(:hello_ack, "a second hello acknowledgement on an attached stream; ignoring it")
|
|
103
|
+
when :job_steps, :step_ack then once(:steps, "a durable-step frame this client did not ask for; ignoring it")
|
|
104
|
+
else once(:unknown, "the scheduler sent a frame this build does not recognise; ignoring it")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def handshake(ack)
|
|
109
|
+
unless ack.protocol_version == PROTOCOL_VERSION
|
|
110
|
+
@acked << true
|
|
111
|
+
raise AttachError.new(GRPC::Core::StatusCodes::FAILED_PRECONDITION,
|
|
112
|
+
"protocol version mismatch: we speak #{PROTOCOL_VERSION}, " \
|
|
113
|
+
"the scheduler speaks #{ack.protocol_version}", permanent: true)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
@token = @op.metadata&.fetch(SESSION_METADATA_KEY, nil)
|
|
117
|
+
@log.warn("the attach response carried no #{SESSION_METADATA_KEY}; heartbeats will be refused") unless @token
|
|
118
|
+
@side_on = ack.capabilities.include?(CAP_SIDE_CHANNEL)
|
|
119
|
+
@attached = true
|
|
120
|
+
@acked << true
|
|
121
|
+
@log.info("attached as #{@config.id} to #{ack.scheduler_id} (slots=#{@config.slots}, " \
|
|
122
|
+
"tasks=#{@handlers.size}, side_channel=#{@side_on}, lease=#{ack.capabilities.include?(CAP_LEASE)})")
|
|
123
|
+
@heartbeat = Thread.new { heartbeat_loop }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def dispatch(frame)
|
|
127
|
+
job = Job.new(frame, side: @side_on ? @outbox : nil)
|
|
128
|
+
handler = @handlers[job.task_name]
|
|
129
|
+
unless handler
|
|
130
|
+
return refuse(job, "TaskNotRegistered", "task not registered: #{job.task_name}", should_retry: false)
|
|
131
|
+
end
|
|
132
|
+
unless @slots.acquire
|
|
133
|
+
return refuse(job, "NoCapacity", "executor did not run '#{job.task_name}': no free slot", should_retry: true)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Booked before the thread starts: a `cancel` can follow its `job` frame at once.
|
|
137
|
+
@mutex.synchronize { @running[job.id] = job }
|
|
138
|
+
Thread.new { execute(job, handler) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def refuse(job, errtype, message, should_retry:)
|
|
142
|
+
@outbox.push(Outcome.failure(job, TaskError.encode(errtype, message), should_retry: should_retry))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def execute(job, handler)
|
|
146
|
+
settled = false
|
|
147
|
+
started = Clock.now
|
|
148
|
+
value = error = nil
|
|
149
|
+
begin
|
|
150
|
+
value = handler.call(job)
|
|
151
|
+
rescue StandardError, ScriptError => e
|
|
152
|
+
error = e
|
|
153
|
+
end
|
|
154
|
+
settle(job, Outcome.new(value: value, error: error, wall: Clock.now - started,
|
|
155
|
+
timed_out: job.timed_out?, cancelled: job.cancelled?).frame(job))
|
|
156
|
+
settled = true
|
|
157
|
+
ensure
|
|
158
|
+
# Exactly one settling frame per job, even for a thread that was killed or raised past
|
|
159
|
+
# StandardError: a job never settled is a job the scheduler waits on until its timeout.
|
|
160
|
+
unless settled
|
|
161
|
+
message = "the handler ended without returning: #{$ERROR_INFO&.class || "thread killed"}"
|
|
162
|
+
settle(job, Outcome.failure(job, TaskError.encode("HandlerAborted", message), should_retry: true))
|
|
163
|
+
end
|
|
164
|
+
@mutex.synchronize { @running.delete(job.id) }
|
|
165
|
+
@slots.release
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def settle(job, frame)
|
|
169
|
+
@outbox.promote(job.id)
|
|
170
|
+
@outbox.push(frame)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def cancel(job_id)
|
|
174
|
+
@mutex.synchronize { @running[job_id] }&.request_cancel!
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def heartbeat_loop
|
|
178
|
+
heartbeat(@slots.available) until @heartbeat_stop.pop(timeout: @config.heartbeat_interval)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Unary and off the stream, so it carries the session token rather than the executor id:
|
|
182
|
+
# an id is a name this executor picked, and could be another executor's.
|
|
183
|
+
def heartbeat(free_slots)
|
|
184
|
+
return unless @token
|
|
185
|
+
|
|
186
|
+
# The interval bounds the call, but never below a second, so a short interval in a test
|
|
187
|
+
# cannot make every heartbeat time out.
|
|
188
|
+
deadline = Time.now + [@config.heartbeat_interval, 1].max
|
|
189
|
+
@stub.heartbeat(V1::HeartbeatRequest.new(session: @token, free_slots: free_slots),
|
|
190
|
+
metadata: metadata, deadline: deadline)
|
|
191
|
+
rescue GRPC::BadStatus => e
|
|
192
|
+
@log.debug("heartbeat failed: #{e.message}")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def drain
|
|
196
|
+
@slots.drain
|
|
197
|
+
# Zero capacity first, so the scheduler stops matching work to this stream.
|
|
198
|
+
heartbeat(0) if @attached
|
|
199
|
+
unless @slots.await_idle(@config.shutdown_drain)
|
|
200
|
+
@log.warn("#{@slots.in_flight} job(s) still running after the #{@config.shutdown_drain}s drain")
|
|
201
|
+
end
|
|
202
|
+
@outbox.close
|
|
203
|
+
@log.warn("gave up flushing results after #{FLUSH_BUDGET}s") unless @outbox.await_drained(FLUSH_BUDGET)
|
|
204
|
+
# The scheduler ends the stream once it reads the half-close; cancel only if it does not.
|
|
205
|
+
@op&.cancel unless @done.pop(timeout: CLOSE_GRACE)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def teardown
|
|
209
|
+
@done << true
|
|
210
|
+
@drainer&.join
|
|
211
|
+
@finished = true
|
|
212
|
+
@outbox.close
|
|
213
|
+
@heartbeat_stop << true
|
|
214
|
+
@heartbeat&.join
|
|
215
|
+
@acked << false
|
|
216
|
+
@mutex.synchronize { @running.each_value(&:request_cancel!) }
|
|
217
|
+
@op&.cancel
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def ending(reason, error = nil) = Ending.new(reason: reason, attached: @attached == true, error: error)
|
|
221
|
+
|
|
222
|
+
def once(key, message)
|
|
223
|
+
return if @warned[key]
|
|
224
|
+
|
|
225
|
+
@warned[key] = true
|
|
226
|
+
@log.warn(message)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlexiQ
|
|
4
|
+
module Executor
|
|
5
|
+
# The executor's concurrency: how many jobs may run at once, shared across reconnects so a
|
|
6
|
+
# job still running from the last stream keeps its slot.
|
|
7
|
+
class Slots
|
|
8
|
+
attr_reader :total
|
|
9
|
+
|
|
10
|
+
def initialize(total)
|
|
11
|
+
@total = total
|
|
12
|
+
@free = total
|
|
13
|
+
@draining = false
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
@changed = ConditionVariable.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Takes a slot; false when none is free or the session is draining.
|
|
19
|
+
def acquire
|
|
20
|
+
@mutex.synchronize do
|
|
21
|
+
return false if @draining || @free <= 0
|
|
22
|
+
|
|
23
|
+
@free -= 1
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def release
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
@free = [@free + 1, total].min
|
|
31
|
+
@changed.broadcast
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Free capacity as a heartbeat reports it: zero while draining, so no new work is matched.
|
|
36
|
+
def available = @mutex.synchronize { @draining ? 0 : @free }
|
|
37
|
+
|
|
38
|
+
def in_flight = @mutex.synchronize { total - @free }
|
|
39
|
+
|
|
40
|
+
def drain = @mutex.synchronize { @draining = true }
|
|
41
|
+
|
|
42
|
+
def resume = @mutex.synchronize { @draining = false }
|
|
43
|
+
|
|
44
|
+
# Waits for every running job to finish, up to `budget` seconds.
|
|
45
|
+
def await_idle(budget)
|
|
46
|
+
deadline = Clock.now + budget
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
until @free >= total
|
|
49
|
+
remaining = deadline - Clock.now
|
|
50
|
+
return false unless remaining.positive?
|
|
51
|
+
|
|
52
|
+
@changed.wait(@mutex, remaining)
|
|
53
|
+
end
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
3
|
+
# source: flexiq/executor/v1/executor_service.proto
|
|
4
|
+
|
|
5
|
+
require 'google/protobuf'
|
|
6
|
+
|
|
7
|
+
require 'google/protobuf/duration_pb'
|
|
8
|
+
require 'google/protobuf/timestamp_pb'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
descriptor_data = "\n)flexiq/executor/v1/executor_service.proto\x12\x12\x66lexiq.executor.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x90\x04\n\rAttachRequest\x12\x36\n\x05hello\x18\x01 \x01(\x0b\x32\x1e.flexiq.executor.v1.HelloFrameH\x00R\x05hello\x12<\n\x07success\x18\x02 \x01(\x0b\x32 .flexiq.executor.v1.SuccessFrameH\x00R\x07success\x12<\n\x07\x66\x61ilure\x18\x03 \x01(\x0b\x32 .flexiq.executor.v1.FailureFrameH\x00R\x07\x66\x61ilure\x12\x42\n\tcancelled\x18\x04 \x01(\x0b\x32\".flexiq.executor.v1.CancelledFrameH\x00R\tcancelled\x12\x36\n\x05slept\x18\x05 \x01(\x0b\x32\x1e.flexiq.executor.v1.SleptFrameH\x00R\x05slept\x12?\n\x08progress\x18\x06 \x01(\x0b\x32!.flexiq.executor.v1.ProgressFrameH\x00R\x08progress\x12=\n\x08task_log\x18\x07 \x01(\x0b\x32 .flexiq.executor.v1.TaskLogFrameH\x00R\x07taskLog\x12\x46\n\x0bstep_commit\x18\x08 \x01(\x0b\x32#.flexiq.executor.v1.StepCommitFrameH\x00R\nstepCommitB\x07\n\x05\x66rame\"\x8a\x03\n\x0e\x41ttachResponse\x12@\n\thello_ack\x18\x01 \x01(\x0b\x32!.flexiq.executor.v1.HelloAckFrameH\x00R\x08helloAck\x12\x30\n\x03job\x18\x02 \x01(\x0b\x32\x1c.flexiq.executor.v1.JobFrameH\x00R\x03job\x12@\n\tjob_steps\x18\x03 \x01(\x0b\x32!.flexiq.executor.v1.JobStepsFrameH\x00R\x08jobSteps\x12=\n\x08step_ack\x18\x04 \x01(\x0b\x32 .flexiq.executor.v1.StepAckFrameH\x00R\x07stepAck\x12\x39\n\x06\x63\x61ncel\x18\x05 \x01(\x0b\x32\x1f.flexiq.executor.v1.CancelFrameH\x00R\x06\x63\x61ncel\x12?\n\x08shutdown\x18\x06 \x01(\x0b\x32!.flexiq.executor.v1.ShutdownFrameH\x00R\x08shutdownB\x07\n\x05\x66rame\"\xd4\x01\n\nHelloFrame\x12\x1f\n\x0b\x65xecutor_id\x18\x01 \x01(\tR\nexecutorId\x12\x10\n\x03sdk\x18\x02 \x01(\tR\x03sdk\x12\x18\n\x07version\x18\x03 \x01(\tR\x07version\x12\x14\n\x05tasks\x18\x04 \x03(\tR\x05tasks\x12\x14\n\x05slots\x18\x05 \x01(\rR\x05slots\x12)\n\x10protocol_version\x18\x06 \x01(\rR\x0fprotocolVersion\x12\"\n\x0c\x63\x61pabilities\x18\x07 \x03(\tR\x0c\x63\x61pabilities\"\x81\x01\n\rHelloAckFrame\x12!\n\x0cscheduler_id\x18\x01 \x01(\tR\x0bschedulerId\x12)\n\x10protocol_version\x18\x02 \x01(\rR\x0fprotocolVersion\x12\"\n\x0c\x63\x61pabilities\x18\x03 \x03(\tR\x0c\x63\x61pabilities\"\x93\x03\n\x08JobFrame\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x1b\n\ttask_name\x18\x02 \x01(\tR\x08taskName\x12\x18\n\x07payload\x18\x03 \x01(\x0cR\x07payload\x12\x1f\n\x0bretry_count\x18\x04 \x01(\x05R\nretryCount\x12\x1f\n\x0bmax_retries\x18\x05 \x01(\x05R\nmaxRetries\x12\x14\n\x05queue\x18\x06 \x01(\tR\x05queue\x12\x33\n\x07timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.DurationR\x07timeout\x12!\n\tnamespace\x18\x08 \x01(\tH\x00R\tnamespace\x88\x01\x01\x12/\n\x13\x64isabled_middleware\x18\t \x03(\tR\x12\x64isabledMiddleware\x12\x1f\n\x08metadata\x18\n \x01(\tH\x01R\x08metadata\x88\x01\x01\x12\x19\n\x05lease\x18\x0b \x01(\x0cH\x02R\x05lease\x88\x01\x01\x42\x0c\n\n_namespaceB\x0b\n\t_metadataB\x08\n\x06_lease\"B\n\rJobStepsFrame\x12\x15\n\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x1a\n\x08snapshot\x18\x02 \x01(\x0cR\x08snapshot\"\xf6\x01\n\x0cStepAckFrame\x12\x15\n\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x10\n\x03seq\x18\x02 \x01(\x05R\x03seq\x12\x0e\n\x02ok\x18\x03 \x01(\x08R\x02ok\x12\x18\n\x07\x61lready\x18\x04 \x01(\x08R\x07\x61lready\x12\x33\n\x07wake_at\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\x06wakeAt\x12\x19\n\x05\x65rror\x18\x06 \x01(\tH\x00R\x05\x65rror\x88\x01\x01\x12\x39\n\x07\x66\x61ilure\x18\x07 \x01(\x0e\x32\x1f.flexiq.executor.v1.StepFailureR\x07\x66\x61ilureB\x08\n\x06_error\"$\n\x0b\x43\x61ncelFrame\x12\x15\n\x06job_id\x18\x01 \x01(\tR\x05jobId\"\x0f\n\rShutdownFrame\"g\n\rProgressFrame\x12\x15\n\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x1a\n\x08progress\x18\x02 \x01(\x05R\x08progress\x12\x19\n\x05lease\x18\x03 \x01(\x0cH\x00R\x05lease\x88\x01\x01\x42\x08\n\x06_lease\"\xbc\x01\n\x0cTaskLogFrame\x12\x15\n\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x1b\n\ttask_name\x18\x02 \x01(\tR\x08taskName\x12\x14\n\x05level\x18\x03 \x01(\tR\x05level\x12\x18\n\x07message\x18\x04 \x01(\tR\x07message\x12\x19\n\x05\x65xtra\x18\x05 \x01(\x0cH\x00R\x05\x65xtra\x88\x01\x01\x12\x19\n\x05lease\x18\x06 \x01(\x0cH\x01R\x05lease\x88\x01\x01\x42\x08\n\x06_extraB\x08\n\x06_lease\"\xc7\x01\n\x0cSuccessFrame\x12\x15\n\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x1b\n\ttask_name\x18\x02 \x01(\tR\x08taskName\x12\x1b\n\x06result\x18\x03 \x01(\x0cH\x00R\x06result\x88\x01\x01\x12\x36\n\twall_time\x18\x04 \x01(\x0b\x32\x19.google.protobuf.DurationR\x08wallTime\x12\x19\n\x05lease\x18\x05 \x01(\x0cH\x01R\x05lease\x88\x01\x01\x42\t\n\x07_resultB\x08\n\x06_lease\"\xb7\x02\n\x0c\x46\x61ilureFrame\x12\x15\n\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x1b\n\ttask_name\x18\x02 \x01(\tR\x08taskName\x12\x14\n\x05\x65rror\x18\x03 \x01(\tR\x05\x65rror\x12\x1f\n\x0bretry_count\x18\x04 \x01(\x05R\nretryCount\x12\x1f\n\x0bmax_retries\x18\x05 \x01(\x05R\nmaxRetries\x12\x36\n\twall_time\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationR\x08wallTime\x12!\n\x0cshould_retry\x18\x07 \x01(\x08R\x0bshouldRetry\x12\x1b\n\ttimed_out\x18\x08 \x01(\x08R\x08timedOut\x12\x19\n\x05lease\x18\t \x01(\x0cH\x00R\x05lease\x88\x01\x01\x42\x08\n\x06_lease\"\xa1\x01\n\x0e\x43\x61ncelledFrame\x12\x15\n\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x1b\n\ttask_name\x18\x02 \x01(\tR\x08taskName\x12\x36\n\twall_time\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationR\x08wallTime\x12\x19\n\x05lease\x18\x04 \x01(\x0cH\x00R\x05lease\x88\x01\x01\x42\x08\n\x06_lease\"\xfb\x01\n\x0fStepCommitFrame\x12\x15\n\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x10\n\x03seq\x18\x02 \x01(\x05R\x03seq\x12\x19\n\x08step_key\x18\x03 \x01(\tR\x07stepKey\x12\x30\n\x04kind\x18\x04 \x01(\x0e\x32\x1c.flexiq.executor.v1.StepKindR\x04kind\x12\x18\n\x07payload\x18\x05 \x01(\x0cR\x07payload\x12\x33\n\x07wake_at\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\x06wakeAt\x12\x19\n\x05lease\x18\x07 \x01(\x0cH\x00R\x05lease\x88\x01\x01\x42\x08\n\x06_lease\"\xd2\x01\n\nSleptFrame\x12\x15\n\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x1b\n\ttask_name\x18\x02 \x01(\tR\x08taskName\x12\x33\n\x07wake_at\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\x06wakeAt\x12\x36\n\twall_time\x18\x04 \x01(\x0b\x32\x19.google.protobuf.DurationR\x08wallTime\x12\x19\n\x05lease\x18\x05 \x01(\x0cH\x00R\x05lease\x88\x01\x01\x42\x08\n\x06_lease\"\xda\x01\n\rSettleRequest\x12<\n\x07success\x18\x01 \x01(\x0b\x32 .flexiq.executor.v1.SuccessFrameH\x00R\x07success\x12<\n\x07\x66\x61ilure\x18\x02 \x01(\x0b\x32 .flexiq.executor.v1.FailureFrameH\x00R\x07\x66\x61ilure\x12\x42\n\tcancelled\x18\x03 \x01(\x0b\x32\".flexiq.executor.v1.CancelledFrameH\x00R\tcancelledB\t\n\x07outcome\"\x10\n\x0eSettleResponse\"y\n\x12\x45xtendLeaseRequest\x12\x15\n\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x14\n\x05lease\x18\x02 \x01(\x0cR\x05lease\x12\x36\n\textend_by\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationR\x08\x65xtendBy\"M\n\x13\x45xtendLeaseResponse\x12\x36\n\x08\x64\x65\x61\x64line\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\x08\x64\x65\x61\x64line\"V\n\x15ReportProgressRequest\x12=\n\x08progress\x18\x01 \x01(\x0b\x32!.flexiq.executor.v1.ProgressFrameR\x08progress\"\x18\n\x16ReportProgressResponse\"R\n\x13WriteTaskLogRequest\x12;\n\x08task_log\x18\x01 \x01(\x0b\x32 .flexiq.executor.v1.TaskLogFrameR\x07taskLog\"\x16\n\x14WriteTaskLogResponse\"K\n\x10HeartbeatRequest\x12\x18\n\x07session\x18\x01 \x01(\x0cR\x07session\x12\x1d\n\nfree_slots\x18\x02 \x01(\rR\tfreeSlots\"\x13\n\x11HeartbeatResponse*M\n\x08StepKind\x12\x19\n\x15STEP_KIND_UNSPECIFIED\x10\x00\x12\x11\n\rSTEP_KIND_RUN\x10\x01\x12\x13\n\x0fSTEP_KIND_SLEEP\x10\x02*\x80\x01\n\x0bStepFailure\x12\x1c\n\x18STEP_FAILURE_UNSPECIFIED\x10\x00\x12\x1a\n\x16STEP_FAILURE_RETRYABLE\x10\x01\x12\x1a\n\x16STEP_FAILURE_PERMANENT\x10\x02\x12\x1b\n\x17STEP_FAILURE_SUPERSEDED\x10\x03\x32\xbd\x04\n\x0f\x45xecutorService\x12S\n\x06\x41ttach\x12!.flexiq.executor.v1.AttachRequest\x1a\".flexiq.executor.v1.AttachResponse(\x01\x30\x01\x12X\n\tHeartbeat\x12$.flexiq.executor.v1.HeartbeatRequest\x1a%.flexiq.executor.v1.HeartbeatResponse\x12O\n\x06Settle\x12!.flexiq.executor.v1.SettleRequest\x1a\".flexiq.executor.v1.SettleResponse\x12^\n\x0b\x45xtendLease\x12&.flexiq.executor.v1.ExtendLeaseRequest\x1a\'.flexiq.executor.v1.ExtendLeaseResponse\x12g\n\x0eReportProgress\x12).flexiq.executor.v1.ReportProgressRequest\x1a*.flexiq.executor.v1.ReportProgressResponse\x12\x61\n\x0cWriteTaskLog\x12\'.flexiq.executor.v1.WriteTaskLogRequest\x1a(.flexiq.executor.v1.WriteTaskLogResponseB\x98\x01\n\x16\x63om.flexiq.executor.v1B\x14\x45xecutorServiceProtoP\x01\xa2\x02\x03\x46\x45X\xaa\x02\x12\x46lexiq.Executor.V1\xca\x02\x12\x46lexiq\\Executor\\V1\xe2\x02\x1e\x46lexiq\\Executor\\V1\\GPBMetadata\xea\x02\x14\x46lexiQ::Executor::V1b\x06proto3"
|
|
12
|
+
|
|
13
|
+
pool = ::Google::Protobuf::DescriptorPool.generated_pool
|
|
14
|
+
pool.add_serialized_file(descriptor_data)
|
|
15
|
+
|
|
16
|
+
module FlexiQ
|
|
17
|
+
module Executor
|
|
18
|
+
module V1
|
|
19
|
+
AttachRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.AttachRequest").msgclass
|
|
20
|
+
AttachResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.AttachResponse").msgclass
|
|
21
|
+
HelloFrame = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.HelloFrame").msgclass
|
|
22
|
+
HelloAckFrame = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.HelloAckFrame").msgclass
|
|
23
|
+
JobFrame = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.JobFrame").msgclass
|
|
24
|
+
JobStepsFrame = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.JobStepsFrame").msgclass
|
|
25
|
+
StepAckFrame = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.StepAckFrame").msgclass
|
|
26
|
+
CancelFrame = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.CancelFrame").msgclass
|
|
27
|
+
ShutdownFrame = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.ShutdownFrame").msgclass
|
|
28
|
+
ProgressFrame = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.ProgressFrame").msgclass
|
|
29
|
+
TaskLogFrame = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.TaskLogFrame").msgclass
|
|
30
|
+
SuccessFrame = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.SuccessFrame").msgclass
|
|
31
|
+
FailureFrame = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.FailureFrame").msgclass
|
|
32
|
+
CancelledFrame = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.CancelledFrame").msgclass
|
|
33
|
+
StepCommitFrame = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.StepCommitFrame").msgclass
|
|
34
|
+
SleptFrame = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.SleptFrame").msgclass
|
|
35
|
+
SettleRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.SettleRequest").msgclass
|
|
36
|
+
SettleResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.SettleResponse").msgclass
|
|
37
|
+
ExtendLeaseRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.ExtendLeaseRequest").msgclass
|
|
38
|
+
ExtendLeaseResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.ExtendLeaseResponse").msgclass
|
|
39
|
+
ReportProgressRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.ReportProgressRequest").msgclass
|
|
40
|
+
ReportProgressResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.ReportProgressResponse").msgclass
|
|
41
|
+
WriteTaskLogRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.WriteTaskLogRequest").msgclass
|
|
42
|
+
WriteTaskLogResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.WriteTaskLogResponse").msgclass
|
|
43
|
+
HeartbeatRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.HeartbeatRequest").msgclass
|
|
44
|
+
HeartbeatResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.HeartbeatResponse").msgclass
|
|
45
|
+
StepKind = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.StepKind").enummodule
|
|
46
|
+
StepFailure = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("flexiq.executor.v1.StepFailure").enummodule
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
2
|
+
# Source: flexiq/executor/v1/executor_service.proto for package 'FlexiQ.Executor.V1'
|
|
3
|
+
# Original file comments:
|
|
4
|
+
# STABLE as of 2.0.0, the release that first shipped the gRPC role. Field
|
|
5
|
+
# numbers, field names and RPC shapes are permanent from here (§2.2 of
|
|
6
|
+
# tasks/specs/2026-09-01-flexiq-v1-proto-design.md): the `proto-breaking`
|
|
7
|
+
# label is for reverting a mistake made in the same release train, never for
|
|
8
|
+
# changing a shipped shape.
|
|
9
|
+
#
|
|
10
|
+
# The rules this package is held to, because a client reads the .proto and not
|
|
11
|
+
# the design document:
|
|
12
|
+
#
|
|
13
|
+
# 1. Field numbers are never reused and never renumbered.
|
|
14
|
+
# 2. Fields are deprecated in place, and on removal both the number and the
|
|
15
|
+
# name are reserved.
|
|
16
|
+
# 3. New RPCs, messages, fields and enum values are additive and never bump
|
|
17
|
+
# the package version.
|
|
18
|
+
# 4. A field's meaning and units are frozen with its number.
|
|
19
|
+
# 5. Readers tolerate what they do not know: unknown fields, unknown enum
|
|
20
|
+
# values, unknown oneof arms.
|
|
21
|
+
#
|
|
22
|
+
# This package is deliberately separate from flexiq.v1. The audiences,
|
|
23
|
+
# credentials and exposure differ: a producer submits work, an executor runs
|
|
24
|
+
# it. flexiq.executor.v1 may import flexiq.v1; the reverse import is forbidden,
|
|
25
|
+
# so the package a third party generates a producer client from stays
|
|
26
|
+
# compilable and reviewable on its own.
|
|
27
|
+
#
|
|
28
|
+
|
|
29
|
+
require 'grpc'
|
|
30
|
+
require 'flexiq/executor/v1/executor_service_pb'
|
|
31
|
+
|
|
32
|
+
module FlexiQ
|
|
33
|
+
module Executor
|
|
34
|
+
module V1
|
|
35
|
+
module ExecutorService
|
|
36
|
+
# The executor door: attach, run what you are given, report what happened.
|
|
37
|
+
#
|
|
38
|
+
# An attached executor holds no database credential. It never reads a job row,
|
|
39
|
+
# never writes a result, and never resolves its own namespace, owner or attempt
|
|
40
|
+
# — the scheduler applies every one of those from the dispatch it recorded.
|
|
41
|
+
# That is what makes this a door an application container may dial out through.
|
|
42
|
+
#
|
|
43
|
+
# The frames below are the worker frame protocol in BINDING_CONTRACT.md, with
|
|
44
|
+
# the same ordering rules and the same meanings. This service is a fourth
|
|
45
|
+
# transport for that protocol, not a second one: an executor attached over gRPC
|
|
46
|
+
# and an executor attached over a socket are the same executor to the
|
|
47
|
+
# scheduler, are placed by the same rules, and appear side by side in the same
|
|
48
|
+
# registry.
|
|
49
|
+
#
|
|
50
|
+
# What this service deliberately does not have:
|
|
51
|
+
#
|
|
52
|
+
# * No HTTP binding, ever. The JSON facade serves flexiq.v1 and nothing else.
|
|
53
|
+
# * No admin surface, and no producer surface. A credential scoped to this
|
|
54
|
+
# package may run work; it may not submit any.
|
|
55
|
+
# * No way to name a namespace, an owner, an attempt or a resource cap.
|
|
56
|
+
# Anything an executor could name is something an executor could forge, so
|
|
57
|
+
# the scheduler resolves all of them itself.
|
|
58
|
+
# * No registry fingerprint on the wire. `hello` carries `tasks` and the
|
|
59
|
+
# scheduler derives the fingerprint from it; a wire field would be a second
|
|
60
|
+
# copy of one fact, free to disagree with the first.
|
|
61
|
+
#
|
|
62
|
+
# One thing a client must do for itself: **raise its own message-size limits.**
|
|
63
|
+
# gRPC libraries default to 4 MiB in each direction, and this door carries the
|
|
64
|
+
# payloads the worker frame protocol allows — up to 64 MiB, plus envelope. An
|
|
65
|
+
# executor that leaves the default in place attaches fine and then fails on its
|
|
66
|
+
# first large job, which is the worst time to find out.
|
|
67
|
+
class Service
|
|
68
|
+
|
|
69
|
+
include ::GRPC::GenericService
|
|
70
|
+
|
|
71
|
+
self.marshal_class_method = :encode
|
|
72
|
+
self.unmarshal_class_method = :decode
|
|
73
|
+
self.service_name = 'flexiq.executor.v1.ExecutorService'
|
|
74
|
+
|
|
75
|
+
# Attach for the lifetime of one stream: `hello` in, `hello_ack` out, then
|
|
76
|
+
# jobs out and results in until either side ends it.
|
|
77
|
+
#
|
|
78
|
+
# **The stream is bounded on purpose.** A stream cannot be load balanced once
|
|
79
|
+
# it has started, so the scheduler ends it on a timer and expects the
|
|
80
|
+
# executor to reconnect. Before it does, it stops matching new work to the
|
|
81
|
+
# stream and waits for what the executor already holds, so a rotation never
|
|
82
|
+
# costs a job. A clean end means "reconnect"; a `shutdown` frame means "stop".
|
|
83
|
+
rpc :Attach, stream(::FlexiQ::Executor::V1::AttachRequest), stream(::FlexiQ::Executor::V1::AttachResponse)
|
|
84
|
+
# Report liveness and current free capacity.
|
|
85
|
+
#
|
|
86
|
+
# Off the dispatch stream on purpose. A heartbeat sharing the stream it is
|
|
87
|
+
# meant to report on cannot distinguish a busy stream from a dead peer, and
|
|
88
|
+
# it is the first thing a stream-per-connection design has to take back out.
|
|
89
|
+
#
|
|
90
|
+
# `session` comes from the `flexiq-attach-session-bin` metadata on the Attach
|
|
91
|
+
# response. It is the scheduler's own value handed back: an executor naming
|
|
92
|
+
# itself here could shrink a peer's advertised capacity by claiming its id.
|
|
93
|
+
#
|
|
94
|
+
# The metadata arrives before the handshake finishes, but this RPC is still
|
|
95
|
+
# ordered behind it: `hello` is the first thing the scheduler reads on a
|
|
96
|
+
# stream, and a heartbeat that overtakes it is read *as* the handshake and
|
|
97
|
+
# refuses the attach. Wait for `hello_ack`.
|
|
98
|
+
rpc :Heartbeat, ::FlexiQ::Executor::V1::HeartbeatRequest, ::FlexiQ::Executor::V1::HeartbeatResponse
|
|
99
|
+
# Report the outcome of a dispatch that outlived the request it arrived on.
|
|
100
|
+
#
|
|
101
|
+
# For a push target, which is handed a job over HTTP and answers `202
|
|
102
|
+
# Accepted` because the work will not finish inside the platform's request
|
|
103
|
+
# deadline. The connection that started the job is gone by the time this is
|
|
104
|
+
# called; the lease inside the frame is what says which dispatch of which
|
|
105
|
+
# attempt is being settled.
|
|
106
|
+
#
|
|
107
|
+
# **Single-use, and fenced.** The scheduler records one settle marker per
|
|
108
|
+
# accepted dispatch and removes it in the same statement that tests it, so
|
|
109
|
+
# exactly one of this RPC, the same RPC on another replica, and the settle
|
|
110
|
+
# deadline passing can win. A call that lost that race is
|
|
111
|
+
# FAILED_PRECONDITION — never ABORTED, which sits in the retry-with-backoff
|
|
112
|
+
# class: a settle that lost its fence must not be resent, because resending
|
|
113
|
+
# it is the double execution the fence exists to refuse.
|
|
114
|
+
rpc :Settle, ::FlexiQ::Executor::V1::SettleRequest, ::FlexiQ::Executor::V1::SettleResponse
|
|
115
|
+
# Ask for longer before the attempt is considered lost.
|
|
116
|
+
#
|
|
117
|
+
# An accepted dispatch is waited on until a deadline, and a target that
|
|
118
|
+
# needs longer than the job's timeout must be able to say so or the job is
|
|
119
|
+
# retried under it. Repeatable: the ceiling is per call, not in total, so a
|
|
120
|
+
# target that needs six hours asks six times and its asking is the liveness
|
|
121
|
+
# signal. A target that stops asking is one the deadline will collect.
|
|
122
|
+
rpc :ExtendLease, ::FlexiQ::Executor::V1::ExtendLeaseRequest, ::FlexiQ::Executor::V1::ExtendLeaseResponse
|
|
123
|
+
# Report progress for an accepted dispatch.
|
|
124
|
+
#
|
|
125
|
+
# The frame the attach stream carries, off the stream — a push target has no
|
|
126
|
+
# stream to put it on. Fire and forget, as it is there: a task that only
|
|
127
|
+
# wanted to report progress must never block on the scheduler to do it, and
|
|
128
|
+
# an empty response says the frame was taken, not that a row was written.
|
|
129
|
+
rpc :ReportProgress, ::FlexiQ::Executor::V1::ReportProgressRequest, ::FlexiQ::Executor::V1::ReportProgressResponse
|
|
130
|
+
# Write one structured log line for an accepted dispatch. As
|
|
131
|
+
# ReportProgress, and with the same fire-and-forget meaning.
|
|
132
|
+
rpc :WriteTaskLog, ::FlexiQ::Executor::V1::WriteTaskLogRequest, ::FlexiQ::Executor::V1::WriteTaskLogResponse
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
Stub = Service.rpc_stub_class
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlexiQ
|
|
4
|
+
module Executor
|
|
5
|
+
# Attaches to a flexiq-server's executor door and runs the jobs it dispatches.
|
|
6
|
+
#
|
|
7
|
+
# worker = FlexiQ::Executor::Worker.new("queue.internal:50051", token: ENV.fetch("FLEXIQ_EXECUTE_TOKEN"))
|
|
8
|
+
# worker.handle("billing.charge") { |job| Receipt.for(job.args.first) }
|
|
9
|
+
# trap("TERM") { worker.stop }
|
|
10
|
+
# worker.run
|
|
11
|
+
class Worker
|
|
12
|
+
# Options (all keywords, all but `token:` optional):
|
|
13
|
+
#
|
|
14
|
+
# token:: bearer token carrying the `execute` scope
|
|
15
|
+
# id:: executor id, unique among attached executors; host and pid by default
|
|
16
|
+
# slots:: jobs run at once; the processor count by default
|
|
17
|
+
# insecure:: plaintext gRPC, for loopback and tests only
|
|
18
|
+
# credentials:: a GRPC::Core::ChannelCredentials; the system roots by default
|
|
19
|
+
# max_message_bytes:: 68 MiB by default, the door's own ceiling
|
|
20
|
+
# user_agent:: prepended to this gem's own
|
|
21
|
+
# handshake_timeout:: seconds to wait for `hello_ack` (10)
|
|
22
|
+
# heartbeat_interval:: seconds between heartbeats (5)
|
|
23
|
+
# shutdown_drain:: seconds running jobs get to finish on a drain (30)
|
|
24
|
+
# backoff_min, backoff_max:: reconnect delay bounds in seconds (0.25, 30)
|
|
25
|
+
# logger:: a Logger; $stderr at INFO by default
|
|
26
|
+
def initialize(target, **)
|
|
27
|
+
@config = Config.new(**)
|
|
28
|
+
@stub = V1::ExecutorService::Stub.new(target, @config.channel_credentials,
|
|
29
|
+
channel_args: @config.channel_args)
|
|
30
|
+
@handlers = {}
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
@wakeup = Queue.new
|
|
33
|
+
@started = false
|
|
34
|
+
@stopping = false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Registers the handler for one task name: a block, or anything responding to `call`. It is
|
|
38
|
+
# given a Job; what it returns is the result, what it raises is the failure.
|
|
39
|
+
def handle(task_name, callable = nil, &block)
|
|
40
|
+
handler = callable || block
|
|
41
|
+
raise ArgumentError, "task name must not be empty" if task_name.to_s.empty?
|
|
42
|
+
raise ArgumentError, "handler for #{task_name.inspect} must respond to call" unless handler.respond_to?(:call)
|
|
43
|
+
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
raise FlexiQ::Error, "cannot register #{task_name.inspect} after run has started" if @started
|
|
46
|
+
raise ArgumentError, "task #{task_name.inspect} is already registered" if @handlers.key?(task_name.to_s)
|
|
47
|
+
|
|
48
|
+
@handlers[task_name.to_s] = handler
|
|
49
|
+
end
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Attaches and serves until the scheduler sends `shutdown` or `stop` is called. A stream
|
|
54
|
+
# the scheduler ends is a rotation, not a failure: it reconnects. Raises AttachError for a
|
|
55
|
+
# refusal that reconnecting cannot fix.
|
|
56
|
+
def run
|
|
57
|
+
handlers = begin_run
|
|
58
|
+
slots = Slots.new(@config.slots)
|
|
59
|
+
backoff = Backoff.new(@config.backoff_min, @config.backoff_max)
|
|
60
|
+
|
|
61
|
+
while (session = next_session(handlers, slots))
|
|
62
|
+
ending = session.run
|
|
63
|
+
backoff.reset if ending.attached
|
|
64
|
+
return if finished?(ending)
|
|
65
|
+
return if retry_after?(ending) && pause(backoff.next_delay)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Drains and ends `run`: no new work, running jobs get `shutdown_drain` seconds, their
|
|
70
|
+
# results are flushed. Returns at once, and is safe to call from a signal handler.
|
|
71
|
+
def stop
|
|
72
|
+
Thread.new do
|
|
73
|
+
session = @mutex.synchronize do
|
|
74
|
+
@stopping = true
|
|
75
|
+
@session
|
|
76
|
+
end
|
|
77
|
+
@wakeup << true
|
|
78
|
+
session&.begin_drain(:stopped)
|
|
79
|
+
end
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def begin_run
|
|
86
|
+
@mutex.synchronize do
|
|
87
|
+
raise FlexiQ::Error, "run has already been called on this worker" if @started
|
|
88
|
+
if @handlers.empty?
|
|
89
|
+
raise FlexiQ::Error, "no handlers registered: an executor advertising no tasks is sent no work"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
@started = true
|
|
93
|
+
@handlers.dup.freeze
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def next_session(handlers, slots)
|
|
98
|
+
@mutex.synchronize do
|
|
99
|
+
@session = @stopping ? nil : Session.new(@config, @stub, handlers, slots)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def finished?(ending)
|
|
104
|
+
case ending.reason
|
|
105
|
+
when :shutdown
|
|
106
|
+
@config.logger.info("the scheduler sent shutdown; stopping")
|
|
107
|
+
true
|
|
108
|
+
when :stopped then true
|
|
109
|
+
else false
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# True when the next attach should wait first. A rotation reconnects at once; a failure,
|
|
114
|
+
# or a stream that ended before it ever attached, backs off.
|
|
115
|
+
def retry_after?(ending)
|
|
116
|
+
if ending.reason == :rotated && ending.attached
|
|
117
|
+
@config.logger.info("the scheduler ended the stream; reconnecting")
|
|
118
|
+
return false
|
|
119
|
+
end
|
|
120
|
+
error = ending.error
|
|
121
|
+
raise error if error&.permanent?
|
|
122
|
+
|
|
123
|
+
@config.logger.warn("attach failed; retrying: #{error&.message || "the stream ended before hello_ack"}")
|
|
124
|
+
true
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Sleeps `delay` seconds; true when `stop` cut the wait short.
|
|
128
|
+
def pause(delay)
|
|
129
|
+
@wakeup.pop(timeout: delay)
|
|
130
|
+
@mutex.synchronize { @stopping }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# FlexiQ executor client over a flexiq-server's executor door (flexiq.executor.v1).
|
|
4
|
+
# The wire contract is contracts/REMOTE_SDK_CONTRACT.md in the FlexiQ repository.
|
|
5
|
+
#
|
|
6
|
+
# An executor cannot enqueue: the door has no enqueue-shaped RPC. A task that fans out goes back
|
|
7
|
+
# through the producer door with the `flexiq` gem and a produce-scoped credential of its own.
|
|
8
|
+
|
|
9
|
+
require "flexiq"
|
|
10
|
+
require "grpc"
|
|
11
|
+
|
|
12
|
+
require_relative "executor/version"
|
|
13
|
+
require_relative "executor/v1/executor_service_services_pb"
|
|
14
|
+
|
|
15
|
+
require_relative "executor/clock"
|
|
16
|
+
require_relative "executor/errors"
|
|
17
|
+
require_relative "executor/config"
|
|
18
|
+
require_relative "executor/job"
|
|
19
|
+
require_relative "executor/outcome"
|
|
20
|
+
require_relative "executor/outbox"
|
|
21
|
+
require_relative "executor/slots"
|
|
22
|
+
require_relative "executor/backoff"
|
|
23
|
+
require_relative "executor/session"
|
|
24
|
+
require_relative "executor/worker"
|