Scott Hiett commited on
Commit
2e92879
0 Parent(s):

Initial commit

Browse files
.formatter.exs ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # Used by "mix format"
2
+ [
3
+ inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
4
+ ]
.gitignore ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The directory Mix will write compiled artifacts to.
2
+ /_build/
3
+
4
+ # If you run "mix test --cover", coverage assets end up here.
5
+ /cover/
6
+
7
+ # The directory Mix downloads your dependencies sources to.
8
+ /deps/
9
+
10
+ # Where third-party dependencies like ExDoc output generated docs.
11
+ /doc/
12
+
13
+ # Ignore .fetch files in case you like to edit your project deps locally.
14
+ /.fetch
15
+
16
+ # If the VM crashes, it generates a dump, let's ignore it too.
17
+ erl_crash.dump
18
+
19
+ # Also ignore archive artifacts (built via "mix archive.build").
20
+ *.ez
21
+
22
+ # Ignore package tarball (built via "mix hex.build").
23
+ srh-*.tar
24
+
25
+ # Temporary files, for example, from tests.
26
+ /tmp/
27
+
28
+ .idea/
29
+
30
+ *.iml
README.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Serverless Redis HTTP
2
+ A redis connection pooler for serverless applications. This allows your serverless functions to talk to Redis via HTTP,
3
+ while also not having to worry about the Redis max connection limits.
4
+
5
+ Totally open source so you still own the data - run it with your own Redis server.
6
+
7
+ I will also offer some kinda SaaS version when it's a bit more done ;) - but still BYOR (bring your own Redis)!
8
+
9
+ Don't use this yet - it's still in development.
10
+
11
+ Also, it's totally compatible with the Upstash Redis Client SDKs.
lib/srh.ex ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Srh do
2
+ use Application
3
+
4
+ def start(_type, _args) do
5
+ children = [
6
+ {GenRegistry, worker_module: Srh.Redis.Client},
7
+ {
8
+ Plug.Cowboy,
9
+ scheme: :http,
10
+ plug: Srh.Http.BaseRouter,
11
+ options: [
12
+ port: 8080
13
+ ]
14
+ }
15
+ ]
16
+
17
+ opts = [strategy: :one_for_one, name: Srh.Supervisor]
18
+
19
+ Supervisor.start_link(children, opts)
20
+ end
21
+ end
lib/srh/auth/token_resolver.ex ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Srh.Auth.TokenResolver do
2
+ def resolve(token) do
3
+ {
4
+ :ok,
5
+ Jason.decode!(
6
+ # This is done to replicate what will eventually be API endpoints, so they keys are not atoms
7
+ Jason.encode!(
8
+ %{
9
+ srh_id: "1000",
10
+ connection_string: "redis://localhost:6379",
11
+ max_connections: 10
12
+ }
13
+ )
14
+ )
15
+ }
16
+ end
17
+ end
lib/srh/http/base_router.ex ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Srh.Http.BaseRouter do
2
+ use Plug.Router
3
+ alias Srh.Http.RequestValidator
4
+ alias Srh.Http.CommandHandler
5
+
6
+ plug :match
7
+ plug Plug.Parsers, parsers: [:json], pass: ["application/json"], json_decoder: Jason
8
+ plug :dispatch
9
+
10
+ get "/" do
11
+ handle_response({:ok, "Welcome to Serverless Redis HTTP!"}, conn)
12
+ end
13
+
14
+ post "/" do
15
+ case conn
16
+ |> get_req_header("authorization")
17
+ |> RequestValidator.validate_bearer_header()
18
+ do
19
+ {:ok, token} ->
20
+ CommandHandler.handle_command(conn, token)
21
+ {:error, _} ->
22
+ {:malformed_data, "Missing/Invalid authorization header"}
23
+ end
24
+ |> handle_response(conn)
25
+ end
26
+
27
+ match _ do
28
+ send_resp(conn, 404, "Endpoint not found")
29
+ end
30
+
31
+ defp handle_response(response, conn) do
32
+ %{code: code, message: message} =
33
+ case response do
34
+ {:ok, data} -> %{code: 200, message: Jason.encode!(data)}
35
+ {:not_found, message} -> %{code: 404, message: message}
36
+ {:malformed_data, message} -> %{code: 400, message: message}
37
+ {:server_error, _} -> %{code: 500, message: "An error occurred internally"}
38
+ end
39
+
40
+ conn
41
+ |> send_resp(code, message)
42
+ end
43
+ end
lib/srh/http/command_handler.ex ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Srh.Http.CommandHandler do
2
+ alias Srh.Http.RequestValidator
3
+ alias Srh.Auth.TokenResolver
4
+ alias Srh.Redis.Client
5
+
6
+ def handle_command(conn, token) do
7
+ case RequestValidator.validate_redis_body(conn.body_params) do
8
+ {:ok, command_array} ->
9
+ IO.inspect(command_array)
10
+ do_handle_command(command_array, token)
11
+ {:error, error_message} ->
12
+ {:malformed_data, error_message}
13
+ end
14
+ end
15
+
16
+ defp do_handle_command(command_array, token) do
17
+ case TokenResolver.resolve(token) do
18
+ {:ok, connection_info} ->
19
+ dispatch_command(command_array, connection_info)
20
+ {:error, _} -> {:error, "Invalid token"}
21
+ end
22
+ end
23
+
24
+ defp dispatch_command(command_array, %{"srh_id" => srh_id, "max_connections" => max_connections} = connection_info)
25
+ when is_number(max_connections) do
26
+ case GenRegistry.lookup_or_start(Client, srh_id, [max_connections, connection_info]) do
27
+ {:ok, pid} ->
28
+ # Run the command
29
+ {:ok, res} = Client.redis_command(pid, command_array)
30
+ {:ok, %{result: res}}
31
+ {:error, msg} ->
32
+ {:server_error, msg}
33
+ end
34
+ end
35
+ end
lib/srh/http/request_validator.ex ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Srh.Http.RequestValidator do
2
+ def validate_redis_body(%{"_json" => command_array}) when is_list(command_array), do: {:ok, command_array}
3
+
4
+ def validate_redis_body(payload),
5
+ do: {:error, "Invalid command array. Expected a string array at root of the command and its arguments."}
6
+
7
+ def validate_bearer_header(header_value_array) when is_list(header_value_array) do
8
+ do_validate_bearer_header(header_value_array)
9
+ end
10
+
11
+ # any amount of items left
12
+ defp do_validate_bearer_header([first_item | rest]) do
13
+ case first_item
14
+ |> String.split(" ") do
15
+ ["Bearer", token] ->
16
+ {:ok, token}
17
+ _ ->
18
+ do_validate_bearer_header(rest)
19
+ end
20
+ end
21
+
22
+ # no items left
23
+ defp do_validate_bearer_header([]), do: {:error, :not_found}
24
+ end
lib/srh/redis/client.ex ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Srh.Redis.Client do
2
+ use GenServer
3
+ alias Srh.Redis.ClientRegistry
4
+ alias Srh.Redis.ClientWorker
5
+
6
+ @idle_death_time 1000 * 3
7
+
8
+ def start_link(max_connections, connection_info) do
9
+ GenServer.start_link(__MODULE__, {max_connections, connection_info}, [])
10
+ end
11
+
12
+ def init({max_connections, connection_info}) do
13
+ IO.puts("Client starting alive! Srh_id=#{Map.get(connection_info, "srh_id", "not found")}")
14
+
15
+ Process.send(self(), :create_registry, [])
16
+
17
+ {
18
+ :ok,
19
+ %{
20
+ registry_pid: nil,
21
+ idle_death_ref: nil,
22
+ max_connections: max_connections,
23
+ connection_info: connection_info
24
+ }
25
+ }
26
+ end
27
+
28
+ def redis_command(client, command_array) do
29
+ GenServer.call(client, {:redis_command, command_array})
30
+ end
31
+
32
+ def handle_call({:redis_command, command_array}, _from, %{registry_pid: registry_pid} = state)
33
+ when is_pid(registry_pid) do
34
+ {:ok, worker} = ClientRegistry.find_worker(registry_pid)
35
+ Process.send(self(), :reset_idle_death, [])
36
+ {:reply, ClientWorker.redis_command(worker, command_array), state}
37
+ end
38
+
39
+ def handle_call(_msg, _from, state) do
40
+ {:reply, :ok, state}
41
+ end
42
+
43
+ def handle_cast(_msg, state) do
44
+ {:noreply, state}
45
+ end
46
+
47
+ def handle_info(:idle_death, state) do
48
+ IO.puts("Client dying! No requests for period. Srh_id=#{Map.get(state.connection_info, "srh_id", "not found")}")
49
+ ClientRegistry.destroy_workers(state.registry_pid)
50
+
51
+ {:stop, :normal, state}
52
+ end
53
+
54
+ def handle_info(:reset_idle_death, state) do
55
+ if state.idle_death_ref != nil do
56
+ Process.cancel_timer(state.idle_death_ref)
57
+ end
58
+
59
+ {
60
+ :noreply,
61
+ %{state | idle_death_ref: Process.send_after(self(), :idle_death, @idle_death_time)}
62
+ }
63
+ end
64
+
65
+ def handle_info(:create_registry, state) do
66
+ {:ok, pid} = ClientRegistry.start_link()
67
+
68
+ # Spin up three workers
69
+ for _ <- 0..2 do
70
+ {:ok, worker} = ClientWorker.start_link(state.connection_info)
71
+ ClientRegistry.add_worker(pid, worker)
72
+ end
73
+
74
+ {:noreply, %{state | registry_pid: pid}}
75
+ end
76
+
77
+ def handle_info(_msg, state) do
78
+ {:noreply, state}
79
+ end
80
+ end
lib/srh/redis/client_registry.ex ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Srh.Redis.ClientRegistry do
2
+ use GenServer
3
+
4
+ def start_link() do
5
+ GenServer.start_link(__MODULE__, %{}, [])
6
+ end
7
+
8
+ def init(_opts) do
9
+ {
10
+ :ok,
11
+ %{
12
+ worker_pids: [],
13
+ last_worker_index: 0
14
+ }
15
+ }
16
+ end
17
+
18
+ def find_worker(registry) do
19
+ GenServer.call(registry, {:find_worker})
20
+ end
21
+
22
+ def add_worker(registry, pid) do
23
+ GenServer.cast(registry, {:add_worker, pid})
24
+ end
25
+
26
+ def destroy_workers(registry) do
27
+ GenServer.cast(registry, {:destroy_workers})
28
+ end
29
+
30
+ def handle_call({:find_worker}, _from, state) do
31
+ case length(state.worker_pids) do
32
+ 0 ->
33
+ {:reply, {:error, :none_available}, state}
34
+
35
+ len ->
36
+ target = state.last_worker_index + 1
37
+
38
+ corrected_target = case target >= len do
39
+ true -> 0
40
+ false -> target
41
+ end
42
+
43
+ IO.puts("Worker target: #{corrected_target}, pool size: #{len}")
44
+
45
+ {:reply, {:ok, Enum.at(state.worker_pids, corrected_target)}, %{state | last_worker_index: corrected_target}}
46
+ end
47
+ end
48
+
49
+ def handle_call(_msg, _from, state) do
50
+ {:reply, :ok, state}
51
+ end
52
+
53
+ def handle_cast({:add_worker, pid}, state) do
54
+ Process.monitor(pid)
55
+
56
+ {
57
+ :noreply,
58
+ %{
59
+ state
60
+ |
61
+ worker_pids:
62
+ [pid | state.worker_pids]
63
+ |> Enum.uniq()
64
+ }
65
+ }
66
+ end
67
+
68
+ def handle_cast({:destroy_workers}, state) do
69
+ for worker_pid <- state.worker_pids do
70
+ Process.exit(worker_pid, :normal)
71
+ end
72
+
73
+ {:noreply, %{state | worker_pids: [], last_worker_index: 0}}
74
+ end
75
+
76
+ def handle_cast(_msg, state) do
77
+ {:noreply, state}
78
+ end
79
+
80
+ def handle_info({:DOWN, pid, :normal, _ref}, state) do
81
+ {:noreply, %{state | worker_pids: List.delete(state.worker_pids, pid)}}
82
+ end
83
+
84
+ def handle_info(_msg, state) do
85
+ {:noreply, state}
86
+ end
87
+ end
lib/srh/redis/client_worker.ex ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Srh.Redis.ClientWorker do
2
+ use GenServer
3
+
4
+ def start_link(connection_info) do
5
+ GenServer.start_link(__MODULE__, connection_info, [])
6
+ end
7
+
8
+ def init(connection_info) do
9
+ IO.puts("Client worker reporting for duty! Srh_id=#{Map.get(connection_info, "srh_id", "not found")}")
10
+
11
+ Process.send(self(), :create_connection, [])
12
+
13
+ {
14
+ :ok,
15
+ %{
16
+ connection_info: connection_info,
17
+ redix_pid: nil
18
+ }
19
+ }
20
+ end
21
+
22
+ def redis_command(worker, command_array) do
23
+ GenServer.call(worker, {:redis_command, command_array})
24
+ end
25
+
26
+ def handle_call({:redis_command, command_array}, _from, %{redix_pid: redix_pid} = state)
27
+ when is_pid(redix_pid) do
28
+ case Redix.command(redix_pid, command_array) do
29
+ {:ok, res} ->
30
+ IO.puts("Worker PID:")
31
+ IO.inspect(self())
32
+ IO.puts("Worker Redis response:")
33
+ IO.inspect(res)
34
+ {:reply, {:ok, res}, state}
35
+
36
+ {:error, res} ->
37
+ {:reply, {:error, res}, state}
38
+ end
39
+ end
40
+
41
+ def handle_call(msg, _from, state) do
42
+ IO.inspect(msg)
43
+ {:reply, :ok, state}
44
+ end
45
+
46
+ def handle_cast(_msg, state) do
47
+ {:noreply, state}
48
+ end
49
+
50
+ # TODO: Handle host / port connections
51
+ def handle_info(
52
+ :create_connection,
53
+ %{
54
+ connection_info: %{
55
+ "connection_string" => connection_string
56
+ }
57
+ } = state
58
+ )
59
+ when is_binary(connection_string) do
60
+ {:ok, pid} = Redix.start_link(connection_string)
61
+ IO.puts("Redis up and running for worker Srh_id=#{Map.get(state.connection_info, "srh_id", "not found")}")
62
+ {:noreply, %{state | redix_pid: pid}}
63
+ end
64
+
65
+ def handle_info(_msg, state) do
66
+ {:noreply, state}
67
+ end
68
+ end
mix.exs ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Srh.MixProject do
2
+ use Mix.Project
3
+
4
+ def project do
5
+ [
6
+ app: :srh,
7
+ version: "0.1.0",
8
+ elixir: "~> 1.13",
9
+ start_permanent: Mix.env() == :prod,
10
+ deps: deps()
11
+ ]
12
+ end
13
+
14
+ # Run "mix help compile.app" to learn about applications.
15
+ def application do
16
+ [
17
+ extra_applications: [:logger],
18
+ mod: {Srh, []}
19
+ ]
20
+ end
21
+
22
+ # Run "mix help deps" to learn about dependencies.
23
+ defp deps do
24
+ [
25
+ {:redix, "~> 1.1"},
26
+ {:castore, ">= 0.0.0"},
27
+ {:plug, "~> 1.13"},
28
+ {:cowboy, "~> 2.9"},
29
+ {:plug_cowboy, "~> 2.5"},
30
+ {:jason, "~> 1.3"},
31
+ {:gen_registry, "~> 1.1"}
32
+ ]
33
+ end
34
+ end
mix.lock ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ %{
2
+ "castore": {:hex, :castore, "0.1.17", "ba672681de4e51ed8ec1f74ed624d104c0db72742ea1a5e74edbc770c815182f", [:mix], [], "hexpm", "d9844227ed52d26e7519224525cb6868650c272d4a3d327ce3ca5570c12163f9"},
3
+ "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
4
+ "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
5
+ "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
6
+ "gen_registry": {:hex, :gen_registry, "1.1.0", "30d483ae11dee615e500783fcec3f493de77fc80d346ba785947731207f790b7", [:mix], [], "hexpm", "c3db77e15c62599d01dc6b65aec6a3412a99b00d7785ef4a065c0c043b5a4478"},
7
+ "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"},
8
+ "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"},
9
+ "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"},
10
+ "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"},
11
+ "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"},
12
+ "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
13
+ "redix": {:hex, :redix, "1.1.5", "6fc460d66a5c2287e83e6d73dddc8d527ff59cb4d4f298b41e03a4db8c3b2bd5", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "679afdd4c14502fe9c11387ff1cdcb33065a1cf511097da1eee407f17c7a418b"},
14
+ "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"},
15
+ }