Packages
statefilex
0.1.0
StateFile is a utility that allows you to store any Elixir term to disk in a series of rolling files under a single directory
Current section
Files
Jump to
Current section
Files
lib/state_file.ex
# Copyright 2018 Comcast Cable Communications Management, LLC
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule StateFile do
@moduledoc """
Persists terms to disk in a directory of a limited number of files.
## Examples
iex> sf = StateFile.new("/tmp/example", 2)
iex> StateFile.read(sf)
nil
iex> StateFile.write(sf, :hello)
:ok
iex> StateFile.read(sf)
{:ok, :hello}
iex> StateFile.write(sf, :world)
:ok
iex> StateFile.write(sf, :goodbye)
:ok
iex> {:ok, files} = File.ls("/tmp/example")
iex> length(files)
2
iex> StateFile.read(sf)
:goodbye
"""
@file_match ~r/#{__MODULE__}-\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d*Z/
require Logger
defstruct directory: nil, max_files: 5
@opaque t :: %__MODULE__{directory: Path.t(), max_files: non_neg_integer}
@doc "Initializes a new StateFile."
@spec new(Path.t(), non_neg_integer) :: t
def new(directory, max_files) do
%__MODULE__{directory: directory, max_files: max_files}
end
@doc """
Reads a term from a StateFile directory.
If there is no readable data, `nil` is returned.
"""
@spec read(t) :: {:ok, term} | nil
def read(sf) do
case ls(sf) do
file_list when is_list(file_list) ->
file_list |> Enum.find_value(&read_file/1)
_err ->
nil
end
end
@doc """
Writes a term to the StateFile directory.
If a file could not be written or old files could not be rotated
out, `{:error, File.posix()}` will be returned.
"""
@spec write(t, term) :: :ok | {:error, File.posix()}
def write(%__MODULE__{directory: dir} = sf, value) do
with :ok <- File.mkdir_p(dir),
:ok <- check_permissions(dir),
:ok <- File.write(next_filename(dir), :erlang.term_to_binary(value)) do
prune(sf)
end
end
defp next_filename(dir) do
Path.join(dir, "#{__MODULE__}-#{DateTime.utc_now() |> DateTime.to_iso8601()}")
end
defp read_file(file) do
try do
contents = file |> File.read!() |> :erlang.binary_to_term()
{:ok, contents}
rescue
file_error in [File.Error] ->
_ = Logger.error("Could not read file #{file}: #{Exception.message(file_error)}")
false
badarg in [ArgumentError] ->
_ = Logger.error("Data corrupted in file #{file}: #{Exception.message(badarg)}")
false
end
end
defp prune(%__MODULE__{max_files: max_files} = sf) do
# Prunes files that exceed `max_files`, keeping only the latest
case ls(sf) do
file_list when is_list(file_list) ->
file_list
|> Enum.drop(max_files)
|> Enum.each(&File.rm/1)
err ->
err
end
end
@doc false
def ls(%__MODULE__{directory: dir}) do
# Lists files in the StateFile directory in reverse order with
# full paths.
with {:ok, files} <- File.ls(dir) do
files
|> Enum.filter(fn s -> Regex.match?(@file_match, s) end)
|> Enum.sort()
|> Enum.reverse()
|> Enum.map(fn f -> Path.join(dir, f) end)
end
end
defp check_permissions(directory) do
with {:ok, stat} <- File.stat(directory) do
if stat.access == :read_write do
:ok
else
{:error, :eacces}
end
end
end
end