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,37 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Restate
5
+ # A stateless Restate service.
6
+ #
7
+ # @example
8
+ # class Greeter < Restate::Service
9
+ # handler def greet(name)
10
+ # "Hello, #{name}!"
11
+ # end
12
+ # end
13
+ class Service
14
+ extend T::Sig
15
+ extend ServiceDSL
16
+
17
+ # Register a handler method on this service.
18
+ # Use as: +handler def my_method(arg)+ or +handler :my_method, input: String+
19
+ #
20
+ # @param method_name [Symbol] name of the method to register
21
+ # @param opts [Hash] handler options (+input:+, +output:+, +accept:+, +content_type:+)
22
+ # @return [Symbol] the method name
23
+ def self.handler(method_name = nil, **opts)
24
+ if method_name.is_a?(String)
25
+ raise ArgumentError,
26
+ "handler expects a Symbol (use `handler def #{method_name}(...)` or `handler :#{method_name}`)"
27
+ end
28
+ return method_name unless method_name.is_a?(Symbol)
29
+
30
+ _register_handler(method_name, **T.unsafe({ kind: nil, **opts }))
31
+ end
32
+
33
+ def self._service_kind
34
+ 'service'
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,243 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Restate
5
+ # Shared class-level DSL for defining Restate services via class inheritance.
6
+ #
7
+ # Extended into Service, VirtualObject, and Workflow. Provides the +handler+,
8
+ # +shared+, +main+, and +service_name+ class macros.
9
+ #
10
+ # @example
11
+ # class Counter < Restate::VirtualObject
12
+ # handler def add(addend)
13
+ # ctx = Restate.current_object_context
14
+ # old = ctx.get('counter') || 0
15
+ # ctx.set('counter', old + addend)
16
+ # old + addend
17
+ # end
18
+ # end
19
+ module ServiceDSL # rubocop:disable Metrics/ModuleLength
20
+ # Called when a subclass is created; initializes the handler registry.
21
+ def inherited(subclass) # rubocop:disable Metrics/MethodLength
22
+ super
23
+ subclass.instance_variable_set(:@_handler_registry, {})
24
+ subclass.instance_variable_set(:@_service_name, nil)
25
+ subclass.instance_variable_set(:@_handlers, nil)
26
+ subclass.instance_variable_set(:@_enable_lazy_state, nil)
27
+ subclass.instance_variable_set(:@_description, nil)
28
+ subclass.instance_variable_set(:@_metadata, nil)
29
+ subclass.instance_variable_set(:@_inactivity_timeout, nil)
30
+ subclass.instance_variable_set(:@_abort_timeout, nil)
31
+ subclass.instance_variable_set(:@_journal_retention, nil)
32
+ subclass.instance_variable_set(:@_idempotency_retention, nil)
33
+ subclass.instance_variable_set(:@_ingress_private, nil)
34
+ subclass.instance_variable_set(:@_invocation_retry_policy, nil)
35
+ end
36
+
37
+ # Get or set the service name. Defaults to the unqualified class name.
38
+ #
39
+ # @param name [String, nil] when provided, sets the service name
40
+ # @return [String] the current service name
41
+ def service_name(name = nil)
42
+ if name
43
+ @_service_name = name
44
+ else
45
+ @_service_name || T.unsafe(self).name&.split('::')&.last
46
+ end
47
+ end
48
+
49
+ # Enable lazy state loading for all handlers in this service.
50
+ # When enabled, state is fetched on demand rather than pre-loaded.
51
+ #
52
+ # @param value [Boolean] whether to enable lazy state
53
+ def enable_lazy_state(value = true) # rubocop:disable Style/OptionalBooleanParameter
54
+ @_enable_lazy_state = value
55
+ end
56
+
57
+ # Set or get a human-readable description for this service.
58
+ #
59
+ # @param text [String, nil] when provided, sets the description
60
+ # @return [String, nil] the current description
61
+ def description(text = nil)
62
+ if text
63
+ @_description = text
64
+ else
65
+ @_description
66
+ end
67
+ end
68
+
69
+ # Set or get metadata for this service.
70
+ #
71
+ # @param hash [Hash, nil] when provided, sets the metadata
72
+ # @return [Hash, nil] the current metadata
73
+ def metadata(hash = nil)
74
+ if hash
75
+ @_metadata = hash
76
+ else
77
+ @_metadata
78
+ end
79
+ end
80
+
81
+ # Set the inactivity timeout (in seconds) for this service.
82
+ #
83
+ # @param seconds [Numeric] timeout in seconds
84
+ def inactivity_timeout(seconds)
85
+ @_inactivity_timeout = seconds
86
+ end
87
+
88
+ # Set the abort timeout (in seconds) for this service.
89
+ #
90
+ # @param seconds [Numeric] timeout in seconds
91
+ def abort_timeout(seconds)
92
+ @_abort_timeout = seconds
93
+ end
94
+
95
+ # Set the journal retention (in seconds) for this service.
96
+ #
97
+ # @param seconds [Numeric] retention in seconds
98
+ def journal_retention(seconds)
99
+ @_journal_retention = seconds
100
+ end
101
+
102
+ # Set the idempotency retention (in seconds) for this service.
103
+ #
104
+ # @param seconds [Numeric] retention in seconds
105
+ def idempotency_retention(seconds)
106
+ @_idempotency_retention = seconds
107
+ end
108
+
109
+ # Mark this service as private to the ingress.
110
+ #
111
+ # @param value [Boolean] whether the service is ingress-private
112
+ def ingress_private(value = true) # rubocop:disable Style/OptionalBooleanParameter
113
+ @_ingress_private = value
114
+ end
115
+
116
+ # Set the invocation retry policy for this service.
117
+ #
118
+ # @param initial_interval [Numeric, nil] initial retry interval in seconds
119
+ # @param max_interval [Numeric, nil] maximum retry interval in seconds
120
+ # @param max_attempts [Integer, nil] maximum number of retry attempts
121
+ # @param exponentiation_factor [Numeric, nil] backoff exponentiation factor
122
+ # @param on_max_attempts [Symbol, String, nil] action on max attempts (:pause or :kill)
123
+ def invocation_retry_policy(initial_interval: nil, max_interval: nil, max_attempts: nil,
124
+ exponentiation_factor: nil, on_max_attempts: nil)
125
+ @_invocation_retry_policy = {
126
+ initial_interval: initial_interval,
127
+ max_interval: max_interval,
128
+ max_attempts: max_attempts,
129
+ exponentiation_factor: exponentiation_factor,
130
+ on_max_attempts: on_max_attempts
131
+ }.compact
132
+ end
133
+
134
+ # Returns the ServiceTag for this class-based service.
135
+ # Subclasses must define +_service_kind+.
136
+ #
137
+ # @return [ServiceTag]
138
+ def service_tag
139
+ ServiceTag.new(kind: T.unsafe(self)._service_kind, name: service_name,
140
+ description: @_description, metadata: @_metadata)
141
+ end
142
+
143
+ # Returns the service-level lazy state setting (nil if not set).
144
+ def lazy_state?
145
+ @_enable_lazy_state
146
+ end
147
+
148
+ # Returns the service-level inactivity timeout (nil if not set).
149
+ def svc_inactivity_timeout
150
+ @_inactivity_timeout
151
+ end
152
+
153
+ # Returns the service-level abort timeout (nil if not set).
154
+ def svc_abort_timeout
155
+ @_abort_timeout
156
+ end
157
+
158
+ # Returns the service-level journal retention (nil if not set).
159
+ def svc_journal_retention
160
+ @_journal_retention
161
+ end
162
+
163
+ # Returns the service-level idempotency retention (nil if not set).
164
+ def svc_idempotency_retention
165
+ @_idempotency_retention
166
+ end
167
+
168
+ # Returns the service-level ingress private setting (nil if not set).
169
+ def svc_ingress_private
170
+ @_ingress_private
171
+ end
172
+
173
+ # Returns the service-level invocation retry policy (nil if not set).
174
+ def svc_invocation_retry_policy
175
+ @_invocation_retry_policy
176
+ end
177
+
178
+ # Returns a hash of handler name (String) to Handler.
179
+ # Built lazily on first access and cached.
180
+ #
181
+ # @return [Hash{String => Handler}]
182
+ def handlers
183
+ @_handlers ||= _build_handlers # rubocop:disable Naming/MemoizedInstanceVariableName
184
+ end
185
+
186
+ private
187
+
188
+ # Store handler metadata for later building.
189
+ def _register_handler(method_name, kind:, **opts)
190
+ @_handlers = nil # invalidate cache
191
+ @_handler_registry[method_name.to_s] = { kind: kind, **opts }
192
+ method_name
193
+ end
194
+
195
+ # Build Handler structs from the registry using instance_method + bind.
196
+ def _build_handlers # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
197
+ tag = service_tag
198
+ result = {}
199
+ instance = T.unsafe(self).allocate
200
+
201
+ @_handler_registry.each do |name, meta| # rubocop:disable Metrics/BlockLength
202
+ input_serde = Serde.resolve(meta[:input])
203
+ output_serde = Serde.resolve(meta[:output])
204
+
205
+ handler_io = HandlerIO.new(
206
+ accept: meta[:accept] || 'application/json',
207
+ content_type: meta[:content_type] || 'application/json',
208
+ input_serde: input_serde,
209
+ output_serde: output_serde
210
+ )
211
+
212
+ um = T.unsafe(self).instance_method(name)
213
+ arity = um.arity.abs
214
+ unless [0, 1].include?(arity)
215
+ Kernel.raise ArgumentError, "handler '#{name}' must accept 0 or 1 parameters ([input]), got #{arity}"
216
+ end
217
+
218
+ bound = um.bind(instance)
219
+
220
+ result[name] = Handler.new(
221
+ service_tag: tag,
222
+ handler_io: handler_io,
223
+ kind: meta[:kind],
224
+ name: name,
225
+ callable: bound,
226
+ arity: arity,
227
+ enable_lazy_state: meta[:enable_lazy_state],
228
+ description: meta[:description],
229
+ metadata: meta[:metadata],
230
+ inactivity_timeout: meta[:inactivity_timeout],
231
+ abort_timeout: meta[:abort_timeout],
232
+ journal_retention: meta[:journal_retention],
233
+ idempotency_retention: meta[:idempotency_retention],
234
+ workflow_completion_retention: meta[:workflow_completion_retention],
235
+ ingress_private: meta[:ingress_private],
236
+ invocation_retry_policy: meta[:invocation_retry_policy]
237
+ )
238
+ end
239
+
240
+ result
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,197 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require 'restate'
5
+ require 'socket'
6
+ require 'net/http'
7
+ require 'json'
8
+ require 'uri'
9
+ require 'testcontainers'
10
+
11
+ module Restate
12
+ # Test harness for running Restate services with a real Restate server.
13
+ # Opt-in via `require "restate/testing"` (not loaded by default).
14
+ #
15
+ # Requires Docker and the testcontainers-core gem.
16
+ #
17
+ # Block-based (recommended):
18
+ # Restate::Testing.start(Greeter, Counter) do |env|
19
+ # env.ingress_url # => "http://localhost:32771"
20
+ # env.admin_url # => "http://localhost:32772"
21
+ # end
22
+ #
23
+ # Manual lifecycle (for RSpec before/after hooks):
24
+ # harness = Restate::Testing::RestateTestHarness.new(Greeter, Counter)
25
+ # harness.start
26
+ # harness.ingress_url
27
+ # harness.stop
28
+ module Testing
29
+ # Starts a Restate test environment with the given services.
30
+ # When a block is given, stops automatically on block exit.
31
+ # Without a block, returns the harness for manual lifecycle management.
32
+ def self.start(*services, **options)
33
+ harness = RestateTestHarness.new(*services, **options)
34
+ harness.start
35
+
36
+ if block_given?
37
+ begin
38
+ yield harness
39
+ ensure
40
+ harness.stop
41
+ end
42
+ else
43
+ harness
44
+ end
45
+ end
46
+
47
+ # Manages the lifecycle of an SDK server and a Restate container for testing.
48
+ class RestateTestHarness
49
+ attr_reader :ingress_url, :admin_url
50
+
51
+ # @param services [Array<Class>] Service classes to register.
52
+ # @param restate_image [String] Docker image for Restate server.
53
+ # @param always_replay [Boolean] Force replay on every suspension point.
54
+ # @param disable_retries [Boolean] Disable Restate retry policy.
55
+ def initialize(*services,
56
+ restate_image: 'docker.io/restatedev/restate:latest',
57
+ always_replay: false,
58
+ disable_retries: false)
59
+ @services = services
60
+ @restate_image = restate_image
61
+ @always_replay = always_replay
62
+ @disable_retries = disable_retries
63
+ @server_thread = nil
64
+ @container = nil
65
+ @port = nil
66
+ @ingress_url = nil
67
+ @admin_url = nil
68
+ end
69
+
70
+ def start
71
+ @port = find_free_port
72
+ rack_app = Restate.endpoint(*@services).app
73
+ start_sdk_server(rack_app)
74
+ wait_for_tcp(@port)
75
+ start_restate_container
76
+ register_sdk
77
+ self
78
+ rescue StandardError => e
79
+ stop
80
+ raise e
81
+ end
82
+
83
+ def stop
84
+ stop_restate_container
85
+ stop_sdk_server
86
+ end
87
+
88
+ private
89
+
90
+ def find_free_port
91
+ server = TCPServer.new('0.0.0.0', 0)
92
+ port = server.addr[1]
93
+ server.close
94
+ port
95
+ end
96
+
97
+ def start_sdk_server(rack_app)
98
+ require 'async'
99
+ require 'async/http/endpoint'
100
+ require 'falcon/server'
101
+
102
+ port = @port
103
+ ready = Queue.new
104
+
105
+ @server_thread = Thread.new do
106
+ Async do
107
+ endpoint = Async::HTTP::Endpoint.parse("http://0.0.0.0:#{port}")
108
+ middleware = Falcon::Server.middleware(rack_app, cache: false)
109
+ server = Falcon::Server.new(middleware, endpoint)
110
+ ready.push(true)
111
+ server.run
112
+ end
113
+ end
114
+
115
+ ready.pop
116
+ end
117
+
118
+ def wait_for_tcp(port, timeout: 10)
119
+ deadline = Time.now + timeout
120
+ loop do
121
+ TCPSocket.new('127.0.0.1', port).close
122
+ return
123
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET
124
+ raise "SDK server failed to start on port #{port} within #{timeout}s" if Time.now > deadline
125
+
126
+ sleep 0.1
127
+ end
128
+ end
129
+
130
+ def start_restate_container
131
+ env = {
132
+ 'RESTATE_LOG_FILTER' => 'restate=info',
133
+ 'RESTATE_BOOTSTRAP_NUM_PARTITIONS' => '1',
134
+ 'RESTATE_DEFAULT_NUM_PARTITIONS' => '1',
135
+ 'RESTATE_SHUTDOWN_TIMEOUT' => '10s',
136
+ 'RESTATE_ROCKSDB_TOTAL_MEMORY_SIZE' => '32 MB',
137
+ 'RESTATE_WORKER__INVOKER__IN_MEMORY_QUEUE_LENGTH_LIMIT' => '64',
138
+ 'RESTATE_WORKER__INVOKER__INACTIVITY_TIMEOUT' => @always_replay ? '0s' : '10m',
139
+ 'RESTATE_WORKER__INVOKER__ABORT_TIMEOUT' => '10m'
140
+ }
141
+ env['RESTATE_WORKER__INVOKER__RETRY_POLICY__TYPE'] = 'none' if @disable_retries
142
+
143
+ @container = RestateContainer.new(@restate_image)
144
+ @container.with_exposed_ports(8080, 9070)
145
+ @container.with_env(env)
146
+ @container.start
147
+
148
+ @container.wait_for_http(path: '/restate/health', container_port: 8080, status: 200, timeout: 30)
149
+ @container.wait_for_http(path: '/health', container_port: 9070, status: 200, timeout: 30)
150
+
151
+ @ingress_url = "http://#{@container.host}:#{@container.mapped_port(8080)}"
152
+ @admin_url = "http://#{@container.host}:#{@container.mapped_port(9070)}"
153
+ end
154
+
155
+ def register_sdk
156
+ uri = URI("#{@admin_url}/deployments")
157
+ sdk_url = "http://host.docker.internal:#{@port}"
158
+
159
+ request = Net::HTTP::Post.new(uri)
160
+ request['Content-Type'] = 'application/json'
161
+ request.body = JSON.generate({ uri: sdk_url })
162
+
163
+ response = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(request) }
164
+ return if response.code.start_with?('2')
165
+
166
+ raise "Failed to register SDK at #{sdk_url}: #{response.code} #{response.body}"
167
+ end
168
+
169
+ def stop_restate_container
170
+ return unless @container
171
+
172
+ @container.stop!
173
+ @container.remove(force: true)
174
+ rescue StandardError
175
+ # Ignore cleanup errors
176
+ end
177
+
178
+ def stop_sdk_server
179
+ @server_thread&.kill
180
+ @server_thread&.join(5)
181
+ end
182
+ end
183
+
184
+ # Testcontainers::DockerContainer subclass that adds ExtraHosts support
185
+ # so the Restate container can reach the host-bound SDK server.
186
+ class RestateContainer < Testcontainers::DockerContainer
187
+ private
188
+
189
+ def _container_create_options
190
+ options = super
191
+ options['HostConfig'] ||= {}
192
+ options['HostConfig']['ExtraHosts'] = ['host.docker.internal:host-gateway']
193
+ options
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,6 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Restate
5
+ VERSION = '0.4.3'
6
+ end
@@ -0,0 +1,58 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Restate
5
+ # A keyed virtual object with durable state.
6
+ #
7
+ # @example
8
+ # class Counter < Restate::VirtualObject
9
+ # handler def add(addend)
10
+ # ctx = Restate.current_object_context
11
+ # old = ctx.get("count") || 0
12
+ # ctx.set("count", old + addend)
13
+ # old + addend
14
+ # end
15
+ #
16
+ # shared def get
17
+ # ctx = Restate.current_object_context
18
+ # ctx.get("count") || 0
19
+ # end
20
+ # end
21
+ class VirtualObject
22
+ extend T::Sig
23
+ extend ServiceDSL
24
+
25
+ # Register an exclusive handler. Use as: +handler def my_method(arg)+
26
+ #
27
+ # @param method_name [Symbol] name of the method to register
28
+ # @param kind [Symbol] concurrency mode (+:exclusive+ or +:shared+)
29
+ # @param opts [Hash] handler options (+input:+, +output:+, +accept:+, +content_type:+)
30
+ # @return [Symbol] the method name
31
+ def self.handler(method_name = nil, kind: :exclusive, **opts)
32
+ if method_name.is_a?(String)
33
+ raise ArgumentError,
34
+ "handler expects a Symbol (use `handler def #{method_name}(...)` or `handler :#{method_name}`)"
35
+ end
36
+ return method_name unless method_name.is_a?(Symbol)
37
+
38
+ _register_handler(method_name, **T.unsafe({ kind: kind.to_s, **opts }))
39
+ end
40
+
41
+ # Register a shared (concurrent-access) handler.
42
+ #
43
+ # @param method_name [Symbol] name of the method to register
44
+ # @return [Symbol] the method name
45
+ def self.shared(method_name, **opts)
46
+ if method_name.is_a?(String)
47
+ raise ArgumentError,
48
+ "handler expects a Symbol (use `shared def #{method_name}(...)` or `shared :#{method_name}`)"
49
+ end
50
+
51
+ _register_handler(method_name, **T.unsafe({ kind: 'shared', **opts }))
52
+ end
53
+
54
+ def self._service_kind
55
+ 'object'
56
+ end
57
+ end
58
+ end