├── README.md ├── api ├── .formatter.exs ├── .gitignore ├── README.md ├── config │ ├── config.exs │ ├── dev.exs │ ├── prod.exs │ └── test.exs ├── lib │ ├── elixir_bench │ │ ├── application.ex │ │ ├── benchmarks │ │ │ ├── benchmark.ex │ │ │ ├── benchmarks.ex │ │ │ ├── config.ex │ │ │ ├── job.ex │ │ │ ├── measurement.ex │ │ │ └── runner.ex │ │ ├── github │ │ │ ├── client.ex │ │ │ └── github.ex │ │ ├── repo.ex │ │ └── repos │ │ │ ├── repo.ex │ │ │ └── repos.ex │ ├── elixir_bench_web.ex │ └── elixir_bench_web │ │ ├── controllers │ │ ├── fallback_controller.ex │ │ └── job_controller.ex │ │ ├── endpoint.ex │ │ ├── router.ex │ │ ├── schema.ex │ │ ├── schema │ │ └── content_types.ex │ │ └── views │ │ ├── changeset_view.ex │ │ ├── error_helpers.ex │ │ ├── error_view.ex │ │ └── job_view.ex ├── mix.exs ├── mix.lock ├── priv │ └── repo │ │ ├── migrations │ │ ├── 20171209140949_create_repos.exs │ │ ├── 20171209141057_create_benchmarks.exs │ │ ├── 20171209155559_create_measurements.exs │ │ ├── 20171210113018_create_runners.exs │ │ ├── 20171210122300_create_jobs.exs │ │ ├── 20171210162439_add_config_to_jobs.exs │ │ └── 20171210214237_add_uuid_to_job.exs │ │ └── seeds.exs ├── rel │ └── config.exs └── test │ ├── elixir_bench │ └── repos │ │ └── repos_test.exs │ ├── elixir_bench_web │ └── views │ │ └── error_view_test.exs │ ├── support │ ├── channel_case.ex │ ├── conn_case.ex │ └── data_case.ex │ └── test_helper.exs ├── runner-container ├── Dockerfile ├── README.md └── docker-entrypoint.sh ├── runner ├── .formatter.exs ├── .gitignore ├── README.md ├── config │ └── config.exs ├── lib │ ├── runner.ex │ └── runner │ │ ├── api.ex │ │ ├── application.ex │ │ ├── config.ex │ │ ├── config │ │ └── parser.ex │ │ └── job.ex ├── mix.exs ├── mix.lock ├── rel │ └── config.exs └── test │ ├── runner │ ├── config_test.exs │ └── job_test.exs │ └── test_helper.exs └── web ├── .env.development ├── .env.production ├── .gitignore ├── README.md ├── package.json ├── public ├── CNAME ├── favicon.ico ├── images │ ├── icons │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ └── safari-pinned-tab.svg │ ├── logo-white.svg │ ├── logo.png │ └── logo.svg ├── index.html └── manifest.json ├── src ├── __templates │ ├── Component │ │ ├── index.js │ │ └── styles.js │ └── List │ │ ├── ListItem │ │ ├── index.js │ │ └── styles.js │ │ ├── index.js │ │ └── styles.js ├── components │ ├── Button │ │ ├── index.js │ │ └── styles.js │ ├── ElixirBenchLogo │ │ └── index.js │ ├── FormField │ │ ├── index.js │ │ └── styles.js │ ├── GithubLogo │ │ └── index.js │ ├── Grid │ │ ├── index.js │ │ └── styles.js │ ├── GridContainer │ │ ├── index.js │ │ └── styles.js │ ├── Logs │ │ ├── index.js │ │ └── styles.js │ ├── Page │ │ ├── index.js │ │ └── styles.js │ └── PageBlock │ │ ├── index.js │ │ └── styles.js ├── configs │ └── index.js ├── containers │ ├── App.js │ ├── blocks │ │ ├── BenchmarksList │ │ │ ├── BenchmarkListItem │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── Footer │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── JobsList │ │ │ ├── JobListItem │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── MeasurementsChart │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── Navigation │ │ │ ├── index.js │ │ │ └── styles.js │ │ └── ReposList │ │ │ ├── RepoListItem │ │ │ ├── index.js │ │ │ └── styles.js │ │ │ ├── index.js │ │ │ └── styles.js │ ├── forms │ │ └── ScheduleJobForm │ │ │ ├── index.js │ │ │ └── styles.js │ ├── layouts │ │ └── AppLayout │ │ │ ├── index.js │ │ │ └── styles.js │ └── pages │ │ ├── BenchmarkDetailsPage │ │ ├── index.js │ │ └── styles.js │ │ ├── IndexPage │ │ └── index.js │ │ ├── JobDetailsPage │ │ ├── index.js │ │ └── styles.js │ │ ├── NotFoundPage │ │ └── index.js │ │ ├── RepoDetailsPage │ │ ├── index.js │ │ └── styles.js │ │ └── ReposListPage │ │ ├── index.js │ │ └── styles.js ├── graphql │ ├── mutations │ │ └── index.js │ └── queries │ │ └── index.js ├── index.js ├── reducers │ └── index.js ├── registerServiceWorker.js ├── routes │ └── index.js └── services │ ├── apolloClient.js │ └── store.js └── yarn.lock /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # ElixirBench 4 | 5 | Long Running Benchmarks for Elixir Projects. 6 | 7 | ## Goal 8 | 9 | The primary goal of ElixirBench is to improve the Elixir and Erlang ecosystem of open source libraries. 10 | 11 | We're maintainers of couple open source packages, and we know how difficult it sometimes is, 12 | to guard against performance regressions. While test suites are popular, benchmark suites are not - 13 | which is a great shame. Poor performace is a bug like every other yet it is often overlooked. 14 | 15 | To solve this problem, we decided to create this project that will help mainainers of 16 | libraries in providing consistent performance of their code through constant monitoring. 17 | 18 | The project is inspired by similar projects from other ecosystems: 19 | * https://rubybench.org/ 20 | * https://speed.python.org/ 21 | * https://perf.rust-lang.org/ 22 | 23 | Unlike those projects, though, that are focused on some fixed packages, ElixirBench looks to 24 | provide a common service, similar to a continuous integration system, available for all packages 25 | published on Hex. 26 | 27 | ## Implementation 28 | 29 | The project consists of several components: 30 | 31 | * [the API server](api/) - powers everything and is responsible for scheduling execution of 32 | benchmarks. The server provides a public GraphQL API for exploring the results of the 33 | benchmarks and a private JSON API for communication with the runner server. 34 | * [the runner server](runner/) - runs on separate infrastructure and is responsible for consistent 35 | execution of benchmarks. 36 | * [the runner container](runner-container/) - scripts to build docker container which fetches project 37 | source and executes benchmarks. 38 | * [the front-end](web/) - is a website that leverages the GraphQL API and facilitates exploration 39 | of the results.. 40 | 41 | ## How to use it 42 | 43 | To leverage ElixirBench in a project, a YAML configuration file is expected in `bench/config.yml` 44 | in the project's Githib repository. 45 | This configuration file specifies the environment for running the benchmark. Additional services, 46 | like databases can be provisioned through docker containers. 47 | 48 | ```yaml 49 | elixir: 1.5.2 50 | erlang: 20.1.2 51 | environment: 52 | PG_URL: postgres:postgres@localhost 53 | MYSQL_URL: root@localhost 54 | deps: 55 | docker: 56 | - container_name: postgres 57 | image: postgres:9.6.6-alpine 58 | - container_name: mysql 59 | image: mysql:5.7.20 60 | environment: 61 | MYSQL_ALLOW_EMPTY_PASSWORD: "true" 62 | ``` 63 | 64 | Right now, only one benchmark runner is supported - the [`benchee`](https://github.com/PragTob/benchee) package. 65 | The runner, once the whole environment is brought up, will invoke a single command to run the benchmarks: 66 | ``` 67 | mix run benches/bench_helper.exs 68 | ``` 69 | This script is responsible for setup, execution and cleanup of benchmarks. 70 | Results of the runs, should be stored in JSON format in the directory indicated by the 71 | `BENCHMARKS_OUTPUT_PATH` environment variable. An example benchmark can be found in the 72 | [Ecto repository](https://github.com/elixir-ecto/ecto/blob/00284340a69f4cb5327323f12e37c98a81208279/bench/insert_bench.exs). 73 | 74 | In the future, we expect to establish a common file format for results and support multiple 75 | benchmark runners for mix and rebar projects. 76 | 77 | ### Running benchmarks 78 | 79 | Benchmarking UI is available on the [official website](http://www.elixirbench.org/). 80 | 81 | In the future, benchmarks should be executed automatically through hooks on repositories whenever 82 | new code is pushed. Right now, a manual scheduling of runs is required. This can be done in the 83 | [GraphiQL](https://api.elixirbench.org/api/graphiql) API explorer by issuing a mutation: 84 | ```graphql 85 | mutation { 86 | scheduleJob(repoSlug: "elixir-ecto/ecto", branchName: "mm/benches", commitSha: "2a5a8efbc3afee3c6893f4cba33679e98142df3f") { 87 | id 88 | } 89 | } 90 | ``` 91 | The above branch and commit contain a valid sample benchmark. 92 | 93 | ## Future development 94 | 95 | - Improve test coverage; 96 | - Foolproof agains common errors; 97 | - Investigate security of worker docker containers; 98 | - Create a GitHub marketplace app that would ease integration to the level of most common CI tools; 99 | - Automatic running of benchmarks through repository hooks; 100 | - More benchmark tools which are easier to use and require less manual setup - maybe some generators; 101 | - Also, it would be awesome to have integration with GitHub PR check's, so that maintainers can keep track how each PR affects library performance. If we would not be able to handle heavy load because of all that jobs - we can white-list most common packages and give it's developers lightweight version or PR checks - to request to run some commit from another fork/branch to compare how it works before merging it; 102 | - Provide reliable servers. Since we're running benchmarks, cloud server providers are not 103 | appropriate because of the noisy neighbour problem. Some dedicated servers would be required 104 | for running the benchmarks. 105 | -------------------------------------------------------------------------------- /api/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | locals_without_parens: [ 5 | plug: 1, 6 | plug: 2, 7 | pipeline: 2, 8 | scope: 2, 9 | pipe_through: 1, 10 | forward: 3, 11 | socket: 2, 12 | object: 2, 13 | field: 2, 14 | field: 3 15 | ], 16 | line_length: 120 17 | ] 18 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | 7 | # Generated on crash by the VM 8 | erl_crash.dump 9 | 10 | # Files matching config/*.secret.exs pattern contain sensitive 11 | # data and you should not commit them into version control. 12 | # 13 | # Alternatively, you may comment the line below and commit the 14 | # secrets files as long as you replace their contents by environment 15 | # variables. 16 | /config/*.secret.exs 17 | -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # ElixirBench API 4 | 5 | This project provides a public GraphQL API for exploring the results of the 6 | benchmarks and is responsible for jobs scheduling. 7 | 8 | You can explore it's API doc in public [GraphiQL](https://api.elixirbench.org/api/graphiql) 9 | interface. 10 | 11 | ## Requirements 12 | 13 | The projects needs Erlang, Elixir and PostgreSQL installed. 14 | 15 | For development, a database user called `postgress` with password `postgress` is required. 16 | If desired otherwise, this configuration can be changed in `config/dev.exs`. 17 | 18 | ## Getting started 19 | 20 | To start your Phoenix server: 21 | 22 | * Install dependencies with `mix deps.get` 23 | * Set up the database and some sample seed data `mix ecto.setup` 24 | * Start Phoenix endpoint with `mix phx.server` 25 | 26 | Now you can visit [`localhost:4000/api/graphiql`](http://localhost:4000/api/graphiql) from your browser. 27 | 28 | ## Deployment 29 | 30 | To build the release you can use `mix release`. The relese requires a `PORT` environment variable. 31 | -------------------------------------------------------------------------------- /api/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | use Mix.Config 7 | 8 | # General application configuration 9 | config :elixir_bench, 10 | ecto_repos: [ElixirBench.Repo] 11 | 12 | # Configures the endpoint 13 | config :elixir_bench, ElixirBenchWeb.Endpoint, 14 | url: [host: "localhost"], 15 | secret_key_base: "T3V6SzBeO9ItbppdTUPuT/RK11O21rhBDKBcklh/0l58bD16hKRC1RzXMapYv1wy", 16 | render_errors: [view: ElixirBenchWeb.ErrorView, accepts: ~w(json)], 17 | pubsub: [name: ElixirBench.PubSub, 18 | adapter: Phoenix.PubSub.PG2] 19 | 20 | # Configures Elixir's Logger 21 | config :logger, :console, 22 | format: "$time $metadata[$level] $message\n", 23 | metadata: [:request_id] 24 | 25 | config :elixir_bench, :default_elixir_version, {:system, "DEFAULT_ELIXIR_VERSION", "1.5.2"} 26 | config :elixir_bench, :supported_elixir_versions, {:system, :list, "SUPPORTED_ELIXIR_VERSIONS", ["1.5.2"]} 27 | 28 | config :elixir_bench, :default_erlang_version, {:system, "DEFAULT_ERLANG_VERSION", "20.1.2"} 29 | config :elixir_bench, :supported_erlang_versions, {:system, :list, "SUPPORTED_ERLANG_VERSIONS", ["20.1.2"]} 30 | 31 | 32 | # Import environment specific config. This must remain at the bottom 33 | # of this file so it overrides the configuration defined above. 34 | import_config "#{Mix.env}.exs" 35 | -------------------------------------------------------------------------------- /api/config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with brunch.io to recompile .js and .css sources. 9 | config :elixir_bench, ElixirBenchWeb.Endpoint, 10 | http: [port: 4000], 11 | debug_errors: true, 12 | code_reloader: true, 13 | check_origin: false, 14 | watchers: [] 15 | 16 | # ## SSL Support 17 | # 18 | # In order to use HTTPS in development, a self-signed 19 | # certificate can be generated by running the following 20 | # command from your terminal: 21 | # 22 | # openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 -subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=www.example.com" -keyout priv/server.key -out priv/server.pem 23 | # 24 | # The `http:` config above can be replaced with: 25 | # 26 | # https: [port: 4000, keyfile: "priv/server.key", certfile: "priv/server.pem"], 27 | # 28 | # If desired, both `http:` and `https:` keys can be 29 | # configured to run both http and https servers on 30 | # different ports. 31 | 32 | # Do not include metadata nor timestamps in development logs 33 | config :logger, :console, format: "[$level] $message\n" 34 | 35 | # Set a higher stacktrace during development. Avoid configuring such 36 | # in production as building large stacktraces may be expensive. 37 | config :phoenix, :stacktrace_depth, 20 38 | 39 | # Configure your database 40 | config :elixir_bench, ElixirBench.Repo, 41 | adapter: Ecto.Adapters.Postgres, 42 | username: "postgres", 43 | password: "postgres", 44 | database: "elixir_bench_dev", 45 | hostname: "localhost", 46 | pool_size: 10 47 | -------------------------------------------------------------------------------- /api/config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For production, we often load configuration from external 4 | # sources, such as your system environment. For this reason, 5 | # you won't find the :http configuration below, but set inside 6 | # ElixirBenchWeb.Endpoint.init/2 when load_from_system_env is 7 | # true. Any dynamic configuration should be done there. 8 | # 9 | # Don't forget to configure the url host to something meaningful, 10 | # Phoenix uses this information when generating URLs. 11 | # 12 | # Finally, we also include the path to a cache manifest 13 | # containing the digested version of static files. This 14 | # manifest is generated by the mix phx.digest task 15 | # which you typically run after static files are built. 16 | config :elixir_bench, ElixirBenchWeb.Endpoint, 17 | load_from_system_env: true, 18 | url: [host: "elixirbench.org", port: 80], 19 | cache_static_manifest: "priv/static/cache_manifest.json" 20 | 21 | # Do not print debug messages in production 22 | config :logger, level: :info 23 | 24 | # ## SSL Support 25 | # 26 | # To get SSL working, you will need to add the `https` key 27 | # to the previous section and set your `:url` port to 443: 28 | # 29 | # config :elixir_bench, ElixirBenchWeb.Endpoint, 30 | # ... 31 | # url: [host: "example.com", port: 443], 32 | # https: [:inet6, 33 | # port: 443, 34 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 35 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")] 36 | # 37 | # Where those two env variables return an absolute path to 38 | # the key and cert in disk or a relative path inside priv, 39 | # for example "priv/ssl/server.key". 40 | # 41 | # We also recommend setting `force_ssl`, ensuring no data is 42 | # ever sent via http, always redirecting to https: 43 | # 44 | # config :elixir_bench, ElixirBenchWeb.Endpoint, 45 | # force_ssl: [hsts: true] 46 | # 47 | # Check `Plug.SSL` for all available options in `force_ssl`. 48 | 49 | config :elixir_bench, ElixirBenchWeb.Endpoint, server: true 50 | 51 | # Finally import the config/prod.secret.exs 52 | # which should be versioned separately. 53 | import_config "prod.secret.exs" 54 | -------------------------------------------------------------------------------- /api/config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :elixir_bench, ElixirBenchWeb.Endpoint, 6 | http: [port: 4001], 7 | server: false 8 | 9 | # Print only warnings and errors during test 10 | config :logger, level: :warn 11 | 12 | # Configure your database 13 | config :elixir_bench, ElixirBench.Repo, 14 | adapter: Ecto.Adapters.Postgres, 15 | username: "postgres", 16 | password: "postgres", 17 | database: "elixir_bench_test", 18 | hostname: "localhost", 19 | pool: Ecto.Adapters.SQL.Sandbox 20 | -------------------------------------------------------------------------------- /api/lib/elixir_bench/application.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Application do 2 | use Application 3 | 4 | # See https://hexdocs.pm/elixir/Application.html 5 | # for more information on OTP Applications 6 | def start(_type, _args) do 7 | import Supervisor.Spec 8 | 9 | # Define workers and child supervisors to be supervised 10 | children = [ 11 | # Start the Ecto repository 12 | supervisor(ElixirBench.Repo, []), 13 | # Start the endpoint when the application starts 14 | supervisor(ElixirBenchWeb.Endpoint, []), 15 | # Start your own worker by calling: ElixirBench.Worker.start_link(arg1, arg2, arg3) 16 | # worker(ElixirBench.Worker, [arg1, arg2, arg3]), 17 | ] 18 | 19 | # See https://hexdocs.pm/elixir/Supervisor.html 20 | # for other strategies and supported options 21 | opts = [strategy: :one_for_one, name: ElixirBench.Supervisor] 22 | Supervisor.start_link(children, opts) 23 | end 24 | 25 | # Tell Phoenix to update the endpoint configuration 26 | # whenever the application is updated. 27 | def config_change(changed, _new, removed) do 28 | ElixirBenchWeb.Endpoint.config_change(changed, removed) 29 | :ok 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /api/lib/elixir_bench/benchmarks/benchmark.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Benchmarks.Benchmark do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | alias ElixirBench.Benchmarks.{Benchmark, Measurement} 6 | 7 | schema "benchmarks" do 8 | field :name, :string 9 | field :repo_id, :integer 10 | 11 | has_many :measurements, Measurement 12 | 13 | timestamps(type: :utc_datetime) 14 | end 15 | 16 | @doc false 17 | def changeset(%Benchmark{} = benchmark, attrs) do 18 | benchmark 19 | |> cast(attrs, [:name]) 20 | |> validate_required([:name]) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /api/lib/elixir_bench/benchmarks/benchmarks.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Benchmarks do 2 | import Ecto 3 | import Ecto.Query, warn: false 4 | alias ElixirBench.Repo 5 | alias Ecto.Multi 6 | 7 | alias ElixirBench.Github 8 | alias ElixirBench.Benchmarks.{Benchmark, Measurement, Job, Runner, Config} 9 | 10 | def data() do 11 | Dataloader.Ecto.new(Repo, query: &query/2) 12 | end 13 | 14 | def query(Benchmark, %{repo_id: repo_id}) do 15 | from b in Benchmark, where: b.repo_id == ^repo_id 16 | end 17 | 18 | def query(queryable, _args) do 19 | queryable 20 | end 21 | 22 | def create_runner(attrs) do 23 | %Runner{} 24 | |> Runner.changeset(attrs) 25 | |> Repo.insert() 26 | end 27 | 28 | def authenticate_runner(name, api_key) do 29 | with {:ok, runner} <- Repo.fetch(where(Runner, name: ^name)) do 30 | if Runner.verify_api_key?(runner, api_key) do 31 | {:ok, runner} 32 | else 33 | {:error, :not_found} 34 | end 35 | end 36 | end 37 | 38 | def fetch_benchmark(repo_id, name) do 39 | Repo.fetch(where(Benchmark, repo_id: ^repo_id, name: ^name)) 40 | end 41 | 42 | def fetch_measurement(id) do 43 | Repo.fetch(where(Measurement, id: ^id)) 44 | end 45 | 46 | def fetch_job(id) do 47 | Repo.fetch(where(Job, id: ^id)) 48 | end 49 | 50 | def fetch_job_by_uuid(uuid) do 51 | Repo.fetch(where(Job, uuid: ^uuid)) 52 | end 53 | 54 | def list_benchmarks_by_repo_id(repo_ids) do 55 | Repo.all(from(b in Benchmark, where: b.repo_id in ^repo_ids)) 56 | end 57 | 58 | def list_jobs_by_repo_id(repo_ids) do 59 | Repo.all(from(j in Job, where: j.repo_id in ^repo_ids)) 60 | end 61 | 62 | def list_jobs() do 63 | Repo.all(Job) 64 | end 65 | 66 | def create_job(repo, attrs) do 67 | changeset = Job.create_changeset(%Job{repo_id: repo.id}, attrs) 68 | with {:ok, job} <- Ecto.Changeset.apply_action(changeset, :insert), 69 | {:ok, raw_config} <- Github.fetch_config(repo.owner, repo.name, job.commit_sha) do 70 | config_changeset = Config.changeset(%Config{}, raw_config) 71 | changeset 72 | |> Ecto.Changeset.put_embed(:config, config_changeset) 73 | |> Repo.insert() 74 | end 75 | end 76 | 77 | def claim_job(%Runner{} = runner) do 78 | Repo.transaction(fn -> 79 | with {:ok, job} <- fetch_unclaimed_job(runner) do 80 | changeset = Job.claim_changeset(job, runner.id) 81 | Repo.update!(changeset) 82 | else 83 | {:error, error} -> Repo.rollback(error) 84 | end 85 | end) 86 | end 87 | 88 | def submit_job(%Job{} = job, results) do 89 | multi = Multi.update(Multi.new(), :job, Job.submit_changeset(job, results)) 90 | 91 | multi = 92 | Enum.reduce(results["measurements"] || [], multi, fn {name, result}, multi -> 93 | benchmark = {:benchmark, name} 94 | 95 | multi 96 | |> Multi.run(benchmark, fn _ -> 97 | {:ok, get_or_create_benchmark!(job.repo_id, name)} 98 | end) 99 | |> Multi.run({:measurement, name}, fn %{^benchmark => benchmark} -> 100 | create_measurement(benchmark, job, result) 101 | end) 102 | end) 103 | 104 | case Repo.transaction(multi) do 105 | {:ok, _} -> :ok 106 | {:error, _, changeset, _} -> {:error, changeset} 107 | end 108 | end 109 | 110 | defp get_or_create_benchmark!(repo_id, name) do 111 | # We need to set something on_conflict, otherwise, we won't be able to get the id 112 | # of already existing result in returning 113 | updates = [updated_at: DateTime.utc_now()] 114 | opts = [on_conflict: [set: updates], conflict_target: [:repo_id, :name], returning: true] 115 | Repo.insert!(%Benchmark{repo_id: repo_id, name: name}, opts) 116 | end 117 | 118 | defp create_measurement(%Benchmark{} = bench, %Job{} = job, attrs) do 119 | build_assoc(bench, :measurements, job_id: job.id) 120 | |> Measurement.changeset(attrs) 121 | |> Repo.insert() 122 | end 123 | 124 | defp fetch_unclaimed_job(runner) do 125 | # Unclaimed or claimed by this runner but not completed 126 | Repo.fetch(from j in Job, 127 | where: is_nil(j.claimed_by) and is_nil(j.claimed_at) and is_nil(j.completed_at), 128 | or_where: j.claimed_by == ^runner.id and not is_nil(j.claimed_at) and is_nil(j.completed_at), 129 | lock: "FOR UPDATE SKIP LOCKED", 130 | order_by: j.inserted_at, 131 | limit: 1 132 | ) 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /api/lib/elixir_bench/benchmarks/config.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Benchmarks.Config do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | alias ElixirBench.Benchmarks.Config 6 | 7 | @primary_key false 8 | 9 | embedded_schema do 10 | field :elixir, :string, default: Confex.fetch_env!(:elixir_bench, :default_elixir_version) 11 | field :erlang, :string, default: Confex.fetch_env!(:elixir_bench, :default_erlang_version) 12 | field :environment, {:map, :string}, default: %{} 13 | embeds_one :deps, Dep, primary_key: false do 14 | embeds_many :docker, Docker, primary_key: {:image, :string, []} do 15 | field :container_name, :string 16 | field :environment, {:map, :string}, default: %{} 17 | end 18 | end 19 | end 20 | 21 | def changeset(%Config{} = config, attrs) do 22 | supported_elixir_version = Confex.fetch_env!(:elixir_bench, :supported_elixir_versions) 23 | supported_erlang_version = Confex.fetch_env!(:elixir_bench, :supported_erlang_versions) 24 | 25 | config 26 | |> cast(attrs, [:elixir, :erlang, :environment]) 27 | |> validate_inclusion(:elixir, supported_elixir_version) 28 | |> validate_inclusion(:erlang, supported_erlang_version) 29 | |> cast_embed(:deps, with: &deps_changeset/2) 30 | end 31 | 32 | defp deps_changeset(deps, attrs) do 33 | deps 34 | |> cast(attrs, []) 35 | |> cast_embed(:docker, with: &docker_changeset/2) 36 | end 37 | 38 | defp docker_changeset(docker, attrs) do 39 | docker 40 | |> cast(attrs, [:image, :container_name, :environment]) 41 | |> validate_required([:image]) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /api/lib/elixir_bench/benchmarks/job.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Benchmarks.Job do 2 | use Ecto.Schema 3 | 4 | import Ecto.Changeset 5 | 6 | alias ElixirBench.Benchmarks.{Runner, Job, Config} 7 | 8 | schema "jobs" do 9 | field :uuid, :binary_id 10 | field :repo_id, :id 11 | 12 | belongs_to :claimant, Runner, foreign_key: :claimed_by 13 | field :claimed_at, :utc_datetime 14 | field :completed_at, :utc_datetime 15 | field :log, :string 16 | 17 | field :branch_name, :string 18 | field :commit_message, :string 19 | field :commit_sha, :string 20 | field :commit_url, :string 21 | 22 | field :cpu, :string 23 | field :cpu_count, :integer 24 | field :dependency_versions, {:map, :string} 25 | field :elixir_version, :string 26 | field :erlang_version, :string 27 | field :memory_mb, :integer 28 | 29 | embeds_one :config, Config 30 | 31 | timestamps() 32 | end 33 | 34 | @submit_fields [ 35 | :elixir_version, 36 | :erlang_version, 37 | :dependency_versions, 38 | :cpu, 39 | :cpu_count, 40 | # TODO: change to a string memory 41 | # :memory_mb, 42 | :log 43 | ] 44 | 45 | @create_fields [ 46 | :branch_name, 47 | :commit_sha, 48 | ] 49 | 50 | def claim_changeset(%Job{} = job, claimed_by) do 51 | job 52 | |> change(claimed_by: claimed_by, claimed_at: DateTime.utc_now()) 53 | end 54 | 55 | def create_changeset(%Job{} = job, attrs) do 56 | job 57 | |> cast(attrs, @create_fields) 58 | |> validate_required(@create_fields) 59 | |> put_change(:uuid, Ecto.UUID.generate()) 60 | end 61 | 62 | def submit_changeset(%Job{} = job, attrs) do 63 | job 64 | |> cast(attrs, @submit_fields) 65 | |> validate_required(@submit_fields) 66 | |> put_change(:completed_at, DateTime.utc_now()) 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /api/lib/elixir_bench/benchmarks/measurement.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Benchmarks.Measurement do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | alias ElixirBench.Benchmarks.{Benchmark, Job, Measurement} 5 | 6 | schema "measurements" do 7 | belongs_to :benchmark, Benchmark 8 | belongs_to :job, Job 9 | 10 | field :sample_size, :integer 11 | field :mode, :float 12 | field :minimum, :float 13 | field :median, :float 14 | field :maximum, :float 15 | field :average, :float 16 | field :std_dev, :float 17 | field :std_dev_ratio, :float 18 | 19 | field :ips, :float 20 | field :std_dev_ips, :float 21 | 22 | field :run_times, {:array, :float} 23 | field :percentiles, {:map, :float} 24 | 25 | timestamps(type: :utc_datetime) 26 | end 27 | 28 | @fields [ 29 | :sample_size, 30 | :mode, 31 | :minimum, 32 | :median, 33 | :maximum, 34 | :average, 35 | :std_dev, 36 | :std_dev_ratio, 37 | :ips, 38 | :std_dev_ips, 39 | :run_times, 40 | :percentiles 41 | ] 42 | 43 | @doc false 44 | def changeset(%Measurement{} = measurement, attrs) do 45 | measurement 46 | |> cast(attrs, @fields) 47 | |> validate_required(@fields) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /api/lib/elixir_bench/benchmarks/runner.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Benchmarks.Runner do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | alias ElixirBench.Benchmarks.Runner 5 | 6 | schema "runners" do 7 | field :api_key, :string, virtual: true 8 | field :api_key_hash, :string 9 | field :name, :string 10 | 11 | timestamps() 12 | end 13 | 14 | @doc false 15 | def changeset(%Runner{} = runners, attrs) do 16 | runners 17 | |> cast(attrs, [:name, :api_key]) 18 | |> validate_required([:name, :api_key]) 19 | |> hash_api_key() 20 | end 21 | 22 | def verify_api_key?(%Runner{api_key_hash: key_hash}, key) do 23 | Bcrypt.verify_pass(key, key_hash) 24 | end 25 | 26 | defp hash_api_key(changeset) do 27 | if api_key = get_change(changeset, :api_key) do 28 | change(changeset, api_key_hash: Bcrypt.hash_pwd_salt(api_key)) 29 | else 30 | changeset 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /api/lib/elixir_bench/github/client.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Github.Client do 2 | alias ElixirBench.Github.Client 3 | 4 | defstruct [:base_url, ssl_options: []] 5 | 6 | def get_plain(%Client{} = client, url, params \\ []) do 7 | get(client, url(client, url, params), [{"accept", "text/plain"}], &(&1)) 8 | end 9 | 10 | def get_json(%Client{} = client, url, params \\ []) do 11 | get(client, url(client, url, params), [{"accept", "application/json"}], &Antidote.decode/1) 12 | end 13 | 14 | def get_yaml(%Client{} = client, url, params \\ []) do 15 | get(client, url(client, url, params), [{"accept", "application/yaml"}], &decode_yaml/1) 16 | end 17 | 18 | @doc false 19 | # Public for testing purposes 20 | def decode_yaml(content) do 21 | case :yamerl.decode(content, [:str_node_as_binary, map_node_format: :map]) do 22 | [parsed] -> {:ok, parsed} 23 | _ -> {:error, :invalid} 24 | end 25 | end 26 | 27 | defp get(client, url, headers, cb) do 28 | ssl_options = client.ssl_options 29 | options = [:with_body, ssl_options: ssl_options] 30 | 31 | case :hackney.get(url, headers, <<>>, options) do 32 | {:ok, 200, _headers, data} -> 33 | cb.(data) 34 | {:ok, status, _headers, data} -> 35 | {:error, {status, data}} 36 | {:error, error} -> 37 | {:error, error} 38 | end 39 | end 40 | 41 | defp url(%{base_url: base}, url, params) do 42 | Path.join(base, url) <> "?" <> URI.encode_query(params) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /api/lib/elixir_bench/github/github.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Github do 2 | require Logger 3 | 4 | alias ElixirBench.Github.Client 5 | 6 | def fetch_config(repo_owner, repo_name, branch_or_commit) do 7 | path = [repo_owner, "/", repo_name, "/", branch_or_commit, "/bench/config.yml"] 8 | slug = "#{repo_owner}/#{repo_name}##{branch_or_commit}" 9 | Logger.info("Requesting bench config for: #{slug}") 10 | case Client.get_yaml(raw_client(), path) do 11 | {:ok, config} -> 12 | {:ok, config} 13 | {:error, {404, _}} -> 14 | {:error, :failed_config_fetch} 15 | {:error, reason} -> 16 | Logger.error("Failed to fetch config for #{slug}: #{inspect reason}") 17 | {:error, :failed_config_fetch} 18 | end 19 | end 20 | 21 | def raw_client() do 22 | %Client{base_url: "https://raw.githubusercontent.com"} 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /api/lib/elixir_bench/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Repo do 2 | use Ecto.Repo, otp_app: :elixir_bench 3 | 4 | def fetch(queryable, opts \\ []) do 5 | case one(queryable, opts) do 6 | nil -> {:error, :not_found} 7 | result -> {:ok, result} 8 | end 9 | end 10 | 11 | @doc """ 12 | Dynamically loads the repository url from the 13 | DATABASE_URL environment variable. 14 | """ 15 | def init(_, opts) do 16 | {:ok, Keyword.put(opts, :url, System.get_env("DATABASE_URL"))} 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /api/lib/elixir_bench/repos/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Repos.Repo do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | alias ElixirBench.Repos.Repo 5 | 6 | 7 | schema "repos" do 8 | field :name, :string 9 | field :owner, :string 10 | 11 | timestamps() 12 | end 13 | 14 | @doc false 15 | def changeset(%Repo{} = repo, attrs) do 16 | repo 17 | |> cast(attrs, [:owner, :name]) 18 | |> validate_required([:owner, :name]) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /api/lib/elixir_bench/repos/repos.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Repos do 2 | import Ecto.Query, warn: false 3 | alias ElixirBench.Repo 4 | 5 | alias ElixirBench.Repos 6 | 7 | def data() do 8 | Dataloader.Ecto.new(Repo, query: &query/2) 9 | end 10 | 11 | def query(queryable, _args) do 12 | queryable 13 | end 14 | 15 | def list_repos() do 16 | Repo.all(Repos.Repo) 17 | end 18 | 19 | def fetch_repo_by_slug(slug) do 20 | parse_slug(slug, fn owner, name -> 21 | Repo.fetch(where(Repos.Repo, [owner: ^owner, name: ^name])) 22 | end) 23 | end 24 | 25 | def fetch_repo(id) do 26 | Repo.fetch(where(Repos.Repo, id: ^id)) 27 | end 28 | 29 | def fetch_repo_id_by_slug(slug) do 30 | parse_slug(slug, fn owner, name -> 31 | Repo.fetch(from r in Repos.Repo, where: [owner: ^owner, name: ^name], select: r.id) 32 | end) 33 | end 34 | 35 | def create_repo(attrs \\ %{}) do 36 | %Repos.Repo{} 37 | |> Repos.Repo.changeset(attrs) 38 | |> Repo.insert() 39 | end 40 | 41 | def update_repo(%Repos.Repo{} = repo, attrs) do 42 | repo 43 | |> Repos.Repo.changeset(attrs) 44 | |> Repo.update() 45 | end 46 | 47 | def delete_repo(%Repos.Repo{} = repo) do 48 | Repo.delete(repo) 49 | end 50 | 51 | defp parse_slug(slug, callback) do 52 | case String.split(slug, "/", trim: true, parts: 2) do 53 | [owner, name] -> callback.(owner, name) 54 | _ -> {:error, :invalid_slug} 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /api/lib/elixir_bench_web.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBenchWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use ElixirBenchWeb, :controller 9 | use ElixirBenchWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: ElixirBenchWeb 23 | import Plug.Conn 24 | import ElixirBenchWeb.Router.Helpers 25 | end 26 | end 27 | 28 | def view do 29 | quote do 30 | use Phoenix.View, root: "lib/elixir_bench_web/templates", 31 | namespace: ElixirBenchWeb 32 | 33 | # Import convenience functions from controllers 34 | import Phoenix.Controller, only: [get_flash: 2, view_module: 1] 35 | 36 | import ElixirBenchWeb.Router.Helpers 37 | import ElixirBenchWeb.ErrorHelpers 38 | end 39 | end 40 | 41 | def router do 42 | quote do 43 | use Phoenix.Router 44 | import Plug.Conn 45 | import Phoenix.Controller 46 | end 47 | end 48 | 49 | def channel do 50 | quote do 51 | use Phoenix.Channel 52 | end 53 | end 54 | 55 | @doc """ 56 | When used, dispatch to the appropriate controller/view/etc. 57 | """ 58 | defmacro __using__(which) when is_atom(which) do 59 | apply(__MODULE__, which, []) 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /api/lib/elixir_bench_web/controllers/fallback_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBenchWeb.FallbackController do 2 | @moduledoc """ 3 | Translates controller action results into valid `Plug.Conn` responses. 4 | 5 | See `Phoenix.Controller.action_fallback/1` for more details. 6 | """ 7 | use ElixirBenchWeb, :controller 8 | 9 | def call(conn, {:error, %Ecto.Changeset{} = changeset}) do 10 | conn 11 | |> put_status(:unprocessable_entity) 12 | |> render(ElixirBenchWeb.ChangesetView, "error.json", changeset: changeset) 13 | end 14 | 15 | def call(conn, {:error, :not_found}) do 16 | conn 17 | |> put_status(:not_found) 18 | |> render(ElixirBenchWeb.ErrorView, :"404") 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /api/lib/elixir_bench_web/controllers/job_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBenchWeb.JobController do 2 | use ElixirBenchWeb, :controller 3 | 4 | alias ElixirBench.{Benchmarks, Repos, Benchmarks.Job} 5 | 6 | action_fallback ElixirBenchWeb.FallbackController 7 | 8 | def claim(conn, _params) do 9 | with {:ok, %Job{} = job} <- Benchmarks.claim_job(conn.assigns.runner), 10 | {:ok, %Repos.Repo{} = repo} <- Repos.fetch_repo(job.repo_id) do 11 | conn 12 | |> render("show.json", job: job, repo: repo) 13 | end 14 | end 15 | 16 | def update(conn, %{"id" => id, "job" => job_params}) do 17 | with {:ok, %Job{} = job} <- Benchmarks.fetch_job_by_uuid(id), 18 | :ok <- Benchmarks.submit_job(job, job_params) do 19 | conn 20 | |> send_resp(:no_content, "") 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /api/lib/elixir_bench_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBenchWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :elixir_bench 3 | 4 | # Serve at "/" the static files from "priv/static" directory. 5 | # 6 | # You should set gzip to true if you are running phoenix.digest 7 | # when deploying your static files in production. 8 | plug Plug.Static, 9 | at: "/", 10 | from: :elixir_bench, 11 | gzip: false, 12 | only: ~w(css fonts images js favicon.ico robots.txt) 13 | 14 | # Code reloading can be explicitly enabled under the 15 | # :code_reloader configuration of your endpoint. 16 | if code_reloading? do 17 | plug Phoenix.CodeReloader 18 | end 19 | 20 | plug Plug.RequestId 21 | plug Plug.Logger 22 | 23 | plug Plug.Parsers, 24 | parsers: [:urlencoded, :multipart, :json, Absinthe.Plug.Parser], 25 | pass: ["*/*"], 26 | json_decoder: Poison 27 | 28 | plug Plug.MethodOverride 29 | plug Plug.Head 30 | 31 | plug Corsica, max_age: 600, origins: [~r/localhost:\d+$/, ~r/elixirbench.org$/], allow_headers: ~w(accept content-type origin) 32 | 33 | plug ElixirBenchWeb.Router 34 | 35 | @doc """ 36 | Callback invoked for dynamically configuring the endpoint. 37 | 38 | It receives the endpoint configuration and checks if 39 | configuration should be loaded from the system environment. 40 | """ 41 | def init(_key, config) do 42 | if config[:load_from_system_env] do 43 | port = System.get_env("PORT") || raise "expected the PORT environment variable to be set" 44 | {:ok, Keyword.put(config, :http, [:inet6, port: port])} 45 | else 46 | {:ok, config} 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /api/lib/elixir_bench_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBenchWeb.Router do 2 | use ElixirBenchWeb, :router 3 | 4 | alias ElixirBench.Benchmarks 5 | 6 | pipeline :api do 7 | plug :accepts, ["json"] 8 | end 9 | 10 | pipeline :runner_secure do 11 | plug BasicAuth, callback: &__MODULE__.authenticate_runner/3 12 | end 13 | 14 | scope "/runner-api", ElixirBenchWeb do 15 | pipe_through [:api, :runner_secure] 16 | 17 | post "/jobs/claim", JobController, :claim 18 | put "/jobs/:id", JobController, :update 19 | end 20 | 21 | scope "/api" do 22 | pipe_through :api 23 | 24 | forward "/graphiql", Absinthe.Plug.GraphiQL, schema: ElixirBenchWeb.Schema 25 | 26 | forward "/", Absinthe.Plug, schema: ElixirBenchWeb.Schema 27 | end 28 | 29 | def authenticate_runner(conn, username, password) do 30 | case Benchmarks.authenticate_runner(username, password) do 31 | {:ok, runner} -> 32 | assign(conn, :runner, runner) 33 | {:error, _reason} -> 34 | send_resp(conn, :unauthorized, Antidote.encode!(%{error: "unauthorized"})) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /api/lib/elixir_bench_web/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBenchWeb.Schema do 2 | use Absinthe.Schema 3 | 4 | alias ElixirBenchWeb.Schema 5 | 6 | alias ElixirBench.{Repos, Benchmarks} 7 | 8 | import_types Absinthe.Type.Custom 9 | import_types Schema.ContentTypes 10 | 11 | query do 12 | field :repos, list_of(:repo) do 13 | resolve fn _, _ -> 14 | {:ok, Repos.list_repos()} 15 | end 16 | end 17 | 18 | field :repo, non_null(:repo) do 19 | arg :slug, non_null(:string) 20 | resolve fn %{slug: slug}, _ -> 21 | Repos.fetch_repo_by_slug(slug) 22 | end 23 | end 24 | 25 | field :benchmark, non_null(:benchmark) do 26 | arg :repo_slug, non_null(:string) 27 | arg :name, non_null(:string) 28 | resolve fn %{repo_slug: slug, name: name}, _ -> 29 | with {:ok, repo_id} <- Repos.fetch_repo_id_by_slug(slug) do 30 | Benchmarks.fetch_benchmark(repo_id, name) 31 | end 32 | end 33 | end 34 | 35 | field :measurement, non_null(:measurement) do 36 | arg :id, non_null(:id) 37 | resolve fn %{id: id}, _ -> 38 | Benchmarks.fetch_measurement(id) 39 | end 40 | end 41 | 42 | field :jobs, list_of(:job) do 43 | resolve fn _, _ -> 44 | {:ok, Benchmarks.list_jobs()} 45 | end 46 | end 47 | 48 | field :job, non_null(:job) do 49 | arg :id, non_null(:id) 50 | resolve fn %{id: id}, _ -> 51 | Benchmarks.fetch_job(id) 52 | end 53 | end 54 | end 55 | 56 | mutation do 57 | field :schedule_job, type: :job do 58 | arg :branch_name, non_null(:string) 59 | arg :commit_sha, non_null(:string) 60 | arg :repo_slug, non_null(:string) 61 | 62 | resolve fn %{repo_slug: slug} = data, _ -> 63 | with {:ok, repo} <- Repos.fetch_repo_by_slug(slug) do 64 | Benchmarks.create_job(repo, data) 65 | end 66 | end 67 | end 68 | end 69 | 70 | def context(ctx) do 71 | loader = 72 | Dataloader.new 73 | |> Dataloader.add_source(Repos, Repos.data()) 74 | |> Dataloader.add_source(Benchmarks, Benchmarks.data()) 75 | 76 | Map.put(ctx, :loader, loader) 77 | end 78 | 79 | def plugins do 80 | [Absinthe.Middleware.Dataloader] ++ Absinthe.Plugin.defaults() 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /api/lib/elixir_bench_web/schema/content_types.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBenchWeb.Schema.ContentTypes do 2 | use Absinthe.Schema.Notation 3 | use Absinthe.Ecto, repo: ElixirBench.Repo 4 | import Absinthe.Resolution.Helpers 5 | 6 | alias ElixirBench.{Repos, Benchmarks} 7 | 8 | object :repo do 9 | field :owner, :string 10 | field :name, :string 11 | field :slug, :string, resolve: fn repo, _args, _info -> {:ok, "#{repo.owner}/#{repo.name}"} end 12 | 13 | field :benchmarks, list_of(:benchmark) do 14 | resolve fn %{id: id}, _, _ -> 15 | batch({__MODULE__, :list_benchmarks_by_repo_id}, id, fn batch_results -> 16 | {:ok, Map.get(batch_results, id, [])} 17 | end) 18 | end 19 | end 20 | 21 | field :jobs, list_of(:job) do 22 | resilve fn %{id: id}, _, _ -> 23 | batch({__MODULE__, :list_jobs_by_repo_id}, id, fn batch_results -> 24 | {:ok, Map.get(batch_results, id, [])} 25 | end) 26 | end 27 | end 28 | end 29 | 30 | object :job do 31 | field :id, :id 32 | field :branch_name, :string 33 | field :commit_sha, :string 34 | field :claimed_at, :datetime 35 | field :completed_at, :datetime 36 | field :log, :string 37 | field :repo_slug, :string do 38 | resolve fn %{repo_id: repo_id}, _, %{context: %{loader: loader}} -> 39 | loader 40 | |> Dataloader.load(Repos, Repos.Repo, repo_id) 41 | |> on_load(fn loader -> 42 | %{owner: owner, name: name} = Dataloader.get(loader, Repos, Repos.Repo, repo_id) 43 | {:ok, "#{owner}/#{name}"} 44 | end) 45 | end 46 | end 47 | end 48 | 49 | object :benchmark do 50 | field :name, :string 51 | field :measurements, list_of(:measurement), resolve: assoc(:measurements) 52 | end 53 | 54 | object :measurement do 55 | field :id, :id 56 | field :collected_at, :datetime do 57 | resolve fn measurement, _, %{context: %{loader: loader}} -> 58 | loader 59 | |> Dataloader.load(Benchmarks, :job, measurement) 60 | |> on_load(fn loader -> 61 | %{completed_at: value} = Dataloader.get(loader, Benchmarks, :job, measurement) 62 | {:ok, value} 63 | end) 64 | end 65 | end 66 | field :commit, :commit, resolve: dataloader(Benchmarks, :job) 67 | field :environment, :environment, resolve: dataloader(Benchmarks, :job) 68 | field :result, :result, resolve: parent() 69 | end 70 | 71 | object :commit do 72 | field :sha, :string, resolve: key(:commit_sha) 73 | field :message, :string, resolve: key(:commit_message) 74 | field :url, :string, resolve: key(:commit_url) 75 | end 76 | 77 | object :environment do 78 | field :elixir_version, :string 79 | field :erlang_version, :string 80 | field :dependency_versions, list_of(:package_version), 81 | resolve: map_to_list(:dependency_versions, :name, :version) 82 | field :cpu, :string 83 | field :cpu_count, :integer 84 | field :memory, :integer, resolve: key(:memory_mb) 85 | end 86 | 87 | object :package_version do 88 | field :name, :string 89 | field :version, :string 90 | end 91 | 92 | object :result do 93 | field :sample_size, :integer 94 | field :percentiles, list_of(:percentile), resolve: map_to_list(:percentiles, :name, :value) 95 | 96 | field :mode, :float 97 | field :minimum, :float 98 | field :median, :float 99 | field :maximum, :float 100 | field :average, :float 101 | field :std_dev, :float 102 | field :std_dev_ratio, :float 103 | 104 | field :ips, :float 105 | field :std_dev_ips, :integer 106 | 107 | field :run_times, list_of(:float) 108 | end 109 | 110 | object :percentile do 111 | field :name, :string 112 | field :value, :float 113 | end 114 | 115 | defp parent() do 116 | fn parent, _, _ -> 117 | {:ok, parent} 118 | end 119 | end 120 | 121 | defp key(name) do 122 | fn %{^name => value}, _, _ -> 123 | {:ok, value} 124 | end 125 | end 126 | 127 | defp map_to_list(key, key_name, value_name) do 128 | fn %{^key => map}, _, _ -> 129 | {:ok, Enum.map(map, fn {key, value} -> %{key_name => key, value_name => value} end)} 130 | end 131 | end 132 | 133 | @doc false 134 | def list_benchmarks_by_repo_id(_, repo_ids) do 135 | data = Benchmarks.list_benchmarks_by_repo_id(repo_ids) 136 | Enum.group_by(data, &Map.fetch!(&1, :repo_id)) 137 | end 138 | 139 | @doc false 140 | def list_jobs_by_repo_id(_, repo_ids) do 141 | data = Benchmarks.list_jobs_by_repo_id(repo_ids) 142 | Enum.group_by(data, &Map.fetch!(&1, :repo_id)) 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /api/lib/elixir_bench_web/views/changeset_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBenchWeb.ChangesetView do 2 | use ElixirBenchWeb, :view 3 | 4 | @doc """ 5 | Traverses and translates changeset errors. 6 | 7 | See `Ecto.Changeset.traverse_errors/2` and 8 | `ElixirBenchWeb.ErrorHelpers.translate_error/1` for more details. 9 | """ 10 | def translate_errors(changeset) do 11 | Ecto.Changeset.traverse_errors(changeset, &translate_error/1) 12 | end 13 | 14 | def render("error.json", %{changeset: changeset}) do 15 | # When encoded, the changeset returns its errors 16 | # as a JSON object. So we just pass it forward. 17 | %{errors: translate_errors(changeset)} 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /api/lib/elixir_bench_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBenchWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | @doc """ 7 | Translates an error message using gettext. 8 | """ 9 | def translate_error({msg, _opts}) do 10 | msg 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /api/lib/elixir_bench_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBenchWeb.ErrorView do 2 | use ElixirBenchWeb, :view 3 | 4 | def render("404.json", _assigns) do 5 | %{errors: %{detail: "Page not found"}} 6 | end 7 | 8 | def render("500.json", _assigns) do 9 | %{errors: %{detail: "Internal server error"}} 10 | end 11 | 12 | # In case no render clause matches or no 13 | # template is found, let's render it as 500 14 | def template_not_found(_template, assigns) do 15 | render "500.json", assigns 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /api/lib/elixir_bench_web/views/job_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBenchWeb.JobView do 2 | use ElixirBenchWeb, :view 3 | alias ElixirBenchWeb.JobView 4 | 5 | def render("index.json", %{jobs: jobs, repo: repo}) do 6 | %{data: render_many(jobs, JobView, "job.json", repo: repo)} 7 | end 8 | 9 | def render("show.json", %{job: job, repo: repo}) do 10 | %{data: render_one(job, JobView, "job.json", repo: repo)} 11 | end 12 | 13 | def render("job.json", %{job: %{config: config} = job, repo: repo}) do 14 | %{ 15 | id: job.uuid, 16 | repo_slug: "#{repo.owner}/#{repo.name}", 17 | branch_name: job.branch_name, 18 | commit_sha: job.commit_sha, 19 | config: %{ 20 | elixir_version: config.elixir, 21 | erlang_version: config.erlang, 22 | environment_variables: config.environment, 23 | deps: %{ 24 | docker: render_docker(config.deps) 25 | } 26 | } 27 | } 28 | end 29 | 30 | defp render_docker(%{docker: docker}) when is_list(docker) do 31 | Enum.map(docker, &render_each_docker/1) 32 | end 33 | defp render_docker(nil), do: [] 34 | 35 | def render_each_docker(docker) do 36 | %{ 37 | image: docker.image, 38 | environment: docker.environment, 39 | container_name: docker.container_name 40 | } 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /api/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :elixir_bench, 7 | version: "0.0.1", 8 | elixir: "~> 1.4", 9 | elixirc_paths: elixirc_paths(Mix.env), 10 | compilers: [:phoenix, ] ++ Mix.compilers, 11 | start_permanent: Mix.env == :prod, 12 | aliases: aliases(), 13 | deps: deps() 14 | ] 15 | end 16 | 17 | # Configuration for the OTP application. 18 | # 19 | # Type `mix help compile.app` for more information. 20 | def application do 21 | [ 22 | mod: {ElixirBench.Application, []}, 23 | extra_applications: [:logger, :runtime_tools] 24 | ] 25 | end 26 | 27 | # Specifies which paths to compile per environment. 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | # Specifies your project dependencies. 32 | # 33 | # Type `mix help deps` for examples and options. 34 | defp deps do 35 | [ 36 | {:phoenix, "~> 1.3.0"}, 37 | {:phoenix_pubsub, "~> 1.0"}, 38 | {:phoenix_ecto, "~> 3.2"}, 39 | {:postgrex, ">= 0.0.0"}, 40 | {:cowboy, "~> 1.0"}, 41 | {:absinthe, "~> 1.4"}, 42 | {:absinthe_plug, "~> 1.4"}, 43 | {:absinthe_ecto, "~> 0.1.3"}, 44 | {:dataloader, "~> 1.0"}, 45 | {:corsica, "~> 1.0"}, 46 | {:distillery, "~> 1.5"}, 47 | {:bcrypt_elixir, "~> 1.0"}, 48 | {:hackney, "~> 1.10"}, 49 | {:antidote, github: "michalmuskala/antidote"}, 50 | {:yamerl, "~> 0.6.0"}, 51 | {:confex, "~> 3.3"}, 52 | {:basic_auth, "~> 2.2"} 53 | ] 54 | end 55 | 56 | # Aliases are shortcuts or tasks specific to the current project. 57 | # For example, to create, migrate and run the seeds file at once: 58 | # 59 | # $ mix ecto.setup 60 | # 61 | # See the documentation for `Mix` for more info on aliases. 62 | defp aliases do 63 | [ 64 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 65 | "ecto.reset": ["ecto.drop", "ecto.setup"], 66 | "test": ["ecto.create --quiet", "ecto.migrate", "test"] 67 | ] 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /api/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "absinthe": {:hex, :absinthe, "1.4.5", "a5c60aca65cdcd76305974f63d164573961d528af54e41f97e1ce6b400704e93", [], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 3 | "absinthe_ecto": {:hex, :absinthe_ecto, "0.1.3", "420b68129e79fe4571a4838904ba03e282330d335da47729ad52ffd7b8c5fcb1", [], [{:absinthe, "~> 1.3.0 or ~> 1.4.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:ecto, ">= 0.0.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "absinthe_plug": {:hex, :absinthe_plug, "1.4.1", "78e733f86869f4b1afec8c30afc42cb7efacb0ad73eaed624cf8a6108f14fcba", [], [{:absinthe, "~> 1.4", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.2 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "antidote": {:git, "https://github.com/michalmuskala/antidote.git", "55209fca8d6cccb1c3e735f420aa103cbcda7969", []}, 6 | "basic_auth": {:hex, :basic_auth, "2.2.1", "32e9383f4d3fbbecbe11690d73fabbd2e81fe0cf7d209f2bf8983c8e2b9f11bd", [], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 0.14 or ~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "bcrypt_elixir": {:hex, :bcrypt_elixir, "1.0.5", "cca70b5c8d9a98a0151c2d2796c728719c9c4b3f8bd2de015758ef577ee5141e", [], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "certifi": {:hex, :certifi, "2.0.0", "a0c0e475107135f76b8c1d5bc7efb33cd3815cb3cf3dea7aefdd174dabead064", [], [], "hexpm"}, 9 | "confex": {:hex, :confex, "3.3.1", "8febaf751bf293a16a1ed2cbd258459cdcc7ca53cfa61d3f83d49dd276a992b4", [], [], "hexpm"}, 10 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [], [], "hexpm"}, 11 | "corsica": {:hex, :corsica, "1.0.0", "e11d39e72c9907c96650d1a5b5586e9f333643a17d0120380ad1d7dacdc9eb43", [], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 12 | "cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [], [{:cowlib, "~> 1.0.2", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, 13 | "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [], [], "hexpm"}, 14 | "dataloader": {:hex, :dataloader, "1.0.0", "d461598ddaa822d32bd7aca59f4b592fb6c8b21d74723d9ce9a5b8e32c301a4d", [], [{:ecto, ">= 0.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, 15 | "db_connection": {:hex, :db_connection, "1.1.2", "2865c2a4bae0714e2213a0ce60a1b12d76a6efba0c51fbda59c9ab8d1accc7a8", [], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, 16 | "decimal": {:hex, :decimal, "1.4.1", "ad9e501edf7322f122f7fc151cce7c2a0c9ada96f2b0155b8a09a795c2029770", [], [], "hexpm"}, 17 | "distillery": {:hex, :distillery, "1.5.2", "eec18b2d37b55b0bcb670cf2bcf64228ed38ce8b046bb30a9b636a6f5a4c0080", [], [], "hexpm"}, 18 | "ecto": {:hex, :ecto, "2.2.7", "2074106ff4a5cd9cb2b54b12ca087c4b659ddb3f6b50be4562883c1d763fb031", [], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, 19 | "elixir_make": {:hex, :elixir_make, "0.4.0", "992f38fabe705bb45821a728f20914c554b276838433349d4f2341f7a687cddf", [], [], "hexpm"}, 20 | "gettext": {:hex, :gettext, "0.13.1", "5e0daf4e7636d771c4c71ad5f3f53ba09a9ae5c250e1ab9c42ba9edccc476263", [], [], "hexpm"}, 21 | "hackney": {:hex, :hackney, "1.10.1", "c38d0ca52ea80254936a32c45bb7eb414e7a96a521b4ce76d00a69753b157f21", [], [{:certifi, "2.0.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 22 | "idna": {:hex, :idna, "5.1.0", "d72b4effeb324ad5da3cab1767cb16b17939004e789d8c0ad5b70f3cea20c89a", [], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 23 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [], [], "hexpm"}, 24 | "mime": {:hex, :mime, "1.1.0", "01c1d6f4083d8aa5c7b8c246ade95139620ef8effb009edde934e0ec3b28090a", [], [], "hexpm"}, 25 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [], [], "hexpm"}, 26 | "phoenix": {:hex, :phoenix, "1.3.0", "1c01124caa1b4a7af46f2050ff11b267baa3edb441b45dbf243e979cd4c5891b", [], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, 27 | "phoenix_ecto": {:hex, :phoenix_ecto, "3.3.0", "702f6e164512853d29f9d20763493f2b3bcfcb44f118af2bc37bb95d0801b480", [], [{:ecto, "~> 2.1", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 28 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.0.2", "bfa7fd52788b5eaa09cb51ff9fcad1d9edfeb68251add458523f839392f034c1", [], [], "hexpm"}, 29 | "plug": {:hex, :plug, "1.4.3", "236d77ce7bf3e3a2668dc0d32a9b6f1f9b1f05361019946aae49874904be4aed", [], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, 30 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [], [], "hexpm"}, 31 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [], [], "hexpm"}, 32 | "postgrex": {:hex, :postgrex, "0.13.3", "c277cfb2a9c5034d445a722494c13359e361d344ef6f25d604c2353185682bfc", [], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"}, 33 | "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [], [], "hexpm"}, 34 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [], [], "hexpm"}, 35 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [], [], "hexpm"}, 36 | "yamerl": {:hex, :yamerl, "0.6.0", "5cb124a01a66418d7d8f2382948c89b48c1764079798eaeb9b578ddb3c7dc58b", [], [], "hexpm"}, 37 | } 38 | -------------------------------------------------------------------------------- /api/priv/repo/migrations/20171209140949_create_repos.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Repo.Migrations.CreateRepos do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:repos) do 6 | add :owner, :string 7 | add :name, :string 8 | 9 | timestamps() 10 | end 11 | 12 | create unique_index(:repos, [:owner, :name]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /api/priv/repo/migrations/20171209141057_create_benchmarks.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Repo.Migrations.CreateBenchmarks do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:benchmarks) do 6 | add :repo_id, references(:repos), null: false 7 | add :name, :string 8 | 9 | timestamps() 10 | end 11 | 12 | create unique_index(:benchmarks, [:repo_id, :name]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /api/priv/repo/migrations/20171209155559_create_measurements.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Repo.Migrations.CreateMeasurements do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:measurements) do 6 | add :collected_at, :utc_datetime 7 | add :commit_sha, :string 8 | add :commit_message, :text 9 | add :commited_date, :utc_datetime 10 | add :commit_url, :string 11 | 12 | add :elixir_version, :string 13 | add :erlang_version, :string 14 | add :dependency_versions, :map 15 | add :cpu, :string 16 | add :cpu_count, :integer 17 | add :memory, :integer 18 | 19 | add :sample_size, :integer 20 | 21 | add :mode, :float 22 | add :minimum, :float 23 | add :median, :float 24 | add :maximum, :float 25 | add :average, :float 26 | add :std_dev, :float 27 | add :std_dev_ratio, :float 28 | 29 | add :ips, :float 30 | add :std_dev_ips, :float 31 | 32 | add :run_times, {:array, :float} 33 | add :percentiles, :map 34 | 35 | add :benchmark_id, references(:benchmarks), null: false 36 | 37 | timestamps() 38 | end 39 | 40 | create index(:measurements, [:collected_at]) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /api/priv/repo/migrations/20171210113018_create_runners.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Repo.Migrations.CreateRunners do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:runners) do 6 | add :name, :string 7 | add :api_key_hash, :string 8 | 9 | timestamps() 10 | end 11 | 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /api/priv/repo/migrations/20171210122300_create_jobs.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Repo.Migrations.CreateJobs do 2 | use Ecto.Migration 3 | 4 | def up do 5 | create table(:jobs) do 6 | add :repo_id, references(:repos), null: false 7 | 8 | add :claimed_by, references(:runners) 9 | add :claimed_at, :utc_datetime 10 | add :completed_at, :utc_datetime 11 | add :log, :text 12 | 13 | add :branch_name, :string 14 | add :commit_sha, :string 15 | add :commit_message, :string 16 | add :commit_url, :string 17 | add :elixir_version, :string 18 | add :erlang_version, :string 19 | add :dependency_versions, :map 20 | add :cpu, :string 21 | add :cpu_count, :integer 22 | add :memory_mb, :integer 23 | 24 | timestamps() 25 | end 26 | 27 | alter table(:measurements) do 28 | remove :collected_at 29 | remove :commit_sha 30 | remove :commit_message 31 | remove :commit_url 32 | remove :elixir_version 33 | remove :erlang_version 34 | remove :dependency_versions 35 | remove :cpu 36 | remove :cpu_count 37 | remove :memory 38 | 39 | add :job_id, references(:jobs), null: false 40 | end 41 | 42 | create index(:jobs, [:repo_id]) 43 | create index(:jobs, [:claimed_by]) 44 | create index(:jobs, [:completed_at]) 45 | create index(:measurements, [:job_id]) 46 | end 47 | 48 | def down do 49 | drop table(:jobs) 50 | 51 | drop index(:measurements, [:job_id]) 52 | 53 | alter table(:measurements) do 54 | remove :job_id 55 | 56 | add :collected_at, :utc_datetime 57 | add :commit_sha, :string 58 | add :commit_message, :string 59 | add :commit_url, :string 60 | add :elixir_version, :string 61 | add :erlang_version, :string 62 | add :dependency_versions, :map 63 | add :cpu, :string 64 | add :cpu_count, :integer 65 | add :memory, :integer 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /api/priv/repo/migrations/20171210162439_add_config_to_jobs.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Repo.Migrations.AddConfigToJobs do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:jobs) do 6 | add :config, :map 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /api/priv/repo/migrations/20171210214237_add_uuid_to_job.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Repo.Migrations.AddUuidToJob do 2 | use Ecto.Migration 3 | 4 | def change do 5 | execute """ 6 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 7 | """, "" # nothing on down 8 | 9 | alter table(:jobs) do 10 | add :uuid, :uuid, null: false, default: fragment("uuid_generate_v4()") 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /api/priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | alias ElixirBench.{Benchmarks, Repos} 2 | 3 | {:ok, runner} = Benchmarks.create_runner(%{name: "test-runner", api_key: "test"}) 4 | {:ok, repo} = Repos.create_repo(%{owner: "elixir-ecto", name: "ecto"}) 5 | 6 | {:ok, %{id: job_id}} = Benchmarks.create_job(repo, %{branch_name: "mm/benches", commit_sha: "207b2a0"}) 7 | 8 | {:ok, %{id: ^job_id} = job} = Benchmarks.claim_job(runner) 9 | 10 | data = %{ 11 | "elixir_version" => "1.5.2", 12 | "erlang_version" => "20.1", 13 | "dependency_versions" => %{}, 14 | "cpu" => "Intel(R) Core(TM) i7-4770HQ CPU @ 2.20GHz", 15 | "cpu_count" => 8, 16 | "memory_mb" => 16384, 17 | "log" => """ 18 | [now] Oh how ward it was to run this benchmark! 19 | """, 20 | "measurements" => %{ 21 | "insert_mysql/insert_plain" => %{ 22 | "average" => 393.560253365004, 23 | "ips" => 2540.906993147397, 24 | "maximum" => 12453.0, 25 | "median" => 377.0, 26 | "minimum" => 306.0, 27 | "mode" => 369.0, 28 | "percentiles" => %{"50" => 377.0, "99" => 578.6900000000005}, 29 | "sample_size" => 12630, 30 | "std_dev" => 209.33476197004862, 31 | "std_dev_ips" => 1351.5088377210595, 32 | "std_dev_ratio" => 0.5319001605985423, 33 | "run_times" => [] 34 | }, 35 | "insert_mysql/insert_changeset" => %{ 36 | "average" => 450.2023723288664, 37 | "ips" => 2221.2233019276814, 38 | "maximum" => 32412.0, 39 | "median" => 397.0, 40 | "minimum" => 301.0, 41 | "mode" => 378.0, 42 | "percentiles" => %{"50" => 397.0, "99" => 1003.3999999999942}, 43 | "sample_size" => 11044, 44 | "std_dev" => 573.9417528830307, 45 | "std_dev_ips" => 2831.732735787863, 46 | "std_dev_ratio" => 1.274852795453007, 47 | "run_times" => [] 48 | }, 49 | "insert_pg/insert_plain" => %{ 50 | "average" => 473.0912894636744, 51 | "ips" => 2113.756947699591, 52 | "maximum" => 13241.0, 53 | "median" => 450.0, 54 | "minimum" => 338.0, 55 | "mode" => 442.0, 56 | "percentiles" => %{"50" => 450.0, "99" => 727.0}, 57 | "sample_size" => 10516, 58 | "std_dev" => 273.63253429178945, 59 | "std_dev_ips" => 1222.5815257169884, 60 | "std_dev_ratio" => 0.5783926704759165, 61 | "run_times" => [] 62 | }, 63 | "insert_pg/insert_changeset" => %{ 64 | "average" => 465.8669101807624, 65 | "ips" => 2146.5357984150173, 66 | "maximum" => 13092.0, 67 | "median" => 452.0, 68 | "minimum" => 338.0, 69 | "mode" => 440.0, 70 | "percentiles" => %{"50" => 452.0, "99" => 638.0}, 71 | "sample_size" => 10677, 72 | "std_dev" => 199.60367678670747, 73 | "std_dev_ips" => 919.6970816229071, 74 | "std_dev_ratio" => 0.4284564377179282, 75 | "run_times" => [] 76 | } 77 | } 78 | } 79 | 80 | :ok = Benchmarks.submit_job(job, data) 81 | -------------------------------------------------------------------------------- /api/rel/config.exs: -------------------------------------------------------------------------------- 1 | # Import all plugins from `rel/plugins` 2 | # They can then be used by adding `plugin MyPlugin` to 3 | # either an environment, or release definition, where 4 | # `MyPlugin` is the name of the plugin module. 5 | Path.join(["rel", "plugins", "*.exs"]) 6 | |> Path.wildcard() 7 | |> Enum.map(&Code.eval_file(&1)) 8 | 9 | use Mix.Releases.Config, 10 | # This sets the default release built by `mix release` 11 | default_release: :default, 12 | # This sets the default environment used by `mix release` 13 | default_environment: Mix.env() 14 | 15 | # For a full list of config options for both releases 16 | # and environments, visit https://hexdocs.pm/distillery/configuration.html 17 | 18 | 19 | # You may define one or more environments in this file, 20 | # an environment's settings will override those of a release 21 | # when building in that environment, this combination of release 22 | # and environment configuration is called a profile 23 | 24 | environment :dev do 25 | # If you are running Phoenix, you should make sure that 26 | # server: true is set and the code reloader is disabled, 27 | # even in dev mode. 28 | # It is recommended that you build with MIX_ENV=prod and pass 29 | # the --env flag to Distillery explicitly if you want to use 30 | # dev mode. 31 | set dev_mode: true 32 | set include_erts: false 33 | set cookie: :"2snaw!E1bVZOLH}2VYOqPYaIr&g5O@cH~wtblA" 34 | end 35 | 36 | environment :prod do 37 | set include_erts: true 38 | set include_src: false 39 | set cookie: :"?HJ`Ukk;))_nwy4>j>@bPXc6oNSZ==ivI9kF96,63D)S]lkI~tT^NJL]TN@Or5*4" 40 | end 41 | 42 | # You may define one or more releases in this file. 43 | # If you have not set a default release, or selected one 44 | # when running `mix release`, the first release in the file 45 | # will be used by default 46 | 47 | release :elixir_bench do 48 | set version: current_version(:elixir_bench) 49 | set applications: [ 50 | :runtime_tools 51 | ] 52 | end 53 | -------------------------------------------------------------------------------- /api/test/elixir_bench/repos/repos_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.ReposTest do 2 | use ElixirBench.DataCase 3 | alias ElixirBench.Repos 4 | 5 | describe "repos" do 6 | alias ElixirBench.Repos.Repo 7 | 8 | @valid_attrs %{name: "some name", owner: "some owner"} 9 | @update_attrs %{name: "some updated name", owner: "some updated owner"} 10 | @invalid_attrs %{name: nil, owner: nil} 11 | 12 | def repo_fixture(attrs \\ %{}) do 13 | {:ok, repo} = 14 | attrs 15 | |> Enum.into(@valid_attrs) 16 | |> Repos.create_repo() 17 | 18 | repo 19 | end 20 | 21 | test "list_repos/0 returns all repos" do 22 | repo = repo_fixture() 23 | assert Repos.list_repos() == [repo] 24 | end 25 | 26 | test "get_repo!/1 returns the repo with given id" do 27 | repo = repo_fixture() 28 | assert Repos.fetch_repo_by_slug(repo.owner <> "/" <> repo.name) == {:ok, repo} 29 | end 30 | 31 | test "create_repo/1 with valid data creates a repo" do 32 | assert {:ok, %Repo{} = repo} = Repos.create_repo(@valid_attrs) 33 | assert repo.name == "some name" 34 | assert repo.owner == "some owner" 35 | end 36 | 37 | test "create_repo/1 with invalid data returns error changeset" do 38 | assert {:error, %Ecto.Changeset{}} = Repos.create_repo(@invalid_attrs) 39 | end 40 | 41 | test "update_repo/2 with valid data updates the repo" do 42 | repo = repo_fixture() 43 | assert {:ok, repo} = Repos.update_repo(repo, @update_attrs) 44 | assert %Repo{} = repo 45 | assert repo.name == "some updated name" 46 | assert repo.owner == "some updated owner" 47 | end 48 | 49 | test "update_repo/2 with invalid data returns error changeset" do 50 | repo = repo_fixture() 51 | assert {:error, %Ecto.Changeset{}} = Repos.update_repo(repo, @invalid_attrs) 52 | assert Repos.fetch_repo_by_slug(repo.owner <> "/" <> repo.name) == {:ok, repo} 53 | end 54 | 55 | test "delete_repo/1 deletes the repo" do 56 | repo = repo_fixture() 57 | assert {:ok, %Repo{}} = Repos.delete_repo(repo) 58 | assert Repos.fetch_repo_by_slug(repo.owner <> "/" <> repo.name) == {:error, :not_found} 59 | end 60 | end 61 | 62 | test "Repo.changeset/2 returns a repo changeset" do 63 | repo = repo_fixture() 64 | assert %Ecto.Changeset{} = Repos.Repo.changeset(repo, %{}) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /api/test/elixir_bench_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBenchWeb.ErrorViewTest do 2 | use ElixirBenchWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.json" do 8 | assert render(ElixirBenchWeb.ErrorView, "404.json", []) == 9 | %{errors: %{detail: "Page not found"}} 10 | end 11 | 12 | test "render 500.json" do 13 | assert render(ElixirBenchWeb.ErrorView, "500.json", []) == 14 | %{errors: %{detail: "Internal server error"}} 15 | end 16 | 17 | test "render any other" do 18 | assert render(ElixirBenchWeb.ErrorView, "505.json", []) == 19 | %{errors: %{detail: "Internal server error"}} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /api/test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBenchWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common datastructures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with channels 21 | use Phoenix.ChannelTest 22 | 23 | # The default endpoint for testing 24 | @endpoint ElixirBenchWeb.Endpoint 25 | end 26 | end 27 | 28 | 29 | setup tags do 30 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(ElixirBench.Repo) 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(ElixirBench.Repo, {:shared, self()}) 33 | end 34 | :ok 35 | end 36 | 37 | end 38 | -------------------------------------------------------------------------------- /api/test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBenchWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common datastructures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with connections 21 | use Phoenix.ConnTest 22 | import ElixirBenchWeb.Router.Helpers 23 | 24 | # The default endpoint for testing 25 | @endpoint ElixirBenchWeb.Endpoint 26 | end 27 | end 28 | 29 | 30 | setup tags do 31 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(ElixirBench.Repo) 32 | unless tags[:async] do 33 | Ecto.Adapters.SQL.Sandbox.mode(ElixirBench.Repo, {:shared, self()}) 34 | end 35 | {:ok, conn: Phoenix.ConnTest.build_conn()} 36 | end 37 | 38 | end 39 | -------------------------------------------------------------------------------- /api/test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate 16 | 17 | using do 18 | quote do 19 | alias ElixirBench.Repo 20 | 21 | import Ecto 22 | import Ecto.Changeset 23 | import Ecto.Query 24 | import ElixirBench.DataCase 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(ElixirBench.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(ElixirBench.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | 38 | @doc """ 39 | A helper that transform changeset errors to a map of messages. 40 | 41 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 42 | assert "password is too short" in errors_on(changeset).password 43 | assert %{password: ["password is too short"]} = errors_on(changeset) 44 | 45 | """ 46 | def errors_on(changeset) do 47 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 48 | Enum.reduce(opts, message, fn {key, value}, acc -> 49 | String.replace(acc, "%{#{key}}", to_string(value)) 50 | end) 51 | end) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /api/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | Ecto.Adapters.SQL.Sandbox.mode(ElixirBench.Repo, :manual) 4 | 5 | -------------------------------------------------------------------------------- /runner-container/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nebo15/alpine-erlang:20.1.2 2 | MAINTAINER Andrew Dryga andrew@dryga.com 3 | 4 | ENV REFRESHED_AT=2017-12-09 5 | 6 | ENV LANG=en_US.UTF-8 \ 7 | LANGUAGE=en_US:en \ 8 | LC_ALL=en_US.UTF-8 \ 9 | TERM=xterm \ 10 | HOME=/opt/ \ 11 | ELIXIR_VERSION=1.5.2 12 | 13 | WORKDIR /tmp/elixir-build 14 | 15 | # Install Elixir from precompiled version, Hex and Rebar 16 | RUN set -xe; \ 17 | ELIXIR_DOWNLOAD_URL="https://github.com/elixir-lang/elixir/releases/download/v${ELIXIR_VERSION}/Precompiled.zip" && \ 18 | apk add --no-cache --update --virtual .elixir-build \ 19 | unzip \ 20 | curl \ 21 | ca-certificates && \ 22 | curl -fSL -o elixir-precompiled.zip "${ELIXIR_DOWNLOAD_URL}" && \ 23 | unzip -d /usr/local elixir-precompiled.zip && \ 24 | rm elixir-precompiled.zip && \ 25 | mix local.hex --force && \ 26 | mix local.rebar --force && \ 27 | cd /tmp && \ 28 | rm -rf /tmp/elixir-build && \ 29 | apk del .elixir-build 30 | 31 | # Install runtime dependencies 32 | RUN apk --no-cache --update add \ 33 | ca-certificates \ 34 | make \ 35 | git 36 | 37 | # Configure bench runner 38 | ENV PROJECT_PATH=/opt/app \ 39 | BENCHMARKS_OUTPUT_PATH=/var/bench 40 | 41 | # Create a non-root user to run the suite 42 | RUN addgroup -S app && \ 43 | adduser -S -g app app 44 | 45 | # Create app folders 46 | RUN set -xe; \ 47 | mkdir -p ${PROJECT_PATH} && \ 48 | mkdir -p ${BENCHMARKS_OUTPUT_PATH} && \ 49 | chown -R app ${HOME} && \ 50 | chown -R app ${BENCHMARKS_OUTPUT_PATH} && \ 51 | chmod 777 ${PROJECT_PATH} ${BENCHMARKS_OUTPUT_PATH} 52 | 53 | # Change workdir 54 | WORKDIR ${PROJECT_PATH} 55 | RUN cd ${PROJECT_PATH} 56 | 57 | # Export benchmarks results as a volume 58 | VOLUME ${BENCHMARKS_OUTPUT_PATH} 59 | 60 | # Add entrypoint that fetches project source 61 | COPY docker-entrypoint.sh /usr/local/bin/ 62 | RUN ln -s usr/local/bin/docker-entrypoint.sh / # backwards compat 63 | ENTRYPOINT ["/docker-entrypoint.sh"] 64 | 65 | # USER app 66 | 67 | # Run benchmarking suite 68 | CMD ["mix run bench/bench_helper.exs"] 69 | -------------------------------------------------------------------------------- /runner-container/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # ElixirBench Runner Container 4 | 5 | This is a container that fetches project source and runs benchmarking code. 6 | 7 | To start it requires following environment variables: 8 | 9 | * `ELIXIRBENCH_REPO_SLUG` - GitHub repo slug for a repo; 10 | * `ELIXIRBENCH_REPO_BRANCH` - branch name on which we are running our benchmarking suite; 11 | * `ELIXIRBENCH_REPO_COMMIT` - commit hash for which we are running our benchmarking suite. 12 | 13 | Benchmarking results should be written to a path from `BENCHMARKS_OUTPUT_PATH` environment variable. 14 | 15 | ## Building and running container 16 | 17 | ``` 18 | # Build the container (Elixir and Erlang versions should be in the tag) 19 | docker build --tag "elixirbench/1.5.2-20.1.2" ./ 20 | 21 | # Test it on a sample benchmark, not that PostgeSQL and MySQL are required for this sample 22 | docker run --env ELIXIRBENCH_REPO_SLUG=elixir-ecto/ecto \ 23 | --env ELIXIRBENCH_REPO_BRANCH=mm/benches \ 24 | --env ELIXIRBENCH_REPO_COMMIT=d99b70ca17dd41ad7731ec0b0fb3879065952297 \ 25 | --env PG_URL=postgres:postgres@docker.for.mac.localhost \ 26 | --env MYSQL_URL=root@docker.for.mac.localhost \ 27 | -v /tmp/benchmarking_results:/var/bench \ 28 | elixirbench/1.5.2-20.1.2 29 | 30 | # Push it to Docker Hub 31 | docker push elixirbench/1.5.2-20.1.2 32 | ``` 33 | 34 | By default, it will run benchmarks via `mix run bench/bench_helper.exs` on a project source with a `MIX_ENV=bench`. 35 | -------------------------------------------------------------------------------- /runner-container/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Validate that all required env variables are set 5 | [[ -z "${ELIXIRBENCH_REPO_SLUG}" ]] && { echo >&2 "ELIXIRBENCH_REPO_SLUG is not set. Aborting."; exit 1; } 6 | [[ -z "${ELIXIRBENCH_REPO_BRANCH}" ]] && { echo >&2 "ELIXIRBENCH_REPO_BRANCH is not set. Aborting."; exit 1; } 7 | [[ -z "${ELIXIRBENCH_REPO_COMMIT}" ]] && { echo >&2 "ELIXIRBENCH_REPO_COMMIT is not set. Aborting."; exit 1; } 8 | [[ -z "${BENCHMARKS_OUTPUT_PATH}" ]] && { echo >&2 "BENCHMARKS_OUTPUT_PATH is not set. Aborting."; exit 1; } 9 | 10 | # Set default MIX_ENV variable if it's not overridden 11 | [[ -z "${MIX_ENV}" ]] && export MIX_ENV="bench" 12 | 13 | echo "[I] Cloning the repo.." 14 | git clone --recurse-submodules \ 15 | --depth=50 \ 16 | --branch=${ELIXIRBENCH_REPO_BRANCH} \ 17 | https://github.com/${ELIXIRBENCH_REPO_SLUG} \ 18 | ${PROJECT_PATH} 19 | 20 | if [[ "$?" != "0" ]]; then 21 | echo >&2 "[E] Can not fetch project source. Aborting."; 22 | exit $? 23 | fi 24 | 25 | git checkout -qf ${ELIXIRBENCH_REPO_COMMIT} 26 | 27 | if [[ "$?" != "0" ]]; then 28 | echo >&2 "[E] Can not fetch commit ${ELIXIRBENCH_REPO_COMMIT}. Aborting."; 29 | exit $? 30 | fi 31 | 32 | echo "[I] Fetching project dependencies" 33 | mix deps.get 34 | 35 | echo "[I] Compiling the source" 36 | mix compile 37 | 38 | echo "[I] Persisting mix.lock file" 39 | cat mix.lock > ${BENCHMARKS_OUTPUT_PATH}/mix.lock 40 | 41 | echo "[I] Executing benchmarks" 42 | if [[ "$1" == "mix run bench/bench_helper.exs" ]]; then 43 | mix run bench/bench_helper.exs 44 | else 45 | exec "$@" 46 | fi; 47 | -------------------------------------------------------------------------------- /runner/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | locals_without_parens: [ 5 | plug: 1, 6 | plug: 2, 7 | pipeline: 2, 8 | scope: 2, 9 | pipe_through: 1, 10 | forward: 3, 11 | socket: 2, 12 | object: 2, 13 | field: 2, 14 | field: 3 15 | ], 16 | line_length: 120 17 | ] 18 | -------------------------------------------------------------------------------- /runner/.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 | # Ignore package tarball (built via "mix hex.build"). 23 | runner-*.tar 24 | 25 | -------------------------------------------------------------------------------- /runner/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # ElixirBench Runner 4 | 5 | This is an Elixir daemon that pulls for job our API and executes tests in `runner-container`. 6 | 7 | ## Dependencies 8 | 9 | Benchmarks are running inside a docker container, so you need to have 10 | [`docker`](https://docs.docker.com/engine/installation/) and 11 | [`docker-compose`](https://docs.docker.com/compose/install/) installed. 12 | 13 | ## Deployment 14 | 15 | To build the release you can use `mix release`. The relese requires a `RUNNER_API_KEY` and `RUNNER_API_USER` 16 | environment variables for communication with the API server. 17 | 18 | The server needs to have proper credentials for the runner configured as well. This can be done from 19 | the release console using: 20 | ```elixir 21 | ElixirBench.Benchmarks.create_runner(%{api_key: some_key, name: some_name}) 22 | ``` 23 | -------------------------------------------------------------------------------- /runner/config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :runner, :default_elixir_version, {:system, "DEFAULT_ELIXIR_VERSION", "1.5.2"} 4 | config :runner, :supported_elixir_versions, {:system, :list, "SUPPORTED_ELIXIR_VERSIONS", ["1.5.2"]} 5 | 6 | config :runner, :default_erlang_version, {:system, "DEFAULT_ERLANG_VERSION", "20.1.2"} 7 | config :runner, :supported_erlang_versions, {:system, :list, "SUPPORTED_ERLANG_VERSIONS", ["20.1.2"]} 8 | 9 | config :runner, :benchmars_output_path, {:system, "BENCHMARKS_OUTPUT_PATH", "/tmp/benchmarks"} 10 | config :runner, :container_benchmars_output_path, "/var/bench" 11 | 12 | config :runner, :job_timeout, {:system, :integer, "JOB_TIMEOUT", 900_000} 13 | 14 | config :runner, :api_user, {:system, "RUNNER_API_USER"} 15 | config :runner, :api_key, {:system, "RUNNER_API_KEY"} 16 | 17 | config :logger, level: :info 18 | -------------------------------------------------------------------------------- /runner/lib/runner.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Runner do 2 | use GenServer 3 | require Logger 4 | 5 | alias ElixirBench.Runner.{Api, Job, Config} 6 | 7 | @claim_delay 10_000 8 | 9 | def start_link(opts) do 10 | GenServer.start_link(__MODULE__, opts, opts) 11 | end 12 | 13 | def init(_opts) do 14 | Process.send_after(self(), :try_claim, @claim_delay) 15 | client = %Api.Client{username: Confex.fetch_env!(:runner, :api_user), password: Confex.fetch_env!(:runner, :api_key)} 16 | {:ok, client} 17 | end 18 | 19 | def handle_info(:try_claim, client) do 20 | case Api.claim_job(client) do 21 | {:ok, %{"data" => %{"id" => id} = job}} -> 22 | Logger.info("Claimed job:#{id}") 23 | process_job(job, client) 24 | 25 | {:error, reason} -> 26 | Logger.info("Failed to claim job: #{inspect(reason)}") 27 | end 28 | 29 | Process.send_after(self(), :try_claim, @claim_delay) 30 | {:noreply, client} 31 | end 32 | 33 | defp process_job(job, client) do 34 | %{"id" => id, "repo_slug" => repo_slug, "branch_name" => branch, "commit_sha" => commit, "config" => config} = job 35 | config = Config.from_string_map(config) 36 | job = Job.start_job(id, repo_slug, branch, commit, config) 37 | data = %{ 38 | elixir_version: job.config.elixir_version, 39 | erlang_version: job.config.erlang_version, 40 | measurements: job.measurements, 41 | log: job.log || "" 42 | } 43 | data = Map.merge(data, job.context) 44 | {:ok, _} = Api.submit_results(client, job, data) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /runner/lib/runner/api.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Runner.Api do 2 | 3 | defmodule Client do 4 | defstruct [:username, :password, base_url: "https://api.elixirbench.org/runner-api"] 5 | end 6 | 7 | def claim_job(client) do 8 | request(:post, client, "/jobs/claim", %{}) 9 | end 10 | 11 | def submit_results(client, job, results) do 12 | request(:put, client, "/jobs/#{job.id}", %{job: results}) 13 | end 14 | 15 | defp request(method, client, url, data) do 16 | headers = [{"accept", "application/json"}, {"content-type", "application/json"}] 17 | data = Antidote.encode!(data) 18 | full_url = client.base_url <> url 19 | auth = {client.username, client.password} 20 | case :hackney.request(method, full_url, headers, data, [:with_body, basic_auth: auth]) do 21 | {:ok, status, _headers, ""} when status in 200..299 -> 22 | {:ok, %{}} 23 | {:ok, status, _headers, body} when status in 200..299 -> 24 | {:ok, Antidote.decode!(body)} 25 | {:ok, 404, _headers, _body} -> 26 | {:error, :not_found} 27 | {:ok, status, _headers, body} -> 28 | {:error, {status, body}} 29 | {:error, error} -> 30 | {:error, error} 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /runner/lib/runner/application.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Runner.Application do 2 | @moduledoc false 3 | use Application 4 | 5 | def start(_type, _args) do 6 | children = [ 7 | {Task.Supervisor, name: ElixirBench.Runner.JobsSupervisor}, 8 | {ElixirBench.Runner, name: ElixirBench.Runner} 9 | ] 10 | 11 | opts = [strategy: :one_for_one, name: ElixirBench.Runner.Supervisor] 12 | Supervisor.start_link(children, opts) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /runner/lib/runner/config.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Runner.Config do 2 | defstruct elixir_version: nil, erlang_version: nil, environment_variables: [], deps: [] 3 | 4 | def from_string_map(map) do 5 | %{ 6 | "elixir_version" => elixir, 7 | "erlang_version" => erlang, 8 | "environment_variables" => env, 9 | "deps" => %{"docker" => docker} 10 | } = map 11 | %__MODULE__{ 12 | elixir_version: elixir, 13 | erlang_version: erlang, 14 | environment_variables: env, 15 | deps: docker 16 | } 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /runner/lib/runner/config/parser.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Runner.Config.Parser do 2 | @moduledoc """ 3 | This module is responsible for processing 4 | """ 5 | 6 | def parse_yaml(content) do 7 | content 8 | |> YamlElixir.read_from_string(atoms!: true) 9 | |> validate_and_build_struct() 10 | end 11 | 12 | defp validate_and_build_struct(raw_config) when is_map(raw_config) do 13 | with {:ok, elixir_version} <- fetch_elixir_version(raw_config), 14 | {:ok, erlang_version} <- fetch_erlang_version(raw_config), 15 | {:ok, environment_variables} <- fetch_environment_variables(raw_config), 16 | {:ok, deps} <- fetch_deps(raw_config) do 17 | {:ok, %ElixirBench.Runner.Config{ 18 | elixir_version: elixir_version, 19 | erlang_version: erlang_version, 20 | environment_variables: environment_variables, 21 | deps: deps 22 | }} 23 | end 24 | end 25 | 26 | defp validate_and_build_struct(_raw_config) do 27 | {:error, :malformed_config} 28 | end 29 | 30 | defp fetch_elixir_version(raw_config) do 31 | supported_elixir_versions = Confex.fetch_env!(:runner, :supported_elixir_versions) 32 | 33 | case Map.fetch(raw_config, "elixir") do 34 | :error -> {:ok, Confex.fetch_env!(:runner, :default_elixir_version)} 35 | {:ok, version} when is_binary(version) -> do_fetch_elixir_version(version, supported_elixir_versions) 36 | {:ok, _version} -> {:error, :malformed_elixir_version} 37 | end 38 | end 39 | 40 | defp do_fetch_elixir_version(version, supported_elixir_versions) do 41 | if version in supported_elixir_versions do 42 | {:ok, version} 43 | else 44 | {:error, {:unsupported_elixir_version, supported_versions: supported_elixir_versions}} 45 | end 46 | end 47 | 48 | defp fetch_erlang_version(raw_config) do 49 | supported_erlang_versions = Confex.fetch_env!(:runner, :supported_erlang_versions) 50 | 51 | case Map.fetch(raw_config, "erlang") do 52 | :error -> {:ok, Confex.fetch_env!(:runner, :default_erlang_version)} 53 | {:ok, version} when is_binary(version) -> do_fetch_erlang_version(version, supported_erlang_versions) 54 | {:ok, _version} -> {:error, :malformed_erlang_version} 55 | end 56 | end 57 | 58 | defp do_fetch_erlang_version(version, supported_erlang_versions) do 59 | if version in supported_erlang_versions do 60 | {:ok, version} 61 | else 62 | {:error, {:unsupported_erlang_version, supported_versions: supported_erlang_versions}} 63 | end 64 | end 65 | 66 | defp fetch_environment_variables(%{"environment" => environment_variables}) when is_map(environment_variables) do 67 | errors = Enum.reject(environment_variables, fn {key, value} -> is_binary(key) and is_binary(value) end) 68 | if errors == [] do 69 | {:ok, stringify_values(environment_variables)} 70 | else 71 | {:error, {:invalid_environment_variables, errors}} 72 | end 73 | end 74 | defp fetch_environment_variables(%{"environment" => environment_variables}) do 75 | {:error, {:malformed_environment_variables, environment_variables}} 76 | end 77 | defp fetch_environment_variables(_raw_config) do 78 | {:ok, []} 79 | end 80 | 81 | defp fetch_deps(%{"deps" => %{"docker" => docker_deps}}) when is_list(docker_deps) do 82 | errors = Enum.reject(docker_deps, fn dep -> Map.has_key?(dep, "image") end) 83 | if errors == [] do 84 | deps = Enum.map(docker_deps, &stringify_environment(&1)) 85 | {:ok, deps} 86 | else 87 | {:error, {:invalid_deps, errors}} 88 | end 89 | end 90 | defp fetch_deps(%{"deps" => %{"docker" => docker_deps}}) do 91 | {:error, {:malformed_deps, docker_deps}} 92 | end 93 | defp fetch_deps(_raw_config) do 94 | {:ok, []} 95 | end 96 | 97 | defp stringify_environment(%{"environment" => environment} = raw_config), 98 | do: Map.put(raw_config, "environment", stringify_values(environment)) 99 | defp stringify_environment(raw_config), 100 | do: raw_config 101 | 102 | defp stringify_values(map) do 103 | for {key, value} <- map, do: {key, to_string(value)}, into: %{} 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /runner/lib/runner/job.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Runner.Job do 2 | @moduledoc """ 3 | This module is responsible for starting and halting jobs. 4 | 5 | Under the hood it's using CLI integration to docker-compose. 6 | """ 7 | alias ElixirBench.Runner.{Job, Config} 8 | 9 | defstruct id: nil, 10 | repo_slug: nil, 11 | branch: nil, 12 | commit: nil, 13 | config: %Config{}, 14 | status: nil, 15 | log: nil, 16 | context: %{}, 17 | measurements: %{} 18 | 19 | # --force-recreate - Recreate containers even if their configuration and image haven't changed; 20 | # --no-build - We don't allow users to use our benchmarking service to build images, 21 | # TODO: warn when `build` config key is present in docker deps; 22 | # --abort-on-container-exit - Stops all containers when one of them exists, 23 | # this allows us to shut down all deps when benchmarks are completed; 24 | # --remove-orphans - Remove containers for services not defined in the Compose file. 25 | @static_compose_args ~w[up --force-recreate --no-build --abort-on-container-exit --remove-orphans] 26 | 27 | # Both host and container:runner network modes are allowing 28 | # to use localhost for sending requests to a helper container, 29 | # with following cons: 30 | # * `container:runner` network mode violates start order, 31 | # so runner is started before DB's, but source pooling saves the day; 32 | # * `host` binds to all host interfaces, which is not best for 33 | # security but gives better performance. 34 | @network_mode "host" 35 | 36 | @doc """ 37 | Executes a benchmarking job for a specific commit. 38 | """ 39 | def start_job(id, repo_slug, branch, commit, config) do 40 | ensure_no_other_jobs!() 41 | 42 | job = %Job{id: to_string(id), repo_slug: repo_slug, branch: branch, commit: commit, config: config} 43 | 44 | task = 45 | Task.Supervisor.async_nolink(ElixirBench.Runner.JobsSupervisor, fn -> 46 | run_job(job) 47 | end) 48 | 49 | timeout = Confex.fetch_env!(:runner, :job_timeout) 50 | case Task.yield(task, timeout) || Task.shutdown(task) do 51 | {:ok, result} -> result 52 | 53 | nil -> 54 | %{job | status: 127, log: "Job execution timed out"} 55 | end 56 | end 57 | 58 | defp ensure_no_other_jobs! do 59 | %{active: 0} = Supervisor.count_children(ElixirBench.Runner.JobsSupervisor) 60 | end 61 | 62 | @doc false 63 | # Public for testing purposes 64 | def run_job(job) do 65 | benchmars_output_path = get_benchmars_output_path(job) 66 | File.mkdir_p!(benchmars_output_path) 67 | 68 | compose_config = get_compose_config(job) 69 | compose_config_path = "#{benchmars_output_path}/#{job.id}-config.yml" 70 | File.write!(compose_config_path, compose_config) 71 | 72 | try do 73 | {log, status} = System.cmd("docker-compose", ["-f", compose_config_path] ++ @static_compose_args) 74 | measurements = collect_measurements(benchmars_output_path) 75 | context = collect_context(benchmars_output_path) 76 | %{job | log: log, status: status, measurements: measurements, context: context} 77 | after 78 | # Stop all containers and delete all containers, images and build cache 79 | {_log, 0} = System.cmd("docker", ~w[system prune -a -f]) 80 | 81 | # Clean benchmarking temporary files 82 | File.rm_rf!(benchmars_output_path) 83 | end 84 | end 85 | 86 | defp get_benchmars_output_path(%Job{id: id}) do 87 | Confex.fetch_env!(:runner, :benchmars_output_path) <> "/" <> id 88 | end 89 | 90 | defp get_compose_config(job) do 91 | Antidote.encode!(%{version: "3", services: build_services(job)}) 92 | end 93 | 94 | defp build_services(job) do 95 | %{id: id, config: config} = job 96 | 97 | services = 98 | config.deps 99 | |> Enum.reduce(%{}, fn dep, services -> 100 | dep = Map.put(dep, "network_mode", @network_mode) 101 | name = "job_" <> id <> "_" <> get_dep_container_name(dep) 102 | Map.put(services, name, dep) 103 | end) 104 | 105 | Map.put(services, "runner", build_runner_service(job, Map.keys(services))) 106 | end 107 | 108 | defp get_dep_container_name(%{"container_name" => container_name}), do: container_name 109 | defp get_dep_container_name(%{"image" => image}), do: dep_name_from_image(image) 110 | 111 | defp build_runner_service(job, deps) do 112 | container_benchmars_output_path = Confex.fetch_env!(:runner, :container_benchmars_output_path) 113 | 114 | %{ 115 | network_mode: @network_mode, 116 | image: "elixirbench/runner:#{job.config.elixir_version}-#{job.config.erlang_version}", 117 | volumes: ["#{get_benchmars_output_path(job)}:#{container_benchmars_output_path}:Z"], 118 | depends_on: deps, 119 | environment: build_runner_environment(job) 120 | } 121 | end 122 | 123 | defp build_runner_environment(job) do 124 | %{repo_slug: repo_slug, branch: branch, commit: commit, config: config} = job 125 | 126 | config.environment_variables 127 | |> Map.put("ELIXIRBENCH_REPO_SLUG", repo_slug) 128 | |> Map.put("ELIXIRBENCH_REPO_BRANCH", branch) 129 | |> Map.put("ELIXIRBENCH_REPO_COMMIT", commit) 130 | end 131 | 132 | defp dep_name_from_image(image) do 133 | [slug | _tag] = String.split(image, ":") 134 | slug |> String.split("/") |> List.last() 135 | end 136 | 137 | defp collect_measurements(benchmars_output_path) do 138 | "#{benchmars_output_path}/*.json" 139 | |> Path.wildcard() 140 | |> Enum.reduce(%{}, fn path, acc -> 141 | benchmark_name = Path.basename(path, ".json") 142 | new_data = path |> File.read!() |> Antidote.decode!() |> format_measurement(benchmark_name) 143 | Map.merge(acc, new_data) 144 | end) 145 | end 146 | 147 | defp format_measurement(measurement, benchmark_name) do 148 | run_times = Map.get(measurement, "run_times") 149 | statistics = Map.get(measurement, "statistics") 150 | 151 | Map.new(run_times, fn {name, runs} -> 152 | data = Map.fetch!(statistics, name) 153 | data = Map.put(data, :run_times, runs) 154 | {benchmark_name <> "/" <> name, data} 155 | end) 156 | end 157 | 158 | defp collect_context(benchmars_output_path) do 159 | mix_deps = read_mix_deps("#{benchmars_output_path}/mix.lock") 160 | 161 | %{ 162 | dependency_versions: mix_deps, 163 | cpu_count: Benchee.System.num_cores(), 164 | worker_os: Benchee.System.os(), 165 | memory: Benchee.System.available_memory(), 166 | cpu: Benchee.System.cpu_speed() 167 | } 168 | end 169 | 170 | def read_mix_deps(file) do 171 | case File.read(file) do 172 | {:ok, info} -> 173 | case Code.eval_string(info, [], file: file) do 174 | {lock, _binding} when is_map(lock) -> 175 | Map.new(lock, fn 176 | {dep_name, ast} when elem(ast, 0) == :git -> 177 | {dep_name, elem(ast, 1)} 178 | 179 | {dep_name, ast} when elem(ast, 0) == :hex -> 180 | {dep_name, elem(ast, 2)} 181 | 182 | {dep_name, ast} when elem(ast, 0) == :path -> 183 | {dep_name, elem(ast, 1)} 184 | end) 185 | 186 | {_, _binding} -> 187 | %{} 188 | end 189 | {:error, _} -> 190 | %{} 191 | end 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /runner/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Runner.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :runner, 7 | version: "0.1.0", 8 | elixir: "~> 1.5", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | mod: {ElixirBench.Runner.Application, []}, 18 | extra_applications: [:logger, :runtime_tools] 19 | ] 20 | end 21 | 22 | # Run "mix help deps" to learn about dependencies. 23 | defp deps do 24 | [ 25 | {:yaml_elixir, "~> 1.3"}, 26 | {:confex, "~> 3.3"}, 27 | {:hackney, "~> 1.10"}, 28 | {:antidote, github: "michalmuskala/antidote", branch: "master"}, 29 | {:benchee, "~> 0.11.0"}, 30 | {:distillery, "~> 1.5", runtime: false} 31 | ] 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /runner/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "antidote": {:git, "https://github.com/michalmuskala/antidote.git", "64a20bbb58aaadbc022aada45c7c437d3224fc0c", [branch: "master"]}, 3 | "benchee": {:hex, :benchee, "0.11.0", "cf96e328ff5d69838dd89c21a9db22716bfcc6ef772e9d9dddf7ba622102722d", [], [{:deep_merge, "~> 0.1", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "certifi": {:hex, :certifi, "2.0.0", "a0c0e475107135f76b8c1d5bc7efb33cd3815cb3cf3dea7aefdd174dabead064", [], [], "hexpm"}, 5 | "confex": {:hex, :confex, "3.3.1", "8febaf751bf293a16a1ed2cbd258459cdcc7ca53cfa61d3f83d49dd276a992b4", [], [], "hexpm"}, 6 | "deep_merge": {:hex, :deep_merge, "0.1.1", "c27866a7524a337b6a039eeb8dd4f17d458fd40fbbcb8c54661b71a22fffe846", [], [], "hexpm"}, 7 | "distillery": {:hex, :distillery, "1.5.2", "eec18b2d37b55b0bcb670cf2bcf64228ed38ce8b046bb30a9b636a6f5a4c0080", [], [], "hexpm"}, 8 | "hackney": {:hex, :hackney, "1.10.1", "c38d0ca52ea80254936a32c45bb7eb414e7a96a521b4ce76d00a69753b157f21", [], [{:certifi, "2.0.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "idna": {:hex, :idna, "5.1.0", "d72b4effeb324ad5da3cab1767cb16b17939004e789d8c0ad5b70f3cea20c89a", [], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [], [], "hexpm"}, 11 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [], [], "hexpm"}, 12 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [], [], "hexpm"}, 13 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [], [], "hexpm"}, 14 | "yamerl": {:hex, :yamerl, "0.6.0", "5cb124a01a66418d7d8f2382948c89b48c1764079798eaeb9b578ddb3c7dc58b", [], [], "hexpm"}, 15 | "yaml_elixir": {:hex, :yaml_elixir, "1.3.1", "b84b6333343b0cba176c43c463e622f838825e7476e35334f719b83df4535bff", [], [{:yamerl, "~> 0.5", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm"}, 16 | } 17 | -------------------------------------------------------------------------------- /runner/rel/config.exs: -------------------------------------------------------------------------------- 1 | # Import all plugins from `rel/plugins` 2 | # They can then be used by adding `plugin MyPlugin` to 3 | # either an environment, or release definition, where 4 | # `MyPlugin` is the name of the plugin module. 5 | Path.join(["rel", "plugins", "*.exs"]) 6 | |> Path.wildcard() 7 | |> Enum.map(&Code.eval_file(&1)) 8 | 9 | use Mix.Releases.Config, 10 | # This sets the default release built by `mix release` 11 | default_release: :default, 12 | # This sets the default environment used by `mix release` 13 | default_environment: Mix.env() 14 | 15 | # For a full list of config options for both releases 16 | # and environments, visit https://hexdocs.pm/distillery/configuration.html 17 | 18 | 19 | # You may define one or more environments in this file, 20 | # an environment's settings will override those of a release 21 | # when building in that environment, this combination of release 22 | # and environment configuration is called a profile 23 | 24 | environment :dev do 25 | # If you are running Phoenix, you should make sure that 26 | # server: true is set and the code reloader is disabled, 27 | # even in dev mode. 28 | # It is recommended that you build with MIX_ENV=prod and pass 29 | # the --env flag to Distillery explicitly if you want to use 30 | # dev mode. 31 | set dev_mode: true 32 | set include_erts: false 33 | set cookie: :"2snaw!E1bVZOLH}2VYOqPYaIr&g5O@cH~wtblA" 34 | end 35 | 36 | environment :prod do 37 | set include_erts: true 38 | set include_src: false 39 | set cookie: :"?HJ`Ukk;))_nwy4>j>@bPXc6oNSZ==ivI9kF96,63D)S]lkI~tT^NJL]TN@Or5*4" 40 | end 41 | 42 | # You may define one or more releases in this file. 43 | # If you have not set a default release, or selected one 44 | # when running `mix release`, the first release in the file 45 | # will be used by default 46 | 47 | release :runner do 48 | set version: current_version(:runner) 49 | set applications: [ 50 | :runtime_tools 51 | ] 52 | end 53 | -------------------------------------------------------------------------------- /runner/test/runner/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Runner.ConfigTest do 2 | use ExUnit.Case, async: true 3 | import ElixirBench.Runner.Config 4 | 5 | @repo_slug_fixture "elixir-ecto/ecto" 6 | @branch_fixture "mm/benches" 7 | @commit_fixture "207b2a0fb5407b7162a454a12bacf8f1a4c962c0" 8 | 9 | @tag requires_internet_connection: true 10 | describe "fetch_config_by_repo_slug/1" do 11 | test "loads configuration from repo" do 12 | assert {:ok, %ElixirBench.Runner.Config{}} = fetch_config_by_repo_slug(@repo_slug_fixture, @branch_fixture) 13 | assert {:ok, %ElixirBench.Runner.Config{}} = fetch_config_by_repo_slug(@repo_slug_fixture, @commit_fixture) 14 | end 15 | 16 | test "returns error when file does not exist" do 17 | assert fetch_config_by_repo_slug("elixir-ecto/ecto", "not_a_branch") == {:error, :config_not_found} 18 | assert fetch_config_by_repo_slug("not-elixir-ecto/ecto", "mm/benches") == {:error, :config_not_found} 19 | assert fetch_config_by_repo_slug("not-elixir-ecto/ecto") == {:error, :config_not_found} 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /runner/test/runner/job_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBench.Runner.JobTest do 2 | use ExUnit.Case 3 | import ElixirBench.Runner.Job 4 | alias ElixirBench.Runner.Job 5 | 6 | @moduletag timeout: :infinity, docker_integration: true, requires_internet_connection: true 7 | 8 | test "run_job/1" do 9 | raw_config = """ 10 | elixir: 1.5.2 11 | erlang: 20.1.2 12 | environment: 13 | PG_URL: postgres:postgres@localhost 14 | MYSQL_URL: root@localhost 15 | deps: 16 | docker: 17 | - container_name: postgres 18 | image: postgres:9.6.6-alpine 19 | - container_name: mysql 20 | image: mysql:5.7.20 21 | environment: 22 | MYSQL_ALLOW_EMPTY_PASSWORD: "true" 23 | """ 24 | 25 | {:ok, config} = ElixirBench.Runner.Config.Parser.parse_yaml(raw_config) 26 | 27 | job = %Job{ 28 | id: "test_job", 29 | repo_slug: "elixir-ecto/ecto", 30 | branch: "mm/benches", 31 | commit: "2a5a8efbc3afee3c6893f4cba33679e98142df3f", 32 | config: config 33 | } 34 | 35 | job = run_job(job) 36 | 37 | assert job.status == 0 38 | assert job.log =~ "Cloning the repo.." 39 | assert length(job.measurements) > 1 40 | 41 | # Make sure context is present 42 | assert is_binary(Keyword.fetch!(job.context.mix_deps, :benchee)) 43 | assert is_binary(job.context.worker_available_memory) 44 | assert is_binary(job.context.worker_cpu_speed) 45 | assert job.context.worker_num_cores in 1..64 46 | assert job.context.worker_os in [:Linux, :Windows, :macOS] 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /runner/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start(capture_log: true) 2 | -------------------------------------------------------------------------------- /web/.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL="https://api.elixirbench.org" 2 | -------------------------------------------------------------------------------- /web/.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL="https://api.elixirbench.org" 2 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # ElixirBench Web 4 | 5 | This project was bootstrapped with [Create React App](https://github.com/facebookincubator/create-react-app). 6 | 7 | ## Available Scripts 8 | 9 | In the project directory, you can run: 10 | 11 | ### `yarn start` 12 | 13 | Runs the app in the development mode.
14 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 15 | 16 | The page will reload if you make edits.
17 | You will also see any lint errors in the console. 18 | 19 | ### `yarn test` 20 | 21 | Launches the test runner in the interactive watch mode.
22 | See the section about [running tests](#running-tests) for more information. 23 | 24 | ### `yarn build` 25 | 26 | Builds the app for production to the `build` folder.
27 | It correctly bundles React in production mode and optimizes the build for the best performance. 28 | 29 | The build is minified and the filenames include the hashes.
30 | Your app is ready to be deployed! 31 | 32 | ## Deployment 33 | 34 | ``` 35 | yarn deploy 36 | ``` 37 | 38 | Builds the project and publishes it to gh pages of this repo 39 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "https://www.elixirbench.org", 6 | "dependencies": { 7 | "apollo-cache-inmemory": "^1.1.4", 8 | "apollo-client": "^2.0.4", 9 | "apollo-client-preset": "^1.0.5", 10 | "apollo-link-http": "^1.3.1", 11 | "classnames": "^2.2.5", 12 | "d3": "^4.12.0", 13 | "date-fns": "^1.29.0", 14 | "graphql": "^0.11.7", 15 | "graphql-tag": "^2.6.0", 16 | "lodash": "^4.17.4", 17 | "material-ui": "^1.0.0-beta.23", 18 | "material-ui-icons": "^1.0.0-beta.17", 19 | "react": "^16.2.0", 20 | "react-apollo": "^2.0.4", 21 | "react-dom": "^16.2.0", 22 | "react-nebo15-validate": "^0.1.13", 23 | "react-nl2br": "^0.4.0", 24 | "react-redux": "^5.0.6", 25 | "react-router": "3", 26 | "react-router-redux": "^4.0.8", 27 | "react-scripts": "1.0.17", 28 | "recharts": "^1.0.0-beta.6", 29 | "recompose": "^0.26.0", 30 | "redux": "^3.7.2", 31 | "redux-form": "^7.2.0", 32 | "reset.css": "^2.0.2", 33 | "typeface-roboto": "^0.0.45" 34 | }, 35 | "scripts": { 36 | "start": "cross-env REACT_APP_VERSION=`node -p \"require('./package.json').version\"` NODE_PATH=./src react-scripts start", 37 | "build": "cross-env REACT_APP_VERSION=`node -p \"require('./package.json').version\"` NODE_PATH=./src react-scripts build", 38 | "test": "cross-env REACT_APP_VERSION=`node -p \"require('./package.json').version\"` NODE_PATH=./src react-scripts test --env=jsdom", 39 | "eject": "react-scripts eject", 40 | "predeploy": "yarn build", 41 | "deploy": "ghpages -p build" 42 | }, 43 | "devDependencies": { 44 | "cross-env": "^5.1.1", 45 | "ghpages": "^0.0.10", 46 | "redux-devtools-extension": "^2.13.2" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /web/public/CNAME: -------------------------------------------------------------------------------- 1 | www.elixirbench.org 2 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spawnfest/elixirbench/51ed43ee1f30cf3fbf441f2623f1bf6322c0486b/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/images/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spawnfest/elixirbench/51ed43ee1f30cf3fbf441f2623f1bf6322c0486b/web/public/images/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /web/public/images/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spawnfest/elixirbench/51ed43ee1f30cf3fbf441f2623f1bf6322c0486b/web/public/images/icons/favicon-16x16.png -------------------------------------------------------------------------------- /web/public/images/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spawnfest/elixirbench/51ed43ee1f30cf3fbf441f2623f1bf6322c0486b/web/public/images/icons/favicon-32x32.png -------------------------------------------------------------------------------- /web/public/images/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 24 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /web/public/images/logo-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /web/public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spawnfest/elixirbench/51ed43ee1f30cf3fbf441f2623f1bf6322c0486b/web/public/images/logo.png -------------------------------------------------------------------------------- /web/public/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 27 | ElixirBench - Long Running Benchmarks for Elixir Projects. 28 | 29 | 30 | 33 |
34 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /web/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /web/src/__templates/Component/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose, pure } from 'recompose' 3 | import { withStyles } from 'material-ui/styles'; 4 | 5 | import styles from './styles' 6 | 7 | const Component = ({ classes, children }) => ( 8 |
9 | { children } 10 |
11 | ) 12 | 13 | export default compose( 14 | pure, 15 | withStyles(styles) 16 | )(Component); 17 | -------------------------------------------------------------------------------- /web/src/__templates/Component/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default () => ({ 3 | root: {}, 4 | }) 5 | -------------------------------------------------------------------------------- /web/src/__templates/List/ListItem/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose, pure, withHandlers } from 'recompose' 3 | import { withStyles } from 'material-ui/styles' 4 | import { withRouter } from 'react-router' 5 | import { ListItem as MuiListItem, ListItemText as MuiListItemText } from 'material-ui/List' 6 | 7 | import styles from './styles' 8 | 9 | const ListItem = ({ key, name, slug, onClick }) => ( 10 | 11 | 12 | 13 | ) 14 | 15 | export default compose( 16 | pure, 17 | withRouter, 18 | withStyles(styles), 19 | withHandlers({ 20 | onClick: ({ slug, router }) => () => ( 21 | router.push(`/repos/${slug}`) 22 | ) 23 | }), 24 | )(ListItem); 25 | -------------------------------------------------------------------------------- /web/src/__templates/List/ListItem/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default () => ({ 3 | root: {}, 4 | }) 5 | -------------------------------------------------------------------------------- /web/src/__templates/List/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose, pure } from 'recompose' 3 | import { withStyles } from 'material-ui/styles'; 4 | import MuiList from 'material-ui/List' 5 | 6 | import ListItem from './ListItem' 7 | import styles from './styles' 8 | 9 | const List = ({ repos = [] }) => ( 10 | 11 | { repos.map(repo => ( 12 | 13 | ))} 14 | 15 | ) 16 | 17 | export default compose( 18 | pure, 19 | withStyles(styles) 20 | )(List); 21 | -------------------------------------------------------------------------------- /web/src/__templates/List/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default () => ({ 3 | root: {}, 4 | }) 5 | -------------------------------------------------------------------------------- /web/src/components/Button/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose, pure } from 'recompose' 3 | import { withStyles } from 'material-ui/styles'; 4 | 5 | import MuiButton from 'material-ui/Button' 6 | import styles from './styles' 7 | 8 | const Button = ({ classes, children, leftIcon, rightIcon, ...rest }) => ( 9 | 10 | { leftIcon && ( 11 | { leftIcon } 12 | ) } 13 | { children } 14 | { rightIcon && ( 15 | { rightIcon } 16 | ) } 17 | 18 | ) 19 | 20 | export default compose( 21 | pure, 22 | withStyles(styles) 23 | )(Button); 24 | -------------------------------------------------------------------------------- /web/src/components/Button/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default (theme) => ({ 3 | root: {}, 4 | leftIcon: { 5 | marginRight: theme.spacing.unit, 6 | }, 7 | rightIcon: { 8 | marginLeft: theme.spacing.unit, 9 | }, 10 | }) 11 | -------------------------------------------------------------------------------- /web/src/components/ElixirBenchLogo/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | 3 | import React from 'react'; 4 | import SvgIcon from 'material-ui/SvgIcon'; 5 | 6 | function ElixirBench(props) { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | 17 | 18 | 19 | ); 20 | } 21 | 22 | ElixirBench.muiName = 'SvgIcon'; 23 | 24 | export default ElixirBench; 25 | -------------------------------------------------------------------------------- /web/src/components/FormField/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose, pure, withPropsOnChange } from 'recompose' 3 | import { withStyles } from 'material-ui/styles'; 4 | 5 | import { ErrorMessages } from 'react-nebo15-validate' 6 | import TextField from 'material-ui/TextField' 7 | import Typography from 'material-ui/Typography' 8 | 9 | import styles from './styles' 10 | 11 | const Component = ({ classes, input, meta, error, children, ...rest }) => ( 12 |
13 | 19 | 20 | 21 | { children } 22 | 23 | 24 |
25 | ) 26 | 27 | export default compose( 28 | pure, 29 | withPropsOnChange( 30 | ['meta'], 31 | ({ meta }) => ({ 32 | error: ((meta.visited && meta.dirty) || meta.submitFailed) && meta.error 33 | }) 34 | ), 35 | withStyles(styles) 36 | )(Component); 37 | -------------------------------------------------------------------------------- /web/src/components/FormField/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default () => ({ 3 | root: { 4 | display: 'inline-block', 5 | }, 6 | error: { 7 | marginTop: 4, 8 | } 9 | }) 10 | -------------------------------------------------------------------------------- /web/src/components/GithubLogo/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | 3 | import React from 'react'; 4 | import SvgIcon from 'material-ui/SvgIcon'; 5 | 6 | function GitHub(props) { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | } 13 | 14 | GitHub.muiName = 'SvgIcon'; 15 | 16 | export default GitHub; 17 | -------------------------------------------------------------------------------- /web/src/components/Grid/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose, pure } from 'recompose' 3 | 4 | import { withStyles } from 'material-ui/styles' 5 | import MuiGrid from 'material-ui/Grid' 6 | 7 | import classnames from 'classnames' 8 | 9 | import styles from './styles' 10 | 11 | const Grid = ({ classes, flex, className, ...rest }) => ( 12 | 20 | ) 21 | 22 | export default compose( 23 | pure, 24 | withStyles(styles) 25 | )(Grid); 26 | -------------------------------------------------------------------------------- /web/src/components/Grid/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default () => ({ 3 | root: {}, 4 | flex: { 5 | flex: '1 1 auto', 6 | }, 7 | }) 8 | -------------------------------------------------------------------------------- /web/src/components/GridContainer/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose, pure } from 'recompose' 3 | import { withStyles } from 'material-ui/styles'; 4 | 5 | import styles from './styles' 6 | 7 | const GridContainer = ({ classes, children }) => ( 8 |
9 | { children } 10 |
11 | ) 12 | 13 | export default compose( 14 | pure, 15 | withStyles(styles) 16 | )(GridContainer); 17 | -------------------------------------------------------------------------------- /web/src/components/GridContainer/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default () => ({ 3 | root: { 4 | flexGrow: 1, 5 | width: '100%', 6 | overflowX: 'hidden', 7 | overflowY: 'auto', 8 | }, 9 | }) 10 | -------------------------------------------------------------------------------- /web/src/components/Logs/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose, pure, withPropsOnChange } from 'recompose' 3 | import { isEmpty } from 'lodash' 4 | import { withStyles } from 'material-ui/styles'; 5 | 6 | import nl2br from 'react-nl2br' 7 | 8 | import styles from './styles' 9 | 10 | const Logs = ({ classes, children, log, multilineLog, onRestartClick }) => ( 11 |
12 | { isEmpty(log) ? ( 13 |
14 |

15 | We don't have any logs. Try to wait or restart the job. 16 |

17 |

18 | We're re-fetching logs every 5 seconds... 19 |

20 |
21 | ) :
22 | { multilineLog } 23 |
} 24 |
25 | ) 26 | 27 | export default compose( 28 | pure, 29 | withPropsOnChange( 30 | ['log'], 31 | ({ log }) => ({ 32 | multilineLog: nl2br(log), 33 | }) 34 | ), 35 | withStyles(styles) 36 | )(Logs); 37 | -------------------------------------------------------------------------------- /web/src/components/Logs/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default () => ({ 3 | root: { 4 | background: '#333', 5 | color: 'white', 6 | padding: 12, 7 | borderRadius: 4, 8 | lineHeight: '1.2', 9 | fontSize: 12, 10 | fontFamily: 'monospace', 11 | fontWeight: 'normal', 12 | maxHeight: 1200, 13 | overflowY: 'auto', 14 | 15 | '& a': { 16 | textDecoration: 'underline', 17 | cursor: 'pointer', 18 | } 19 | }, 20 | empty: { 21 | padding: '40px 0', 22 | textAlign: 'center' 23 | } 24 | }) 25 | -------------------------------------------------------------------------------- /web/src/components/Page/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose, pure } from 'recompose' 3 | import { Link } from 'react-router' 4 | import { withStyles } from 'material-ui/styles' 5 | import classnames from 'classnames' 6 | 7 | import Typography from 'material-ui/Typography' 8 | import KeyboardArrowLeft from 'material-ui-icons/KeyboardArrowLeft' 9 | 10 | import Grid from 'components/Grid' 11 | 12 | import styles from './styles' 13 | 14 | const Page = ({ classes, title, maxWidth, backLink, backTitle, children }) => ( 15 |
16 | 17 | { backLink && ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | { backTitle } 26 | 27 | 28 | 29 | 30 | )} 31 | { title && ( 32 | 33 | 34 | 35 | { title } 36 | 37 | 38 | 39 | )} 40 | 41 |
47 | { children } 48 |
49 |
50 |
51 |
52 | ) 53 | 54 | export default compose( 55 | pure, 56 | withStyles(styles) 57 | )(Page); 58 | -------------------------------------------------------------------------------- /web/src/components/Page/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default () => ({ 3 | root: { 4 | padding: 24, 5 | }, 6 | maxWidth: { 7 | maxWidth: 600, 8 | marginRight: 'auto' 9 | }, 10 | backIcon: { 11 | lineHeight: 0, 12 | } 13 | }) 14 | -------------------------------------------------------------------------------- /web/src/components/PageBlock/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose, pure } from 'recompose' 3 | import { withStyles } from 'material-ui/styles'; 4 | import classnames from 'classnames' 5 | import Paper from 'material-ui/Paper' 6 | import Typography from 'material-ui/Typography' 7 | 8 | import styles from './styles' 9 | 10 | const PageBlock = ({ classes, title, maxWidth, children }) => ( 11 | 21 | { title && { title } } 22 |
23 | { children } 24 |
25 |
26 | ) 27 | 28 | export default compose( 29 | pure, 30 | withStyles(styles) 31 | )(PageBlock); 32 | -------------------------------------------------------------------------------- /web/src/components/PageBlock/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default () => ({ 3 | root: { 4 | marginTop: 24, 5 | }, 6 | maxWidth: { 7 | maxWidth: 600, 8 | marginRight: 'auto' 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /web/src/configs/index.js: -------------------------------------------------------------------------------- 1 | 2 | export const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:4000' 3 | -------------------------------------------------------------------------------- /web/src/containers/App.js: -------------------------------------------------------------------------------- 1 | import 'typeface-roboto' 2 | import React from 'react' 3 | import { Provider } from 'react-redux' 4 | import { ApolloProvider } from 'react-apollo' 5 | 6 | import Routes from 'routes' 7 | 8 | const App = ({ apolloClient,store, history }) => ( 9 | 10 | 11 | 12 | 13 | 14 | ) 15 | 16 | export default App 17 | -------------------------------------------------------------------------------- /web/src/containers/blocks/BenchmarksList/BenchmarkListItem/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose, pure, withHandlers } from 'recompose' 3 | import { withStyles } from 'material-ui/styles' 4 | import { withRouter } from 'react-router' 5 | import { ListItem as MuiListItem, ListItemText as MuiListItemText } from 'material-ui/List' 6 | 7 | import styles from './styles' 8 | 9 | const ListItem = ({ key, benchmark, onClick }) => ( 10 | 11 | 12 | 13 | ) 14 | 15 | export default compose( 16 | pure, 17 | withRouter, 18 | withStyles(styles), 19 | withHandlers({ 20 | onClick: ({ benchmark, onClick }) => (e) => ( 21 | onClick(e, benchmark) 22 | ) 23 | }), 24 | )(ListItem); 25 | -------------------------------------------------------------------------------- /web/src/containers/blocks/BenchmarksList/BenchmarkListItem/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default () => ({ 3 | root: {}, 4 | }) 5 | -------------------------------------------------------------------------------- /web/src/containers/blocks/BenchmarksList/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose, pure } from 'recompose' 3 | import { withStyles } from 'material-ui/styles'; 4 | import MuiList from 'material-ui/List' 5 | 6 | import BenchmarkListItem from './BenchmarkListItem' 7 | import styles from './styles' 8 | 9 | const BenchmarksList = ({ benchmarks = [], onBenchmarkClick }) => ( 10 | 11 | { benchmarks.map(benchmark => ( 12 | 17 | ))} 18 | 19 | ) 20 | 21 | export default compose( 22 | pure, 23 | withStyles(styles) 24 | )(BenchmarksList); 25 | -------------------------------------------------------------------------------- /web/src/containers/blocks/BenchmarksList/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default () => ({ 3 | root: {}, 4 | }) 5 | -------------------------------------------------------------------------------- /web/src/containers/blocks/Footer/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose, pure } from 'recompose' 3 | import { withStyles } from 'material-ui/styles'; 4 | 5 | import Typography from 'material-ui/Typography' 6 | import Grid from 'components/Grid' 7 | 8 | import styles from './styles' 9 | 10 | const Footer = ({ classes, children }) => ( 11 | 12 | 13 | 14 | 2017 { new Date().getFullYear() === 2017 ? '' : ` - ${new Date().getFullYear()}`} ❤  15 | ElixirBench 20 | 21 | 22 | 23 | ) 24 | 25 | export default compose( 26 | pure, 27 | withStyles(styles) 28 | )(Footer); 29 | -------------------------------------------------------------------------------- /web/src/containers/blocks/Footer/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default () => ({ 3 | root: {}, 4 | }) 5 | -------------------------------------------------------------------------------- /web/src/containers/blocks/JobsList/JobListItem/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose, pure, withHandlers } from 'recompose' 3 | import { withStyles } from 'material-ui/styles'; 4 | 5 | import { ListItem, ListItemText } from 'material-ui/List' 6 | 7 | import styles from './styles' 8 | 9 | const LastJobItem = ({ key, job, classes, onClick, children }) => ( 10 | 11 | 15 | 16 | ) 17 | 18 | export default compose( 19 | pure, 20 | withHandlers({ 21 | onClick: ({ job, onClick }) => (e) => ( 22 | onClick(e, job) 23 | ) 24 | }), 25 | withStyles(styles) 26 | )(LastJobItem); 27 | -------------------------------------------------------------------------------- /web/src/containers/blocks/JobsList/JobListItem/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default () => ({ 3 | root: {}, 4 | }) 5 | -------------------------------------------------------------------------------- /web/src/containers/blocks/JobsList/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { get } from 'lodash' 3 | import { withRouter } from 'react-router' 4 | import { compose, pure, withHandlers } from 'recompose' 5 | import { withStyles } from 'material-ui/styles' 6 | 7 | import List from 'material-ui/List' 8 | import { getJobs } from 'graphql/queries' 9 | import { graphql } from 'react-apollo' 10 | 11 | import JobListItem from './JobListItem' 12 | import styles from './styles' 13 | 14 | const LastJobs = ({ classes, data, onJobClick, children }) => ( 15 |
16 | 17 | { get(data, 'jobs', []).map(i => ( 18 | 19 | )) } 20 | 21 | { children } 22 |
23 | ) 24 | 25 | export default compose( 26 | pure, 27 | withRouter, 28 | graphql(getJobs), 29 | withHandlers({ 30 | onJobClick: ({ router }) => (e, job) => ( 31 | router.push(`/job/${job.id}`) 32 | ), 33 | }), 34 | withStyles(styles) 35 | )(LastJobs); 36 | -------------------------------------------------------------------------------- /web/src/containers/blocks/JobsList/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default () => ({ 3 | root: {}, 4 | }) 5 | -------------------------------------------------------------------------------- /web/src/containers/blocks/MeasurementsChart/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { map } from 'lodash' 3 | import { compose, pure,withPropsOnChange } from 'recompose' 4 | import { withStyles } from 'material-ui/styles' 5 | import * as d3 from 'd3' 6 | import { parse as parseDate } from 'date-fns' 7 | 8 | import { 9 | ResponsiveContainer, ComposedChart, 10 | XAxis, YAxis, Tooltip, Legend, 11 | } from 'recharts' 12 | 13 | import styles from './styles' 14 | 15 | 16 | var formatMinute = d3.timeFormat("%I:%M"), 17 | formatHour = d3.timeFormat("%I %p"), 18 | formatDay = d3.timeFormat("%a %d"), 19 | formatWeek = d3.timeFormat("%b %d"), 20 | formatMonth = d3.timeFormat("%B"), 21 | formatYear = d3.timeFormat("%Y"); 22 | 23 | function multiFormat(date) { 24 | return (d3.timeHour(date) < date ? formatMinute 25 | : d3.timeDay(date) < date ? formatHour 26 | : d3.timeMonth(date) < date ? (d3.timeWeek(date) < date ? formatDay : formatWeek) 27 | : d3.timeYear(date) < date ? formatMonth 28 | : formatYear)(date); 29 | } 30 | 31 | function formatXAxis(dateStr) { 32 | return multiFormat(parseDate(dateStr)) 33 | } 34 | 35 | const MeasurementsChart = ({ 36 | classes, 37 | measurements = [], 38 | children, 39 | dataKeys = [] 40 | }) => ( 41 | 42 | 43 | 44 | 45 | 46 | 47 | { children } 48 | 49 | 50 | ) 51 | 52 | export default compose( 53 | pure, 54 | withStyles(styles), 55 | withPropsOnChange( 56 | ['measurements'], 57 | ({ measurements }) => ({ 58 | measurements: map(measurements, i => ({ 59 | ...i.result, 60 | collectedAt: i.collectedAt 61 | })) 62 | }) 63 | ) 64 | )(MeasurementsChart); 65 | -------------------------------------------------------------------------------- /web/src/containers/blocks/MeasurementsChart/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default () => ({ 3 | root: {}, 4 | }) 5 | -------------------------------------------------------------------------------- /web/src/containers/blocks/Navigation/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose, pure } from 'recompose' 3 | import { withStyles } from 'material-ui/styles' 4 | import { Link } from 'react-router' 5 | 6 | import AppBar from 'material-ui/AppBar' 7 | import Toolbar from 'material-ui/Toolbar' 8 | import Typography from 'material-ui/Typography' 9 | import Grid from 'material-ui/Grid' 10 | 11 | import GridContainer from 'components/GridContainer' 12 | import GithubLogo from 'components/GithubLogo' 13 | import ElixirBenchLogo from 'components/ElixirBenchLogo' 14 | 15 | import styles from './styles' 16 | 17 | const Navigation = ({ classes, children }) => ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ElixirBench 27 | 28 | 29 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | ) 49 | 50 | export default compose( 51 | pure, 52 | withStyles(styles) 53 | )(Navigation); 54 | -------------------------------------------------------------------------------- /web/src/containers/blocks/Navigation/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default () => ({ 3 | root: {}, 4 | bolder: { 5 | fontWeight: 900, 6 | }, 7 | icon: { 8 | color: 'white', 9 | }, 10 | logo: { 11 | color: 'white', 12 | }, 13 | logoIcon: { 14 | marginRight: 5, 15 | marginBottom: -4, 16 | } 17 | }) 18 | -------------------------------------------------------------------------------- /web/src/containers/blocks/ReposList/RepoListItem/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose, pure, withHandlers } from 'recompose' 3 | import { withStyles } from 'material-ui/styles' 4 | import { withRouter } from 'react-router' 5 | import { ListItem, ListItemText } from 'material-ui/List' 6 | 7 | import styles from './styles' 8 | 9 | const RepoListItem = ({ key, name, slug, onClick }) => ( 10 | 11 | 12 | 13 | ) 14 | 15 | export default compose( 16 | pure, 17 | withRouter, 18 | withStyles(styles), 19 | withHandlers({ 20 | onClick: ({ slug, router }) => () => ( 21 | router.push(`/repos/${slug}`) 22 | ) 23 | }), 24 | )(RepoListItem); 25 | -------------------------------------------------------------------------------- /web/src/containers/blocks/ReposList/RepoListItem/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default () => ({ 3 | root: {}, 4 | }) 5 | -------------------------------------------------------------------------------- /web/src/containers/blocks/ReposList/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose, pure } from 'recompose' 3 | import { withStyles } from 'material-ui/styles'; 4 | import List from 'material-ui/List' 5 | 6 | import RepoListItem from './RepoListItem' 7 | import styles from './styles' 8 | 9 | const RepoList = ({ repos = [] }) => ( 10 | 11 | { repos.map(repo => ( 12 | 13 | ))} 14 | 15 | ) 16 | 17 | export default compose( 18 | pure, 19 | withStyles(styles) 20 | )(RepoList); 21 | -------------------------------------------------------------------------------- /web/src/containers/blocks/ReposList/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default () => ({ 3 | root: {}, 4 | }) 5 | -------------------------------------------------------------------------------- /web/src/containers/forms/ScheduleJobForm/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose, pure } from 'recompose' 3 | import { withStyles } from 'material-ui/styles'; 4 | import { reduxForm, Field } from 'redux-form' 5 | import { reduxFormValidate } from 'react-nebo15-validate' 6 | 7 | import Button from 'components/Button' 8 | import FormField from 'components/FormField' 9 | import Grid from 'components/Grid' 10 | import ElixirBenchLogo from 'components/ElixirBenchLogo' 11 | import Typography from 'material-ui/Typography' 12 | 13 | import styles from './styles' 14 | 15 | const ScheduleJobForm = ({ classes, error, handleSubmit }) => ( 16 |
17 | 18 | 19 | 24 | 25 | 26 | 32 | 33 | 34 | 40 | 41 | 42 | 50 | 51 | 52 | { error } 53 |
54 | ) 55 | 56 | export default compose( 57 | pure, 58 | withStyles(styles), 59 | reduxForm({ 60 | form: 'scheduleJob', 61 | validate: reduxFormValidate({ 62 | branchName: { 63 | required: true 64 | }, 65 | commitSha: { 66 | required: true, 67 | }, 68 | repoSlug: { 69 | required: true, 70 | } 71 | }) 72 | }), 73 | )(ScheduleJobForm); 74 | -------------------------------------------------------------------------------- /web/src/containers/forms/ScheduleJobForm/styles.js: -------------------------------------------------------------------------------- 1 | 2 | export default () => ({ 3 | root: {}, 4 | }) 5 | -------------------------------------------------------------------------------- /web/src/containers/layouts/AppLayout/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose, pure } from 'recompose' 3 | import { withStyles } from 'material-ui/styles' 4 | 5 | import Grid from 'components/Grid' 6 | import GridContainer from 'components/GridContainer' 7 | 8 | import Navigation from 'containers/blocks/Navigation' 9 | import Footer from 'containers/blocks/Footer' 10 | 11 | import 'reset.css' 12 | import styles from './styles' 13 | 14 | const AppLayout = ({ classes, children }) => ( 15 | 16 | 23 | 24 | 25 | 26 | 27 | { children } 28 | 29 | 30 |