# DurableObject
Durable Objects for Elixir - persistent, single-instance objects accessed by ID.
This library provides a programming model for stateful, persistent actors in Elixir, leveraging native GenServer capabilities, Ecto for persistence, and the Spark DSL for a declarative developer experience.
## Features
- **Global Uniqueness**: One instance per (module, object_id) pair across the cluster
- **Persistent State**: State survives process crashes and restarts via Ecto
- **Automatic Lifecycle**: Processes hibernate after inactivity, optionally shut down
- **Alarm Scheduling**: Built-in support for future work with database-backed persistence
- **Declarative DSL**: Define objects with Spark DSL for clean, expressive code
- **Distribution Ready**: Optional Horde integration for multi-node clusters
## Installation
Add `durable_object` to your dependencies:
```elixir
def deps do
[
{:durable_object, "~> 0.1.0"},
# Optional: for distributed mode
{:horde, "~> 0.10"},
# Optional: for Oban-based alarm scheduling
{:oban, "~> 2.17"}
]
end
```
### Quick Setup with Igniter
```bash
mix igniter.install durable_object
```
### Manual Setup
1. Generate the migration:
```bash
mix ecto.gen.migration add_durable_objects
```
2. Update the migration file:
```elixir
defmodule MyApp.Repo.Migrations.AddDurableObjects do
use Ecto.Migration
def up, do: DurableObject.Migration.up()
def down, do: DurableObject.Migration.down()
end
```
3. Run the migration:
```bash
mix ecto.migrate
```
4. Configure DurableObject in your application:
```elixir
# config/config.exs
config :durable_object,
repo: MyApp.Repo,
registry_mode: :local, # or :horde for distributed
scheduler: DurableObject.Scheduler.Polling,
scheduler_opts: [polling_interval: :timer.seconds(30)]
```
## Usage
### Define a Durable Object
```elixir
defmodule MyApp.Counter do
use DurableObject
state do
field :count, :integer, default: 0
field :last_incremented_at, :utc_datetime
end
handlers do
handler :increment, args: [:amount]
handler :get
handler :reset
end
options do
hibernate_after :timer.minutes(5)
shutdown_after :timer.hours(1)
end
def handle_increment(amount \\ 1, state) do
new_count = Map.get(state, :count, 0) + amount
new_state = %{state | count: new_count, last_incremented_at: DateTime.utc_now()}
{:reply, new_count, new_state}
end
def handle_get(state) do
{:reply, Map.get(state, :count, 0), state}
end
def handle_reset(state) do
{:reply, :ok, %{state | count: 0}}
end
end
```
### Use the Generated Client API
The DSL automatically generates client functions:
```elixir
# Increment by 5
{:ok, 5} = MyApp.Counter.increment("user-123", 5)
# Get current count
{:ok, 5} = MyApp.Counter.get("user-123")
# Reset
{:ok, :ok} = MyApp.Counter.reset("user-123")
```
### Or Use the Generic API
```elixir
{:ok, 5} = DurableObject.call(MyApp.Counter, "user-123", :increment, [5])
{:ok, 5} = DurableObject.call(MyApp.Counter, "user-123", :get)
```
## Alarms
Schedule work to happen in the future:
```elixir
defmodule MyApp.RateLimiter do
use DurableObject
state do
field :requests, :integer, default: 0
field :window_start, :utc_datetime
end
handlers do
handler :check, args: [:limit]
end
# Schedule initial alarm when object is first loaded
@impl DurableObject.Behaviour
def after_load(state) do
if is_nil(state.window_start) do
{:ok, %{state | window_start: DateTime.utc_now()},
{:schedule_alarm, :reset_window, :timer.minutes(1)}}
else
{:ok, state}
end
end
def handle_check(limit, state) do
if state.requests < limit do
{:reply, :allowed, %{state | requests: state.requests + 1}}
else
{:reply, :rate_limited, state}
end
end
@impl DurableObject.Behaviour
def handle_alarm(:reset_window, state) do
# Reset the window and reschedule
{:noreply, %{state | requests: 0, window_start: DateTime.utc_now()},
{:schedule_alarm, :reset_window, :timer.minutes(1)}}
end
end
```
## Distribution with Horde
For multi-node clusters, enable Horde:
```elixir
# config/config.exs
config :durable_object,
registry_mode: :horde
```
This ensures:
- Only one instance of each object exists across the cluster
- Objects are automatically migrated when nodes join/leave
- Alarms fire exactly once (singleton poller)
## Telemetry
DurableObject emits telemetry events for observability:
- `[:durable_object, :storage, :save, :start | :stop | :exception]`
- `[:durable_object, :storage, :load, :start | :stop | :exception]`
- `[:durable_object, :storage, :delete, :start | :stop | :exception]`
## License
MIT License - see [LICENSE](LICENSE) for details.