Build resilient applications with durable execution in Ruby. The Restate Ruby SDK lets you write handlers that survive crashes, retries, and infrastructure failures — with the simplicity of ordinary Ruby code.
# greeter.rb
require 'restate'
class Greeter < Restate::Service
handler def greet(ctx, name)
ctx.run_sync('build-greeting') { "Hello, #{name}!" }
end
end
endpoint = Restate.endpoint(Greeter)# config.ru
require_relative 'greeter'
run endpoint.appbundle exec falcon serve --bind http://localhost:9080restate deployments register http://localhost:9080
curl localhost:8080/Greeter/greet -H 'content-type: application/json' -d '"World"'
# → "Hello, World!"The SDK provides three service types, each with different durability and concurrency guarantees.
Stateless handlers that can be invoked by name. Each invocation is independent.
class MyService < Restate::Service
handler def my_handler(ctx, input)
# ctx is the Restate context; input is the deserialized JSON body
# return value is serialized as the JSON response
{ 'result' => input }
end
endInvoke: POST /MyService/my_handler
Each virtual object instance is identified by a key and has durable K/V state scoped to that key.
class Counter < Restate::VirtualObject
# Exclusive handler — one invocation at a time per key.
handler def add(ctx, amount)
current = ctx.get('count') || 0
ctx.set('count', current + amount)
current + amount
end
# Shared handler — concurrent access allowed (read-only).
shared def get(ctx)
ctx.get('count') || 0
end
endInvoke: POST /Counter/my-counter/add (key is my-counter)
A workflow's main handler runs exactly once per key. Shared handlers let external callers
query state and send signals.
class UserSignup < Restate::Workflow
main def run(ctx, email)
user_id = ctx.run_sync('create-account') { create_user(email) }
ctx.set('status', 'waiting_for_approval')
# Block until approve() is called
approval = ctx.promise('approval')
ctx.set('status', 'active')
{ 'user_id' => user_id, 'approval' => approval }
end
handler def approve(ctx, reason)
ctx.resolve_promise('approval', reason)
end
handler def status(ctx)
ctx.get('status') || 'unknown'
end
endInvoke:
curl localhost:8080/UserSignup/user42/run -d '"user@example.com"'
curl localhost:8080/UserSignup/user42/approve -d '"approved by admin"'
curl localhost:8080/UserSignup/user42/status -d 'null'The context object provides access to all Restate operations. It is passed as the first argument to every handler:
handler def greet(ctx, name) # ctx is always the first parameter
ctx.run_sync('step') { ... }
endAll operations that interact with Restate return durable results — if the handler crashes and retries, completed operations are replayed from the journal without re-executing.
Execute a side effect exactly once. The result is durably recorded — on retry, the block is skipped and the stored result is returned.
run returns a DurableFuture; call .await to get the result. Use run_sync to get
the value directly:
# Returns a future — useful for fan-out (see below)
future = ctx.run('step-name') { do_something() }
result = future.await
# Returns the value directly — convenient for sequential steps
result = ctx.run_sync('step-name') { do_something() }With retry policy:
policy = Restate::RunRetryPolicy.new(
initial_interval: 100, # ms between retries
max_attempts: 5, # max retry count
interval_factor: 2.0, # exponential backoff multiplier
max_interval: 10_000, # ms cap on retry interval
max_duration: 60_000 # ms total duration cap
)
result = ctx.run_sync('flaky-call', retry_policy: policy) { call_external_api() }Terminal errors (non-retryable):
ctx.run_sync('validate') do
raise Restate::TerminalError.new('invalid input', status_code: 400)
endBackground thread (background: true):
Background thread pool (background: true):
With Async and Ruby 3.1+, the Fiber Scheduler automatically intercepts most blocking I/O
(Net::HTTP, TCPSocket, file I/O, etc.) and yields the fiber — so run already handles
I/O-bound work without blocking the event loop.
Pass background: true only for CPU-heavy native extensions that release the GVL (e.g.,
image processing, crypto). The block runs in a shared thread pool (default 8 workers,
configurable via RESTATE_BACKGROUND_POOL_SIZE):
result = ctx.run_sync('resize-image', background: true) { process_image(data) }Available in VirtualObject and Workflow handlers.
value = ctx.get('key') # Read state (nil if absent)
ctx.set('key', value) # Write state
ctx.clear('key') # Delete one key
ctx.clear_all # Delete all keys
keys = ctx.state_keys # List all key namesAsync variants — return a DurableFuture instead of blocking, useful for fan-out:
future_a = ctx.get_async('key_a')
future_b = ctx.get_async('key_b')
keys_future = ctx.state_keys_async
# Await results (fetches happen concurrently)
val_a = future_a.await
val_b = future_b.await
keys = keys_future.awaitValues are JSON-serialized by default. Pass serde: for custom serialization:
ctx.get('key', serde: Restate::BytesSerde)
ctx.get_async('key', serde: Restate::BytesSerde)
ctx.set('key', raw_bytes, serde: Restate::BytesSerde)ctx.sleep(5.0).await # Sleep for 5 seconds (durable timer)The timer survives crashes — if the handler restarts, it resumes waiting for the remaining time.
Call another handler and await the result. The call is durable — if the caller crashes, Restate delivers the result when the caller retries.
# Typed call (resolves serdes from target handler registration)
result = ctx.service_call(MyService, :my_handler, arg).await
result = ctx.object_call(Counter, :add, 'my-key', 5).await
result = ctx.workflow_call(UserSignup, :run, 'user42', email).await
# String-based call (uses JsonSerde)
result = ctx.service_call('MyService', 'my_handler', arg).awaitDurableCallFuture methods:
future = ctx.service_call(MyService, :handler, arg)
result = future.await # Block until result
id = future.invocation_id # Get invocation ID
future.cancel # Cancel the remote invocationDispatch a call without waiting for the result.
handle = ctx.service_send(MyService, :handler, arg)
handle = ctx.object_send(Counter, :add, 'my-key', 5)
# Delayed send (executes after 60 seconds)
handle = ctx.service_send(MyService, :handler, arg, delay: 60.0)SendHandle methods:
id = handle.invocation_id # Get invocation ID
handle.cancel # Cancel the invocationAll call/send methods accept these keyword arguments:
ctx.service_call(
MyService, :handler, arg,
idempotency_key: 'unique-key', # Deduplication key
headers: { 'x-custom' => 'val' }, # Custom headers
input_serde: MyCustomSerde, # Override input serializer
output_serde: MyCustomSerde # Override output serializer
)Launch multiple calls concurrently, then collect all results.
# Fan-out: launch calls
futures = tasks.map { |t| ctx.service_call(Worker, :process, t) }
# Fan-in: await all
results = futures.map(&:await)Wait for the first future to complete out of several.
future_a = ctx.service_call(ServiceA, :slow, arg)
future_b = ctx.service_call(ServiceB, :fast, arg)
completed, remaining = ctx.wait_any(future_a, future_b)
winner = completed.first.awaitPause a handler until an external system calls back via Restate's API.
# In your handler: create an awakeable
awakeable_id, future = ctx.awakeable
# Send the ID to an external system
ctx.run_sync('notify') { send_to_external_system(awakeable_id) }
# Block until the external system resolves it
result = future.awaitThe external system resolves the awakeable via Restate's HTTP API:
curl -X POST http://restate:8080/restate/awakeables/$AWAKEABLE_ID/resolve \
-H 'content-type: application/json' -d '"callback data"'From another handler:
ctx.resolve_awakeable(awakeable_id, payload)
ctx.reject_awakeable(awakeable_id, 'reason', code: 500)Durable promises allow communication between a workflow's main handler and its signal handlers.
# In main handler: block until promise is resolved
value = ctx.promise('approval')
# In signal handler: resolve the promise
ctx.resolve_promise('approval', value)
# Non-blocking peek (returns nil if not yet resolved)
value = ctx.peek_promise('approval')
# Reject a promise
ctx.reject_promise('approval', 'denied', code: 400)request = ctx.request
request.id # Invocation ID (String)
request.headers # Request headers (Hash)
request.body # Raw input bytes (String)
key = ctx.key # Object/workflow key (String)The attempt_finished_event on ctx.request signals when the current attempt is about to finish
(e.g., the connection is closing). This is useful for long-running handlers that need to perform
cleanup or flush work before the attempt ends.
event = ctx.request.attempt_finished_event
event.set? # Non-blocking check: has the attempt finished? (true/false)
event.wait # Blocks the current fiber until the attempt finishesThe context is passed as the first argument to every handler. For nested helper methods that
don't have ctx in scope, you can use the fiber-local accessors:
class OrderService < Restate::Service
handler def process(ctx, order)
validate(order)
fulfill(order)
end
private
def validate(order)
# Fiber-local accessor — works from any method within the handler's fiber
ctx = Restate.current_context
ctx.run_sync('validate') { check_inventory(order) }
end
def fulfill(order)
ctx = Restate.current_context
ctx.run_sync('fulfill') { ship_order(order) }
end
endThe following fiber-local accessors are available, each returning the appropriately-typed context:
| Accessor | Returns | Use in |
|---|---|---|
Restate.current_context |
Context |
Any handler |
Restate.current_object_context |
ObjectContext |
VirtualObject exclusive handlers (full state) |
Restate.current_shared_context |
ObjectSharedContext |
VirtualObject shared handlers (read-only state) |
Restate.current_workflow_context |
WorkflowContext |
Workflow main handler (full state + promises) |
Restate.current_shared_workflow_context |
WorkflowSharedContext |
Workflow shared handlers (read-only state + promises) |
Shared contexts (ObjectSharedContext, WorkflowSharedContext) expose get and state_keys
but NOT set, clear, or clear_all — shared handlers have read-only access to state.
Runtime validation: Calling the wrong accessor for your handler type (e.g.,
Restate.current_object_context from a Service handler) raises an error. Calling any accessor
outside a handler also raises.
Implementation: These use fiber-local storage (Thread.current[:key], which is fiber-scoped
in Ruby). The context is set automatically when a handler begins and cleared when it returns.
ctx.cancel_invocation(invocation_id)class MyService < Restate::Service
# Inline decorator style
handler def greet(ctx, name)
"Hello, #{name}!"
end
# With options
handler :process, input: String, output: Hash
def process(ctx, input)
{ 'result' => input.upcase }
end
endhandler :my_handler,
input: String, # Type or serde for input (generates JSON schema)
output: Hash, # Type or serde for output (generates JSON schema)
accept: 'application/json', # Input content type
content_type: 'application/json' # Output content typeThe input: and output: options accept:
- A type class (e.g.,
String,Integer,Dry::Structsubclass) — auto-resolves serde + JSON schema - A serde object (responds to
serialize/deserialize) — used directly - Omitted — defaults to
JsonSerdewith no schema
Handlers also accept configuration options that control Restate server behavior:
handler :process,
input: String, output: String,
description: 'Process a task', # Human-readable description
metadata: { 'team' => 'backend' }, # Arbitrary key-value metadata
inactivity_timeout: 300, # Seconds before Restate considers handler inactive
abort_timeout: 60, # Seconds before Restate aborts a stuck handler
journal_retention: 86_400, # Seconds to retain the journal (1 day)
idempotency_retention: 3600, # Seconds to retain idempotency keys (1 hour)
ingress_private: true, # Hide from public ingress
enable_lazy_state: true, # Fetch state on demand (VirtualObject/Workflow)
invocation_retry_policy: { # Custom retry policy
initial_interval: 0.1, # First retry after 100ms
max_interval: 30, # Cap retry interval at 30s
max_attempts: 10, # Max 10 attempts
exponentiation_factor: 2.0, # Double interval each retry
on_max_attempts: :kill # Kill invocation on exhaustion (:pause or :kill)
}For workflow main handlers, there is an additional option:
main :run,
workflow_completion_retention: 86_400 # Seconds to retain workflow completion (1 day)By default, the service name is the unqualified class name. Override it:
class MyLongClassName < Restate::Service
service_name 'ShortName'
# Registered as "ShortName" in Restate
endEvery handler receives ctx as its first parameter. An optional second parameter receives the
deserialized input:
handler def no_input(ctx) # Called with null/empty body
'ok'
end
handler def with_input(ctx, data) # data = deserialized JSON body
data['name']
endUse class-level DSL methods to set defaults for the entire service. These are reported to the Restate server via the discovery protocol and control server-side behavior.
class OrderProcessor < Restate::VirtualObject
# Documentation
description 'Processes customer orders'
metadata 'team' => 'commerce', 'tier' => 'critical'
# Timeouts
inactivity_timeout 300 # Seconds before Restate considers a handler inactive
abort_timeout 60 # Seconds before Restate aborts a stuck handler
# Retention
journal_retention 86_400 # Seconds to retain the journal (1 day)
idempotency_retention 3600 # Seconds to retain idempotency keys (1 hour)
# Access control
ingress_private # Hide from public ingress
# State loading
enable_lazy_state # Fetch state on demand instead of pre-loading
# Retry policy for handler invocations
invocation_retry_policy initial_interval: 0.1,
max_interval: 30,
max_attempts: 10,
exponentiation_factor: 2.0,
on_max_attempts: :kill
handler def process(ctx, order)
# ...
end
endAll time values are in seconds. All options are optional — when omitted, the Restate server uses its built-in defaults.
Handler-level options override service-level defaults for individual handlers.
| Option | Service | Handler | Description |
|---|---|---|---|
description |
yes | yes | Human-readable documentation |
metadata |
yes | yes | Arbitrary key-value pairs |
inactivity_timeout |
yes | yes | Seconds before handler is considered inactive |
abort_timeout |
yes | yes | Seconds before a stuck handler is aborted |
journal_retention |
yes | yes | Seconds to retain the invocation journal |
idempotency_retention |
yes | yes | Seconds to retain idempotency keys |
ingress_private |
yes | yes | Hide from public ingress |
enable_lazy_state |
yes | yes | Fetch state on demand (VirtualObject/Workflow) |
invocation_retry_policy |
yes | yes | Custom retry policy for handler invocations |
workflow_completion_retention |
— | main only | Seconds to retain workflow completion |
The endpoint binds services and creates the Rack application.
# Bind multiple services
endpoint = Restate.endpoint(Greeter, Counter, UserSignup)
# Or bind incrementally
endpoint = Restate.endpoint
endpoint.bind(Greeter)
endpoint.bind(Counter, UserSignup)
# Force protocol mode (auto-detected by default)
endpoint.streaming_protocol # Force bidirectional streaming
endpoint.request_response_protocol # Force request/response
# Add identity verification keys
endpoint.identity_key('publickeyv1_...')
# Get the Rack app
run endpoint.app # In config.ruThe input: and output: options on handler declarations let you use typed structs for
handler I/O. The SDK automatically deserializes input JSON into struct instances and generates
JSON Schema for Restate's discovery protocol.
Two struct libraries are supported out of the box — pick whichever fits your project:
If you already use Sorbet, T::Struct gives you full type safety
and IDE support with no extra dependencies.
require 'restate'
class GreetingRequest < T::Struct
const :name, String
const :greeting, T.nilable(String)
end
class Greeter < Restate::Service
handler :greet, input: GreetingRequest, output: String
def greet(ctx, request)
# request is a GreetingRequest instance, not a raw Hash
greeting = request.greeting || "Hello"
"#{greeting}, #{request.name}!"
end
endThe SDK introspects T::Struct props to generate JSON Schema. Serialization uses
T::Struct#serialize and .from_hash.
Supported Sorbet type mappings:
| Sorbet type | JSON Schema |
|---|---|
String |
{type: 'string'} |
Integer |
{type: 'integer'} |
Float |
{type: 'number'} |
T::Boolean |
{type: 'boolean'} |
T.nilable(String) |
{anyOf: [{type: 'string'}, {type: 'null'}]} |
T::Array[String] |
{type: 'array', items: {type: 'string'}} |
T::Hash[String, Integer] |
{type: 'object'} |
Nested T::Struct |
Recursive object schema |
dry-struct is a popular typed struct library that works without Sorbet. Add it as an optional dependency:
gem 'dry-struct'require 'restate'
require 'dry-struct'
module Types
include Dry.Types()
end
class GreetingRequest < Dry::Struct
attribute :name, Types::String
attribute? :greeting, Types::String # optional attribute
end
class Greeter < Restate::Service
handler :greet, input: GreetingRequest, output: String
def greet(ctx, request)
# request is a GreetingRequest instance, not a raw Hash
greeting = request.greeting || "Hello"
"#{greeting}, #{request.name}!"
end
endSupported dry-types mappings:
| dry-types | JSON Schema |
|---|---|
Types::String |
{type: 'string'} |
Types::Integer |
{type: 'integer'} |
Types::Float |
{type: 'number'} |
Types::Bool |
{type: 'boolean'} |
Types::Integer.optional |
{anyOf: [{type: 'integer'}, {type: 'null'}]} |
Types::Array.of(Types::String) |
{type: 'array', items: {type: 'string'}} |
Nested Dry::Struct |
Recursive object schema |
Both struct types are auto-detected at runtime — no configuration needed. When a handler
declares input: MyRequest:
- Input JSON is deserialized into a struct instance (not a raw Hash)
- JSON Schema is generated from the struct definition and published via Restate discovery
- Output is serialized based on the
output:type
You can also use primitive Ruby types for simple handlers:
handler :greet, input: String, output: String
handler :compute, input: Integer, output: IntegerThese generate the corresponding JSON Schema ({type: 'string'}, {type: 'integer'}, etc.)
and use standard JSON serialization.
When input: or output: is provided, the SDK resolves a serde in this order:
- Serde object — if it responds to
serializeanddeserialize, use it directly - T::Struct subclass — use
TStructSerde(Sorbet native) - Dry::Struct subclass — use
DryStructSerde - Primitive type (
String,Integer, etc.) — useJsonSerdewith type schema - Class with
.json_schema— useJsonSerdewith that schema - Fallback —
JsonSerdewith no schema
| Serde | Serialize | Deserialize | Use Case |
|---|---|---|---|
JsonSerde (default) |
JSON.generate |
JSON.parse |
Structured data |
BytesSerde |
Pass-through | Pass-through | Raw bytes |
Implement a module with serialize and deserialize:
module MarshalSerde
def self.serialize(obj)
Marshal.dump(obj).b
end
def self.deserialize(buf)
Marshal.load(buf) # rubocop:disable Security/MarshalLoad
end
end
# Use in handler registration
handler :process, input: MarshalSerde, output: MarshalSerdeRaise TerminalError to fail a handler permanently (no retries).
raise Restate::TerminalError.new('not found', status_code: 404)Terminal errors propagate through service calls:
begin
ctx.service_call(OtherService, :handler, arg).await
rescue Restate::TerminalError => e
e.message # Error message
e.status_code # HTTP status code
endAny StandardError (other than TerminalError) triggers a retry of the entire invocation.
Restate automatically retries with exponential backoff.
Do not use bare rescue => e in handlers — it catches internal SDK control flow exceptions
(SuspendedError, InternalError) and breaks the durability protocol.
# BAD — catches SuspendedError
begin
result = ctx.service_call(Other, :handler, arg).await
rescue => e
handle_error(e)
end
# GOOD — catch only what you mean
begin
result = ctx.service_call(Other, :handler, arg).await
rescue Restate::TerminalError => e
handle_error(e)
endThe SDK works out of the box with Ruby LSP in VSCode. Install the Ruby LSP extension and you'll get code completion, hover docs, and go-to-definition for all Restate types — no extra setup needed.
Add YARD @param tags to your handlers for full context completion:
class Greeter < Restate::Service
# @param ctx [Restate::Context]
handler def greet(ctx, name)
ctx.run_sync('step') { "Hello, #{name}!" }
end
endContext types by service type:
| Service type | Handler kind | Context type |
|---|---|---|
Service |
handler |
Restate::Context |
VirtualObject |
handler (exclusive) |
Restate::ObjectContext |
VirtualObject |
shared |
Restate::ObjectSharedContext |
Workflow |
main |
Restate::WorkflowContext |
Workflow |
handler (shared) |
Restate::WorkflowSharedContext |
For full static type checking, the SDK ships RBI files inside the gem and a Tapioca DSL compiler that generates typed handler signatures.
1. Add Sorbet and Tapioca to your Gemfile:
group :development do
gem 'sorbet', require: false
gem 'tapioca', require: false
end2. Generate type information:
bundle install
bundle exec tapioca gems # Generate RBI for all gems (one-time)
bundle exec tapioca dsl # Generate typed handler signaturesThis creates RBI files under sorbet/rbi/. For example, given:
class Counter < Restate::VirtualObject
handler def add(ctx, addend)
old = ctx.get('count') || 0
ctx.set('count', old + addend)
end
shared def get(ctx)
ctx.get('count') || 0
end
endTapioca generates:
# sorbet/rbi/dsl/counter.rbi (auto-generated, do not edit)
class Counter
sig { params(ctx: Restate::ObjectContext, input: T.untyped).returns(T.untyped) }
def add(ctx, input); end
sig { params(ctx: Restate::ObjectSharedContext).returns(T.untyped) }
def get(ctx); end
endRun tapioca dsl again whenever you add or rename handlers. Commit the generated
sorbet/rbi/ files to version control so the whole team benefits.
cd examples
bundle exec falcon serve --bind http://localhost:9080 -n 1bundle exec falcon serve --bind http://0.0.0.0:9080FROM ruby:3.3-slim-bookworm
RUN apt-get update && apt-get install -y build-essential curl clang \
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle install && bundle exec rake compile
COPY . .
CMD ["bundle", "exec", "falcon", "serve", "--bind", "http://0.0.0.0:9080"]# Using Restate CLI
restate deployments register http://localhost:9080
# Using admin API directly
curl http://localhost:9070/deployments \
-H 'content-type: application/json' \
-d '{"uri": "http://localhost:9080"}'
# Force re-register after code changes
curl http://localhost:9070/deployments \
-H 'content-type: application/json' \
-d '{"uri": "http://localhost:9080", "force": true}'The SDK ships a test harness that starts a real Restate server via Docker, serves your services on a local Falcon server, and registers them automatically. No external setup is needed — just Docker.
Opt-in with require 'restate/testing'. Add testcontainers-core to your Gemfile:
gem 'testcontainers-core', require: falserequire 'restate/testing'
Restate::Testing.start(Greeter, Counter) do |env|
# env.ingress_url => "http://localhost:32771"
# env.admin_url => "http://localhost:32772"
uri = URI("#{env.ingress_url}/Greeter/greet")
request = Net::HTTP::Post.new(uri)
request['Content-Type'] = 'application/json'
request.body = '"World"'
response = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(request) }
puts response.body # => "Hello, World!"
end
# Container and server are automatically cleaned up.require 'restate/testing'
RSpec.describe 'my services' do
before(:all) do
@harness = Restate::Testing::RestateTestHarness.new(Greeter, Counter)
@harness.start
end
after(:all) do
@harness&.stop
end
it 'greets' do
uri = URI("#{@harness.ingress_url}/Greeter/greet")
request = Net::HTTP::Post.new(uri)
request['Content-Type'] = 'application/json'
request.body = '"World"'
response = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(request) }
expect(JSON.parse(response.body)).to eq('Hello, World!')
end
endAll options are keyword arguments on both start and RestateTestHarness.new:
| Option | Default | Description |
|---|---|---|
restate_image: |
"docker.io/restatedev/restate:latest" |
Docker image for Restate server |
always_replay: |
false |
Force replay on every suspension point (useful for catching non-determinism bugs) |
disable_retries: |
false |
Disable Restate retry policy |
Restate::Testing.start(MyService, always_replay: true, disable_retries: true) do |env|
# ...
endmake test-harness # Requires Docker| Service Type | URL Pattern | Example |
|---|---|---|
| Service | /ServiceName/handler |
/Greeter/greet |
| VirtualObject | /ObjectName/key/handler |
/Counter/my-counter/add |
| Workflow | /WorkflowName/key/handler |
/UserSignup/user42/run |
The examples/ directory contains runnable examples:
| File | Shows |
|---|---|
greeter.rb |
Hello World: simplest stateless service |
durable_execution.rb |
ctx.run, ctx.run_sync, background: true, RunRetryPolicy, TerminalError |
virtual_objects.rb |
State ops, handler vs shared, state_keys, clear_all |
workflow.rb |
Promises, signals, workflow state |
service_communication.rb |
Calls, sends, fan-out/fan-in, wait_any, awakeables |
typed_handlers.rb |
input:/output: with Dry::Struct, JSON Schema generation |
typed_handlers_sorbet.rb |
input:/output: with T::Struct (Sorbet), JSON Schema generation |
service_configuration.rb |
Service-level config: timeouts, retention, retry policy, lazy state |
Run any example:
cd examples
bundle exec falcon serve --bind http://localhost:9080
restate deployments register http://localhost:9080class MyService < Restate::Service
handler def method(ctx, arg)
# ctx is always the first parameter
end
end
class MyObject < Restate::VirtualObject
handler def exclusive_method(ctx, arg) # One at a time per key
end
shared def concurrent_method(ctx) # Many readers
end
end
class MyWorkflow < Restate::Workflow
main def run(ctx, arg) # Runs once per key
end
handler def query(ctx) # Shared handler
end
end# State (VirtualObject / Workflow)
ctx.get(name) → value | nil
ctx.get_async(name) → DurableFuture
ctx.set(name, value)
ctx.clear(name)
ctx.clear_all
ctx.state_keys → Array[String]
ctx.state_keys_async → DurableFuture
# Durable execution
ctx.run(name, background: false) { block } → DurableFuture
ctx.run_sync(name, background: false) { block } → value # run + await
ctx.sleep(seconds) → DurableFuture
# Service calls
ctx.service_call(svc, handler, arg) → DurableCallFuture
ctx.object_call(svc, handler, key, arg) → DurableCallFuture
ctx.workflow_call(svc, handler, key, arg) → DurableCallFuture
# Fire-and-forget
ctx.service_send(svc, handler, arg, delay: nil) → SendHandle
ctx.object_send(svc, handler, key, arg, delay: nil) → SendHandle
ctx.workflow_send(svc, handler, key, arg, delay: nil) → SendHandle
# Awakeables
ctx.awakeable → [id, DurableFuture]
ctx.resolve_awakeable(id, payload)
ctx.reject_awakeable(id, message, code: 500)
# Promises (Workflow only)
ctx.promise(name) → value # Blocks until resolved
ctx.peek_promise(name) → value | nil
ctx.resolve_promise(name, payload)
ctx.reject_promise(name, message, code: 500)
# Futures
ctx.wait_any(*futures) → [completed, remaining]
# Metadata
ctx.request → Request{id, headers, body}
ctx.request.attempt_finished_event → AttemptFinishedEvent
ctx.key → String
# Cancellation
ctx.cancel_invocation(invocation_id)Restate.current_context # → Context (any handler)
Restate.current_object_context # → ObjectContext (exclusive — full state)
Restate.current_shared_context # → ObjectSharedContext (shared — read-only state)
Restate.current_workflow_context # → WorkflowContext (main — full state + promises)
Restate.current_shared_workflow_context # → WorkflowSharedContext (shared — read-only + promises)# DurableFuture (from ctx.run, ctx.sleep)
future.await → value
future.completed? → bool
# DurableCallFuture (from ctx.service_call, etc.)
future.await → value
future.completed? → bool
future.invocation_id → String
future.cancel
# SendHandle (from ctx.service_send, etc.)
handle.invocation_id → String
handle.cancel