restate-sdk 0.11.0 → 0.13.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 +4 -4
- data/Cargo.lock +1 -1
- data/ext/restate_internal/Cargo.toml +1 -1
- data/ext/restate_internal/src/lib.rs +39 -6
- data/lib/restate/config.rb +11 -0
- data/lib/restate/discovery.rb +1 -1
- data/lib/restate/endpoint.rb +2 -2
- data/lib/restate/errors.rb +4 -2
- data/lib/restate/server/context.rb +680 -0
- data/lib/restate/server/handler.rb +269 -0
- data/lib/restate/server.rb +7 -264
- data/lib/restate/version.rb +1 -1
- data/lib/restate/vm.rb +22 -10
- data/lib/restate.rb +7 -2
- data/sig/restate.rbs +10 -3
- metadata +4 -3
- data/lib/restate/server_context.rb +0 -678
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# typed: ignore
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'async'
|
|
5
|
+
require 'async/queue'
|
|
6
|
+
require 'logger'
|
|
7
|
+
require_relative 'context'
|
|
8
|
+
|
|
9
|
+
module Restate
|
|
10
|
+
module Server
|
|
11
|
+
# Rack-compatible application that handles Restate protocol requests.
|
|
12
|
+
# Designed to work with Falcon for HTTP/2 bidirectional streaming.
|
|
13
|
+
#
|
|
14
|
+
# Routes:
|
|
15
|
+
# GET /discover → service manifest
|
|
16
|
+
# GET /health → health check
|
|
17
|
+
# POST /invoke/:service/:handler → handler invocation
|
|
18
|
+
class Handler
|
|
19
|
+
SDK_VERSION = Internal::SDK_VERSION
|
|
20
|
+
X_RESTATE_SERVER = "restate-sdk-ruby/#{SDK_VERSION}".freeze
|
|
21
|
+
|
|
22
|
+
LOGGER = Logger.new($stdout, progname: 'Restate::Server::Handler')
|
|
23
|
+
|
|
24
|
+
def initialize(endpoint)
|
|
25
|
+
@endpoint = endpoint
|
|
26
|
+
@identity_verifier = Internal::IdentityVerifier.new(endpoint.identity_keys)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Rack interface
|
|
30
|
+
def call(env)
|
|
31
|
+
path = env['PATH_INFO'] || '/'
|
|
32
|
+
parsed = parse_path(path)
|
|
33
|
+
|
|
34
|
+
case parsed[:type]
|
|
35
|
+
when :health
|
|
36
|
+
health_response
|
|
37
|
+
when :discover
|
|
38
|
+
handle_discover(env)
|
|
39
|
+
when :invocation
|
|
40
|
+
handle_invocation(env, parsed[:service], parsed[:handler])
|
|
41
|
+
else
|
|
42
|
+
not_found_response
|
|
43
|
+
end
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
LOGGER.error("Exception in Restate server: #{e.inspect}")
|
|
46
|
+
LOGGER.error(e.backtrace&.join("\n")) if e.backtrace
|
|
47
|
+
error_response(500, 'Internal server error')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def parse_path(path)
|
|
53
|
+
segments = path.split('/').reject(&:empty?)
|
|
54
|
+
|
|
55
|
+
# Check for /invoke/:service/:handler
|
|
56
|
+
if segments.length >= 3
|
|
57
|
+
invoke_idx = segments.rindex('invoke')
|
|
58
|
+
if invoke_idx && segments.length > invoke_idx + 2
|
|
59
|
+
return {
|
|
60
|
+
type: :invocation,
|
|
61
|
+
service: segments[invoke_idx + 1],
|
|
62
|
+
handler: segments[invoke_idx + 2]
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
case segments.last
|
|
68
|
+
when 'health'
|
|
69
|
+
{ type: :health }
|
|
70
|
+
when 'discover'
|
|
71
|
+
{ type: :discover }
|
|
72
|
+
else
|
|
73
|
+
{ type: :unknown }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def health_response
|
|
78
|
+
[200, { 'content-type' => 'application/json', 'x-restate-server' => X_RESTATE_SERVER }, ['{"status":"ok"}']]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def not_found_response
|
|
82
|
+
[404, { 'x-restate-server' => X_RESTATE_SERVER }, ['']]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def error_response(status, message)
|
|
86
|
+
[status, { 'content-type' => 'text/plain', 'x-restate-server' => X_RESTATE_SERVER }, [message]]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def handle_discover(env)
|
|
90
|
+
# Detect HTTP version for protocol mode
|
|
91
|
+
http_version = env['HTTP_VERSION'] || env['SERVER_PROTOCOL'] || 'HTTP/1.1'
|
|
92
|
+
discovered_as = http_version.include?('2') ? 'bidi' : 'request_response'
|
|
93
|
+
|
|
94
|
+
# Negotiate discovery protocol version from Accept header
|
|
95
|
+
accept = env['HTTP_ACCEPT'] || ''
|
|
96
|
+
version = negotiate_version(accept)
|
|
97
|
+
return error_response(415, "Unsupported discovery version: #{accept}") unless version
|
|
98
|
+
|
|
99
|
+
begin
|
|
100
|
+
json = Discovery.compute_discovery_json(@endpoint, version, discovered_as)
|
|
101
|
+
content_type = "application/vnd.restate.endpointmanifest.v#{version}+json"
|
|
102
|
+
[
|
|
103
|
+
200,
|
|
104
|
+
{
|
|
105
|
+
'content-type' => content_type,
|
|
106
|
+
'x-restate-server' => X_RESTATE_SERVER
|
|
107
|
+
},
|
|
108
|
+
[json]
|
|
109
|
+
]
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
error_response(500, "Error computing discovery: #{e.message}")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def negotiate_version(accept)
|
|
116
|
+
if accept.include?('application/vnd.restate.endpointmanifest.v4+json')
|
|
117
|
+
4
|
|
118
|
+
elsif accept.include?('application/vnd.restate.endpointmanifest.v3+json')
|
|
119
|
+
3
|
|
120
|
+
elsif accept.include?('application/vnd.restate.endpointmanifest.v2+json')
|
|
121
|
+
2
|
|
122
|
+
elsif accept.empty?
|
|
123
|
+
2
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def handle_invocation(env, service_name, handler_name)
|
|
128
|
+
# Verify identity
|
|
129
|
+
request_headers = extract_headers(env)
|
|
130
|
+
path = env['PATH_INFO'] || '/'
|
|
131
|
+
begin
|
|
132
|
+
@identity_verifier.verify(request_headers, path)
|
|
133
|
+
rescue Internal::IdentityVerificationError
|
|
134
|
+
return [401, { 'x-restate-server' => X_RESTATE_SERVER }, ['']]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Find the service and handler
|
|
138
|
+
service = @endpoint.services[service_name]
|
|
139
|
+
return not_found_response unless service
|
|
140
|
+
|
|
141
|
+
handler = service.handlers[handler_name]
|
|
142
|
+
return not_found_response unless handler
|
|
143
|
+
|
|
144
|
+
# Process the invocation with streaming
|
|
145
|
+
process_invocation(env, handler, request_headers)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def process_invocation(env, handler, request_headers)
|
|
149
|
+
vm = VMWrapper.new(request_headers)
|
|
150
|
+
status, response_headers = vm.get_response_head
|
|
151
|
+
|
|
152
|
+
# Streaming response body — output chunks are sent to Restate as they're
|
|
153
|
+
# produced. This is critical for BidiStream mode where the VM needs output
|
|
154
|
+
# acknowledged before it can make further progress.
|
|
155
|
+
output_queue = Async::Queue.new
|
|
156
|
+
send_output = ->(chunk) { output_queue.enqueue(chunk) }
|
|
157
|
+
|
|
158
|
+
# Input queue bridges the HTTP body reader and the handler's progress loop.
|
|
159
|
+
input_queue = Async::Queue.new
|
|
160
|
+
|
|
161
|
+
# Read request body chunks and feed to VM until ready to execute,
|
|
162
|
+
# then continue feeding remaining chunks via the input queue.
|
|
163
|
+
rack_input = env['rack.input']
|
|
164
|
+
ready = false
|
|
165
|
+
if rack_input
|
|
166
|
+
# Feed chunks until the VM has enough to start execution
|
|
167
|
+
while (chunk = rack_input.read_partial(16_384))
|
|
168
|
+
vm.notify_input(chunk.b) unless chunk.empty?
|
|
169
|
+
if vm.is_ready_to_execute
|
|
170
|
+
ready = true
|
|
171
|
+
break
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
vm.notify_input_closed unless ready
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
invocation = vm.sys_input
|
|
178
|
+
|
|
179
|
+
# Spawn a background task to continue reading remaining input
|
|
180
|
+
if ready
|
|
181
|
+
Async do
|
|
182
|
+
while (chunk = rack_input.read_partial(16_384))
|
|
183
|
+
input_queue.enqueue(chunk.b) unless chunk.empty?
|
|
184
|
+
end
|
|
185
|
+
input_queue.enqueue(:eof)
|
|
186
|
+
rescue StandardError => e
|
|
187
|
+
LOGGER.error("Input reader error: #{e.inspect}")
|
|
188
|
+
input_queue.enqueue(:disconnected)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
context = Context.new(
|
|
193
|
+
vm: vm,
|
|
194
|
+
handler: handler,
|
|
195
|
+
invocation: invocation,
|
|
196
|
+
send_output: send_output,
|
|
197
|
+
input_queue: input_queue,
|
|
198
|
+
middleware: @endpoint.middleware,
|
|
199
|
+
outbound_middleware: @endpoint.outbound_middleware
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Spawn the handler as an async task so the response body can stream
|
|
203
|
+
# output concurrently.
|
|
204
|
+
Async do
|
|
205
|
+
begin
|
|
206
|
+
context.enter
|
|
207
|
+
rescue DisconnectedError
|
|
208
|
+
# Client disconnected
|
|
209
|
+
rescue StandardError => e
|
|
210
|
+
LOGGER.error("Exception in handler: #{e.inspect}")
|
|
211
|
+
ensure
|
|
212
|
+
# Signal that the attempt is finished — wakes any waiters on
|
|
213
|
+
# ctx.request.attempt_finished_event and cancels pending background pool jobs.
|
|
214
|
+
context.on_attempt_finished
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Drain remaining output from VM
|
|
218
|
+
loop do
|
|
219
|
+
chunk = vm.take_output
|
|
220
|
+
break if chunk.nil? || chunk.empty?
|
|
221
|
+
|
|
222
|
+
output_queue.enqueue(chunk)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Signal end of output
|
|
226
|
+
output_queue.enqueue(nil)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
body = StreamingBody.new(output_queue)
|
|
230
|
+
|
|
231
|
+
merged_headers = response_headers.to_h { |pair| [pair[0], pair[1]] }
|
|
232
|
+
merged_headers['x-restate-server'] = X_RESTATE_SERVER
|
|
233
|
+
|
|
234
|
+
[status, merged_headers, body]
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Rack 3 streaming body that yields chunks from an Async::Queue.
|
|
238
|
+
# Terminates when nil is dequeued.
|
|
239
|
+
class StreamingBody
|
|
240
|
+
def initialize(queue)
|
|
241
|
+
@queue = queue
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def each
|
|
245
|
+
loop do
|
|
246
|
+
chunk = @queue.dequeue
|
|
247
|
+
break if chunk.nil?
|
|
248
|
+
|
|
249
|
+
yield chunk
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def extract_headers(env)
|
|
255
|
+
headers = []
|
|
256
|
+
env.each do |key, value|
|
|
257
|
+
next unless key.start_with?('HTTP_')
|
|
258
|
+
|
|
259
|
+
header_name = key.byteslice(5..).tr('_', '-').downcase!
|
|
260
|
+
headers << [header_name, value]
|
|
261
|
+
end
|
|
262
|
+
# Also include content-type and content-length if present
|
|
263
|
+
headers << ['content-type', env['CONTENT_TYPE']] if env['CONTENT_TYPE']
|
|
264
|
+
headers << ['content-length', env['CONTENT_LENGTH']] if env['CONTENT_LENGTH']
|
|
265
|
+
headers
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
data/lib/restate/server.rb
CHANGED
|
@@ -1,266 +1,9 @@
|
|
|
1
|
-
# typed: ignore
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
4
|
-
require
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
#
|
|
12
|
-
# Routes:
|
|
13
|
-
# GET /discover → service manifest
|
|
14
|
-
# GET /health → health check
|
|
15
|
-
# POST /invoke/:service/:handler → handler invocation
|
|
16
|
-
class Server
|
|
17
|
-
SDK_VERSION = Internal::SDK_VERSION
|
|
18
|
-
X_RESTATE_SERVER = "restate-sdk-ruby/#{SDK_VERSION}".freeze
|
|
19
|
-
|
|
20
|
-
LOGGER = Logger.new($stdout, progname: 'Restate::Server')
|
|
21
|
-
|
|
22
|
-
def initialize(endpoint)
|
|
23
|
-
@endpoint = endpoint
|
|
24
|
-
@identity_verifier = Internal::IdentityVerifier.new(endpoint.identity_keys)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# Rack interface
|
|
28
|
-
def call(env)
|
|
29
|
-
path = env['PATH_INFO'] || '/'
|
|
30
|
-
parsed = parse_path(path)
|
|
31
|
-
|
|
32
|
-
case parsed[:type]
|
|
33
|
-
when :health
|
|
34
|
-
health_response
|
|
35
|
-
when :discover
|
|
36
|
-
handle_discover(env)
|
|
37
|
-
when :invocation
|
|
38
|
-
handle_invocation(env, parsed[:service], parsed[:handler])
|
|
39
|
-
else
|
|
40
|
-
not_found_response
|
|
41
|
-
end
|
|
42
|
-
rescue StandardError => e
|
|
43
|
-
LOGGER.error("Exception in Restate server: #{e.inspect}")
|
|
44
|
-
LOGGER.error(e.backtrace&.join("\n")) if e.backtrace
|
|
45
|
-
error_response(500, 'Internal server error')
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
private
|
|
49
|
-
|
|
50
|
-
def parse_path(path)
|
|
51
|
-
segments = path.split('/').reject(&:empty?)
|
|
52
|
-
|
|
53
|
-
# Check for /invoke/:service/:handler
|
|
54
|
-
if segments.length >= 3
|
|
55
|
-
invoke_idx = segments.rindex('invoke')
|
|
56
|
-
if invoke_idx && segments.length > invoke_idx + 2
|
|
57
|
-
return {
|
|
58
|
-
type: :invocation,
|
|
59
|
-
service: segments[invoke_idx + 1],
|
|
60
|
-
handler: segments[invoke_idx + 2]
|
|
61
|
-
}
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
case segments.last
|
|
66
|
-
when 'health'
|
|
67
|
-
{ type: :health }
|
|
68
|
-
when 'discover'
|
|
69
|
-
{ type: :discover }
|
|
70
|
-
else
|
|
71
|
-
{ type: :unknown }
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def health_response
|
|
76
|
-
[200, { 'content-type' => 'application/json', 'x-restate-server' => X_RESTATE_SERVER }, ['{"status":"ok"}']]
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def not_found_response
|
|
80
|
-
[404, { 'x-restate-server' => X_RESTATE_SERVER }, ['']]
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def error_response(status, message)
|
|
84
|
-
[status, { 'content-type' => 'text/plain', 'x-restate-server' => X_RESTATE_SERVER }, [message]]
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def handle_discover(env)
|
|
88
|
-
# Detect HTTP version for protocol mode
|
|
89
|
-
http_version = env['HTTP_VERSION'] || env['SERVER_PROTOCOL'] || 'HTTP/1.1'
|
|
90
|
-
discovered_as = http_version.include?('2') ? 'bidi' : 'request_response'
|
|
91
|
-
|
|
92
|
-
# Negotiate discovery protocol version from Accept header
|
|
93
|
-
accept = env['HTTP_ACCEPT'] || ''
|
|
94
|
-
version = negotiate_version(accept)
|
|
95
|
-
return error_response(415, "Unsupported discovery version: #{accept}") unless version
|
|
96
|
-
|
|
97
|
-
begin
|
|
98
|
-
json = Discovery.compute_discovery_json(@endpoint, version, discovered_as)
|
|
99
|
-
content_type = "application/vnd.restate.endpointmanifest.v#{version}+json"
|
|
100
|
-
[
|
|
101
|
-
200,
|
|
102
|
-
{
|
|
103
|
-
'content-type' => content_type,
|
|
104
|
-
'x-restate-server' => X_RESTATE_SERVER
|
|
105
|
-
},
|
|
106
|
-
[json]
|
|
107
|
-
]
|
|
108
|
-
rescue StandardError => e
|
|
109
|
-
error_response(500, "Error computing discovery: #{e.message}")
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def negotiate_version(accept)
|
|
114
|
-
if accept.include?('application/vnd.restate.endpointmanifest.v4+json')
|
|
115
|
-
4
|
|
116
|
-
elsif accept.include?('application/vnd.restate.endpointmanifest.v3+json')
|
|
117
|
-
3
|
|
118
|
-
elsif accept.include?('application/vnd.restate.endpointmanifest.v2+json')
|
|
119
|
-
2
|
|
120
|
-
elsif accept.empty?
|
|
121
|
-
2
|
|
122
|
-
end
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def handle_invocation(env, service_name, handler_name)
|
|
126
|
-
# Verify identity
|
|
127
|
-
request_headers = extract_headers(env)
|
|
128
|
-
path = env['PATH_INFO'] || '/'
|
|
129
|
-
begin
|
|
130
|
-
@identity_verifier.verify(request_headers, path)
|
|
131
|
-
rescue Internal::IdentityVerificationError
|
|
132
|
-
return [401, { 'x-restate-server' => X_RESTATE_SERVER }, ['']]
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
# Find the service and handler
|
|
136
|
-
service = @endpoint.services[service_name]
|
|
137
|
-
return not_found_response unless service
|
|
138
|
-
|
|
139
|
-
handler = service.handlers[handler_name]
|
|
140
|
-
return not_found_response unless handler
|
|
141
|
-
|
|
142
|
-
# Process the invocation with streaming
|
|
143
|
-
process_invocation(env, handler, request_headers)
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def process_invocation(env, handler, request_headers)
|
|
147
|
-
vm = VMWrapper.new(request_headers)
|
|
148
|
-
status, response_headers = vm.get_response_head
|
|
149
|
-
|
|
150
|
-
# Streaming response body — output chunks are sent to Restate as they're
|
|
151
|
-
# produced. This is critical for BidiStream mode where the VM needs output
|
|
152
|
-
# acknowledged before it can make further progress.
|
|
153
|
-
output_queue = Async::Queue.new
|
|
154
|
-
send_output = ->(chunk) { output_queue.enqueue(chunk) }
|
|
155
|
-
|
|
156
|
-
# Input queue bridges the HTTP body reader and the handler's progress loop.
|
|
157
|
-
input_queue = Async::Queue.new
|
|
158
|
-
|
|
159
|
-
# Read request body chunks and feed to VM until ready to execute,
|
|
160
|
-
# then continue feeding remaining chunks via the input queue.
|
|
161
|
-
rack_input = env['rack.input']
|
|
162
|
-
ready = false
|
|
163
|
-
if rack_input
|
|
164
|
-
# Feed chunks until the VM has enough to start execution
|
|
165
|
-
while (chunk = rack_input.read_partial(16_384))
|
|
166
|
-
vm.notify_input(chunk.b) unless chunk.empty?
|
|
167
|
-
if vm.is_ready_to_execute
|
|
168
|
-
ready = true
|
|
169
|
-
break
|
|
170
|
-
end
|
|
171
|
-
end
|
|
172
|
-
vm.notify_input_closed unless ready
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
invocation = vm.sys_input
|
|
176
|
-
|
|
177
|
-
# Spawn a background task to continue reading remaining input
|
|
178
|
-
if ready
|
|
179
|
-
Async do
|
|
180
|
-
while (chunk = rack_input.read_partial(16_384))
|
|
181
|
-
input_queue.enqueue(chunk.b) unless chunk.empty?
|
|
182
|
-
end
|
|
183
|
-
input_queue.enqueue(:eof)
|
|
184
|
-
rescue StandardError => e
|
|
185
|
-
LOGGER.error("Input reader error: #{e.inspect}")
|
|
186
|
-
input_queue.enqueue(:disconnected)
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
context = ServerContext.new(
|
|
191
|
-
vm: vm,
|
|
192
|
-
handler: handler,
|
|
193
|
-
invocation: invocation,
|
|
194
|
-
send_output: send_output,
|
|
195
|
-
input_queue: input_queue,
|
|
196
|
-
middleware: @endpoint.middleware,
|
|
197
|
-
outbound_middleware: @endpoint.outbound_middleware
|
|
198
|
-
)
|
|
199
|
-
|
|
200
|
-
# Spawn the handler as an async task so the response body can stream
|
|
201
|
-
# output concurrently.
|
|
202
|
-
Async do
|
|
203
|
-
begin
|
|
204
|
-
context.enter
|
|
205
|
-
rescue DisconnectedError
|
|
206
|
-
# Client disconnected
|
|
207
|
-
rescue StandardError => e
|
|
208
|
-
LOGGER.error("Exception in handler: #{e.inspect}")
|
|
209
|
-
ensure
|
|
210
|
-
# Signal that the attempt is finished — wakes any waiters on
|
|
211
|
-
# ctx.request.attempt_finished_event and cancels pending background pool jobs.
|
|
212
|
-
context.on_attempt_finished
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
# Drain remaining output from VM
|
|
216
|
-
loop do
|
|
217
|
-
chunk = vm.take_output
|
|
218
|
-
break if chunk.nil? || chunk.empty?
|
|
219
|
-
|
|
220
|
-
output_queue.enqueue(chunk)
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
# Signal end of output
|
|
224
|
-
output_queue.enqueue(nil)
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
body = StreamingBody.new(output_queue)
|
|
228
|
-
|
|
229
|
-
merged_headers = response_headers.to_h { |pair| [pair[0], pair[1]] }
|
|
230
|
-
merged_headers['x-restate-server'] = X_RESTATE_SERVER
|
|
231
|
-
|
|
232
|
-
[status, merged_headers, body]
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
# Rack 3 streaming body that yields chunks from an Async::Queue.
|
|
236
|
-
# Terminates when nil is dequeued.
|
|
237
|
-
class StreamingBody
|
|
238
|
-
def initialize(queue)
|
|
239
|
-
@queue = queue
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
def each
|
|
243
|
-
loop do
|
|
244
|
-
chunk = @queue.dequeue
|
|
245
|
-
break if chunk.nil?
|
|
246
|
-
|
|
247
|
-
yield chunk
|
|
248
|
-
end
|
|
249
|
-
end
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
def extract_headers(env)
|
|
253
|
-
headers = []
|
|
254
|
-
env.each do |key, value|
|
|
255
|
-
next unless key.start_with?('HTTP_')
|
|
256
|
-
|
|
257
|
-
header_name = key.byteslice(5..).tr('_', '-').downcase!
|
|
258
|
-
headers << [header_name, value]
|
|
259
|
-
end
|
|
260
|
-
# Also include content-type and content-length if present
|
|
261
|
-
headers << ['content-type', env['CONTENT_TYPE']] if env['CONTENT_TYPE']
|
|
262
|
-
headers << ['content-length', env['CONTENT_LENGTH']] if env['CONTENT_LENGTH']
|
|
263
|
-
headers
|
|
264
|
-
end
|
|
265
|
-
end
|
|
266
|
-
end
|
|
3
|
+
# Convenience entry point: `require "restate/server"` loads the core SDK
|
|
4
|
+
# plus the server module (Rack app + async execution context).
|
|
5
|
+
#
|
|
6
|
+
# Use this when you want both core and server loaded eagerly, e.g.:
|
|
7
|
+
# gem "restate-sdk", require: "restate/server"
|
|
8
|
+
require_relative '../restate'
|
|
9
|
+
require_relative 'server/handler'
|
data/lib/restate/version.rb
CHANGED
data/lib/restate/vm.rb
CHANGED
|
@@ -7,14 +7,19 @@ begin
|
|
|
7
7
|
RUBY_VERSION =~ /(\d+\.\d+)/
|
|
8
8
|
require_relative "#{Regexp.last_match(1)}/restate_internal"
|
|
9
9
|
rescue LoadError
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
begin
|
|
11
|
+
# rake compile output (ext.lib_dir = 'lib/restate' in Rakefile)
|
|
12
|
+
require_relative 'restate_internal'
|
|
13
|
+
rescue LoadError
|
|
14
|
+
# gem install from source: extconf.rb builds to lib/restate_internal
|
|
15
|
+
require_relative '../restate_internal'
|
|
16
|
+
end
|
|
12
17
|
end
|
|
13
18
|
|
|
14
19
|
module Restate
|
|
15
20
|
# Ruby-side data types for VM results
|
|
16
21
|
Invocation = Struct.new(:invocation_id, :random_seed, :headers, :input_buffer, :key, keyword_init: true)
|
|
17
|
-
Failure = Struct.new(:code, :message, :stacktrace, keyword_init: true)
|
|
22
|
+
Failure = Struct.new(:code, :message, :stacktrace, :metadata, keyword_init: true)
|
|
18
23
|
|
|
19
24
|
class NotReady; end
|
|
20
25
|
class Suspended; end
|
|
@@ -157,12 +162,12 @@ module Restate
|
|
|
157
162
|
end
|
|
158
163
|
|
|
159
164
|
def propose_run_completion_failure(handle, failure)
|
|
160
|
-
native_failure = Internal::Failure.new(failure.code, failure.message, nil)
|
|
165
|
+
native_failure = Internal::Failure.new(failure.code, failure.message, nil, native_metadata(failure))
|
|
161
166
|
@vm.propose_run_completion_failure(handle, native_failure)
|
|
162
167
|
end
|
|
163
168
|
|
|
164
169
|
def propose_run_completion_transient(handle, failure:, attempt_duration_ms:, config:)
|
|
165
|
-
native_failure = Internal::Failure.new(failure.code, failure.message, failure.stacktrace)
|
|
170
|
+
native_failure = Internal::Failure.new(failure.code, failure.message, failure.stacktrace, nil)
|
|
166
171
|
native_config = Internal::ExponentialRetryConfig.new(
|
|
167
172
|
config.initial_interval, config.max_attempts,
|
|
168
173
|
config.max_duration, config.max_interval,
|
|
@@ -176,7 +181,7 @@ module Restate
|
|
|
176
181
|
end
|
|
177
182
|
|
|
178
183
|
def sys_write_output_failure(failure)
|
|
179
|
-
native_failure = Internal::Failure.new(failure.code, failure.message, nil)
|
|
184
|
+
native_failure = Internal::Failure.new(failure.code, failure.message, nil, native_metadata(failure))
|
|
180
185
|
@vm.sys_write_output_failure(native_failure)
|
|
181
186
|
end
|
|
182
187
|
|
|
@@ -198,7 +203,7 @@ module Restate
|
|
|
198
203
|
end
|
|
199
204
|
|
|
200
205
|
def sys_complete_awakeable_failure(awakeable_id, failure)
|
|
201
|
-
native_failure = Internal::Failure.new(failure.code, failure.message, nil)
|
|
206
|
+
native_failure = Internal::Failure.new(failure.code, failure.message, nil, native_metadata(failure))
|
|
202
207
|
@vm.sys_complete_awakeable_failure(awakeable_id, native_failure)
|
|
203
208
|
end
|
|
204
209
|
|
|
@@ -215,7 +220,7 @@ module Restate
|
|
|
215
220
|
end
|
|
216
221
|
|
|
217
222
|
def sys_complete_promise_failure(key, failure)
|
|
218
|
-
native_failure = Internal::Failure.new(failure.code, failure.message, nil)
|
|
223
|
+
native_failure = Internal::Failure.new(failure.code, failure.message, nil, native_metadata(failure))
|
|
219
224
|
@vm.sys_complete_promise_failure(key, native_failure)
|
|
220
225
|
end
|
|
221
226
|
|
|
@@ -232,12 +237,19 @@ module Restate
|
|
|
232
237
|
end
|
|
233
238
|
|
|
234
239
|
def sys_complete_signal_failure(invocation_id, name, failure)
|
|
235
|
-
native_failure = Internal::Failure.new(failure.code, failure.message, nil)
|
|
240
|
+
native_failure = Internal::Failure.new(failure.code, failure.message, nil, native_metadata(failure))
|
|
236
241
|
@vm.sys_complete_signal_failure(invocation_id, name, native_failure)
|
|
237
242
|
end
|
|
238
243
|
|
|
239
244
|
private
|
|
240
245
|
|
|
246
|
+
def native_metadata(failure)
|
|
247
|
+
md = failure.metadata
|
|
248
|
+
return nil if md.nil? || md.empty?
|
|
249
|
+
|
|
250
|
+
md.map { |k, v| [k.to_s, v.to_s] }
|
|
251
|
+
end
|
|
252
|
+
|
|
241
253
|
def map_do_progress(result)
|
|
242
254
|
case result
|
|
243
255
|
when Internal::Suspended
|
|
@@ -270,7 +282,7 @@ module Restate
|
|
|
270
282
|
# The native layer returns RString for both.
|
|
271
283
|
result
|
|
272
284
|
when Internal::Failure
|
|
273
|
-
Failure.new(code: result.code, message: result.message)
|
|
285
|
+
Failure.new(code: result.code, message: result.message, metadata: result.metadata.to_h)
|
|
274
286
|
when Internal::StateKeys
|
|
275
287
|
result.keys
|
|
276
288
|
else
|
data/lib/restate.rb
CHANGED
|
@@ -11,7 +11,6 @@ require_relative 'restate/service_dsl'
|
|
|
11
11
|
require_relative 'restate/service'
|
|
12
12
|
require_relative 'restate/virtual_object'
|
|
13
13
|
require_relative 'restate/workflow'
|
|
14
|
-
require_relative 'restate/server_context'
|
|
15
14
|
require_relative 'restate/durable_future'
|
|
16
15
|
require_relative 'restate/discovery'
|
|
17
16
|
require_relative 'restate/endpoint'
|
|
@@ -77,7 +76,13 @@ module Restate # rubocop:disable Metrics/ModuleLength
|
|
|
77
76
|
def client
|
|
78
77
|
cfg = config
|
|
79
78
|
Client.new(ingress_url: cfg.ingress_url, admin_url: cfg.admin_url,
|
|
80
|
-
ingress_headers: cfg.ingress_headers,
|
|
79
|
+
ingress_headers: resolve_headers(cfg.ingress_headers),
|
|
80
|
+
admin_headers: resolve_headers(cfg.admin_headers))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @!visibility private
|
|
84
|
+
def resolve_headers(headers)
|
|
85
|
+
headers.respond_to?(:call) ? headers.call : headers
|
|
81
86
|
end
|
|
82
87
|
|
|
83
88
|
# ── Context accessor (internal) ──
|
data/sig/restate.rbs
CHANGED
|
@@ -69,7 +69,8 @@ module Restate
|
|
|
69
69
|
|
|
70
70
|
class TerminalError < StandardError
|
|
71
71
|
attr_reader status_code: Integer
|
|
72
|
-
|
|
72
|
+
attr_reader metadata: Hash[String, String]?
|
|
73
|
+
def initialize: (?String message, ?status_code: Integer, ?metadata: Hash[String, String]?) -> void
|
|
73
74
|
end
|
|
74
75
|
|
|
75
76
|
class SuspendedError < StandardError
|
|
@@ -185,8 +186,14 @@ module Restate
|
|
|
185
186
|
|
|
186
187
|
# ── Internal ──
|
|
187
188
|
|
|
188
|
-
|
|
189
|
-
|
|
189
|
+
module Server
|
|
190
|
+
class Handler
|
|
191
|
+
def initialize: (untyped endpoint) -> void
|
|
192
|
+
def call: (Hash[String, untyped] env) -> [Integer, Hash[String, String], untyped]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
class Context
|
|
196
|
+
end
|
|
190
197
|
end
|
|
191
198
|
|
|
192
199
|
module JsonSerde
|