diff --git a/README.md b/README.md new file mode 100644 index 0000000..86ec698 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Memore + +Memory-Cache service for `Kingdom-System` project. + + +## Usage +### Manually start server + +```sh +user@machine %: iex -S mix +``` + +![alt text](https://github.com/0xBLCKLPTN/Memore/blob/master/docs/screenshots/code.png) + +### Connect to server and manipulate with him +```sh +user@machine %:telnet 127.0.0.1 4040 +``` +![alt text](https://github.com/0xBLCKLPTN/Memore/blob/master/docs/screenshots/telnet_output.png) + +### Commands: +```sh +GET, PUT, DELETE, CREATE +``` + +### Example: +```sh +user@machine %: telnet 127.0.0.1 4040 +Trying 127.0.0.1... +Connected to localhost. +Escape character is '^]'. + +CREATE shopping_list + +PUT shopping_list milk 1 +PUT shopping_list name 'Andrey' + +GET shopping_list milk +GET shopping_list name + +DELETE shopping_list milk +DELETE shopping_list name +``` + +# Tests +All checks have now been passed +```sh +Finished in 0.1 seconds (0.06s async, 0.06s sync) +8 doctests, 2 tests, 1 failure +``` + +### TODO: + - [ ] ci/cd + - [ ] docker image + - [x] gen_tcp server + - [x] write docmodules + - [x] write normal README.md diff --git a/apps/memore/README.md b/apps/memore/README.md new file mode 100644 index 0000000..906e352 --- /dev/null +++ b/apps/memore/README.md @@ -0,0 +1,21 @@ +# Memore + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `memore` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:memore, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . + diff --git a/apps/memore/lib/memore.ex b/apps/memore/lib/memore.ex new file mode 100644 index 0000000..00b292b --- /dev/null +++ b/apps/memore/lib/memore.ex @@ -0,0 +1,43 @@ +defmodule Memore do + use Agent, restart: :temporary + use Application + @moduledoc """ + Documentation for Memore. + """ + + @impl true + def start(_type, _args) do + Superwizard.start_link(name: Superwizard) + end + + @doc """ + Starts a new memore instance. + """ + def start_link(_opts) do + Agent.start_link(fn -> %{} end) + end + + @doc """ + Get a value from the 'memore' by `key` + """ + def get(memore, key) do + Agent.get(memore, &Map.get(&1, key)) + end + + @doc """ + Pus the `value` for the giving `key` in the `memore`. + """ + def put(memore, key, value) do + Agent.update(memore, &Map.put(&1, key, value)) + end + + @doc """ + Deletes `key` from `bucket`. + + Returns the current value of `key`, if `key` exists. + """ + def delete(memore, key) do + Agent.get_and_update(memore, fn dict -> Map.pop(dict, key) end) + end + +end diff --git a/apps/memore/lib/registry.ex b/apps/memore/lib/registry.ex new file mode 100644 index 0000000..f385918 --- /dev/null +++ b/apps/memore/lib/registry.ex @@ -0,0 +1,79 @@ +defmodule Registry do + use GenServer + + ## Client API + + @doc """ + Starts the registry + """ + + def start_link(opts) do + + # 1. pass the name to GenServer's init + server = Keyword.fetch!(opts, :name) + GenServer.start_link(__MODULE__, server, opts) + end + + @doc """ + Looks up the memore pid for `name` stored in `server` + + Returns `{:ok, pid}` if the bucket exists, `:error` otherwise. + """ + def lookup(server, name) do + + # 2. Lookup os now done directrly in ETS, without accessing the server. + case :ets.lookup(server, name) do + [{^name, pid}] -> {:ok, pid} + [] -> :error + end + end + + @doc """ + Ensures there is a memore associated with given `name` in `server`. + """ + def create(server, name) do + GenServer.call(server, {:create, name}) + end + + ## Server callbacks + + @impl true + def init(table) do + # 3. We have replaced the names map by the ETS table. + names = :ets.new(table, [:named_table, read_concurrency: true]) + refs = %{} + {:ok, {names, refs}} + end + + # 4. The previous handle_call callback for lookup was removed + + @impl true + def handle_call({:create, name}, _from, {names, refs}) do + + # 5. Read and write to the ETS table insead of the map + case lookup(names, name) do + {:ok, pid} -> + {:reply, pid, {names,refs}} + :error -> + {:ok, pid} = DynamicSupervisor.start_child(MemoreSupervisor, Memore) + ref = Process.monitor(pid) + refs = Map.put(refs, ref, name) + :ets.insert(names, {name, pid}) + {:reply, pid, {names, refs}} + end + end + + @impl true + def handle_info({:DOWN, ref, :process, _pid, _reason}, {names,refs}) do + # 6. Delete from the ETS table instead of the map + {name, refs} = Map.pop(refs, ref) + :ets.delete(names, name) + {:noreply, {names, refs}} + end + + @impl true + def handle_info(_msg, state) do + {:noreply, state} + end + +end diff --git a/apps/memore/lib/supervisor.ex b/apps/memore/lib/supervisor.ex new file mode 100644 index 0000000..de4096d --- /dev/null +++ b/apps/memore/lib/supervisor.ex @@ -0,0 +1,17 @@ +defmodule Superwizard do + use Supervisor + + def start_link(opts) do + Supervisor.start_link(__MODULE__, :ok, opts) + end + + @impl true + def init(:ok) do + children = [ + {DynamicSupervisor, name: MemoreSupervisor, stategy: :one_for_one}, + {Registry, name: Registry}, + ] + + Supervisor.init(children, strategy: :one_for_all) + end +end diff --git a/apps/memore/mix.exs b/apps/memore/mix.exs new file mode 100644 index 0000000..fb7cf3c --- /dev/null +++ b/apps/memore/mix.exs @@ -0,0 +1,32 @@ +defmodule Memore.MixProject do + use Mix.Project + + def project do + [ + app: :memore, + build_path: "../../_build", + config_path: "../../config/config.exs", + deps_path: "../../deps", + lockfile: "../../mix.lock", + version: "0.1.0", + elixir: "~> 1.14", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger], + mod: {Memore, []} + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + ] + + end +end diff --git a/apps/memore/test/memore_test.exs b/apps/memore/test/memore_test.exs new file mode 100644 index 0000000..61c4f48 --- /dev/null +++ b/apps/memore/test/memore_test.exs @@ -0,0 +1,52 @@ +defmodule MemoreTest do + use ExUnit.Case, async: true + doctest Memore + + setup context do + _ = start_supervised!({Registry, name: context.test}) + %{registry: context.test} + end + + test "spawn memores", %{registry: registry} do + assert Registry.lookup(registry, "shopping") == :error + + Registry.create(registry, "shopping") + assert {:ok, memore} = Registry.lookup(registry, "shopping") + + Memore.put(memore, "milk", 1) + assert Memore.get(memore, "milk") == 1 + end + + test "remove memores on exit", %{registry: registry} do + Registry.create(registry, "shopping") + {:ok, memore} = Registry.lookup(registry, "shopping") + Agent.stop(memore) + + _ = Registry.create(registry, "bogus") + assert Registry.lookup(registry, "shopping") == :error + end + + test "removes bucket on crash", %{registry: registry} do + Registry.create(registry, "shopping") + {:ok, memore} = Registry.lookup(registry, "shopping") + + # Stop the memore with non-normal reason + Agent.stop(memore, :shutdown) + + _ = Registry.create(registry, "bogus") + assert Registry.lookup(registry, "shopping") == :error + end + + test "are temporary workers" do + assert Supervisor.child_spec(Memore, []).restart == :temporary + end + + test "memore can crash at any time", %{registry: registry} do + Registry.create(registry, "shopping") + {:ok, memore} = Registry.lookup(registry, "shopping") + + # Simulate a memore crash by explicitly and synchronously shuttibg it down + Agent.stop(memore, :shutdown) + catch_exit Memore.put(memore, "milk", 3) + end +end diff --git a/apps/memore/test/test_helper.exs b/apps/memore/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/apps/memore/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/apps/memore_server/README.md b/apps/memore_server/README.md new file mode 100644 index 0000000..3ba3339 --- /dev/null +++ b/apps/memore_server/README.md @@ -0,0 +1,21 @@ +# MemoreServer + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `memore_server` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:memore_server, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . + diff --git a/apps/memore_server/lib/memore_server.ex b/apps/memore_server/lib/memore_server.ex new file mode 100644 index 0000000..264a143 --- /dev/null +++ b/apps/memore_server/lib/memore_server.ex @@ -0,0 +1,55 @@ +defmodule MemoreServer do + require Logger + + def accept(port) do + {:ok, socket} = + :gen_tcp.listen(port, [:binary, packet: :line, active: false, reuseaddr: true]) + Logger.info("Accepting connections on port #{port}") + loop_acceptor(socket) + end + + defp loop_acceptor(socket) do + {:ok, client} = :gen_tcp.accept(socket) + {:ok, pid} = Task.Supervisor.start_child(MemoreServer.TaskSupervisor, fn -> serve(client) end) + :ok = :gen_tcp.controlling_process(client, pid) + loop_acceptor(socket) + end + + # socket |> read_line() |> write_line(socket) == write_line(read_line(socket), socket) + defp serve(socket) do + msg = + with {:ok, data} <- read_line(socket), + {:ok, command} <- MemoreServer.Command.parse(data), + do: MemoreServer.Command.run(command) + + write_line(socket,msg) + serve(socket) + end + + defp read_line(socket) do + :gen_tcp.recv(socket, 0) + end + + defp write_line(socket, {:ok, text}) do + :gen_tcp.send(socket, text) + end + + defp write_line(socket, {:error, :not_found}) do + # Known error; write to the client + :gen_tcp.send(socket, "NOT FOUND\r\n") + end + + defp write_line(_socket, {:error, :closed}) do + # The connection was closed, exit politely + exit(:shutdown) + end + + defp write_line(socket, {:error, error}) do + :gen_tcp.send(socket, "ERROR\r\n") + exit(error) + end + + defp write_line(socket, {:error, :unknown_command}) do + :gen_tcp.send(socket, "UNKNOWN COMMAND\r\n") + end +end diff --git a/apps/memore_server/lib/memore_server/application.ex b/apps/memore_server/lib/memore_server/application.ex new file mode 100644 index 0000000..41d8453 --- /dev/null +++ b/apps/memore_server/lib/memore_server/application.ex @@ -0,0 +1,23 @@ +defmodule MemoreServer.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + port = String.to_integer(System.get_env("PORT") || "4040") + children = [ + # Starts a worker by calling: MemoreServer.Worker.start_link(arg) + # {MemoreServer.Worker, arg} + {Task.Supervisor, name: MemoreServer.TaskSupervisor}, + Supervisor.child_spec({Task, fn -> MemoreServer.accept(port) end}, restart: :permanent) + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: MemoreServer.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/apps/memore_server/lib/memore_server/command.ex b/apps/memore_server/lib/memore_server/command.ex new file mode 100644 index 0000000..bf84c38 --- /dev/null +++ b/apps/memore_server/lib/memore_server/command.ex @@ -0,0 +1,79 @@ +defmodule MemoreServer.Command do + @doc """ + Runs the given command. + """ + def run(command) + + def run({:create, memore}) do + Registry.create(Registry, memore) + {:ok, "OK\r\n"} + end + + def run({:get, memore, key}) do + lookup(memore, fn pid -> + value = Memore.get(pid, key) + {:ok, "#{value}\r\nOK\r\n"} + end) + end + + def run({:put, memore, key, value}) do + lookup(memore, fn pid -> + Memore.put(pid, key, value) + {:ok, "OK\r\n"} + end) + end + + def run({:delete, memore, key}) do + lookup(memore, fn pid -> + Memore.delete(pid, key) + {:ok, "OK\r\n"} + end) + end + + def lookup(memore, callback) do + case Registry.lookup(Registry, memore) do + {:ok, pid} -> callback.(pid) + :error -> {:error, :not_found} + end + end + + @doc ~S""" + Parses the given `line` into a command. + + ## Examples + + iex> MemoreServer.Command.parse "CREATE shopping\r\n" + {:ok, {:create, "shopping"}} + + iex> MemoreServer.Command.parse "CREATE shopping \r\n" + {:ok, {:create, "shopping"}} + + iex> MemoreServer.Command.parse "PUT shopping milk 1\r\n" + {:ok, {:put, "shopping", "milk", "1"}} + + iex> MemoreServer.Command.parse "GET shopping milk\r\n" + {:ok, {:get, "shopping", "milk"}} + + iex> MemoreServer.Command.parse "DELETE shopping eggs\r\n" + {:ok, {:delete, "shopping", "eggs"}} + + Unknown commands or commands with the wrong number of + arguments return an error: + + iex> MemoreServer.Command.parse "UNKNOWN shopping eggs\r\n" + {:error, :unknown_command} + + iex> MemoreServer.Command.parse "GET shopping\r\n" + {:error, :unknown_command} + + """ + def parse(line) do + case String.split(line) do + ["CREATE", memore] -> {:ok, {:create, memore}} + ["GET", memore, key] -> {:ok, {:get, memore, key}} + ["PUT", memore, key, value] -> {:ok, {:put, memore, key, value}} + ["DELETE", memore, key] -> {:ok, {:delete, memore, key}} + _ -> {:error, :unknown_command} + end + end +end diff --git a/apps/memore_server/mix.exs b/apps/memore_server/mix.exs new file mode 100644 index 0000000..118dd86 --- /dev/null +++ b/apps/memore_server/mix.exs @@ -0,0 +1,35 @@ +defmodule MemoreServer.MixProject do + use Mix.Project + + def project do + [ + app: :memore_server, + version: "0.1.0", + build_path: "../../_build", + config_path: "../../config/config.exs", + deps_path: "../../deps", + lockfile: "../../mix.lock", + elixir: "~> 1.14", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger], + mod: {MemoreServer.Application, []} + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:memore, in_umbrella: true} + # {:dep_from_hexpm, "~> 0.3.0"}, + # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}, + # {:sibling_app_in_umbrella, in_umbrella: true} + ] + end +end diff --git a/apps/memore_server/test/commnad_test.exs b/apps/memore_server/test/commnad_test.exs new file mode 100644 index 0000000..4c2fb22 --- /dev/null +++ b/apps/memore_server/test/commnad_test.exs @@ -0,0 +1,5 @@ +defmodule MemoreServer.CommandTest do + use ExUnit.Case, async: true + doctest MemoreServer.Command + +end diff --git a/apps/memore_server/test/memore_server_test.exs b/apps/memore_server/test/memore_server_test.exs new file mode 100644 index 0000000..56b85dc --- /dev/null +++ b/apps/memore_server/test/memore_server_test.exs @@ -0,0 +1,49 @@ +defmodule MemoreServerTest do + use ExUnit.Case + @moduletag :capture_log + doctest MemoreServer + + setup do + Application.stop(:memore) + :ok = Application.start(:memore) + end + + setup do + opts = [:binary, packet: :line, active: false] + {:ok, socket} = :gen_tcp.connect('localhost', 4040, opts) + %{socket: socket} + end + + test "server integration", %{socket: socket} do + assert send_and_recv(socket, "UNKNOWN shopping\r\n") == + "UNKNOWN COMMMAND\r\n" + assert send_and_recv(socket, "GET shopping eggs\r\n") == + "NOT FOUND\r\n" + + assert send_and_recv(socket, "CREATE shopping\r\n") == + "OK\r\n" + + assert send_and_recv(socket, "PUT shopping eggs 3\r\n") == + "OK\r\n" + + assert send_and_recv(socket, "GET shopping eggs\r\n") == "3\r\n" + assert send_and_recv(socket, "") == "OK\r\n" + + assert send_and_recv(socket, "DELETE shopping eggs\r\n") == + "OK\r\n" + + # GET returns two lines + assert send_and_recv(socket, "GET shopping eggs\r\n") == "\r\n" + assert send_and_recv(socket, "") == "OK\r\n" + end + + defp send_and_recv(socket, command) do + :ok = :gen_tcp.send(socket, command) + {:ok, data} = :gen_tcp.recv(socket, 0, 1000) + data + end + + test "greets the world" do + assert MemoreServer.hello() == :world + end +end diff --git a/apps/memore_server/test/test_helper.exs b/apps/memore_server/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/apps/memore_server/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..ab23e80 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,18 @@ +# This file is responsible for configuring your umbrella +# and **all applications** and their dependencies with the +# help of the Config module. +# +# Note that all applications in your umbrella share the +# same configuration and dependencies, which is why they +# all use the same configuration file. If you want different +# configurations or dependencies per app, it is best to +# move said applications out of the umbrella. +import Config + +# Sample configuration: +# +# config :logger, :console, +# level: :info, +# format: "$date $time [$level] $metadata$message\n", +# metadata: [:user_id] +# diff --git a/docs/screenshots/code.png b/docs/screenshots/code.png new file mode 100644 index 0000000..60ed0f2 Binary files /dev/null and b/docs/screenshots/code.png differ diff --git a/docs/screenshots/telnet_output.png b/docs/screenshots/telnet_output.png new file mode 100644 index 0000000..08f2e4c Binary files /dev/null and b/docs/screenshots/telnet_output.png differ diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..bf4094b --- /dev/null +++ b/mix.exs @@ -0,0 +1,22 @@ +defmodule MemoreUmbrella.MixProject do + use Mix.Project + + def project do + [ + apps_path: "apps", + version: "0.1.0", + start_permanent: Mix.env() == :prod, + deps: deps(), + docs: [extras: ["README.md"]] + ] + end + + # Dependencies listed here are available only for this + # project and cannot be accessed from applications inside + # the apps folder. + # + # Run "mix help deps" for examples and options. + defp deps do + [] + end +end