v0.4.0 (2026-07-12)

Enhancements

  • dgen_registry — a defined consistency model (Still considered experimental). The registry now has a precise, documented contract; see docs/dgen_registry_design.md for the full design, guarantees, and non-goals. In brief:

    • CP and fenced. Leadership is an emergent property of the backend: the elected leader is fenced on a leader key, so a node that has lost leadership physically cannot commit a registration — there is no split-brain dual-accept, and the minority side of a partition refuses writes rather than inventing a second owner.
    • Single-fault singleton uniqueness. At most one live process holds a name, preserved across the failure of any single member. Pids are never persisted; the durable footprint is ~2 keys per registry (a leader key and a version counter), independent of how many names are registered.
    • Two-holder addressability. A registration acknowledged yes is held by at least two members (a forwarded registration by the leader and the forwarding follower; a direct one by replicate-before-ack), so it survives any single node loss. Tunable per registry via register_replicas / replicate_timeout / strict_replication (default: one replica, degrade-open on timeout with telemetry).
    • Dynamic membership with an automatic mesh. Nodes join by starting the registry and leave by stopping it — no node list to maintain. The registry keeps Erlang distribution in step with membership: given nodes that can connect (shared cookie, reachability), nodes() converges to every member without an external discovery library.
    • Lock-free snapshot reads. Each member keeps its local name replica in a protected ETS table, so whereis_name/1 resolves a name with an ets:lookup/2 directly in the calling process — no round-trip through the member's mailbox. Many processes read concurrently without queuing behind the member, and the semantics are unchanged (a still-snapshot, eventually-consistent read). The table is recreated empty whenever a member (re)starts, so a read in that brief window returns undefined.
    • Per-registration metadata. A registration can carry metadata — an index map and an opaque data payload — attached at register time (register_name/3) or replaced later (set_metadata/2), and read back lock-free in the caller (get_metadata/1) or authoritatively through the leader (get_metadata_consistent/1). Metadata rides the same fenced group-commit pipeline as names, replicates to followers, survives a leadership handoff (freshest wins), and is removed automatically when the process exits or is unregistered — its lifetime is exactly the registration's. See §4.7 of the design doc.
    • Indexed metadata queries. Each member maintains an inverted index over the registrations' index maps, so query/2 (local snapshot) and query_consistent/2 (leader-authoritative) find every registration matching a conjunction of exact equalities (AND-equal) over indexed attributes, returning #{name, pid, index, data} matches. Queries run on the member's mailbox, so they observe a batch-consistent snapshot (never a half-applied commit) at the cost of serialising — a deliberate trade, since single-key reads stay lock-free. An empty constraint map is rejected; data is not queryable.
    • Durable presence. A caller registers a watch query and a notify query under an application-chosen SubId; whenever a committed batch changes which processes satisfy the watch query, the registry pushes {dgen_presence, SubId, Events} (events {joined | left, Name, Pid}) to every process matching the notify query — a live feed built on the one-shot query/2 (§4.7). Subscribing delivers an initial snapshot of the current watch set, and the feed is correct for every way the set can move: register, unregister, process death, or a set_metadata that shifts a row in or out of the query. Subscriptions are durable — stored in the elector's backend-backed dgen_server state (keyed by SubId, an idempotent upsert), so they outlive the whole cluster: an application can tie a subscription to a database entity and have presence come back intact after a scale-to-zero.
    • Formally verified replication. The replication protocol's core safety properties — prefix consistency, unique binding, two-holder durability across a single node failure, and leader-epoch fencing — are checked exhaustively against a TLA+ model of the protocol (formal/), not just tested. See formal/README.md for how to run the checker and what it covers.
  • dgen_transaction (new behaviour). Owns a single backend transaction in its own process (create → body → commit → retry, with caller-controlled retry semantics), delivering the outcome to an owner. It is the substrate for the registry's non-blocking, cached-GRV group commit.

  • dgen_serverconsume_k fully documented, and inlining disabled when consume_k > 1. The consume_k batch-size option is now documented (batch semantics, the throughput/transaction-size trade-off, the single-consumer property). When consume_k > 1, opportunistic inline handling of calls is disabled so every call rides the durable queue and the batched consume loop — keeping consume_k always in effect (and, for the registry, a stable consumer and leader). priority_call/priority_cast still bypass the queue, as before.

Behavioral notes

  • In rare cases, the registry may forcibly terminate a registered process to enforce uniqueness. Register only processes that can withstand being forcibly killed — supervised and restart-safe, or transient by design. This is intentional (a singleton registry) and configurable (terminate_on_conflict, default true). See §5.6 of the design doc.

Breaking changes

  • dgen_registry:start_link/3 (the SupName-embedding variant) is removed. It was unused and is superseded by the "zero atoms created per registry" change above: the registry's own supervisor is never registered under a name (embedding it under a caller-chosen name doesn't fit a nameless-by-design supervisor). start_link/2 and the options-map start_link/3 (Name, Tenant, Opts) are unaffected. Callers should hold onto the returned supervisor pid instead of a name.
  • dgen_registry:elector_name/1 is removed in favor of elector_pid/1, which returns the elector's current pid via supervisor lookup rather than a registered name (the elector no longer has one). member_name/1 and names_table/1 are kept for compatibility but are now identity functions (member_name(Name) =:= Name).
  • dgen_registry:register_name/2,3 exits on a call timeout instead of answering no. no is now strictly an adjudicated verdict (name taken / no leader / leader unreachable); a timeout — where the registration may or may not have committed — propagates as a call exit, matching global/syn/gproc conventions, so a supervisor restarts the caller rather than acting on a wrong "already taken" answer. The bound is configurable via the new register_timeout application-environment knob (default 5000 ms; §8 of the design doc).

v0.3.0 (2026-06-17)

Enhancements

  • Transactional callback variantshandle_cast_tx/3, handle_call_tx/4, and handle_info_tx/3 are optional callback variants that receive a tx_ctx() map as their first argument (#{td := tenant(), tuid := tuid()}). When exported, the _tx variant is preferred over the plain variant. The td value is the open backend transaction+directory pair for the current consume cycle, allowing callbacks to read or write arbitrary keys atomically alongside the module-state commit. The new tx_ctx/0 type is exported from dgen_server.

  • lock_timeout option for dgen_server — Sets the maximum milliseconds a distributed lock may be held before other consumers treat it as stale and clear it. Previously a consumer killed (SIGKILL / VM abort) while holding the lock would block all other consumers permanently if no new messages arrived to trigger a re-check. With lock_timeout set, a backstop timer is scheduled whenever a live lock is observed: after the remaining timeout the consumer re-evaluates staleness and clears the lock if the holder has not done so. infinity (the default) preserves the previous behaviour. dgen_registry uses lock_timeout: 6_000.

  • dgen_registry — Experimental. An OTP-compatible process registry backed by the configured storage backend. Implements the four-function {via, dgen_registry, {Name, LogicalName}} contract so standard OTP processes (gen_server, gen_statem, etc.) can be started and addressed by name across an Erlang cluster. Writes and consistent reads go through an elected leader; whereis_name/1 (used by OTP via-tuple routing) is a snapshot read from the local member's in-memory map with no backend round-trip. The leader monitors registered pids and propagates {name_unregistered} to followers on process exit. Start with dgen_registry:start_link(Name, Tenant) and supply a supervisor name as the first argument to start_link/3 to embed it in an existing supervision tree.

    Partition recovery is reliable: each join carries a unique token so stale member_down messages from before a reconnect are discarded rather than undoing the rejoin. Leader transitions during a partition no longer trigger automatic distribution reconnect.

Breaking changes

  • DGenServer renamed to DGen.Serveruse DGenServer becomes use DGen.Server; all DGenServer.* call sites become DGen.Server.*. The module now lives at lib/dgen/server.ex.

  • handle_locked/3handle_locked/4 — a db_ctx() map is now prepended as the first argument, matching the convention of handle_call_tx/4 and friends. db_ctx() carries #{db := tenant(), tuid := tuid()} where db is the DB-level tenant (not a transaction); use dgen_backend:transactional/2 inside the callback to open explicit transactions. Update all handle_locked implementations to accept the new first argument.

v0.2.0 (2026-04-05)

Enhancements

  • Dead-letter queue — opt-in poison-message handling via the new dead_letter_threshold start option (default infinity, i.e. disabled). When set to a positive integer, messages that crash the consumer that many times are moved to a dead-letter queue (DLQ) stored in FoundationDB instead of being retried indefinitely. For call messages the blocked caller raises {dead_letter, N}. The optional handle_dead_letter/2 callback is invoked when a message is dead-lettered.

  • dgen_server:outbox_cast/1,2 — returns a Cast = fun((Tx, Message) -> ok) closure for enqueuing a cast message atomically within the caller's own FDB transaction. Call it before opening the transaction as a preparatory step; the closure captures the queue directory and identifier internally. Intended for callers already operating directly with a backend transaction who need to compose the enqueue with other writes atomically.

v0.1.0 (2026-02-22)

Initial release.