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.
@@ -0,0 +1,60 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Restate
5
+ # Raised to indicate a non-retryable failure. Restate will not retry the invocation.
6
+ #
7
+ # @example
8
+ # raise Restate::TerminalError.new('not found', status_code: 404)
9
+ class TerminalError < StandardError
10
+ extend T::Sig
11
+
12
+ sig { returns(Integer) }
13
+ attr_reader :status_code
14
+
15
+ sig { params(message: String, status_code: Integer).void }
16
+ def initialize(message = 'Internal Server Error', status_code: 500)
17
+ super(message)
18
+ @status_code = T.let(status_code, Integer)
19
+ end
20
+ end
21
+
22
+ # Internal: raised when the VM suspends execution.
23
+ # User code should NOT catch this.
24
+ class SuspendedError < StandardError
25
+ extend T::Sig
26
+
27
+ sig { void }
28
+ def initialize
29
+ super(
30
+ "Invocation got suspended, Restate will resume this invocation when progress can be made.\n" \
31
+ "This exception is safe to ignore. If you see it, you might be using a bare rescue.\n\n" \
32
+ "Don't do:\nbegin\n # Code\nrescue => e\n # This catches SuspendedError!\nend\n\n" \
33
+ "Do instead:\nbegin\n # Code\nrescue Restate::TerminalError => e\n # Handle terminal errors\nend"
34
+ )
35
+ end
36
+ end
37
+
38
+ # Internal: raised when the VM encounters a retryable error.
39
+ class InternalError < StandardError
40
+ extend T::Sig
41
+
42
+ sig { void }
43
+ def initialize
44
+ super(
45
+ "Invocation attempt raised a retryable error.\n" \
46
+ 'Restate will retry executing this invocation from the point where it left off.'
47
+ )
48
+ end
49
+ end
50
+
51
+ # Internal: raised when the HTTP connection is lost.
52
+ class DisconnectedError < StandardError
53
+ extend T::Sig
54
+
55
+ sig { void }
56
+ def initialize
57
+ super('Disconnected. The connection to the restate server was lost. Restate will retry the attempt.')
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,51 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Restate
5
+ # Identifies which service a handler belongs to.
6
+ ServiceTag = Struct.new(:kind, :name, :description, :metadata, keyword_init: true)
7
+
8
+ # Describes the input/output serialization for a handler.
9
+ # Schema is accessed via the serde's json_schema method.
10
+ HandlerIO = Struct.new(:accept, :content_type, :input_serde, :output_serde, keyword_init: true) do
11
+ def initialize(accept: 'application/json', content_type: 'application/json',
12
+ input_serde: JsonSerde, output_serde: JsonSerde)
13
+ super
14
+ end
15
+ end
16
+
17
+ # A registered handler with its metadata and callable block.
18
+ Handler = Struct.new(
19
+ :service_tag, :handler_io, :kind, :name, :callable, :arity,
20
+ :enable_lazy_state,
21
+ :description, :metadata,
22
+ :inactivity_timeout, :abort_timeout,
23
+ :journal_retention, :idempotency_retention,
24
+ :workflow_completion_retention,
25
+ :ingress_private,
26
+ :invocation_retry_policy,
27
+ keyword_init: true
28
+ )
29
+
30
+ extend T::Sig
31
+
32
+ module_function
33
+
34
+ # Invoke a handler with raw input bytes. The context is available via
35
+ # fiber-local Restate.current_context (set by ServerContext#enter).
36
+ # Returns raw output bytes.
37
+ sig { params(handler: T.untyped, in_buffer: String).returns(String) }
38
+ def invoke_handler(handler:, in_buffer:)
39
+ if handler.arity == 1
40
+ begin
41
+ in_arg = handler.handler_io.input_serde.deserialize(in_buffer)
42
+ rescue StandardError => e
43
+ Kernel.raise TerminalError, "Unable to parse input argument: #{e.message}"
44
+ end
45
+ out_arg = handler.callable.call(in_arg)
46
+ else
47
+ out_arg = handler.callable.call
48
+ end
49
+ handler.handler_io.output_serde.serialize(out_arg)
50
+ end
51
+ end
@@ -0,0 +1,313 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+
6
+ module Restate
7
+ # JSON serializer/deserializer (default).
8
+ # Converts Ruby objects to JSON byte strings and back.
9
+ module JsonSerde
10
+ extend T::Sig
11
+
12
+ module_function
13
+
14
+ # Serialize a Ruby object to a JSON byte string. Returns empty bytes for nil.
15
+ sig { params(obj: T.untyped).returns(String) }
16
+ def serialize(obj)
17
+ return ''.b if obj.nil?
18
+
19
+ JSON.generate(obj).b
20
+ end
21
+
22
+ # Deserialize a JSON byte string to a Ruby object. Returns nil for nil or empty input.
23
+ sig { params(buf: T.nilable(String)).returns(T.untyped) }
24
+ def deserialize(buf)
25
+ return nil if buf.nil? || buf.empty?
26
+
27
+ JSON.parse(buf, symbolize_names: false)
28
+ end
29
+
30
+ # Return the JSON Schema for this serde, or nil if unspecified.
31
+ sig { returns(T.nilable(T::Hash[String, T.untyped])) }
32
+ def json_schema
33
+ nil
34
+ end
35
+ end
36
+
37
+ # Sentinel value to distinguish "caller didn't pass serde" from an explicit value.
38
+ NOT_SET = Object.new.freeze
39
+
40
+ # Pass-through bytes serializer/deserializer.
41
+ # Passes binary data through without transformation.
42
+ module BytesSerde
43
+ extend T::Sig
44
+
45
+ module_function
46
+
47
+ # Serialize an object by returning its binary encoding. Returns empty bytes for nil.
48
+ sig { params(obj: T.untyped).returns(String) }
49
+ def serialize(obj)
50
+ return ''.b if obj.nil?
51
+
52
+ obj.b
53
+ end
54
+
55
+ # Deserialize by returning the raw buffer unchanged.
56
+ sig { params(buf: T.nilable(String)).returns(T.nilable(String)) }
57
+ def deserialize(buf)
58
+ buf
59
+ end
60
+
61
+ # Return the JSON Schema for this serde, or nil if unspecified.
62
+ sig { returns(T.nilable(T::Hash[String, T.untyped])) }
63
+ def json_schema
64
+ nil
65
+ end
66
+ end
67
+
68
+ # Maps Ruby primitive types to JSON Schema snippets for discovery.
69
+ PRIMITIVE_SCHEMAS = {
70
+ String => { 'type' => 'string' },
71
+ Integer => { 'type' => 'integer' },
72
+ Float => { 'type' => 'number' },
73
+ TrueClass => { 'type' => 'boolean' },
74
+ FalseClass => { 'type' => 'boolean' },
75
+ Array => { 'type' => 'array' },
76
+ Hash => { 'type' => 'object' },
77
+ NilClass => { 'type' => 'null' }
78
+ }.freeze
79
+
80
+ # Serde resolution utilities: converts a type or serde into a serde object.
81
+ module Serde
82
+ extend T::Sig
83
+
84
+ module_function
85
+
86
+ # Check if an object quacks like a serde (has serialize + deserialize).
87
+ sig { params(obj: T.untyped).returns(T::Boolean) }
88
+ def serde?(obj)
89
+ obj.respond_to?(:serialize) && obj.respond_to?(:deserialize)
90
+ end
91
+
92
+ # Resolve a type or serde into a serde object with serialize/deserialize/json_schema.
93
+ sig { params(type_or_serde: T.untyped).returns(T.untyped) }
94
+ def resolve(type_or_serde)
95
+ return JsonSerde if type_or_serde.nil?
96
+ return type_or_serde if serde?(type_or_serde)
97
+ return TStructSerde.new(type_or_serde) if t_struct?(type_or_serde)
98
+ return DryStructSerde.new(type_or_serde) if dry_struct?(type_or_serde)
99
+ return TypeSerde.new(type_or_serde, PRIMITIVE_SCHEMAS[type_or_serde]) if PRIMITIVE_SCHEMAS.key?(type_or_serde)
100
+ return TypeSerde.new(type_or_serde, type_or_serde.json_schema) if type_or_serde.respond_to?(:json_schema)
101
+
102
+ JsonSerde
103
+ end
104
+
105
+ # Check if a value is a T::Struct subclass.
106
+ sig { params(val: T.untyped).returns(T::Boolean) }
107
+ def t_struct?(val)
108
+ !!(val.is_a?(Class) && val < T::Struct)
109
+ end
110
+
111
+ # Check if a value is a Dry::Struct subclass.
112
+ sig { params(val: T.untyped).returns(T.nilable(T::Boolean)) }
113
+ def dry_struct?(val)
114
+ defined?(::Dry::Struct) && val.is_a?(Class) && val < ::Dry::Struct
115
+ end
116
+
117
+ # Generate a JSON Schema from a T::Struct class by introspecting its props.
118
+ sig { params(struct_class: T.class_of(T::Struct)).returns(T::Hash[String, T.untyped]) }
119
+ def t_struct_to_json_schema(struct_class) # rubocop:disable Metrics
120
+ properties = {}
121
+ required = []
122
+
123
+ T.unsafe(struct_class).props.each do |name, meta|
124
+ prop_name = (meta[:serialized_form] || name).to_s
125
+ properties[prop_name] = t_type_to_json_schema(meta[:type_object] || meta[:type])
126
+ required << prop_name unless meta[:fully_optional] || meta[:_tnilable]
127
+ end
128
+
129
+ schema = { 'type' => 'object', 'properties' => properties }
130
+ schema['required'] = required unless required.empty?
131
+ schema
132
+ end
133
+
134
+ # Convert a Sorbet T::Types type object to a JSON Schema hash.
135
+ sig { params(type: T.untyped).returns(T::Hash[String, T.untyped]) }
136
+ def t_type_to_json_schema(type) # rubocop:disable Metrics
137
+ case type
138
+ when T::Types::Simple
139
+ PRIMITIVE_SCHEMAS[type.raw_type] || {}
140
+ when T::Types::Union
141
+ schemas = type.types.map { |t| t_type_to_json_schema(t) }
142
+ schemas.uniq!
143
+ schemas.length == 1 ? schemas.first : { 'anyOf' => schemas }
144
+ when T::Types::TypedArray
145
+ { 'type' => 'array', 'items' => t_type_to_json_schema(type.type) }
146
+ when T::Types::TypedHash
147
+ { 'type' => 'object' }
148
+ when Class
149
+ return t_struct_to_json_schema(type) if type < T::Struct
150
+
151
+ PRIMITIVE_SCHEMAS[type] || {}
152
+ else
153
+ {}
154
+ end
155
+ end
156
+
157
+ # Generate a JSON Schema from a Dry::Struct class.
158
+ sig { params(struct_class: T.untyped).returns(T::Hash[String, T.untyped]) }
159
+ def dry_struct_to_json_schema(struct_class)
160
+ properties = {}
161
+ required = []
162
+
163
+ struct_class.schema.each do |key|
164
+ name = key.name.to_s
165
+ properties[name] = dry_type_to_json_schema(key.type)
166
+ required << name if key.required?
167
+ end
168
+
169
+ schema = { 'type' => 'object', 'properties' => properties }
170
+ schema['required'] = required unless required.empty?
171
+ schema
172
+ end
173
+
174
+ # Convert a dry-types type to a JSON Schema hash.
175
+ sig { params(type: T.untyped).returns(T::Hash[String, T.untyped]) }
176
+ def dry_type_to_json_schema(type) # rubocop:disable Metrics
177
+ type_class = type.class.name || ''
178
+
179
+ # Constrained -> unwrap
180
+ return dry_type_to_json_schema(type.type) if type_class.include?('Constrained') && type.respond_to?(:type)
181
+
182
+ # Sum -> anyOf
183
+ if type.respond_to?(:left) && type.respond_to?(:right)
184
+ left = dry_type_to_json_schema(type.left)
185
+ right = dry_type_to_json_schema(type.right)
186
+ return left if left == right
187
+
188
+ return { 'anyOf' => [left, right] }
189
+ end
190
+
191
+ # Array with member type
192
+ return { 'type' => 'array', 'items' => dry_type_to_json_schema(type.member) } if type.respond_to?(:member)
193
+
194
+ # Nominal type with primitive
195
+ return nominal_to_json_schema(type) if type.respond_to?(:primitive)
196
+
197
+ {}
198
+ end
199
+
200
+ # Convert a nominal dry-type (with .primitive) to JSON Schema.
201
+ sig { params(type: T.untyped).returns(T::Hash[String, T.untyped]) }
202
+ def nominal_to_json_schema(type)
203
+ prim = type.primitive
204
+ return dry_struct_to_json_schema(prim) if dry_struct?(prim)
205
+
206
+ PRIMITIVE_SCHEMAS[prim] || {}
207
+ end
208
+ end
209
+
210
+ # Serde wrapper for primitive types and classes with a .json_schema method.
211
+ # Delegates serialize/deserialize to JsonSerde, adds schema.
212
+ class TypeSerde
213
+ extend T::Sig
214
+
215
+ # Create a TypeSerde for the given type with a precomputed JSON Schema.
216
+ sig { params(type: T.untyped, schema: T.nilable(T::Hash[String, T.untyped])).void }
217
+ def initialize(type, schema)
218
+ @type = type
219
+ @schema = schema
220
+ end
221
+
222
+ # Serialize a Ruby object to JSON bytes via JsonSerde.
223
+ sig { params(obj: T.untyped).returns(String) }
224
+ def serialize(obj)
225
+ JsonSerde.serialize(obj)
226
+ end
227
+
228
+ # Deserialize JSON bytes to a Ruby object via JsonSerde.
229
+ sig { params(buf: T.nilable(String)).returns(T.untyped) }
230
+ def deserialize(buf)
231
+ JsonSerde.deserialize(buf)
232
+ end
233
+
234
+ # Return the JSON Schema for this type.
235
+ sig { returns(T.nilable(T::Hash[String, T.untyped])) }
236
+ def json_schema
237
+ @schema
238
+ end
239
+ end
240
+
241
+ # Serde for Dry::Struct types.
242
+ # Deserializes JSON into struct instances, serializes structs to JSON.
243
+ class DryStructSerde
244
+ extend T::Sig
245
+
246
+ # Create a DryStructSerde for the given Dry::Struct class.
247
+ sig { params(struct_class: T.untyped).void }
248
+ def initialize(struct_class)
249
+ @struct_class = struct_class
250
+ end
251
+
252
+ # Serialize a Dry::Struct (or hash-like object) to JSON bytes.
253
+ sig { params(obj: T.untyped).returns(String) }
254
+ def serialize(obj)
255
+ return ''.b if obj.nil?
256
+
257
+ hash = obj.respond_to?(:to_h) ? obj.to_h : obj
258
+ JSON.generate(hash).b
259
+ end
260
+
261
+ # Deserialize JSON bytes into a Dry::Struct instance.
262
+ sig { params(buf: T.nilable(String)).returns(T.untyped) }
263
+ def deserialize(buf)
264
+ return nil if buf.nil? || buf.empty?
265
+
266
+ hash = JSON.parse(buf, symbolize_names: true)
267
+ @struct_class.new(**hash)
268
+ end
269
+
270
+ # Return the JSON Schema derived from the Dry::Struct definition.
271
+ sig { returns(T::Hash[String, T.untyped]) }
272
+ def json_schema
273
+ @json_schema ||= Serde.dry_struct_to_json_schema(@struct_class)
274
+ end
275
+ end
276
+
277
+ # Serde for T::Struct types (Sorbet's native typed structs).
278
+ # Uses T::Struct#serialize for output and T::Struct.from_hash for input.
279
+ # Generates JSON Schema from T::Struct props introspection.
280
+ class TStructSerde
281
+ extend T::Sig
282
+
283
+ # Create a TStructSerde for the given T::Struct subclass.
284
+ sig { params(struct_class: T.class_of(T::Struct)).void }
285
+ def initialize(struct_class)
286
+ @struct_class = struct_class
287
+ end
288
+
289
+ # Serialize a T::Struct instance to JSON bytes.
290
+ sig { params(obj: T.untyped).returns(String) }
291
+ def serialize(obj)
292
+ return ''.b if obj.nil?
293
+
294
+ hash = obj.is_a?(T::Struct) ? obj.serialize : obj
295
+ JSON.generate(hash).b
296
+ end
297
+
298
+ # Deserialize JSON bytes into a T::Struct instance.
299
+ sig { params(buf: T.nilable(String)).returns(T.untyped) }
300
+ def deserialize(buf)
301
+ return nil if buf.nil? || buf.empty?
302
+
303
+ hash = JSON.parse(buf)
304
+ T.unsafe(@struct_class).from_hash(hash)
305
+ end
306
+
307
+ # Return the JSON Schema derived from the T::Struct props.
308
+ sig { returns(T::Hash[String, T.untyped]) }
309
+ def json_schema
310
+ @json_schema ||= Serde.t_struct_to_json_schema(@struct_class)
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,280 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require 'async'
5
+ require 'async/queue'
6
+ require 'logger'
7
+
8
+ module Restate
9
+ # Rack-compatible application that handles Restate protocol requests.
10
+ # Designed to work with Falcon for HTTP/2 bidirectional streaming.
11
+ #
12
+ # Routes:
13
+ # GET /discover → service manifest
14
+ # GET /health → health check
15
+ # POST /invoke/:service/:handler → handler invocation
16
+ class Server
17
+ extend T::Sig
18
+
19
+ SDK_VERSION = T.let(Internal::SDK_VERSION, String)
20
+ X_RESTATE_SERVER = T.let("restate-sdk-ruby/#{SDK_VERSION}".freeze, String)
21
+
22
+ LOGGER = T.let(Logger.new($stdout, progname: 'Restate::Server'), Logger)
23
+
24
+ sig { params(endpoint: Endpoint).void }
25
+ def initialize(endpoint)
26
+ @endpoint = T.let(endpoint, Endpoint)
27
+ @identity_verifier = T.let(Internal::IdentityVerifier.new(endpoint.identity_keys), Internal::IdentityVerifier)
28
+ end
29
+
30
+ # Rack interface
31
+ sig { params(env: T::Hash[String, T.untyped]).returns(T.untyped) }
32
+ def call(env)
33
+ path = env['PATH_INFO'] || '/'
34
+ parsed = parse_path(path)
35
+
36
+ case parsed[:type]
37
+ when :health
38
+ health_response
39
+ when :discover
40
+ handle_discover(env)
41
+ when :invocation
42
+ handle_invocation(env, parsed[:service], parsed[:handler])
43
+ else
44
+ not_found_response
45
+ end
46
+ rescue StandardError => e
47
+ LOGGER.error("Exception in Restate server: #{e.inspect}")
48
+ LOGGER.error(e.backtrace&.join("\n")) if e.backtrace
49
+ error_response(500, 'Internal server error')
50
+ end
51
+
52
+ private
53
+
54
+ sig { params(path: String).returns(T::Hash[Symbol, T.untyped]) }
55
+ def parse_path(path)
56
+ segments = path.split('/').reject(&:empty?)
57
+
58
+ # Check for /invoke/:service/:handler
59
+ if segments.length >= 3
60
+ invoke_idx = segments.rindex('invoke')
61
+ if invoke_idx && segments.length > invoke_idx + 2
62
+ return {
63
+ type: :invocation,
64
+ service: segments[invoke_idx + 1],
65
+ handler: segments[invoke_idx + 2]
66
+ }
67
+ end
68
+ end
69
+
70
+ case segments.last
71
+ when 'health'
72
+ { type: :health }
73
+ when 'discover'
74
+ { type: :discover }
75
+ else
76
+ { type: :unknown }
77
+ end
78
+ end
79
+
80
+ sig { returns(T.untyped) }
81
+ def health_response
82
+ [200, { 'content-type' => 'application/json', 'x-restate-server' => X_RESTATE_SERVER }, ['{"status":"ok"}']]
83
+ end
84
+
85
+ sig { returns(T.untyped) }
86
+ def not_found_response
87
+ [404, { 'x-restate-server' => X_RESTATE_SERVER }, ['']]
88
+ end
89
+
90
+ sig { params(status: Integer, message: String).returns(T.untyped) }
91
+ def error_response(status, message)
92
+ [status, { 'content-type' => 'text/plain', 'x-restate-server' => X_RESTATE_SERVER }, [message]]
93
+ end
94
+
95
+ sig { params(env: T::Hash[String, T.untyped]).returns(T.untyped) }
96
+ def handle_discover(env)
97
+ # Detect HTTP version for protocol mode
98
+ http_version = env['HTTP_VERSION'] || env['SERVER_PROTOCOL'] || 'HTTP/1.1'
99
+ discovered_as = http_version.include?('2') ? 'bidi' : 'request_response'
100
+
101
+ # Negotiate discovery protocol version from Accept header
102
+ accept = env['HTTP_ACCEPT'] || ''
103
+ version = negotiate_version(accept)
104
+ return error_response(415, "Unsupported discovery version: #{accept}") unless version
105
+
106
+ begin
107
+ json = Discovery.compute_discovery_json(@endpoint, version, discovered_as)
108
+ content_type = "application/vnd.restate.endpointmanifest.v#{version}+json"
109
+ [
110
+ 200,
111
+ {
112
+ 'content-type' => content_type,
113
+ 'x-restate-server' => X_RESTATE_SERVER
114
+ },
115
+ [json]
116
+ ]
117
+ rescue StandardError => e
118
+ error_response(500, "Error computing discovery: #{e.message}")
119
+ end
120
+ end
121
+
122
+ sig { params(accept: String).returns(T.nilable(Integer)) }
123
+ def negotiate_version(accept)
124
+ if accept.include?('application/vnd.restate.endpointmanifest.v4+json')
125
+ 4
126
+ elsif accept.include?('application/vnd.restate.endpointmanifest.v3+json')
127
+ 3
128
+ elsif accept.include?('application/vnd.restate.endpointmanifest.v2+json')
129
+ 2
130
+ elsif accept.empty?
131
+ 2
132
+ end
133
+ end
134
+
135
+ sig { params(env: T::Hash[String, T.untyped], service_name: T.untyped, handler_name: T.untyped).returns(T.untyped) }
136
+ def handle_invocation(env, service_name, handler_name)
137
+ # Verify identity
138
+ request_headers = extract_headers(env)
139
+ path = env['PATH_INFO'] || '/'
140
+ begin
141
+ @identity_verifier.verify(request_headers, path)
142
+ rescue Internal::IdentityVerificationError
143
+ return [401, { 'x-restate-server' => X_RESTATE_SERVER }, ['']]
144
+ end
145
+
146
+ # Find the service and handler
147
+ service = @endpoint.services[service_name]
148
+ return not_found_response unless service
149
+
150
+ handler = service.handlers[handler_name]
151
+ return not_found_response unless handler
152
+
153
+ # Process the invocation with streaming
154
+ process_invocation(env, handler, request_headers)
155
+ end
156
+
157
+ sig { params(env: T::Hash[String, T.untyped], handler: T.untyped, request_headers: T.untyped).returns(T.untyped) }
158
+ def process_invocation(env, handler, request_headers)
159
+ vm = VMWrapper.new(request_headers)
160
+ status, response_headers = vm.get_response_head
161
+
162
+ # Streaming response body — output chunks are sent to Restate as they're
163
+ # produced. This is critical for BidiStream mode where the VM needs output
164
+ # acknowledged before it can make further progress.
165
+ output_queue = Async::Queue.new
166
+ send_output = ->(chunk) { output_queue.enqueue(chunk) }
167
+
168
+ # Input queue bridges the HTTP body reader and the handler's progress loop.
169
+ input_queue = Async::Queue.new
170
+
171
+ # Read request body chunks and feed to VM until ready to execute,
172
+ # then continue feeding remaining chunks via the input queue.
173
+ rack_input = env['rack.input']
174
+ ready = T.let(false, T::Boolean)
175
+ if rack_input
176
+ # Feed chunks until the VM has enough to start execution
177
+ while (chunk = rack_input.read_partial(16_384))
178
+ vm.notify_input(chunk.b) unless chunk.empty?
179
+ if vm.is_ready_to_execute
180
+ ready = true
181
+ break
182
+ end
183
+ end
184
+ vm.notify_input_closed unless ready
185
+ end
186
+
187
+ invocation = vm.sys_input
188
+
189
+ # Spawn a background task to continue reading remaining input
190
+ if ready
191
+ Async do
192
+ while (chunk = rack_input.read_partial(16_384))
193
+ input_queue.enqueue(chunk.b) unless chunk.empty?
194
+ end
195
+ input_queue.enqueue(:eof)
196
+ rescue StandardError => e
197
+ LOGGER.error("Input reader error: #{e.inspect}")
198
+ input_queue.enqueue(:disconnected)
199
+ end
200
+ end
201
+
202
+ context = ServerContext.new(
203
+ vm: vm,
204
+ handler: handler,
205
+ invocation: invocation,
206
+ send_output: send_output,
207
+ input_queue: input_queue
208
+ )
209
+
210
+ # Spawn the handler as an async task so the response body can stream
211
+ # output concurrently.
212
+ Async do
213
+ begin
214
+ context.enter
215
+ rescue DisconnectedError
216
+ # Client disconnected
217
+ rescue StandardError => e
218
+ LOGGER.error("Exception in handler: #{e.inspect}")
219
+ ensure
220
+ # Signal that the attempt is finished — wakes any waiters on
221
+ # ctx.request.attempt_finished_event and cancels pending background pool jobs.
222
+ context.on_attempt_finished
223
+ end
224
+
225
+ # Drain remaining output from VM
226
+ loop do
227
+ chunk = vm.take_output
228
+ break if chunk.nil? || chunk.empty?
229
+
230
+ output_queue.enqueue(chunk)
231
+ end
232
+
233
+ # Signal end of output
234
+ output_queue.enqueue(nil)
235
+ end
236
+
237
+ body = StreamingBody.new(output_queue)
238
+
239
+ merged_headers = response_headers.to_h { |pair| [pair[0], pair[1]] }
240
+ merged_headers['x-restate-server'] = X_RESTATE_SERVER
241
+
242
+ [status, merged_headers, body]
243
+ end
244
+
245
+ # Rack 3 streaming body that yields chunks from an Async::Queue.
246
+ # Terminates when nil is dequeued.
247
+ class StreamingBody
248
+ extend T::Sig
249
+
250
+ sig { params(queue: Async::Queue).void }
251
+ def initialize(queue)
252
+ @queue = T.let(queue, Async::Queue)
253
+ end
254
+
255
+ def each
256
+ loop do
257
+ chunk = @queue.dequeue
258
+ break if chunk.nil?
259
+
260
+ yield chunk
261
+ end
262
+ end
263
+ end
264
+
265
+ sig { params(env: T::Hash[String, T.untyped]).returns(T::Array[T::Array[String]]) }
266
+ def extract_headers(env)
267
+ headers = T.let([], T::Array[T::Array[String]])
268
+ env.each do |key, value|
269
+ next unless key.start_with?('HTTP_')
270
+
271
+ header_name = key.sub('HTTP_', '').tr('_', '-').downcase
272
+ headers << [header_name, value]
273
+ end
274
+ # Also include content-type and content-length if present
275
+ headers << ['content-type', env['CONTENT_TYPE']] if env['CONTENT_TYPE']
276
+ headers << ['content-length', env['CONTENT_LENGTH']] if env['CONTENT_LENGTH']
277
+ headers
278
+ end
279
+ end
280
+ end