-
Notifications
You must be signed in to change notification settings - Fork 4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Extract a Brain module as an abstraction for persistence #11
Conversation
Tests failing like whoa, but it works in the console so it’s the tests’ problem Also, just `send`, no need to `reply`
as an alternative to Redis. Eventually both should be available as storage options. I don’t like the warning to group handle_call and handle_cast functions together. I get it for normal function clauses, but the GenServer API requires unrelated functions to have similar-looking callback clauses and I’d rather keep them separate than grouped.
neat that setup works for doctests, too!
A general-purpose key-value storage mechanism. | ||
""" | ||
|
||
# @TODO: should this handle encoding/decoding things? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODO found
I don’t love calling Brain.set in test setup, though, but I guess it makes sense. Starting the Brain process with existing data felt somehow cleaner than using its public API, but as I write that it feels like the opposite should be true.
def at_index(key, index) when is_binary(key) and is_integer(index) and index >= 0 do | ||
command!(["LINDEX", key, index]) | ||
# @TODO: handle when key points to non-list | ||
# @TODO: handle out of range |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODO found
""" | ||
def at_index(key, index) when is_binary(key) and is_integer(index) and index >= 0 do | ||
command!(["LINDEX", key, index]) | ||
# @TODO: handle when key points to non-list |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODO found
def remove(key, item) when is_binary(key) and is_binary(item) do | ||
command!(["LREM", key, 0, item]) | ||
# @TODO: handle when key points to non-list | ||
# @TODO: handle when item is not in list? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODO found
""" | ||
def remove(key, item) when is_binary(key) and is_binary(item) do | ||
command!(["LREM", key, 0, item]) | ||
# @TODO: handle when key points to non-list |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODO found
""" | ||
def add(key, item) when is_binary(key) and is_binary(item) do | ||
command!(["RPUSH", key, item]) | ||
# @TODO: handle when key points to non-list |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODO found
""" | ||
def get(key) when is_binary(key) do | ||
command!(["LRANGE", key, 0, -1]) | ||
# @TODO: handle when key points to non-list |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODO found
A general-purpose key-value storage mechanism. | ||
""" | ||
|
||
# @TODO: should this handle encoding/decoding things? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODO found
@enforce_keys ~w(message timestamp)a | ||
defstruct ~w(message timestamp)a | ||
|
||
def new(message, timestamp) when is_integer(timestamp) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
@@ -23,17 +25,22 @@ defmodule Elix.MessageScheduler do | |||
:ok | |||
|
|||
""" | |||
def send_at(message, timestamp) do | |||
GenServer.cast(__MODULE__, {:schedule, {message, timestamp}}) | |||
def send_at(timestamp, message) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
end | ||
|
||
def handle_cast({:schedule, {message, timestamp} = scheduled_message}, state) do | ||
@store.add(message, timestamp) | ||
def handle_cast({:schedule, %ScheduledMessage{} = scheduled_message}, state) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
@doc """ | ||
Sets a key to a value | ||
""" | ||
def set(key, value) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
@doc """ | ||
Returns all strings stored under a key | ||
""" | ||
def get(key) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
@doc """ | ||
Deletes the value stored under a key | ||
""" | ||
def delete(key) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
@doc """ | ||
Stores an additional string under a key | ||
""" | ||
def add(key, item) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
@store.set(key, val) | ||
end | ||
|
||
def all(key) when is_binary(key) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
@store.get(key) || [] | ||
end | ||
|
||
def delete(key) when is_binary(key) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
@store.delete(key) | ||
end | ||
|
||
def add(key, item) when is_binary(key) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
@store.add(key, item) | ||
end | ||
|
||
def remove(key, item) when is_binary(key) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
Need a way to test this separately, but coupling the store to the environment makes that hard
{:ok, _pid} = Redix.start_link(redis_url, name: :redis) | ||
end | ||
|
||
def set(key, list) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
end) | ||
end | ||
|
||
def get(key) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
Enum.map(list, &decode_if_necessary/1) | ||
end | ||
|
||
def delete(key) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
command!(["DEL", key]) | ||
end | ||
|
||
def add(key, item) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
command!(["RPUSH", key, encode_if_necsssary(item)]) | ||
end | ||
|
||
def remove(key, item) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
command!(["LREM", key, 0, encode_if_necsssary(item)]) | ||
end | ||
|
||
def at_index(key, index) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
decode_if_necessary(item) | ||
end | ||
|
||
def encode_if_necsssary(term) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
def decode_if_necessary(@encoded_binary_prefix <> binary) do | ||
:erlang.binary_to_term(binary) | ||
end | ||
def decode_if_necessary(binary), do: binary |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
@store.remove(key, item) | ||
end | ||
|
||
def at_index(key, index) when is_binary(key) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
Ebert has finished reviewing this Pull Request and has found:
You can see more details about this review at https://ebertapp.io/github/indyelixir/elix/pulls/11. |
end | ||
|
||
@spec get(String.t) :: any | ||
def delete(key) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this one does, Ebert. Update: whoops, nope I badly copy-pasta'd 🍝
end | ||
|
||
@spec add(String.t, any) :: any | ||
def remove(key, item) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another badly copy-pasted type signature.
end | ||
|
||
@spec add(String.t, integer) :: any | ||
def at_index(key, index) do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functions should have a @SPEC type specification.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another badly copy-pasted type signature.
def set(key, value) do | ||
Agent.update(__MODULE__, fn (state) -> | ||
Map.put(state, key, value) | ||
end) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could perhaps be rewritten as:
Agent.update(__MODULE__, &Map.put(&1, key, value))
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I ended up rewriting a number of these to use the capture syntax.
@encoded_binary_prefix "binterm:" | ||
|
||
def start_link do | ||
redis_url = System.get_env("REDIS_URL") || "redis://" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I should perhaps also check for Application.get_env(:brain, :redis_url)
to allow configuration via config.exs
in case folks don't want to use ENV vars.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems like a case where you don' want a default value in production
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why's that? To prevent people from using a Redis instance they may not intend? I generally agree about having a higher bar for intentionality in production.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pretty much. If part of your test setup clears the database, you don't want that to accidentally happen in production. It still can but you should roadblock as much as possible. And halt running when a step (configuration) has been skipped.
command!(["DEL", key]) | ||
Enum.each(list, fn (item) -> | ||
command!(["RPUSH", key, encode_if_necsssary(item)]) | ||
end) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should probably use a MULTI
transaction (or whatever Redix provides for that) so that these operations are atomic and the key isn't deleted unless adding the new values also succeeds.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Concur
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I ended up wrapping this in a MULTI
transaction, and also taking advantage of the fact that RPUSH
can take an arbitrary number of items in order to append them all in a single operation:
def set(key, list) do
transaction fn ->
command!(["DEL", key])
items = Enum.map(list, &encode_if_necsssary(&1))
command!(["RPUSH", key] ++ items)
end
end
https://github.com/stevegrossi/hedwig_brain/blob/master/lib/brain/redis_store.ex#L18
else | ||
@encoded_binary_prefix <> :erlang.term_to_binary(term) | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Whoops, I could totally replace the conditional here with two function clauses, like I did for decode_if_necessary
...
@@ -11,7 +11,7 @@ defmodule Elix.Responders.Lists do | |||
show lists - Displays all lists | |||
""" | |||
respond ~r/show lists\Z/i, msg do | |||
reply(msg, render_items(List.all_names)) | |||
send(msg, render_items(List.all_names)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not really related, but I should show some kind of "There are no lists yet" message when List.all_names
is empty. Currently, the robot just doesn't reply.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should List.allnames
return a nullobject-ey thing so you don't need to change this logic?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I ended up using a case
:
response =
case List.all_names do
[] -> "There are no lists yet."
lists -> render_items(lists)
end
...but I'd be very interested in null-object-like patterns in Elixir. The only (crazy) thing I can think of offhand is a custom struct that you defimpl
enumerable on so when I Enum.map
over the result of List.all_names
it returns a string. But that feels pretty heavy-handed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is reasonable. That is effectively null object. Really the decision is where does the defaulting happen. In this case it might make sense to push it down and have a NullList
object that gets returned from the List
module. Without typing it hard to say what I would like more.
|
||
setup do | ||
RedisStore.start_link | ||
Redix.command!(:redis, ["FLUSHDB"]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe this should be a function on RedisStore
? Feels a little awkward reaching down into its Redix dependency directly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like a good idea.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ended up adding a RedisStore.delete_all
function. I considered adding something similar to the ProcessStore
but figure I can wait until there's actually a use for it. I also considered giving it a clever name like Brain.amnesia!
but when I get clever I tend to regret it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some belated feedback. Overall excellent
end | ||
|
||
@doc """ | ||
Stores a list at the given key, overwriting the existing value if present. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel like this is not the right documentation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🍝 Good catch, thanks.
@encoded_binary_prefix "binterm:" | ||
|
||
def start_link do | ||
redis_url = System.get_env("REDIS_URL") || "redis://" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems like a case where you don' want a default value in production
command!(["DEL", key]) | ||
Enum.each(list, fn (item) -> | ||
command!(["RPUSH", key, encode_if_necsssary(item)]) | ||
end) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Concur
@@ -0,0 +1,11 @@ | |||
defmodule Elix.Brain.Store do | |||
@callback start_link :: any |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Haven't seen @callback
before AFAIK. What does that do? This looks dangerously like a header file 😁
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@callback
is part of definining behaviours (docs). Here I'm saying that any module that implements @behaviour Elix.Brain.Store
(i.e. the redis and process stores) must implement a start_link
function with the signature declared here. If it doesn't, we get a compiler warning, so I've found it a handy way to gently enforce a consistent API when using polymorphism. Though I do find @callback
to be a confusing name for this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@@ -64,4 +71,14 @@ defmodule Elix.MessageScheduler do | |||
defp heartbeat do | |||
Process.send_after(self(), :heartbeat, :timer.seconds(1)) | |||
end | |||
|
|||
# This is unnecessary for the process brain. I feel like |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Concur with comment. Perhaps should have been a TODO
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh yeah, and I can totally do this now, as the Redis brain already handles encoding and decoding itself. Good catch!
@@ -11,7 +11,7 @@ defmodule Elix.Responders.Lists do | |||
show lists - Displays all lists | |||
""" | |||
respond ~r/show lists\Z/i, msg do | |||
reply(msg, render_items(List.all_names)) | |||
send(msg, render_items(List.all_names)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should List.allnames
return a nullobject-ey thing so you don't need to change this logic?
|
||
setup do | ||
RedisStore.start_link | ||
Redix.command!(:redis, ["FLUSHDB"]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like a good idea.
All the Redis stuff I was doing was fairly straightforward key-value manipulation. I'd like to create an
Elix.Brain
abstraction to get a few benefits:hedwig_brain
library for sharingResolves #12