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,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
|