{"id":50812784,"url":"https://github.com/phoenixframework/durable_server","last_synced_at":"2026-06-13T06:33:51.144Z","repository":{"id":357967584,"uuid":"1135207903","full_name":"phoenixframework/durable_server","owner":"phoenixframework","description":null,"archived":false,"fork":false,"pushed_at":"2026-06-12T15:29:43.000Z","size":684,"stargazers_count":66,"open_issues_count":4,"forks_count":4,"subscribers_count":2,"default_branch":"main","last_synced_at":"2026-06-12T16:19:41.101Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Elixir","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/phoenixframework.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.md","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-01-15T19:37:06.000Z","updated_at":"2026-06-11T22:35:30.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/phoenixframework/durable_server","commit_stats":null,"previous_names":["phoenixframework/durable_server"],"tags_count":4,"template":false,"template_full_name":null,"purl":"pkg:github/phoenixframework/durable_server","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/phoenixframework%2Fdurable_server","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/phoenixframework%2Fdurable_server/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/phoenixframework%2Fdurable_server/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/phoenixframework%2Fdurable_server/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/phoenixframework","download_url":"https://codeload.github.com/phoenixframework/durable_server/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/phoenixframework%2Fdurable_server/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34275068,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-13T02:00:06.617Z","response_time":62,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-13T06:33:49.187Z","updated_at":"2026-06-13T06:33:51.138Z","avatar_url":"https://github.com/phoenixframework.png","language":"Elixir","funding_links":[],"categories":[],"sub_categories":[],"readme":"# DurableServer\n\nDurableServer provides durable, distributed GenServer processes backed by pluggable storage backends.\n\nIt implements fault-tolerant, stateful processes that can survive node failures, restarts, and deployments by automatically persisting state and coordinating across a distributed cluster.\n\n## Key Features\n\n- **Durable state**: Automatically persists state to storage with configurable sync intervals\n- **Cluster coordination**: Uses distributed registry for process discovery and health monitoring\n- **Capacity-aware placement**: Monitors CPU, memory, and disk usage to route new processes to nodes with available capacity\n- **Sticky placement**: Environment variable-based placement preferences (e.g., same machine, same region, etc.) with time-gated fallback to ensure servers restart on preferred nodes when possible\n- **Automatic recovery**: Failed processes are detected and restarted across the cluster\n- **Graceful shutdown**: Ensures state is synchronized before termination\n- **Lifecycle monitoring \u0026 dispatch**: Monitor lifecycle events and dispatch messages between DurableServers and other processes\n- **Pluggable backends**: Run with object storage, EKV, or a dual-backend migration adapter\n\n## Installation\n\nAdd `durable_server` to your list of dependencies in `mix.exs`:\n\n```elixir\ndef deps do\n  [\n    {:durable_server, \"~\u003e 0.1.0\"}\n  ]\nend\n```\n\nFor releases, add `:os_mon` to `extra_applications`:\n\n```elixir\ndef application do\n  [\n    mod: {MyApp.Application, []},\n    extra_applications: [:logger, :runtime_tools, :os_mon]\n  ]\nend\n```\n\n## Basic Usage\n\n```elixir\ndefmodule MyCounterServer do\n  use DurableServer, vsn: 1\n\n  def dump_state(state), do: %{count: state.count}\n\n  def load_state(_old_vsn, %{\"count\" =\u003e count}), do: %{count: count}\n\n  def init(%{count: count} = state) do\n    {:ok, Map.merge(state, %{started_at: DateTime.utc_now()})}\n  end\n\n  def handle_call(:increment, _from, state) do\n    new_state = %{state | count: state.count + 1}\n    {:reply, new_state.count, new_state}\n  end\n\n  def handle_call(:get_count, _from, state) do\n    {:reply, state.count, state}\n  end\nend\n```\n\nStart the supervisor (typically in your application supervision tree):\n\n```elixir\nchildren = [\n  {DurableServer.Supervisor,\n   name: MyDurableSup,\n   prefix: \"my_app/\",\n   object_store: [\n     bucket: \"my-bucket\",\n     access_key_id: System.fetch_env!(\"DURABLE_AWS_ACCESS_KEY_ID\"),\n     secret_access_key: System.fetch_env!(\"DURABLE_AWS_SECRET_ACCESS_KEY\"),\n     s3_endpoint: System.fetch_env!(\"DURABLE_AWS_ENDPOINT_URL_S3\"),\n     default_region: System.fetch_env!(\"DURABLE_AWS_REGION\")\n   ]}\n]\n```\n\nStart and use individual servers:\n\n```elixir\n{:ok, {pid, _meta}} = DurableServer.Supervisor.start_child(\n  MyDurableSup,\n  {MyCounterServer, key: \"user_123\", initial_state: %{count: 0}}\n)\n\nGenServer.call(pid, :increment)  # =\u003e 1\nGenServer.call(pid, :increment)  # =\u003e 2\nGenServer.call(pid, :get_count)  # =\u003e 2\n```\n\n`:initial_state` is required and must be a map. On first boot, DurableServer\npasses it through `dump_state/1`, the configured backend's encode/decode path,\nand then `load_state/2` before `init/1` or `init/2`. The dumped initial state\nmust therefore be encodable by your configured backend.\n\n## Storage Backends\n\n`DurableServer` includes two built-in backends:\n\n### Object Storage Backend\n\n```elixir\n{DurableServer.Supervisor,\n name: MyDurableSup,\n prefix: \"my_app/\",\n backend: {DurableServer.Backends.ObjectStore,\n  [\n    bucket: \"my-bucket\",\n    access_key_id: \"...\",\n    secret_access_key: \"...\",\n    s3_endpoint: \"...\",\n    default_region: \"...\"\n  ]}}\n```\n\n### EKV Backend\n\nStart EKV in your application tree (CAS config is required for DurableServer lock semantics):\n\n```elixir\nekv_config = [\n  name: :durable_ekv,\n  data_dir: \"/path/to/ekv_store\",\n  cluster_size: 3\n]\n\nchildren = [\n  {EKV,\n   name: :durable_ekv,\n   data_dir: \"/data/ekv/durable\",\n   cluster_size: 3,\n   node_id: System.fetch_env!(\"EKV_NODE_ID\")},\n  {DurableServer.Supervisor,\n   name: MyDurableSup,\n   prefix: \"my_app/\",\n   backend: {DurableServer.Backends.EKVStore, ekv_config}}\n]\n```\n\nIf you use EKV backend, add EKV to your app's dependencies.\n\n### Mirror Backend (Object Storage -\u003e EKV)\n\nUse the mirror backend to dual-write while you cut over reads/writes in phases.\n\nSee `DurableServer.Backends.MirrorStore` for usage and an example rollout.\n\n## Configuration Options\n\nDurableServer supports these options in the `init/1` return tuple:\n\n- `:auto_sync` - Enable automatic periodic syncing (default: false)\n- `:sync_every_ms` - Sync interval in milliseconds (default: 30_000)\n- `:meta` - Optional metadata included in the global registry\n\n## State Synchronization\n\nState is synchronized to storage in these scenarios:\n\n1. **Manual sync**: Return `:sync` from any callback: `{:noreply, state, :sync}`\n2. **Automatic sync**: When `:auto_sync` is enabled, changes sync on the `:sync_every_ms` interval\n3. **Graceful shutdown**: State is always synced before termination\n\n## Group\n\n`Group` provides distributed process groups, registry, lifecycle monitoring, and isolated subclusters.\n\n### Monitoring Events\n\nMonitor lifecycle events for DurableServers:\n\n```elixir\n# Monitor a specific key\n:ok = Group.monitor(MyDurableSup, \"user/123\")\n\n# Monitor all keys with a prefix\n:ok = Group.monitor(MyDurableSup, \"user/\")\n\n# Monitor all events\n:ok = Group.monitor(MyDurableSup, :all)\n```\n\nMonitors receive `{:group, events, info}` tuples in their mailbox:\n\n```elixir\ndef handle_info({:group, events, _info}, state) do\n  Enum.each(events, fn\n    %Group.Event{type: :registered, key: key, pid: pid, previous_meta: nil} -\u003e\n      # A DurableServer started (previous_meta is nil for first registration)\n      :ok\n    %Group.Event{type: :unregistered, key: key, reason: reason} -\u003e\n      # A DurableServer stopped\n      :ok\n    _ -\u003e :ok\n  end)\n  {:noreply, state}\nend\n```\n\nEvent types: `:registered`, `:unregistered`, `:joined`, `:left`\n\n`:registered` and `:joined` events include a `previous_meta` field (`nil` for new, old meta for re-register/re-join). Single operations produce one event per tuple; bulk operations (nodedown, process death) batch all events together.\n\n### Joining as a Member\n\nNon-DurableServer processes can join keys to be discoverable and receive dispatched messages:\n\n```elixir\n# Join a key (e.g., from a Phoenix Channel)\n:ok = Group.join(MyDurableSup, \"room/123\", %{type: :channel})\n\n# Re-joining updates metadata in place\n:ok = Group.join(MyDurableSup, \"room/123\", %{type: :channel, status: :active})\n\n# Query all members of a key (DurableServers + joined processes)\nmembers = Group.members(MyDurableSup, \"room/123\")\n# =\u003e [{#PID\u003c0.150.0\u003e, %{...}}, {#PID\u003c0.200.0\u003e, %{type: :channel, status: :active}}]\n\n# Leave when done (also happens automatically on process death)\n:ok = Group.leave(MyDurableSup, \"room/123\")\n```\n\n### Dispatching to Members\n\nSend messages to all members of a key:\n\n```elixir\n# From a DurableServer, broadcast to all connected channels\nGroup.dispatch(MyDurableSup, state.key, {:new_message, message})\n```\n\n### Named Clusters\n\nFor advanced use cases, you can create isolated subclusters where only connected nodes receive events:\n\n```elixir\n# Connect this node to a named cluster\n:ok = Group.connect(MyDurableSup, :game_servers)\n\n# Join/monitor/dispatch with the cluster: option\n:ok = Group.join(MyDurableSup, \"room/123\", %{}, cluster: :game_servers)\n:ok = Group.monitor(MyDurableSup, :all, cluster: :game_servers)\n```\n\nNote: DurableServers always register in the default cluster to ensure global uniqueness. Named clusters are purely for the pub/sub layer.\n\n### Monitor vs Join\n\n- **`monitor/2`**: Receive lifecycle events (`:registered`, `:unregistered`, `:joined`, `:left`) - system-generated\n- **`join/3`**: Be discoverable via `members/2` and receive `dispatch/3` messages - application-level\n\nThese are independent - joining does not monitor events, and monitoring does not make you discoverable.\n\n## Running Tests\n\n### Unit Tests (with LocalStack)\n\nStart LocalStack for S3-compatible storage:\n\n```bash\ndocker run -d --name localstack -p 4566:4566 localstack/localstack\n```\n\nRun the tests:\n\n```bash\nmix test\n```\n\n### Integration Tests (with Tigris)\n\nSet the required environment variables:\n\u003e *Note*: You can add these to a gitignored .env in this project and they will be loaded\nautomatically in `test_helper.exs`\n\n```bash\nexport DURABLE_AWS_ACCESS_KEY_ID=\u003cyour-tigris-access-key\u003e\nexport DURABLE_AWS_SECRET_ACCESS_KEY=\u003cyour-tigris-secret-key\u003e\nexport DURABLE_AWS_ENDPOINT_URL_S3=https://t3.storage.dev\nexport DURABLE_AWS_ENDPOINT_URL_IAM=https://iam.storage.dev\nexport DURABLE_AWS_REGION=\u003cyour-region\u003e\nexport DURABLE_BUCKET=\u003cyour-bucket-name\u003e\n```\n\nRun integration tests (which hit t3.storage.dev directly):\n\n```bash\nmix test --include integration\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fphoenixframework%2Fdurable_server","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fphoenixframework%2Fdurable_server","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fphoenixframework%2Fdurable_server/lists"}