Skip to content

PagerDuty/cream_ex

 
 

Repository files navigation

Cache. Rules. Everything. Around. Me.

A Dalli compatible memcached client.

It uses the same consistent hashing algorithm to connect to a cluster of memcached servers.

Table of contents

  1. Features
  2. Installation
  3. Quickstart
  4. Connecting to a cluster
  5. Using modules
  6. Memcachex options
  7. Memcachex API
  8. Ruby compatibility
  9. Supervision
  10. Instrumentation
  11. Documentation
  12. Running the tests
  13. TODO

Features

  • connect to a "cluster" of memcached servers
  • compatible with Ruby's Dallie gem (same consistent hashing algorithm)
  • fetch with anonymous function
  • multi set
  • multi get
  • multi fetch
  • built in pooling via poolboy
  • complete supervision trees
  • fully documented
  • instrumentation with the Instrumentation package.

Installation

In your mix.exs file...

def deps do
  [
    {:cream, ">= 0.1.0"}
  ]
end

Quickstart

# Connects to localhost:11211 with worker pool of size 10
{:ok, cluster} = Cream.Cluster.start_link

# Single set and get
Cream.Cluster.set(cluster, {"name", "Callie"})
Cream.Cluster.get(cluster, "name")
# => "Callie"

# Single fetch
Cream.Cluster.fetch cluster, "some", fn ->
  "thing"
end
# => "thing"

# Multi set / multi get with list
Cream.Cluster.set(cluster, [{"name", "Callie"}, {"buddy", "Chris"}])
Cream.Cluster.get(cluster, ["name", "buddy"])
# => %{"name" => "Callie", "buddy" => "Chris"}

# Multi set / multi get with map
Cream.Cluster.set(cluster, %{"species" => "canine", "gender" => "female"})
Cream.Cluster.get(cluster, ["species", "gender"])
# => %{"species" => "canine", "gender" => "female"}

# Multi fetch
Cream.Cluster.fetch cluster, ["foo", "bar", "baz"], fn missing_keys ->
  Enum.map(missing_keys, &String.reverse/1)
end
# => %{"foo" => "oof", "bar" => "rab", "baz" => "zab"}

Connecting to a cluster

{:ok, cluster} = Cream.Cluster.start_link servers: ["cache01:11211", "cache02:11211"]

Using modules

You can use modules to configure clusters, exactly like how Ecto repos work.

# In config/*.exs

config :my_app, MyCluster,
  servers: ["cache01:11211", "cache02:11211"],
  pool: 5

# Elsewhere

defmodule MyCluster do
  use Cream.Cluster, otp_app: :my_app

  # Optional callback to do runtime configuration.
  def init(config) do
    # config = Keyword.put(config, :pool, System.get_env("POOL_SIZE"))
    {:ok, config}
  end
end

MyCluster.start_link
MyCluster.get("foo")

Memcachex options

Cream uses Memcachex for individual connections to the cluster. You can pass options to Memcachex via Cream.Cluster.start_link/1:

Cream.Cluster.start_link(
  servers: ["localhost:11211"],
  memcachex: [ttl: 3600, namespace: "foo"]
)

Or if using modules:

use Mix.Config

config :my_app, MyCluster,
  servers: ["localhost:11211"],
  memcachex: [ttl: 3600, namespace: "foo"]

MyCluster.start_link

Any option you can pass to Memcache.start_link, you can pass via the :memcachex option for Cream.Cluster.start_link.

Memcachex API

Cream.Cluster's API is very small: get, set, fetch, flush. It may expand in the future, but for now, you can access Memcachex's API directly if you need.

Cream will still provide worker pooling and key routing, even when using Memcachex's API directly.

If you are using a single key, things are pretty straight forward...

results = Cream.Cluster.with_conn cluster, key, fn conn ->
  Memcache.get(conn, key)
end

It gets a bit more complex with a list of keys...

results = Cream.Cluster.with_conn cluster, keys, fn conn, keys ->
  Memcache.multi_get(conn, keys)
end
# results will be a list of whatever was returned by the invocations of the given function.

Basically, Cream will group keys by memcached server and then call the provided function for each group and return a list of the results of each call.

Ruby compatibility

By default, Dalli uses Marshal to encode values stored in memcached, which Elixir can't understand. So you have to change the serializer to something like JSON:

Ruby

client = Dalli::Client.new(
  ["host01:11211", "host2:11211"],
  serializer: JSON,
)
client.set("foo", 100)

Elixir

{:ok, cluster} = Cream.Cluster.start_link(
  servers: ["host01:11211", "host2:11211"],
  memcachex: [coder: Memcache.Coder.JSON]
)
Cream.Cluster.get(cluster, "foo")
# => "100"

So now both Ruby and Elixir will read/write to the memcached cluster in JSON, but still beware! There are some differences between how Ruby and Elixir parse JSON. For example, if you write an integer with Ruby, Ruby will read an integer, but Elixir will read a string.

Supervision

Everything is supervised, even the supervisors, so it really does make a supervision tree.

A "cluster" is really a poolboy pool of cluster supervisors. A cluster supervisor supervises each Memcache.Connection process and one Cream.Cluster.Worker process.

No pids are stored anywhere, but instead processes are tracked via Elixir's Registry module.

The results of Cream.Cluster.start_link and MyClusterModule.start_link can be inserted into your application's supervision tree.

Instrumentation

Cream uses Instrumentation for... well, instrumentation. It's default logging is hooked into this package. You can do your own logging (or instrumentation) very easily.

config :my_app, MyCluster,
  log: false

Instrumentation.subscribe "cream", fn tag, payload ->
  Logger.debug("cream.#{tag} took #{payload[:duration]} ms")
end

Running the tests

Test dependencies:

  • Docker
  • Docker Compose
  • Ruby
  • Bundler

Then run...

bundle install
docker-compose up -d
mix test

# Stop and clean up containers
docker-compose stop
docker-compose rm

TODO

  • Server weights
  • Parallel memcached requests

Packages

No packages published

Languages

  • Elixir 98.4%
  • Ruby 1.6%