Current section

Files

Jump to
cmdc lib cmdc checkpoint backend dets.ex
Raw

lib/cmdc/checkpoint/backend/dets.ex

defmodule CMDC.Checkpoint.Backend.DETS do
@moduledoc """
轻持久化 DETS 后端 — 单节点磁盘持久化。
适合:开发笔记本 / 单实例部署 / 不想拉 PG 的小服务。
不适合:高并发、跨节点(DETS 单写者)、上 GB 数据。
## 表设计
与 ETS 后端结构一致:
- 表名 `:cmdc_checkpoints_dets`
- key:`{session_id, checkpoint_id}`
- value:`%CMDC.Checkpoint.Snapshot{}`
## 启动
- **自动**:通过 `CMDC.Checkpoint.Backend.DETS.start/0`(用户在 application.ex 加)
- **手动**`{:ok, _} = CMDC.Checkpoint.Backend.DETS.start(file: "checkpoints.dets")`
- **关闭**`CMDC.Checkpoint.Backend.DETS.stop/0`
## 注意
- DETS 是单写者;高并发场景请用 PG backend
- 文件位置默认 `priv/checkpoints.dets`,可通过 `:file` 选项覆盖
"""
@behaviour CMDC.Checkpoint.Backend
alias CMDC.Checkpoint.Snapshot
@table :cmdc_checkpoints_dets
@default_file "checkpoints.dets"
# ==========================================================================
# 表生命周期
# ==========================================================================
@doc """
打开 DETS 表(幂等)。
## 选项
- `:file` — 文件路径,默认 `"checkpoints.dets"`
- `:dir` — 文件存放目录,默认 `Application.app_dir(:cmdc, "priv")`
"""
@spec start(keyword()) :: {:ok, atom()} | {:error, term()}
def start(opts \\ []) do
case :dets.info(@table) do
:undefined -> do_open(opts)
_ -> {:ok, @table}
end
end
@doc "关闭 DETS 表(幂等)。"
@spec stop() :: :ok
def stop do
case :dets.info(@table) do
:undefined -> :ok
_ -> :dets.close(@table)
end
end
@doc "清空表(测试用)。"
@spec reset(keyword()) :: :ok
def reset(opts \\ []) do
case start(opts) do
{:ok, @table} ->
:dets.delete_all_objects(@table)
:ok
{:error, _} = err ->
err
end
end
# ==========================================================================
# CMDC.Checkpoint.Backend callbacks
# ==========================================================================
@impl true
def save(session_id, %Snapshot{checkpoint_id: ckpt_id} = snapshot, opts) do
with {:ok, _} <- start(opts) do
:dets.match_delete(@table, {{session_id, ckpt_id}, :_})
:dets.insert(@table, {{session_id, ckpt_id}, snapshot})
:dets.sync(@table)
:ok
end
end
@impl true
def load(session_id, opts) do
with {:ok, _} <- start(opts) do
do_load(session_id, opts)
end
end
defp do_load(session_id, opts) do
case Keyword.fetch(opts, :checkpoint_id) do
{:ok, ckpt_id} -> load_one(session_id, ckpt_id)
:error -> load_latest(session_id)
end
end
defp load_one(session_id, ckpt_id) do
case :dets.lookup(@table, {session_id, ckpt_id}) do
[{_, snapshot}] -> {:ok, snapshot}
[] -> {:error, :not_found}
end
end
defp load_latest(session_id) do
case list_snapshots(session_id) do
[] -> {:error, :not_found}
[latest | _] -> {:ok, latest}
end
end
@impl true
def list(session_id, opts) do
with {:ok, _} <- start(opts) do
{:ok, list_snapshots(session_id)}
end
end
@impl true
def delete(session_id, opts) do
with {:ok, _} <- start(opts) do
case Keyword.fetch(opts, :checkpoint_id) do
{:ok, ckpt_id} ->
:dets.delete(@table, {session_id, ckpt_id})
:error ->
:dets.match_delete(@table, {{session_id, :_}, :_})
end
:dets.sync(@table)
:ok
end
end
# ==========================================================================
# 私有
# ==========================================================================
defp do_open(opts) do
file_path = build_file_path(opts)
ensure_dir(file_path)
case :dets.open_file(@table, [
{:file, String.to_charlist(file_path)},
{:type, :bag}
]) do
{:ok, @table} -> {:ok, @table}
{:error, _} = err -> err
end
end
defp build_file_path(opts) do
file = Keyword.get(opts, :file, @default_file)
dir =
Keyword.get_lazy(opts, :dir, fn ->
:cmdc
|> Application.app_dir("priv")
|> Path.join("checkpoints")
end)
Path.join(dir, file)
end
defp ensure_dir(file_path) do
file_path |> Path.dirname() |> File.mkdir_p!()
end
defp list_snapshots(session_id) do
@table
|> :dets.match_object({{session_id, :_}, :_})
|> Enum.map(fn {_, snap} -> snap end)
|> Enum.sort_by(& &1.saved_at, {:desc, DateTime})
end
end