DEV Community

Jules Smeets
Jules Smeets

Posted on

Dynamic machine ID leases in Elixir

Distributed ID generators often look simple: combine a timestamp, a counter, and a machine ID. NoNoncense uses that idea for fast counter, sortable, and encrypted nonces. It is a wonderfully fast design, right up until two running nodes receive the same machine ID.

The hard part is not incrementing a counter. It is deciding who may use each machine ID, especially when pods are replaced, nodes autoscale, deployments overlap, and network partitions happen. Two locally correct counters with the same machine ID can produce globally duplicate values.

NoNoncense 1.x left that decision to application startup: derive an ID, initialize the nonce factory, and make the topology safe yourself. That is perfectly reasonable for a fixed fleet. Version 2.0 brings the decision into the supervision tree and gives different deployments a suitable way to establish exclusivity.

Where exclusivity comes from

The strategy is not an afterthought; it expresses what makes an ID unique in a particular deployment:

  • SQL leases coordinate through PostgreSQL or MySQL. They are a natural fit when the application already has a database and wants a durable, inspectable source of truth.
  • Valkey or Redis leases use modern per-field hash TTLs for a compact, server-managed lease registry.
  • Kubernetes StatefulSets can use the pod ordinal through an environment variable when that ordinal is globally unique among nonce-generating pods.
  • Host identifiers retain the fixed-node model for deployments with a known, stable node list.

The last two are intentionally uncomplicated: when the platform can prove an instance's identity, NoNoncense can use it directly. The interesting work begins when it cannot and the application needs to coordinate competing nodes.

One lifecycle, several coordinators

For dynamic deployments, NoNoncense treats a machine ID as a lease. NoNoncense.MachineId starts a LeaseManager under supervision and waits for it to acquire an initial lease before application startup can complete. Only then are the configured nonce factories initialized. There is no interval in which a node generates values with an unproven ID.

The manager then renews the lease in the background. Its strategy interface distinguishes a confirmed loss from an uncertain result:

{:ok, lease, ttl_ms}
{:error, :lost, reason}
{:error, :retry, reason}
Enter fullscreen mode Exit fullscreen mode

lost means the coordinator has positively rejected ownership, perhaps because the lease expired and somebody else claimed the ID. retry means the result is uncertain: a timeout or a dropped connection might have happened before or after the coordinator applied the renewal. The manager retries uncertainty for a bounded period, but on confirmed loss or local expiry it erases the affected factories and calls the optional :on_lease_lost callback. Continuing after that would trade an availability problem for a uniqueness problem, so the library chooses uniqueness.

For dynamic strategies, it then retries acquisition with backoff and reinitializes the factories only after it holds a new lease. That makes recovery part of the component's normal lifecycle rather than a bespoke application failure path.

The manager also has a small, in-memory lease cache. If the manager itself crashes and supervision restarts it, it first tries to renew the cached lease rather than needlessly claim another one of the 512 available IDs. The cache is deliberately not durable: after a node crash or forced kill, normal lease expiry remains the source of truth.

This lifecycle is not limited to the included backends. A strategy is a small behaviour that implements acquire/2, renew/3, release/2, and declares whether its IDs are deterministic. Applications can therefore bring a coordinator with their own allocation rules while keeping the same startup, recovery, and safety policy.

With that shared lifecycle in place, the interesting question becomes how a coordinator makes ownership atomic without slowing the nonce path down.

SQL: let the database arbitrate the race

The SQL strategy works with PostgreSQL and MySQL using a small, pre-populated lease table. Each possible machine ID has a row containing an expiry time, an ownership token, and a lock version.

Acquisition is a two-stage optimistic operation:

  1. Read a small set of expired candidate rows, ordered by ID.
  2. Attempt to claim one with an UPDATE that matches both its ID and the lock_version just read.

The update sets a new random token, moves expires_at forward, and increments the version. If two nodes read the same expired row, they both try to update it, but only one update still matches the old version. The loser tries another candidate. The database turns a familiar optimistic-locking primitive into lease arbitration: no process-local lock, no table-wide lock, and no opportunity to overwrite a concurrent claim.

The strategy uses CURRENT_TIMESTAMP in the database for both expiry checks and new expirations. That is a small but important choice: eligibility is decided by one clock, rather than by application nodes whose clocks may disagree.

Once claimed, the random token becomes the lease identity. Renewals and releases match both the machine ID and that token, so a node that has lost its lease cannot extend or release the next owner's row.

Valkey and Redis: turn field existence into ownership

Redis 8 and Valkey 9 add HSETEX: conditional hash updates with a per-field TTL. That makes it possible to store all leases in one hash without a polling cleanup job.

At first, an ID field appears sufficient:

"17" => "random-token"
Enter fullscreen mode Exit fullscreen mode

But HSETEX conditions check field existence, not whether a field still holds a particular value. A renewal conditioned only on field "17" could refresh an ID after another owner had taken it. That is exactly the operation a lease protocol must reject.

NoNoncense writes two fields in one HSETEX command, with the same TTL:

"17"                    => "random-token"
"token.17.random-token" => "17"
Enter fullscreen mode Exit fullscreen mode

The first field makes occupied IDs easy to discover. The second is an ownership marker: its key contains both the ID and the token. On acquisition, HSETEX ... FNX creates both fields only if neither already exists. On renewal or release, HSETEX ... FXX succeeds only if both fields still exist.

When a lease expires, both fields disappear together. A later owner will create a new token marker, not the old one. Therefore an old owner cannot refresh or delete the new owner's lease: its marker is absent, so the conditional command returns zero updated fields and the manager treats the lease as lost.

This is the useful pattern here: when a datastore can conditionally test only existence, give each ownership generation its own expiring marker and update the resource and marker atomically. The marker converts an existence check into proof that this exact owner still has the right to act.

Fast generation, deliberate ownership

The new strategies make the same promise in different environments: nonce factories run only while the deployment can establish that their machine ID belongs to them. SQL gets there with a database clock, tokens, and optimistic compare-and-swap. Valkey and Redis get there with an atomic, expiring ownership marker. StatefulSets and fixed fleets can use the identity their platform already provides.

The optional conflict guard adds another signal for connected Erlang nodes, and optional Telemetry reports acquisition, renewal, retries, loss, release, and conflicts. Neither feature touches the nonce generation hot path.

The result is a deliberately asymmetric design: the path that generates a nonce stays tiny and fast, while the rare coordination path does the careful work needed to answer one question with confidence: who is allowed to generate values with this machine ID right now?

NoNoncense 2.0 is available on Hex. The machine ID documentation and migration guide cover configuration and upgrading from 1.x.

Top comments (0)