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.
@@ -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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlexiQ
4
+ module Executor
5
+ # Mirrored from the workspace version by scripts/version.mjs; never hand-edit.
6
+ VERSION = "2.1.0"
7
+ end
8
+ 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"