diff --git a/README.md b/README.md index 69d627b..73970ff 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,24 @@ In this example we are requiring a continuous successful response with no simula ```json { + "route": "/*", "random_content": "428kb", + "stages": [ + { + "at": 0, + "latency": 0, + "status": 200 + } + ] +} +``` + +To simulate serving payloads of different sizes, a random content range recipe can be used. For example, the example below enables OriginSimulator to serve payloads of random and varying sizes within a range of 300kb and 400kb. + +```json +{ + "route": "/*", + "random_content": "300kb..400kb", "stages": [ { "at": 0, diff --git a/examples/permanent_200_random_content_range_gzip.json b/examples/permanent_200_random_content_range_gzip.json new file mode 100644 index 0000000..7319e9a --- /dev/null +++ b/examples/permanent_200_random_content_range_gzip.json @@ -0,0 +1,16 @@ +[ + { + "stages": [ + { + "status": 200, + "latency": 0, + "at": 0 + } + ], + "route": "/*", + "random_content": "300kb..400kb", + "headers": { + "content-encoding": "gzip" + } + } +] \ No newline at end of file diff --git a/lib/origin_simulator.ex b/lib/origin_simulator.ex index 170b869..46a0e6c 100644 --- a/lib/origin_simulator.ex +++ b/lib/origin_simulator.ex @@ -23,11 +23,11 @@ defmodule OriginSimulator do def admin_domain(), do: Application.get_env(:origin_simulator, :admin_domain) defp serve_payload(conn, route) do - {status, latency} = Simulation.state(:simulation, route) + {status, latency, payload_id} = Simulation.state(:simulation, route) sleep(latency) - {:ok, body} = Payload.body(:payload, status, conn.request_path, route) + {:ok, body} = Payload.body(:payload, status, conn.request_path, payload_id) recipe = Simulation.recipe(:simulation, route) diff --git a/lib/origin_simulator/flakiness.ex b/lib/origin_simulator/flakiness.ex new file mode 100644 index 0000000..19b799e --- /dev/null +++ b/lib/origin_simulator/flakiness.ex @@ -0,0 +1,86 @@ +defmodule OriginSimulator.Flakiness do + @moduledoc """ + A server that introduces latency, payload and status flakiness + during simulation. + """ + use GenServer + alias OriginSimulator.{Flakiness, Payload} + + @default_interval 1000 + @simulation_server :simulation + + defstruct payload: [], status: [], route: "/*", interval: nil + + @type t :: %__MODULE__{ + payload: [binary()], + status: [integer()], + route: binary, + interval: integer() + } + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: :flakiness) + end + + def new(), do: %__MODULE__{} + def new(payload_series, route), do: %__MODULE__{payload: payload_series, route: route} + + def start(%{random_content: value}, route) do + String.split(value, "..") + |> Payload.random_payload_series() + |> Flakiness.new(route) + |> Map.put(:interval, @default_interval) + |> set() + + start() + end + + def set(flakiness), do: GenServer.call(:flakiness, {:set, flakiness}) + def state(), do: GenServer.call(:flakiness, :state) + def start(), do: GenServer.call(:flakiness, :start) + + # TODO + # def stop() + + # Callbacks + + @impl true + def init(_) do + {:ok, new()} + end + + @impl true + def handle_call(:state, _from, flakiness) do + {:reply, flakiness, flakiness} + end + + @impl true + def handle_call(:start, _from, flakiness) do + schedule_flakiness() + {:reply, :ok, flakiness} + end + + @impl true + def handle_call({:set, new_flakiness}, _from, _flakiness) do + {:reply, :ok, new_flakiness} + end + + @impl true + def handle_info(:flaky, flakiness) do + send( + @simulation_server, + {:update, {flakiness.route, flakiness.payload |> Enum.random()}} + ) + + schedule_flakiness(flakiness.interval) + + {:noreply, flakiness} + end + + defp schedule_flakiness(interval \\ 0) + defp schedule_flakiness(nil), do: :ok + + defp schedule_flakiness(interval) do + Process.send_after(self(), :flaky, interval) + end +end diff --git a/lib/origin_simulator/payload.ex b/lib/origin_simulator/payload.ex index b7dbc58..689a4e7 100644 --- a/lib/origin_simulator/payload.ex +++ b/lib/origin_simulator/payload.ex @@ -5,12 +5,19 @@ defmodule OriginSimulator.Payload do @http_client Application.get_env(:origin_simulator, :http_client) + @random_payload_step_size 5 + @unit "kb" + @unit_regex ~r/kb/ + ## Client API def start_link(opts) do GenServer.start_link(__MODULE__, opts, name: :payload) end + # TODO: rename `fetch` to `create` or `generate` to better reflect that OS actually create + # in-memory payload via various mechanisms, i.e. fetch from origin, + # provided in recipe or random content def fetch(server, %Recipe{origin: value, route: route} = recipe) when is_binary(value) do GenServer.call(server, {:fetch, recipe, route}) end @@ -19,8 +26,14 @@ defmodule OriginSimulator.Payload do GenServer.call(server, {:parse, recipe, route}) end - def fetch(server, %Recipe{random_content: value, route: route} = recipe) - when is_binary(value) do + def fetch(server, %Recipe{random_content: value, route: route} = recipe) when is_binary(value) do + case String.contains?(value, "..") do + true -> fetch(server, %{recipe | random_content: String.split(value, "..")}) + false -> GenServer.call(server, {:generate, recipe, route}) + end + end + + def fetch(server, %Recipe{random_content: [_min, _max], route: route} = recipe) do GenServer.call(server, {:generate, recipe, route}) end @@ -34,6 +47,17 @@ defmodule OriginSimulator.Payload do end end + def random_payload_step_size, do: @random_payload_step_size + + def random_payload_series([min, max]) do + min_integer = Regex.replace(@unit_regex, min, "") |> String.to_integer() + max_integer = Regex.replace(@unit_regex, max, "") |> String.to_integer() + + min_integer..max_integer + |> Enum.take_every(@random_payload_step_size) + |> Enum.filter(&(&1 != 0)) + end + defp cache_lookup(route) do case :ets.lookup(:payload, route) do [{^route, body}] -> {:ok, body} @@ -53,6 +77,7 @@ defmodule OriginSimulator.Payload do def handle_call({:fetch, recipe, route}, _from, state) do {:ok, %HTTPoison.Response{body: body}} = @http_client.get(recipe.origin, recipe.headers) :ets.insert(:payload, {route, body}) + {:reply, :ok, state} end @@ -63,6 +88,19 @@ defmodule OriginSimulator.Payload do {:reply, :ok, state} end + @impl true + def handle_call({:generate, %{random_content: [min, max]} = recipe, route}, _from, state) do + :ets.insert(:payload, {route, Body.randomise(max, recipe.headers)}) + + random_payload_series([min, max]) + |> Enum.each(fn size -> + size_kb = Integer.to_string(size) <> @unit + :ets.insert(:payload, {{route, size}, Body.randomise(size_kb, recipe.headers)}) + end) + + {:reply, :ok, state} + end + @impl true def handle_call({:generate, recipe, route}, _from, state) do :ets.insert(:payload, {route, Body.randomise(recipe.random_content, recipe.headers)}) diff --git a/lib/origin_simulator/recipe.ex b/lib/origin_simulator/recipe.ex index 43ba8d6..067755a 100644 --- a/lib/origin_simulator/recipe.ex +++ b/lib/origin_simulator/recipe.ex @@ -1,6 +1,7 @@ defmodule OriginSimulator.Recipe do defstruct origin: nil, body: nil, random_content: nil, headers: %{}, stages: [], route: "/*" + # TODO: missing stage stages, type spec for 'stage' @type t :: %__MODULE__{ origin: String.t(), body: String.t(), diff --git a/lib/origin_simulator/simulation.ex b/lib/origin_simulator/simulation.ex index 1e22d5b..197c998 100644 --- a/lib/origin_simulator/simulation.ex +++ b/lib/origin_simulator/simulation.ex @@ -1,7 +1,17 @@ defmodule OriginSimulator.Simulation do use GenServer - alias OriginSimulator.{Recipe, Payload, Duration} + alias OriginSimulator.{Recipe, Payload, Duration, Simulation, Flakiness} + + defstruct latency: 0, payload_id: nil, recipe: nil, status: 406 + + @type recipe :: OriginSimulator.Recipe.t() + @type t :: %__MODULE__{ + latency: integer(), + payload_id: binary(), + recipe: recipe, + status: integer() + } ## Client API @@ -9,6 +19,8 @@ defmodule OriginSimulator.Simulation do GenServer.start_link(__MODULE__, opts, name: :simulation) end + def new(), do: %Simulation{} + def state(server) do GenServer.call(server, :state) end @@ -54,7 +66,7 @@ defmodule OriginSimulator.Simulation do @impl true def init(_) do - {:ok, %{Recipe.default_route() => default_simulation()}} + {:ok, %{Recipe.default_route() => new()}} end @impl true @@ -64,12 +76,21 @@ defmodule OriginSimulator.Simulation do @impl true def handle_call({:state, route}, _from, state) do - {:reply, {state[route].status, state[route].latency}, state} + case state[route] do + %{status: status, latency: latency, payload_id: payload_id} -> + {:reply, {status, latency, payload_id}, state} + + nil -> + {:reply, {406, 0, nil}, state} + end end @impl true def handle_call({:recipe, route}, _from, state) do - {:reply, state[route].recipe, state} + case state[route] do + %{recipe: recipe} -> {:reply, recipe, state} + nil -> {:reply, nil, state} + end end # retrieve all recipes @@ -94,11 +115,13 @@ defmodule OriginSimulator.Simulation do Enum.map(new_recipe.stages, fn item -> Process.send_after( self(), - {:update, route, item["status"], Duration.parse(item["latency"])}, + {:update, route, item["status"], Duration.parse(item["latency"]), route}, Duration.parse(item["at"]) ) end) + if auto_flakiness?(new_recipe), do: Flakiness.start(new_recipe, route) + {:reply, :ok, Map.put(state, route, %{simulation | recipe: new_recipe})} end @@ -111,14 +134,21 @@ defmodule OriginSimulator.Simulation do def handle_call(:route, _from, state), do: {:reply, state |> Map.keys(), state} @impl true - def handle_info({:update, route, status, latency}, state) do - {:noreply, Map.put(state, route, %{state[route] | status: status, latency: latency})} + def handle_info({:update, route, status, latency, payload_id}, state) do + {:noreply, Map.put(state, route, %{state[route] | status: status, latency: latency, payload_id: payload_id})} end - defp get(nil), do: default_simulation() - defp get(current_state), do: current_state + @impl true + def handle_info({:update, {route, payload_id}}, state) do + {:noreply, Map.put(state, route, %{state[route] | payload_id: {route, payload_id}})} + end - defp default_simulation(), do: %{recipe: nil, status: 406, latency: 0} + defp auto_flakiness?(%{random_content: nil}), do: false + defp auto_flakiness?(%{random_content: value}), do: String.contains?(value, "..") + defp auto_flakiness?(_other_recipe_type), do: false + + defp get(nil), do: new() + defp get(current_state), do: current_state defp match_route(state, nil, route) do Map.keys(state) diff --git a/lib/origin_simulator/supervisor.ex b/lib/origin_simulator/supervisor.ex index 007eb0e..65a7227 100644 --- a/lib/origin_simulator/supervisor.ex +++ b/lib/origin_simulator/supervisor.ex @@ -10,7 +10,8 @@ defmodule OriginSimulator.Supervisor do children = [ OriginSimulator.Simulation, OriginSimulator.Payload, - OriginSimulator.Counter + OriginSimulator.Counter, + OriginSimulator.Flakiness ] opts = [ diff --git a/test/fixtures/recipes.exs b/test/fixtures/recipes.exs index be5ef85..a6faa79 100644 --- a/test/fixtures/recipes.exs +++ b/test/fixtures/recipes.exs @@ -27,11 +27,12 @@ defmodule Fixtures.Recipes do } end - def random_content_recipe(size \\ "50kb", headers \\ %{}) do + def random_content_recipe(size \\ "50kb", headers \\ %{}, route \\ "/*") do %Recipe{ random_content: size, stages: [%{"at" => 0, "status" => 200, "latency" => 0}], - headers: headers + headers: headers, + route: route } end diff --git a/test/origin_simulator/flakiness_test.exs b/test/origin_simulator/flakiness_test.exs new file mode 100644 index 0000000..489c9b8 --- /dev/null +++ b/test/origin_simulator/flakiness_test.exs @@ -0,0 +1,60 @@ +defmodule OriginSimulator.FlakinessTest do + use ExUnit.Case, async: true + import Fixtures.Recipes + alias OriginSimulator.{Flakiness, Simulation} + + @default_interval 1000 + @simulation_server :simulation + + test "new/0 returns a new Flakiness struct" do + assert Flakiness.new() == %Flakiness{interval: nil, payload: [], route: "/*", status: []} + end + + test "new/2 returns a Flakiness struct for random payload series and route" do + flakiness = Flakiness.new([150, 155, 160], "/an_origin_route") + + assert flakiness.payload == [150, 155, 160] + assert flakiness.route == "/an_origin_route" + end + + test "state/0 returns the current flakiness state" do + Flakiness.new([150, 155, 160], "/an_origin_route") + |> Flakiness.set() + + assert Flakiness.state() == %Flakiness{payload: [150, 155, 160], route: "/an_origin_route"} + end + + test "set/1 flakiness" do + previous_flakiness = Flakiness.state() + new_flakiness = Flakiness.new([500, 505, 510], "/route_a") + + Flakiness.set(new_flakiness) + + refute Flakiness.state() == previous_flakiness + assert Flakiness.state() == %Flakiness{payload: [500, 505, 510], route: "/route_a"} + end + + test "start/2 flakiness for a route" do + recipe = random_content_recipe("10kb..50kb", %{"content-encoding" => "gzip"}, "/route_for_random_payloads") + + Simulation.add_recipe(@simulation_server, recipe) + Process.sleep(5) + + assert Flakiness.state() == %Flakiness{ + interval: @default_interval, + payload: [10, 15, 20, 25, 30, 35, 40, 45, 50], + route: "/route_for_random_payloads" + } + end + + test "current flakiness payload is within the intended random range" do + recipe = random_content_recipe("50kb..100kb", %{"content-encoding" => "gzip"}, "/route_for_random_payloads") + + Simulation.add_recipe(@simulation_server, recipe) + Process.sleep(50) + + {_status, _latency, {_route, payload}} = Simulation.state(@simulation_server, "/route_for_random_payloads") + + assert payload in Flakiness.state().payload + end +end diff --git a/test/origin_simulator/payload_test.exs b/test/origin_simulator/payload_test.exs index 0b4f69e..78a5952 100644 --- a/test/origin_simulator/payload_test.exs +++ b/test/origin_simulator/payload_test.exs @@ -5,6 +5,8 @@ defmodule OriginSimulator.PayloadTest do alias OriginSimulator.Payload + @random_payload_step_size OriginSimulator.Payload.random_payload_step_size() + # TODO: additional tests for fetching and storing multi-origin / source content in ETS describe "with origin" do setup do @@ -61,11 +63,21 @@ defmodule OriginSimulator.PayloadTest do assert Payload.body(:payload, 200) == {:ok, :zlib.gzip("{\"hello\":\"world\"}")} end - test "returns gzip random content" do + test "returns gzip random payload of a specified size" do Payload.fetch(:payload, random_content_recipe("10kb", %{"content-encoding" => "gzip"})) {:ok, gzip_content} = Payload.body(:payload, 200) assert gzip_content |> :zlib.gunzip() |> String.length() == 10 * 1024 end + + test "returns gzip random payloads within a specified range" do + Payload.fetch(:payload, random_content_recipe("0kb..100kb", %{"content-encoding" => "gzip"})) + + # currently with fixed 20kb step sizes + for size <- Enum.take_every(20..100, @random_payload_step_size) do + {:ok, gzip_content} = Payload.body(:payload, 200, "/*", {"/*", size}) + assert gzip_content |> :zlib.gunzip() |> String.length() == size * 1024 + end + end end end diff --git a/test/origin_simulator/simulation_test.exs b/test/origin_simulator/simulation_test.exs index a6fd02e..cf3eac9 100644 --- a/test/origin_simulator/simulation_test.exs +++ b/test/origin_simulator/simulation_test.exs @@ -16,14 +16,22 @@ defmodule OriginSimulator.SimulationTest do {:ok, recipe: recipe, route: recipe.route} end - test "state() returns a tuple with http status and latency in ms for a route", %{route: route} do - assert Simulation.state(:simulation, route) == {200, 1000} + test "state/2 returns a tuple with http status, latency, payload_id for a route", %{route: route} do + assert Simulation.state(:simulation, route) == {200, 1000, route} end - test "recipe() returns the loaded recipe for a route", %{recipe: recipe, route: route} do + test "state/2 does not crash GenServer and returns default tuple for non existing routes" do + assert Simulation.state(:simulation, "/non_existing") == {406, 0, nil} + end + + test "recipe/2 returns the loaded recipe for a route", %{recipe: recipe, route: route} do assert Simulation.recipe(:simulation, route) == recipe end + test "recipe/2 does not crash GenServer and returns nil for non existing routes" do + assert Simulation.recipe(:simulation, "/non_existing") == nil + end + test "route/2 returns matching route", %{recipe: recipe, route: route} do assert Simulation.route(:simulation, route) == recipe |> Map.get(:route) end @@ -73,11 +81,11 @@ defmodule OriginSimulator.SimulationTest do {:ok, recipe: recipe, route: recipe.route} end - test "state() returns a tuple with http status and latency in ms", %{route: route} do - assert Simulation.state(:simulation, route) == {200, 1000..1200} + test "state/2 returns a tuple with http status, latency in ms, payload id (route)", %{route: route} do + assert Simulation.state(:simulation, route) == {200, 1000..1200, route} end - test "recipe() returns the loaded recipe", %{recipe: recipe, route: route} do + test "recipe/2 returns the loaded recipe", %{recipe: recipe, route: route} do assert Simulation.recipe(:simulation, route) == recipe end end @@ -96,13 +104,13 @@ defmodule OriginSimulator.SimulationTest do {:ok, recipe: recipe, route: recipe.route} end - test "state() returns a tuple with http status and latency in ms", %{route: route} do - assert Simulation.state(:simulation, route) == {200, 0} + test "state/2 returns a tuple with http status, latency in ms, payload id (route)", %{route: route} do + assert Simulation.state(:simulation, route) == {200, 0, route} Process.sleep(80) - assert Simulation.state(:simulation, route) == {503, 1000} + assert Simulation.state(:simulation, route) == {503, 1000, route} end - test "recipe() returns the loaded recipe", %{recipe: recipe, route: route} do + test "recipe/2 returns the loaded recipe", %{recipe: recipe, route: route} do assert Simulation.recipe(:simulation, route) == recipe end end @@ -113,15 +121,15 @@ defmodule OriginSimulator.SimulationTest do Process.sleep(5) end - test "state() returns a tuple with default values" do - assert Simulation.state(:simulation, Recipe.default_route()) == {406, 0} + test "state/2 returns a tuple with default values" do + assert Simulation.state(:simulation, Recipe.default_route()) == {406, 0, nil} end - test "recipe() returns an empty list" do + test "recipe/1 returns an empty list" do assert Simulation.recipe(:simulation) == [] end - test "route() returns default route" do + test "route/2 returns default route" do assert Simulation.route(:simulation, "/random_path") == "/*" end end