├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ └── elixir.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── config └── config.exs ├── lib └── rollout.ex ├── mix.exs ├── mix.lock └── test ├── rollout_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "10:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: ex_doc 11 | versions: 12 | - 0.24.0 13 | - 0.24.1 14 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | env: 10 | MIX_ENV: test 11 | 12 | jobs: 13 | deps: 14 | name: Install Dependencies 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | elixir: [1.11] 19 | otp: [23.3] 20 | steps: 21 | - name: checkout 22 | uses: actions/checkout@v2 23 | with: 24 | fetch-depth: 0 25 | - name: setup 26 | uses: erlef/setup-beam@v1 27 | with: 28 | elixir-version: ${{ matrix.elixir }} 29 | otp-version: ${{ matrix.otp }} 30 | - name: Retrieve Cached Dependencies 31 | uses: actions/cache@v2 32 | id: mix-cache 33 | with: 34 | path: | 35 | deps 36 | _build 37 | priv/plts 38 | key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('mix.lock') }} 39 | - name: Install deps 40 | if: steps.mix-cache.outputs.cache-hit != 'true' 41 | run: | 42 | mkdir -p priv/plts 43 | mix local.rebar --force 44 | mix local.hex --force 45 | mix deps.get 46 | mix deps.compile 47 | mix dialyzer --plt 48 | 49 | analyze: 50 | name: Analysis 51 | needs: deps 52 | runs-on: ubuntu-latest 53 | strategy: 54 | matrix: 55 | elixir: [1.11] 56 | otp: [23.3] 57 | steps: 58 | - uses: actions/checkout@v2 59 | with: 60 | fetch-depth: 0 61 | - name: Setup elixir 62 | uses: erlef/setup-beam@v1 63 | with: 64 | elixir-version: ${{ matrix.elixir }} 65 | otp-version: ${{ matrix.otp }} 66 | - name: Retrieve Cached Dependencies 67 | uses: actions/cache@v2 68 | id: mix-cache 69 | with: 70 | path: | 71 | deps 72 | _build 73 | priv/plts 74 | key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('mix.lock') }} 75 | - name: Run Dialyzer 76 | run: mix dialyzer --no-check --halt-exit-status 77 | 78 | tests: 79 | name: Tests 80 | needs: deps 81 | runs-on: ubuntu-latest 82 | strategy: 83 | matrix: 84 | elixir: [1.11] 85 | otp: [23.3] 86 | steps: 87 | - uses: actions/checkout@v2 88 | with: 89 | fetch-depth: 0 90 | - uses: erlef/setup-beam@v1 91 | with: 92 | elixir-version: ${{ matrix.elixir }} 93 | otp-version: ${{ matrix.otp }} 94 | - name: Retrieve Cached Dependencies 95 | uses: actions/cache@v2 96 | id: mix-cache 97 | with: 98 | path: | 99 | deps 100 | _build 101 | priv/plts 102 | key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('mix.lock') }} 103 | - name: start epmd 104 | run: epmd -daemon 105 | - name: Run Tests 106 | run: mix test 107 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 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 | rollout-*.tar 24 | 25 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Christopher Jon Keathley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rollout 2 | 3 | Rollout allows you to flip features quickly and easily. It relies on 4 | distributed erlang and uses LWW-Register and Hybrid-logical clocks 5 | to provide maximum availability. Rollout has no dependency on an external 6 | service such as redis which means rollout feature flags can be used in the 7 | critical path of a request with minimal latency increase. 8 | 9 | * [Docs](https://hexdocs.pm/rollout). 10 | 11 | ## Installation 12 | 13 | ```elixir 14 | def deps do 15 | [ 16 | {:rollout, "~> 0.2"} 17 | ] 18 | end 19 | ``` 20 | 21 | ## Usage 22 | 23 | Rollout provides a simple api for enabling and disabling feature flags across 24 | your cluster. A feature flag can be any term. 25 | 26 | ```elixir 27 | # Check if a feature is active 28 | Rollout.active?(:blog_post_comments) 29 | # => false 30 | 31 | # Activate the feature 32 | Rollout.activate(:blog_post_comments) 33 | 34 | # De-activate the feature 35 | Rollout.deactivate(:blog_post_comments) 36 | ``` 37 | 38 | You can also activate a feature a certain percentage of the time. 39 | 40 | ```elixir 41 | Rollout.activate_percentage(:blog_post_comments, 20) 42 | ``` 43 | 44 | You can run this function on one node in your cluster and the updates will 45 | be propogated across the system. This means that updates to feature flags may 46 | not be instantaneous across the cluster but under normal conditions should propogate 47 | quickly. This is a tradeoff I've made in order to maintain the low latency when 48 | checking if a flag is enabled. 49 | 50 | ## How does Rollout work? 51 | 52 | Rollout utilizes [Groot](https://github.com/keathley/groot) for replicating flags 53 | across your cluster. Please look at the groot docs for implementation details. 54 | 55 | ## Caveats 56 | 57 | Rollout relies on your nodes being connected through distributed erlang. If you 58 | are running your application on more than one node and you are not clustering than 59 | your changes won't propogate. You will need to run the command on all nodes but 60 | in practice you'll probably just want to look for an alternative solution. 61 | 62 | Flags are *not* maintained in between node restarts. New nodes added to your cluster 63 | will be caught up on the current state. But if you bring up an entirely new cluster 64 | your flags will revert to their default states. You can mitigate this problem 65 | by setting defaults for each flag in your `Application.start/2` callback. 66 | 67 | Changes made on one node will take time to replicate to other nodes in the cluster. 68 | But, its a safe operation to run the same command on multiple nodes. Feature flags 69 | will merge cleanly and always default to the latest change seen. 70 | 71 | ## Should I use this? 72 | 73 | If you're already running a clustered application then this should be a reasonable 74 | solution if you need feature flags. 75 | 76 | ## Future work 77 | 78 | I'd like to implement an alternative storage engine for use in non-clustered 79 | environments, preferably using a fast storage engine such as redis. If anyone 80 | wants to submit that PR I'd love to take a look at it. 81 | 82 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # third-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :rollout, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:rollout, :key) 18 | # 19 | # You can also configure a third-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env()}.exs" 31 | -------------------------------------------------------------------------------- /lib/rollout.ex: -------------------------------------------------------------------------------- 1 | defmodule Rollout do 2 | @moduledoc """ 3 | Rollout allows you to flip features quickly and easily. It relies on 4 | distributed erlang and uses LWW-register and Hybrid-logical clocks 5 | to provide maximum availability. Rollout has no dependency on an external 6 | service such as redis which means rollout feature flags can be used in the 7 | critical path of a request with minimal latency increase. 8 | 9 | ## Usage 10 | 11 | Rollout provides a simple api for enabling and disabling feature flags across 12 | your cluster. A feature flag can be any term. 13 | 14 | ```elixir 15 | # Check if a feature is active 16 | Rollout.active?(:blog_post_comments) 17 | # => false 18 | 19 | # Activate the feature 20 | Rollout.activate(:blog_post_comments) 21 | 22 | # De-activate the feature 23 | Rollout.deactivate(:blog_post_comments) 24 | ``` 25 | 26 | You can also activate a feature a certain percentage of the time. 27 | 28 | ```elixir 29 | Rollout.activate_percentage(:blog_post_comments, 20) 30 | ``` 31 | 32 | You can run this function on one node in your cluster and the updates will 33 | be propogated across the system. This means that updates to feature flags may 34 | not be instantaneous across the cluster but under normal conditions should propogate 35 | quickly. This is a tradeoff I've made in order to maintain the low latency when 36 | checking if a flag is enabled. 37 | 38 | ## How does Rollout work? 39 | 40 | Rollout utilizes [Groot](https://github.com/keathley/groot) for replicating flags 41 | across your cluster. Please look at the groot docs for implementation details. 42 | """ 43 | 44 | @doc """ 45 | Checks to see if a feature is active or not. 46 | """ 47 | @spec active?(term()) :: boolean() 48 | def active?(flag) do 49 | case Groot.get(flag) do 50 | nil -> 51 | false 52 | 53 | 0 -> 54 | false 55 | 56 | 100 -> 57 | true 58 | 59 | val -> 60 | :rand.uniform(100) <= val 61 | end 62 | end 63 | 64 | @doc """ 65 | Fully activates a feature flag. 66 | """ 67 | @spec activate(term()) :: :ok 68 | def activate(flag) do 69 | activate_percentage(flag, 100) 70 | end 71 | 72 | @doc """ 73 | Disables a feature flag. 74 | """ 75 | @spec deactivate(term()) :: :ok 76 | def deactivate(flag) do 77 | activate_percentage(flag, 0) 78 | end 79 | 80 | @doc """ 81 | Activates a feature flag for a percentage of requests. An integer between 0 and 100 82 | must be provided. Deciding whether a flag is active is done with the following 83 | calculation: `:rand.uniform(100) <= provided_percentage` 84 | """ 85 | @spec activate_percentage(term(), 0..100) :: :ok 86 | def activate_percentage(flag, percentage) when is_integer(percentage) and 0 <= percentage and percentage <= 100 do 87 | Groot.set(flag, percentage) 88 | end 89 | end 90 | 91 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Rollout.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.2.1" 5 | 6 | def project do 7 | [ 8 | app: :rollout, 9 | version: @version, 10 | elixir: "~> 1.8", 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | aliases: aliases(), 14 | 15 | description: description(), 16 | package: package(), 17 | name: "Rollout", 18 | source_url: "https://github.com/keathley/rollout", 19 | docs: docs(), 20 | ] 21 | end 22 | 23 | # Run "mix help compile.app" to learn about applications. 24 | def application do 25 | [ 26 | ] 27 | end 28 | 29 | # Run "mix help deps" to learn about dependencies. 30 | defp deps do 31 | [ 32 | {:groot, "~> 0.1"}, 33 | {:norm, "~> 0.10"}, 34 | 35 | {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false}, 36 | {:local_cluster, "~> 1.0", only: [:dev, :test]}, 37 | {:schism, "~> 1.0", only: [:dev, :test]}, 38 | {:ex_doc, ">= 0.0.0", only: :dev} 39 | ] 40 | end 41 | 42 | def aliases do 43 | [ 44 | test: ["test --no-start"] 45 | ] 46 | end 47 | 48 | def description do 49 | """ 50 | Rollout allows you to flip features quickly and easily. It relies on 51 | distributed erlang and uses LWW-Registers and Hybrid-logical clocks 52 | to provide maximum availability and low latency. 53 | """ 54 | end 55 | 56 | def package do 57 | [ 58 | name: "rollout", 59 | licenses: ["MIT"], 60 | links: %{"GitHub" => "https://github.com/keathley/rollout"}, 61 | ] 62 | end 63 | 64 | def docs do 65 | [ 66 | source_ref: "v#{@version}", 67 | source_url: "https://github.com/keathley/rollout", 68 | main: "Rollout", 69 | ] 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, 3 | "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.29", "149d50dcb3a93d9f3d6f3ecf18c918fb5a2d3c001b5d3305c926cddfbd33355b", [:mix], [], "hexpm", "4902af1b3eb139016aed210888748db8070b8125c2342ce3dcae4f38dcc63503"}, 5 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 6 | "ex_doc": {:hex, :ex_doc, "0.29.1", "b1c652fa5f92ee9cf15c75271168027f92039b3877094290a75abcaac82a9f77", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "b7745fa6374a36daf484e2a2012274950e084815b936b1319aeebcf7809574f6"}, 7 | "global_flags": {:hex, :global_flags, "1.0.0", "ee6b864979a1fb38d1fbc67838565644baf632212bce864adca21042df036433", [:rebar3], [], "hexpm", "85d944cecd0f8f96b20ce70b5b16ebccedfcd25e744376b131e89ce61ba93176"}, 8 | "groot": {:hex, :groot, "0.1.2", "c1fa9c81739b7814becbac63f863caeb1e5e425239e84e37f2fd283a3c9f6147", [:mix], [{:hlclock, "~> 1.0", [hex: :hlclock, repo: "hexpm", optional: false]}], "hexpm", "8947f847ef377787888954ea19ed2bd76fed743b1c87159e39e5614133b4ccc0"}, 9 | "hlclock": {:hex, :hlclock, "1.0.0", "7a72fc7a20a9382499216227edf97a8b118e21fc3fcad0e81b8d10c616ce1431", [:mix], [], "hexpm", "d3f994336a7fcbc68bf08b14b2101b61e57bef82c032a6e05c1cdc753612c941"}, 10 | "local_cluster": {:hex, :local_cluster, "1.2.1", "8eab3b8a387680f0872eacfb1a8bd5a91cb1d4d61256eec6a655b07ac7030c73", [:mix], [{:global_flags, "~> 1.0", [hex: :global_flags, repo: "hexpm", optional: false]}], "hexpm", "aae80c9bc92c911cb0be085fdeea2a9f5b88f81b6bec2ff1fec244bb0acc232c"}, 11 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 12 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 13 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 14 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 15 | "norm": {:hex, :norm, "0.13.0", "2c562113f3205e3f195ee288d3bd1ab903743e7e9f3282562c56c61c4d95dec4", [:mix], [{:stream_data, "~> 0.5", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "447cc96dd2d0e19dcb37c84b5fc0d6842aad69386e846af048046f95561d46d7"}, 16 | "schism": {:hex, :schism, "1.0.1", "b700883b4023b06faa5ab4add3aba5706877feb0a3dcfe8127b5dfeefe2513a5", [:mix], [], "hexpm", "23080d2e0b4490eb2e207c8fee71d34bc0e58cc4f0f6879ca06b8fabe0c531ca"}, 17 | } 18 | -------------------------------------------------------------------------------- /test/rollout_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RolloutTest do 2 | use ExUnit.Case 3 | 4 | setup_all do 5 | Application.ensure_all_started(:rollout) 6 | nodes = LocalCluster.start_nodes("rollout-test", 2) 7 | 8 | {:ok, nodes: nodes} 9 | end 10 | 11 | setup do 12 | # Clean out storage in between test runs 13 | Groot.Storage.delete_all() 14 | 15 | :ok 16 | end 17 | 18 | test "flags are replicated across the cluster", %{nodes: nodes} do 19 | [n1, n2] = nodes 20 | 21 | Rollout.activate(:comments) 22 | 23 | eventually(fn -> 24 | assert Rollout.active?(:comments) == true 25 | assert :rpc.call(n1, Rollout, :active?, [:comments]) == true 26 | assert :rpc.call(n2, Rollout, :active?, [:comments]) == true 27 | end) 28 | 29 | Rollout.deactivate(:comments) 30 | 31 | eventually(fn -> 32 | assert Rollout.active?(:comments) == false 33 | assert :rpc.call(n1, Rollout, :active?, [:comments]) == false 34 | assert :rpc.call(n2, Rollout, :active?, [:comments]) == false 35 | end) 36 | end 37 | 38 | def eventually(f, retries \\ 0) do 39 | f.() 40 | rescue 41 | err -> 42 | if retries >= 10 do 43 | reraise err, __STACKTRACE__ 44 | else 45 | :timer.sleep(500) 46 | eventually(f, retries + 1) 47 | end 48 | catch 49 | _exit, _term -> 50 | :timer.sleep(500) 51 | eventually(f, retries + 1) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | LocalCluster.start() 2 | ExUnit.start() 3 | --------------------------------------------------------------------------------