{"slug": "zenoh-s-put-is-fire-and-forget-get-isn-t-a-read-after-write-race-in-elixir", "title": "Zenoh's put is fire-and-forget, get isn't — a read-after-write race in Elixir", "summary": "A developer experimenting with Zenoh's Elixir bindings, Zenohex, discovered a read-after-write race condition where a put followed immediately by a get occasionally returns stale data. The issue stems from Zenoh's put being fire-and-forget, only waiting for the local publish to be queued, while get waits for a remote reply. The developer proposes a wrapper that retries the get until the written payload is confirmed, and notes an open Zenoh issue tracking the same gap.", "body_md": "This English version is an AI translation of [my original article on Qiita (in Japanese)](https://qiita.com/kikuyuta/items/578bceafe60b4bb53d31).\n\nI've been experimenting with [Zenoh](https://zenoh.io) via its Elixir bindings, [Zenohex](https://hex.pm/packages/zenohex), not for its usual pub/sub use case but for its `put`\n\n/`get`\n\nstorage feature. It mostly worked, except every so the state I picked back up was one step behind. Digging into why turned into a fun rabbit hole, so here's the writeup.\n\nTo keep things simple, strip out the GenServer part entirely and just loop `put`\n\nimmediately followed by `get`\n\non the same key:\n\n``` php\n{:ok, session_id} = Zenohex.Session.open(config)\n\nEnum.each(1..2000, fn i ->\n  payload = Integer.to_string(i)\n  :ok = Zenohex.Session.put(session_id, key, payload)\n\n  {:ok, replies} = Zenohex.Session.get(session_id, key, 3_000, consolidation: :latest)\n\n  case Enum.find(replies, &match?(%Zenohex.Sample{}, &1)) do\n    %Zenohex.Sample{payload: ^payload} -> :ok\n    %Zenohex.Sample{payload: other} -> IO.puts(\"stale! put #{payload} but got #{other}\")\n    nil -> IO.puts(\"no reply at all\")\n  end\nend)\n```\n\nOut of 2000 iterations, a small fraction print `stale!`\n\n— about 78 (3.9%) in one run. The interesting part: querying again immediately afterward almost always returns the correct value (the fastest I measured was a single extra `get`\n\nabout 1ms later). So it's not that the value disappears — there's just a small window of lag before the write is actually visible.\n\n`Zenohex.Session.put/4`\n\nis a thin Rustler wrapper around zenoh-rust's `put`\n\n. Looking at the [NIF implementation](https://github.com/biyooon-ex/zenohex/blob/main/native/zenohex_nif/src/session.rs):\n\n``` php\nfn session_put(...) -> rustler::NifResult<rustler::Atom> {\n    ...\n    publication_builder\n        .apply_opts(opts)?\n        .wait()   // <- only waits for the local publish to be queued\n        ...\n    Ok(rustler::types::atom::ok())\n}\n```\n\n`.wait()`\n\nonly waits for the local session to finish handing the message off — not for the remote side (the `zenohd`\n\nrouter backing the storage) to actually receive and apply it. `session_get`\n\n, on the other hand, is registered as a `DirtyIo`\n\nNIF and genuinely waits for a reply from the remote side within a timeout — a real request/response.\n\nIf you think of it in Elixir/GenServer terms, `put`\n\nbehaves like `cast`\n\nand `get`\n\nbehaves like `call`\n\n. Firing a `cast`\n\nand immediately assuming the effect is visible, then doing a `call`\n\nthat depends on it, is exactly the kind of race this pattern invites.\n\nThis isn't just me — zenoh itself has an open issue tracking the same gap: [eclipse-zenoh/zenoh#2511](https://github.com/eclipse-zenoh/zenoh/issues/2511) (\"[Design] Acknowledged put: confirmed storage writes via query path vs protocol extension\"), still open as of this writing. One line from it sums up the whole thing:\n\nZenoh's pub/sub path is fire-and-forget —\n\n`session.put()`\n\nreturns when the message is sent, not when it's stored.\n\nA small wrapper: `put`\n\n, then `get`\n\nthe same key right after, and only return once the written payload can actually be read back. Retry at a short interval until a timeout is reached.\n\n```\ndefmodule ZenohAckPut do\n  @default_confirm_timeout_ms 3_000\n  @default_confirm_interval_ms 1\n  @default_query_timeout_ms 3_000\n\n  def put(session_id, key_expr, payload, put_opts \\\\ [], confirm_opts \\\\ []) do\n    confirm_timeout_ms =\n      Keyword.get(confirm_opts, :confirm_timeout_ms, @default_confirm_timeout_ms)\n\n    confirm_interval_ms =\n      Keyword.get(confirm_opts, :confirm_interval_ms, @default_confirm_interval_ms)\n\n    query_timeout_ms = Keyword.get(confirm_opts, :query_timeout_ms, @default_query_timeout_ms)\n\n    with :ok <- Zenohex.Session.put(session_id, key_expr, payload, put_opts) do\n      deadline = System.monotonic_time(:millisecond) + confirm_timeout_ms\n      confirm(session_id, key_expr, payload, query_timeout_ms, confirm_interval_ms, deadline)\n    end\n  end\n\n  defp confirm(session_id, key_expr, payload, query_timeout_ms, confirm_interval_ms, deadline) do\n    if fetch(session_id, key_expr, query_timeout_ms) == payload do\n      :ok\n    else\n      if System.monotonic_time(:millisecond) >= deadline do\n        {:error, :not_confirmed}\n      else\n        Process.sleep(confirm_interval_ms)\n        confirm(session_id, key_expr, payload, query_timeout_ms, confirm_interval_ms, deadline)\n      end\n    end\n  end\n\n  defp fetch(session_id, key_expr, query_timeout_ms) do\n    case Zenohex.Session.get(session_id, key_expr, query_timeout_ms, consolidation: :latest) do\n      {:ok, replies} ->\n        case Enum.find(replies, &match?(%Zenohex.Sample{}, &1)) do\n          %Zenohex.Sample{payload: found_payload} -> found_payload\n          nil -> nil\n        end\n\n      {:error, _reason} ->\n        nil\n    end\n  end\nend\n```\n\nUsage:\n\n```\niex> ZenohAckPut.put(session_id, \"key/expr\", \"payload\")\n:ok\n```\n\nThree possible return values:\n\n`:ok`\n\n— the put succeeded and the read-after-write confirmation also succeeded`{:error, :not_confirmed}`\n\n— the put itself succeeded, but confirmation didn't land within the timeout (this does `{:error, reason}`\n\n— the underlying `put`\n\nitself failedRunning the same 2000-iteration loop through `ZenohAckPut.put`\n\ninstead: zero stale reads, zero unconfirmed timeouts.\n\nIt's published as a standalone module, along with the reproduction scripts used above and a script that verifies the fix:\n\nNot on Hex yet, so pull it in as a git dependency for now:\n\n```\ndefp deps do\n  [\n    {:zenohackput, git: \"https://github.com/kikuyuta/zenohackput.git\"}\n  ]\nend\n```\n\nZenoh's `put`\n\nis fire-and-forget while `get`\n\nis a real request/response, and a `get`\n\nright after a `put`\n\ncan occasionally return a stale value — a few percent of the time in my measurements. It's a known, currently-unresolved gap upstream. An application-level \"put, then confirm with a get\" wrapper is enough to close it in practice for use cases (like state handoff) that need read-your-own-writes.\n\nIf you're using Zenoh's `put`\n\n/`get`\n\nfor anything where you expect a write to be immediately visible — not just eventually — keep this asymmetry in mind.", "url": "https://wpnews.pro/news/zenoh-s-put-is-fire-and-forget-get-isn-t-a-read-after-write-race-in-elixir", "canonical_source": "https://dev.to/kikuyuta/zenohs-put-is-fire-and-forget-get-isnt-a-read-after-write-race-in-elixir-o6l", "published_at": "2026-08-15 18:53:59+00:00", "updated_at": "2026-08-15 19:11:40.945104+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Zenoh", "Zenohex", "Elixir", "eclipse-zenoh/zenoh#2511"], "alternates": {"html": "https://wpnews.pro/news/zenoh-s-put-is-fire-and-forget-get-isn-t-a-read-after-write-race-in-elixir", "markdown": "https://wpnews.pro/news/zenoh-s-put-is-fire-and-forget-get-isn-t-a-read-after-write-race-in-elixir.md", "text": "https://wpnews.pro/news/zenoh-s-put-is-fire-and-forget-get-isn-t-a-read-after-write-race-in-elixir.txt", "jsonld": "https://wpnews.pro/news/zenoh-s-put-is-fire-and-forget-get-isn-t-a-read-after-write-race-in-elixir.jsonld"}}