├── .credo.exs ├── .github └── workflows │ ├── quality.yml │ └── test.yml ├── .gitignore ├── .iex.exs ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── benchmarks ├── README.md ├── cache.exs ├── flag.exs └── persistence.exs ├── bin ├── console_ecto └── console_pubsub ├── config ├── config.exs └── test.exs ├── dev_support ├── ecto │ └── repo.ex └── protocols.ex ├── lib ├── fun_with_flags.ex └── fun_with_flags │ ├── application.ex │ ├── config.ex │ ├── flag.ex │ ├── gate.ex │ ├── notifications │ ├── phoenix_pubsub.ex │ └── redis.ex │ ├── protocols │ ├── actor.ex │ └── group.ex │ ├── simple_store.ex │ ├── store.ex │ ├── store │ ├── cache.ex │ ├── persistent.ex │ ├── persistent │ │ ├── ecto.ex │ │ ├── ecto │ │ │ ├── null_repo.ex │ │ │ └── record.ex │ │ └── redis.ex │ └── serializer │ │ ├── ecto.ex │ │ └── redis.ex │ ├── supervisor.ex │ ├── telemetry.ex │ └── timestamps.ex ├── mix.exs ├── mix.lock ├── priv └── ecto_repo │ └── migrations │ └── 00000000000000_create_feature_flags_table.exs └── test ├── fun_with_flags ├── config_test.exs ├── flag_test.exs ├── gate_test.exs ├── notifications │ ├── phoenix_pubsub_test.exs │ └── redis_test.exs ├── protocols │ ├── actor_test.exs │ └── group_test.exs ├── simple_store_test.exs ├── store │ ├── cache_test.exs │ ├── persistent │ │ ├── ecto_test.exs │ │ └── redis_test.exs │ └── serializer │ │ ├── ecto_test.exs │ │ └── redis_test.exs ├── store_test.exs ├── supervisor_test.exs ├── telemetry_test.exs └── timestamps_test.exs ├── fun_with_flags_test.exs ├── support ├── test_case.ex ├── test_user.ex └── test_utils.ex └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: [ 25 | "lib/", 26 | "src/", 27 | # "test/", 28 | "web/", 29 | "apps/*/lib/", 30 | "apps/*/src/", 31 | "apps/*/test/", 32 | "apps/*/web/" 33 | ], 34 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 35 | }, 36 | # 37 | # Load and configure plugins here: 38 | # 39 | plugins: [], 40 | # 41 | # If you create your own checks, you must specify the source files for 42 | # them here, so they can be loaded by Credo before running the analysis. 43 | # 44 | requires: [], 45 | # 46 | # If you want to enforce a style guide and need a more traditional linting 47 | # experience, you can change `strict` to `true` below: 48 | # 49 | strict: true, # false, 50 | # 51 | # To modify the timeout for parsing files, change this value: 52 | # 53 | parse_timeout: 5000, 54 | # 55 | # If you want to use uncolored output by default, you can change `color` 56 | # to `false` below: 57 | # 58 | color: true, 59 | # 60 | # You can customize the parameters of any check by adding a second element 61 | # to the tuple. 62 | # 63 | # To disable a check put `false` as second element: 64 | # 65 | # {Credo.Check.Design.DuplicatedCode, false} 66 | # 67 | checks: %{ 68 | enabled: [ 69 | # 70 | ## Consistency Checks 71 | # 72 | {Credo.Check.Consistency.ExceptionNames, []}, 73 | {Credo.Check.Consistency.LineEndings, []}, 74 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 75 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 76 | {Credo.Check.Consistency.SpaceInParentheses, []}, 77 | {Credo.Check.Consistency.TabsOrSpaces, []}, 78 | 79 | # 80 | ## Design Checks 81 | # 82 | # You can customize the priority of any check 83 | # Priority values are: `low, normal, high, higher` 84 | # 85 | # {Credo.Check.Design.AliasUsage, 86 | # [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 87 | {Credo.Check.Design.TagFIXME, []}, 88 | # You can also customize the exit_status of each check. 89 | # If you don't want TODO comments to cause `mix credo` to fail, just 90 | # set this value to 0 (zero). 91 | # 92 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 93 | 94 | # 95 | ## Readability Checks 96 | # 97 | {Credo.Check.Readability.AliasOrder, []}, 98 | {Credo.Check.Readability.FunctionNames, []}, 99 | {Credo.Check.Readability.LargeNumbers, []}, 100 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 101 | {Credo.Check.Readability.ModuleAttributeNames, []}, 102 | {Credo.Check.Readability.ModuleDoc, []}, 103 | {Credo.Check.Readability.ModuleNames, []}, 104 | {Credo.Check.Readability.ParenthesesInCondition, []}, 105 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 106 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, 107 | {Credo.Check.Readability.PredicateFunctionNames, []}, 108 | # {Credo.Check.Readability.PreferImplicitTry, []}, 109 | {Credo.Check.Readability.RedundantBlankLines, [max_blank_lines: 2]}, 110 | {Credo.Check.Readability.Semicolons, []}, 111 | {Credo.Check.Readability.SpaceAfterCommas, []}, 112 | {Credo.Check.Readability.StringSigils, []}, 113 | {Credo.Check.Readability.TrailingBlankLine, []}, 114 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 115 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 116 | {Credo.Check.Readability.VariableNames, []}, 117 | {Credo.Check.Readability.WithSingleClause, []}, 118 | 119 | # 120 | ## Refactoring Opportunities 121 | # 122 | {Credo.Check.Refactor.Apply, []}, 123 | {Credo.Check.Refactor.CondStatements, []}, 124 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 125 | {Credo.Check.Refactor.FilterCount, []}, 126 | {Credo.Check.Refactor.FilterFilter, []}, 127 | {Credo.Check.Refactor.FunctionArity, []}, 128 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 129 | {Credo.Check.Refactor.MapJoin, []}, 130 | {Credo.Check.Refactor.MatchInCondition, []}, 131 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 132 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 133 | {Credo.Check.Refactor.Nesting, [max_nesting: 3]}, 134 | {Credo.Check.Refactor.RedundantWithClauseResult, []}, 135 | {Credo.Check.Refactor.RejectReject, []}, 136 | {Credo.Check.Refactor.UnlessWithElse, []}, 137 | {Credo.Check.Refactor.WithClauses, []}, 138 | 139 | # 140 | ## Warnings 141 | # 142 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 143 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 144 | {Credo.Check.Warning.Dbg, []}, 145 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 146 | {Credo.Check.Warning.IExPry, []}, 147 | {Credo.Check.Warning.IoInspect, []}, 148 | {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, 149 | {Credo.Check.Warning.OperationOnSameValues, []}, 150 | {Credo.Check.Warning.OperationWithConstantResult, []}, 151 | {Credo.Check.Warning.RaiseInsideRescue, []}, 152 | {Credo.Check.Warning.SpecWithStruct, []}, 153 | {Credo.Check.Warning.UnsafeExec, []}, 154 | {Credo.Check.Warning.UnusedEnumOperation, []}, 155 | {Credo.Check.Warning.UnusedFileOperation, []}, 156 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 157 | {Credo.Check.Warning.UnusedListOperation, []}, 158 | {Credo.Check.Warning.UnusedPathOperation, []}, 159 | {Credo.Check.Warning.UnusedRegexOperation, []}, 160 | {Credo.Check.Warning.UnusedStringOperation, []}, 161 | {Credo.Check.Warning.UnusedTupleOperation, []}, 162 | {Credo.Check.Warning.WrongTestFileExtension, []} 163 | ], 164 | disabled: [ 165 | # 166 | # Checks scheduled for next check update (opt-in for now) 167 | {Credo.Check.Refactor.UtcNowTruncate, []}, 168 | 169 | # 170 | # Controversial and experimental checks (opt-in, just move the check to `:enabled` 171 | # and be sure to use `mix credo --strict` to see low priority checks) 172 | # 173 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 174 | {Credo.Check.Consistency.UnusedVariableNames, []}, 175 | {Credo.Check.Design.DuplicatedCode, []}, 176 | {Credo.Check.Design.SkipTestWithoutComment, []}, 177 | {Credo.Check.Readability.AliasAs, []}, 178 | {Credo.Check.Readability.BlockPipe, []}, 179 | {Credo.Check.Readability.ImplTrue, []}, 180 | {Credo.Check.Readability.MultiAlias, []}, 181 | {Credo.Check.Readability.NestedFunctionCalls, []}, 182 | {Credo.Check.Readability.OneArityFunctionInPipe, []}, 183 | {Credo.Check.Readability.OnePipePerLine, []}, 184 | {Credo.Check.Readability.SeparateAliasRequire, []}, 185 | {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, 186 | {Credo.Check.Readability.SinglePipe, []}, 187 | {Credo.Check.Readability.Specs, []}, 188 | {Credo.Check.Readability.StrictModuleLayout, []}, 189 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 190 | {Credo.Check.Refactor.ABCSize, []}, 191 | {Credo.Check.Refactor.AppendSingleItem, []}, 192 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 193 | {Credo.Check.Refactor.FilterReject, []}, 194 | {Credo.Check.Refactor.IoPuts, []}, 195 | {Credo.Check.Refactor.MapMap, []}, 196 | {Credo.Check.Refactor.ModuleDependencies, []}, 197 | {Credo.Check.Refactor.NegatedIsNil, []}, 198 | {Credo.Check.Refactor.PassAsyncInTestCases, []}, 199 | {Credo.Check.Refactor.PipeChainStart, []}, 200 | {Credo.Check.Refactor.RejectFilter, []}, 201 | {Credo.Check.Refactor.VariableRebinding, []}, 202 | {Credo.Check.Warning.LazyLogging, []}, 203 | {Credo.Check.Warning.LeakyEnvironment, []}, 204 | {Credo.Check.Warning.MapGetUnsafePass, []}, 205 | {Credo.Check.Warning.MixEnv, []}, 206 | {Credo.Check.Warning.UnsafeToAtom, []} 207 | 208 | # {Credo.Check.Refactor.MapInto, []}, 209 | 210 | # 211 | # Custom checks can be created using `mix credo.gen.check`. 212 | # 213 | ] 214 | } 215 | } 216 | ] 217 | } 218 | -------------------------------------------------------------------------------- /.github/workflows/quality.yml: -------------------------------------------------------------------------------- 1 | name: Code Quality 2 | 3 | on: 4 | push: 5 | branches: [master, v2] 6 | pull_request: 7 | branches: [master, v2] 8 | 9 | env: 10 | elixir_version: '1.18' 11 | otp_version: '27.3' 12 | 13 | jobs: 14 | credo: 15 | name: Credo 16 | runs-on: ubuntu-24.04 17 | 18 | strategy: 19 | fail-fast: false 20 | 21 | steps: 22 | - name: Set up Elixir and OTP 23 | uses: erlef/setup-beam@v1 24 | with: 25 | elixir-version: ${{ env.elixir_version }} 26 | otp-version: ${{ env.otp_version }} 27 | 28 | - name: Checkout code 29 | uses: actions/checkout@v4 30 | 31 | - name: 'Restore cache for deps/ and _build/ directories' 32 | uses: actions/cache@v4 33 | with: 34 | path: | 35 | deps 36 | _build 37 | key: ${{ runner.os }}-mix-dev-${{ env.elixir_version }}-${{ env.otp_version }}-${{ hashFiles('**/mix.lock') }} 38 | restore-keys: ${{ runner.os }}-mix-dev-${{ env.elixir_version }}-${{ env.otp_version }}- 39 | 40 | - name: Install Mix dependencies 41 | run: mix deps.get 42 | 43 | - name: Run credo 44 | run: mix credo 45 | dialyzer: 46 | name: Dialyzer 47 | runs-on: ubuntu-24.04 48 | 49 | strategy: 50 | fail-fast: false 51 | 52 | steps: 53 | - name: Set up Elixir and OTP 54 | uses: erlef/setup-beam@v1 55 | with: 56 | elixir-version: ${{ env.elixir_version }} 57 | otp-version: ${{ env.otp_version }} 58 | 59 | - name: Checkout code 60 | uses: actions/checkout@v4 61 | 62 | - name: 'Restore cache for deps/ and _build/ directories' 63 | uses: actions/cache@v4 64 | with: 65 | path: | 66 | deps 67 | _build 68 | # Dialyzer uses PLT files for caching project info. These are in _build/dev/, but they're only created 69 | # when running dialyzer for the first time, and updated when the deps change. 70 | key: ${{ runner.os }}-plts-${{ env.elixir_version }}-${{ env.otp_version }}-${{ hashFiles('**/mix.lock') }} 71 | restore-keys: ${{ runner.os }}-plts-${{ env.elixir_version }}-${{ env.otp_version }}- 72 | 73 | - name: Install Mix dependencies 74 | run: mix deps.get 75 | 76 | - name: Run dialyzer 77 | run: mix dialyzer 78 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Mix Tests 2 | 3 | on: 4 | push: 5 | branches: [master, v2] 6 | pull_request: 7 | branches: [master, v2] 8 | 9 | jobs: 10 | build: 11 | name: Elixir ${{ matrix.elixir }} with OTP ${{ matrix.otp }} 12 | runs-on: ubuntu-24.04 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | elixir: 18 | - '1.18' 19 | - '1.17' 20 | - '1.16' 21 | otp: 22 | - '27.3' 23 | - '27.2' 24 | - '26.2' 25 | - '25.3' 26 | exclude: 27 | # Elixir 1.18 + OTP 25 is technically supported: 28 | # https://hexdocs.pm/elixir/1.18.1/compatibility-and-deprecations.html#between-elixir-and-erlang-otp 29 | # but not on GHA: 30 | # https://github.com/tompave/fun_with_flags/actions/runs/12515433561/job/34912997388?pr=189#step:5:20 31 | - elixir: '1.18' 32 | otp: '25.3' 33 | - elixir: '1.16' 34 | otp: '27.3' 35 | - elixir: '1.16' 36 | otp: '27.2' 37 | 38 | services: 39 | postgres: 40 | image: postgres:16 41 | ports: ['5432:5432'] 42 | env: 43 | POSTGRES_PASSWORD: postgres 44 | options: >- 45 | --health-cmd pg_isready 46 | --health-interval 10s 47 | --health-timeout 5s 48 | --health-retries 3 49 | redis: 50 | image: redis 51 | ports: ['6379:6379'] 52 | 53 | steps: 54 | 55 | - name: Start Default MySQL 5.7 56 | run: sudo /etc/init.d/mysql start 57 | 58 | - name: 'Wait for MySQL to be ready (TODO: needs timeout)' 59 | run: | 60 | while ! mysqladmin ping -h"127.0.0.1" -uroot -proot -P3306 --silent >/dev/null 2>&1; do 61 | sleep 1 62 | done 63 | 64 | - name: Set up Elixir and OTP 65 | uses: erlef/setup-beam@v1 66 | with: 67 | elixir-version: ${{ matrix.elixir }} 68 | otp-version: ${{ matrix.otp }} 69 | 70 | - name: Checkout code 71 | uses: actions/checkout@v4 72 | 73 | - name: 'Restore cache for deps/ and _build/ directories' 74 | uses: actions/cache@v4 75 | with: 76 | path: | 77 | deps 78 | _build 79 | key: ${{ runner.os }}-mix-test-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }} 80 | restore-keys: ${{ runner.os }}-mix-test-${{ matrix.elixir }}-${{ matrix.otp }}- 81 | 82 | # - name: '[DEBUG] List cached dirs' 83 | # run: | 84 | # ls -la deps 85 | # ls -la _build 86 | # ls -la _build/test/lib 87 | 88 | - name: Install Mix dependencies 89 | run: mix deps.get 90 | 91 | - name: Create and migrate the DB on Postgres 92 | run: | 93 | MIX_ENV=test PERSISTENCE=ecto RDBMS=postgres mix compile --warnings-as-errors 94 | MIX_ENV=test PERSISTENCE=ecto RDBMS=postgres mix do ecto.create, ecto.migrate 95 | rm -rf _build/test/lib/fun_with_flags 96 | 97 | - name: Create and migrate the DB on MySQL 98 | run: | 99 | MIX_ENV=test PERSISTENCE=ecto RDBMS=mysql mix compile --warnings-as-errors 100 | MIX_ENV=test PERSISTENCE=ecto RDBMS=mysql mix do ecto.create, ecto.migrate 101 | rm -rf _build/test/lib/fun_with_flags 102 | 103 | - name: Create and migrate the DB on SQLite 104 | run: | 105 | MIX_ENV=test PERSISTENCE=ecto RDBMS=sqlite mix compile --warnings-as-errors 106 | MIX_ENV=test PERSISTENCE=ecto RDBMS=sqlite mix do ecto.create, ecto.migrate 107 | rm -rf _build/test/lib/fun_with_flags 108 | 109 | - name: Run all tests 110 | run: mix test.all 111 | -------------------------------------------------------------------------------- /.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 3rd-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 | # Redis db 23 | dump.rdb 24 | 25 | # VSCode 26 | .vscode 27 | 28 | # Sqlite3 db 29 | fun_with_flags_test 30 | fun_with_flags_test-shm 31 | fun_with_flags_test-wal 32 | -------------------------------------------------------------------------------- /.iex.exs: -------------------------------------------------------------------------------- 1 | import FunWithFlags 2 | alias FunWithFlags.{Store,Config,Flag,Gate} 3 | alias FunWithFlags.Store.{Cache,Persistent,Serializer} 4 | alias FunWithFlags.{Actor,Group} 5 | 6 | alias FunWithFlags.Dev.EctoRepo, as: Repo 7 | alias FunWithFlags.Store.Persistent.Ecto.Record 8 | alias FunWithFlags.Supervisor, as: Sup 9 | 10 | 11 | # When calling `respawn` in a iex session, e.g. debugging tests, 12 | # the .iex.exs file will be parsed and executed again, and 13 | # these `start_link` with explicit names will fail as already 14 | # started. 15 | # 16 | with_safe_restart = fn(f) -> 17 | case f.() do 18 | {:ok, _pid} -> 19 | # IO.puts "starting" 20 | :ok 21 | {:error, {:already_started, _pid}} -> 22 | # IO.puts "already started" 23 | :ok 24 | end 25 | end 26 | 27 | if Config.persist_in_ecto? do 28 | with_safe_restart.(fn -> 29 | FunWithFlags.Dev.EctoRepo.start_link() 30 | end) 31 | else 32 | with_safe_restart.(fn -> 33 | Redix.start_link( 34 | Keyword.merge( 35 | Config.redis_config, 36 | [name: :dev_console_redis, sync_connect: false] 37 | ) 38 | ) 39 | end) 40 | end 41 | 42 | if Config.phoenix_pubsub? do 43 | with_safe_restart.(fn -> 44 | children = [ 45 | {Phoenix.PubSub, [name: :fwf_test, adapter: Phoenix.PubSub.PG2, pool_size: 1]} 46 | ] 47 | opts = [strategy: :one_for_one, name: MyApp.Supervisor] 48 | Supervisor.start_link(children, opts) 49 | end) 50 | end 51 | 52 | alias FunWithFlags.Store.Persistent.Ecto, as: PEcto 53 | 54 | cacheinfo = fn() -> 55 | size = :ets.info(:fun_with_flags_cache)[:size] 56 | IO.puts "size: #{size}" 57 | :ets.i(:fun_with_flags_cache) 58 | end 59 | 60 | # FunWithFlags.Telemetry.attach_debug_handler() 61 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Tommaso Pavese 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /benchmarks/README.md: -------------------------------------------------------------------------------- 1 | # Benchmarks 2 | 3 | Some simple benchmark scripts for the package. Use them as they are or modify them to test specific scenarios. 4 | 5 | Example with Redis: 6 | 7 | ``` 8 | rm -r _build/dev/lib/fun_with_flags/ && 9 | PERSISTENCE=redis CACHE_ENABLED=true mix run benchmarks/flag.exs 10 | ``` 11 | 12 | Running the benchmarks with Ecto: 13 | 14 | ``` 15 | rm -r _build/dev/lib/fun_with_flags/ && 16 | PERSISTENCE=ecto RDBMS=postgres CACHE_ENABLED=false mix run benchmarks/persistence.exs 17 | ``` 18 | -------------------------------------------------------------------------------- /benchmarks/cache.exs: -------------------------------------------------------------------------------- 1 | # :observer.start 2 | 3 | Logger.configure(level: :error) 4 | 5 | # Start the ecto repo if running the benchmarks with ecto. 6 | if System.get_env("PERSISTENCE") == "ecto" do 7 | {:ok, _pid} = FunWithFlags.Dev.EctoRepo.start_link() 8 | end 9 | 10 | FunWithFlags.clear(:one) 11 | FunWithFlags.clear(:two) 12 | FunWithFlags.clear(:three) 13 | FunWithFlags.clear(:four) 14 | 15 | alias PlainUser, as: User 16 | 17 | u1 = %User{id: 1, group: "foo"} 18 | u2 = %User{id: 2, group: "foo"} 19 | u3 = %User{id: 3, group: "bar"} 20 | u4 = %User{id: 4, group: "bar"} 21 | 22 | FunWithFlags.enable(:one) 23 | 24 | FunWithFlags.enable(:two) 25 | FunWithFlags.enable(:two, for_actor: u4) 26 | FunWithFlags.disable(:two, for_group: "nope") 27 | 28 | FunWithFlags.disable(:three) 29 | FunWithFlags.enable(:three, for_actor: u2) 30 | FunWithFlags.enable(:three, for_actor: u3) 31 | FunWithFlags.enable(:three, for_actor: u4) 32 | FunWithFlags.disable(:three, for_group: "nope") 33 | FunWithFlags.disable(:three, for_group: "nope2") 34 | 35 | 36 | FunWithFlags.disable(:four) 37 | FunWithFlags.enable(:four, for_actor: u2) 38 | FunWithFlags.enable(:four, for_actor: u3) 39 | FunWithFlags.enable(:four, for_actor: u4) 40 | FunWithFlags.enable(:four, for_actor: "a") 41 | FunWithFlags.enable(:four, for_actor: "b") 42 | FunWithFlags.enable(:four, for_actor: "c") 43 | FunWithFlags.enable(:four, for_actor: "d") 44 | FunWithFlags.enable(:four, for_actor: "e") 45 | FunWithFlags.disable(:four, for_group: "nope") 46 | FunWithFlags.disable(:four, for_group: "nope2") 47 | FunWithFlags.disable(:four, for_group: "nope3") 48 | FunWithFlags.disable(:four, for_group: "nope4") 49 | FunWithFlags.enable(:four, for_percentage_of: {:actors, 0.99}) 50 | 51 | # warm up the cache 52 | FunWithFlags.enabled?(:one) 53 | FunWithFlags.enabled?(:two) 54 | FunWithFlags.enabled?(:three) 55 | FunWithFlags.enabled?(:four) 56 | 57 | 58 | alias FunWithFlags.Store.Cache 59 | 60 | # ----------------------------------- 61 | one = fn() -> 62 | Cache.get(:one) 63 | end 64 | 65 | two = fn() -> 66 | Cache.get(:two) 67 | end 68 | 69 | three = fn() -> 70 | Cache.get(:three) 71 | end 72 | 73 | four = fn() -> 74 | Cache.get(:four) 75 | end 76 | 77 | 78 | Benchee.run( 79 | %{ 80 | "one" => one, 81 | "two" => two, 82 | "three" => three, 83 | "four" => four, 84 | }#, 85 | # formatters: [ 86 | # Benchee.Formatters.HTML, 87 | # Benchee.Formatters.Console 88 | # ] 89 | ) 90 | -------------------------------------------------------------------------------- /benchmarks/flag.exs: -------------------------------------------------------------------------------- 1 | # :observer.start 2 | 3 | Logger.configure(level: :error) 4 | 5 | # Start the ecto repo if running the benchmarks with ecto. 6 | if System.get_env("PERSISTENCE") == "ecto" do 7 | {:ok, _pid} = FunWithFlags.Dev.EctoRepo.start_link() 8 | end 9 | 10 | FunWithFlags.clear(:one) 11 | FunWithFlags.clear(:two) 12 | FunWithFlags.clear(:three) 13 | FunWithFlags.clear(:four) 14 | 15 | alias PlainUser, as: User 16 | 17 | u1 = %User{id: 1, group: "foo"} 18 | u2 = %User{id: 2, group: "foo"} 19 | u3 = %User{id: 3, group: "bar"} 20 | u4 = %User{id: 4, group: "bar"} 21 | 22 | FunWithFlags.enable(:one) 23 | 24 | FunWithFlags.enable(:two) 25 | FunWithFlags.enable(:two, for_actor: u4) 26 | FunWithFlags.disable(:two, for_group: "nope") 27 | 28 | FunWithFlags.disable(:three) 29 | FunWithFlags.enable(:three, for_actor: u2) 30 | FunWithFlags.enable(:three, for_actor: u3) 31 | FunWithFlags.enable(:three, for_actor: u4) 32 | FunWithFlags.disable(:three, for_group: "nope") 33 | FunWithFlags.disable(:three, for_group: "nope2") 34 | 35 | 36 | FunWithFlags.disable(:four) 37 | FunWithFlags.enable(:four, for_actor: u2) 38 | FunWithFlags.enable(:four, for_actor: u3) 39 | FunWithFlags.enable(:four, for_actor: u4) 40 | FunWithFlags.enable(:four, for_actor: "a") 41 | FunWithFlags.enable(:four, for_actor: "b") 42 | FunWithFlags.enable(:four, for_actor: "c") 43 | FunWithFlags.enable(:four, for_actor: "d") 44 | FunWithFlags.enable(:four, for_actor: "e") 45 | FunWithFlags.disable(:four, for_group: "nope") 46 | FunWithFlags.disable(:four, for_group: "nope2") 47 | FunWithFlags.disable(:four, for_group: "nope3") 48 | FunWithFlags.disable(:four, for_group: "nope4") 49 | FunWithFlags.enable(:four, for_percentage_of: {:actors, 0.99}) 50 | 51 | # warm up the cache 52 | FunWithFlags.enabled?(:one) 53 | FunWithFlags.enabled?(:two) 54 | FunWithFlags.enabled?(:three) 55 | FunWithFlags.enabled?(:four) 56 | 57 | # ----------------------------------- 58 | one_a = fn() -> 59 | FunWithFlags.enabled?(:one) 60 | end 61 | 62 | one_b = fn() -> 63 | FunWithFlags.enabled?(:one, for: u1) 64 | end 65 | 66 | two_a = fn() -> 67 | FunWithFlags.enabled?(:two) 68 | end 69 | 70 | two_b = fn() -> 71 | FunWithFlags.enabled?(:two, for: u1) 72 | end 73 | 74 | three_a = fn() -> 75 | FunWithFlags.enabled?(:three) 76 | end 77 | 78 | three_b = fn() -> 79 | FunWithFlags.enabled?(:three, for: u1) 80 | end 81 | 82 | four_a = fn() -> 83 | FunWithFlags.enabled?(:four) 84 | end 85 | 86 | four_b = fn() -> 87 | FunWithFlags.enabled?(:four, for: u1) 88 | end 89 | 90 | Benchee.run( 91 | %{ 92 | "one_a" => one_a, 93 | "one_b" => one_b, 94 | "two_a" => two_a, 95 | "two_b" => two_b, 96 | "three_a" => three_a, 97 | "three_b" => three_b, 98 | "four_a" => four_a, 99 | "four_b" => four_b, 100 | }#, 101 | # formatters: [ 102 | # Benchee.Formatters.HTML, 103 | # Benchee.Formatters.Console 104 | # ] 105 | ) 106 | -------------------------------------------------------------------------------- /benchmarks/persistence.exs: -------------------------------------------------------------------------------- 1 | # Test the performance of the persistence adapters. 2 | # This benchmark is mostly affected by the performance of the underlying datastore. 3 | # However, it's also useful to assess how the store is accessed in Elixir. For example, 4 | # when switching from compiled-in config to just straight calls to the config module. 5 | 6 | # :observer.start 7 | 8 | Logger.configure(level: :error) 9 | 10 | # Start the ecto repo if running the benchmarks with ecto. 11 | if System.get_env("PERSISTENCE") == "ecto" do 12 | {:ok, _pid} = FunWithFlags.Dev.EctoRepo.start_link() 13 | end 14 | 15 | FunWithFlags.clear(:one) 16 | FunWithFlags.clear(:two) 17 | FunWithFlags.clear(:three) 18 | FunWithFlags.clear(:four) 19 | 20 | alias PlainUser, as: User 21 | 22 | u1 = %User{id: 1, group: "foo"} 23 | u2 = %User{id: 2, group: "foo"} 24 | u3 = %User{id: 3, group: "bar"} 25 | u4 = %User{id: 4, group: "bar"} 26 | 27 | FunWithFlags.enable(:one) 28 | 29 | FunWithFlags.enable(:two) 30 | FunWithFlags.enable(:two, for_actor: u4) 31 | FunWithFlags.disable(:two, for_group: "nope") 32 | 33 | FunWithFlags.disable(:three) 34 | FunWithFlags.enable(:three, for_actor: u2) 35 | FunWithFlags.enable(:three, for_actor: u3) 36 | FunWithFlags.enable(:three, for_actor: u4) 37 | FunWithFlags.disable(:three, for_group: "nope") 38 | FunWithFlags.disable(:three, for_group: "nope2") 39 | 40 | 41 | FunWithFlags.disable(:four) 42 | FunWithFlags.enable(:four, for_actor: u2) 43 | FunWithFlags.enable(:four, for_actor: u3) 44 | FunWithFlags.enable(:four, for_actor: u4) 45 | FunWithFlags.enable(:four, for_actor: "a") 46 | FunWithFlags.enable(:four, for_actor: "b") 47 | FunWithFlags.enable(:four, for_actor: "c") 48 | FunWithFlags.enable(:four, for_actor: "d") 49 | FunWithFlags.enable(:four, for_actor: "e") 50 | FunWithFlags.disable(:four, for_group: "nope") 51 | FunWithFlags.disable(:four, for_group: "nope2") 52 | FunWithFlags.disable(:four, for_group: "nope3") 53 | FunWithFlags.disable(:four, for_group: "nope4") 54 | FunWithFlags.enable(:four, for_percentage_of: {:actors, 0.99}) 55 | 56 | alias FunWithFlags.SimpleStore 57 | 58 | # ----------------------------------- 59 | one = fn() -> 60 | SimpleStore.lookup(:one) 61 | end 62 | 63 | two = fn() -> 64 | SimpleStore.lookup(:two) 65 | end 66 | 67 | three = fn() -> 68 | SimpleStore.lookup(:three) 69 | end 70 | 71 | four = fn() -> 72 | SimpleStore.lookup(:four) 73 | end 74 | 75 | 76 | Benchee.run( 77 | %{ 78 | "one" => one, 79 | "two" => two, 80 | "three" => three, 81 | "four" => four, 82 | } 83 | ) 84 | -------------------------------------------------------------------------------- /bin/console_ecto: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | rdbms=${1:-postgres} 4 | 5 | # Support `$@` to pass extra options to `iex`. 6 | 7 | rm -rf _build/dev/lib/fun_with_flags/ && 8 | rm -rf _build/test/lib/fun_with_flags/ && 9 | PERSISTENCE=ecto RDBMS="$rdbms" PUBSUB_BROKER=phoenix_pubsub iex -S mix; 10 | -------------------------------------------------------------------------------- /bin/console_pubsub: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | nodename=${1:-one} 4 | 5 | # Support `$@` to pass extra options to `iex`. 6 | 7 | rm -rf _build/dev/lib/fun_with_flags/ && 8 | rm -rf _build/test/lib/fun_with_flags/ && 9 | PERSISTENCE=ecto RDBMS="postgres" PUBSUB_BROKER=phoenix_pubsub iex --name "$nodename" -S mix; 10 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config :fun_with_flags, :persistence, 4 | # [adapter: FunWithFlags.Store.Persistent.Redis] 5 | # config :fun_with_flags, :cache_bust_notifications, 6 | # [enabled: true, adapter: FunWithFlags.Notifications.Redis] 7 | 8 | 9 | # ------------------------------------------------- 10 | # Extract from the ENV 11 | 12 | with_cache = 13 | case System.get_env("CACHE_ENABLED") do 14 | "false" -> false 15 | "0" -> false 16 | _ -> true # default 17 | end 18 | 19 | with_phx_pubsub = 20 | case System.get_env("PUBSUB_BROKER") do 21 | "phoenix_pubsub" -> true 22 | _ -> false 23 | end 24 | 25 | with_ecto = 26 | case System.get_env("PERSISTENCE") do 27 | "ecto" -> true 28 | _ -> false # default 29 | end 30 | 31 | 32 | # ------------------------------------------------- 33 | # Configuration 34 | 35 | config :fun_with_flags, :cache, 36 | enabled: with_cache, 37 | ttl: 60 38 | 39 | 40 | if with_phx_pubsub do 41 | config :fun_with_flags, :cache_bust_notifications, [ 42 | adapter: FunWithFlags.Notifications.PhoenixPubSub, 43 | client: :fwf_test 44 | ] 45 | end 46 | 47 | 48 | if with_ecto do 49 | # this library's config 50 | config :fun_with_flags, :persistence, 51 | adapter: FunWithFlags.Store.Persistent.Ecto, 52 | repo: FunWithFlags.Dev.EctoRepo 53 | 54 | # To test the compile-time config warnings. 55 | # config :fun_with_flags, :persistence, 56 | # ecto_table_name: System.get_env("ECTO_TABLE_NAME", "fun_with_flags_toggles") 57 | 58 | # ecto's config 59 | config :fun_with_flags, ecto_repos: [FunWithFlags.Dev.EctoRepo] 60 | 61 | config :fun_with_flags, FunWithFlags.Dev.EctoRepo, 62 | database: "fun_with_flags_dev", 63 | hostname: "localhost", 64 | pool_size: 10 65 | 66 | case System.get_env("RDBMS") do 67 | "mysql" -> 68 | mysql_password = case System.get_env("CI") do 69 | "true" -> "root" # On GitHub Actions. 70 | _ -> "" # For a default dev-insecure installation, e.g. via Homebrew on macOS. 71 | end 72 | 73 | config :fun_with_flags, FunWithFlags.Dev.EctoRepo, 74 | username: "root", 75 | password: mysql_password 76 | "sqlite" -> 77 | config :fun_with_flags, FunWithFlags.Dev.EctoRepo, 78 | username: "sqlite", 79 | password: "sqlite" 80 | _ -> 81 | config :fun_with_flags, FunWithFlags.Dev.EctoRepo, 82 | username: "postgres", 83 | password: "postgres" 84 | end 85 | end 86 | 87 | # ------------------------------------------------- 88 | # Import 89 | # 90 | case config_env() do 91 | :test -> import_config "test.exs" 92 | _ -> nil 93 | end 94 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :fun_with_flags, :redis, 4 | database: 5 5 | 6 | config :logger, level: :error 7 | 8 | 9 | if System.get_env("PERSISTENCE") == "ecto" do 10 | config :fun_with_flags, FunWithFlags.Dev.EctoRepo, 11 | database: "fun_with_flags_test", 12 | pool: Ecto.Adapters.SQL.Sandbox, 13 | ownership_timeout: 10 * 60 * 1000 14 | end 15 | -------------------------------------------------------------------------------- /dev_support/ecto/repo.ex: -------------------------------------------------------------------------------- 1 | if FunWithFlags.Config.persist_in_ecto? do 2 | defmodule FunWithFlags.Dev.EctoRepo do 3 | 4 | # Only for dev and test. 5 | # 6 | @variant (case System.get_env("RDBMS") do 7 | "mysql" -> 8 | # Ecto.Adapters.MySQL # mariaex, legacy 9 | Ecto.Adapters.MyXQL # myxql, introduced in ecto_sql 3.1 10 | "sqlite" -> 11 | Ecto.Adapters.SQLite3 12 | _ -> 13 | Ecto.Adapters.Postgres 14 | end) 15 | 16 | use Ecto.Repo, otp_app: :fun_with_flags, adapter: @variant 17 | 18 | # For testing setups that use multi-tenancy using foreign keys 19 | # as described in the Ecto docs: 20 | # https://hexdocs.pm/ecto/3.8.4/multi-tenancy-with-foreign-keys.html 21 | # 22 | # FunWithFlags sets the custom query option `:fun_with_flags` to 23 | # `true` to allow such setups to detect queries originating from 24 | # FunWithFlags. 25 | # 26 | # This dev/repo implements the callback simplu as a detection mechanism: 27 | # if the package code changed to remove the custom query option, this will 28 | # cause the tests to fail. Other than that, it has no purpose. 29 | # 30 | @impl true 31 | def prepare_query(_operation, query, opts) do 32 | cond do 33 | opts[:schema_migration] || opts[:fun_with_flags] -> 34 | {query, opts} 35 | 36 | true -> 37 | raise "expected fun_with_flags query option to be set" 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /dev_support/protocols.ex: -------------------------------------------------------------------------------- 1 | defimpl FunWithFlags.Actor, for: Map do 2 | def id(%{actor_id: actor_id}) do 3 | "map:#{actor_id}" 4 | end 5 | 6 | def id(map) do 7 | map 8 | |> inspect() 9 | |> (&:crypto.hash(:md5, &1)).() 10 | |> Base.encode16 11 | |> (&"map:#{&1}").() 12 | end 13 | end 14 | 15 | 16 | defimpl FunWithFlags.Actor, for: BitString do 17 | def id(str) do 18 | "string:#{str}" 19 | end 20 | end 21 | 22 | defimpl FunWithFlags.Group, for: BitString do 23 | def in?(str, group_name) do 24 | String.contains?(str, to_string(group_name)) 25 | end 26 | end 27 | 28 | 29 | defimpl FunWithFlags.Group, for: Map do 30 | def in?(%{group: group_name}, group_name), do: true 31 | def in?(_, _), do: false 32 | end 33 | 34 | 35 | defmodule PlainUser do 36 | defstruct [:id, :group] 37 | end 38 | 39 | defimpl FunWithFlags.Actor, for: PlainUser do 40 | def id(%{id: id}) do 41 | "user:#{id}" 42 | end 43 | end 44 | 45 | defimpl FunWithFlags.Group, for: PlainUser do 46 | def in?(%{group: group}, group), do: true 47 | def in?(_, _), do: false 48 | end 49 | -------------------------------------------------------------------------------- /lib/fun_with_flags/application.ex: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | def start(_type, _args) do 7 | FunWithFlags.Supervisor.start_link(nil) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/fun_with_flags/config.ex: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.Config do 2 | require Application 3 | 4 | @moduledoc false 5 | @default_redis_config [ 6 | host: "localhost", 7 | port: 6379, 8 | database: 0, 9 | ] 10 | 11 | @default_cache_config [ 12 | enabled: true, 13 | ttl: 900 # in seconds, 15 minutes 14 | ] 15 | 16 | @default_notifications_config [ 17 | enabled: true, 18 | adapter: FunWithFlags.Notifications.Redis 19 | ] 20 | 21 | @default_persistence_config [ 22 | adapter: FunWithFlags.Store.Persistent.Redis, 23 | repo: FunWithFlags.NullEctoRepo, 24 | ecto_table_name: "fun_with_flags_toggles", 25 | ecto_primary_key_type: :id 26 | ] 27 | 28 | def redis_config do 29 | case Application.get_env(:fun_with_flags, :redis, []) do 30 | uri when is_binary(uri) -> 31 | uri 32 | {uri, opts} when is_binary(uri) and is_list(opts) -> 33 | {uri, opts} 34 | opts when is_list(opts) -> 35 | if Keyword.has_key?(opts, :sentinel) do 36 | @default_redis_config 37 | |> Keyword.take([:database]) 38 | |> Keyword.merge(opts) 39 | else 40 | Keyword.merge(@default_redis_config, opts) 41 | end 42 | {:system, var} when is_binary(var) -> 43 | System.get_env(var) 44 | end 45 | end 46 | 47 | 48 | def cache? do 49 | Keyword.get(ets_cache_config(), :enabled) 50 | end 51 | 52 | 53 | def cache_ttl do 54 | Keyword.get(ets_cache_config(), :ttl) 55 | end 56 | 57 | 58 | def ets_cache_config do 59 | Keyword.merge( 60 | @default_cache_config, 61 | Application.get_env(:fun_with_flags, :cache, []) 62 | ) 63 | end 64 | 65 | # Used to determine the store module at compile time, which is stored in a 66 | # module attribute. `Application.compile_env` cannot be used in functions, 67 | # so here we are. 68 | @compile_time_cache_config Application.compile_env(:fun_with_flags, :cache, []) 69 | 70 | # If we're not using the cache, then don't bother with 71 | # the 2-level logic in the default Store module. 72 | # 73 | def store_module_determined_at_compile_time do 74 | cache_conf = Keyword.merge( 75 | @default_cache_config, 76 | @compile_time_cache_config 77 | ) 78 | 79 | if Keyword.get(cache_conf, :enabled) do 80 | FunWithFlags.Store 81 | else 82 | FunWithFlags.SimpleStore 83 | end 84 | end 85 | 86 | 87 | # Used to determine the Ecto table name at compile time. 88 | @compile_time_persistence_config Application.compile_env(:fun_with_flags, :persistence, []) 89 | 90 | 91 | def ecto_table_name_determined_at_compile_time do 92 | pers_conf = Keyword.merge( 93 | @default_persistence_config, 94 | @compile_time_persistence_config 95 | ) 96 | Keyword.get(pers_conf, :ecto_table_name) 97 | end 98 | 99 | 100 | def ecto_primary_key_type_determined_at_compile_time do 101 | pers_conf = Keyword.merge( 102 | @default_persistence_config, 103 | @compile_time_persistence_config 104 | ) 105 | Keyword.get(pers_conf, :ecto_primary_key_type) 106 | end 107 | 108 | 109 | defp persistence_config do 110 | Keyword.merge( 111 | @default_persistence_config, 112 | Application.get_env(:fun_with_flags, :persistence, []) 113 | ) 114 | end 115 | 116 | # Defaults to FunWithFlags.Store.Persistent.Redis 117 | # 118 | def persistence_adapter do 119 | Keyword.get(persistence_config(), :adapter) 120 | end 121 | 122 | 123 | def ecto_repo do 124 | Keyword.get(persistence_config(), :repo) 125 | end 126 | 127 | 128 | def persist_in_ecto? do 129 | persistence_adapter() == FunWithFlags.Store.Persistent.Ecto 130 | end 131 | 132 | 133 | defp notifications_config do 134 | Keyword.merge( 135 | @default_notifications_config, 136 | Application.get_env(:fun_with_flags, :cache_bust_notifications, []) 137 | ) 138 | end 139 | 140 | 141 | # Defaults to FunWithFlags.Notifications.Redis 142 | # 143 | def notifications_adapter do 144 | Keyword.get(notifications_config(), :adapter) 145 | end 146 | 147 | 148 | def phoenix_pubsub? do 149 | notifications_adapter() == FunWithFlags.Notifications.PhoenixPubSub 150 | end 151 | 152 | 153 | def pubsub_client do 154 | Keyword.get(notifications_config(), :client) 155 | end 156 | 157 | 158 | # Should the application emir cache busting/syncing notifications? 159 | # Defaults to false if we are not using a cache and if there is no 160 | # notifications adapter configured. Else, it defaults to true. 161 | # 162 | def change_notifications_enabled? do 163 | cache?() && 164 | notifications_adapter() && 165 | Keyword.get(notifications_config(), :enabled) 166 | end 167 | 168 | 169 | # I can't use Kernel.make_ref/0 because this needs to be 170 | # serializable to a string and sent via Redis. 171 | # Erlang References lose a lot of "uniqueness" when 172 | # represented as binaries. 173 | # 174 | def build_unique_id do 175 | (:crypto.strong_rand_bytes(10) <> inspect(:os.timestamp())) 176 | |> Base.url_encode64(padding: false) 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /lib/fun_with_flags/flag.ex: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.Flag do 2 | @moduledoc """ 3 | Represents a feature flag. 4 | 5 | This module is not meant to be used directly. 6 | """ 7 | 8 | alias FunWithFlags.Gate 9 | 10 | defstruct [name: nil, gates: []] 11 | @type t :: %FunWithFlags.Flag{name: atom, gates: [FunWithFlags.Gate.t]} 12 | @typep options :: Keyword.t 13 | 14 | 15 | @doc false 16 | def new(name, gates \\ []) when is_atom(name) do 17 | %__MODULE__{name: name, gates: gates} 18 | end 19 | 20 | 21 | @doc false 22 | @spec enabled?(t, options) :: boolean 23 | def enabled?(flag, options \\ []) 24 | 25 | def enabled?(%__MODULE__{gates: []}, _), do: false 26 | 27 | # Check the boolean gate first, as if that's enabled we 28 | # can stop immediately. Also, a boolean gate is almost 29 | # always present, while a percentage_of_time gate is not 30 | # used often. 31 | # 32 | def enabled?(%__MODULE__{gates: gates}, []) do 33 | check_boolean_gate(gates) || check_percentage_of_time_gate(gates) 34 | end 35 | 36 | 37 | def enabled?(%__MODULE__{gates: gates, name: flag_name}, [for: item]) do 38 | case check_actor_gates(gates, item) do 39 | {:ok, bool} -> bool 40 | :ignore -> 41 | case check_group_gates(gates, item) do 42 | {:ok, bool} -> bool 43 | :ignore -> 44 | check_boolean_gate(gates) || check_percentage_gate(gates, item, flag_name) 45 | end 46 | end 47 | end 48 | 49 | 50 | defp check_percentage_gate(gates, item, flag_name) do 51 | case percentage_of_actors_gate(gates) do 52 | nil -> 53 | check_percentage_of_time_gate(gates) 54 | gate -> 55 | check_percentage_of_actors_gate(gate, item, flag_name) 56 | end 57 | end 58 | 59 | 60 | defp check_actor_gates([], _), do: :ignore 61 | 62 | defp check_actor_gates([%Gate{type: :actor} = gate | rest], item) do 63 | case Gate.enabled?(gate, for: item) do 64 | :ignore -> check_actor_gates(rest, item) 65 | result -> result 66 | end 67 | end 68 | 69 | defp check_actor_gates([_gate | rest], item) do 70 | check_actor_gates(rest, item) 71 | end 72 | 73 | 74 | # If the tested item belongs to multiple conflicting groups, 75 | # the disabled ones take precedence. Guaranteeing that something 76 | # is consistently disabled is more important than the opposite. 77 | # 78 | # If a group gate is explicitly disabled, then return false. 79 | # If a group gate is enabled, store the result but keep 80 | # looping in case there is another group that is disabled. 81 | # 82 | defp check_group_gates(gates, item, result \\ :ignore) 83 | 84 | defp check_group_gates([], _, result), do: result 85 | 86 | defp check_group_gates([%Gate{type: :group} = gate|rest], item, temp_result) do 87 | case Gate.enabled?(gate, for: item) do 88 | :ignore -> check_group_gates(rest, item, temp_result) 89 | {:ok, false} -> {:ok, false} 90 | {:ok, true} -> check_group_gates(rest, item, {:ok, true}) 91 | end 92 | end 93 | 94 | defp check_group_gates([_gate | rest], item, temp_result) do 95 | check_group_gates(rest, item, temp_result) 96 | end 97 | 98 | 99 | defp check_boolean_gate(gates) do 100 | gate = boolean_gate(gates) 101 | if gate do 102 | {:ok, bool} = Gate.enabled?(gate) 103 | bool 104 | else 105 | false 106 | end 107 | end 108 | 109 | 110 | defp check_percentage_of_time_gate(gates) do 111 | gate = percentage_of_time_gate(gates) 112 | if gate do 113 | {:ok, bool} = Gate.enabled?(gate) 114 | bool 115 | else 116 | false 117 | end 118 | end 119 | 120 | 121 | defp check_percentage_of_actors_gate(gate, item, flag_name) do 122 | {:ok, bool} = Gate.enabled?(gate, for: item, flag_name: flag_name) 123 | bool 124 | end 125 | 126 | 127 | defp boolean_gate(gates) do 128 | Enum.find(gates, &Gate.boolean?/1) 129 | end 130 | 131 | defp percentage_of_time_gate(gates) do 132 | Enum.find(gates, &Gate.percentage_of_time?/1) 133 | end 134 | 135 | defp percentage_of_actors_gate(gates) do 136 | Enum.find(gates, &Gate.percentage_of_actors?/1) 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /lib/fun_with_flags/gate.ex: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.Gate do 2 | @moduledoc """ 3 | Represents a feature flag gate, that is one of several conditions 4 | attached to a feature flag. 5 | 6 | This module is not meant to be used directly. 7 | """ 8 | 9 | alias FunWithFlags.{Actor, Group} 10 | 11 | defmodule InvalidGroupNameError do 12 | defexception [:message] 13 | end 14 | 15 | defmodule InvalidTargetError do 16 | defexception [:message] 17 | end 18 | 19 | 20 | defstruct [:type, :for, :enabled] 21 | @type t :: %FunWithFlags.Gate{type: atom, for: (nil | String.t), enabled: boolean} 22 | @typep options :: Keyword.t 23 | 24 | @doc false 25 | @spec new(atom, boolean | float) :: t 26 | def new(:boolean, enabled) when is_boolean(enabled) do 27 | %__MODULE__{type: :boolean, for: nil, enabled: enabled} 28 | end 29 | 30 | # Don't accept 0 or 1 because a boolean gate should be used instead. 31 | # 32 | def new(:percentage_of_time, ratio) 33 | when is_float(ratio) and ratio > 0 and ratio < 1 do 34 | %__MODULE__{type: :percentage_of_time, for: ratio, enabled: true} 35 | end 36 | 37 | def new(:percentage_of_time, ratio) 38 | when is_float(ratio) and ratio <= 0 or ratio >= 1 do 39 | raise InvalidTargetError, "percentage_of_time gates must have a ratio in the range '0.0 < r < 1.0'." 40 | end 41 | 42 | def new(:percentage_of_actors, ratio) 43 | when is_float(ratio) and ratio > 0 and ratio < 1 do 44 | %__MODULE__{type: :percentage_of_actors, for: ratio, enabled: true} 45 | end 46 | 47 | def new(:percentage_of_actors, ratio) 48 | when is_float(ratio) and ratio <= 0 or ratio >= 1 do 49 | raise InvalidTargetError, "percentage_of_actors gates must have a ratio in the range '0.0 < r < 1.0'." 50 | end 51 | 52 | @doc false 53 | @spec new(atom, binary | term, boolean) :: t 54 | def new(:actor, actor, enabled) when is_boolean(enabled) do 55 | %__MODULE__{type: :actor, for: Actor.id(actor), enabled: enabled} 56 | end 57 | 58 | def new(:group, group_name, enabled) when is_boolean(enabled) do 59 | validate_group_name(group_name) 60 | %__MODULE__{type: :group, for: to_string(group_name), enabled: enabled} 61 | end 62 | 63 | 64 | defp validate_group_name(name) when is_binary(name) or is_atom(name), do: nil 65 | defp validate_group_name(name) do 66 | raise InvalidGroupNameError, "invalid group name '#{inspect(name)}', it should be a binary or an atom." 67 | end 68 | 69 | 70 | @doc false 71 | def boolean?(%__MODULE__{type: :boolean}), do: true 72 | def boolean?(%__MODULE__{type: _}), do: false 73 | 74 | @doc false 75 | def actor?(%__MODULE__{type: :actor}), do: true 76 | def actor?(%__MODULE__{type: _}), do: false 77 | 78 | @doc false 79 | def group?(%__MODULE__{type: :group}), do: true 80 | def group?(%__MODULE__{type: _}), do: false 81 | 82 | @doc false 83 | def percentage_of_time?(%__MODULE__{type: :percentage_of_time}), do: true 84 | def percentage_of_time?(%__MODULE__{type: _}), do: false 85 | 86 | @doc false 87 | def percentage_of_actors?(%__MODULE__{type: :percentage_of_actors}), do: true 88 | def percentage_of_actors?(%__MODULE__{type: _}), do: false 89 | 90 | 91 | @doc false 92 | @spec enabled?(t, options) :: {:ok, boolean} | :ignore 93 | def enabled?(gate, options \\ []) 94 | 95 | def enabled?(%__MODULE__{type: :boolean, enabled: enabled}, []) do 96 | {:ok, enabled} 97 | end 98 | def enabled?(%__MODULE__{type: :boolean, enabled: enabled}, [for: _]) do 99 | {:ok, enabled} 100 | end 101 | 102 | def enabled?(%__MODULE__{type: :actor, for: actor_id, enabled: enabled}, [for: actor]) do 103 | case Actor.id(actor) do 104 | ^actor_id -> {:ok, enabled} 105 | _ -> :ignore 106 | end 107 | end 108 | 109 | def enabled?(%__MODULE__{type: :group, for: group, enabled: enabled}, [for: item]) do 110 | if Group.in?(item, group) do 111 | {:ok, enabled} 112 | else 113 | :ignore 114 | end 115 | end 116 | 117 | def enabled?(%__MODULE__{type: :percentage_of_time, for: ratio}, _) do 118 | roll = random_float() 119 | enabled = roll <= ratio 120 | {:ok, enabled} 121 | end 122 | 123 | def enabled?(%__MODULE__{type: :percentage_of_actors, for: ratio}, opts) do 124 | actor = Keyword.fetch!(opts, :for) 125 | flag_name = Keyword.fetch!(opts, :flag_name) 126 | 127 | roll = Actor.Percentage.score(actor, flag_name) 128 | enabled = roll <= ratio 129 | {:ok, enabled} 130 | end 131 | 132 | # Returns a float (4 digit precision) between 0.0 and 1.0 133 | # 134 | # Alternative: 135 | # :crypto.rand_uniform(1, 10_000) / 10_000 136 | # 137 | defp random_float do 138 | :rand.uniform(10_000) / 10_000 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/fun_with_flags/notifications/phoenix_pubsub.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Phoenix.PubSub) do 2 | 3 | defmodule FunWithFlags.Notifications.PhoenixPubSub do 4 | @moduledoc false 5 | use GenServer 6 | require Logger 7 | alias FunWithFlags.{Config, Store} 8 | 9 | @channel "fun_with_flags_changes" 10 | @max_attempts 5 11 | 12 | 13 | def worker_spec do 14 | %{ 15 | id: __MODULE__, 16 | start: {__MODULE__, :start_link, []}, 17 | restart: :permanent, 18 | type: :worker, 19 | } 20 | end 21 | 22 | 23 | # Initialize the GenServer with a unique id (binary). 24 | # This id will stay with the GenServer until it's terminated, and is 25 | # used to build the outgoing notification payloads and to ignore 26 | # incoming messages that originated from this node. 27 | # 28 | def start_link do 29 | GenServer.start_link(__MODULE__, Config.build_unique_id, [name: __MODULE__]) 30 | end 31 | 32 | # Get the unique_id for this running node, which is the state 33 | # passed to the GenServer when it's (re)started. 34 | # 35 | def unique_id do 36 | {:ok, unique_id} = GenServer.call(__MODULE__, :get_unique_id) 37 | unique_id 38 | end 39 | 40 | # Get the pubsub subscription status for the current note, which tells us if 41 | # the GenServer for this module has successfully completed the Phoenix.PubSub 42 | # subscription procedure to the change notification topic. 43 | # 44 | # The GenServer might still be unsubscribed if this is called very early 45 | # after the application has started. (i.e. in some unit tests), but in general 46 | # a runtime exception is raised if subscribing is not completed within a few 47 | # seconds. 48 | # 49 | def subscribed? do 50 | {:ok, subscription_status} = GenServer.call(__MODULE__, :get_subscription_status) 51 | subscription_status == :subscribed 52 | end 53 | 54 | 55 | def publish_change(flag_name) do 56 | Logger.debug fn -> "FunWithFlags.Notifications: publish change for '#{flag_name}'" end 57 | Task.start fn() -> 58 | Phoenix.PubSub.broadcast!(client(), @channel, 59 | {:fwf_changes, {:updated, flag_name, unique_id()}} 60 | ) 61 | end 62 | end 63 | 64 | 65 | # ------------------------------------------------------------ 66 | # GenServer callbacks 67 | 68 | 69 | # The unique_id will become the state of the GenServer 70 | # 71 | def init(unique_id) do 72 | subscription_status = subscribe(1) 73 | {:ok, {unique_id, subscription_status}} 74 | end 75 | 76 | 77 | defp subscribe(attempt) when attempt <= @max_attempts do 78 | try do 79 | case Phoenix.PubSub.subscribe(client(), @channel) do 80 | :ok -> 81 | # All good 82 | Logger.debug fn -> "FunWithFlags: Connected to Phoenix.PubSub process #{inspect(client())}" end 83 | :subscribed 84 | {:error, reason} -> 85 | # Handled application errors 86 | Logger.debug fn -> "FunWithFlags: Cannot subscribe to Phoenix.PubSub process #{inspect(client())} ({:error, #{inspect(reason)}})." end 87 | try_again_to_subscribe(attempt) 88 | :unsubscribed 89 | end 90 | rescue 91 | e -> 92 | # The pubsub process was probably not running. This happens when using it in Phoenix, as it tries to connect the 93 | # first time while the application is booting, and the Phoenix.PubSub process is not fully started yet. 94 | Logger.debug fn -> "FunWithFlags: Cannot subscribe to Phoenix.PubSub process #{inspect(client())} (exception: #{inspect(e)})." end 95 | try_again_to_subscribe(attempt) 96 | :unsubscribed 97 | end 98 | end 99 | 100 | 101 | # We can't connect to the PubSub process. Possibly because it didn't start. 102 | # 103 | defp subscribe(_) do 104 | raise "Tried to subscribe to Phoenix.PubSub process #{inspect(client())} #{@max_attempts} times. Giving up." 105 | end 106 | 107 | 108 | # Wait 1 second and try again 109 | # 110 | defp try_again_to_subscribe(attempt) do 111 | Process.send_after(self(), {:subscribe_retry, (attempt + 1)}, 1000) 112 | end 113 | 114 | 115 | def handle_call(:get_unique_id, _from, state = {unique_id, _subscription_status}) do 116 | {:reply, {:ok, unique_id}, state} 117 | end 118 | 119 | def handle_call(:get_subscription_status, _from, state = {_unique_id, subscription_status}) do 120 | {:reply, {:ok, subscription_status}, state} 121 | end 122 | 123 | # Test helper 124 | # 125 | def handle_call({:test_helper_set_subscription_status, new_subscription_status}, _from, {unique_id, _current_subscription_status}) do 126 | {:reply, :ok, {unique_id, new_subscription_status}} 127 | end 128 | 129 | 130 | def handle_info({:fwf_changes, {:updated, _name, unique_id}}, state = {unique_id, _subscription_status}) do 131 | # received my own message, doing nothing 132 | {:noreply, state} 133 | end 134 | 135 | def handle_info({:fwf_changes, {:updated, name, _}}, state) do 136 | # received message from another node, reload the flag 137 | Logger.debug fn -> "FunWithFlags: received change notification for flag '#{name}'" end 138 | Task.start(Store, :reload, [name]) 139 | {:noreply, state} 140 | end 141 | 142 | 143 | # When subscribing to the pubsub process fails, the process sends itself a delayed message 144 | # to try again. It will be handled here. 145 | # 146 | def handle_info({:subscribe_retry, attempt}, state = {unique_id, _subscription_status}) do 147 | Logger.debug fn -> "FunWithFlags: retrying to subscribe to Phoenix.PubSub, attempt #{attempt}." end 148 | case subscribe(attempt) do 149 | :subscribed -> 150 | Logger.debug fn -> "FunWithFlags: updating Phoenix.PubSub's subscription status to :subscribed." end 151 | {:noreply, {unique_id, :subscribed}} 152 | _ -> 153 | # don't change the state 154 | {:noreply, state} 155 | end 156 | end 157 | 158 | defp client, do: Config.pubsub_client() 159 | end 160 | 161 | end # Code.ensure_loaded? 162 | -------------------------------------------------------------------------------- /lib/fun_with_flags/notifications/redis.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Redix.PubSub) do 2 | 3 | defmodule FunWithFlags.Notifications.Redis do 4 | @moduledoc false 5 | use GenServer 6 | require Logger 7 | alias FunWithFlags.{Config, Store} 8 | 9 | # Use the Redis conn from the persistence module to 10 | # issue Redis commands (to publish notification). 11 | @write_conn FunWithFlags.Store.Persistent.Redis 12 | 13 | @conn :fun_with_flags_notifications 14 | @conn_options [name: @conn, sync_connect: false] 15 | @channel "fun_with_flags_changes" 16 | 17 | # Retrieve the configuration to connect to Redis, and package it as an argument 18 | # to be passed to the start_link function. 19 | # 20 | def worker_spec do 21 | redis_conn_config = case Config.redis_config do 22 | uri when is_binary(uri) -> 23 | {uri, @conn_options} 24 | {uri, opts} when is_binary(uri) and is_list(opts) -> 25 | {uri, Keyword.merge(opts, @conn_options)} 26 | opts when is_list(opts) -> 27 | Keyword.merge(opts, @conn_options) 28 | end 29 | 30 | %{ 31 | id: __MODULE__, 32 | start: {__MODULE__, :start_link, [redis_conn_config]}, 33 | restart: :permanent, 34 | type: :worker, 35 | } 36 | end 37 | 38 | 39 | # Initialize the GenServer with a unique id (binary). 40 | # This id will stay with the GenServer until it's terminated, and is 41 | # used to build the outgoing notification payloads and to ignore 42 | # incoming messages that originated from this node. 43 | # 44 | def start_link(redis_conn_config) do 45 | GenServer.start_link(__MODULE__, {redis_conn_config, Config.build_unique_id}, [name: __MODULE__]) 46 | end 47 | 48 | 49 | # Get the unique_id for this running node, which is the state 50 | # passed to the GenServer when it's (re)started. 51 | # 52 | def unique_id do 53 | {:ok, unique_id} = GenServer.call(__MODULE__, :get_unique_id) 54 | unique_id 55 | end 56 | 57 | 58 | # Build a payload to be passed to Redis. 59 | # Must go through the GenServer because we need the unique_id 60 | # stored in its state. 61 | # 62 | @spec payload_for(atom) :: [String.t] 63 | def payload_for(flag_name) do 64 | [@channel, "#{unique_id()}:#{to_string(flag_name)}"] 65 | end 66 | 67 | 68 | def publish_change(flag_name) do 69 | Logger.debug fn -> "FunWithFlags.Notifications: publish change for '#{flag_name}'" end 70 | Task.start fn() -> 71 | Redix.command( 72 | @write_conn, 73 | ["PUBLISH" | payload_for(flag_name)] 74 | ) 75 | end 76 | end 77 | 78 | # ------------------------------------------------------------ 79 | # GenServer callbacks 80 | 81 | 82 | # The unique_id will become the state of the GenServer 83 | # 84 | def init({redis_conn_config, unique_id}) do 85 | {:ok, _pid} = case redis_conn_config do 86 | {uri, opts} when is_binary(uri) and is_list(opts) -> 87 | Redix.PubSub.start_link(uri, opts) 88 | opts when is_list(opts) -> 89 | Redix.PubSub.start_link(opts) 90 | end 91 | 92 | {:ok, ref} = Redix.PubSub.subscribe(@conn, @channel, self()) 93 | state = {unique_id, ref} 94 | {:ok, state} 95 | end 96 | 97 | 98 | def handle_call(:get_unique_id, _from, state = {unique_id, _ref}) do 99 | {:reply, {:ok, unique_id}, state} 100 | end 101 | 102 | 103 | def handle_info({:redix_pubsub, _from, ref, :subscribed, %{channel: @channel}}, state = {_, ref}) do 104 | {:noreply, state} 105 | end 106 | 107 | def handle_info({:redix_pubsub, _from, ref, :unsubscribed, %{channel: @channel}}, state = {_, ref}) do 108 | {:noreply, state} 109 | end 110 | 111 | def handle_info({:redix_pubsub, _from, ref, :disconnected, %{error: error}}, state = {_, ref}) do 112 | Logger.error("FunWithFlags: Redis pub-sub connection interrupted, reason: '#{Redix.ConnectionError.message(error)}'.") 113 | {:noreply, state} 114 | end 115 | 116 | 117 | # 1/2 118 | # Another node has updated a flag and published an event. 119 | # We react to it by validating the unique_id in the message. 120 | # 121 | def handle_info({:redix_pubsub, _from, ref, :message, %{channel: @channel, payload: msg}}, state = {unique_id, ref}) do 122 | validate_message(msg, unique_id) 123 | {:noreply, state} 124 | end 125 | 126 | # 2/2 127 | # If it matches our unique_id, then it originated from this node 128 | # and we don't need to reload the cached flag. 129 | # If it doesn't match, on the other hand, we need to reload it. 130 | # 131 | defp validate_message(msg, unique_id) do 132 | case String.split(msg, ":") do 133 | [^unique_id, _name] -> 134 | # received my own message, doing nothing 135 | nil 136 | [_id, name] -> 137 | # received message from another node, reload the flag 138 | Logger.debug fn -> "FunWithFlags: received change notification for flag '#{name}'" end 139 | Task.start(Store, :reload, [String.to_atom(name)]) 140 | _ -> 141 | # invalid message, ignore 142 | nil 143 | end 144 | 145 | end 146 | end 147 | 148 | end # Code.ensure_loaded? 149 | -------------------------------------------------------------------------------- /lib/fun_with_flags/protocols/actor.ex: -------------------------------------------------------------------------------- 1 | defprotocol FunWithFlags.Actor do 2 | @moduledoc ~S""" 3 | Implement this protocol to provide actors. 4 | 5 | 6 | Actor gates allows you to enable or disable a flag for one or more entities. 7 | For example, in web applications it's common to use a `%User{}` struct or 8 | equivalent as an actor, or perhaps the data used to represent the current 9 | country for an HTTP request. 10 | This can be useful to showcase a work-in-progress feature to someone, to 11 | gradually rollout a functionality by country, or to dynamically disable some 12 | features in some contexts (e.g. a deploy introduces a critical error that 13 | only happens in one specific country). 14 | 15 | Actor gates take precedence over the others, both when they're enabled and 16 | when they're disabled. They can be considered as toggle overrides. 17 | 18 | 19 | In order to be used as an actor, an entity must implement 20 | the `FunWithFlags.Actor` protocol. This can be implemented for custom structs 21 | or literally any other type. 22 | 23 | 24 | ## Examples 25 | 26 | This protocol is typically implemented for some application structure. 27 | 28 | defmodule MyApp.User do 29 | defstruct [:id, :name] 30 | end 31 | 32 | defimpl FunWithFlags.Actor, for: MyApp.User do 33 | def id(%{id: id}) do 34 | "user:#{id}" 35 | end 36 | end 37 | 38 | bruce = %User{id: 1, name: "Bruce"} 39 | alfred = %User{id: 2, name: "Alfred"} 40 | 41 | FunWithFlags.Actor.id(bruce) 42 | "user:1" 43 | FunWithFlags.Actor.id(alfred) 44 | "user:2" 45 | 46 | FunWithFlags.enable(:batmobile, for_actor: bruce) 47 | 48 | 49 | but it can also be implemented for the builtin types: 50 | 51 | 52 | defimpl FunWithFlags.Actor, for: Map do 53 | def id(%{actor_id: actor_id}) do 54 | "map:#{actor_id}" 55 | end 56 | 57 | def id(map) do 58 | map 59 | |> inspect() 60 | |> (&:crypto.hash(:md5, &1)).() 61 | |> Base.encode16 62 | |> (&"map:#{&1}").() 63 | end 64 | end 65 | 66 | 67 | defimpl FunWithFlags.Actor, for: BitString do 68 | def id(str) do 69 | "string:#{str}" 70 | end 71 | end 72 | 73 | FunWithFlags.Actor.id(%{actor_id: "bar"}) 74 | "map:bar" 75 | FunWithFlags.Actor.id(%{foo: "bar"}) 76 | "map:E0BB5BA6873E3AC34B0B6928190C1F2B" 77 | FunWithFlags.Actor.id("foobar") 78 | "string:foobar" 79 | 80 | 81 | FunWithFlags.disable(:foobar, for_actor: %{actor_id: "just a map"}) 82 | FunWithFlags.enable(:foobar, for_actor: "just a string") 83 | 84 | 85 | Actor identifiers must be globally unique binaries. Since supporting multiple 86 | kinds of actors is a common requirement, all the examples use the common 87 | technique of namespacing the IDs: 88 | 89 | 90 | defimpl FunWithFlags.Actor, for: MyApp.User do 91 | def id(user) do 92 | "user:#{user.id}" 93 | end 94 | end 95 | 96 | defimpl FunWithFlags.Actor, for: MyApp.Country do 97 | def id(country) do 98 | "country:#{country.iso3166}" 99 | end 100 | end 101 | """ 102 | 103 | 104 | @doc """ 105 | Should return a globally unique binary. 106 | 107 | ## Example 108 | 109 | iex> FunWithFlags.Actor.id(%FunWithFlags.TestUser{id: 313}) 110 | "user:313" 111 | 112 | """ 113 | @spec id(term) :: binary 114 | def id(actor) 115 | end 116 | 117 | 118 | defmodule FunWithFlags.Actor.Percentage do 119 | @moduledoc false 120 | 121 | alias FunWithFlags.Actor 122 | 123 | # Combine an actor id and a flag name to get 124 | # a score. The flag name must be included to 125 | # ensure that the same actors get different 126 | # scores for different flags, but with 127 | # deterministic and predictable results. 128 | # 129 | @spec score(term, atom) :: float 130 | def score(actor, flag_name) do 131 | blob = Actor.id(actor) <> to_string(flag_name) 132 | _actor_score(blob) 133 | end 134 | 135 | # first 16 bits: 136 | # 2 ** 16 = 65_536 137 | # 138 | # %_ratio : 1.0 = 16_bits : 65_536 139 | # 140 | defp _actor_score(string) do 141 | <> = :crypto.hash(:sha256, string) 142 | score / 65_536 143 | end 144 | 145 | 146 | # To verify that the distribution is uniform 147 | # 148 | def distributions(count, flag_name) do 149 | key_fun = fn(i) -> 150 | a = %{actor_id: i} 151 | score = FunWithFlags.Actor.Percentage.score(a, flag_name) 152 | round(score * 100) 153 | end 154 | 155 | 1..count 156 | |> Enum.group_by(key_fun) 157 | |> Enum.map(fn({perc, items}) -> 158 | {perc, length(items)} 159 | end) 160 | |> Enum.sort() 161 | |> Enum.each(fn({perc, count}) -> 162 | IO.puts("#{perc} - #{count}") 163 | end) 164 | 165 | nil 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /lib/fun_with_flags/protocols/group.ex: -------------------------------------------------------------------------------- 1 | defprotocol FunWithFlags.Group do 2 | @moduledoc """ 3 | Implement this protocol to provide groups. 4 | 5 | Group gates are similar to actor gates, but they apply to a category of entities rather than specific ones. They can be toggled on or off for the _name of the group_ (as an atom) instead of a specific term. 6 | 7 | Group gates take precedence over boolean gates but are overridden by actor gates. 8 | 9 | The semantics to determine which entities belong to which groups are application specific. 10 | Entities could have an explicit list of groups they belong to, or the groups could be abstract and inferred from some other attribute. For example, an `:employee` group could comprise all `%User{}` structs with an email address matching the company domain, or an `:admin` group could be made of all users with `%User{admin: true}`. 11 | 12 | In order to be affected by a group gate, an entity should implement the `FunWithFlags.Group` protocol. The protocol automatically falls back to a default `Any` implementation, which states that any entity belongs to no group at all. This makes it possible to safely use "normal" actors when querying group gates, and to implement the protocol only for structs and types for which it matters. 13 | 14 | The protocol can be implemented for custom structs or literally any other type. 15 | 16 | 17 | defmodule MyApp.User do 18 | defstruct [:email, admin: false, groups: []] 19 | end 20 | 21 | defimpl FunWithFlags.Group, for: MyApp.User do 22 | def in?(%{email: email}, :employee), do: Regex.match?(~r/@mycompany.com$/, email) 23 | def in?(%{admin: is_admin}, :admin), do: !!is_admin 24 | def in?(%{groups: list}, group_name), do: group_name in list 25 | end 26 | 27 | elisabeth = %User{email: "elisabeth@mycompany.com", admin: true, groups: [:engineering, :product]} 28 | FunWithFlags.Group.in?(elisabeth, :employee) 29 | true 30 | FunWithFlags.Group.in?(elisabeth, :admin) 31 | true 32 | FunWithFlags.Group.in?(elisabeth, :engineering) 33 | true 34 | FunWithFlags.Group.in?(elisabeth, :marketing) 35 | false 36 | 37 | defimpl FunWithFlags.Group, for: Map do 38 | def in?(%{group: group_name}, group_name), do: true 39 | def in?(_, _), do: false 40 | end 41 | 42 | FunWithFlags.Group.in?(%{group: :dumb_tests}, :dumb_tests) 43 | true 44 | 45 | With the protocol implemented, actors can be used with the library functions: 46 | 47 | 48 | FunWithFlags.disable(:database_access) 49 | FunWithFlags.enable(:database_access, for_group: :engineering) 50 | """ 51 | 52 | @fallback_to_any true 53 | 54 | @doc """ 55 | Should return a boolean. 56 | 57 | The default implementation will always return `false` for 58 | any argument. 59 | 60 | ## Example 61 | 62 | iex> user = %{name: "bolo", group: "staff"} 63 | iex> FunWithFlags.Group.in?(data, "staff") 64 | true 65 | iex> FunWithFlags.Group.in?(data, "superusers") 66 | false 67 | """ 68 | @spec in?(term, String.t | atom) :: boolean 69 | def in?(item, group) 70 | end 71 | 72 | 73 | defimpl FunWithFlags.Group, for: Any do 74 | def in?(_, _), do: false 75 | end 76 | -------------------------------------------------------------------------------- /lib/fun_with_flags/simple_store.ex: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.SimpleStore do 2 | @moduledoc false 3 | 4 | import FunWithFlags.Config, only: [persistence_adapter: 0] 5 | alias FunWithFlags.Telemetry 6 | 7 | @spec lookup(atom) :: {:ok, FunWithFlags.Flag.t} 8 | def lookup(flag_name) do 9 | result = 10 | persistence_adapter().get(flag_name) 11 | |> Telemetry.emit_persistence_event(:read, flag_name, nil) 12 | 13 | case result do 14 | {:ok, flag} -> {:ok, flag} 15 | _ -> raise "Can't load feature flag" 16 | end 17 | end 18 | 19 | @spec put(atom, FunWithFlags.Gate.t) :: {:ok, FunWithFlags.Flag.t} | {:error, any()} 20 | def put(flag_name, gate) do 21 | persistence_adapter().put(flag_name, gate) 22 | |> Telemetry.emit_persistence_event(:write, flag_name, gate) 23 | end 24 | 25 | @spec delete(atom, FunWithFlags.Gate.t) :: {:ok, FunWithFlags.Flag.t} | {:error, any()} 26 | def delete(flag_name, gate) do 27 | persistence_adapter().delete(flag_name, gate) 28 | |> Telemetry.emit_persistence_event(:delete_gate, flag_name, gate) 29 | end 30 | 31 | @spec delete(atom) :: {:ok, FunWithFlags.Flag.t} | {:error, any()} 32 | def delete(flag_name) do 33 | persistence_adapter().delete(flag_name) 34 | |> Telemetry.emit_persistence_event(:delete_flag, flag_name, nil) 35 | end 36 | 37 | @spec all_flags() :: {:ok, [FunWithFlags.Flag.t]} | {:error, any()} 38 | def all_flags do 39 | persistence_adapter().all_flags() 40 | |> Telemetry.emit_persistence_event(:read_all_flags, nil, nil) 41 | end 42 | 43 | @spec all_flag_names() :: {:ok, [atom]} | {:error, any()} 44 | def all_flag_names do 45 | persistence_adapter().all_flag_names() 46 | |> Telemetry.emit_persistence_event(:read_all_flag_names, nil, nil) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/fun_with_flags/store.ex: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.Store do 2 | @moduledoc false 3 | 4 | require Logger 5 | alias FunWithFlags.Store.Cache 6 | alias FunWithFlags.{Config, Flag, Telemetry} 7 | 8 | import FunWithFlags.Config, only: [persistence_adapter: 0] 9 | 10 | @spec lookup(atom) :: {:ok, FunWithFlags.Flag.t} 11 | def lookup(flag_name) do 12 | case Cache.get(flag_name) do 13 | {:ok, flag} -> 14 | {:ok, flag} 15 | {:miss, reason, stale_value_or_nil} -> 16 | case persistence_adapter().get(flag_name) do 17 | {:ok, flag} -> 18 | Telemetry.emit_persistence_event({:ok, nil}, :read, flag_name, nil) 19 | Cache.put(flag) 20 | {:ok, flag} 21 | err = {:error, _reason} -> 22 | Telemetry.emit_persistence_event(err, :read, flag_name, nil) 23 | try_to_use_the_cached_value(reason, stale_value_or_nil, flag_name) 24 | end 25 | end 26 | end 27 | 28 | 29 | defp try_to_use_the_cached_value(:expired, value, flag_name) do 30 | Logger.warning "FunWithFlags: couldn't load flag '#{flag_name}' from storage, falling back to stale cached value from ETS" 31 | {:ok, value} 32 | end 33 | defp try_to_use_the_cached_value(_, _, flag_name) do 34 | raise "Can't load feature flag '#{flag_name}' from neither storage nor the cache" 35 | end 36 | 37 | 38 | @spec put(atom, FunWithFlags.Gate.t) :: {:ok, FunWithFlags.Flag.t} | {:error, any()} 39 | def put(flag_name, gate) do 40 | flag_name 41 | |> persistence_adapter().put(gate) 42 | |> Telemetry.emit_persistence_event(:write, flag_name, gate) 43 | |> publish_change() 44 | |> cache_persistence_result() 45 | end 46 | 47 | 48 | @spec delete(atom, FunWithFlags.Gate.t) :: {:ok, FunWithFlags.Flag.t} | {:error, any()} 49 | def delete(flag_name, gate) do 50 | flag_name 51 | |> persistence_adapter().delete(gate) 52 | |> Telemetry.emit_persistence_event(:delete_gate, flag_name, gate) 53 | |> publish_change() 54 | |> cache_persistence_result() 55 | end 56 | 57 | 58 | @spec delete(atom) :: {:ok, FunWithFlags.Flag.t} | {:error, any()} 59 | def delete(flag_name) do 60 | flag_name 61 | |> persistence_adapter().delete() 62 | |> Telemetry.emit_persistence_event(:delete_flag, flag_name, nil) 63 | |> publish_change() 64 | |> cache_persistence_result() 65 | end 66 | 67 | 68 | @spec reload(atom) :: {:ok, FunWithFlags.Flag.t} | {:error, any()} 69 | def reload(flag_name) do 70 | Logger.debug fn -> "FunWithFlags: reloading cached flag '#{flag_name}' from storage " end 71 | flag_name 72 | |> persistence_adapter().get() 73 | |> Telemetry.emit_persistence_event(:reload, flag_name, nil) 74 | |> cache_persistence_result() 75 | end 76 | 77 | 78 | @spec all_flags() :: {:ok, [FunWithFlags.Flag.t]} | {:error, any()} 79 | def all_flags do 80 | persistence_adapter().all_flags() 81 | |> Telemetry.emit_persistence_event(:read_all_flags, nil, nil) 82 | end 83 | 84 | 85 | @spec all_flag_names() :: {:ok, [atom]} | {:error, any()} 86 | def all_flag_names do 87 | persistence_adapter().all_flag_names() 88 | |> Telemetry.emit_persistence_event(:read_all_flag_names, nil, nil) 89 | end 90 | 91 | defp cache_persistence_result(result = {:ok, flag}) do 92 | Cache.put(flag) 93 | result 94 | end 95 | 96 | defp cache_persistence_result(result) do 97 | result 98 | end 99 | 100 | 101 | defp publish_change(result = {:ok, %Flag{name: flag_name}}) do 102 | if Config.change_notifications_enabled? do 103 | Config.notifications_adapter.publish_change(flag_name) 104 | end 105 | 106 | result 107 | end 108 | 109 | defp publish_change(result) do 110 | result 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/fun_with_flags/store/cache.ex: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.Store.Cache do 2 | @moduledoc """ 3 | The in-memory cache for the feature flag, backed by an ETS table. 4 | 5 | This module is not meant to be used directly, but some of its functions can be 6 | useful to debug flag state. 7 | """ 8 | 9 | @type ttl :: integer 10 | @type cached_at :: integer 11 | 12 | @doc false 13 | use GenServer 14 | 15 | alias FunWithFlags.Config 16 | alias FunWithFlags.Flag 17 | alias FunWithFlags.Timestamps 18 | 19 | @table_name :fun_with_flags_cache 20 | @table_options [ 21 | :set, :protected, :named_table, {:read_concurrency, true} 22 | ] 23 | 24 | 25 | @doc false 26 | def worker_spec do 27 | if FunWithFlags.Config.cache? do 28 | %{ 29 | id: __MODULE__, 30 | start: {__MODULE__, :start_link, []}, 31 | restart: :permanent, 32 | type: :worker, 33 | } 34 | end 35 | end 36 | 37 | 38 | @doc false 39 | def start_link do 40 | GenServer.start_link(__MODULE__, :ok, [name: __MODULE__]) 41 | end 42 | 43 | 44 | # We lookup without going through the GenServer 45 | # for concurrency and performance. 46 | # 47 | @doc false 48 | def get(flag_name) do 49 | case :ets.lookup(@table_name, flag_name) do 50 | [{^flag_name, {flag, timestamp, ttl}}] -> 51 | validate(flag_name, flag, timestamp, ttl) 52 | _ -> 53 | {:miss, :not_found, nil} 54 | end 55 | end 56 | 57 | 58 | defp validate(name, flag = %Flag{name: name}, timestamp, ttl) do 59 | if Timestamps.expired?(timestamp, ttl) do 60 | {:miss, :expired, flag} 61 | else 62 | {:ok, flag} 63 | end 64 | end 65 | defp validate(_name, _flag, _timestamp, _ttl) do 66 | {:miss, :invalid, nil} 67 | end 68 | 69 | 70 | # We want to always write serially through the 71 | # GenServer to avoid race conditions. 72 | # 73 | @doc false 74 | def put(flag = %Flag{}) do 75 | GenServer.call(__MODULE__, {:put, flag}) 76 | end 77 | 78 | 79 | @doc """ 80 | Clears the cache. It will be rebuilt gradually as the public interface of the 81 | package is queried. 82 | """ 83 | @spec flush() :: true 84 | def flush do 85 | GenServer.call(__MODULE__, :flush) 86 | end 87 | 88 | 89 | @doc """ 90 | Returns the contents of the cache ETS table, for inspection. 91 | """ 92 | @spec dump() :: [{atom, {FunWithFlags.Flag.t, cached_at, ttl}}] 93 | def dump do 94 | :ets.tab2list(@table_name) 95 | end 96 | 97 | 98 | # ------------------------------------------------------------ 99 | # GenServer callbacks 100 | 101 | 102 | @doc false 103 | def init(:ok) do 104 | tab_name = @table_name 105 | ^tab_name = :ets.new(@table_name, @table_options) 106 | {:ok, %{tab_name: tab_name, ttl: Config.cache_ttl}} 107 | end 108 | 109 | 110 | @doc false 111 | def handle_call({:put, flag = %Flag{name: name}}, _from, state = %{ttl: ttl}) do 112 | # writing to an ETS table will either return true or raise 113 | :ets.insert(@table_name, {name, {flag, Timestamps.now, ttl}}) 114 | {:reply, {:ok, flag}, state} 115 | end 116 | 117 | 118 | @doc false 119 | def handle_call(:flush, _from, state) do 120 | {:reply, :ets.delete_all_objects(@table_name), state} 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/fun_with_flags/store/persistent.ex: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.Store.Persistent do 2 | @moduledoc """ 3 | A behaviour module for implementing persistence adapters. 4 | 5 | The package ships with persistence adapters for Redis and Ecto, but you 6 | can provide your own adapters by adopting this behaviour. 7 | """ 8 | 9 | @doc """ 10 | A persistent adapter should return either 11 | [a child specification](https://hexdocs.pm/elixir/Supervisor.html#module-child-specification) 12 | if it needs any process to be started and supervised, or `nil` if it does not. 13 | 14 | For example, the builtin Redis persistence adapter implements this function by delegating to 15 | `Redix.child_spec/1` because it needs the Redix processes to work. On the other hand, the 16 | builtin Ecto adapter implements this function by returning `nil`, because the Ecto repo is 17 | provided to this package by the host application, and it's assumed that the Ecto process tree 18 | is started and supervised somewhere else. 19 | 20 | This custom `worker_spec/0` function is used instead of the typical `child_spec/1` function 21 | because this function can return `nil` if the adapter doesn't need to be supervised, whereas 22 | `child_spec/1` _must_ return a valid child spec map. 23 | """ 24 | @callback worker_spec() :: 25 | Supervisor.child_spec 26 | | nil 27 | 28 | 29 | @doc """ 30 | Retrieves a flag by name. 31 | """ 32 | @callback get(flag_name :: atom) :: 33 | {:ok, FunWithFlags.Flag.t} 34 | | {:error, any()} 35 | 36 | @doc """ 37 | Persists a gate for a flag, identified by name. 38 | """ 39 | @callback put(flag_name :: atom, gate :: FunWithFlags.Gate.t) :: 40 | {:ok, FunWithFlags.Flag.t} 41 | | {:error, any()} 42 | 43 | @doc """ 44 | Deletes a gate from a flag, identified by name. 45 | """ 46 | @callback delete(flag_name :: atom, gate :: FunWithFlags.Gate.t) :: 47 | {:ok, FunWithFlags.Flag.t} 48 | | {:error, any()} 49 | 50 | 51 | @doc """ 52 | Deletes an entire flag, identified by name. 53 | """ 54 | @callback delete(flag_name :: atom) :: 55 | {:ok, FunWithFlags.Flag.t} 56 | | {:error, any()} 57 | 58 | 59 | @doc """ 60 | Retrieves all the persisted flags. 61 | """ 62 | @callback all_flags() :: 63 | {:ok, [FunWithFlags.Flag.t]} 64 | | {:error, any()} 65 | 66 | @doc """ 67 | Retrieves all the names of the persisted flags. 68 | """ 69 | @callback all_flag_names() :: 70 | {:ok, [atom]} 71 | | {:error, any()} 72 | end 73 | -------------------------------------------------------------------------------- /lib/fun_with_flags/store/persistent/ecto.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto.Adapters.SQL) do 2 | 3 | defmodule FunWithFlags.Store.Persistent.Ecto do 4 | @moduledoc false 5 | 6 | @behaviour FunWithFlags.Store.Persistent 7 | 8 | alias FunWithFlags.Gate 9 | alias FunWithFlags.Store.Persistent.Ecto.Record 10 | alias FunWithFlags.Store.Serializer.Ecto, as: Serializer 11 | 12 | import FunWithFlags.Config, only: [ecto_repo: 0] 13 | import Ecto.Query 14 | 15 | require Logger 16 | 17 | @mysql_lock_timeout_s 3 18 | @query_opts [fun_with_flags: true] 19 | 20 | 21 | @impl true 22 | def worker_spec do 23 | nil 24 | end 25 | 26 | 27 | @impl true 28 | def get(flag_name) do 29 | name_string = to_string(flag_name) 30 | query = from(r in Record, where: r.flag_name == ^name_string) 31 | try do 32 | results = ecto_repo().all(query, @query_opts) 33 | flag = deserialize(flag_name, results) 34 | {:ok, flag} 35 | rescue 36 | e in [Ecto.QueryError] -> {:error, e} 37 | end 38 | end 39 | 40 | 41 | @impl true 42 | def put(flag_name, gate = %Gate{type: type}) 43 | when type in [:percentage_of_time, :percentage_of_actors] do 44 | name_string = to_string(flag_name) 45 | 46 | find_one_q = from( 47 | r in Record, 48 | where: r.flag_name == ^name_string, 49 | where: r.gate_type == "percentage" 50 | ) 51 | 52 | repo = ecto_repo() 53 | 54 | transaction_fn = case db_type(repo) do 55 | :postgres -> build_transaction_with_lock_postgres_fn(flag_name) 56 | :mysql -> &transaction_with_lock_mysql/2 57 | :sqlite -> &transaction_with_sqlite/2 58 | end 59 | 60 | out = transaction_fn.(repo, fn() -> 61 | case repo.one(find_one_q, @query_opts) do 62 | record = %Record{} -> 63 | changeset = Record.update_target(record, gate) 64 | do_update(repo, flag_name, changeset) 65 | nil -> 66 | changeset = Record.build(flag_name, gate) 67 | do_insert(repo, flag_name, changeset) 68 | end 69 | end) 70 | 71 | 72 | case out do 73 | {:ok, {:ok, result}} -> 74 | {:ok, result} 75 | {:error, _} = error -> 76 | error 77 | end 78 | end 79 | 80 | 81 | @impl true 82 | def put(flag_name, gate = %Gate{}) do 83 | changeset = Record.build(flag_name, gate) 84 | repo = ecto_repo() 85 | options = upsert_options(repo, gate) 86 | 87 | case do_insert(repo, flag_name, changeset, options) do 88 | {:ok, flag} -> 89 | {:ok, flag} 90 | other -> 91 | other 92 | end 93 | end 94 | 95 | 96 | # Returns a transaction-wrapper function for Postgres. 97 | # 98 | defp build_transaction_with_lock_postgres_fn(flag_name) do 99 | fn(repo, upsert_fn) -> 100 | repo.transaction fn() -> 101 | Ecto.Adapters.SQL.query!(repo, 102 | "SELECT pg_advisory_xact_lock(hashtext('fun_with_flags_percentage_gate_upsert'), hashtext($1))", 103 | [to_string(flag_name)] 104 | ) 105 | upsert_fn.() 106 | end 107 | end 108 | end 109 | 110 | 111 | # Is itself a transaction-wrapper function for MySQL. 112 | # 113 | defp transaction_with_lock_mysql(repo, upsert_fn) do 114 | repo.transaction fn() -> 115 | if mysql_lock!(repo) do 116 | try do 117 | upsert_fn.() 118 | rescue 119 | e -> 120 | repo.rollback("Exception: #{inspect(e)}") 121 | else 122 | {:error, reason} -> 123 | repo.rollback("Error while upserting the gate: #{inspect(reason)}") 124 | {:ok, value} -> 125 | {:ok, value} 126 | after 127 | # This is not guaranteed to run if the VM crashes, but at least the 128 | # lock gets released when the MySQL client session is terminated. 129 | mysql_unlock!(repo) 130 | end 131 | else 132 | Logger.error("Couldn't acquire lock with 'SELECT GET_LOCK()' after #{@mysql_lock_timeout_s} seconds") 133 | repo.rollback("couldn't acquire lock") 134 | end 135 | end 136 | end 137 | 138 | # Is itself a transaction-wrapper function for SQLite. 139 | # 140 | defp transaction_with_sqlite(repo, upsert_fn) do 141 | repo.transaction(fn -> 142 | try do 143 | upsert_fn.() 144 | rescue 145 | e -> 146 | repo.rollback("Exception: #{inspect(e)}") 147 | else 148 | {:error, reason} -> 149 | repo.rollback("Error while upserting the gate: #{inspect(reason)}") 150 | {:ok, value} -> 151 | {:ok, value} 152 | end 153 | end) 154 | end 155 | 156 | 157 | @impl true 158 | def delete(flag_name, %Gate{type: type}) 159 | when type in [:percentage_of_time, :percentage_of_actors] do 160 | name_string = to_string(flag_name) 161 | 162 | query = from( 163 | r in Record, 164 | where: r.flag_name == ^name_string 165 | and r.gate_type == "percentage" 166 | ) 167 | 168 | try do 169 | {_count, _} = ecto_repo().delete_all(query, @query_opts) 170 | {:ok, flag} = get(flag_name) 171 | {:ok, flag} 172 | rescue 173 | e in [Ecto.QueryError] -> {:error, e} 174 | end 175 | end 176 | 177 | 178 | # Deletes one gate from the toggles table in the DB. 179 | # Deleting gates is idempotent and deleting unknown gates is safe. 180 | # A flag will continue to exist even though it has no gates. 181 | # 182 | @impl true 183 | def delete(flag_name, gate = %Gate{}) do 184 | name_string = to_string(flag_name) 185 | gate_type = to_string(gate.type) 186 | target = Record.serialize_target(gate.for) 187 | 188 | query = from( 189 | r in Record, 190 | where: r.flag_name == ^name_string 191 | and r.gate_type == ^gate_type 192 | and r.target == ^target 193 | ) 194 | 195 | try do 196 | {_count, _} = ecto_repo().delete_all(query, @query_opts) 197 | {:ok, flag} = get(flag_name) 198 | {:ok, flag} 199 | rescue 200 | e in [Ecto.QueryError] -> {:error, e} 201 | end 202 | end 203 | 204 | 205 | # Deletes all of of this flags' gates from the toggles table, thus deleting 206 | # the entire flag. 207 | # Deleting flags is idempotent and deleting unknown flags is safe. 208 | # After the operation fetching the now-deleted flag will return the default 209 | # empty flag structure. 210 | # 211 | @impl true 212 | def delete(flag_name) do 213 | name_string = to_string(flag_name) 214 | 215 | query = from( 216 | r in Record, 217 | where: r.flag_name == ^name_string 218 | ) 219 | 220 | try do 221 | {_count, _} = ecto_repo().delete_all(query, @query_opts) 222 | {:ok, flag} = get(flag_name) 223 | {:ok, flag} 224 | rescue 225 | e in [Ecto.QueryError] -> {:error, e} 226 | end 227 | end 228 | 229 | 230 | @impl true 231 | def all_flags do 232 | flags = 233 | Record 234 | |> ecto_repo().all(@query_opts) 235 | |> Enum.group_by(&(&1.flag_name)) 236 | |> Enum.map(fn ({name, records}) -> deserialize(name, records) end) 237 | {:ok, flags} 238 | rescue 239 | e in [Ecto.QueryError] -> {:error, e} 240 | end 241 | 242 | 243 | @impl true 244 | def all_flag_names do 245 | query = from(r in Record, select: r.flag_name, distinct: true) 246 | strings = ecto_repo().all(query, @query_opts) 247 | atoms = Enum.map(strings, &String.to_atom(&1)) 248 | {:ok, atoms} 249 | rescue 250 | e in [Ecto.QueryError] -> {:error, e} 251 | end 252 | 253 | 254 | defp deserialize(flag_name, records) do 255 | Serializer.deserialize_flag(flag_name, records) 256 | end 257 | 258 | 259 | defp mysql_lock!(repo) do 260 | result = Ecto.Adapters.SQL.query!( 261 | repo, 262 | "SELECT GET_LOCK('fun_with_flags_percentage_gate_upsert', #{@mysql_lock_timeout_s})" 263 | ) 264 | 265 | %{rows: [[i]]} = result 266 | i == 1 267 | end 268 | 269 | 270 | defp mysql_unlock!(repo) do 271 | result = Ecto.Adapters.SQL.query!( 272 | repo, 273 | "SELECT RELEASE_LOCK('fun_with_flags_percentage_gate_upsert');" 274 | ) 275 | 276 | %{rows: [[i]]} = result 277 | i == 1 278 | end 279 | 280 | 281 | # PostgreSQL UPSERTs require an explicit conflict target. 282 | # MySQL/SQLite3 UPSERTs don't need it. 283 | # 284 | defp upsert_options(repo, gate = %Gate{}) do 285 | options = [on_conflict: [set: [enabled: gate.enabled]]] 286 | 287 | case db_type(repo) do 288 | :postgres -> 289 | options ++ [conflict_target: [:flag_name, :gate_type, :target]] 290 | type when type in [:mysql, :sqlite] -> 291 | options 292 | end 293 | end 294 | 295 | defp db_type(repo) do 296 | case repo.__adapter__() do 297 | Ecto.Adapters.Postgres -> :postgres 298 | Ecto.Adapters.MySQL -> :mysql # legacy, Mariaex 299 | Ecto.Adapters.MyXQL -> :mysql # new in ecto_sql 3.1 300 | Ecto.Adapters.SQLite3 -> :sqlite 301 | other -> raise "Ecto adapter #{inspect(other)} is not supported" 302 | end 303 | end 304 | 305 | 306 | defp do_insert(repo, flag_name, changeset, options \\ []) do 307 | changeset 308 | |> repo.insert(options) 309 | |> handle_write(flag_name) 310 | end 311 | 312 | 313 | defp do_update(repo, flag_name, changeset, options \\ []) do 314 | changeset 315 | |> repo.update(options) 316 | |> handle_write(flag_name) 317 | end 318 | 319 | 320 | defp handle_write(result, flag_name) do 321 | case result do 322 | {:ok, %Record{}} -> 323 | get(flag_name) # {:ok, flag} 324 | {:error, bad_changeset} -> 325 | {:error, bad_changeset.errors} 326 | end 327 | end 328 | 329 | end 330 | 331 | end # Code.ensure_loaded? 332 | -------------------------------------------------------------------------------- /lib/fun_with_flags/store/persistent/ecto/null_repo.ex: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.NullEctoRepo do 2 | @moduledoc false 3 | 4 | # This is here just to raise some more helpful errors if a user 5 | # forgets to configure an Ecto repo. 6 | 7 | @error_msg "The NullEctoRepo doesn't implement this. You must configure a proper repo to persist flags with Ecto." 8 | 9 | def all(_), do: raise(@error_msg) 10 | def one(_), do: raise(@error_msg) 11 | def insert(_, _), do: raise(@error_msg) 12 | def update(_, _), do: raise(@error_msg) 13 | def delete_all(_), do: raise(@error_msg) 14 | def transaction(_), do: raise(@error_msg) 15 | def rollback(_), do: raise(@error_msg) 16 | def __adapter__, do: raise(@error_msg) 17 | end 18 | -------------------------------------------------------------------------------- /lib/fun_with_flags/store/persistent/ecto/record.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto.Adapters.SQL) do 2 | 3 | defmodule FunWithFlags.Store.Persistent.Ecto.Record do 4 | @moduledoc false 5 | use Ecto.Schema 6 | import Ecto.Changeset 7 | alias FunWithFlags.{Config, Gate} 8 | 9 | @primary_key {:id, Config.ecto_primary_key_type_determined_at_compile_time(), autogenerate: true} 10 | 11 | schema Config.ecto_table_name_determined_at_compile_time() do 12 | field :flag_name, :string 13 | field :gate_type, :string 14 | field :target, :string 15 | field :enabled, :boolean 16 | end 17 | 18 | @fields [:flag_name, :gate_type, :target, :enabled] 19 | 20 | def changeset(struct, params \\ %{}) do 21 | struct 22 | |> cast(params, @fields) 23 | |> validate_required(@fields) 24 | |> unique_constraint( 25 | :gate_type, 26 | name: "fwf_flag_name_gate_target_idx", 27 | message: "Can't store a duplicated gate." 28 | ) 29 | end 30 | 31 | 32 | def build(flag_name, gate) do 33 | {type, target} = get_type_and_target(gate) 34 | 35 | data = %{ 36 | flag_name: to_string(flag_name), 37 | gate_type: type, 38 | target: target, 39 | enabled: gate.enabled 40 | } 41 | changeset(%__MODULE__{}, data) 42 | end 43 | 44 | 45 | def update_target(record = %__MODULE__{gate_type: "percentage"}, gate) do 46 | {"percentage", target} = get_type_and_target(gate) 47 | change(record, target: target) 48 | end 49 | 50 | # Do not just store NULL for `target: nil`, because the unique 51 | # index in the table does not see NULL values as equal. 52 | # 53 | def serialize_target(nil), do: "_fwf_none" 54 | def serialize_target(str) when is_binary(str), do: str 55 | def serialize_target(atm) when is_atom(atm), do: to_string(atm) 56 | 57 | 58 | defp get_type_and_target(%Gate{type: :percentage_of_time, for: target}) do 59 | {"percentage", "time/#{to_string(target)}"} 60 | end 61 | 62 | defp get_type_and_target(%Gate{type: :percentage_of_actors, for: target}) do 63 | {"percentage", "actors/#{to_string(target)}"} 64 | end 65 | 66 | defp get_type_and_target(%Gate{type: type, for: target}) do 67 | {to_string(type), serialize_target(target)} 68 | end 69 | end 70 | 71 | end # Code.ensure_loaded? 72 | -------------------------------------------------------------------------------- /lib/fun_with_flags/store/persistent/redis.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Redix) do 2 | 3 | defmodule FunWithFlags.Store.Persistent.Redis do 4 | @moduledoc false 5 | 6 | @behaviour FunWithFlags.Store.Persistent 7 | 8 | alias FunWithFlags.{Config, Gate} 9 | alias FunWithFlags.Store.Serializer.Redis, as: Serializer 10 | 11 | @conn __MODULE__ 12 | @conn_options [name: @conn, sync_connect: false] 13 | @prefix "fun_with_flags:" 14 | @flags_set "fun_with_flags" 15 | 16 | 17 | # Retrieve the configuration to connect to Redis, and package it as an argument 18 | # to be passed to the start_link function. 19 | # 20 | @impl true 21 | def worker_spec do 22 | conf = case Config.redis_config do 23 | uri when is_binary(uri) -> 24 | {uri, @conn_options} 25 | {uri, opts} when is_binary(uri) and is_list(opts) -> 26 | {uri, Keyword.merge(opts, @conn_options)} 27 | opts when is_list(opts) -> 28 | Keyword.merge(opts, @conn_options) 29 | end 30 | 31 | Redix.child_spec(conf) 32 | end 33 | 34 | 35 | @impl true 36 | def get(flag_name) do 37 | case Redix.command(@conn, ["HGETALL", format(flag_name)]) do 38 | {:ok, data} -> {:ok, Serializer.deserialize_flag(flag_name, data)} 39 | {:error, why} -> {:error, redis_error(why)} 40 | end 41 | end 42 | 43 | 44 | @impl true 45 | def put(flag_name, gate = %Gate{}) do 46 | data = Serializer.serialize(gate) 47 | 48 | result = Redix.pipeline(@conn, [ 49 | ["MULTI"], 50 | ["SADD", @flags_set, flag_name], 51 | ["HSET" | [format(flag_name) | data]], 52 | ["EXEC"] 53 | ]) 54 | 55 | case result do 56 | {:ok, ["OK", "QUEUED", "QUEUED", [a, b]]} when a in [0, 1] and b in [0, 1] -> 57 | {:ok, flag} = get(flag_name) 58 | {:ok, flag} 59 | {:error, reason} -> 60 | {:error, redis_error(reason)} 61 | {:ok, results} -> 62 | {:error, redis_error("one of the commands failed: #{inspect(results)}")} 63 | end 64 | end 65 | 66 | 67 | # Deletes one gate from the Flag's Redis hash. 68 | # Deleting gates is idempotent and deleting unknown gates is safe. 69 | # A flag will continue to exist even though it has no gates. 70 | # 71 | @impl true 72 | def delete(flag_name, gate = %Gate{}) do 73 | hash_key = format(flag_name) 74 | [field_key, _] = Serializer.serialize(gate) 75 | 76 | case Redix.command(@conn, ["HDEL", hash_key, field_key]) do 77 | {:ok, _number} -> 78 | {:ok, flag} = get(flag_name) 79 | {:ok, flag} 80 | {:error, reason} -> 81 | {:error, redis_error(reason)} 82 | end 83 | end 84 | 85 | 86 | # Deletes an entire Flag's Redis hash and removes its name from the Redis set. 87 | # Deleting flags is idempotent and deleting unknown flags is safe. 88 | # After the operation fetching the now-deleted flag will return the default 89 | # empty flag structure. 90 | # 91 | @impl true 92 | def delete(flag_name) do 93 | result = Redix.pipeline(@conn, [ 94 | ["MULTI"], 95 | ["SREM", @flags_set, flag_name], 96 | ["DEL", format(flag_name)], 97 | ["EXEC"] 98 | ]) 99 | 100 | case result do 101 | {:ok, ["OK", "QUEUED", "QUEUED", [a, b]]} when a in [0, 1] and b in [0, 1] -> 102 | {:ok, flag} = get(flag_name) 103 | {:ok, flag} 104 | {:error, reason} -> 105 | {:error, redis_error(reason)} 106 | {:ok, results} -> 107 | {:error, redis_error("one of the commands failed: #{inspect(results)}")} 108 | end 109 | end 110 | 111 | 112 | @impl true 113 | def all_flags do 114 | case all_flag_names() do 115 | {:ok, flag_names} -> materialize_flags_from_names(flag_names) 116 | error -> error 117 | end 118 | end 119 | 120 | defp materialize_flags_from_names(flag_names) do 121 | flags = Enum.map(flag_names, fn(name) -> 122 | case get(name) do 123 | {:ok, flag} -> flag 124 | error -> error 125 | end 126 | end) 127 | {:ok, flags} 128 | end 129 | 130 | 131 | @impl true 132 | def all_flag_names do 133 | case Redix.command(@conn, ["SMEMBERS", @flags_set]) do 134 | {:ok, strings} -> 135 | atoms = Enum.map(strings, &String.to_atom(&1)) 136 | {:ok, atoms} 137 | {:error, reason} -> 138 | {:error, redis_error(reason)} 139 | end 140 | end 141 | 142 | 143 | defp format(flag_name) do 144 | @prefix <> to_string(flag_name) 145 | end 146 | 147 | defp redis_error(%Redix.ConnectionError{reason: reason_atom}) do 148 | "Redis Connection Error: #{reason_atom}" 149 | end 150 | 151 | defp redis_error(%Redix.Error{message: message}) do 152 | "Redis Error: #{message}" 153 | end 154 | 155 | defp redis_error(reason_atom) do 156 | "Redis Error: #{reason_atom}" 157 | end 158 | end 159 | 160 | end # Code.ensure_loaded? 161 | -------------------------------------------------------------------------------- /lib/fun_with_flags/store/serializer/ecto.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto.Adapters.SQL) do 2 | 3 | defmodule FunWithFlags.Store.Serializer.Ecto do 4 | @moduledoc false 5 | 6 | alias FunWithFlags.Flag 7 | alias FunWithFlags.Gate 8 | alias FunWithFlags.Store.Persistent.Ecto.Record 9 | 10 | def deserialize_flag(name, []), do: Flag.new(to_atom(name), []) 11 | 12 | def deserialize_flag(name, list) when is_list(list) do 13 | gates = 14 | list 15 | |> Enum.sort_by(&(&1.gate_type)) 16 | |> Enum.map(&deserialize_gate(to_string(name), &1)) 17 | |> Enum.reject(&(!&1)) 18 | Flag.new(to_atom(name), gates) 19 | end 20 | 21 | 22 | def deserialize_gate(flag_name, record = %Record{flag_name: flag_name}) do 23 | do_deserialize_gate(record) 24 | end 25 | 26 | def deserialize_gate(_flag_name, _record), do: nil 27 | 28 | 29 | defp do_deserialize_gate(%Record{gate_type: "boolean", enabled: enabled}) do 30 | %Gate{type: :boolean, for: nil, enabled: enabled} 31 | end 32 | 33 | defp do_deserialize_gate(%Record{gate_type: "actor", enabled: enabled, target: target}) do 34 | %Gate{type: :actor, for: target, enabled: enabled} 35 | end 36 | 37 | defp do_deserialize_gate(%Record{gate_type: "group", enabled: enabled, target: target}) do 38 | %Gate{type: :group, for: target, enabled: enabled} 39 | end 40 | 41 | defp do_deserialize_gate(%Record{gate_type: "percentage", target: "time/" <> ratio_s}) do 42 | %Gate{type: :percentage_of_time, for: parse_float(ratio_s), enabled: true} 43 | end 44 | 45 | defp do_deserialize_gate(%Record{gate_type: "percentage", target: "actors/" <> ratio_s}) do 46 | %Gate{type: :percentage_of_actors, for: parse_float(ratio_s), enabled: true} 47 | end 48 | 49 | def to_atom(atm) when is_atom(atm), do: atm 50 | def to_atom(str) when is_binary(str), do: String.to_atom(str) 51 | 52 | defp parse_float(f_s), do: String.to_float(f_s) 53 | end 54 | 55 | end # Code.ensure_loaded? 56 | -------------------------------------------------------------------------------- /lib/fun_with_flags/store/serializer/redis.ex: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.Store.Serializer.Redis do 2 | @moduledoc false 3 | alias FunWithFlags.Flag 4 | alias FunWithFlags.Gate 5 | 6 | @type redis_hash_pair :: [String.t] 7 | 8 | @spec serialize(FunWithFlags.Gate.t) :: redis_hash_pair 9 | 10 | def serialize(%Gate{type: :boolean, for: nil, enabled: enabled}) do 11 | ["boolean", to_string(enabled)] 12 | end 13 | 14 | def serialize(%Gate{type: :actor, for: actor_id, enabled: enabled}) do 15 | ["actor/#{actor_id}", to_string(enabled)] 16 | end 17 | 18 | def serialize(%Gate{type: :group, for: group, enabled: enabled}) do 19 | ["group/#{group}", to_string(enabled)] 20 | end 21 | 22 | def serialize(%Gate{type: :percentage_of_time, for: ratio}) do 23 | ["percentage", "time/#{to_string(ratio)}"] 24 | end 25 | 26 | def serialize(%Gate{type: :percentage_of_actors, for: ratio}) do 27 | ["percentage", "actors/#{to_string(ratio)}"] 28 | end 29 | 30 | 31 | def deserialize_gate(["boolean", enabled]) do 32 | %Gate{type: :boolean, for: nil, enabled: parse_bool(enabled)} 33 | end 34 | 35 | def deserialize_gate(["actor/" <> actor_id, enabled]) do 36 | %Gate{type: :actor, for: actor_id, enabled: parse_bool(enabled)} 37 | end 38 | 39 | def deserialize_gate(["group/" <> group_name, enabled]) do 40 | %Gate{type: :group, for: group_name, enabled: parse_bool(enabled)} 41 | end 42 | 43 | def deserialize_gate(["percentage", "time/" <> ratio_s]) do 44 | %Gate{type: :percentage_of_time, for: parse_float(ratio_s), enabled: true} 45 | end 46 | 47 | def deserialize_gate(["percentage", "actors/" <> ratio_s]) do 48 | %Gate{type: :percentage_of_actors, for: parse_float(ratio_s), enabled: true} 49 | end 50 | 51 | 52 | # `list` comes from redis HGETALL, and it would 53 | # be something like this: 54 | # 55 | # [ 56 | # "boolean", 57 | # "true", 58 | # "actor/user:42", 59 | # "false", 60 | # "group/bananas", 61 | # "true", 62 | # "percentage_of_time", 63 | # "0.5" 64 | # ] 65 | # 66 | def deserialize_flag(name, []), do: Flag.new(name, []) 67 | def deserialize_flag(name, list) when is_list(list) do 68 | gates = 69 | list 70 | |> Enum.chunk_every(2) 71 | |> Enum.map(&deserialize_gate/1) 72 | Flag.new(name, gates) 73 | end 74 | 75 | defp parse_bool("true"), do: true 76 | defp parse_bool(_), do: false 77 | 78 | defp parse_float(f_s), do: String.to_float(f_s) 79 | end 80 | -------------------------------------------------------------------------------- /lib/fun_with_flags/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.Supervisor do 2 | @moduledoc """ 3 | A [Module-based supervisor](https://hexdocs.pm/elixir/Supervisor.html#module-module-based-supervisors). 4 | 5 | It implements [`Supervisor.child_spec/1`](https://hexdocs.pm/elixir/Supervisor.html#module-child_spec-1) to describe the supervision tree for the 6 | `:fun_with_flags` application. 7 | 8 | This module is used internally by the package when the `:fun_with_flags` OTP 9 | application starts its own supervision tree, which is the default behavior. 10 | If that is disabled, the user's host application should use this module to start 11 | the supervision tree directly. 12 | 13 | The main purpose of this API is allow the user's host application to control 14 | when FunWithFlag's supervision tree is started. This is helpful when the 15 | package is configured to depend on some of the host application's modules, e.g. 16 | the `Phoenix.PubSub` process ([as documented](readme.html#pubsub-adapters)). 17 | 18 | More detailed instructions on how to configure this in an application are 19 | available in the [readme](readme.html#application-start-behaviour). 20 | """ 21 | 22 | alias FunWithFlags.Config 23 | require Logger 24 | 25 | # Automatically defines `child_spec/1`. 26 | use Supervisor 27 | 28 | @doc """ 29 | How to start this supervisor and its tree. 30 | 31 | This function is referenced by the `child_spec/1` definition for this supervisor module. 32 | """ 33 | def start_link(init_arg) do 34 | Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) 35 | end 36 | 37 | 38 | @impl true 39 | def init(_init_arg) do 40 | Supervisor.init(children(), strategy: :one_for_one) 41 | end 42 | 43 | 44 | defp children do 45 | [ 46 | FunWithFlags.Store.Cache.worker_spec(), 47 | persistence_spec(), 48 | notifications_spec(), 49 | ] 50 | |> Enum.reject(&(!&1)) 51 | end 52 | 53 | 54 | defp persistence_spec do 55 | adapter = Config.persistence_adapter() 56 | 57 | try do 58 | adapter.worker_spec() 59 | rescue 60 | e in [UndefinedFunctionError] -> 61 | Logger.error "FunWithFlags: It looks like you're trying to use #{inspect(adapter)} " <> 62 | "to persist flags, but you haven't added its optional dependency to the Mixfile " <> 63 | "of your project." 64 | reraise e, __STACKTRACE__ 65 | end 66 | end 67 | 68 | # If the change notifications are enabled AND the adapter can 69 | # be supervised, then return a spec for the supervisor. 70 | # Also handle cases where an adapter has been configured but its 71 | # optional dependency is not required in the Mixfile. 72 | # 73 | defp notifications_spec do 74 | try do 75 | Config.change_notifications_enabled? && Config.notifications_adapter.worker_spec() 76 | rescue 77 | e in [UndefinedFunctionError] -> 78 | Logger.error "FunWithFlags: It looks like you're trying to use #{inspect(Config.notifications_adapter)} " <> 79 | "for the cache-busting notifications, but you haven't added its optional dependency to the Mixfile " <> 80 | "of your project. If you don't need cache-busting notifications, they can be disabled to make this " <> 81 | "error go away." 82 | reraise e, __STACKTRACE__ 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/fun_with_flags/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.Telemetry do 2 | @moduledoc """ 3 | Telemetry events for FunWithFlags. 4 | 5 | This module centralizes the emission of all [Telemetry](https://hexdocs.pm/telemetry/readme.html) 6 | events for the package. 7 | 8 | ## Events 9 | 10 | The common prefix for all events is `:fun_with_flags`, followed by a logical 11 | scope (e.g. `:persistence`) and the event name. 12 | 13 | Events are simple "point in time" events rather than span events (that is, 14 | there is no distinct `:start` and `:stop` events with a duration measurement). 15 | 16 | ### Persistence 17 | 18 | Events for CRUD operations on the persistent datastore. 19 | 20 | All events contain the same measurement: 21 | * `system_time` (integer), which is the current system time in the 22 | `:native` time unit. See `:erlang.system_time/0`. 23 | 24 | Events: 25 | 26 | * `[:fun_with_flags, :persistence, :read]`, emitted when a flag is read from 27 | the DB. Crucially, this event is not emitted when the cache is enabled and 28 | there is a cache hit, and it's emitted only when retrieving a flag reads 29 | from the persistent datastore. Therefore, when the cache is disabled, this 30 | event is always emitted every time a flag is queried. 31 | 32 | Metadata: 33 | * `flag_name` (atom), the name of the flag being read. 34 | 35 | * `[:fun_with_flags, :persistence, :read_all_flags]`, emitted when all flags 36 | are read from the DB. No extra metadata. 37 | 38 | * `[:fun_with_flags, :persistence, :read_all_flag_names]`, emitted when all 39 | flags names are read from the DB. No extra metadata. 40 | 41 | * `[:fun_with_flags, :persistence, :write]`, emitted when writing a flag to 42 | the DB. In practive, what is written is one of the gates of the flag, which 43 | is always upserted. 44 | 45 | Metadata: 46 | * `flag_name` (atom), the name of the flag being written. 47 | * `gate` (`FunWithFlags.Gate`), the gate being upserted. 48 | 49 | * `[:fun_with_flags, :persistence, :delete_flag]`, emitted when an entire flag 50 | is deleted from the DB. 51 | 52 | Metadata: 53 | * `flag_name` (atom), the name of the flag being deleted. 54 | 55 | * `[:fun_with_flags, :persistence, :delete_gate]`, emitted when one of the flag's 56 | gates is deleted from the DB. 57 | 58 | Metadata: 59 | * `flag_name` (atom), the name of the flag whose gate is being deleted. 60 | * `gate` (`FunWithFlags.Gate`), the gate being deleted. 61 | 62 | * `[:fun_with_flags, :persistence, :reload]`, emitted when a flag is reloaded 63 | from the DB. This typically happens when the node has received a change 64 | notification for a flag, which results in the cache being invalidated and 65 | the flag being reloaded from the DB. 66 | 67 | Metadata: 68 | * `flag_name` (atom), the name of the flag being reloaded. 69 | 70 | * `[:fun_with_flags, :persistence, :error]`, emitted for erorrs in any of the 71 | above operations. 72 | 73 | Metadata: 74 | * `error` (any), the error that occurred. This is typically a string or any 75 | appropriate error term returned by the underlying persistence adapters. 76 | * `original_event` (atom), the name of the original event that failed, e.g. 77 | `:read`, `:write`, `:delete_gate`, etc. 78 | * `flag_name` (atom), the name of the flag being operated on, if supported 79 | by the original event. 80 | * `gate` (`FunWithFlags.Gate`), the gate being operated on, if supported by 81 | the original event. 82 | """ 83 | 84 | require Logger 85 | 86 | @typedoc false 87 | @type pipelining_value :: {:ok, any()} | {:error, any()} 88 | 89 | # Receive the flag name as an explicit parameter rather than pattern matching 90 | # it from the `{:ok, _}` tuple, because: 91 | # 92 | # * That tuple is only available on success, and it's therefore not available 93 | # when pipelining on an error. 94 | # * It makes it possible to use this function even when the :ok result does 95 | # not contain a flag. 96 | # 97 | @doc false 98 | @spec emit_persistence_event( 99 | pipelining_value(), 100 | event_name :: atom(), 101 | flag_name :: (atom() | nil), 102 | gate :: (FunWithFlags.Gate.t | nil) 103 | ) :: pipelining_value() 104 | def emit_persistence_event(result = {:ok, _}, event_name, flag_name, gate) do 105 | metadata = %{ 106 | flag_name: flag_name, 107 | gate: gate, 108 | } 109 | 110 | do_send_event([:fun_with_flags, :persistence, event_name], metadata) 111 | result 112 | end 113 | 114 | def emit_persistence_event(result = {:error, reason}, event_name, flag_name, gate) do 115 | metadata = %{ 116 | flag_name: flag_name, 117 | gate: gate, 118 | error: reason, 119 | original_event: event_name 120 | } 121 | 122 | do_send_event([:fun_with_flags, :persistence, :error], metadata) 123 | result 124 | end 125 | 126 | @doc false 127 | @spec do_send_event([atom], :telemetry.event_metadata()) :: :ok 128 | def do_send_event(event_name, metadata) do 129 | measurements = %{ 130 | system_time: :erlang.system_time() 131 | } 132 | 133 | Logger.debug(fn -> 134 | "Telemetry event: #{inspect(event_name)}, metadata: #{inspect(metadata)}, measurements: #{inspect(measurements)}" 135 | end) 136 | 137 | :telemetry.execute(event_name, measurements, metadata) 138 | end 139 | 140 | 141 | @doc """ 142 | Attach a debug handler to FunWithFlags telemetry events. 143 | 144 | Attach a Telemetry handler that logs all events at the `:alert` level. 145 | It uses the `:alert` level rather than `:debug` or `:info` simply to make it 146 | more convenient to eyeball these logs and to print them while running the tests. 147 | """ 148 | @spec attach_debug_handler() :: :ok | {:error, :already_exists} 149 | def attach_debug_handler do 150 | events = [ 151 | [:fun_with_flags, :persistence, :read], 152 | [:fun_with_flags, :persistence, :read_all_flags], 153 | [:fun_with_flags, :persistence, :read_all_flag_names], 154 | [:fun_with_flags, :persistence, :write], 155 | [:fun_with_flags, :persistence, :delete_flag], 156 | [:fun_with_flags, :persistence, :delete_gate], 157 | [:fun_with_flags, :persistence, :reload], 158 | [:fun_with_flags, :persistence, :error], 159 | ] 160 | 161 | :telemetry.attach_many("local-debug-handler", events, &__MODULE__.debug_event_handler/4, %{}) 162 | end 163 | 164 | @doc false 165 | def debug_event_handler([:fun_with_flags, :persistence, event], %{system_time: system_time}, metadata, _config) do 166 | dt = DateTime.from_unix!(system_time, :native) |> DateTime.to_iso8601() 167 | 168 | Logger.alert(fn -> 169 | "FunWithFlags telemetry event: #{event}, system_time: #{dt}, metadata: #{inspect(metadata)}" 170 | end) 171 | 172 | :ok 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /lib/fun_with_flags/timestamps.ex: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.Timestamps do 2 | @moduledoc false 3 | 4 | def now do 5 | DateTime.utc_now() |> DateTime.to_unix(:second) 6 | end 7 | 8 | def expired?(timestamp, ttl) do 9 | (timestamp + ttl) < __MODULE__.now() 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/tompave/fun_with_flags" 5 | @version "1.13.0" 6 | 7 | def project do 8 | [ 9 | app: :fun_with_flags, 10 | version: @version, 11 | elixir: "~> 1.16", 12 | elixirc_paths: elixirc_paths(Mix.env), 13 | build_embedded: Mix.env == :prod, 14 | start_permanent: Mix.env == :prod, 15 | deps: deps(), 16 | description: description(), 17 | package: package(), 18 | docs: docs(), 19 | aliases: aliases(), 20 | dialyzer: dialyzer(), 21 | ] 22 | end 23 | 24 | def application do 25 | # Specify extra applications you'll use from Erlang/Elixir 26 | [extra_applications: extra_applications(Mix.env), 27 | mod: {FunWithFlags.Application, []}] 28 | end 29 | 30 | defp extra_applications(:test), do: local_extra_applications() 31 | defp extra_applications(:dev), do: local_extra_applications() 32 | defp extra_applications(_), do: [:logger] 33 | 34 | # When working locally with the Ecto adapter, start the ecto_sql 35 | # and postgrex applications. They're not started automatically 36 | # because they're optional, I think. 37 | # 38 | # Also start the Phoenix PubSub application if that notification 39 | # adapter is configured. 40 | # 41 | defp local_extra_applications do 42 | # Required to run the Erlang observer with Elixir 1.15 and above. 43 | # https://elixirforum.com/t/cannot-start-observer-undefinedfunctionerror-function-observer-start-0-is-undefined/56642 44 | # https://github.com/elixir-lang/elixir/blob/v1.15/CHANGELOG.md#potential-incompatibilities 45 | apps = 46 | if Version.match?(System.version, ">= 1.15.0") do 47 | [:logger, :observer, :wx, :runtime_tools] 48 | else 49 | [:logger] 50 | end 51 | 52 | apps = 53 | if System.get_env("PERSISTENCE") == "ecto" do 54 | apps ++ [:ecto, :ecto_sql, :postgrex] 55 | else 56 | apps 57 | end 58 | 59 | apps = 60 | if System.get_env("PUBSUB_BROKER") == "phoenix_pubsub" do 61 | [:phoenix_pubsub | apps] 62 | else 63 | apps 64 | end 65 | 66 | apps 67 | end 68 | 69 | defp deps do 70 | [ 71 | {:redix, "~> 1.0", optional: true}, 72 | {:ecto_sql, "~> 3.0", optional: true}, 73 | {:ecto_sqlite3, "~> 0.8", optional: true, only: [:dev, :test]}, 74 | {:postgrex, "~> 0.16", optional: true, only: [:dev, :test]}, 75 | {:myxql, "~> 0.2", optional: true, only: [:dev, :test]}, 76 | {:phoenix_pubsub, "~> 2.0", optional: true}, 77 | {:telemetry, "~> 1.3"}, 78 | 79 | {:mock, "~> 0.3", only: :test}, 80 | 81 | {:ex_doc, "~> 0.21", only: :dev, runtime: false}, 82 | {:credo, "~> 1.7", only: :dev, runtime: false}, 83 | {:dialyxir, "~> 1.0", only: :dev, runtime: false}, 84 | 85 | {:benchee, "~> 1.0", only: :dev, runtime: false}, 86 | {:benchee_html, "~> 1.0", only: :dev, runtime: false}, 87 | ] 88 | end 89 | 90 | defp dialyzer do 91 | [ 92 | # Add optional dependencies to avoid "unknown_function" warnings. 93 | plt_add_apps: [:redix, :ecto, :ecto_sql, :phoenix_pubsub], 94 | ] 95 | end 96 | 97 | defp aliases do 98 | [ 99 | {:"test.all", [&run_all_tests/1]}, 100 | {:"test.phx", [&run_tests__redis_pers__phoenix_pubsub/1]}, 101 | {:"test.ecto.postgres", [&run_tests__ecto_pers_postgres__phoenix_pubsub/1]}, 102 | {:"test.ecto.mysql", [&run_tests__ecto_pers_mysql__phoenix_pubsub/1]}, 103 | {:"test.ecto.sqlite", [&run_tests__ecto_pers_sqlite__phoenix_pubsub/1]}, 104 | {:"test.redis", [&run_tests__redis_pers__redis_pubsub/1]}, 105 | ] 106 | end 107 | 108 | # Runs all the test configurations. 109 | # If any fails, exit with status code 1, so that this can properly fail in CI. 110 | # 111 | defp run_all_tests(arg) do 112 | tests = [ 113 | &run_tests__redis_pers__redis_pubsub/1, &run_integration_tests__redis_pers__redis_pubsub__no_cache/1, 114 | &run_tests__redis_pers__phoenix_pubsub/1, &run_integration_tests__redis_pers__phoenix_pubsub__no_cache/1, 115 | &run_tests__ecto_pers_postgres__phoenix_pubsub/1, &run_integration_tests__ecto_pers_postgres__phoenix_pubsub__no_cache/1, 116 | &run_tests__ecto_pers_mysql__phoenix_pubsub/1, &run_integration_tests__ecto_pers_mysql__phoenix_pubsub__no_cache/1, 117 | &run_tests__ecto_pers_sqlite__phoenix_pubsub/1, &run_integration_tests__ecto_pers_sqlite__phoenix_pubsub__no_cache/1, 118 | ] 119 | 120 | exit_codes = case System.get_env("CI") do 121 | "true" -> 122 | tests |> Enum.map(fn test_fn -> _run_test_with_retries(3, 500, fn -> test_fn.(arg) end) end) 123 | _ -> 124 | tests |> Enum.map(fn test_fn -> test_fn.(arg) end) 125 | end 126 | 127 | if Enum.any?(exit_codes, &(&1 != 0)) do 128 | require Logger 129 | Logger.error("Some test configuration did not pass.") 130 | exit({:shutdown, 1}) 131 | end 132 | end 133 | 134 | # Because some tests are flaky in CI. 135 | # 136 | defp _run_test_with_retries(attempts, sleep_ms, test_fn) when attempts > 0 do 137 | IO.puts("---\nRunning a test task with retries. Attempts left: #{attempts}, sleep ms: #{sleep_ms}.\n---") 138 | case test_fn.() do 139 | 0 -> 0 # Successful run, simply return the status. 140 | _ -> 141 | :timer.sleep(sleep_ms) 142 | remaining = attempts - 1 143 | IO.puts("Test failed. Retries left: #{remaining}.") 144 | _run_test_with_retries(remaining, sleep_ms, test_fn) 145 | end 146 | end 147 | 148 | defp _run_test_with_retries(_, _, _) do 149 | IO.puts("---\nAll retries failed. Returning exit code 1.\n---") 150 | 1 151 | end 152 | 153 | # Run the tests with Redis as persistent store and Redis PubSub as broker. 154 | # 155 | # Cache enabled, force re-compilation. 156 | # 157 | defp run_tests__redis_pers__redis_pubsub(arg) do 158 | Mix.shell.cmd( 159 | "mix test --color --force --exclude phoenix_pubsub --exclude ecto_persistence #{Enum.join(arg, " ")}", 160 | env: [ 161 | {"CACHE_ENABLED", "true"}, 162 | ] 163 | ) 164 | end 165 | 166 | # Runs the integration tests only. 167 | # Cache disabled, Redis as persistent store and Redis PubSub as broker. 168 | # 169 | defp run_integration_tests__redis_pers__redis_pubsub__no_cache(arg) do 170 | Mix.shell.cmd( 171 | "mix test --color --force --only integration #{Enum.join(arg, " ")}", 172 | env: [ 173 | {"CACHE_ENABLED", "false"}, 174 | ] 175 | ) 176 | end 177 | 178 | # Run the tests with Redis as persistent store and Phoenix.PubSub as broker. 179 | # 180 | defp run_tests__redis_pers__phoenix_pubsub(arg) do 181 | Mix.shell.cmd( 182 | "mix test --color --force --exclude redis_pubsub --exclude ecto_persistence #{Enum.join(arg, " ")}", 183 | env: [ 184 | {"CACHE_ENABLED", "true"}, 185 | {"PUBSUB_BROKER", "phoenix_pubsub"}, 186 | ] 187 | ) 188 | end 189 | 190 | # Runs the integration tests only. 191 | # Cache disabled, Redis as persistent store and Phoenix.PubSubas broker. 192 | # 193 | defp run_integration_tests__redis_pers__phoenix_pubsub__no_cache(arg) do 194 | Mix.shell.cmd( 195 | "mix test --color --force --only integration #{Enum.join(arg, " ")}", 196 | env: [ 197 | {"CACHE_ENABLED", "false"}, 198 | {"PUBSUB_BROKER", "phoenix_pubsub"}, 199 | ] 200 | ) 201 | end 202 | 203 | # Run the tests with Ecto+PostgreSQL as persistent store and Phoenix.PubSub as broker. 204 | # 205 | defp run_tests__ecto_pers_postgres__phoenix_pubsub(arg) do 206 | Mix.shell.cmd( 207 | "mix test --color --force --exclude redis_pubsub --exclude redis_persistence #{Enum.join(arg, " ")}", 208 | env: [ 209 | {"CACHE_ENABLED", "true"}, 210 | {"PUBSUB_BROKER", "phoenix_pubsub"}, 211 | {"PERSISTENCE", "ecto"}, 212 | {"RDBMS", "postgres"}, 213 | ] 214 | ) 215 | end 216 | 217 | # Run the tests with Ecto+MySQL as persistent store and Phoenix.PubSub as broker. 218 | # 219 | defp run_tests__ecto_pers_mysql__phoenix_pubsub(arg) do 220 | Mix.shell.cmd( 221 | "mix test --color --force --exclude redis_pubsub --exclude redis_persistence #{Enum.join(arg, " ")}", 222 | env: [ 223 | {"CACHE_ENABLED", "true"}, 224 | {"PUBSUB_BROKER", "phoenix_pubsub"}, 225 | {"PERSISTENCE", "ecto"}, 226 | {"RDBMS", "mysql"}, 227 | ] 228 | ) 229 | end 230 | 231 | # Run the tests with Ecto+SQLite as persistent store and Phoenix.PubSub as broker. 232 | # 233 | defp run_tests__ecto_pers_sqlite__phoenix_pubsub(arg) do 234 | Mix.shell.cmd( 235 | "mix test --color --force --exclude redis_pubsub --exclude redis_persistence #{Enum.join(arg, " ")}", 236 | env: [ 237 | {"CACHE_ENABLED", "true"}, 238 | {"PUBSUB_BROKER", "phoenix_pubsub"}, 239 | {"PERSISTENCE", "ecto"}, 240 | {"RDBMS", "sqlite"}, 241 | ] 242 | ) 243 | end 244 | 245 | # Runs the integration tests only. 246 | # Cache disabled, Ecto+PostgreSQL as persistent store and Phoenix.PubSub as broker. 247 | # 248 | defp run_integration_tests__ecto_pers_postgres__phoenix_pubsub__no_cache(arg) do 249 | Mix.shell.cmd( 250 | "mix test --color --force --only integration #{Enum.join(arg, " ")}", 251 | env: [ 252 | {"CACHE_ENABLED", "false"}, 253 | {"PUBSUB_BROKER", "phoenix_pubsub"}, 254 | {"PERSISTENCE", "ecto"}, 255 | {"RDBMS", "postgres"}, 256 | ] 257 | ) 258 | end 259 | 260 | # Runs the integration tests only. 261 | # Cache disabled, Ecto+MySQL as persistent store and Phoenix.PubSub as broker. 262 | # 263 | defp run_integration_tests__ecto_pers_mysql__phoenix_pubsub__no_cache(arg) do 264 | Mix.shell.cmd( 265 | "mix test --color --force --only integration #{Enum.join(arg, " ")}", 266 | env: [ 267 | {"CACHE_ENABLED", "false"}, 268 | {"PUBSUB_BROKER", "phoenix_pubsub"}, 269 | {"PERSISTENCE", "ecto"}, 270 | {"RDBMS", "mysql"}, 271 | ] 272 | ) 273 | end 274 | 275 | # Runs the integration tests only. 276 | # Cache disabled, Ecto+SQLite as persistent store and Phoenix.PubSub as broker. 277 | # 278 | defp run_integration_tests__ecto_pers_sqlite__phoenix_pubsub__no_cache(arg) do 279 | Mix.shell.cmd( 280 | "mix test --color --force --only integration #{Enum.join(arg, " ")}", 281 | env: [ 282 | {"CACHE_ENABLED", "false"}, 283 | {"PUBSUB_BROKER", "phoenix_pubsub"}, 284 | {"PERSISTENCE", "ecto"}, 285 | {"RDBMS", "sqlite"}, 286 | ] 287 | ) 288 | end 289 | 290 | defp elixirc_paths(:test), do: ["lib", "test/support", "dev_support"] 291 | defp elixirc_paths(:dev), do: ["lib", "dev_support"] 292 | defp elixirc_paths(_), do: ["lib"] 293 | 294 | defp description do 295 | """ 296 | FunWithFlags, a flexible and fast feature toggle library for Elixir. 297 | """ 298 | end 299 | 300 | defp package do 301 | [ 302 | maintainers: [ 303 | "Tommaso Pavese" 304 | ], 305 | licenses: [ 306 | "MIT" 307 | ], 308 | links: %{ 309 | "GitHub" => @source_url, 310 | "Changelog" => "#{@source_url}/blob/master/CHANGELOG.md" 311 | } 312 | ] 313 | end 314 | 315 | defp docs do 316 | [ 317 | extras: ["README.md", "CHANGELOG.md"], 318 | main: "FunWithFlags", 319 | source_url: @source_url, 320 | source_ref: "v#{@version}" 321 | ] 322 | end 323 | end 324 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "1.3.1", "c786e6a76321121a44229dde3988fc772bca73ea75170a73fd5f4ddf1af95ccf", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "76224c58ea1d0391c8309a8ecbfe27d71062878f59bd41a390266bf4ac1cc56d"}, 3 | "benchee_html": {:hex, :benchee_html, "1.0.1", "1e247c0886c3fdb0d3f4b184b653a8d6fb96e4ad0d0389267fe4f36968772e24", [:mix], [{:benchee, ">= 0.99.0 and < 2.0.0", [hex: :benchee, repo: "hexpm", optional: false]}, {:benchee_json, "~> 1.0", [hex: :benchee_json, repo: "hexpm", optional: false]}], "hexpm", "b00a181af7152431901e08f3fc9f7197ed43ff50421a8347b0c80bf45d5b3fef"}, 4 | "benchee_json": {:hex, :benchee_json, "1.0.0", "cc661f4454d5995c08fe10dd1f2f72f229c8f0fb1c96f6b327a8c8fc96a91fe5", [:mix], [{:benchee, ">= 0.99.0 and < 2.0.0", [hex: :benchee, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "da05d813f9123505f870344d68fb7c86a4f0f9074df7d7b7e2bb011a63ec231c"}, 5 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 6 | "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"}, 7 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 8 | "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, 9 | "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, 10 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 11 | "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, 12 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 13 | "earmark": {:hex, :earmark, "1.4.7", "7b5f0474469688f5514948a4e0c42955a5690d165deaf19c8eb950a3443a40f3", [:mix], [], "hexpm", "0a55a49ee6fa8bc8678f894dfa8a882af6fe8deb65aed6856122226db5b0fe5b"}, 14 | "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, 15 | "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, 16 | "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, 17 | "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.19.0", "00030bbaba150369ff3754bbc0d2c28858e8f528ae406bf6997d1772d3a03203", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "297b16750fe229f3056fe32afd3247de308094e8b0298aef0d73a8493ce97c81"}, 18 | "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, 19 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 20 | "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, 21 | "exqlite": {:hex, :exqlite, "0.29.0", "e6f1de4bfe3ce6e4c4260b15fef830705fa36632218dc7eafa0a5aba3a5d6e04", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a75f8a069fcdad3e5f95dfaddccd13c2112ea3b742fdcc234b96410e9c1bde00"}, 22 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 23 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 24 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 25 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 26 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 27 | "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, 28 | "mock": {:hex, :mock, "0.3.9", "10e44ad1f5962480c5c9b9fa779c6c63de9bd31997c8e04a853ec990a9d841af", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "9e1b244c4ca2551bb17bb8415eed89e40ee1308e0fbaed0a4fdfe3ec8a4adbd3"}, 29 | "myxql": {:hex, :myxql, "0.7.1", "7c7b75aa82227cd2bc9b7fbd4de774fb19a1cdb309c219f411f82ca8860f8e01", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:geo, "~> 3.4", [hex: :geo, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a491cdff53353a09b5850ac2d472816ebe19f76c30b0d36a43317a67c9004936"}, 30 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 31 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 32 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 33 | "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, 34 | "redix": {:hex, :redix, "1.5.2", "ab854435a663f01ce7b7847f42f5da067eea7a3a10c0a9d560fa52038fd7ab48", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "78538d184231a5d6912f20567d76a49d1be7d3fca0e1aaaa20f4df8e1142dcb8"}, 35 | "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, 36 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 37 | } 38 | -------------------------------------------------------------------------------- /priv/ecto_repo/migrations/00000000000000_create_feature_flags_table.exs: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.Dev.EctoRepo.Migrations.CreateFeatureFlagsTable do 2 | use Ecto.Migration 3 | 4 | # This migration assumes the default table name of "fun_with_flags_toggles" 5 | # is being used. If you have overridden that via configuration, you should 6 | # change this migration accordingly. 7 | 8 | def up do 9 | create table(:fun_with_flags_toggles, primary_key: false) do 10 | add :id, :bigserial, primary_key: true 11 | # If you configure :ecto_primary_key_type to be :binary_id, you should replace 12 | # the line above with: 13 | # add :id, :binary_id, primary_key: true 14 | add :flag_name, :string, null: false 15 | add :gate_type, :string, null: false 16 | add :target, :string, null: false 17 | add :enabled, :boolean, null: false 18 | end 19 | 20 | create index( 21 | :fun_with_flags_toggles, 22 | [:flag_name, :gate_type, :target], 23 | [unique: true, name: "fwf_flag_name_gate_target_idx"] 24 | ) 25 | end 26 | 27 | def down do 28 | drop table(:fun_with_flags_toggles) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/fun_with_flags/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.ConfigTest do 2 | use FunWithFlags.TestCase, async: true 3 | alias FunWithFlags.Config 4 | 5 | import FunWithFlags.TestUtils, only: [ 6 | configure_redis_with: 1, 7 | ensure_default_redis_config_in_app_env: 0, 8 | reset_app_env_to_default_redis_config: 0, 9 | ] 10 | 11 | # Test all of these in the same test case because Mix provides 12 | # no API to clear or reset the App configuration. Since the test 13 | # order is randomized, testing these cases separately makes them 14 | # non-deterministic and causes random failures. 15 | # 16 | # The good thing is that the OTP app is started _before_ the 17 | # tests, thus changing this configuration should not affect the 18 | # Redis connection. 19 | # 20 | test "the redis configuration" do 21 | # without configuration, it returns the defaults 22 | ensure_default_redis_config_in_app_env() 23 | defaults = [host: "localhost", port: 6379, database: 5] 24 | assert ^defaults = Config.redis_config 25 | 26 | # when configured to use a URL string, it returns the string and ignores the defaults 27 | url = "redis:://localhost:1234/1" 28 | configure_redis_with(url) 29 | assert ^url = Config.redis_config 30 | 31 | # when configured to use a URL + Redis config tuple, it returns the tuple and ignores the defaults 32 | url = "redis:://localhost:1234/1" 33 | configure_redis_with({url, socket_opts: [:inet6]}) 34 | {^url, opts} = Config.redis_config 35 | assert [socket_opts: [:inet6]] == opts 36 | 37 | # when configured to use sentinel, it returns sentinel without default host and port 38 | sentinel = [sentinel: [sentinels: ["redis:://locahost:1234/1"], group: "primary"], database: 5] 39 | configure_redis_with(sentinel) 40 | assert ^sentinel = Config.redis_config 41 | 42 | # when configured with keywords, it merges them with the default 43 | configure_redis_with(database: 42, port: 2000) 44 | assert defaults[:host] == Config.redis_config[:host] 45 | assert 2000 == Config.redis_config[:port] 46 | assert 42 == Config.redis_config[:database] 47 | 48 | # When configured with a {:system, env} tuple it looks up the value in the env 49 | System.put_env("123_TEST_REDIS_URL", url) 50 | configure_redis_with({:system, "123_TEST_REDIS_URL"}) 51 | assert url == Config.redis_config 52 | System.delete_env("123_TEST_REDIS_URL") 53 | 54 | # cleanup 55 | reset_app_env_to_default_redis_config() 56 | end 57 | 58 | 59 | test "cache?" do 60 | # defaults to true 61 | assert true == Config.cache? 62 | 63 | # can be configured 64 | Application.put_all_env(fun_with_flags: [cache: [enabled: false]]) 65 | assert false == Config.cache? 66 | 67 | # cleanup 68 | reset_cache_defaults() 69 | assert true == Config.cache? 70 | end 71 | 72 | 73 | test "cache_ttl" do 74 | # defaults to 60 seconds in test 75 | assert 60 = Config.cache_ttl 76 | 77 | # can be configured 78 | Application.put_all_env(fun_with_flags: [cache: [ttl: 3600]]) 79 | assert 3600 = Config.cache_ttl 80 | 81 | # cleanup 82 | reset_cache_defaults() 83 | assert 60 = Config.cache_ttl 84 | end 85 | 86 | 87 | @tag :integration 88 | test "store_module_determined_at_compile_time()" do 89 | # This is not great, but testing compile time stuff is tricky. 90 | if Config.cache?() do 91 | assert FunWithFlags.Store = Config.store_module_determined_at_compile_time() 92 | else 93 | assert FunWithFlags.SimpleStore = Config.store_module_determined_at_compile_time() 94 | end 95 | end 96 | 97 | 98 | test "build_unique_id() returns a unique string" do 99 | assert is_binary(Config.build_unique_id) 100 | 101 | list = Enum.map(1..20, fn(_) -> Config.build_unique_id() end) 102 | assert length(list) == length(Enum.uniq(list)) 103 | end 104 | 105 | 106 | describe "When we are persisting data in Redis" do 107 | @describetag :redis_persistence 108 | test "persistence_adapter() returns the Redis module" do 109 | assert FunWithFlags.Store.Persistent.Redis = Config.persistence_adapter 110 | end 111 | 112 | test "persist_in_ecto? returns false" do 113 | refute Config.persist_in_ecto? 114 | end 115 | 116 | test "ecto_repo() returns the null repo" do 117 | assert FunWithFlags.NullEctoRepo = Config.ecto_repo 118 | end 119 | end 120 | 121 | describe "When we are persisting data in Ecto" do 122 | @describetag :ecto_persistence 123 | test "persistence_adapter() returns the Ecto module" do 124 | assert FunWithFlags.Store.Persistent.Ecto = Config.persistence_adapter 125 | end 126 | 127 | test "persist_in_ecto? returns true" do 128 | assert Config.persist_in_ecto? 129 | end 130 | 131 | test "ecto_repo() returns a repo" do 132 | assert FunWithFlags.Dev.EctoRepo = Config.ecto_repo 133 | end 134 | end 135 | 136 | describe "ecto_table_name_determined_at_compile_time()" do 137 | test "it defaults to \"fun_with_flags_toggles\"" do 138 | assert Config.ecto_table_name_determined_at_compile_time() == "fun_with_flags_toggles" 139 | end 140 | end 141 | 142 | describe "ecto_primary_key_type_determined_at_compile_time()" do 143 | test "it defaults to :id" do 144 | assert Config.ecto_primary_key_type_determined_at_compile_time() == :id 145 | end 146 | end 147 | 148 | describe "When we are sending notifications with Redis PubSub" do 149 | @describetag :redis_pubsub 150 | 151 | test "notifications_adapter() returns the Redis module" do 152 | assert FunWithFlags.Notifications.Redis = Config.notifications_adapter 153 | end 154 | 155 | test "phoenix_pubsub? returns false" do 156 | refute Config.phoenix_pubsub? 157 | end 158 | 159 | test "pubsub_client() returns nil" do 160 | assert is_nil(Config.pubsub_client) 161 | end 162 | end 163 | 164 | describe "When we are sending notifications with Phoenix.PubSub" do 165 | @describetag :phoenix_pubsub 166 | 167 | test "notifications_adapter() returns the Redis module" do 168 | assert FunWithFlags.Notifications.PhoenixPubSub = Config.notifications_adapter 169 | end 170 | 171 | test "phoenix_pubsub? returns true" do 172 | assert Config.phoenix_pubsub? 173 | end 174 | 175 | test "pubsub_client() returns an atom" do 176 | assert :fwf_test = Config.pubsub_client 177 | end 178 | end 179 | 180 | 181 | describe "change_notifications_enabled?()" do 182 | test "returns true by default" do 183 | assert Config.change_notifications_enabled? 184 | end 185 | 186 | test "returns false if the cache is disabled" do 187 | Application.put_all_env(fun_with_flags: [cache: [enabled: false]]) 188 | refute Config.change_notifications_enabled? 189 | 190 | # cleanup 191 | reset_cache_defaults() 192 | assert Config.change_notifications_enabled? 193 | end 194 | 195 | test "returns false if no notification adapter is configured" do 196 | original_adapter = Config.notifications_adapter() 197 | original_client = Config.pubsub_client 198 | Application.put_all_env(fun_with_flags: [cache_bust_notifications: [adapter: nil]]) 199 | refute Config.change_notifications_enabled? 200 | 201 | # cleanup 202 | reset_notifications_defaults(original_adapter, original_client) 203 | assert Config.change_notifications_enabled? 204 | end 205 | 206 | test "returns false if it's explicitly disabled" do 207 | original_adapter = Config.notifications_adapter() 208 | original_client = Config.pubsub_client 209 | Application.put_all_env(fun_with_flags: [cache_bust_notifications: [enabled: false]]) 210 | refute Config.change_notifications_enabled? 211 | 212 | # cleanup 213 | reset_notifications_defaults(original_adapter, original_client) 214 | assert Config.change_notifications_enabled? 215 | end 216 | end 217 | 218 | defp reset_cache_defaults do 219 | Application.put_all_env(fun_with_flags: [cache: [enabled: true, ttl: 60]]) 220 | end 221 | 222 | defp reset_notifications_defaults(adapter, client) do 223 | Application.put_all_env(fun_with_flags: [ 224 | cache_bust_notifications: [ 225 | enabled: true, adapter: adapter, client: client 226 | ] 227 | ]) 228 | end 229 | end 230 | -------------------------------------------------------------------------------- /test/fun_with_flags/gate_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.GateTest do 2 | use FunWithFlags.TestCase, async: true 3 | 4 | alias FunWithFlags.Gate 5 | alias FunWithFlags.TestUser 6 | 7 | describe "new()" do 8 | test "new(:boolean, true|false) returns a new Boolean Gate" do 9 | assert %Gate{type: :boolean, for: nil, enabled: true} = Gate.new(:boolean, true) 10 | assert %Gate{type: :boolean, for: nil, enabled: false} = Gate.new(:boolean, false) 11 | end 12 | 13 | test "new(:percentage_of_time, ratio_f) retrurns a PercentageOfTime gate" do 14 | assert %Gate{type: :percentage_of_time, for: 0.001, enabled: true} = Gate.new(:percentage_of_time, 0.001) 15 | assert %Gate{type: :percentage_of_time, for: 0.1, enabled: true} = Gate.new(:percentage_of_time, 0.1) 16 | assert %Gate{type: :percentage_of_time, for: 0.59, enabled: true} = Gate.new(:percentage_of_time, 0.59) 17 | assert %Gate{type: :percentage_of_time, for: 0.999, enabled: true} = Gate.new(:percentage_of_time, 0.999) 18 | end 19 | 20 | test "new(:percentage_of_time, ratio_f) with an invalid ratio raises an exception" do 21 | assert_raise FunWithFlags.Gate.InvalidTargetError, fn() -> Gate.new(:percentage_of_time, 0.0) end 22 | assert_raise FunWithFlags.Gate.InvalidTargetError, fn() -> Gate.new(:percentage_of_time, 1.0) end 23 | end 24 | 25 | test "new(:percentage_of_actors, ratio_f) retrurns a PercentageOfActors gate" do 26 | assert %Gate{type: :percentage_of_actors, for: 0.001, enabled: true} = Gate.new(:percentage_of_actors, 0.001) 27 | assert %Gate{type: :percentage_of_actors, for: 0.1, enabled: true} = Gate.new(:percentage_of_actors, 0.1) 28 | assert %Gate{type: :percentage_of_actors, for: 0.59, enabled: true} = Gate.new(:percentage_of_actors, 0.59) 29 | assert %Gate{type: :percentage_of_actors, for: 0.999, enabled: true} = Gate.new(:percentage_of_actors, 0.999) 30 | end 31 | 32 | test "new(:percentage_of_actors, ratio_f) with an invalid ratio raises an exception" do 33 | assert_raise FunWithFlags.Gate.InvalidTargetError, fn() -> Gate.new(:percentage_of_actors, 0.0) end 34 | assert_raise FunWithFlags.Gate.InvalidTargetError, fn() -> Gate.new(:percentage_of_actors, 1.0) end 35 | end 36 | 37 | test "new(:actor, actor, true|false) returns a new Actor Gate" do 38 | user = %TestUser{id: 234, email: "pineapple@pine.apple.com" } 39 | 40 | assert %Gate{type: :actor, for: "user:234", enabled: true} = Gate.new(:actor, user, true) 41 | assert %Gate{type: :actor, for: "user:234", enabled: false} = Gate.new(:actor, user, false) 42 | 43 | map = %{actor_id: "hello", foo: "bar"} 44 | assert %Gate{type: :actor, for: "map:hello", enabled: true} = Gate.new(:actor, map, true) 45 | assert %Gate{type: :actor, for: "map:hello", enabled: false} = Gate.new(:actor, map, false) 46 | end 47 | 48 | test "new(:actor, ...) with a non-actor raises an exception" do 49 | assert_raise Protocol.UndefinedError, fn() -> 50 | Gate.new(:actor, :not_a_valid_actor, true) 51 | end 52 | end 53 | 54 | test "new(:group, group_name, true|false) returns a new Group Gate, with atoms" do 55 | assert %Gate{type: :group, for: "plants", enabled: true} = Gate.new(:group, :plants, true) 56 | assert %Gate{type: :group, for: "animals", enabled: false} = Gate.new(:group, :animals, false) 57 | end 58 | 59 | test "new(:group, group_name, true|false) returns a new Group Gate, with binaries" do 60 | assert %Gate{type: :group, for: "plants", enabled: true} = Gate.new(:group, "plants", true) 61 | assert %Gate{type: :group, for: "animals", enabled: false} = Gate.new(:group, "animals", false) 62 | end 63 | 64 | test "new(:group, ...) with a name that is not an atom or a binary raises an exception" do 65 | assert_raise FunWithFlags.Gate.InvalidGroupNameError, fn() -> Gate.new(:group, 123, true) end 66 | assert_raise FunWithFlags.Gate.InvalidGroupNameError, fn() -> Gate.new(:group, %{a: "map"}, false) end 67 | end 68 | end 69 | 70 | 71 | describe "enabled?(gate), for boolean gates" do 72 | test "without extra arguments, it simply checks the value of the gate" do 73 | gate = %Gate{type: :boolean, for: nil, enabled: true} 74 | assert {:ok, true} = Gate.enabled?(gate) 75 | 76 | gate = %Gate{type: :boolean, for: nil, enabled: false} 77 | assert {:ok, false} = Gate.enabled?(gate) 78 | end 79 | 80 | test "an optional [for: something] argument is ignored" do 81 | gandalf = %TestUser{id: 42, email: "gandalf@travels.com" } 82 | 83 | gate = %Gate{type: :boolean, for: nil, enabled: true} 84 | assert {:ok, true} = Gate.enabled?(gate, for: gandalf) 85 | 86 | gate = %Gate{type: :boolean, for: nil, enabled: false} 87 | assert {:ok, false} = Gate.enabled?(gate, for: gandalf) 88 | end 89 | end 90 | 91 | 92 | describe "enabled?(gate, for: actor)" do 93 | setup do 94 | chip = %TestUser{id: 1, email: "chip@rescuerangers.com" } 95 | dale = %TestUser{id: 2, email: "dale@rescuerangers.com" } 96 | gate = Gate.new(:actor, chip, true) 97 | {:ok, gate: gate, chip: chip, dale: dale} 98 | end 99 | 100 | test "without the [for: actor] option it raises an exception", %{gate: gate} do 101 | assert_raise FunctionClauseError, fn() -> 102 | Gate.enabled?(gate) 103 | end 104 | end 105 | 106 | test "passing a nil actor option raises an exception (just because nil is not an Actor)", %{gate: gate} do 107 | assert_raise Protocol.UndefinedError, fn() -> 108 | Gate.enabled?(gate, for: nil) 109 | end 110 | end 111 | 112 | test "for an enabled gate, it returns {:ok, true} for the associated 113 | actor and :ignore for other actors", %{gate: gate, chip: chip, dale: dale} do 114 | assert {:ok, true} = Gate.enabled?(gate, for: chip) 115 | assert :ignore = Gate.enabled?(gate, for: dale) 116 | end 117 | 118 | test "for a disabled gate, it returns {:ok, false} for the associated 119 | actor and :ignore for other actors", %{gate: gate, chip: chip, dale: dale} do 120 | gate = %Gate{gate | enabled: false} 121 | 122 | assert {:ok, false} = Gate.enabled?(gate, for: chip) 123 | assert :ignore = Gate.enabled?(gate, for: dale) 124 | end 125 | end 126 | 127 | 128 | describe "enabled?(gate, for: item), for Group gates" do 129 | setup do 130 | bruce = %TestUser{id: 1, email: "bruce@wayne.com"} 131 | clark = %TestUser{id: 2, email: "clark@kent.com"} 132 | gate = Gate.new(:group, :admin, true) 133 | {:ok, gate: gate, bruce: bruce, clark: clark} 134 | end 135 | 136 | test "without the [for: item] option it raises an exception", %{gate: gate} do 137 | assert_raise FunctionClauseError, fn() -> 138 | Gate.enabled?(gate) 139 | end 140 | end 141 | 142 | test "for an enabled gate, it returns {:ok, true} for items that belongs to the group 143 | and :ignore for the others", %{gate: gate, bruce: bruce, clark: clark} do 144 | assert {:ok, true} = Gate.enabled?(gate, for: bruce) 145 | assert :ignore = Gate.enabled?(gate, for: clark) 146 | end 147 | 148 | test "for a disabled gate, it returns {:ok, false} for items that belongs to the group 149 | and :ignore for the others", %{gate: gate, bruce: bruce, clark: clark} do 150 | gate = %Gate{gate | enabled: false} 151 | 152 | assert {:ok, false} = Gate.enabled?(gate, for: bruce) 153 | assert :ignore = Gate.enabled?(gate, for: clark) 154 | end 155 | 156 | 157 | test "it always returns :ignore for items that do not implement the Group protocol 158 | (because of the fallback to Any)", %{gate: gate} do 159 | assert :ignore = Gate.enabled?(gate, for: nil) 160 | assert :ignore = Gate.enabled?(gate, for: "pompelmo") 161 | assert :ignore = Gate.enabled?(gate, for: [1,2,3]) 162 | assert :ignore = Gate.enabled?(gate, for: {:a, "tuple"}) 163 | end 164 | end 165 | 166 | 167 | describe "enabled?(gate), for PercentageOfTime gates" do 168 | @tag :flaky 169 | test "without extra arguments, it simply checks the value of the gate" do 170 | gate = %Gate{type: :percentage_of_time, for: 0.999999999, enabled: true} 171 | assert {:ok, true} = Gate.enabled?(gate) 172 | 173 | gate = %Gate{type: :percentage_of_time, for: 0.000000001, enabled: true} 174 | assert {:ok, false} = Gate.enabled?(gate) 175 | end 176 | 177 | @tag :flaky 178 | test "an optional [for: something] argument is ignored" do 179 | gandalf = %TestUser{id: 42, email: "gandalf@travels.com" } 180 | 181 | gate = %Gate{type: :percentage_of_time, for: 0.999999999, enabled: true} 182 | assert {:ok, true} = Gate.enabled?(gate, for: gandalf) 183 | 184 | gate = %Gate{type: :percentage_of_time, for: 0.000000001, enabled: true} 185 | assert {:ok, false} = Gate.enabled?(gate, for: gandalf) 186 | end 187 | end 188 | 189 | 190 | describe "enabled?(gate, for: actor, flag_name: atom), for PercentageOfActors gates" do 191 | setup do 192 | gate = %Gate{type: :percentage_of_actors, for: 0.5, enabled: true} 193 | gandalf = %TestUser{id: 42, email: "gandalf@travels.com" } # with coconut: 0.7024383544921875 194 | magneto = %TestUser{id: 2, email: "magneto@mutants.com" } # with coconut: 0.4715118408203125 195 | {:ok, gate: gate, gandalf: gandalf, magneto: magneto} 196 | end 197 | 198 | test "without the [for: actor] option it raises an exception", %{gate: gate} do 199 | assert_raise KeyError, fn() -> 200 | Gate.enabled?(gate, flag_name: :coconut) 201 | end 202 | end 203 | 204 | test "without the [flag_name: atom] option it raises an exception", %{gate: gate, gandalf: gandalf} do 205 | assert_raise KeyError, fn() -> 206 | Gate.enabled?(gate, for: gandalf) 207 | end 208 | end 209 | 210 | test "passing a nil actor option raises an exception (just because nil is not an Actor)", %{gate: gate} do 211 | assert_raise Protocol.UndefinedError, fn() -> 212 | Gate.enabled?(gate, for: nil, flag_name: :coconut) 213 | end 214 | end 215 | 216 | test "for actor-flags pairs with a score lower than the gate percentage it returns {:ok, true}, if the score is higher it returns {:ok, false}", 217 | %{gate: gate, gandalf: gandalf, magneto: magneto} do 218 | 219 | gate = %{gate | for: 0.5} 220 | assert {:ok, false} = Gate.enabled?(gate, for: gandalf, flag_name: :coconut) 221 | assert {:ok, true} = Gate.enabled?(gate, for: magneto, flag_name: :coconut) 222 | 223 | gate = %{gate | for: 0.46} 224 | assert {:ok, false} = Gate.enabled?(gate, for: gandalf, flag_name: :coconut) 225 | assert {:ok, false} = Gate.enabled?(gate, for: magneto, flag_name: :coconut) 226 | 227 | gate = %{gate | for: 0.703} 228 | assert {:ok, true} = Gate.enabled?(gate, for: gandalf, flag_name: :coconut) 229 | assert {:ok, true} = Gate.enabled?(gate, for: magneto, flag_name: :coconut) 230 | end 231 | end 232 | 233 | 234 | describe "boolean?(gate)" do 235 | test "with a boolean gate it returns true" do 236 | gate = %Gate{type: :boolean, for: nil, enabled: false} 237 | assert Gate.boolean?(gate) 238 | end 239 | 240 | test "with an actor gate it returns false" do 241 | gate = %Gate{type: :actor, for: "salami", enabled: false} 242 | refute Gate.boolean?(gate) 243 | end 244 | 245 | test "with a group gate it returns false" do 246 | gate = %Gate{type: :group, for: "prosciutto", enabled: false} 247 | refute Gate.boolean?(gate) 248 | end 249 | 250 | test "with a percentage_of_time gate it returns false" do 251 | gate = %Gate{type: :percentage_of_time, for: 0.5, enabled: true} 252 | refute Gate.boolean?(gate) 253 | end 254 | 255 | test "with a percentage_of_actors gate it returns false" do 256 | gate = %Gate{type: :percentage_of_actors, for: 0.5, enabled: true} 257 | refute Gate.boolean?(gate) 258 | end 259 | end 260 | 261 | describe "actor?(gate)" do 262 | test "with an actor gate it returns true" do 263 | gate = %Gate{type: :actor, for: "salami", enabled: false} 264 | assert Gate.actor?(gate) 265 | end 266 | 267 | test "with a boolean gate it returns false" do 268 | gate = %Gate{type: :boolean, for: nil, enabled: false} 269 | refute Gate.actor?(gate) 270 | end 271 | 272 | test "with a group gate it returns false" do 273 | gate = %Gate{type: :group, for: "prosciutto", enabled: false} 274 | refute Gate.actor?(gate) 275 | end 276 | 277 | test "with a percentage_of_time gate it returns false" do 278 | gate = %Gate{type: :percentage_of_time, for: 0.5, enabled: true} 279 | refute Gate.actor?(gate) 280 | end 281 | 282 | test "with a percentage_of_actors gate it returns false" do 283 | gate = %Gate{type: :percentage_of_actors, for: 0.5, enabled: true} 284 | refute Gate.actor?(gate) 285 | end 286 | end 287 | 288 | describe "group?(gate)" do 289 | test "with a group gate it returns true" do 290 | gate = %Gate{type: :group, for: "prosciutto", enabled: false} 291 | assert Gate.group?(gate) 292 | end 293 | 294 | test "with a boolean gate it returns false" do 295 | gate = %Gate{type: :boolean, for: nil, enabled: false} 296 | refute Gate.group?(gate) 297 | end 298 | 299 | test "with an actor gate it returns false" do 300 | gate = %Gate{type: :actor, for: "salami", enabled: false} 301 | refute Gate.group?(gate) 302 | end 303 | 304 | test "with a percentage_of_time gate it returns false" do 305 | gate = %Gate{type: :percentage_of_time, for: 0.5, enabled: true} 306 | refute Gate.group?(gate) 307 | end 308 | 309 | test "with a percentage_of_actors gate it returns false" do 310 | gate = %Gate{type: :percentage_of_actors, for: 0.5, enabled: true} 311 | refute Gate.group?(gate) 312 | end 313 | end 314 | 315 | describe "percentage_of_time?(gate)" do 316 | test "with a percentage_of_time gate it returns true" do 317 | gate = %Gate{type: :percentage_of_time, for: 0.5, enabled: true} 318 | assert Gate.percentage_of_time?(gate) 319 | end 320 | 321 | test "with a percentage_of_actors gate it returns false" do 322 | gate = %Gate{type: :percentage_of_actors, for: 0.5, enabled: true} 323 | refute Gate.percentage_of_time?(gate) 324 | end 325 | 326 | test "with a boolean gate it returns false" do 327 | gate = %Gate{type: :boolean, for: nil, enabled: false} 328 | refute Gate.percentage_of_time?(gate) 329 | end 330 | 331 | test "with an actor gate it returns false" do 332 | gate = %Gate{type: :actor, for: "salami", enabled: false} 333 | refute Gate.percentage_of_time?(gate) 334 | end 335 | 336 | test "with a group gate it returns false" do 337 | gate = %Gate{type: :group, for: "prosciutto", enabled: false} 338 | refute Gate.percentage_of_time?(gate) 339 | end 340 | end 341 | 342 | describe "percentage_of_actors?(gate)" do 343 | test "with a percentage_of_actors gate it returns true" do 344 | gate = %Gate{type: :percentage_of_actors, for: 0.5, enabled: true} 345 | assert Gate.percentage_of_actors?(gate) 346 | end 347 | 348 | test "with a percentage_of_time gate it returns false" do 349 | gate = %Gate{type: :percentage_of_time, for: 0.5, enabled: true} 350 | refute Gate.percentage_of_actors?(gate) 351 | end 352 | 353 | test "with a boolean gate it returns false" do 354 | gate = %Gate{type: :boolean, for: nil, enabled: false} 355 | refute Gate.percentage_of_actors?(gate) 356 | end 357 | 358 | test "with an actor gate it returns false" do 359 | gate = %Gate{type: :actor, for: "salami", enabled: false} 360 | refute Gate.percentage_of_actors?(gate) 361 | end 362 | 363 | test "with a group gate it returns false" do 364 | gate = %Gate{type: :group, for: "prosciutto", enabled: false} 365 | refute Gate.percentage_of_actors?(gate) 366 | end 367 | end 368 | end 369 | -------------------------------------------------------------------------------- /test/fun_with_flags/notifications/phoenix_pubsub_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.Notifications.PhoenixPubSubTest do 2 | use FunWithFlags.TestCase, async: false 3 | import FunWithFlags.TestUtils 4 | import Mock 5 | 6 | alias FunWithFlags.Notifications.PhoenixPubSub, as: PubSub 7 | 8 | @moduletag :phoenix_pubsub 9 | 10 | describe "unique_id()" do 11 | test "it returns a string" do 12 | assert is_binary(PubSub.unique_id()) 13 | end 14 | 15 | test "it always returns the same ID for the GenServer" do 16 | assert PubSub.unique_id() == PubSub.unique_id() 17 | end 18 | 19 | test "the ID changes if the GenServer restarts" do 20 | a = PubSub.unique_id() 21 | kill_process(PubSub) 22 | :timer.sleep(1) 23 | refute a == PubSub.unique_id() 24 | end 25 | end 26 | 27 | describe "subscribed?()" do 28 | test "it returns true if the GenServer is subscribed to the pubsub topic" do 29 | assert :ok = GenServer.call(PubSub, {:test_helper_set_subscription_status, :subscribed}) 30 | assert true = PubSub.subscribed?() 31 | 32 | # Kill the process to restore its normal state. 33 | kill_process(PubSub) 34 | wait_until_pubsub_is_ready!() 35 | end 36 | 37 | test "it returns false if the GenServer is not subscribed to the pubsub topic" do 38 | assert :ok = GenServer.call(PubSub, {:test_helper_set_subscription_status, :unsubscribed}) 39 | assert false == PubSub.subscribed?() 40 | 41 | # Kill the process to restore its normal state. 42 | kill_process(PubSub) 43 | wait_until_pubsub_is_ready!() 44 | end 45 | end 46 | 47 | describe "publish_change(flag_name)" do 48 | setup do 49 | wait_until_pubsub_is_ready!() 50 | 51 | {:ok, name: unique_atom()} 52 | end 53 | 54 | test "returns a PID (it starts a Task)", %{name: name} do 55 | assert {:ok, pid} = PubSub.publish_change(name) 56 | assert is_pid(pid) 57 | end 58 | 59 | test "publishes a notification to Phoenix.PubSub", %{name: name} do 60 | u_id = PubSub.unique_id() 61 | 62 | with_mocks([ 63 | {Phoenix.PubSub, [:passthrough], []} 64 | ]) do 65 | assert {:ok, _pid} = PubSub.publish_change(name) 66 | 67 | assert_with_retries(fn -> 68 | assert called( 69 | Phoenix.PubSub.broadcast!( 70 | :fwf_test, 71 | "fun_with_flags_changes", 72 | {:fwf_changes, {:updated, name, u_id}} 73 | ) 74 | ) 75 | end) 76 | end 77 | end 78 | 79 | test "causes other subscribers to receive a Phoenix.PubSub notification", %{name: name} do 80 | channel = "fun_with_flags_changes" 81 | u_id = PubSub.unique_id() 82 | 83 | :ok = Phoenix.PubSub.subscribe(:fwf_test, channel) # implicit self 84 | 85 | assert {:ok, _pid} = PubSub.publish_change(name) 86 | 87 | payload = {:updated, name, u_id} 88 | 89 | receive do 90 | {:fwf_changes, ^payload} -> :ok 91 | after 92 | 500 -> flunk "Haven't received any message after 0.5 seconds" 93 | end 94 | 95 | # cleanup 96 | 97 | :ok = Phoenix.PubSub.unsubscribe(:fwf_test, channel) # implicit self 98 | end 99 | end 100 | 101 | 102 | test "it receives messages if something is published on Phoenix.PubSub" do 103 | u_id = PubSub.unique_id() 104 | client = FunWithFlags.Config.pubsub_client() 105 | channel = "fun_with_flags_changes" 106 | message = {:fwf_changes, {:updated, :foobar, u_id}} 107 | 108 | wait_until_pubsub_is_ready!() 109 | 110 | with_mock(PubSub, [:passthrough], []) do 111 | Phoenix.PubSub.broadcast!(client, channel, message) 112 | 113 | assert_with_retries(fn -> 114 | assert called( 115 | PubSub.handle_info(message, {u_id, :subscribed}) 116 | ) 117 | end) 118 | end 119 | end 120 | 121 | 122 | describe "integration: message handling" do 123 | alias FunWithFlags.{Store, Config} 124 | 125 | 126 | test "when the message comes from this same process, it is ignored" do 127 | u_id = PubSub.unique_id() 128 | client = FunWithFlags.Config.pubsub_client() 129 | channel = "fun_with_flags_changes" 130 | message = {:fwf_changes, {:updated, :a_flag_name, u_id}} 131 | 132 | wait_until_pubsub_is_ready!() 133 | 134 | with_mock(Store, [:passthrough], []) do 135 | Phoenix.PubSub.broadcast!(client, channel, message) 136 | 137 | assert_with_retries(fn -> 138 | refute called(Store.reload(:a_flag_name)) 139 | end) 140 | end 141 | end 142 | 143 | 144 | test "when the message comes from another process, it reloads the flag" do 145 | another_u_id = Config.build_unique_id() 146 | refute another_u_id == PubSub.unique_id() 147 | 148 | client = FunWithFlags.Config.pubsub_client() 149 | channel = "fun_with_flags_changes" 150 | message = {:fwf_changes, {:updated, :a_flag_name, another_u_id}} 151 | 152 | wait_until_pubsub_is_ready!() 153 | 154 | with_mock(Store, [:passthrough], []) do 155 | Phoenix.PubSub.broadcast!(client, channel, message) 156 | 157 | assert_with_retries(fn -> 158 | assert called(Store.reload(:a_flag_name)) 159 | end) 160 | end 161 | end 162 | end 163 | 164 | 165 | describe "integration: side effects" do 166 | alias FunWithFlags.Store.Cache 167 | alias FunWithFlags.{Store, Config, Gate, Flag} 168 | 169 | setup do 170 | name = unique_atom() 171 | gate = %Gate{type: :boolean, enabled: true} 172 | stored_flag = %Flag{name: name, gates: [gate]} 173 | 174 | gate2 = %Gate{type: :boolean, enabled: false} 175 | cached_flag = %Flag{name: name, gates: [gate2]} 176 | 177 | {:ok, ^stored_flag} = Config.persistence_adapter.put(name, gate) 178 | assert_with_retries(fn -> 179 | {:ok, ^cached_flag} = Cache.put(cached_flag) 180 | end) 181 | 182 | assert {:ok, ^stored_flag} = Config.persistence_adapter.get(name) 183 | assert {:ok, ^cached_flag} = Cache.get(name) 184 | 185 | wait_until_pubsub_is_ready!() 186 | 187 | {:ok, name: name, stored_flag: stored_flag, cached_flag: cached_flag} 188 | end 189 | 190 | # This should be in `setup` but in there it produces a compiler warning because 191 | # the two variables will never match (duh). 192 | test "verify test setup", %{cached_flag: cached_flag, stored_flag: stored_flag} do 193 | refute match? ^stored_flag, cached_flag 194 | end 195 | 196 | 197 | test "when the message comes from this same process, the Cached value is not changed", %{name: name, cached_flag: cached_flag} do 198 | u_id = PubSub.unique_id() 199 | client = FunWithFlags.Config.pubsub_client() 200 | channel = "fun_with_flags_changes" 201 | message = {:fwf_changes, {:updated, name, u_id}} 202 | 203 | Phoenix.PubSub.broadcast!(client, channel, message) 204 | 205 | assert_with_retries(fn -> 206 | assert {:ok, ^cached_flag} = Cache.get(name) 207 | end) 208 | end 209 | 210 | 211 | test "when the message comes from another process, the Cached value is reloaded", %{name: name, cached_flag: cached_flag, stored_flag: stored_flag} do 212 | another_u_id = Config.build_unique_id() 213 | refute another_u_id == PubSub.unique_id() 214 | 215 | client = FunWithFlags.Config.pubsub_client() 216 | channel = "fun_with_flags_changes" 217 | message = {:fwf_changes, {:updated, name, another_u_id}} 218 | 219 | assert {:ok, ^cached_flag} = Cache.get(name) 220 | Phoenix.PubSub.broadcast!(client, channel, message) 221 | 222 | assert_with_retries(fn -> 223 | assert {:ok, ^stored_flag} = Cache.get(name) 224 | end) 225 | end 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /test/fun_with_flags/notifications/redis_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.Notifications.RedisTest do 2 | use FunWithFlags.TestCase, async: false 3 | import FunWithFlags.TestUtils 4 | import Mock 5 | 6 | alias FunWithFlags.Notifications.Redis, as: NotifiRedis 7 | 8 | @moduletag :redis_pubsub 9 | 10 | describe "worker_spec" do 11 | setup do 12 | # Before each test ensure that the initial config is the default one. 13 | ensure_default_redis_config_in_app_env() 14 | 15 | # Cleanup 16 | on_exit(&reset_app_env_to_default_redis_config/0) 17 | :ok 18 | end 19 | 20 | test "when the Redis config is a URL string" do 21 | url = "redis:://1.2.3.4:5678/42" 22 | configure_redis_with(url) 23 | 24 | expected = %{ 25 | id: FunWithFlags.Notifications.Redis, 26 | start: { 27 | FunWithFlags.Notifications.Redis, 28 | :start_link, 29 | [ 30 | {url, name: :fun_with_flags_notifications, sync_connect: false} 31 | ] 32 | }, 33 | type: :worker, 34 | restart: :permanent 35 | } 36 | 37 | assert ^expected = NotifiRedis.worker_spec() 38 | end 39 | 40 | test "when the Redis config is a {URL, opts} tuple" do 41 | url = "redis:://1.2.3.4:5678/42" 42 | opts = [socket_opts: [:inet6]] 43 | configure_redis_with({url, opts}) 44 | 45 | expected = %{ 46 | id: FunWithFlags.Notifications.Redis, 47 | start: { 48 | FunWithFlags.Notifications.Redis, 49 | :start_link, 50 | [ 51 | { 52 | url, 53 | [ 54 | socket_opts: [:inet6], 55 | name: :fun_with_flags_notifications, 56 | sync_connect: false 57 | ] 58 | } 59 | ] 60 | }, 61 | type: :worker, 62 | restart: :permanent 63 | } 64 | 65 | assert ^expected = NotifiRedis.worker_spec() 66 | end 67 | 68 | test "when the Redis config is keyword list" do 69 | kw = [database: 100, port: 2000] 70 | configure_redis_with(kw) 71 | 72 | expected = %{ 73 | id: FunWithFlags.Notifications.Redis, 74 | start: { 75 | FunWithFlags.Notifications.Redis, 76 | :start_link, 77 | [ 78 | [ 79 | host: "localhost", 80 | database: 100, 81 | port: 2000, 82 | name: :fun_with_flags_notifications, 83 | sync_connect: false 84 | ] 85 | ] 86 | }, 87 | type: :worker, 88 | restart: :permanent 89 | } 90 | 91 | assert ^expected = NotifiRedis.worker_spec() 92 | end 93 | end 94 | 95 | describe "unique_id()" do 96 | test "it returns a string" do 97 | assert is_binary(NotifiRedis.unique_id()) 98 | end 99 | 100 | test "it always returns the same ID for the GenServer" do 101 | assert NotifiRedis.unique_id() == NotifiRedis.unique_id() 102 | end 103 | 104 | test "the ID changes if the GenServer restarts" do 105 | a = NotifiRedis.unique_id() 106 | kill_process(NotifiRedis) 107 | :timer.sleep(1) 108 | refute a == NotifiRedis.unique_id() 109 | end 110 | end 111 | 112 | 113 | describe "payload_for(flag_name)" do 114 | test "it returns a 2 item list" do 115 | flag_name = unique_atom() 116 | 117 | output = NotifiRedis.payload_for(flag_name) 118 | assert is_list(output) 119 | assert 2 == length(output) 120 | end 121 | 122 | test "the first one is the channel name, the second one is the flag 123 | name plus the unique_id for the GenServer" do 124 | flag_name = unique_atom() 125 | u_id = NotifiRedis.unique_id() 126 | channel = "fun_with_flags_changes" 127 | 128 | assert [^channel, << blob :: binary >>] = NotifiRedis.payload_for(flag_name) 129 | assert [^u_id, string] = String.split(blob, ":") 130 | assert ^flag_name = String.to_atom(string) 131 | end 132 | end 133 | 134 | 135 | describe "publish_change(flag_name)" do 136 | setup do 137 | {:ok, name: unique_atom()} 138 | end 139 | 140 | test "returns a PID (it starts a Task)", %{name: name} do 141 | assert {:ok, pid} = NotifiRedis.publish_change(name) 142 | assert is_pid(pid) 143 | end 144 | 145 | test "publishes a notification to Redis", %{name: name} do 146 | u_id = NotifiRedis.unique_id() 147 | 148 | with_mocks([ 149 | {Redix, [:passthrough], []} 150 | ]) do 151 | assert {:ok, _pid} = NotifiRedis.publish_change(name) 152 | :timer.sleep(10) 153 | 154 | assert called( 155 | Redix.command( 156 | FunWithFlags.Store.Persistent.Redis, 157 | ["PUBLISH", "fun_with_flags_changes", "#{u_id}:#{name}"] 158 | ) 159 | ) 160 | end 161 | end 162 | 163 | test "causes other subscribers to receive a Redis notification", %{name: name} do 164 | channel = "fun_with_flags_changes" 165 | u_id = NotifiRedis.unique_id() 166 | 167 | {:ok, receiver} = Redix.PubSub.start_link(Keyword.merge(FunWithFlags.Config.redis_config, [sync_connect: true])) 168 | {:ok, ref} = Redix.PubSub.subscribe(receiver, channel, self()) 169 | 170 | receive do 171 | {:redix_pubsub, ^receiver, ^ref, :subscribed, %{channel: ^channel}} -> :ok 172 | after 173 | 500 -> flunk "Subscribe didn't work" 174 | end 175 | 176 | assert {:ok, _pid} = NotifiRedis.publish_change(name) 177 | 178 | payload = "#{u_id}:#{to_string(name)}" 179 | 180 | receive do 181 | {:redix_pubsub, ^receiver, ^ref, :message, %{channel: ^channel, payload: ^payload}} -> :ok 182 | after 183 | 500 -> flunk "Haven't received any message after 0.5 seconds" 184 | end 185 | 186 | # cleanup 187 | 188 | Redix.PubSub.unsubscribe(receiver, channel, self()) 189 | 190 | receive do 191 | {:redix_pubsub, ^receiver, ^ref, :unsubscribed, %{channel: ^channel}} -> :ok 192 | after 193 | 500 -> flunk "Unsubscribe didn't work" 194 | end 195 | 196 | Process.exit(receiver, :kill) 197 | end 198 | end 199 | 200 | 201 | test "it receives messages if something is published on Redis" do 202 | alias FunWithFlags.Store.Persistent.Redis, as: PersiRedis 203 | 204 | u_id = NotifiRedis.unique_id() 205 | channel = "fun_with_flags_changes" 206 | pubsub_receiver_pid = GenServer.whereis(:fun_with_flags_notifications) 207 | message = "foobar" 208 | 209 | {^u_id, ref} = :sys.get_state(FunWithFlags.Notifications.Redis) 210 | 211 | with_mock(NotifiRedis, [:passthrough], []) do 212 | Redix.command(PersiRedis, ["PUBLISH", channel, message]) 213 | :timer.sleep(1) 214 | 215 | assert called( 216 | NotifiRedis.handle_info( 217 | { 218 | :redix_pubsub, 219 | pubsub_receiver_pid, 220 | ref, 221 | :message, 222 | %{channel: channel, payload: message} 223 | }, 224 | {u_id, ref} 225 | ) 226 | ) 227 | end 228 | end 229 | 230 | 231 | describe "integration: message handling" do 232 | alias FunWithFlags.Store.Persistent.Redis, as: PersiRedis 233 | alias FunWithFlags.{Store, Config} 234 | 235 | 236 | test "when the message is not valid, it is ignored" do 237 | channel = "fun_with_flags_changes" 238 | 239 | with_mock(Store, [:passthrough], []) do 240 | Redix.command(PersiRedis, ["PUBLISH", channel, "foobar"]) 241 | :timer.sleep(30) 242 | refute called(Store.reload(:foobar)) 243 | end 244 | end 245 | 246 | 247 | test "when the message comes from this same process, it is ignored" do 248 | u_id = NotifiRedis.unique_id() 249 | channel = "fun_with_flags_changes" 250 | message = "#{u_id}:foobar" 251 | 252 | with_mock(Store, [:passthrough], []) do 253 | Redix.command(PersiRedis, ["PUBLISH", channel, message]) 254 | :timer.sleep(30) 255 | refute called(Store.reload(:foobar)) 256 | end 257 | end 258 | 259 | 260 | test "when the message comes from another process, it reloads the flag" do 261 | another_u_id = Config.build_unique_id() 262 | refute another_u_id == NotifiRedis.unique_id() 263 | 264 | channel = "fun_with_flags_changes" 265 | message = "#{another_u_id}:foobar" 266 | 267 | with_mock(Store, [:passthrough], []) do 268 | Redix.command(PersiRedis, ["PUBLISH", channel, message]) 269 | :timer.sleep(30) 270 | assert called(Store.reload(:foobar)) 271 | end 272 | end 273 | end 274 | 275 | 276 | describe "integration: side effects" do 277 | alias FunWithFlags.Store.Cache 278 | alias FunWithFlags.Store.Persistent.Redis, as: PersiRedis 279 | alias FunWithFlags.{Store, Config, Gate, Flag} 280 | 281 | setup do 282 | name = unique_atom() 283 | gate = %Gate{type: :boolean, enabled: true} 284 | stored_flag = %Flag{name: name, gates: [gate]} 285 | 286 | gate2 = %Gate{type: :boolean, enabled: false} 287 | cached_flag = %Flag{name: name, gates: [gate2]} 288 | 289 | {:ok, ^stored_flag} = PersiRedis.put(name, gate) 290 | :timer.sleep(10) 291 | {:ok, ^cached_flag} = Cache.put(cached_flag) 292 | 293 | assert {:ok, ^stored_flag} = PersiRedis.get(name) 294 | assert {:ok, ^cached_flag} = Cache.get(name) 295 | 296 | {:ok, name: name, stored_flag: stored_flag, cached_flag: cached_flag} 297 | end 298 | 299 | # This should be in `setup` but in there it produces a compiler warning because 300 | # the two variables will never match (duh). 301 | test "verify test setup", %{cached_flag: cached_flag, stored_flag: stored_flag} do 302 | refute match? ^stored_flag, cached_flag 303 | end 304 | 305 | 306 | test "when the message is not valid, the Cached value is not changed", %{name: name, cached_flag: cached_flag} do 307 | channel = "fun_with_flags_changes" 308 | 309 | Redix.command(PersiRedis, ["PUBLISH", channel, to_string(name)]) 310 | :timer.sleep(30) 311 | assert {:ok, ^cached_flag} = Cache.get(name) 312 | end 313 | 314 | 315 | test "when the message comes from this same process, the Cached value is not changed", %{name: name, cached_flag: cached_flag} do 316 | u_id = NotifiRedis.unique_id() 317 | channel = "fun_with_flags_changes" 318 | message = "#{u_id}:#{to_string(name)}" 319 | 320 | Redix.command(PersiRedis, ["PUBLISH", channel, message]) 321 | :timer.sleep(30) 322 | assert {:ok, ^cached_flag} = Cache.get(name) 323 | end 324 | 325 | 326 | test "when the message comes from another process, the Cached value is reloaded", %{name: name, cached_flag: cached_flag, stored_flag: stored_flag} do 327 | another_u_id = Config.build_unique_id() 328 | refute another_u_id == NotifiRedis.unique_id() 329 | 330 | channel = "fun_with_flags_changes" 331 | message = "#{another_u_id}:#{to_string(name)}" 332 | 333 | assert {:ok, ^cached_flag} = Cache.get(name) 334 | Redix.command(PersiRedis, ["PUBLISH", channel, message]) 335 | :timer.sleep(30) 336 | assert {:ok, ^stored_flag} = Cache.get(name) 337 | end 338 | end 339 | end 340 | -------------------------------------------------------------------------------- /test/fun_with_flags/protocols/actor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.ActorTest do 2 | use FunWithFlags.TestCase, async: true 3 | 4 | alias FunWithFlags.{Actor, TestUser} 5 | 6 | setup do 7 | user = %TestUser{id: 1, email: "bruce@wayne.com"} 8 | {:ok, user: user} 9 | end 10 | 11 | test "id(actor) returns always the same string for the same actor", %{user: user} do 12 | assert "user:1" = Actor.id(user) 13 | assert "user:1" = Actor.id(user) 14 | assert "user:1" = Actor.id(user) 15 | end 16 | 17 | test "different actors produce different strings", %{user: user} do 18 | user2 = %TestUser{id: 2, email: "alfred@wayne.com"} 19 | user3 = %TestUser{id: 3, email: "dick@wayne.com"} 20 | 21 | assert "user:1" = Actor.id(user) 22 | assert "user:2" = Actor.id(user2) 23 | assert "user:3" = Actor.id(user3) 24 | end 25 | 26 | describe "anything can be an actor, e.g. Maps" do 27 | test "map with an id" do 28 | map = %{actor_id: 42} 29 | assert "map:42" = Actor.id(map) 30 | end 31 | 32 | test "map without an id" do 33 | map = %{foo: 42} 34 | assert "map:F0107BBFB094FC97376CFC461E33ABF5" = Actor.id(map) 35 | end 36 | end 37 | 38 | describe "score(actor, flag_name), auto-delegated to a private worker module" do 39 | import FunWithFlags.TestUtils 40 | 41 | test "it returns a float" do 42 | map = %{actor_id: 42} 43 | assert is_float(Actor.Percentage.score(map, :foobar)) 44 | end 45 | 46 | test "the float is between 0.0 and 1.0" do 47 | for _ <- (0..100) do 48 | map = %{actor_id: random_string()} 49 | score = Actor.Percentage.score(map, :foobar) 50 | assert score <= 1.0 51 | assert score >= 0.0 52 | end 53 | end 54 | 55 | test "the same actor-flag combination always produces the same score", %{user: user} do 56 | score = Actor.Percentage.score(user, :foobar) 57 | 58 | for _ <- (1..100) do 59 | assert ^score = Actor.Percentage.score(user, :foobar) 60 | end 61 | end 62 | 63 | test "different actors produce different scores", %{user: user} do 64 | user2 = %TestUser{id: 2, email: "alfred@wayne.com"} 65 | user3 = %TestUser{id: 3, email: "dick@wayne.com"} 66 | 67 | assert Actor.Percentage.score(user, :foobar) != Actor.Percentage.score(user2, :foobar) 68 | assert Actor.Percentage.score(user, :foobar) != Actor.Percentage.score(user3, :foobar) 69 | assert Actor.Percentage.score(user2, :foobar) != Actor.Percentage.score(user3, :foobar) 70 | end 71 | 72 | test "the same actor produces different scores with different flags", %{user: user} do 73 | assert Actor.Percentage.score(user, :one) != Actor.Percentage.score(user, :two) 74 | assert Actor.Percentage.score(user, :one) != Actor.Percentage.score(user, :three) 75 | assert Actor.Percentage.score(user, :two) != Actor.Percentage.score(user, :three) 76 | assert Actor.Percentage.score(user, :two) != Actor.Percentage.score(user, :four) 77 | assert Actor.Percentage.score(user, :four) != Actor.Percentage.score(user, :one) 78 | assert Actor.Percentage.score(user, :four) != Actor.Percentage.score(user, :three) 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/fun_with_flags/protocols/group_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.GroupTest do 2 | use FunWithFlags.TestCase, async: true 3 | 4 | alias FunWithFlags.{Group, TestUser} 5 | 6 | setup do 7 | user1 = %TestUser{id: 1, email: "bruce@wayne.com"} 8 | user2 = %TestUser{id: 2, email: "clark@kent.com"} 9 | {:ok, user1: user1, user2: user2} 10 | end 11 | 12 | test "in?(term, group_name) returns true if the term is in the group", %{user1: user1} do 13 | assert Group.in?(user1, :admin) 14 | end 15 | 16 | test "in?(term, group_name) returns false if the term is not in the group", %{user1: user1, user2: user2} do 17 | refute Group.in?(user2, :admin) 18 | refute Group.in?(user1, :undefined_name) 19 | end 20 | 21 | 22 | describe "anything can be an actor, e.g. Maps" do 23 | test "a map that declares the right group" do 24 | map = %{group: :pug_lovers} 25 | assert Group.in?(map, :pug_lovers) 26 | end 27 | 28 | test "a map that does NOT declare the right group" do 29 | map = %{group: :cat_owner} 30 | refute Group.in?(map, :pug_lovers) 31 | refute Group.in?(%{}, :pug_lovers) 32 | end 33 | end 34 | 35 | 36 | describe "the fallback Any implementation" do 37 | test "returns false for any argument, so that nothing is in any group" do 38 | refute Group.in? 123, :group_name 39 | refute Group.in? [], :group_name 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/fun_with_flags/store/cache_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.Store.CacheTest do 2 | use FunWithFlags.TestCase, async: false # mocks! 3 | import FunWithFlags.TestUtils 4 | import Mock 5 | 6 | alias FunWithFlags.Store.Cache 7 | alias FunWithFlags.{Timestamps, Config, Flag, Gate} 8 | 9 | setup do 10 | Cache.flush() 11 | name = unique_atom() 12 | gate = %Gate{type: :boolean, enabled: true} 13 | flag = %Flag{name: name, gates: [gate]} 14 | {:ok, name: name, flag: flag } 15 | end 16 | 17 | 18 | describe "put(%Flag{})" do 19 | test "put(%Flag{}) changes the cached flag", %{name: name, flag: flag} do 20 | assert {:miss, :not_found, nil} = Cache.get(name) 21 | Cache.put(flag) 22 | assert {:ok, ^flag} = Cache.get(name) 23 | 24 | flag2 = %Flag{ flag | gates: [Gate.new(:boolean, false)]} 25 | 26 | Cache.put(flag2) 27 | assert {:ok, ^flag2} = Cache.get(name) 28 | refute match? {:ok, ^flag}, Cache.get(name) 29 | end 30 | 31 | test "put(%Flag{}) returns the tuple {:ok, %Flag{}}", %{flag: flag} do 32 | assert {:ok, ^flag} = Cache.put(flag) 33 | end 34 | end 35 | 36 | 37 | describe "get(:flag_name)" do 38 | test "looking up an undefined flag returns {:miss, :not_found, nil}" do 39 | flag_name = unique_atom() 40 | assert {:miss, :not_found, nil} = Cache.get(flag_name) 41 | end 42 | 43 | test "looking up an already stored flag returns {:ok, %Flag{}}", %{name: name, flag: flag} do 44 | assert {:miss, :not_found, nil} = Cache.get(name) 45 | Cache.put(flag) 46 | assert {:ok, ^flag} = Cache.get(name) 47 | end 48 | 49 | 50 | test "looking up an expired flag returns {:miss, :expired, stale_value}", %{name: name, flag: flag} do 51 | assert {:miss, :not_found, nil} = Cache.get(name) 52 | 53 | {:ok, ^flag} = Cache.put(flag) 54 | assert {:ok, ^flag} = Cache.get(name) 55 | 56 | # 1 second before expiring 57 | timetravel by: (Config.cache_ttl - 1) do 58 | assert {:ok, ^flag} = Cache.get(name) 59 | end 60 | 61 | # 1 second after expiring 62 | timetravel by: (Config.cache_ttl + 1) do 63 | assert {:miss, :expired, ^flag} = Cache.get(name) 64 | 65 | Cache.flush 66 | assert {:miss, :not_found, nil} = Cache.get(name) 67 | end 68 | end 69 | end 70 | 71 | 72 | describe "integration: enable and disable with the top-level API" do 73 | setup do 74 | # can't use setup_all in here, but the on_exit should 75 | # be run only once because it's identifed by a common ref 76 | on_exit(:cache_integration_group, fn() -> clear_test_db() end) 77 | :ok 78 | end 79 | 80 | test "looking up a disabled flag" do 81 | name = unique_atom() 82 | FunWithFlags.disable(name) 83 | assert {:ok, %Flag{name: ^name, gates: [%Gate{type: :boolean, enabled: false}]}} = Cache.get(name) 84 | end 85 | 86 | test "looking up an enabled flag" do 87 | name = unique_atom() 88 | FunWithFlags.enable(name) 89 | assert {:ok, %Flag{name: ^name, gates: [%Gate{type: :boolean, enabled: true}]}} = Cache.get(name) 90 | end 91 | end 92 | 93 | 94 | test "flush() empties the cache", %{flag: flag} do 95 | Cache.put(flag) 96 | 97 | assert [{n, {f, t, x}}|_] = Cache.dump() 98 | assert is_atom(n) # name 99 | assert %Flag{} = f # value 100 | assert is_integer(t) # timestamp 101 | assert is_integer(x) # ttl 102 | 103 | Cache.flush() 104 | assert [] = Cache.dump() 105 | end 106 | 107 | 108 | test "dump() returns a List with the cached keys", %{name: name1, flag: flag1} do 109 | # because the test is faster than one second 110 | now = Timestamps.now 111 | ttl = Config.cache_ttl 112 | 113 | Cache.put(flag1) 114 | 115 | assert [{^name1, {^flag1, ^now, ^ttl}}|_] = Cache.dump() 116 | 117 | name2 = unique_atom() 118 | gate2 = %Gate{type: :boolean, enabled: true} 119 | flag2 = %Flag{name: name2, gates: [gate2]} 120 | Cache.put(flag2) 121 | 122 | name3 = unique_atom() 123 | gate3 = %Gate{type: :boolean, enabled: true} 124 | flag3 = %Flag{name: name3, gates: [gate3]} 125 | Cache.put(flag3) 126 | 127 | assert [{n, {f, t, x}}|_] = Cache.dump() 128 | assert is_atom(n) # name 129 | assert %Flag{} = f # value 130 | assert is_integer(t) # timestamp 131 | assert is_integer(x) # ttl 132 | 133 | kw = Cache.dump() 134 | assert is_list(kw) 135 | assert {^flag1, ^now, ^ttl} = Keyword.get(kw, name1) 136 | assert {^flag2, ^now, ^ttl} = Keyword.get(kw, name2) 137 | assert {^flag3, ^now, ^ttl} = Keyword.get(kw, name3) 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /test/fun_with_flags/store/serializer/ecto_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.Store.Serializer.EctoTest do 2 | use FunWithFlags.TestCase, async: true 3 | 4 | alias FunWithFlags.Flag 5 | alias FunWithFlags.Gate 6 | alias FunWithFlags.Store.Persistent.Ecto.Record 7 | alias FunWithFlags.Store.Serializer.Ecto, as: Serializer 8 | 9 | setup do 10 | flag_name = "chicken" 11 | bool_record = %Record{enabled: true, flag_name: flag_name, gate_type: "boolean", id: 2, target: nil} 12 | actor_record = %Record{enabled: true, flag_name: flag_name, gate_type: "actor", id: 4,target: "user:123"} 13 | group_record = %Record{enabled: false, flag_name: flag_name, gate_type: "group", id: 3, target: "admins"} 14 | po_time_record = %Record{enabled: true, flag_name: flag_name, gate_type: "percentage", id: 5, target: "time/0.42"} 15 | po_actors_record = %Record{enabled: true, flag_name: flag_name, gate_type: "percentage", id: 5, target: "actors/0.42"} 16 | {:ok, 17 | flag_name: String.to_atom(flag_name), 18 | bool_record: bool_record, 19 | actor_record: actor_record, 20 | group_record: group_record, 21 | percentage_of_time_record: po_time_record, 22 | percentage_of_actors_record: po_actors_record 23 | } 24 | end 25 | 26 | describe "deserialize_flag(name, [%Record{}])" do 27 | test "with empty data it returns an empty flag" do 28 | assert %Flag{name: :kiwi, gates: []} = Serializer.deserialize_flag(:kiwi, []) 29 | end 30 | 31 | test "with boolean gate data it returns a simple boolean flag", %{flag_name: flag_name, bool_record: bool_record} do 32 | assert( 33 | %Flag{name: ^flag_name, gates: [%Gate{type: :boolean, enabled: true}]} = 34 | Serializer.deserialize_flag(flag_name, [bool_record]) 35 | ) 36 | 37 | disabled_bool_record = %{bool_record | enabled: false} 38 | assert( 39 | %Flag{name: ^flag_name, gates: [%Gate{type: :boolean, enabled: false}]} = 40 | Serializer.deserialize_flag(flag_name, [disabled_bool_record]) 41 | ) 42 | end 43 | 44 | 45 | test "with more than one gate it returns a composite flag", 46 | %{flag_name: flag_name, bool_record: bool_record, 47 | actor_record: actor_record, group_record: group_record, 48 | percentage_of_time_record: percentage_of_time_record, 49 | percentage_of_actors_record: percentage_of_actors_record} do 50 | 51 | flag = %Flag{name: flag_name, gates: [ 52 | %Gate{type: :actor, for: "user:123", enabled: true}, 53 | %Gate{type: :boolean, enabled: true}, 54 | %Gate{type: :group, for: "admins", enabled: false}, 55 | ]} 56 | assert ^flag = Serializer.deserialize_flag(flag_name, [bool_record, actor_record, group_record]) 57 | 58 | flag = %Flag{name: flag_name, gates: [ 59 | %Gate{type: :actor, for: "user:123", enabled: true}, 60 | %Gate{type: :actor, for: "string:albicocca", enabled: false}, 61 | %Gate{type: :boolean, enabled: true}, 62 | %Gate{type: :group, for: "admins", enabled: false}, 63 | %Gate{type: :group, for: "penguins", enabled: true}, 64 | %Gate{type: :percentage_of_time, for: 0.42, enabled: true}, 65 | ]} 66 | 67 | actor_record_2 = %{actor_record | id: 5, target: "string:albicocca", enabled: false} 68 | group_record_2 = %{group_record | id: 6, target: "penguins", enabled: true} 69 | 70 | assert ^flag = Serializer.deserialize_flag( 71 | flag_name, 72 | [ 73 | bool_record, 74 | actor_record, 75 | group_record, 76 | actor_record_2, 77 | group_record_2, 78 | percentage_of_time_record 79 | ] 80 | ) 81 | 82 | flag = %Flag{name: flag_name, gates: [ 83 | %Gate{type: :actor, for: "string:albicocca", enabled: false}, 84 | %Gate{type: :boolean, enabled: true}, 85 | %Gate{type: :group, for: "penguins", enabled: true}, 86 | %Gate{type: :percentage_of_actors, for: 0.42, enabled: true}, 87 | ]} 88 | 89 | actor_record_2 = %{actor_record | id: 5, target: "string:albicocca", enabled: false} 90 | group_record_2 = %{group_record | id: 6, target: "penguins", enabled: true} 91 | 92 | assert ^flag = Serializer.deserialize_flag( 93 | flag_name, 94 | [ 95 | bool_record, 96 | actor_record_2, 97 | group_record_2, 98 | percentage_of_actors_record 99 | ] 100 | ) 101 | end 102 | end 103 | 104 | 105 | describe "deserialize_gate(flag_name, %Record{}) returns a Gate struct" do 106 | setup(shared) do 107 | {:ok, flag_name: to_string(shared.flag_name)} 108 | end 109 | 110 | test "with boolean data", %{flag_name: flag_name, bool_record: bool_record} do 111 | bool_record = %{bool_record | enabled: true} 112 | assert %Gate{type: :boolean, for: nil, enabled: true} = Serializer.deserialize_gate(flag_name, bool_record) 113 | 114 | bool_record = %{bool_record | enabled: false} 115 | assert %Gate{type: :boolean, for: nil, enabled: false} = Serializer.deserialize_gate(flag_name, bool_record) 116 | end 117 | 118 | test "with actor data", %{flag_name: flag_name, actor_record: actor_record} do 119 | actor_record = %{actor_record | enabled: true} 120 | assert %Gate{type: :actor, for: "user:123", enabled: true} = Serializer.deserialize_gate(flag_name, actor_record) 121 | 122 | actor_record = %{actor_record | enabled: false} 123 | assert %Gate{type: :actor, for: "user:123", enabled: false} = Serializer.deserialize_gate(flag_name, actor_record) 124 | end 125 | 126 | test "with group data", %{flag_name: flag_name, group_record: group_record} do 127 | group_record = %{group_record | enabled: true} 128 | assert %Gate{type: :group, for: "admins", enabled: true} = Serializer.deserialize_gate(flag_name, group_record) 129 | 130 | group_record = %{group_record | enabled: false} 131 | assert %Gate{type: :group, for: "admins", enabled: false} = Serializer.deserialize_gate(flag_name, group_record) 132 | end 133 | 134 | test "with percentage_of_time data", %{flag_name: flag_name, percentage_of_time_record: percentage_of_time_record} do 135 | assert %Gate{type: :percentage_of_time, for: 0.42, enabled: true} = Serializer.deserialize_gate(flag_name, percentage_of_time_record) 136 | end 137 | 138 | test "with percentage_of_actors data", %{flag_name: flag_name, percentage_of_actors_record: percentage_of_actors_record} do 139 | assert %Gate{type: :percentage_of_actors, for: 0.42, enabled: true} = Serializer.deserialize_gate(flag_name, percentage_of_actors_record) 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /test/fun_with_flags/store/serializer/redis_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.Store.Serializer.RedisTest do 2 | use FunWithFlags.TestCase, async: true 3 | 4 | alias FunWithFlags.Flag 5 | alias FunWithFlags.Gate 6 | alias FunWithFlags.Store.Serializer.Redis, as: Serializer 7 | 8 | describe "serialize(gate) returns a List ready to be saved in Redis" do 9 | test "with a boolean gate" do 10 | gate = Gate.new(:boolean, true) 11 | assert ["boolean", "true"] = Serializer.serialize(gate) 12 | 13 | gate = Gate.new(:boolean, false) 14 | assert ["boolean", "false"] = Serializer.serialize(gate) 15 | end 16 | 17 | test "with an actor gate" do 18 | gate = %Gate{type: :actor, for: "user:42", enabled: true} 19 | assert ["actor/user:42", "true"] = Serializer.serialize(gate) 20 | 21 | gate = %Gate{type: :actor, for: "user:123", enabled: false} 22 | assert ["actor/user:123", "false"] = Serializer.serialize(gate) 23 | end 24 | 25 | test "with a group gate" do 26 | gate = %Gate{type: :group, for: :runners, enabled: true} 27 | assert ["group/runners", "true"] = Serializer.serialize(gate) 28 | 29 | gate = %Gate{type: :group, for: :swimmers, enabled: false} 30 | assert ["group/swimmers", "false"] = Serializer.serialize(gate) 31 | 32 | gate = %Gate{type: :group, for: "runners", enabled: true} 33 | assert ["group/runners", "true"] = Serializer.serialize(gate) 34 | 35 | gate = %Gate{type: :group, for: "swimmers", enabled: false} 36 | assert ["group/swimmers", "false"] = Serializer.serialize(gate) 37 | end 38 | 39 | test "with a percentage_of_time gate" do 40 | gate = %Gate{type: :percentage_of_time, for: 0.123, enabled: true} 41 | assert ["percentage", "time/0.123"] = Serializer.serialize(gate) 42 | 43 | gate = %Gate{type: :percentage_of_time, for: 0.42, enabled: true} 44 | assert ["percentage", "time/0.42"] = Serializer.serialize(gate) 45 | end 46 | 47 | test "with a percentage_of_actors gate" do 48 | gate = %Gate{type: :percentage_of_actors, for: 0.123, enabled: true} 49 | assert ["percentage", "actors/0.123"] = Serializer.serialize(gate) 50 | 51 | gate = %Gate{type: :percentage_of_actors, for: 0.42, enabled: true} 52 | assert ["percentage", "actors/0.42"] = Serializer.serialize(gate) 53 | end 54 | end 55 | 56 | 57 | describe "deserialize_flag(name, [gate, data])" do 58 | test "with empty data it returns an empty flag" do 59 | assert %Flag{name: :kiwi, gates: []} = Serializer.deserialize_flag(:kiwi, []) 60 | end 61 | 62 | test "with boolean gate data it returns a simple boolean flag" do 63 | assert( 64 | %Flag{name: :kiwi, gates: [%Gate{type: :boolean, enabled: true}]} = 65 | Serializer.deserialize_flag(:kiwi, ["boolean", "true"]) 66 | ) 67 | 68 | assert( 69 | %Flag{name: :kiwi, gates: [%Gate{type: :boolean, enabled: false}]} = 70 | Serializer.deserialize_flag(:kiwi, ["boolean", "false"]) 71 | ) 72 | end 73 | 74 | test "with more than one gate it returns a composite flag" do 75 | flag = %Flag{name: :peach, gates: [ 76 | %Gate{type: :boolean, enabled: true}, 77 | %Gate{type: :actor, for: "user:123", enabled: false}, 78 | ]} 79 | assert ^flag = Serializer.deserialize_flag(:peach, ["boolean", "true", "actor/user:123", "false"]) 80 | 81 | flag = %Flag{name: :apricot, gates: [ 82 | %Gate{type: :actor, for: "string:albicocca", enabled: true}, 83 | %Gate{type: :boolean, enabled: false}, 84 | %Gate{type: :percentage_of_time, for: 0.5, enabled: true}, 85 | %Gate{type: :actor, for: "user:123", enabled: false}, 86 | %Gate{type: :group, for: "penguins", enabled: true}, 87 | ]} 88 | 89 | raw_redis_data = [ 90 | "actor/string:albicocca", "true", 91 | "boolean", "false", 92 | "percentage", "time/0.5", 93 | "actor/user:123", "false", 94 | "group/penguins", "true" 95 | ] 96 | assert ^flag = Serializer.deserialize_flag(:apricot, raw_redis_data) 97 | 98 | 99 | flag = %Flag{name: :apricot, gates: [ 100 | %Gate{type: :actor, for: "string:albicocca", enabled: true}, 101 | %Gate{type: :boolean, enabled: false}, 102 | %Gate{type: :percentage_of_actors, for: 0.5, enabled: true}, 103 | %Gate{type: :group, for: "penguins", enabled: true}, 104 | ]} 105 | 106 | raw_redis_data = [ 107 | "actor/string:albicocca", "true", 108 | "boolean", "false", 109 | "percentage", "actors/0.5", 110 | "group/penguins", "true" 111 | ] 112 | assert ^flag = Serializer.deserialize_flag(:apricot, raw_redis_data) 113 | end 114 | end 115 | 116 | describe "deserialize_gate() returns a Gate struct" do 117 | test "with boolean data" do 118 | assert %Gate{type: :boolean, for: nil, enabled: true} = Serializer.deserialize_gate(["boolean", "true"]) 119 | assert %Gate{type: :boolean, for: nil, enabled: false} = Serializer.deserialize_gate(["boolean", "false"]) 120 | end 121 | 122 | test "with actor data" do 123 | assert %Gate{type: :actor, for: "anything", enabled: true} = Serializer.deserialize_gate(["actor/anything", "true"]) 124 | assert %Gate{type: :actor, for: "really:123", enabled: false} = Serializer.deserialize_gate(["actor/really:123", "false"]) 125 | end 126 | 127 | test "with group data" do 128 | assert %Gate{type: :group, for: "fishes", enabled: true} = Serializer.deserialize_gate(["group/fishes", "true"]) 129 | assert %Gate{type: :group, for: "cetacea", enabled: false} = Serializer.deserialize_gate(["group/cetacea", "false"]) 130 | end 131 | 132 | test "with percentage_of_time data" do 133 | assert %Gate{type: :percentage_of_time, for: 0.001, enabled: true} = Serializer.deserialize_gate(["percentage", "time/0.001"]) 134 | assert %Gate{type: :percentage_of_time, for: 0.95, enabled: true} = Serializer.deserialize_gate(["percentage", "time/0.95"]) 135 | end 136 | 137 | test "with percentage_of_actors data" do 138 | assert %Gate{type: :percentage_of_actors, for: 0.001, enabled: true} = Serializer.deserialize_gate(["percentage", "actors/0.001"]) 139 | assert %Gate{type: :percentage_of_actors, for: 0.95, enabled: true} = Serializer.deserialize_gate(["percentage", "actors/0.95"]) 140 | end 141 | end 142 | 143 | end 144 | -------------------------------------------------------------------------------- /test/fun_with_flags/supervisor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.SupervisorTest do 2 | use FunWithFlags.TestCase, async: false 3 | 4 | alias FunWithFlags.Config 5 | 6 | test "the auto-generated child_spec/1" do 7 | expected = %{ 8 | id: FunWithFlags.Supervisor, 9 | start: {FunWithFlags.Supervisor, :start_link, [nil]}, 10 | type: :supervisor 11 | } 12 | 13 | assert ^expected = FunWithFlags.Supervisor.child_spec(nil) 14 | end 15 | 16 | describe "initializing the config for the children" do 17 | @tag :redis_persistence 18 | @tag :redis_pubsub 19 | test "with Redis persistence and Redis PubSub" do 20 | expected = { 21 | :ok, 22 | { 23 | expected_supervisor_spec(), 24 | [ 25 | %{ 26 | id: FunWithFlags.Store.Cache, 27 | restart: :permanent, 28 | start: {FunWithFlags.Store.Cache, :start_link, []}, 29 | type: :worker 30 | }, 31 | %{ 32 | id: Redix, 33 | start: {Redix, :start_link, 34 | [ 35 | [ 36 | host: "localhost", 37 | port: 6379, 38 | database: 5, 39 | name: FunWithFlags.Store.Persistent.Redis, 40 | sync_connect: false 41 | ] 42 | ]}, 43 | type: :worker 44 | }, 45 | %{ 46 | id: FunWithFlags.Notifications.Redis, 47 | restart: :permanent, 48 | start: {FunWithFlags.Notifications.Redis, :start_link, [ 49 | [host: "localhost", port: 6379, database: 5, name: :fun_with_flags_notifications, sync_connect: false] 50 | ]}, 51 | type: :worker 52 | } 53 | ] 54 | } 55 | } 56 | 57 | assert ^expected = FunWithFlags.Supervisor.init(nil) 58 | end 59 | 60 | @tag :redis_persistence 61 | @tag :phoenix_pubsub 62 | test "with Redis persistence and Phoenix PubSub" do 63 | expected = { 64 | :ok, 65 | { 66 | expected_supervisor_spec(), 67 | [ 68 | %{ 69 | id: FunWithFlags.Store.Cache, 70 | restart: :permanent, 71 | start: {FunWithFlags.Store.Cache, :start_link, []}, 72 | type: :worker 73 | }, 74 | %{ 75 | id: Redix, 76 | start: {Redix, :start_link, 77 | [ 78 | [ 79 | host: "localhost", 80 | port: 6379, 81 | database: 5, 82 | name: FunWithFlags.Store.Persistent.Redis, 83 | sync_connect: false 84 | ] 85 | ]}, 86 | type: :worker 87 | }, 88 | %{ 89 | id: FunWithFlags.Notifications.PhoenixPubSub, 90 | restart: :permanent, 91 | start: {FunWithFlags.Notifications.PhoenixPubSub, :start_link, []}, 92 | type: :worker 93 | } 94 | ] 95 | } 96 | } 97 | 98 | assert ^expected = FunWithFlags.Supervisor.init(nil) 99 | end 100 | 101 | @tag :ecto_persistence 102 | @tag :phoenix_pubsub 103 | test "with Ecto persistence and Phoenix PubSub" do 104 | expected = { 105 | :ok, 106 | { 107 | expected_supervisor_spec(), 108 | [ 109 | %{ 110 | id: FunWithFlags.Store.Cache, 111 | restart: :permanent, 112 | start: {FunWithFlags.Store.Cache, :start_link, []}, 113 | type: :worker 114 | }, 115 | %{ 116 | id: FunWithFlags.Notifications.PhoenixPubSub, 117 | restart: :permanent, 118 | start: {FunWithFlags.Notifications.PhoenixPubSub, :start_link, []}, 119 | type: :worker 120 | } 121 | ] 122 | } 123 | } 124 | 125 | assert ^expected = FunWithFlags.Supervisor.init(nil) 126 | end 127 | end 128 | 129 | 130 | describe "initializing the config for the children (no cache)" do 131 | setup do 132 | # Capture the original cache config 133 | original_cache_config = Config.ets_cache_config() 134 | 135 | # Disable the cache for these tests. 136 | Application.put_all_env(fun_with_flags: [cache: [ 137 | enabled: false, ttl: original_cache_config[:ttl] 138 | ]]) 139 | 140 | # Restore the original config 141 | on_exit fn -> 142 | Application.put_all_env(fun_with_flags: [cache: original_cache_config]) 143 | assert ^original_cache_config = Config.ets_cache_config() 144 | end 145 | end 146 | 147 | @tag :redis_persistence 148 | @tag :redis_pubsub 149 | test "with Redis persistence and Redis PubSub, no cache" do 150 | expected = { 151 | :ok, 152 | { 153 | expected_supervisor_spec(), 154 | [ 155 | %{ 156 | id: Redix, 157 | start: {Redix, :start_link, 158 | [ 159 | [ 160 | host: "localhost", 161 | port: 6379, 162 | database: 5, 163 | name: FunWithFlags.Store.Persistent.Redis, 164 | sync_connect: false 165 | ] 166 | ]}, 167 | type: :worker 168 | } 169 | ] 170 | } 171 | } 172 | 173 | assert ^expected = FunWithFlags.Supervisor.init(nil) 174 | end 175 | 176 | @tag :redis_persistence 177 | @tag :phoenix_pubsub 178 | test "with Redis persistence and Phoenix PubSub, no cache" do 179 | expected = { 180 | :ok, 181 | { 182 | expected_supervisor_spec(), 183 | [ 184 | %{ 185 | id: Redix, 186 | start: {Redix, :start_link, 187 | [ 188 | [ 189 | host: "localhost", 190 | port: 6379, 191 | database: 5, 192 | name: FunWithFlags.Store.Persistent.Redis, 193 | sync_connect: false 194 | ] 195 | ]}, 196 | type: :worker 197 | } 198 | ] 199 | } 200 | } 201 | 202 | assert ^expected = FunWithFlags.Supervisor.init(nil) 203 | end 204 | 205 | @tag :ecto_persistence 206 | @tag :phoenix_pubsub 207 | test "with Ecto persistence and Phoenix PubSub, no cache" do 208 | expected = { 209 | :ok, 210 | { 211 | expected_supervisor_spec(), 212 | [] 213 | } 214 | } 215 | 216 | assert ^expected = FunWithFlags.Supervisor.init(nil) 217 | end 218 | end 219 | 220 | defp expected_supervisor_spec do 221 | %{intensity: 3, period: 5, strategy: :one_for_one, auto_shutdown: :never} 222 | end 223 | end 224 | -------------------------------------------------------------------------------- /test/fun_with_flags/telemetry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.TelemetryTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias FunWithFlags.Telemetry, as: FWFTel 5 | alias FunWithFlags.Gate 6 | 7 | @moduletag :telemetry 8 | 9 | describe "emit_persistence_event()" do 10 | test "with a success tuple" do 11 | result = {:ok, "something"} 12 | event_name = :some_event 13 | flag_name = :some_flag 14 | gate = %Gate{type: :boolean, enabled: true} 15 | 16 | ref = :telemetry_test.attach_event_handlers(self(), [[:fun_with_flags, :persistence, event_name]]) 17 | 18 | assert ^result = FWFTel.emit_persistence_event(result, event_name, flag_name, gate) 19 | 20 | assert_received { 21 | [:fun_with_flags, :persistence, ^event_name], 22 | ^ref, 23 | %{system_time: time_value}, 24 | %{flag_name: ^flag_name, gate: ^gate} 25 | } 26 | 27 | assert is_integer(time_value) 28 | 29 | :telemetry.detach(ref) 30 | end 31 | 32 | test "with an error tuple" do 33 | result = {:error, "some error"} 34 | event_name = :some_event 35 | flag_name = :some_flag 36 | gate = %Gate{type: :boolean, enabled: true} 37 | 38 | ref = :telemetry_test.attach_event_handlers(self(), [[:fun_with_flags, :persistence, :error]]) 39 | 40 | assert ^result = FWFTel.emit_persistence_event(result, event_name, flag_name, gate) 41 | 42 | assert_received { 43 | [:fun_with_flags, :persistence, :error], 44 | ^ref, 45 | %{system_time: time_value}, 46 | %{flag_name: ^flag_name, gate: ^gate, error: "some error", original_event: ^event_name} 47 | } 48 | 49 | assert is_integer(time_value) 50 | 51 | :telemetry.detach(ref) 52 | end 53 | end 54 | 55 | describe "do_send_event()" do 56 | test "it emits an event with the right prefix and measures" do 57 | metadata = %{foo: "bar"} 58 | event = [:foo, :bar, :monkey] 59 | 60 | ref = :telemetry_test.attach_event_handlers(self(), [event]) 61 | 62 | assert :ok = FWFTel.do_send_event(event, metadata) 63 | 64 | assert_received { 65 | ^event, 66 | ^ref, 67 | %{system_time: time_value}, 68 | ^metadata 69 | } 70 | 71 | assert is_integer(time_value) 72 | 73 | :telemetry.detach(ref) 74 | end 75 | end 76 | 77 | describe "attach_debug_handler" do 78 | test "it attaches a debug handler to FunWithFlags telemetry events" do 79 | assert :telemetry_handler_table.list_by_prefix([:fun_with_flags]) == [] 80 | 81 | FWFTel.attach_debug_handler() 82 | 83 | assert length(:telemetry_handler_table.list_by_prefix([:fun_with_flags])) == 8 84 | 85 | :telemetry.detach("local-debug-handler") 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /test/fun_with_flags/timestamps_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.TimestampsTest do 2 | use FunWithFlags.TestCase 3 | alias FunWithFlags.Timestamps, as: TS 4 | 5 | test "now() returns a Unix timestamp" do 6 | assert is_integer(TS.now) 7 | 8 | %DateTime{ 9 | year: year, 10 | month: month, 11 | day: day, 12 | hour: hour, 13 | minute: minute, 14 | second: second # I assume the tests are fast enough 15 | } = DateTime.utc_now 16 | 17 | assert {:ok, %DateTime{ 18 | year: ^year, 19 | month: ^month, 20 | day: ^day, 21 | hour: ^hour, 22 | minute: ^minute, 23 | second: ^second 24 | }} = DateTime.from_unix(TS.now) 25 | end 26 | 27 | 28 | describe "expired?() tells if a timestamp is past its ttl" do 29 | test "it returns true when the timestamp is expired" do 30 | one_min_ago = TS.now - 60 31 | assert TS.expired?(one_min_ago, 10) 32 | assert TS.expired?(one_min_ago, 59) 33 | end 34 | 35 | test "it returns false when the timestamp is not expired" do 36 | one_min_ago = TS.now - 60 37 | refute TS.expired?(one_min_ago, 61) 38 | refute TS.expired?(one_min_ago, 3600) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/support/test_case.ex: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.TestCase do 2 | use ExUnit.CaseTemplate 3 | alias FunWithFlags.Dev.EctoRepo, as: Repo 4 | 5 | setup tags do 6 | # Setup the SQL sandbox if the persistent store is Ecto 7 | if FunWithFlags.Config.persist_in_ecto? do 8 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo) 9 | unless tags[:async] do 10 | Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()}) 11 | end 12 | end 13 | :ok 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/support/test_user.ex: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.TestUser do 2 | # A Test user 3 | defstruct [:id, :email, :name, groups: []] 4 | end 5 | 6 | defimpl FunWithFlags.Actor, for: FunWithFlags.TestUser do 7 | def id(%{id: id}) do 8 | "user:#{id}" 9 | end 10 | end 11 | 12 | 13 | defimpl FunWithFlags.Group, for: FunWithFlags.TestUser do 14 | def in?(%{email: email}, "admin") do 15 | Regex.match?(~r/@wayne.com$/, email) 16 | end 17 | 18 | def in?(user, :admin) do 19 | __MODULE__.in?(user, "admin") 20 | end 21 | 22 | # Matches binaries or atoms. 23 | # 24 | def in?(%{groups: groups}, group) when is_list(groups) do 25 | group_s = to_string(group) 26 | Enum.any? groups, fn(g) -> to_string(g) == group_s end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/support/test_utils.ex: -------------------------------------------------------------------------------- 1 | defmodule FunWithFlags.TestUtils do 2 | alias FunWithFlags.Config 3 | import ExUnit.Assertions, only: [assert: 1] 4 | 5 | @test_db 5 6 | @redis FunWithFlags.Store.Persistent.Redis 7 | 8 | # Since the flags are saved on shared storage (ETS and 9 | # Redis), in order to keep the tests isolated _and_ async 10 | # each test must use unique flag names. Not doing so would 11 | # cause some tests to override other tests flag values. 12 | # 13 | # This method should _never_ be used at runtime because 14 | # atoms are not garbage collected. 15 | # 16 | def unique_atom do 17 | String.to_atom(random_string()) 18 | end 19 | 20 | def random_string do 21 | :crypto.strong_rand_bytes(7) 22 | |> Base.encode32(padding: false, case: :lower) 23 | end 24 | 25 | def use_redis_test_db do 26 | Redix.command!(@redis, ["SELECT", @test_db]) 27 | end 28 | 29 | def clear_test_db do 30 | unless Config.persist_in_ecto? do 31 | use_redis_test_db() 32 | 33 | Redix.command!(@redis, ["DEL", "fun_with_flags"]) 34 | Redix.command!(@redis, ["KEYS", "fun_with_flags:*"]) 35 | |> delete_keys() 36 | end 37 | end 38 | 39 | defp delete_keys([]), do: 0 40 | defp delete_keys(keys) do 41 | Redix.command!(@redis, ["DEL" | keys]) 42 | end 43 | 44 | def clear_cache do 45 | if Config.cache? do 46 | FunWithFlags.Store.Cache.flush() 47 | end 48 | end 49 | 50 | defmacro timetravel([by: offset], [do: body]) do 51 | quote do 52 | fake_now = FunWithFlags.Timestamps.now + unquote(offset) 53 | # IO.puts("now: #{FunWithFlags.Timestamps.now}") 54 | # IO.puts("offset: #{unquote(offset)}") 55 | # IO.puts("fake_now: #{fake_now}") 56 | 57 | with_mock(FunWithFlags.Timestamps, [ 58 | now: fn() -> 59 | fake_now 60 | end, 61 | expired?: fn(timestamp, ttl) -> 62 | :meck.passthrough([timestamp, ttl]) 63 | end 64 | ]) do 65 | unquote(body) 66 | end 67 | end 68 | end 69 | 70 | def kill_process(name) do 71 | true = GenServer.whereis(name) |> Process.exit(:kill) 72 | end 73 | 74 | def configure_redis_with(conf) do 75 | Application.put_all_env(fun_with_flags: [redis: conf]) 76 | assert ^conf = Application.get_env(:fun_with_flags, :redis) 77 | end 78 | 79 | def ensure_default_redis_config_in_app_env do 80 | assert match?([database: 5], Application.get_env(:fun_with_flags, :redis)) 81 | end 82 | 83 | def reset_app_env_to_default_redis_config do 84 | configure_redis_with([database: 5]) 85 | end 86 | 87 | def phx_pubsub_ready? do 88 | try do 89 | Process.whereis(FunWithFlags.Notifications.PhoenixPubSub) && 90 | FunWithFlags.Notifications.PhoenixPubSub.subscribed? 91 | catch 92 | :exit, _reason -> 93 | # This is to catch failures when the GenServer is still recovering from `Process.exit(:kill)`, 94 | # as in that case this function might fail with: 95 | # (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started 96 | # 97 | # I'm not entirely sure about the sequencing here. I'd suppose that `Process.whereis()` should 98 | # protect us from that, but likely there is a race condition somewhere so that the GenServer is 99 | # exited/killed after the `whereis()` call has returned a truthy value. 100 | 101 | # IO.puts "EXIT while checking for Phoenix Pubsub readiness: #{inspect reason}" 102 | false 103 | end 104 | end 105 | 106 | def wait_until_pubsub_is_ready!(attempts \\ 20, wait_time_ms \\ 25) 107 | 108 | def wait_until_pubsub_is_ready!(attempts, wait_time_ms) when attempts > 0 do 109 | case phx_pubsub_ready?() do 110 | true -> 111 | :ok 112 | _ -> 113 | :timer.sleep(wait_time_ms) 114 | wait_until_pubsub_is_ready!(attempts - 1, wait_time_ms) 115 | end 116 | end 117 | 118 | def wait_until_pubsub_is_ready!(_, _) do 119 | raise "Phoenix PubSub is never ready, giving up" 120 | end 121 | 122 | def assert_with_retries(attempts \\ 30, wait_time_ms \\ 25, test_fn) do 123 | try do 124 | test_fn.() 125 | rescue 126 | e -> 127 | if attempts == 1 do 128 | reraise e, __STACKTRACE__ 129 | else 130 | IO.write("|") 131 | :timer.sleep(wait_time_ms) 132 | assert_with_retries(attempts - 1, wait_time_ms, test_fn) 133 | end 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # If we are not using Ecto and we're not using Phoenix.PubSub, then 2 | # we need a Redis instance for either persistence or PubSub. 3 | does_anything_need_redis = !( 4 | FunWithFlags.Config.persist_in_ecto? && FunWithFlags.Config.phoenix_pubsub? 5 | ) 6 | 7 | 8 | if FunWithFlags.Config.phoenix_pubsub? do 9 | # The Phoenix PubSub application must be running before we try to start our 10 | # PubSub process and subscribe. 11 | :ok = Application.ensure_started(:phoenix_pubsub) 12 | 13 | # Start a Phoenix.PubSub process for the tests. 14 | # The `:fwf_test` connection name will be injected into this 15 | # library in `config/test.exs`. 16 | children = [ 17 | {Phoenix.PubSub, [name: :fwf_test, adapter: Phoenix.PubSub.PG2, pool_size: 1]} 18 | ] 19 | opts = [strategy: :one_for_one, name: MyApp.Supervisor] 20 | {:ok, _pid} = Supervisor.start_link(children, opts) 21 | end 22 | 23 | IO.puts "--------------------------------------------------------------" 24 | IO.puts "$CACHE_ENABLED=#{System.get_env("CACHE_ENABLED")}" 25 | IO.puts "$PERSISTENCE=#{System.get_env("PERSISTENCE")}" 26 | IO.puts "$RDBMS=#{System.get_env("RDBMS")}" 27 | IO.puts "$PUBSUB_BROKER=#{System.get_env("PUBSUB_BROKER")}" 28 | IO.puts "$CI=#{System.get_env("CI")}" 29 | IO.puts "--------------------------------------------------------------" 30 | IO.puts "Elixir version: #{System.version()}" 31 | IO.puts "Erlang/OTP version: #{:erlang.system_info(:system_version) |> to_string() |> String.trim_trailing()}" 32 | IO.puts "Logger level: #{inspect(Logger.level())}" 33 | IO.puts "Cache enabled: #{inspect(FunWithFlags.Config.cache?)}" 34 | IO.puts "Persistence adapter: #{inspect(FunWithFlags.Config.persistence_adapter())}" 35 | IO.puts "RDBMS driver: #{inspect(if FunWithFlags.Config.persist_in_ecto?, do: FunWithFlags.Dev.EctoRepo.__adapter__(), else: nil)}" 36 | IO.puts "Notifications adapter: #{inspect(FunWithFlags.Config.notifications_adapter())}" 37 | IO.puts "Anything using Redis: #{inspect(does_anything_need_redis)}" 38 | IO.puts "--------------------------------------------------------------" 39 | 40 | if does_anything_need_redis do 41 | FunWithFlags.TestUtils.use_redis_test_db() 42 | end 43 | 44 | # FunWithFlags.Telemetry.attach_debug_handler() 45 | 46 | ExUnit.start() 47 | 48 | if FunWithFlags.Config.persist_in_ecto? do 49 | {:ok, _pid} = FunWithFlags.Dev.EctoRepo.start_link() 50 | Ecto.Adapters.SQL.Sandbox.mode(FunWithFlags.Dev.EctoRepo, :manual) 51 | end 52 | --------------------------------------------------------------------------------