Skip to content
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

Merged
merged 18 commits into from
Jan 29, 2017

Conversation

stevegrossi
Copy link
Member

@stevegrossi stevegrossi commented Jan 22, 2017

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:

  • a common interface for persistence—I've tried a few different approaches with the "lists" and "remind me" responders and have ideas about what works best. This will avoid needing to reinvent the wheel any time a responder needs to store state.
  • a default storage mechanism as an Agent so that tests can interact with an actual persistence layer instead of me having to create brittle, non-functional mock modules
  • an optional Redis storage plugin for to-disk persistence
  • eventually extracting this into a separate hedwig_brain library for sharing

Resolves #12

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?
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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?
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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?
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Member Author

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)
Copy link
Member Author

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.

@stevegrossi
Copy link
Member Author

Ebert has finished reviewing this Pull Request and has found:

  • 4 possible new issues (including those that may have been commented here).
  • 20 fixed issues! 🎉

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
Copy link
Member Author

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.

Copy link
Member Author

@stevegrossi stevegrossi Jan 30, 2017

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
Copy link
Member Author

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.

Copy link
Member Author

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
Copy link
Member Author

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.

Copy link
Member Author

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.

@stevegrossi stevegrossi merged commit 786694d into master Jan 29, 2017
@stevegrossi stevegrossi deleted the stevegrossi/brain branch January 29, 2017 18:37
def set(key, value) do
Agent.update(__MODULE__, fn (state) ->
Map.put(state, key, value)
end)
Copy link
Member Author

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))

Copy link
Member Author

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://"
Copy link
Member Author

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.

Copy link

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

Copy link
Member Author

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.

Copy link

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)
Copy link
Member Author

@stevegrossi stevegrossi Jan 30, 2017

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.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concur

Copy link
Member Author

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
Copy link
Member Author

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))
Copy link
Member Author

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.

Copy link

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?

Copy link
Member Author

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.

Copy link

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"])
Copy link
Member Author

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.

Copy link

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.

Copy link
Member Author

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.

Copy link

@janxious janxious left a 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.
Copy link

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.

Copy link
Member Author

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://"
Copy link

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)
Copy link

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
Copy link

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 😁

Copy link
Member Author

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.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

neat

@@ -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
Copy link

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

Copy link
Member Author

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))
Copy link

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"])
Copy link

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants