restate-sdk 0.4.3
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/Cargo.lock +1040 -0
- data/Cargo.toml +8 -0
- data/LICENSE +21 -0
- data/README.md +133 -0
- data/ext/restate_internal/Cargo.toml +16 -0
- data/ext/restate_internal/extconf.rb +4 -0
- data/ext/restate_internal/src/lib.rs +1094 -0
- data/lib/restate/context.rb +336 -0
- data/lib/restate/discovery.rb +150 -0
- data/lib/restate/durable_future.rb +131 -0
- data/lib/restate/endpoint.rb +69 -0
- data/lib/restate/errors.rb +60 -0
- data/lib/restate/handler.rb +51 -0
- data/lib/restate/serde.rb +313 -0
- data/lib/restate/server.rb +280 -0
- data/lib/restate/server_context.rb +812 -0
- data/lib/restate/service.rb +37 -0
- data/lib/restate/service_dsl.rb +243 -0
- data/lib/restate/testing.rb +197 -0
- data/lib/restate/version.rb +6 -0
- data/lib/restate/virtual_object.rb +58 -0
- data/lib/restate/vm.rb +325 -0
- data/lib/restate/workflow.rb +57 -0
- data/lib/restate.rb +130 -0
- data/lib/tapioca/dsl/compilers/restate.rb +45 -0
- metadata +127 -0
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'async'
|
|
5
|
+
require 'async/queue'
|
|
6
|
+
require 'logger'
|
|
7
|
+
|
|
8
|
+
module Restate
|
|
9
|
+
# The core execution context for a Restate handler invocation.
|
|
10
|
+
# Implements the progress loop and all context API methods (state, run, sleep, call, send).
|
|
11
|
+
#
|
|
12
|
+
# Concurrency model:
|
|
13
|
+
# - The handler runs inside a Fiber managed by Falcon/Async.
|
|
14
|
+
# - `run` blocks spawn child Async tasks.
|
|
15
|
+
# - When the progress loop needs input, it dequeues from @input_queue, yielding the Fiber.
|
|
16
|
+
# - The HTTP input reader (a separate Async task) feeds chunks into @input_queue.
|
|
17
|
+
# - Output chunks are written directly to the streaming response body.
|
|
18
|
+
class ServerContext
|
|
19
|
+
include WorkflowContext
|
|
20
|
+
include WorkflowSharedContext
|
|
21
|
+
extend T::Sig
|
|
22
|
+
|
|
23
|
+
LOGGER = T.let(Logger.new($stdout, progname: 'Restate::ServerContext'), Logger)
|
|
24
|
+
|
|
25
|
+
sig { returns(VMWrapper) }
|
|
26
|
+
attr_reader :vm
|
|
27
|
+
|
|
28
|
+
sig { returns(T.untyped) }
|
|
29
|
+
attr_reader :invocation
|
|
30
|
+
|
|
31
|
+
sig { params(vm: VMWrapper, handler: T.untyped, invocation: T.untyped, send_output: T.untyped, input_queue: Async::Queue).void }
|
|
32
|
+
def initialize(vm:, handler:, invocation:, send_output:, input_queue:)
|
|
33
|
+
@vm = T.let(vm, VMWrapper)
|
|
34
|
+
@handler = T.let(handler, T.untyped)
|
|
35
|
+
@invocation = T.let(invocation, T.untyped)
|
|
36
|
+
@send_output = T.let(send_output, T.untyped)
|
|
37
|
+
@input_queue = T.let(input_queue, Async::Queue)
|
|
38
|
+
@run_coros_to_execute = T.let({}, T::Hash[Integer, T.untyped])
|
|
39
|
+
@attempt_finished_event = T.let(AttemptFinishedEvent.new, AttemptFinishedEvent)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# ── Main entry point ──
|
|
43
|
+
|
|
44
|
+
# Runs the handler to completion, writing the output (or failure) to the journal.
|
|
45
|
+
sig { void }
|
|
46
|
+
def enter
|
|
47
|
+
Thread.current[:restate_context] = self
|
|
48
|
+
Thread.current[:restate_service_kind] = @handler.service_tag.kind
|
|
49
|
+
Thread.current[:restate_handler_kind] = @handler.kind
|
|
50
|
+
in_buffer = @invocation.input_buffer
|
|
51
|
+
out_buffer = Restate.invoke_handler(handler: @handler, in_buffer: in_buffer)
|
|
52
|
+
@vm.sys_write_output_success(out_buffer.b)
|
|
53
|
+
@vm.sys_end
|
|
54
|
+
rescue TerminalError => e
|
|
55
|
+
failure = Failure.new(code: e.status_code, message: e.message)
|
|
56
|
+
@vm.sys_write_output_failure(failure)
|
|
57
|
+
@vm.sys_end
|
|
58
|
+
rescue SuspendedError, InternalError
|
|
59
|
+
# These are expected internal control flow exceptions; do nothing.
|
|
60
|
+
rescue DisconnectedError
|
|
61
|
+
raise
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
# Walk the cause chain for TerminalError or internal exceptions
|
|
64
|
+
cause = T.let(e, T.nilable(Exception))
|
|
65
|
+
handled = T.let(false, T::Boolean)
|
|
66
|
+
while cause
|
|
67
|
+
if cause.is_a?(TerminalError)
|
|
68
|
+
f = Failure.new(code: cause.status_code, message: cause.message)
|
|
69
|
+
@vm.sys_write_output_failure(f)
|
|
70
|
+
@vm.sys_end
|
|
71
|
+
handled = true
|
|
72
|
+
break
|
|
73
|
+
elsif cause.is_a?(SuspendedError) || cause.is_a?(InternalError)
|
|
74
|
+
handled = true
|
|
75
|
+
break
|
|
76
|
+
end
|
|
77
|
+
cause = cause.cause
|
|
78
|
+
end
|
|
79
|
+
unless handled
|
|
80
|
+
@vm.notify_error(e.inspect, e.backtrace&.join("\n"))
|
|
81
|
+
raise
|
|
82
|
+
end
|
|
83
|
+
ensure
|
|
84
|
+
@run_coros_to_execute.clear
|
|
85
|
+
Thread.current[:restate_context] = nil
|
|
86
|
+
Thread.current[:restate_service_kind] = nil
|
|
87
|
+
Thread.current[:restate_handler_kind] = nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Called by the server when the attempt ends (handler completed, disconnected,
|
|
91
|
+
# or transient error). Signals the attempt_finished_event so that user code
|
|
92
|
+
# and background pool jobs can clean up.
|
|
93
|
+
sig { void }
|
|
94
|
+
def on_attempt_finished
|
|
95
|
+
@attempt_finished_event.set!
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# ── State operations ──
|
|
99
|
+
|
|
100
|
+
# Durably retrieves a state entry by name. Returns nil if unset.
|
|
101
|
+
sig { override.params(name: String, serde: T.untyped).returns(T.untyped) }
|
|
102
|
+
def get(name, serde: JsonSerde)
|
|
103
|
+
get_async(name, serde: serde).await
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Returns a DurableFuture for a state entry. Resolves to nil if unset.
|
|
107
|
+
sig { override.params(name: String, serde: T.untyped).returns(DurableFuture) }
|
|
108
|
+
def get_async(name, serde: JsonSerde)
|
|
109
|
+
handle = @vm.sys_get_state(name)
|
|
110
|
+
DurableFuture.new(self, handle, serde: serde)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Durably sets a state entry. The value is serialized via +serde+.
|
|
114
|
+
sig { override.params(name: String, value: T.untyped, serde: T.untyped).void }
|
|
115
|
+
def set(name, value, serde: JsonSerde)
|
|
116
|
+
@vm.sys_set_state(name, serde.serialize(value).b)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Durably removes a single state entry by name.
|
|
120
|
+
sig { override.params(name: String).void }
|
|
121
|
+
def clear(name)
|
|
122
|
+
@vm.sys_clear_state(name)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Durably removes all state entries for this virtual object or workflow.
|
|
126
|
+
sig { override.void }
|
|
127
|
+
def clear_all
|
|
128
|
+
@vm.sys_clear_all_state
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Returns the list of all state entry names for this virtual object or workflow.
|
|
132
|
+
sig { override.returns(T.untyped) }
|
|
133
|
+
def state_keys
|
|
134
|
+
state_keys_async.await
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Returns a DurableFuture for the list of all state entry names.
|
|
138
|
+
sig { override.returns(DurableFuture) }
|
|
139
|
+
def state_keys_async
|
|
140
|
+
handle = @vm.sys_get_state_keys
|
|
141
|
+
DurableFuture.new(self, handle)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# ── Sleep ──
|
|
145
|
+
|
|
146
|
+
# Returns a durable future that completes after the given duration.
|
|
147
|
+
# The timer survives handler restarts.
|
|
148
|
+
sig { params(seconds: Numeric).returns(DurableFuture) }
|
|
149
|
+
def sleep(seconds)
|
|
150
|
+
millis = (seconds * 1000).to_i
|
|
151
|
+
handle = @vm.sys_sleep(millis)
|
|
152
|
+
DurableFuture.new(self, handle)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Block until a previously created handle completes. Returns the value.
|
|
156
|
+
sig { params(handle: Integer).returns(T.untyped) }
|
|
157
|
+
def resolve_handle(handle)
|
|
158
|
+
poll_and_take(handle)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Wait until any of the given handles completes. Does not take notifications.
|
|
162
|
+
sig { params(handles: T::Array[Integer]).void }
|
|
163
|
+
def wait_any_handle(handles)
|
|
164
|
+
poll_or_cancel(handles) unless handles.any? { |h| @vm.is_completed(h) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Check if a handle is completed (non-blocking).
|
|
168
|
+
sig { params(handle: Integer).returns(T::Boolean) }
|
|
169
|
+
def completed?(handle)
|
|
170
|
+
@vm.is_completed(handle)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Take a completed handle's notification, returning the value.
|
|
174
|
+
# Raises TerminalError if the handle resolved to a failure.
|
|
175
|
+
sig { params(handle: Integer).returns(T.untyped) }
|
|
176
|
+
def take_completed(handle)
|
|
177
|
+
must_take_notification(handle)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Wait until any of the given futures completes. Returns [completed, remaining].
|
|
181
|
+
sig { override.params(futures: DurableFuture).returns([T::Array[DurableFuture], T::Array[DurableFuture]]) }
|
|
182
|
+
def wait_any(*futures)
|
|
183
|
+
handles = futures.map(&:handle)
|
|
184
|
+
wait_any_handle(handles)
|
|
185
|
+
completed = []
|
|
186
|
+
remaining = []
|
|
187
|
+
futures.each do |f|
|
|
188
|
+
if f.completed?
|
|
189
|
+
completed << f
|
|
190
|
+
else
|
|
191
|
+
remaining << f
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
[completed, remaining]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# ── Durable run (side effect) ──
|
|
198
|
+
|
|
199
|
+
# Executes a durable side effect. The block runs at most once; its result is
|
|
200
|
+
# journaled and replayed on retries. Returns a DurableFuture for the result.
|
|
201
|
+
#
|
|
202
|
+
# Pass +background: true+ to run the block in a real OS Thread, keeping the
|
|
203
|
+
# fiber event loop responsive for other concurrent handlers. Use this for
|
|
204
|
+
# CPU-intensive work.
|
|
205
|
+
sig do
|
|
206
|
+
override.params(
|
|
207
|
+
name: String,
|
|
208
|
+
serde: T.untyped,
|
|
209
|
+
retry_policy: T.nilable(RunRetryPolicy),
|
|
210
|
+
background: T::Boolean,
|
|
211
|
+
action: T.proc.returns(T.untyped)
|
|
212
|
+
).returns(DurableFuture)
|
|
213
|
+
end
|
|
214
|
+
def run(name, serde: JsonSerde, retry_policy: nil, background: false, &action)
|
|
215
|
+
handle = @vm.sys_run(name)
|
|
216
|
+
|
|
217
|
+
executor = background ? :execute_run_threaded : :execute_run
|
|
218
|
+
@run_coros_to_execute[handle] = -> { send(executor, handle, action, serde, retry_policy) }
|
|
219
|
+
|
|
220
|
+
DurableFuture.new(self, handle, serde: serde)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Convenience shortcut for +run(...).await+ — executes the durable side effect
|
|
224
|
+
# and returns the result directly.
|
|
225
|
+
#
|
|
226
|
+
# Accepts all the same options as +run+, including +background: true+.
|
|
227
|
+
sig do
|
|
228
|
+
override.params(
|
|
229
|
+
name: String,
|
|
230
|
+
serde: T.untyped,
|
|
231
|
+
retry_policy: T.nilable(RunRetryPolicy),
|
|
232
|
+
background: T::Boolean,
|
|
233
|
+
action: T.proc.returns(T.untyped)
|
|
234
|
+
).returns(T.untyped)
|
|
235
|
+
end
|
|
236
|
+
def run_sync(name, serde: JsonSerde, retry_policy: nil, background: false, &action)
|
|
237
|
+
run(name, serde: serde, retry_policy: retry_policy, background: background, &action).await
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# ── Service calls ──
|
|
241
|
+
|
|
242
|
+
# Durably calls a handler on a Restate service and returns a future for its result.
|
|
243
|
+
sig do
|
|
244
|
+
override.params(
|
|
245
|
+
service: T.any(String, T::Class[T.anything]),
|
|
246
|
+
handler: T.any(String, Symbol),
|
|
247
|
+
arg: T.untyped,
|
|
248
|
+
key: T.nilable(String),
|
|
249
|
+
idempotency_key: T.nilable(String),
|
|
250
|
+
headers: T.nilable(T::Hash[String, String]),
|
|
251
|
+
input_serde: T.untyped,
|
|
252
|
+
output_serde: T.untyped
|
|
253
|
+
).returns(DurableCallFuture)
|
|
254
|
+
end
|
|
255
|
+
def service_call(service, handler, arg, key: nil, idempotency_key: nil, headers: nil,
|
|
256
|
+
input_serde: NOT_SET, output_serde: NOT_SET)
|
|
257
|
+
svc_name, handler_name, handler_meta = resolve_call_target(service, handler)
|
|
258
|
+
in_serde = resolve_serde(input_serde, handler_meta, :input_serde)
|
|
259
|
+
out_serde = resolve_serde(output_serde, handler_meta, :output_serde)
|
|
260
|
+
parameter = in_serde.serialize(arg)
|
|
261
|
+
call_handle = @vm.sys_call(
|
|
262
|
+
service: svc_name, handler: handler_name, parameter: parameter.b,
|
|
263
|
+
key: key, idempotency_key: idempotency_key, headers: headers
|
|
264
|
+
)
|
|
265
|
+
DurableCallFuture.new(self, call_handle.result_handle, call_handle.invocation_id_handle,
|
|
266
|
+
output_serde: out_serde)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Sends a one-way invocation to a Restate service handler (fire-and-forget).
|
|
270
|
+
sig do
|
|
271
|
+
override.params(
|
|
272
|
+
service: T.any(String, T::Class[T.anything]),
|
|
273
|
+
handler: T.any(String, Symbol),
|
|
274
|
+
arg: T.untyped,
|
|
275
|
+
key: T.nilable(String),
|
|
276
|
+
delay: T.nilable(Numeric),
|
|
277
|
+
idempotency_key: T.nilable(String),
|
|
278
|
+
headers: T.nilable(T::Hash[String, String]),
|
|
279
|
+
input_serde: T.untyped
|
|
280
|
+
).returns(SendHandle)
|
|
281
|
+
end
|
|
282
|
+
def service_send(service, handler, arg, key: nil, delay: nil, idempotency_key: nil, headers: nil,
|
|
283
|
+
input_serde: NOT_SET)
|
|
284
|
+
svc_name, handler_name, handler_meta = resolve_call_target(service, handler)
|
|
285
|
+
in_serde = resolve_serde(input_serde, handler_meta, :input_serde)
|
|
286
|
+
parameter = in_serde.serialize(arg)
|
|
287
|
+
delay_ms = delay ? (delay * 1000).to_i : nil
|
|
288
|
+
invocation_id_handle = @vm.sys_send(
|
|
289
|
+
service: svc_name, handler: handler_name, parameter: parameter.b,
|
|
290
|
+
key: key, delay: delay_ms, idempotency_key: idempotency_key, headers: headers
|
|
291
|
+
)
|
|
292
|
+
SendHandle.new(self, invocation_id_handle)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Durably calls a handler on a Restate virtual object, keyed by +key+.
|
|
296
|
+
sig do
|
|
297
|
+
override.params(
|
|
298
|
+
service: T.any(String, T::Class[T.anything]),
|
|
299
|
+
handler: T.any(String, Symbol),
|
|
300
|
+
key: String,
|
|
301
|
+
arg: T.untyped,
|
|
302
|
+
idempotency_key: T.nilable(String),
|
|
303
|
+
headers: T.nilable(T::Hash[String, String]),
|
|
304
|
+
input_serde: T.untyped,
|
|
305
|
+
output_serde: T.untyped
|
|
306
|
+
).returns(DurableCallFuture)
|
|
307
|
+
end
|
|
308
|
+
def object_call(service, handler, key, arg, idempotency_key: nil, headers: nil,
|
|
309
|
+
input_serde: NOT_SET, output_serde: NOT_SET)
|
|
310
|
+
svc_name, handler_name, handler_meta = resolve_call_target(service, handler)
|
|
311
|
+
in_serde = resolve_serde(input_serde, handler_meta, :input_serde)
|
|
312
|
+
out_serde = resolve_serde(output_serde, handler_meta, :output_serde)
|
|
313
|
+
parameter = in_serde.serialize(arg)
|
|
314
|
+
call_handle = @vm.sys_call(
|
|
315
|
+
service: svc_name, handler: handler_name, parameter: parameter.b,
|
|
316
|
+
key: key, idempotency_key: idempotency_key, headers: headers
|
|
317
|
+
)
|
|
318
|
+
DurableCallFuture.new(self, call_handle.result_handle, call_handle.invocation_id_handle,
|
|
319
|
+
output_serde: out_serde)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Sends a one-way invocation to a Restate virtual object handler (fire-and-forget).
|
|
323
|
+
sig do
|
|
324
|
+
override.params(
|
|
325
|
+
service: T.any(String, T::Class[T.anything]),
|
|
326
|
+
handler: T.any(String, Symbol),
|
|
327
|
+
key: String,
|
|
328
|
+
arg: T.untyped,
|
|
329
|
+
delay: T.nilable(Numeric),
|
|
330
|
+
idempotency_key: T.nilable(String),
|
|
331
|
+
headers: T.nilable(T::Hash[String, String]),
|
|
332
|
+
input_serde: T.untyped
|
|
333
|
+
).returns(SendHandle)
|
|
334
|
+
end
|
|
335
|
+
def object_send(service, handler, key, arg, delay: nil, idempotency_key: nil, headers: nil,
|
|
336
|
+
input_serde: NOT_SET)
|
|
337
|
+
svc_name, handler_name, handler_meta = resolve_call_target(service, handler)
|
|
338
|
+
in_serde = resolve_serde(input_serde, handler_meta, :input_serde)
|
|
339
|
+
parameter = in_serde.serialize(arg)
|
|
340
|
+
delay_ms = delay ? (delay * 1000).to_i : nil
|
|
341
|
+
invocation_id_handle = @vm.sys_send(
|
|
342
|
+
service: svc_name, handler: handler_name, parameter: parameter.b,
|
|
343
|
+
key: key, delay: delay_ms, idempotency_key: idempotency_key, headers: headers
|
|
344
|
+
)
|
|
345
|
+
SendHandle.new(self, invocation_id_handle)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Durably calls a handler on a Restate workflow, keyed by +key+.
|
|
349
|
+
sig do
|
|
350
|
+
override.params(
|
|
351
|
+
service: T.any(String, T::Class[T.anything]),
|
|
352
|
+
handler: T.any(String, Symbol),
|
|
353
|
+
key: String,
|
|
354
|
+
arg: T.untyped,
|
|
355
|
+
idempotency_key: T.nilable(String),
|
|
356
|
+
headers: T.nilable(T::Hash[String, String]),
|
|
357
|
+
input_serde: T.untyped,
|
|
358
|
+
output_serde: T.untyped
|
|
359
|
+
).returns(DurableCallFuture)
|
|
360
|
+
end
|
|
361
|
+
def workflow_call(service, handler, key, arg, idempotency_key: nil, headers: nil,
|
|
362
|
+
input_serde: NOT_SET, output_serde: NOT_SET)
|
|
363
|
+
object_call(service, handler, key, arg, idempotency_key: idempotency_key, headers: headers,
|
|
364
|
+
input_serde: input_serde, output_serde: output_serde) # rubocop:disable Layout/HashAlignment
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Sends a one-way invocation to a Restate workflow handler (fire-and-forget).
|
|
368
|
+
sig do
|
|
369
|
+
override.params(
|
|
370
|
+
service: T.any(String, T::Class[T.anything]),
|
|
371
|
+
handler: T.any(String, Symbol),
|
|
372
|
+
key: String,
|
|
373
|
+
arg: T.untyped,
|
|
374
|
+
delay: T.nilable(Numeric),
|
|
375
|
+
idempotency_key: T.nilable(String),
|
|
376
|
+
headers: T.nilable(T::Hash[String, String]),
|
|
377
|
+
input_serde: T.untyped
|
|
378
|
+
).returns(SendHandle)
|
|
379
|
+
end
|
|
380
|
+
def workflow_send(service, handler, key, arg, delay: nil, idempotency_key: nil, headers: nil,
|
|
381
|
+
input_serde: NOT_SET)
|
|
382
|
+
object_send(service, handler, key, arg, delay: delay, idempotency_key: idempotency_key, headers: headers,
|
|
383
|
+
input_serde: input_serde) # rubocop:disable Layout/HashAlignment
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# ── Awakeables ──
|
|
387
|
+
|
|
388
|
+
# Creates an awakeable and returns [awakeable_id, DurableFuture].
|
|
389
|
+
sig { override.params(serde: T.untyped).returns([String, DurableFuture]) }
|
|
390
|
+
def awakeable(serde: JsonSerde)
|
|
391
|
+
id, handle = @vm.sys_awakeable
|
|
392
|
+
[id, DurableFuture.new(self, handle, serde: serde)]
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Resolves an awakeable with a success value.
|
|
396
|
+
sig { override.params(awakeable_id: String, payload: T.untyped, serde: T.untyped).void }
|
|
397
|
+
def resolve_awakeable(awakeable_id, payload, serde: JsonSerde)
|
|
398
|
+
@vm.sys_complete_awakeable_success(awakeable_id, serde.serialize(payload).b)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Rejects an awakeable with a terminal failure.
|
|
402
|
+
sig { override.params(awakeable_id: String, message: String, code: Integer).void }
|
|
403
|
+
def reject_awakeable(awakeable_id, message, code: 500)
|
|
404
|
+
failure = Failure.new(code: code, message: message)
|
|
405
|
+
@vm.sys_complete_awakeable_failure(awakeable_id, failure)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# ── Promises (Workflow API) ──
|
|
409
|
+
|
|
410
|
+
# Gets a durable promise value, blocking until resolved.
|
|
411
|
+
sig { override.params(name: String, serde: T.untyped).returns(T.untyped) }
|
|
412
|
+
def promise(name, serde: JsonSerde)
|
|
413
|
+
handle = @vm.sys_get_promise(name)
|
|
414
|
+
poll_and_take(handle) do |raw|
|
|
415
|
+
raw.nil? ? nil : serde.deserialize(raw)
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Peeks at a durable promise value without blocking. Returns nil if not yet resolved.
|
|
420
|
+
sig { override.params(name: String, serde: T.untyped).returns(T.untyped) }
|
|
421
|
+
def peek_promise(name, serde: JsonSerde)
|
|
422
|
+
handle = @vm.sys_peek_promise(name)
|
|
423
|
+
poll_and_take(handle) do |raw|
|
|
424
|
+
raw.nil? ? nil : serde.deserialize(raw)
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Resolves a durable promise with a success value.
|
|
429
|
+
sig { override.params(name: String, payload: T.untyped, serde: T.untyped).void }
|
|
430
|
+
def resolve_promise(name, payload, serde: JsonSerde)
|
|
431
|
+
handle = @vm.sys_complete_promise_success(name, serde.serialize(payload).b)
|
|
432
|
+
poll_and_take(handle)
|
|
433
|
+
nil
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Rejects a durable promise with a terminal failure.
|
|
437
|
+
sig { override.params(name: String, message: String, code: Integer).void }
|
|
438
|
+
def reject_promise(name, message, code: 500)
|
|
439
|
+
failure = Failure.new(code: code, message: message)
|
|
440
|
+
handle = @vm.sys_complete_promise_failure(name, failure)
|
|
441
|
+
poll_and_take(handle)
|
|
442
|
+
nil
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# ── Cancel invocation ──
|
|
446
|
+
|
|
447
|
+
# Requests cancellation of another invocation by its id.
|
|
448
|
+
sig { override.params(invocation_id: String).void }
|
|
449
|
+
def cancel_invocation(invocation_id)
|
|
450
|
+
@vm.sys_cancel_invocation(invocation_id)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# ── Generic calls (raw bytes, no serde) ──
|
|
454
|
+
|
|
455
|
+
# Durably calls a handler using raw bytes (no serialization). Useful for proxying.
|
|
456
|
+
sig do
|
|
457
|
+
override.params(
|
|
458
|
+
service: String,
|
|
459
|
+
handler: String,
|
|
460
|
+
arg: String,
|
|
461
|
+
key: T.nilable(String),
|
|
462
|
+
idempotency_key: T.nilable(String),
|
|
463
|
+
headers: T.nilable(T::Hash[String, String])
|
|
464
|
+
).returns(DurableCallFuture)
|
|
465
|
+
end
|
|
466
|
+
def generic_call(service, handler, arg, key: nil, idempotency_key: nil, headers: nil)
|
|
467
|
+
call_handle = @vm.sys_call(
|
|
468
|
+
service: service, handler: handler, parameter: arg.b,
|
|
469
|
+
key: key, idempotency_key: idempotency_key, headers: headers
|
|
470
|
+
)
|
|
471
|
+
DurableCallFuture.new(self, call_handle.result_handle, call_handle.invocation_id_handle,
|
|
472
|
+
output_serde: nil)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Sends a one-way invocation using raw bytes (no serialization). Useful for proxying.
|
|
476
|
+
sig do
|
|
477
|
+
override.params(
|
|
478
|
+
service: String,
|
|
479
|
+
handler: String,
|
|
480
|
+
arg: String,
|
|
481
|
+
key: T.nilable(String),
|
|
482
|
+
delay: T.nilable(Numeric),
|
|
483
|
+
idempotency_key: T.nilable(String),
|
|
484
|
+
headers: T.nilable(T::Hash[String, String])
|
|
485
|
+
).returns(SendHandle)
|
|
486
|
+
end
|
|
487
|
+
def generic_send(service, handler, arg, key: nil, delay: nil, idempotency_key: nil, headers: nil)
|
|
488
|
+
delay_ms = delay ? (delay * 1000).to_i : nil
|
|
489
|
+
invocation_id_handle = @vm.sys_send(
|
|
490
|
+
service: service, handler: handler, parameter: arg.b,
|
|
491
|
+
key: key, delay: delay_ms, idempotency_key: idempotency_key, headers: headers
|
|
492
|
+
)
|
|
493
|
+
SendHandle.new(self, invocation_id_handle)
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# ── Request metadata ──
|
|
497
|
+
|
|
498
|
+
# Returns metadata about the current invocation (id, headers, raw body).
|
|
499
|
+
sig { override.returns(T.untyped) }
|
|
500
|
+
def request
|
|
501
|
+
@request ||= Request.new(
|
|
502
|
+
id: @invocation.invocation_id,
|
|
503
|
+
headers: @invocation.headers.to_h,
|
|
504
|
+
body: @invocation.input_buffer,
|
|
505
|
+
attempt_finished_event: @attempt_finished_event
|
|
506
|
+
)
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Returns the key for this virtual object or workflow invocation.
|
|
510
|
+
sig { override.returns(String) }
|
|
511
|
+
def key
|
|
512
|
+
@invocation.key
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
private
|
|
516
|
+
|
|
517
|
+
# ── Progress loop ──
|
|
518
|
+
|
|
519
|
+
# Polls until the given handle(s) complete, then takes the notification.
|
|
520
|
+
sig do
|
|
521
|
+
params(
|
|
522
|
+
handle: Integer,
|
|
523
|
+
block: T.nilable(T.proc.params(arg0: T.untyped).returns(T.untyped))
|
|
524
|
+
).returns(T.untyped)
|
|
525
|
+
end
|
|
526
|
+
def poll_and_take(handle, &block)
|
|
527
|
+
poll_or_cancel([handle]) unless @vm.is_completed(handle)
|
|
528
|
+
must_take_notification(handle, &block)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
sig { params(handles: T::Array[Integer]).void }
|
|
532
|
+
def poll_or_cancel(handles)
|
|
533
|
+
loop do
|
|
534
|
+
flush_output
|
|
535
|
+
response = @vm.do_progress(handles)
|
|
536
|
+
|
|
537
|
+
if response.is_a?(Exception)
|
|
538
|
+
LOGGER.error("Exception in do_progress: #{response}")
|
|
539
|
+
raise InternalError
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
case response
|
|
543
|
+
when Suspended
|
|
544
|
+
raise SuspendedError
|
|
545
|
+
when DoProgressAnyCompleted
|
|
546
|
+
return
|
|
547
|
+
when DoProgressCancelSignalReceived
|
|
548
|
+
raise TerminalError.new('cancelled', status_code: 409)
|
|
549
|
+
when DoProgressExecuteRun
|
|
550
|
+
fn = @run_coros_to_execute.delete(response.handle)
|
|
551
|
+
raise "Missing run coroutine for handle #{response.handle}" unless fn
|
|
552
|
+
|
|
553
|
+
# Spawn child task for the run action
|
|
554
|
+
Async do
|
|
555
|
+
fn.call
|
|
556
|
+
ensure
|
|
557
|
+
@input_queue.enqueue(:run_completed)
|
|
558
|
+
end
|
|
559
|
+
when DoWaitPendingRun, DoProgressReadFromInput
|
|
560
|
+
# Wait for input from the HTTP body reader or a run completion signal
|
|
561
|
+
event = @input_queue.dequeue
|
|
562
|
+
|
|
563
|
+
case event
|
|
564
|
+
when :run_completed
|
|
565
|
+
next
|
|
566
|
+
when :eof
|
|
567
|
+
@vm.notify_input_closed
|
|
568
|
+
when :disconnected
|
|
569
|
+
raise DisconnectedError
|
|
570
|
+
when String
|
|
571
|
+
@vm.notify_input(event)
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
sig do
|
|
578
|
+
params(
|
|
579
|
+
handle: Integer,
|
|
580
|
+
block: T.nilable(T.proc.params(arg0: T.untyped).returns(T.untyped))
|
|
581
|
+
).returns(T.untyped)
|
|
582
|
+
end
|
|
583
|
+
def must_take_notification(handle, &block)
|
|
584
|
+
result = @vm.take_notification(handle)
|
|
585
|
+
|
|
586
|
+
if result.is_a?(Exception)
|
|
587
|
+
flush_output
|
|
588
|
+
LOGGER.error("Exception in take_notification: #{result}")
|
|
589
|
+
raise InternalError
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
case result
|
|
593
|
+
when Suspended
|
|
594
|
+
flush_output
|
|
595
|
+
raise SuspendedError
|
|
596
|
+
when NotReady
|
|
597
|
+
raise "Unexpected NotReady for handle #{handle}"
|
|
598
|
+
when Failure
|
|
599
|
+
raise TerminalError.new(result.message, status_code: result.code)
|
|
600
|
+
else
|
|
601
|
+
block ? yield(result) : result
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
sig { void }
|
|
606
|
+
def flush_output
|
|
607
|
+
loop do
|
|
608
|
+
output = @vm.take_output
|
|
609
|
+
break if output.nil? || output.empty?
|
|
610
|
+
|
|
611
|
+
@send_output.call(output)
|
|
612
|
+
end
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# ── Call target resolution ──
|
|
616
|
+
|
|
617
|
+
# Resolves a service+handler pair from class/symbol or string/string.
|
|
618
|
+
# Returns [service_name, handler_name, handler_metadata_or_nil].
|
|
619
|
+
sig do
|
|
620
|
+
params(
|
|
621
|
+
service: T.any(String, T::Class[T.anything]),
|
|
622
|
+
handler: T.any(String, Symbol)
|
|
623
|
+
).returns([String, String, T.nilable(Handler)])
|
|
624
|
+
end
|
|
625
|
+
def resolve_call_target(service, handler)
|
|
626
|
+
if service.is_a?(Class) && service.respond_to?(:service_name)
|
|
627
|
+
svc_name = T.unsafe(service).service_name
|
|
628
|
+
handler_name = handler.to_s
|
|
629
|
+
handler_meta = service.respond_to?(:handlers) ? T.unsafe(service).handlers[handler_name] : nil
|
|
630
|
+
[svc_name, handler_name, handler_meta]
|
|
631
|
+
else
|
|
632
|
+
[service.to_s, handler.to_s, nil]
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# Resolves a serde value: if the caller passed NOT_SET, fall back to handler metadata, then JsonSerde.
|
|
637
|
+
sig { params(caller_serde: T.untyped, handler_meta: T.nilable(Handler), field: Symbol).returns(T.untyped) }
|
|
638
|
+
def resolve_serde(caller_serde, handler_meta, field)
|
|
639
|
+
return caller_serde unless caller_serde.equal?(NOT_SET)
|
|
640
|
+
|
|
641
|
+
if handler_meta
|
|
642
|
+
handler_meta.handler_io.public_send(field)
|
|
643
|
+
else
|
|
644
|
+
JsonSerde
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
# ── Run execution ──
|
|
649
|
+
|
|
650
|
+
sig do
|
|
651
|
+
params(
|
|
652
|
+
handle: Integer,
|
|
653
|
+
action: T.proc.returns(T.untyped),
|
|
654
|
+
serde: T.untyped,
|
|
655
|
+
retry_policy: T.nilable(RunRetryPolicy)
|
|
656
|
+
).void
|
|
657
|
+
end
|
|
658
|
+
def execute_run(handle, action, serde, retry_policy)
|
|
659
|
+
propose_run_result(handle, action, serde, retry_policy)
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# Like execute_run, but offloads the action to a real OS Thread.
|
|
663
|
+
# The fiber yields (via IO.pipe) while the thread runs, keeping the event loop responsive.
|
|
664
|
+
sig do
|
|
665
|
+
params(
|
|
666
|
+
handle: Integer,
|
|
667
|
+
action: T.proc.returns(T.untyped),
|
|
668
|
+
serde: T.untyped,
|
|
669
|
+
retry_policy: T.nilable(RunRetryPolicy)
|
|
670
|
+
).void
|
|
671
|
+
end
|
|
672
|
+
def execute_run_threaded(handle, action, serde, retry_policy)
|
|
673
|
+
propose_run_result(handle, -> { offload_to_thread(action) }, serde, retry_policy)
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# Runs the action and proposes the result (success/failure/transient) to the VM.
|
|
677
|
+
sig do
|
|
678
|
+
params(
|
|
679
|
+
handle: Integer,
|
|
680
|
+
action: T.proc.returns(T.untyped),
|
|
681
|
+
serde: T.untyped,
|
|
682
|
+
retry_policy: T.nilable(RunRetryPolicy)
|
|
683
|
+
).void
|
|
684
|
+
end
|
|
685
|
+
def propose_run_result(handle, action, serde, retry_policy)
|
|
686
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
687
|
+
begin
|
|
688
|
+
result = action.call
|
|
689
|
+
buffer = serde.serialize(result)
|
|
690
|
+
@vm.propose_run_completion_success(handle, buffer.b)
|
|
691
|
+
rescue TerminalError => e
|
|
692
|
+
failure = Failure.new(code: e.status_code, message: e.message)
|
|
693
|
+
@vm.propose_run_completion_failure(handle, failure)
|
|
694
|
+
rescue SuspendedError, InternalError
|
|
695
|
+
raise
|
|
696
|
+
rescue StandardError => e
|
|
697
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
698
|
+
attempt_duration_ms = (elapsed * 1000).to_i
|
|
699
|
+
failure = Failure.new(
|
|
700
|
+
code: 500,
|
|
701
|
+
message: e.inspect,
|
|
702
|
+
stacktrace: e.backtrace&.join("\n")
|
|
703
|
+
)
|
|
704
|
+
config = RunRetryConfig.new(
|
|
705
|
+
initial_interval: retry_policy&.initial_interval,
|
|
706
|
+
max_attempts: retry_policy&.max_attempts,
|
|
707
|
+
max_duration: retry_policy&.max_duration,
|
|
708
|
+
max_interval: retry_policy&.max_interval,
|
|
709
|
+
interval_factor: retry_policy&.interval_factor
|
|
710
|
+
)
|
|
711
|
+
@vm.propose_run_completion_transient(
|
|
712
|
+
handle,
|
|
713
|
+
failure: failure,
|
|
714
|
+
attempt_duration_ms: attempt_duration_ms,
|
|
715
|
+
config: config
|
|
716
|
+
)
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# Run a block in an OS thread from a shared pool, yielding the current fiber
|
|
721
|
+
# until it completes. Uses IO.pipe to yield the fiber to the Async event loop
|
|
722
|
+
# while the thread does work.
|
|
723
|
+
#
|
|
724
|
+
# The action is wrapped with a cancellation flag so that if the invocation
|
|
725
|
+
# finishes (e.g., suspended, terminal error) before the pool picks up the job,
|
|
726
|
+
# the action is skipped.
|
|
727
|
+
#
|
|
728
|
+
# Note: With Async 2.x and Ruby 3.1+, the Fiber Scheduler already intercepts
|
|
729
|
+
# most blocking I/O (Net::HTTP, TCPSocket, etc.) and yields the fiber
|
|
730
|
+
# automatically. +background: true+ is only needed for CPU-heavy native
|
|
731
|
+
# extensions that release the GVL (e.g., image processing, crypto).
|
|
732
|
+
sig { params(action: T.proc.returns(T.untyped)).returns(T.untyped) }
|
|
733
|
+
def offload_to_thread(action)
|
|
734
|
+
read_io, write_io = IO.pipe
|
|
735
|
+
result = T.let(nil, T.untyped)
|
|
736
|
+
error = T.let(nil, T.nilable(Exception))
|
|
737
|
+
event = @attempt_finished_event
|
|
738
|
+
|
|
739
|
+
begin
|
|
740
|
+
BackgroundPool.submit do
|
|
741
|
+
if event.set?
|
|
742
|
+
# Attempt already finished before pool picked up the job — skip.
|
|
743
|
+
next
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
result = action.call
|
|
747
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
748
|
+
error = e
|
|
749
|
+
ensure
|
|
750
|
+
write_io.close unless write_io.closed?
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
# Yields the fiber in Async context; resumes when the worker closes write_io.
|
|
754
|
+
read_io.read(1)
|
|
755
|
+
read_io.close
|
|
756
|
+
|
|
757
|
+
raise error if error
|
|
758
|
+
|
|
759
|
+
result
|
|
760
|
+
ensure
|
|
761
|
+
read_io.close unless read_io.closed?
|
|
762
|
+
write_io.close unless write_io.closed?
|
|
763
|
+
end
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
# A simple fixed-size thread pool for background: true runs.
|
|
767
|
+
# Avoids creating a new Thread per call (~1ms + ~1MB stack each).
|
|
768
|
+
# Workers are daemon threads that do not prevent process exit.
|
|
769
|
+
module BackgroundPool
|
|
770
|
+
extend T::Sig
|
|
771
|
+
|
|
772
|
+
@queue = T.let(Queue.new, Queue)
|
|
773
|
+
@workers = T.let([], T::Array[Thread])
|
|
774
|
+
@mutex = T.let(Mutex.new, Mutex)
|
|
775
|
+
@size = T.let(0, Integer)
|
|
776
|
+
|
|
777
|
+
POOL_SIZE = T.let(Integer(ENV.fetch('RESTATE_BACKGROUND_POOL_SIZE', 8)), Integer)
|
|
778
|
+
|
|
779
|
+
module_function
|
|
780
|
+
|
|
781
|
+
# Submit a block to be executed by a pool worker.
|
|
782
|
+
sig { params(block: T.proc.void).void }
|
|
783
|
+
def submit(&block)
|
|
784
|
+
ensure_started
|
|
785
|
+
@queue.push(block)
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
sig { void }
|
|
789
|
+
def ensure_started
|
|
790
|
+
return if @size >= POOL_SIZE
|
|
791
|
+
|
|
792
|
+
@mutex.synchronize do
|
|
793
|
+
while @size < POOL_SIZE
|
|
794
|
+
@size += 1
|
|
795
|
+
worker = Thread.new do
|
|
796
|
+
Kernel.loop do
|
|
797
|
+
job = @queue.pop
|
|
798
|
+
break if job == :shutdown
|
|
799
|
+
|
|
800
|
+
job.call
|
|
801
|
+
end
|
|
802
|
+
end
|
|
803
|
+
worker.name = "restate-bg-#{@size}"
|
|
804
|
+
# Daemon thread: does not prevent the process from exiting.
|
|
805
|
+
worker.report_on_exception = false
|
|
806
|
+
@workers << worker
|
|
807
|
+
end
|
|
808
|
+
end
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
end
|
|
812
|
+
end
|