2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Nerves.Runtime.KV の実装から学ぶ遅延読み込み GenServer

Last updated at Posted at 2025-12-02

はじめに

nerves_runtime の CHANGELOG にこんな一文があるのを見かけました。

Defer Nerves KV loading until actually needed.
(KV の読み込みを、本当に必要になるまで遅らせる)

実装を追ってみると、GenServer の初期化時に I/O を避け、必要になったタイミングで一度だけロードする仕組みが、シンプルなフラグ管理で実現されていました。

この実装で使われている発想を読み解きつつ、自分のコードでも使えるように要点を整理してみます。

piyopiyo-board-2025-12.png

※ 写真はイメージです

何を遅延させているのか

Nerves.Runtime.KV は、ファームウェアのメタデータ(nerves_fw_active など)を読み書きするモジュールです。

変更前の実装では、GenServer の init/1 内で次のように KV のロード処理が即座に走る構造になっていました。

この構造だと、起動時に I/O(フラッシュ読み込みなど)が走り、そこで例外が起きるとアプリケーション初期化全体へ影響する可能性があります。

変更後は、init/1 ではロードを行わず、空の状態に
invalid?: true を付けて返すだけになりました。

ここで invalid?: true というフラグがあたらしく導入されていますが、これは「まだ KV をロードしていないので、次のアクセス時に読み込みが必要」という意味のようです。

実際のロード処理は refresh/1 に集約され、
最初のアクセス時にだけバックエンドの KV を読み込む仕組みになっています。

遅延ロードの仕組み

KV のロード処理は refresh/1 に集約されています。

refresh/1 の役割はシンプルで、

  • invalid?: false → そのまま返す(キャッシュ利用)
  • invalid?: true → バックエンドから KV をロードし直す

という動きをします。ロードに成功すると invalid?false に戻し、以降はキャッシュをそのまま使うようになります。

すべての get/put などの操作は最初に refresh(s) を実行するため、KV がまだ読み込まれていない状態でも、自動的に最新データへ同期されます。

さらに、ロードに失敗した場合は InMemory バックエンドへ切り替える処理も用意されており、初期化時のエラーでアプリケーション全体が落ちるのを避けるよう工夫されています。

論よりRun: 遅延読み込み GenServer

同じ発想を自分のアプリで使えるように、なにかやってみましょう。

やりたいことは次の 3 つだけです。

  • 起動時には何もロードしない
  • 最初の get/put 時にだけ loader(遅い処理)を呼ぶ
  • reload/0 を呼ぶと、次回アクセス時に再ロードさせる
defmodule SampleApp.LazyCache do
  use GenServer

  @moduledoc """
  loader 関数を使って遅延読み込みするシンプルなキャッシュの例。
  """

  ## Public API

  def start_link(opts) do
    loader = Keyword.fetch!(opts, :loader)
    name   = Keyword.get(opts, :name, __MODULE__)
    GenServer.start_link(__MODULE__, loader, name: name)
  end

  def get(server \\ __MODULE__, key, default \\ nil) do
    GenServer.call(server, {:get, key, default})
  end

  def put(server \\ __MODULE__, key, value) do
    GenServer.call(server, {:put, key, value})
  end

  def reload(server \\ __MODULE__) do
    GenServer.call(server, :reload)
  end

  ## GenServer callbacks

  @impl GenServer
  def init(loader) do
    state = %{
      loader: loader,
      data: %{},
      invalid?: true
    }

    {:ok, state}
  end

  @impl GenServer
  def handle_call({:get, key, default}, _from, state) do
    state = refresh(state)
    {:reply, Map.get(state.data, key, default), state}
  end

  def handle_call({:put, key, value}, _from, state) do
    state = refresh(state)
    data = Map.put(state.data, key, value)
    {:reply, :ok, %{state | data: data}}
  end

  def handle_call(:reload, _from, state) do
    {:reply, :ok, %{state | invalid?: true}}
  end

  ## Internal helpers

  defp refresh(%{invalid?: false} = state), do: state

  defp refresh(%{loader: loader} = state) do
    case loader.() do
      {:ok, data} ->
        %{state | data: data, invalid?: false}

      {:error, _reason} ->
        state
    end
  end
end

iex から動きを確かめると、どこでロードされるかが分かりやすいです。

IEx
loader = fn ->
  IO.puts("Loading from slow source...")
  Process.sleep(1000)
  {:ok, %{"foo" => 1, "bar" => 2}}
end

{:ok, _} = SampleApp.LazyCache.start_link(loader: loader)

SampleApp.LazyCache.get("foo")
#=> 最初の get でだけ "Loading from slow source..." と表示される

SampleApp.LazyCache.get("foo")
#=> 2 回目以降はキャッシュから返る

SampleApp.LazyCache.reload()
SampleApp.LazyCache.get("foo")
#=> 再びロードが走る

このサンプルではただ Map ロードしているだけですが、loader を設定ファイル読込や外部 API などに差し替えれば、そのまま「遅延読み込み+キャッシュ+手動で無効化」の実装として使えます。

おわりに

Nerves.Runtime.KV の実装を追ってみると、起動時に I/O を避けつつ、必要なタイミングでだけデータを読み込むという仕組みが、とてもシンプルな構成で成り立っていることが分かりました。

設定ローダーや外部サービスとの連携など、「起動時に重い処理を走らせたくない場面」で、そのまま活用できそうです。

:tada::tada::tada:

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?