├── project ├── plugins.sbt └── build.properties ├── .gitignore ├── LICENSE ├── src ├── main │ ├── scala │ │ └── com │ │ │ └── typesafe │ │ │ └── play │ │ │ └── prune │ │ │ ├── Assets.scala │ │ │ ├── JavaVersion.scala │ │ │ ├── PlayVersion.scala │ │ │ ├── Results.scala │ │ │ ├── JsonReport.scala │ │ │ ├── BuildPlay.scala │ │ │ ├── BuildApp.scala │ │ │ ├── RunTest.scala │ │ │ ├── PruneGit.scala │ │ │ ├── Exec.scala │ │ │ ├── Context.scala │ │ │ ├── Records.scala │ │ │ └── Prune.scala │ └── resources │ │ ├── com │ │ └── typesafe │ │ │ └── play │ │ │ └── prune │ │ │ └── assets │ │ │ ├── wrk_report.lua │ │ │ ├── wrk_post.lua │ │ │ ├── wrk_upload.lua │ │ │ └── 50k.bin │ │ └── reference.conf └── test │ └── scala │ └── com │ └── typesafe │ └── play │ └── prune │ └── PlayVersionSpec.scala └── README.md /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "0.8.0-M2") -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | #Activator-generated Properties 2 | #Wed Sep 24 08:05:21 NZST 2014 3 | template.uuid=a855816c-0367-44ba-9adb-6a903f6ad599 4 | sbt.version=0.13.7 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | project/project 3 | project/target 4 | target 5 | tmp 6 | .history 7 | dist 8 | /.idea 9 | /*.iml 10 | /out 11 | /.idea_modules 12 | /.cache 13 | /.classpath 14 | /.project 15 | /RUNNING_PID 16 | /.settings 17 | *.class 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2014 Typesafe, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/play/prune/Assets.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2014 Typesafe Inc. 3 | */ 4 | package com.typesafe.play.prune 5 | 6 | import java.nio.file._ 7 | import org.apache.commons.io.IOUtils 8 | 9 | object Assets { 10 | def extractAssets(implicit ctx: Context): Unit = { 11 | Files.createDirectories(Paths.get(ctx.assetsHome)) 12 | Seq("wrk_report.lua", "50k.bin", "wrk_post.lua", "wrk_upload.lua", "1m.txt") foreach { name => 13 | val p = Paths.get(ctx.assetsHome, name) 14 | val r = getClass.getPackage.getName.replace(".", "/") + "/assets/" + name 15 | //println(s"Reading from $r") 16 | val bytes = IOUtils.toByteArray(this.getClass.getClassLoader.getResourceAsStream(r)) 17 | Files.write(p, bytes) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/test/scala/com/typesafe/play/prune/PlayVersionSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2015 Typesafe Inc. 3 | */ 4 | package com.typesafe.play.prune 5 | 6 | import org.specs2.mutable.Specification 7 | 8 | object PlayVersionSpec extends Specification { 9 | 10 | "Play version passing" should { 11 | "read format 1" in { 12 | val versionString = """|Release.branchVersion in ThisBuild := "2.4.0-SNAPSHOT" 13 | |""".stripMargin 14 | PlayVersion.parsePlayVersion(versionString) must_== "2.4.0-SNAPSHOT" 15 | } 16 | "read format 2" in { 17 | val versionString = """|version in ThisBuild := "2.5.0-SNAPSHOT" 18 | |""".stripMargin 19 | PlayVersion.parsePlayVersion(versionString) must_== "2.5.0-SNAPSHOT" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/play/prune/JavaVersion.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2014 Typesafe Inc. 3 | */ 4 | package com.typesafe.play.prune 5 | 6 | import com.typesafe.config.{ Config, ConfigFactory } 7 | import java.io._ 8 | import java.nio.file._ 9 | import java.util.{ List => JList, Map => JMap, UUID } 10 | import java.util.concurrent.TimeUnit 11 | import org.apache.commons.io.{ FileUtils, IOUtils } 12 | import org.apache.commons.exec._ 13 | import org.eclipse.jgit.api.Git 14 | import org.eclipse.jgit.lib._ 15 | import org.eclipse.jgit.revwalk._ 16 | import org.eclipse.jgit.storage.file.FileRepositoryBuilder 17 | import org.joda.time._ 18 | import scala.collection.JavaConversions 19 | import scala.concurrent._ 20 | import scala.concurrent.duration.Duration 21 | import scala.concurrent.ExecutionContext.Implicits.global 22 | import scopt.OptionParser 23 | 24 | import Exec._ 25 | 26 | object JavaVersion { 27 | 28 | def captureJavaVersion()(implicit ctx: Context): Execution = { 29 | run( 30 | Command( 31 | program = "/bin/java", 32 | args = Seq("-version"), 33 | workingDir = "", 34 | env = Map() 35 | ), 36 | streamHandling = Capture, 37 | errorOnNonZeroExit = false, 38 | timeout = Some(60000) 39 | ) 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/play/prune/PlayVersion.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2014 Typesafe Inc. 3 | */ 4 | package com.typesafe.play.prune 5 | 6 | import java.nio.file._ 7 | import java.util.regex._ 8 | 9 | import scala.Predef 10 | 11 | object PlayVersion { 12 | 13 | def readPlayVersionFromFile()(implicit ctx: Context): String = { 14 | val versionFilePath = Paths.get(ctx.playHome, "framework/version.sbt") 15 | val fileString: String = { 16 | val fileBytes = Files.readAllBytes(versionFilePath) 17 | new String(fileBytes, "UTF-8") 18 | } 19 | parsePlayVersion(fileString) 20 | } 21 | 22 | def parsePlayVersion(rawString: String): String = { 23 | val cleanedString = rawString.trim 24 | // Examples to match: 25 | // - Release.branchVersion in ThisBuild := "2.4-SNAPSHOT" 26 | // - version in ThisBuild := "2.5.0-SNAPSHOT" 27 | // - // 28 | // // Copyright (C) 2009-2016 Typesafe Inc. 29 | // // 30 | // 31 | // version in ThisBuild := "2.5.0-SNAPSHOT" 32 | val pattern = Pattern.compile("(.*)ersion in ThisBuild := \"(.*)\"(.*)", Pattern.DOTALL) 33 | val matcher = pattern.matcher(cleanedString) 34 | assert(matcher.matches(), s"Couldn't read Play version from [$cleanedString]: regex didn't match") 35 | val version = matcher.group(2) 36 | version 37 | } 38 | } -------------------------------------------------------------------------------- /src/main/resources/com/typesafe/play/prune/assets/wrk_report.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- THE CODE IN THIS SCRIPT NEEDS TO BE MANUALLY COPIED INTO THE OTHER LUA SCRIPTS WHENEVER IT IS CHANGED 3 | -- 4 | 5 | -- A script for `wrk` to write out test results in JSON format. 6 | 7 | done = function(summary, latency, requests) 8 | io.write("JSON: {\n") 9 | io.write('"summary":{\n') 10 | 11 | -- summary = { 12 | -- duration = N, -- run duration in microseconds 13 | -- requests = N, -- total completed requests 14 | -- bytes = N, -- total bytes received 15 | -- errors = { 16 | -- connect = N, -- total socket connection errors 17 | -- read = N, -- total socket read errors 18 | -- write = N, -- total socket write errors 19 | -- status = N, -- total HTTP status codes > 399 20 | -- timeout = N -- total request timeouts 21 | -- } 22 | -- } 23 | 24 | io.write(string.format('"duration":%d,\n', summary.duration)) 25 | io.write(string.format('"requests":%d,\n', summary.requests)) 26 | io.write(string.format('"bytes":%d,\n', summary.bytes)) 27 | io.write('"errors":{') 28 | io.write(string.format('"connect":%d,', summary.errors.connect)) 29 | io.write(string.format('"read":%d,', summary.errors.read)) 30 | io.write(string.format('"write":%d,', summary.errors.write)) 31 | io.write(string.format('"status":%d,', summary.errors.status)) 32 | io.write(string.format('"timeout":%d', summary.errors.timeout)) 33 | io.write('}\n') 34 | io.write('},\n') 35 | io.write('"latency":{\n') 36 | stats(latency) 37 | io.write('},\n') 38 | io.write('"requests":{\n') 39 | stats(requests) 40 | io.write('}\n') 41 | io.write("}\n") 42 | end 43 | 44 | function stats(obj) 45 | -- latency.min -- minimum value seen 46 | -- latency.max -- maximum value seen 47 | -- latency.mean -- average value seen 48 | -- latency.stdev -- standard deviation 49 | -- latency:percentile(99.0) -- 99th percentile value 50 | -- latency[i] -- raw sample value 51 | 52 | io.write(string.format('"min":%g,\n', obj.min)) 53 | io.write(string.format('"mean":%g,\n', obj.mean)) 54 | io.write(string.format('"max":%g,\n', obj.max)) 55 | io.write(string.format('"stdev":%g,\n', obj.stdev)) 56 | io.write('"percentile":[') 57 | io.write(string.format('[1,%d],', obj:percentile(1))) 58 | io.write(string.format('[5,%d],', obj:percentile(5))) 59 | io.write(string.format('[10,%d],', obj:percentile(10))) 60 | io.write(string.format('[25,%d],', obj:percentile(25))) 61 | io.write(string.format('[50,%d],', obj:percentile(50))) 62 | io.write(string.format('[75,%d],', obj:percentile(75))) 63 | io.write(string.format('[90,%d],', obj:percentile(90))) 64 | io.write(string.format('[95,%d],', obj:percentile(95))) 65 | io.write(string.format('[99,%d]', obj:percentile(99))) 66 | io.write(']\n') 67 | io.flush() 68 | end 69 | -------------------------------------------------------------------------------- /src/main/resources/com/typesafe/play/prune/assets/wrk_post.lua: -------------------------------------------------------------------------------- 1 | -- example HTTP POST script which demonstrates setting the 2 | -- HTTP method, body, and adding a header 3 | 4 | wrk.method = "POST" 5 | wrk.body = "name=PlayFramework&age=10&email=play@playframework.com&twitter=playframework&github=playframework" 6 | wrk.headers["Content-Type"] = "application/x-www-form-urlencoded" 7 | 8 | -- 9 | -- THE FOLLOWING CODE IS MANUALLY INCLUDED FROM 'wrk_report.lua' 10 | -- 11 | 12 | -- A script for `wrk` to write out test results in JSON format. 13 | 14 | done = function(summary, latency, requests) 15 | io.write("JSON: {\n") 16 | io.write('"summary":{\n') 17 | 18 | -- summary = { 19 | -- duration = N, -- run duration in microseconds 20 | -- requests = N, -- total completed requests 21 | -- bytes = N, -- total bytes received 22 | -- errors = { 23 | -- connect = N, -- total socket connection errors 24 | -- read = N, -- total socket read errors 25 | -- write = N, -- total socket write errors 26 | -- status = N, -- total HTTP status codes > 399 27 | -- timeout = N -- total request timeouts 28 | -- } 29 | -- } 30 | 31 | io.write(string.format('"duration":%d,\n', summary.duration)) 32 | io.write(string.format('"requests":%d,\n', summary.requests)) 33 | io.write(string.format('"bytes":%d,\n', summary.bytes)) 34 | io.write('"errors":{') 35 | io.write(string.format('"connect":%d,', summary.errors.connect)) 36 | io.write(string.format('"read":%d,', summary.errors.read)) 37 | io.write(string.format('"write":%d,', summary.errors.write)) 38 | io.write(string.format('"status":%d,', summary.errors.status)) 39 | io.write(string.format('"timeout":%d', summary.errors.timeout)) 40 | io.write('}\n') 41 | io.write('},\n') 42 | io.write('"latency":{\n') 43 | stats(latency) 44 | io.write('},\n') 45 | io.write('"requests":{\n') 46 | stats(requests) 47 | io.write('}\n') 48 | io.write("}\n") 49 | end 50 | 51 | function stats(obj) 52 | -- latency.min -- minimum value seen 53 | -- latency.max -- maximum value seen 54 | -- latency.mean -- average value seen 55 | -- latency.stdev -- standard deviation 56 | -- latency:percentile(99.0) -- 99th percentile value 57 | -- latency[i] -- raw sample value 58 | 59 | io.write(string.format('"min":%g,\n', obj.min)) 60 | io.write(string.format('"mean":%g,\n', obj.mean)) 61 | io.write(string.format('"max":%g,\n', obj.max)) 62 | io.write(string.format('"stdev":%g,\n', obj.stdev)) 63 | io.write('"percentile":[') 64 | io.write(string.format('[1,%d],', obj:percentile(1))) 65 | io.write(string.format('[5,%d],', obj:percentile(5))) 66 | io.write(string.format('[10,%d],', obj:percentile(10))) 67 | io.write(string.format('[25,%d],', obj:percentile(25))) 68 | io.write(string.format('[50,%d],', obj:percentile(50))) 69 | io.write(string.format('[75,%d],', obj:percentile(75))) 70 | io.write(string.format('[90,%d],', obj:percentile(90))) 71 | io.write(string.format('[95,%d],', obj:percentile(95))) 72 | io.write(string.format('[99,%d]', obj:percentile(99))) 73 | io.write(']\n') 74 | io.flush() 75 | end 76 | -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/play/prune/Results.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2014 Typesafe Inc. 3 | */ 4 | package com.typesafe.play.prune 5 | 6 | import com.fasterxml.jackson.core.JsonParseException 7 | import play.api.libs.functional.syntax._ 8 | import play.api.libs.json.{JsPath, JsValue, Json, Reads} 9 | 10 | 11 | object Results { 12 | 13 | def parseWrkOutput(output: String): Either[String,WrkResult] = { 14 | val split: Array[String] = output.split("JSON:") 15 | split.tail.headOption.toRight("Missing JSON in result").right.flatMap { jsonText: String => 16 | try Right(Json.parse(jsonText)) catch { 17 | case e: JsonParseException => Left(s"Failed to parse wrk result: ${e}: $jsonText") 18 | } 19 | }.right.flatMap { json: JsValue => 20 | val result = WrkResult.reads.reads(json) 21 | result.fold(_ => Left(s"Failed to parse wrk result: $json"), r => Right(r)) 22 | } 23 | } 24 | 25 | } 26 | 27 | case class WrkResult( 28 | duration: Long, requests: Long, bytes: Long, 29 | connectErrors: Long, readErrors: Long, writeErrors: Long, statusErrors: Long, 30 | latency: Stats, requestsPerSecond: Stats 31 | ) { 32 | def summary: Either[String, WrkSummary] = { 33 | def errorMessage(errorCount: Long, errorName: String): Seq[String] = { 34 | if (errorCount == 0) Seq.empty else Seq(s"$errorName errors: $errorCount") 35 | } 36 | val errorMessages: Seq[String] = 37 | errorMessage(connectErrors, "Connect") ++ errorMessage(readErrors, "Read") ++ 38 | errorMessage(writeErrors, "Write") ++ errorMessage(statusErrors, "Status") 39 | 40 | if (errorMessages.isEmpty) { 41 | Right(WrkSummary( 42 | requestsPerSecond = requests.toDouble / duration.toDouble * 1000000, 43 | latencyMean = latency.mean / 1000, 44 | latency95 = latency.percentiles(95).toDouble / 1000 45 | )) 46 | } else { 47 | Left(errorMessages.mkString(", ")) 48 | } 49 | } 50 | } 51 | 52 | object WrkResult { 53 | 54 | implicit val reads: Reads[WrkResult] = ( 55 | (JsPath \ "summary" \ "duration").read[Long] and 56 | (JsPath \ "summary" \ "requests").read[Long] and 57 | (JsPath \ "summary" \ "bytes").read[Long] and 58 | (JsPath \ "summary" \ "errors" \ "connect").read[Long] and 59 | (JsPath \ "summary" \ "errors" \ "read").read[Long] and 60 | (JsPath \ "summary" \ "errors" \ "write").read[Long] and 61 | (JsPath \ "summary" \ "errors" \ "status").read[Long] and 62 | (JsPath \ "latency").read[Stats] and 63 | (JsPath \ "requests").read[Stats] 64 | )(WrkResult.apply _) 65 | } 66 | 67 | case class Stats( 68 | min: Long, 69 | mean: Double, 70 | max: Long, 71 | stdev: Double, 72 | percentiles: Map[Int, Long] 73 | ) 74 | 75 | object Stats { 76 | 77 | implicit val reads: Reads[Stats] = ( 78 | (JsPath \ "min").read[Long] and 79 | (JsPath \ "mean").read[Double] and 80 | (JsPath \ "max").read[Long] and 81 | (JsPath \ "stdev").read[Double] and 82 | (JsPath \ "percentile").read[Seq[Seq[Long]]].map { percentilePairArrays: Seq[Seq[Long]] => 83 | percentilePairArrays.foldLeft[Map[Int,Long]](Map.empty) { 84 | case (percentiles, latencyPairArray) => 85 | val percentile: Int = latencyPairArray(0).toInt 86 | val value: Long = latencyPairArray(1) 87 | percentiles.updated(percentile, value) 88 | } 89 | } 90 | )(Stats.apply _) 91 | } 92 | 93 | case class WrkSummary( 94 | requestsPerSecond: Double, 95 | latencyMean: Double, 96 | latency95: Double 97 | ) { 98 | def display: String = { 99 | s"Requests/s: ${requestsPerSecond}, "+ 100 | s"Mean latency: ${latencyMean}, " + 101 | s"Latency 95%: ${latency95}" 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/main/resources/com/typesafe/play/prune/assets/wrk_upload.lua: -------------------------------------------------------------------------------- 1 | -- HTTP POST script which simulates a file upload 2 | -- HTTP method, body, and adding a header 3 | -- See https://tools.ietf.org/html/rfc1867 4 | 5 | function read_txt_file(path) 6 | local file, errorMessage = io.open(path, "r") 7 | if not file then 8 | error("Could not read the file:" .. errorMessage .. "\n") 9 | end 10 | 11 | local content = file:read "*all" 12 | file:close() 13 | return content 14 | end 15 | 16 | local Boundary = "----WebKitFormBoundaryePkpFF7tjBAqx29L" 17 | local BodyBoundary = "--" .. Boundary 18 | local LastBoundary = "--" .. Boundary .. "--" 19 | 20 | local CRLF = "\r\n" 21 | 22 | local FileBody = read_txt_file("assets/1m.txt") 23 | 24 | -- We don't need different file names here because the test should 25 | -- always replace the uploaded file with the new one. This will avoid 26 | -- the problem with directories having too much files and slowing down 27 | -- the application, which is not what we are trying to test here. 28 | -- This will also avoid overloading wrk with more things do to, which 29 | -- can influence the test results. 30 | local Filename = "test.txt" 31 | 32 | local ContentDisposition = "Content-Disposition: form-data; name=\"file\"; filename=\"" .. Filename .. "\"" 33 | 34 | wrk.method = "PUT" 35 | wrk.headers["Content-Type"] = "multipart/form-data; boundary=" .. Boundary 36 | wrk.body = BodyBoundary .. CRLF .. ContentDisposition .. CRLF .. CRLF .. FileBody .. CRLF .. LastBoundary 37 | 38 | -- 39 | -- THE FOLLOWING CODE IS MANUALLY INCLUDED FROM 'wrk_report.lua' 40 | -- 41 | 42 | -- A script for `wrk` to write out test results in JSON format. 43 | 44 | done = function(summary, latency, requests) 45 | io.write("JSON: {\n") 46 | io.write('"summary":{\n') 47 | 48 | -- summary = { 49 | -- duration = N, -- run duration in microseconds 50 | -- requests = N, -- total completed requests 51 | -- bytes = N, -- total bytes received 52 | -- errors = { 53 | -- connect = N, -- total socket connection errors 54 | -- read = N, -- total socket read errors 55 | -- write = N, -- total socket write errors 56 | -- status = N, -- total HTTP status codes > 399 57 | -- timeout = N -- total request timeouts 58 | -- } 59 | -- } 60 | 61 | io.write(string.format('"duration":%d,\n', summary.duration)) 62 | io.write(string.format('"requests":%d,\n', summary.requests)) 63 | io.write(string.format('"bytes":%d,\n', summary.bytes)) 64 | io.write('"errors":{') 65 | io.write(string.format('"connect":%d,', summary.errors.connect)) 66 | io.write(string.format('"read":%d,', summary.errors.read)) 67 | io.write(string.format('"write":%d,', summary.errors.write)) 68 | io.write(string.format('"status":%d,', summary.errors.status)) 69 | io.write(string.format('"timeout":%d', summary.errors.timeout)) 70 | io.write('}\n') 71 | io.write('},\n') 72 | io.write('"latency":{\n') 73 | stats(latency) 74 | io.write('},\n') 75 | io.write('"requests":{\n') 76 | stats(requests) 77 | io.write('}\n') 78 | io.write("}\n") 79 | end 80 | 81 | function stats(obj) 82 | -- latency.min -- minimum value seen 83 | -- latency.max -- maximum value seen 84 | -- latency.mean -- average value seen 85 | -- latency.stdev -- standard deviation 86 | -- latency:percentile(99.0) -- 99th percentile value 87 | -- latency[i] -- raw sample value 88 | 89 | io.write(string.format('"min":%g,\n', obj.min)) 90 | io.write(string.format('"mean":%g,\n', obj.mean)) 91 | io.write(string.format('"max":%g,\n', obj.max)) 92 | io.write(string.format('"stdev":%g,\n', obj.stdev)) 93 | io.write('"percentile":[') 94 | io.write(string.format('[1,%d],', obj:percentile(1))) 95 | io.write(string.format('[5,%d],', obj:percentile(5))) 96 | io.write(string.format('[10,%d],', obj:percentile(10))) 97 | io.write(string.format('[25,%d],', obj:percentile(25))) 98 | io.write(string.format('[50,%d],', obj:percentile(50))) 99 | io.write(string.format('[75,%d],', obj:percentile(75))) 100 | io.write(string.format('[90,%d],', obj:percentile(90))) 101 | io.write(string.format('[95,%d],', obj:percentile(95))) 102 | io.write(string.format('[99,%d]', obj:percentile(99))) 103 | io.write(']\n') 104 | io.flush() 105 | end 106 | -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/play/prune/JsonReport.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2014 Typesafe Inc. 3 | */ 4 | package com.typesafe.play.prune 5 | 6 | import java.nio.file.{Paths, Files} 7 | import java.util.UUID 8 | import java.util.concurrent.TimeUnit 9 | 10 | import com.typesafe.play.prune.PruneGit.LogEntry 11 | import org.joda.time.DateTime 12 | 13 | import scala.collection.convert.WrapAsScala._ 14 | 15 | object JsonReport { 16 | def generateJsonReport(implicit ctx: Context): String = { 17 | // Use HOURS because MILLISECONDS can overflow DateTime.minusMillis() 18 | val hours = ctx.config.getDuration("jsonReport.duration", TimeUnit.HOURS) 19 | val endTime: DateTime = DateTime.now 20 | val startTime = endTime.minusHours(hours.toInt) 21 | println(s"Generating report from $startTime until $endTime") 22 | 23 | type BranchName = String 24 | type Commit = String 25 | 26 | // TODO: Use PruneGit.LogEntry type instead 27 | case class CommitInfo( 28 | commit: Commit, 29 | time: DateTime 30 | ) 31 | type TestName = String 32 | case class TestResult( 33 | testRunId: UUID, 34 | requestsPerSecond: Double, 35 | latencyMean: Double, 36 | latency95: Double 37 | ) 38 | type TestDescription = String 39 | case class Output( 40 | start: DateTime, 41 | end: DateTime, 42 | branches: Map[BranchName, Seq[CommitInfo]], 43 | tests: Map[TestName, TestDescription], 44 | results: Map[Commit, Map[TestName, TestResult]] 45 | ) 46 | 47 | val branches: Map[BranchName, Seq[CommitInfo]] = { 48 | val branchNames: Seq[BranchName] = asScalaBuffer(ctx.config.getStringList("jsonReport.playBranches")) 49 | branchNames.map { branch => 50 | val commits: Seq[LogEntry] = PruneGit.gitFirstParentsLogToDate(ctx.playHome, branch, "HEAD", startTime) 51 | val commitInfos: Seq[CommitInfo] = commits.map(le => CommitInfo(le.id, le.time)) 52 | (branch, commitInfos) 53 | }.toMap 54 | } 55 | 56 | val tests: Map[TestName, TestDescription] = ctx.testConfig.mapValues(_.description) 57 | 58 | val results: Map[Commit, Map[TestName, TestResult]] = { 59 | // Flatten commits into a set for fast lookup 60 | val commitSet: Set[Commit] = 61 | (for ((branch, commitInfos) <- branches; commitInfo <- commitInfos) yield commitInfo.commit).to[Set] 62 | val flatCommitResults: Iterator[(Commit, TestName, TestResult)] = DB.iterator.flatMap { join => 63 | if (join.pruneInstanceId == ctx.pruneInstanceId && 64 | commitSet.contains(join.playBuildRecord.playCommit)) { 65 | val optStdout: Option[String] = join.testRunRecord.wrkExecutions.last.stdout 66 | val optWrkResult: Option[WrkResult] = 67 | optStdout.flatMap { stdoutString => 68 | Results.parseWrkOutput(stdoutString).fold( 69 | error => None, 70 | result => Some(result) 71 | ) 72 | } 73 | optWrkResult.fold[Iterator[(Commit, TestName, TestResult)]](Iterator.empty) { wr => 74 | wr.summary.fold( 75 | _ => Iterator.empty, 76 | summary => { 77 | val testResult = TestResult( 78 | testRunId = join.testRunId, 79 | requestsPerSecond = summary.requestsPerSecond, 80 | latencyMean = summary.latencyMean, 81 | latency95 = summary.latency95 82 | ) 83 | Iterator((join.playBuildRecord.playCommit, join.testRunRecord.testName, testResult)) 84 | } 85 | ) 86 | } 87 | } else Iterator.empty 88 | } 89 | flatCommitResults.foldLeft[Map[Commit, Map[TestName, TestResult]]](Map.empty) { 90 | case (commitMap, (commit, testName, testResult)) => 91 | val testNameMap: Map[TestName, TestResult] = commitMap.getOrElse(commit, Map.empty) 92 | if (testNameMap.contains(testName)) println(s"Overwriting existing test result for $commit $testName") 93 | commitMap + (commit -> (testNameMap + (testName -> testResult))) 94 | } 95 | } 96 | 97 | val output = Output( 98 | start = startTime, 99 | end = endTime, 100 | branches = branches, 101 | tests = tests, 102 | results = results 103 | ) 104 | 105 | import play.api.libs.json._ 106 | 107 | implicit val writesTestResult = new Writes[TestResult] { 108 | def writes(tr: TestResult) = Json.obj( 109 | "run" -> tr.testRunId, 110 | "req/s" -> tr.requestsPerSecond, 111 | "latMean" -> tr.latencyMean, 112 | "lat95" -> tr.latency95 113 | ) 114 | } 115 | implicit val writesCommitInfo = new Writes[CommitInfo] { 116 | def writes(ci: CommitInfo) = Json.obj( 117 | "commit" -> ci.commit, 118 | "time" -> ci.time 119 | ) 120 | } 121 | implicit val writesOutput = new Writes[Output] { 122 | def writes(o: Output) = Json.obj( 123 | "start" -> o.start, 124 | "end" -> o.end, 125 | "branches" -> o.branches, 126 | "tests" -> o.tests, 127 | "results" -> o.results 128 | ) 129 | } 130 | val json = writesOutput.writes(output) 131 | val jsonString = Json.stringify(json) 132 | jsonString 133 | } 134 | 135 | } 136 | -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/play/prune/BuildPlay.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2014 Typesafe Inc. 3 | */ 4 | package com.typesafe.play.prune 5 | 6 | import java.nio.file._ 7 | import java.nio.file.attribute.{PosixFileAttributes, PosixFilePermission} 8 | import java.util.{UUID, List => JList, Map => JMap} 9 | 10 | import com.typesafe.play.prune.Exec._ 11 | import com.typesafe.play.prune.PruneGit._ 12 | import org.apache.commons.io.FileUtils 13 | 14 | object BuildPlay { 15 | def buildPlay(playBranch: String, playCommit: String)(implicit ctx: Context): Option[(UUID, PlayBuildRecord)] = { 16 | 17 | val description = s"${playCommit.substring(0, 7)} [$playBranch]" 18 | 19 | val javaVersionExecution: Execution = JavaVersion.captureJavaVersion() 20 | 21 | def newPlayBuild(): (UUID, PlayBuildRecord) = { 22 | val newPlayBuildId = UUID.randomUUID() 23 | println(s"Starting new Play $description build: $newPlayBuildId") 24 | 25 | gitCheckout( 26 | localDir = ctx.playHome, 27 | branch = playBranch, 28 | commit = playCommit) 29 | 30 | val executions: Seq[Execution] = buildPlayDirectly(errorOnNonZeroExit = false) 31 | val newPlayBuildRecord = PlayBuildRecord( 32 | pruneInstanceId = ctx.pruneInstanceId, 33 | playCommit = playCommit, 34 | javaVersionExecution = javaVersionExecution, 35 | buildExecutions = executions 36 | ) 37 | PlayBuildRecord.write(newPlayBuildId, newPlayBuildRecord) 38 | val oldPersistentState: PrunePersistentState = PrunePersistentState.readOrElse 39 | PrunePersistentState.write(oldPersistentState.copy(lastPlayBuild = Some(newPlayBuildId))) 40 | (newPlayBuildId, newPlayBuildRecord) 41 | } 42 | 43 | def buildSuccessAndFailuresForCommit(): (Int, Int) = { 44 | Records.iteratorAll[PlayBuildRecord](Paths.get(ctx.dbHome, "play-builds")).foldLeft((0, 0)) { 45 | case ((successes, failures), (uuid, record)) if (record.playCommit == playCommit) => 46 | if (record.successfulBuild) (successes+1, failures) else (successes, failures+1) 47 | case (acc, _) => acc 48 | } 49 | } 50 | 51 | def buildUnlessTooManyFailures(): Option[(UUID, PlayBuildRecord)] = { 52 | val (successes, failures) = buildSuccessAndFailuresForCommit() 53 | if (failures - successes >= ctx.args.maxBuildFailures) { 54 | println(s"Too many previous build failures for ${playCommit.substring(0,7)} ($successes successes, $failures failures), aborting build") 55 | None 56 | } else { 57 | Some(newPlayBuild()) 58 | } 59 | } 60 | 61 | lastBuild().fold[Option[(UUID, PlayBuildRecord)]] { 62 | println("No current build for Play") 63 | buildUnlessTooManyFailures() 64 | } { 65 | case (lastPlayBuildId, lastPlayBuildRecord) => 66 | // We hava previously built version of Play. Can we use it? 67 | if (lastPlayBuildRecord.playCommit != playCommit) { 68 | println(s"New Play build needed: Play commit has changed to ${playCommit.substring(0,7)}") 69 | buildUnlessTooManyFailures() 70 | } else if (!Files.exists(localIvyRepository)) { 71 | println("New Play build needed: Local Ivy repository is missing") 72 | buildUnlessTooManyFailures() 73 | } else { 74 | println("Using existing Play build") 75 | Some((lastPlayBuildId, lastPlayBuildRecord)) 76 | } 77 | } 78 | } 79 | 80 | def lastBuild()(implicit ctx: Context): Option[(UUID, PlayBuildRecord)] = { 81 | for { 82 | persistentState <- PrunePersistentState.read 83 | lastPlayBuildId <- persistentState.lastPlayBuild 84 | lastPlayBuildRecord <- PlayBuildRecord.read(lastPlayBuildId) 85 | } yield (lastPlayBuildId, lastPlayBuildRecord) 86 | } 87 | 88 | private def localIvyRepository(implicit ctx: Context): Path = { 89 | val ivyHome: String = ctx.config.getString("ivy.home") 90 | Paths.get(ivyHome).resolve("local") 91 | } 92 | 93 | def buildPlayDirectly(errorOnNonZeroExit: Boolean = true)(implicit ctx: Context): Seq[Execution] = { 94 | 95 | // While we're building there won't be a current Play build for this app 96 | val oldPersistentState: PrunePersistentState = PrunePersistentState.readOrElse 97 | PrunePersistentState.write(oldPersistentState.copy(lastPlayBuild = None)) 98 | 99 | // Clear target directories and local Ivy repository to ensure an isolated build 100 | Seq( 101 | localIvyRepository, 102 | Paths.get(ctx.playHome, "framework/target"), 103 | Paths.get(ctx.playHome, "framework/project/target") 104 | ) foreach { p => 105 | if (Files.exists(p)) { 106 | FileUtils.deleteDirectory(p.toFile) 107 | } 108 | } 109 | 110 | if (Files.exists(Paths.get(replaceContextValues("/framework/build")))) { 111 | 112 | } 113 | 114 | val commonBuildEnv = Map( 115 | "JAVA_HOME" -> "", 116 | "LANG" -> "en_US.UTF-8" 117 | ) 118 | 119 | val buildCommand: Command = { 120 | val playHome: Path = Paths.get(ctx.playHome) 121 | val oldBuildExists = Files.exists(playHome.resolve("framework/build")) 122 | if (oldBuildExists) { 123 | // Run Play's build command 124 | Command( 125 | program = "./build", 126 | args = Seq("-Dsbt.ivy.home=", ";clean;publishLocal"), 127 | env = commonBuildEnv, 128 | workingDir = "/framework" 129 | ) 130 | } else { 131 | // Run sbt directly 132 | Command( 133 | program = "sbt", 134 | args = Seq("-Dsbt.ivy.home=", "clean", "quickPublish", "publishLocal"), 135 | env = commonBuildEnv, 136 | workingDir = "/framework" 137 | ) 138 | } 139 | } 140 | 141 | val execution = run(buildCommand, streamHandling = Pump, errorOnNonZeroExit = errorOnNonZeroExit) 142 | Seq(execution) 143 | } 144 | 145 | } -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/play/prune/BuildApp.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2014 Typesafe Inc. 3 | */ 4 | package com.typesafe.play.prune 5 | 6 | import java.nio.file._ 7 | import java.util.UUID 8 | import org.apache.commons.io.FileUtils 9 | 10 | import Exec._ 11 | import PruneGit._ 12 | 13 | object BuildApp { 14 | def buildApp( 15 | playBranch: String, 16 | playCommit: String, 17 | appsBranch: String, 18 | appsCommit: String, 19 | appName: String)(implicit ctx: Context): Option[(UUID, AppBuildRecord)] = { 20 | 21 | val description = s"$appName for Play ${playCommit.substring(0, 7)} [$playBranch]" 22 | 23 | val javaVersionExecution: Execution = JavaVersion.captureJavaVersion() 24 | 25 | def newAppBuild(playBuildId: UUID): (UUID, AppBuildRecord) = { 26 | val newAppBuildId = UUID.randomUUID() 27 | println(s"Starting new app $description build: $newAppBuildId") 28 | 29 | gitCheckout( 30 | localDir = ctx.appsHome, 31 | branch = appsBranch, 32 | commit = appsCommit) 33 | 34 | // Clear local target directory to ensure an isolated build 35 | { 36 | val targetDir = Paths.get(ctx.appsHome, appName, "target") 37 | if (Files.exists(targetDir)) { 38 | FileUtils.deleteDirectory(targetDir.toFile) 39 | } 40 | } 41 | 42 | val buildExecutions = buildAppDirectly(appName) 43 | 44 | val newAppBuildRecord = AppBuildRecord( 45 | playBuildId = playBuildId, 46 | appsCommit = appsCommit, 47 | appName = appName, 48 | javaVersionExecution = javaVersionExecution, 49 | buildExecutions = buildExecutions 50 | ) 51 | AppBuildRecord.write(newAppBuildId, newAppBuildRecord) 52 | val oldPersistentState = PrunePersistentState.readOrElse 53 | PrunePersistentState.write(oldPersistentState.copy(lastAppBuilds = oldPersistentState.lastAppBuilds.updated(appName, newAppBuildId))) 54 | (newAppBuildId, newAppBuildRecord) 55 | } 56 | 57 | def lastBuild(): Option[(UUID, AppBuildRecord)] = { 58 | for { 59 | persistentState <- PrunePersistentState.read 60 | lastAppBuildId <- persistentState.lastAppBuilds.get(appName) 61 | lastAppBuildRecord <- AppBuildRecord.read(lastAppBuildId) 62 | } yield (lastAppBuildId, lastAppBuildRecord) 63 | } 64 | 65 | def buildSuccessAndFailuresForCommit(): (Int, Int) = { 66 | Records.iteratorAll[AppBuildRecord](Paths.get(ctx.dbHome, "app-builds")).foldLeft((0, 0)) { 67 | case (acc@(successes, failures), (uuid, record)) if (record.appsCommit == appsCommit) => 68 | PlayBuildRecord.read(record.playBuildId).fold { 69 | // If we can't load the Play build then we don't know if the build for that Play commit failed 70 | acc 71 | } { playBuildRecord => 72 | if (playBuildRecord.playCommit == playCommit) { 73 | if (record.successfulBuild) (successes+1, failures) else (successes, failures+1) 74 | } else acc 75 | } 76 | case (acc, _) => acc 77 | } 78 | } 79 | 80 | val optPlayBuild: Option[(UUID, PlayBuildRecord)] = BuildPlay.buildPlay(playBranch = playBranch, playCommit = playCommit) 81 | 82 | optPlayBuild.fold[Option[(UUID, AppBuildRecord)]] { 83 | println(s"No Play build: skipping build of app $description") 84 | None 85 | } { 86 | case (playBuildId, playBuildRecord) => 87 | 88 | def buildUnlessTooManyFailures(): Option[(UUID, AppBuildRecord)] = { 89 | val (successes, failures) = buildSuccessAndFailuresForCommit() 90 | if (failures - successes >= ctx.args.maxBuildFailures) { 91 | println(s"Too many previous build failures for app $description ($successes successes, $failures failures), aborting build") 92 | None 93 | } else { 94 | Some(newAppBuild(playBuildId)) 95 | } 96 | } 97 | 98 | if (!playBuildRecord.successfulBuild) { 99 | println(s"Play build $playBuildId was unsuccessful: skipping build of app $description") 100 | None 101 | } else { 102 | lastBuild().fold[Option[(UUID, AppBuildRecord)]] { 103 | println(s"No existing app build record for $description") 104 | buildUnlessTooManyFailures() 105 | } { 106 | case (lastAppBuildId, lastAppBuildRecord) => 107 | // We have a previously built app. Can we use it? 108 | if (lastAppBuildRecord.playBuildId != playBuildId) { 109 | println("Need new app build: Play build has changed") 110 | buildUnlessTooManyFailures() 111 | } else if (lastAppBuildRecord.appsCommit != appsCommit) { 112 | println("Need new app build: apps commit has changed") 113 | buildUnlessTooManyFailures() 114 | } else if (!Files.exists(Paths.get(ctx.appsHome, appName, "target/universal/stage/bin", appName))) { 115 | println("Need new app build: app binary not found") 116 | buildUnlessTooManyFailures() 117 | } else { 118 | println(s"App $description already built: ${lastAppBuildId}") 119 | Some((lastAppBuildId, lastAppBuildRecord)) 120 | } 121 | } 122 | } 123 | } 124 | } 125 | 126 | def buildAppDirectly(appName: String, errorOnNonZeroExit: Boolean = false)(implicit ctx: Context): Seq[Execution] = { 127 | 128 | // While we're building there won't be a current build for this app 129 | val oldPersistentState = PrunePersistentState.readOrElse 130 | PrunePersistentState.write(oldPersistentState.copy(lastAppBuilds = oldPersistentState.lastAppBuilds - appName)) 131 | 132 | // Clear local target directory to ensure an isolated build 133 | val targetDir = Paths.get(ctx.appsHome, appName, "target") 134 | if (Files.exists(targetDir)) { 135 | FileUtils.deleteDirectory(targetDir.toFile) 136 | } 137 | 138 | // Scan the Play repo to get the current Play version (assume this is what we need) 139 | val playVersion: String = PlayVersion.readPlayVersionFromFile() 140 | 141 | val buildCommands: Seq[Command] = Seq( 142 | Command( 143 | "sbt", 144 | Seq("-Dsbt.ivy.home=", "-Dplay.version="+playVersion, ";clean;stage"), 145 | workingDir = s"/$appName", 146 | env = Map( 147 | "JAVA_HOME" -> "", 148 | "LANG" -> "en_US.UTF-8" 149 | ) 150 | ) 151 | ) 152 | 153 | buildCommands.map(run(_, Pump, errorOnNonZeroExit = errorOnNonZeroExit)) 154 | } 155 | 156 | } -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/play/prune/RunTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2014 Typesafe Inc. 3 | */ 4 | package com.typesafe.play.prune 5 | 6 | import java.nio.file._ 7 | import java.util.UUID 8 | import java.util.concurrent.TimeUnit 9 | 10 | import Exec._ 11 | 12 | import scala.annotation.tailrec 13 | import scala.util.{Failure, Try} 14 | 15 | object RunTest { 16 | 17 | def runTestTask(testTask: TestTask)(implicit ctx: Context): Option[(UUID, TestRunRecord)] = { 18 | import testTask.playBranch 19 | import testTask.info.{ appName, testName, playCommit } 20 | 21 | val description = s"${testName} on app $appName for Play ${playCommit.substring(0, 7)} [$playBranch]" 22 | 23 | val testConfig = ctx.testConfig.get(testName).getOrElse(sys.error(s"Can't run test $description: No test config for $testName")) 24 | 25 | def runTest(): (UUID, TestRunRecord) = { 26 | val javaVersionExecution: Execution = JavaVersion.captureJavaVersion() 27 | val testRunId = UUID.randomUUID() 28 | println(s"Running test $description: $testRunId") 29 | val testExecutions = runTestDirectly(appName, testConfig.wrkArgs) 30 | 31 | val testRunRecord = TestRunRecord( 32 | appBuildId = PrunePersistentState.read.flatMap(_.lastAppBuilds.get(appName)).get, 33 | testName = testName, 34 | javaVersionExecution = javaVersionExecution, 35 | serverExecution = testExecutions.serverExecution, 36 | wrkExecutions = testExecutions.wrkExecutions 37 | ) 38 | 39 | TestRunRecord.write(testRunId, testRunRecord) 40 | (testRunId, testRunRecord) 41 | } 42 | 43 | BuildApp.buildApp( 44 | playBranch = testTask.playBranch, 45 | playCommit = testTask.info.playCommit, 46 | appsBranch = testTask.appsBranch, 47 | appsCommit = testTask.appsCommit, 48 | appName = appName 49 | ).fold[Option[(UUID, TestRunRecord)]] { 50 | println(s"No existing build record for app, skipping test run $description") 51 | None 52 | } { 53 | case (appBuildId, appBuildRecord) => 54 | if (!appBuildRecord.successfulBuild) { 55 | println(s"App build $appBuildId was unsuccessful: skipping test run $description") 56 | None 57 | } else { 58 | Some(runTest()) 59 | } 60 | } 61 | } 62 | 63 | private def withServer[A](appName: String, extraJavaOpts: Seq[String])(body: => A)(implicit ctx: Context): (Execution, A) = { 64 | val stageDirRelativePath = "target/universal/stage" 65 | val pidFile: Path = Paths.get(ctx.appsHome, appName, stageDirRelativePath, "RUNNING_PID") 66 | 67 | if (Files.exists(pidFile)) { 68 | sys.error(s"Can't run test app ${appName} because $pidFile already exists") 69 | } 70 | 71 | def canConnectTo(host: String, port: Int, timeout: Int): Boolean = { 72 | import java.io.IOException 73 | import java.net._ 74 | val addr = new InetSocketAddress(host, port) 75 | val socket = new Socket() 76 | try { 77 | socket.connect(addr, timeout) 78 | socket.close() 79 | true 80 | } catch { 81 | case _: IOException => false 82 | } 83 | } 84 | 85 | def pollFor(max: Long = 5000, interval: Long = 100)(condition: => Boolean): Boolean = { 86 | val deadlineTime = System.currentTimeMillis + max 87 | while (System.currentTimeMillis < deadlineTime) { 88 | if (condition) return true 89 | Thread.sleep(interval) 90 | } 91 | return false 92 | } 93 | 94 | if (canConnectTo("localhost", 9000, timeout = 50)) { 95 | sys.error("Can't start server: port already in use") 96 | } 97 | 98 | val runHandle: RunAsyncHandle = runAsync( 99 | Command( 100 | s"bin/${appName}", 101 | args = Seq(), 102 | workingDir = s"/${appName}/$stageDirRelativePath", 103 | env = Map( 104 | "JAVA_HOME" -> "", 105 | "JAVA_OPTS" -> (ctx.java8Opts ++ extraJavaOpts).mkString(" ") 106 | ) 107 | ), 108 | Capture, 109 | errorOnNonZeroExit = false 110 | ) 111 | 112 | try { 113 | 114 | // Repeatedly try to connect to localhost:9000 until we detect that the 115 | // server has started. Not very elegant, but it should work. 116 | 117 | val deadlineTime = System.currentTimeMillis + 5000 118 | 119 | @tailrec 120 | def waitForServerActivity(): Unit = { 121 | if (!runHandle.result.isCompleted && System.currentTimeMillis < deadlineTime) { 122 | val serverListening: Boolean = canConnectTo("localhost", 9000, timeout = 50) 123 | if (!serverListening) { 124 | Thread.sleep(50) 125 | waitForServerActivity() 126 | } 127 | } 128 | } 129 | 130 | waitForServerActivity() 131 | 132 | if (runHandle.result.isCompleted) { 133 | sys.error(s"Server didn't start, aborting test: ${runHandle.result.value.get.get}") 134 | } else { 135 | val bodyResult = body 136 | val serverExecution: Execution = runHandle.destroyProcess().get 137 | (serverExecution, bodyResult) 138 | } 139 | } finally { 140 | if (!runHandle.result.isCompleted) { 141 | runHandle.destroyProcess() 142 | } 143 | } 144 | } 145 | 146 | private def runWrk(durationSeconds: Long, wrkArgs: Seq[String])(implicit ctx: Context): Execution = { 147 | val wrkConfig = ctx.config.getConfig("wrk") 148 | val threads = wrkConfig.getInt("threads") 149 | val connections = wrkConfig.getInt("connections") 150 | println(s"Running wrk with "+wrkArgs.mkString(" ")+s" for ${durationSeconds}s") 151 | val execution = run( 152 | Command( 153 | program = "wrk", 154 | args = Seq(s"-t$threads", s"-c$connections", s"-d${durationSeconds}s") ++ wrkArgs, 155 | env = Map(), 156 | workingDir = "" 157 | ), 158 | Capture, 159 | timeout = Some((durationSeconds + 10) * 1000) 160 | ) 161 | 162 | val display = execution.stdout.fold("No wrk stdout") { stdout => 163 | (Results.parseWrkOutput(stdout).right.flatMap(_.summary.right.map(_.display)): Either[String,String]).merge 164 | } 165 | println(s"Wrk summary: $display") 166 | 167 | execution 168 | } 169 | 170 | private def getOverriddenWrkDuration(durationConfigName: String)(implicit ctx: Context): Long = { 171 | val configuredDuration = ctx.config.getDuration(durationConfigName, TimeUnit.SECONDS) 172 | ctx.args.maxWrkDuration.fold(configuredDuration) { i => 173 | if (i < configuredDuration) { 174 | println(s"Overriding wrk duration from $configuredDuration down to $i") 175 | i 176 | } else configuredDuration 177 | } 178 | } 179 | 180 | case class TestExecutions(serverExecution: Execution, wrkExecutions: Seq[Execution]) 181 | 182 | private def runServerAndWrk(appName: String, extraJavaOpts: Seq[String], wrkArgs: Seq[String], wrkDurations: Seq[Long])(implicit ctx: Context): TestExecutions = { 183 | val (serverExecution, wrkExecutions): (Execution, Seq[Execution]) = withServer[Seq[Execution]](appName, extraJavaOpts) { 184 | wrkDurations.map(runWrk(_, wrkArgs)) 185 | } 186 | val eitherStdout: Either[String, String] = wrkExecutions.last.stdout.toRight("No stdout from wrk") 187 | val wrkResult: Either[String, WrkResult] = eitherStdout.right.flatMap(Results.parseWrkOutput) 188 | val message: String = wrkResult.right.flatMap(_.summary.right.map(_.display)).merge 189 | println(message) 190 | TestExecutions(serverExecution, wrkExecutions) 191 | } 192 | 193 | def runTestDirectly(appName: String, wrkArgs: Seq[String])(implicit ctx: Context): TestExecutions = { 194 | val warmupTime: Long = getOverriddenWrkDuration("wrk.warmupTime") 195 | val testTime: Long = getOverriddenWrkDuration("wrk.testTime") 196 | runServerAndWrk(appName, Seq.empty, wrkArgs, Seq(warmupTime, testTime)) 197 | } 198 | 199 | def runProfileDirectly(appName: String, wrkArgs: Seq[String], sessionName: String)(implicit ctx: Context): TestExecutions = { 200 | val warmupTime: Long = getOverriddenWrkDuration("wrk.warmupTime") 201 | val unpaddedTestTime: Long = getOverriddenWrkDuration("yourkit.testTime") 202 | val paddingTime: Long = ctx.config.getDuration("yourkit.wrkDelayPaddingTime", TimeUnit.SECONDS) 203 | println(s"Padding wrk test duration by ${paddingTime}s to allow time for YourKit agent to start") 204 | 205 | 206 | val snapshotDir = Paths.get(ctx.yourkitHome, "snapshots") 207 | val logsDir = Paths.get(ctx.yourkitHome, "logs") 208 | 209 | val testTime: Long = unpaddedTestTime + paddingTime 210 | val delayTime: Long = warmupTime + paddingTime 211 | val extraJavaOpts = { 212 | val replacements: Map[String,String] = Map( 213 | "session.name" -> sessionName, 214 | "delay" -> delayTime.toString 215 | ) 216 | replacements.foldLeft(ctx.yourkitJavaOpts) { 217 | case (opts, (name, value)) => opts.map(_.replace("#"+name+"#", value)) 218 | } 219 | } 220 | 221 | if (Files.notExists(snapshotDir)) Files.createDirectories(snapshotDir) 222 | if (Files.notExists(logsDir)) Files.createDirectories(logsDir) 223 | 224 | runServerAndWrk(appName, extraJavaOpts, wrkArgs, Seq(warmupTime, testTime)) 225 | } 226 | 227 | } -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/play/prune/PruneGit.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2014 Typesafe Inc. 3 | */ 4 | package com.typesafe.play.prune 5 | 6 | import java.nio.file._ 7 | import java.util.{ List => JList } 8 | import org.apache.commons.io.FileUtils 9 | import org.eclipse.jgit.api.Git 10 | import org.eclipse.jgit.lib._ 11 | import org.eclipse.jgit.revwalk.RevCommit 12 | import org.eclipse.jgit.storage.file.FileRepositoryBuilder 13 | import org.eclipse.jgit.transport.{RemoteConfig, RefSpec} 14 | import org.joda.time.{ReadableInstant, DateTime} 15 | import scala.collection.convert.WrapAsJava._ 16 | import scala.collection.convert.WrapAsScala._ 17 | 18 | object PruneGit { 19 | 20 | def withRepository[T](localDir: String)(f: Repository => T): T = { 21 | val builder = new FileRepositoryBuilder() 22 | val repository = builder.setGitDir(Paths.get(localDir).resolve(".git").toFile) 23 | .readEnvironment() // Do we need this? 24 | .findGitDir() 25 | .build() 26 | val result = f(repository) 27 | repository.close() 28 | result 29 | } 30 | 31 | def resolveId(localDir: String, branch: String, rev: String): AnyObjectId = { 32 | withRepository(localDir) { repository => 33 | val fixedRev = if (rev == "HEAD") s"refs/heads/$branch" else rev 34 | Option(repository.resolve(fixedRev)).getOrElse(sys.error(s"Couldn't resolve revision $rev ($fixedRev) on branch $branch in repo $localDir")) 35 | } 36 | } 37 | 38 | private def refSpec(branch: String): RefSpec = { 39 | new RefSpec(s"refs/heads/$branch:refs/remotes/origin/$branch") 40 | } 41 | 42 | def gitCloneOrRebaseBranches( 43 | remote: String, 44 | localDir: String, 45 | branches: Seq[String], 46 | checkedOutBranch: Option[String]): Unit = { 47 | 48 | val branchesString = branches.mkString("[", ", ", "]") 49 | val branchToCheckout: String = checkedOutBranch.getOrElse(branches.head) 50 | val desc = s"$remote $branchesString into $localDir and checking out $branchToCheckout" 51 | 52 | val localDirPath = Paths.get(localDir) 53 | def clone(): Unit = { 54 | println(s"Cloning $desc...") 55 | Files.createDirectories(localDirPath.getParent) 56 | Git.cloneRepository 57 | .setURI(remote) 58 | .setBranchesToClone(seqAsJavaList(branches)) 59 | .setBranch(branchToCheckout) 60 | .setDirectory(localDirPath.toFile).call() 61 | println("Clone done.") 62 | } 63 | if (Files.notExists(localDirPath)) { 64 | clone() 65 | } else { 66 | val originMatches = withRepository(localDir)(remoteOriginMatches(_, remote)) 67 | if (!originMatches) { 68 | println("Remote changed") 69 | FileUtils.deleteDirectory(localDirPath.toFile) 70 | clone() 71 | } else { 72 | println(s"Pulling $desc...") 73 | withRepository(localDir) { repository => 74 | val git: Git = new Git(repository) 75 | 76 | val existingBranches = asScalaBuffer(git.branchList.call()).map(_.getName.split('/').last) 77 | val missingBranches = branches diff existingBranches 78 | for (b <- missingBranches) { 79 | git.branchCreate.setName(b).setStartPoint(s"refs/remotes/origin/$b").call() 80 | } 81 | 82 | val refSpecs = branches.map(refSpec) 83 | git.fetch().setRemote("origin").setRefSpecs(refSpecs: _*).call() 84 | 85 | val branchOperationOrder = branches.filter(_ != branchToCheckout) :+ branchToCheckout // Reorder so `branch` is checked out last 86 | for (b <- branchOperationOrder) { 87 | git.checkout().setName(b).call() 88 | val result = git.rebase().setUpstream(s"refs/remotes/origin/$b").call() 89 | println(s"Branch $b: ${result.getStatus}") 90 | } 91 | } 92 | } 93 | println("Pull done") 94 | } 95 | 96 | } 97 | 98 | private def remoteOriginMatches(repository: Repository, remote: String): Boolean = { 99 | val config = repository.getConfig 100 | val remoteConfig = new RemoteConfig(config, "origin") 101 | val existingURIs = remoteConfig.getURIs 102 | assert(existingURIs.size == 1) 103 | val existingRemote = existingURIs.get(0).toString 104 | return existingRemote == remote 105 | } 106 | 107 | def gitCheckout(localDir: String, branch: String, commit: String): Unit = { 108 | withRepository(localDir) { repository => 109 | val localGit: Git = new Git(repository) 110 | println("Cleaning directory before checking out") 111 | val cleanedFiles = localGit.clean().setCleanDirectories(true).call() 112 | println(s"Cleaned ${cleanedFiles.size} files") 113 | println("Reverting any local modifications") 114 | val localModifications = localGit.status().call().getModified 115 | if (!localModifications.isEmpty) { 116 | val revertCheckout = localGit.checkout 117 | for (f <- iterableAsScalaIterable(localModifications)) { 118 | println(s"Reverting $f") 119 | revertCheckout.addPath(f) 120 | } 121 | revertCheckout.call() 122 | } 123 | println(s"Checking out $commit") 124 | val result = localGit.checkout.setName(commit).call() 125 | } 126 | } 127 | 128 | def gitPushChanges( 129 | remote: String, 130 | localDir: String, 131 | branch: String, 132 | commitMessage: String): Unit = { 133 | 134 | println(s"Pushing changes in $localDir to $remote branch $branch") 135 | 136 | withRepository(localDir) { repository => 137 | val localGit: Git = new Git(repository) 138 | localGit.add.addFilepattern(".").call() 139 | //val result = localGit.push(). 140 | val status = localGit.status.call() 141 | if (!status.getChanged.isEmpty) { 142 | localGit.commit.setAll(true).setMessage(commitMessage).call() 143 | } 144 | println(s"Pushing records to $remote [$branch]") 145 | val pushes = localGit.push.setRemote("origin").setRefSpecs(new RefSpec(s"$branch:$branch")).call() 146 | for { 147 | push <- iterableAsScalaIterable(pushes) 148 | remoteUpdate <- iterableAsScalaIterable(push.getRemoteUpdates) 149 | } { 150 | println(s"Pushed ${remoteUpdate.getSrcRef}: ${remoteUpdate.getStatus} ${remoteUpdate.getNewObjectId.name}") 151 | } 152 | } 153 | 154 | } 155 | 156 | case class LogEntry(id: String, time: DateTime) 157 | 158 | def gitFirstParentsLog(localDir: String, branch: String, startRev: String, endRev: String): Seq[LogEntry] = { 159 | withRepository(localDir) { repository => 160 | val startId: AnyObjectId = resolveId(localDir, branch, startRev) 161 | val endId: AnyObjectId = resolveId(localDir, branch, endRev) 162 | // println(s"Logging from $startId to $endId") 163 | 164 | val logWalk = new Git(repository).log().addRange(startId, endId).call() 165 | val iterator = logWalk.iterator() 166 | 167 | @scala.annotation.tailrec 168 | def walkBackwards(results: Seq[LogEntry], next: AnyObjectId): Seq[LogEntry] = { 169 | if (iterator.hasNext) { 170 | val commit = iterator.next() 171 | val current = commit.getId 172 | if (current == next) { 173 | // Stop walking because we've gone back before the end time 174 | val entry: LogEntry = LogEntry(next.name, new DateTime(commit.getCommitTime.toLong * 1000)) 175 | walkBackwards(results :+ entry, commit.getParent(0).getId) 176 | } else { 177 | // Skip this commit because its not the next commit that we're scanning for 178 | walkBackwards(results, next) 179 | } 180 | } else results 181 | } 182 | walkBackwards(Seq.empty, endId) 183 | } 184 | } 185 | def gitFirstParentsLogToDate(localDir: String, branch: String, lastRev: String, endTime: ReadableInstant): Seq[LogEntry] = { 186 | withRepository(localDir) { repository => 187 | val lastId: AnyObjectId = resolveId(localDir, branch, lastRev) 188 | val endTimeSeconds: Int = (endTime.getMillis / 1000).toInt 189 | // println(s"Logging from $startId to $endId") 190 | 191 | val logWalk = new Git(repository).log().add(lastId).call() 192 | val iterator = logWalk.iterator() 193 | 194 | @scala.annotation.tailrec 195 | def walkBackwards(results: Seq[LogEntry], next: AnyObjectId): Seq[LogEntry] = { 196 | if (iterator.hasNext) { 197 | val commit = iterator.next() 198 | val current = commit.getId 199 | if (current == next) { 200 | val commitTimeSeconds = commit.getCommitTime() 201 | val entry: LogEntry = LogEntry(next.name, new DateTime(commitTimeSeconds.toLong * 1000)) 202 | val newResults: Seq[LogEntry] = results :+ entry 203 | if (commitTimeSeconds < endTimeSeconds) { 204 | // Stop walking because we've gone past the end time that we're interested in 205 | newResults 206 | } else { 207 | // Include this commit in our result and scan for its parent 208 | walkBackwards(newResults, commit.getParent(0).getId) 209 | } 210 | } else { 211 | // Skip this commit because its not the next commit that we're scanning for 212 | walkBackwards(results, next) 213 | } 214 | } else results 215 | } 216 | walkBackwards(Seq.empty, lastId) 217 | } 218 | } 219 | 220 | } -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/play/prune/Exec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2014 Typesafe Inc. 3 | */ 4 | package com.typesafe.play.prune 5 | 6 | import java.io._ 7 | import java.nio.file._ 8 | import java.util.{Map => JMap} 9 | import java.util.concurrent.TimeUnit 10 | 11 | import org.apache.commons.io.{FileUtils, IOUtils} 12 | import org.apache.commons.exec._ 13 | import org.joda.time._ 14 | 15 | import scala.collection.JavaConversions 16 | import scala.concurrent._ 17 | import scala.concurrent.duration.{Deadline, Duration} 18 | import scala.concurrent.ExecutionContext.Implicits.global 19 | import scala.util.{Failure, Try} 20 | 21 | object Exec { 22 | 23 | private case class Prepared( 24 | executor: DefaultExecutor, 25 | commandLine: CommandLine, 26 | environment: JMap[String, String], 27 | watchdog: ExecuteWatchdog, 28 | streamResultGetter: () => Option[(String, String)] 29 | ) 30 | 31 | def replaceContextValues(s: String)(implicit ctx: Context): String = { 32 | val replacements = Map( 33 | "" -> ctx.assetsHome, 34 | "" -> ctx.pruneHome, 35 | "" -> ctx.playHome, 36 | "" -> ctx.appsHome, 37 | "" -> ctx.java8Home, 38 | "" -> ctx.ivyHome, 39 | "" -> "http://localhost:9000", 40 | "" -> ctx.yourkitAgent, 41 | "" -> ctx.yourkitHome 42 | ) 43 | replacements.foldLeft(s) { 44 | case (s, (name, value)) => s.replace(name, value) 45 | } 46 | } 47 | 48 | private def prepare( 49 | command: Command, 50 | streamHandling: StreamHandling, 51 | timeout: Option[Long] = None)(implicit ctx: Context): Prepared = { 52 | 53 | val configuredCommand = command.mapStrings(replaceContextValues(_)) 54 | //println(s"Configured command: $configuredCommand") 55 | val commandLine = new CommandLine(configuredCommand.program) 56 | for (arg <- configuredCommand.args) { commandLine.addArgument(arg) } 57 | 58 | //Files.createDirectories(Paths.get(configuredCommand.workingDir)) 59 | 60 | val executor = new DefaultExecutor() 61 | executor.setWorkingDirectory(new File(configuredCommand.workingDir)) 62 | //println(s"Set working directory: ${configuredCommand.workingDir}") 63 | val watchdog = new ExecuteWatchdog(timeout.getOrElse(ExecuteWatchdog.INFINITE_TIMEOUT)) 64 | executor.setWatchdog(watchdog) 65 | //println(s"Attaching stream handling: $streamHandling") 66 | val streamResultGetter = streamHandling.attach(executor) 67 | 68 | val prepared = Prepared( 69 | executor, 70 | commandLine, 71 | JavaConversions.mapAsJavaMap(configuredCommand.env), 72 | watchdog, 73 | streamResultGetter 74 | ) 75 | //println(s"Prepared run: $prepared") 76 | prepared 77 | } 78 | 79 | trait StreamHandling { 80 | def attach(executor: Executor): () => Option[(String, String)] 81 | } 82 | object Pump extends StreamHandling { 83 | def attach(executor: Executor) = { 84 | executor.setStreamHandler(new PumpStreamHandler) 85 | () => None 86 | } 87 | } 88 | object Capture extends StreamHandling { 89 | def attach(executor: Executor) = { 90 | val sh = new CapturingStreamHandler 91 | executor.setStreamHandler(sh) 92 | () => Some((sh.stdout, sh.stderr)) 93 | } 94 | } 95 | 96 | def run( 97 | command: Command, 98 | streamHandling: StreamHandling, 99 | errorOnNonZeroExit: Boolean = true, 100 | timeout: Option[Long] = None)(implicit ctx: Context): Execution = { 101 | val prepared = prepare(command, streamHandling, timeout) 102 | import prepared._ 103 | val startTime = DateTime.now 104 | val returnCode = try { 105 | executor.execute(commandLine, environment) 106 | } catch { 107 | case ee: ExecuteException => ee.getExitValue 108 | } 109 | val endTime = DateTime.now 110 | val streamResult = streamResultGetter() 111 | 112 | val execution = Execution( 113 | command = command, 114 | stdout = streamResult.map(_._1), 115 | stderr = streamResult.map(_._2), 116 | returnCode = Some(returnCode), 117 | startTime = startTime, 118 | endTime = endTime 119 | ) 120 | if (errorOnNonZeroExit && execution.returnCode.fold(false)(_ != 0)) { 121 | sys.error(s"Execution failed: $execution") 122 | } else execution 123 | } 124 | 125 | trait RunAsyncHandle { 126 | def destroyProcess(): Option[Execution] 127 | def result: Future[Execution] 128 | } 129 | 130 | def runAsync( 131 | command: Command, 132 | streamHandling: StreamHandling, 133 | errorOnNonZeroExit: Boolean = true, 134 | timeout: Option[Long] = None)(implicit ctx: Context): RunAsyncHandle = { 135 | val prepared: Prepared = prepare(command, streamHandling, timeout) 136 | 137 | val handle = new RunAsyncHandle with ExecuteResultHandler { 138 | private val startTime: DateTime = DateTime.now 139 | private val resultPromise: Promise[Execution] = Promise[Execution] 140 | 141 | override def result: Future[Execution] = resultPromise.future 142 | 143 | override def onProcessComplete(exitValue: Int): Unit = synchronized { 144 | val endTime: DateTime = DateTime.now 145 | 146 | if (errorOnNonZeroExit && exitValue != 0) { 147 | resultPromise.failure(new IOException(s"Execution failed: $command")) 148 | } else { 149 | 150 | // Get the contents of stdout/stderr 151 | val streamResult: Option[(String, String)] = prepared.streamResultGetter() 152 | 153 | val execution = Execution( 154 | command = command, 155 | stdout = streamResult.map(_._1), 156 | stderr = streamResult.map(_._2), 157 | returnCode = Some(exitValue), 158 | startTime = startTime, 159 | endTime = endTime 160 | ) 161 | resultPromise.success(execution) 162 | } 163 | } 164 | 165 | override def onProcessFailed(e: ExecuteException): Unit = synchronized { 166 | resultPromise.failure(e) 167 | } 168 | 169 | override def destroyProcess(): Option[Execution] = synchronized { 170 | if (!result.isCompleted) { 171 | prepared.watchdog.destroyProcess() 172 | val deadline = Deadline.now + Duration(ctx.testShutdownSeconds, TimeUnit.SECONDS) 173 | while (!result.isCompleted && deadline.hasTimeLeft()) { Thread.sleep(50) } 174 | } 175 | result.value.map(_.get) 176 | } 177 | 178 | } 179 | 180 | try { 181 | prepared.executor.execute( 182 | prepared.commandLine, 183 | prepared.environment, 184 | handle) 185 | } catch { 186 | case e: ExecutionException => 187 | } 188 | 189 | handle 190 | } 191 | 192 | private[Exec] class CapturingStreamHandler extends ExecuteStreamHandler { 193 | private var stdoutConsumer: Option[InputStream] = None 194 | private var stderrConsumer: Option[InputStream] = None 195 | private var stdinProducer: Option[OutputStream] = None 196 | private val stdoutOutput = Promise[String]() 197 | private val stderrOutput = Promise[String]() 198 | def stdout = Await.result(stdoutOutput.future, Duration.Inf) 199 | def stderr = Await.result(stderrOutput.future, Duration.Inf) 200 | override def setProcessOutputStream(is: InputStream) = stdoutConsumer = Some(is) 201 | override def setProcessErrorStream(is: InputStream) = stderrConsumer = Some(is) 202 | override def setProcessInputStream(os: OutputStream) = stdinProducer = Some(os) 203 | 204 | private def safeButInefficientToString(is: InputStream): String = { 205 | 206 | val maxSize = 1024 * 1024 // Set max size of output to 1mb 207 | val baos = new ByteArrayOutputStream() 208 | 209 | /** 210 | * Recursive function to copy stream up to a limit. We need to apply a limit 211 | * to avoid 212 | */ 213 | @scala.annotation.tailrec 214 | def copyAll(size: Int, truncate: Boolean): Unit = { 215 | 216 | /** Write to the buffer and log a message if an OOME is caught. */ 217 | def logOutOfMemoryError[A](f: => A): A = { 218 | try f catch { 219 | case oome: OutOfMemoryError => 220 | // Print out some diagnostic information 221 | print("OOME hit at size ") 222 | println(size) 223 | throw oome 224 | } 225 | } 226 | 227 | val c = try is.read() catch { 228 | // Work around JVM concurrency bug: http://bugs.java.com/view_bug.do?bug_id=5101298 229 | case ioe: IOException if ioe.getMessage.toLowerCase.contains("stream closed") => -1 230 | } 231 | 232 | if (c == -1) { 233 | // Do nothing and terminate the loop 234 | } else if (size < maxSize) { 235 | logOutOfMemoryError { 236 | baos.write(c) 237 | } 238 | copyAll(size + 1, truncate = false) 239 | } else if (!truncate) { 240 | // We've captured more than maxSize, ignore the rest 241 | println(s"Process output exceeds $maxSize bytes, truncating.") 242 | val truncateMessage = s"\n--- Truncated output to $maxSize bytes ---" 243 | logOutOfMemoryError { 244 | baos.write(truncateMessage.getBytes("ASCII")) 245 | } 246 | copyAll(size, truncate = true) 247 | } else if (truncate) { 248 | // We've already started truncating, just keep doing it 249 | 250 | copyAll(size, truncate = true) 251 | } 252 | } 253 | copyAll(0, truncate = false) 254 | 255 | baos.toString("ASCII") 256 | } 257 | 258 | override def start() = { 259 | stdoutConsumer.foreach { is => 260 | stdoutOutput.completeWith(Future { 261 | safeButInefficientToString(is) 262 | }) 263 | } 264 | stderrConsumer.foreach { is => 265 | stderrOutput.completeWith(Future { 266 | safeButInefficientToString(is) 267 | }) 268 | } 269 | stdinProducer.foreach { os => 270 | Future { os.close() } 271 | } 272 | } 273 | override def stop() = {} 274 | } 275 | 276 | } -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/play/prune/Context.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2014 Typesafe Inc. 3 | */ 4 | package com.typesafe.play.prune 5 | 6 | import java.util.concurrent.TimeUnit 7 | 8 | import com.typesafe.config.Config 9 | import java.util.UUID 10 | import scala.collection.convert.WrapAsScala._ 11 | 12 | case class Context( 13 | args: Args, 14 | config: Config 15 | ) { 16 | val pruneInstanceId = UUID.fromString(config.getString("pruneInstanceId")) 17 | val pruneHome = config.getString("home") 18 | 19 | val java8Home = config.getString("java8.home") 20 | val java8Opts: Seq[String] = asScalaBuffer(config.getStringList("java8.opts")) 21 | 22 | val ivyHome = config.getString("ivy.home") 23 | 24 | val playRemote = config.getString("playRemote") 25 | val playHome = args.playHome.getOrElse(config.getString("playHome")) 26 | 27 | val appsRemote = config.getString("appsRemote") 28 | val appsHome = args.appsHome.getOrElse(config.getString("appsHome")) 29 | 30 | val dbRemote = config.getString("dbRemote") 31 | val dbBranch = config.getString("dbBranch") 32 | val dbHome = config.getString("dbHome") 33 | 34 | val siteRemote = config.getString("siteRemote") 35 | val siteBranch = config.getString("siteBranch") 36 | val siteHome = config.getString("siteHome") 37 | 38 | val assetsHome = config.getString("assetsHome") 39 | 40 | val playTests: Seq[PlayTestsConfig] = { 41 | asScalaBuffer(config.getConfigList("playTests")).map { c: Config => 42 | val sampling = c.getDouble("playRevisionSampling") 43 | assert(sampling >= 0 && sampling <= 1.0) 44 | PlayTestsConfig( 45 | playBranch = c.getString("playBranch"), 46 | playRevisionRange = { 47 | val split = c.getString("playRevisionRange").split("\\.\\.") 48 | if (split.length != 2) { 49 | sys.error(s"Play revision range must contain a single '..': $split") 50 | } 51 | (split(0), split(1)) 52 | }, 53 | playRevisionSampling = sampling, 54 | appsBranch = c.getString("appsBranch"), 55 | appsRevision = c.getString("appsRevision"), 56 | testNames = asScalaBuffer(c.getStringList("testNames")) 57 | ) 58 | } 59 | } 60 | 61 | def playBranches = playTests.map(_.playBranch).distinct 62 | def appsBranches = playTests.map(_.appsBranch).distinct 63 | 64 | val testConfig: Map[String, TestConfig] = { 65 | asScalaBuffer(config.getConfigList("tests")).foldLeft[Map[String, TestConfig]](Map.empty) { 66 | case (m, entry) => 67 | val name: String = entry.getString("name") 68 | assert(!m.contains(name)) 69 | m.updated(name, TestConfig( 70 | app = entry.getString("app"), 71 | description = entry.getString("description"), 72 | wrkArgs = asScalaBuffer(entry.getStringList("wrkArgs")) 73 | )) 74 | } 75 | } 76 | 77 | val testShutdownSeconds: Int = args.testShutdownSeconds.getOrElse(config.getDuration("testShutdown", TimeUnit.SECONDS).toInt) 78 | 79 | val yourkitHome: String = config.getString("yourkit.home") 80 | val yourkitAgent: String = config.getString("yourkit.agent") 81 | val yourkitJavaOpts: Seq[String] = asScalaBuffer(config.getStringList("yourkit.javaOpts")) 82 | 83 | } 84 | 85 | sealed trait CommandArg 86 | case object Pull extends CommandArg 87 | case object Test extends CommandArg 88 | case object PushTestResults extends CommandArg 89 | case object PrintReport extends CommandArg 90 | case object GenerateJsonReport extends CommandArg 91 | case object PullSite extends CommandArg 92 | case object GenerateSiteFiles extends CommandArg 93 | case object PushSite extends CommandArg 94 | case object Wrk extends CommandArg 95 | case object Profile extends CommandArg 96 | 97 | case class Args( 98 | command: Option[CommandArg] = None, 99 | configFile: Option[String] = None, 100 | dbFetch: Boolean = true, 101 | playFetch: Boolean = true, 102 | appsFetch: Boolean = true, 103 | maxTestRuns: Option[Int] = None, 104 | maxBuildFailures: Int = 2, 105 | maxWrkDuration: Option[Int] = None, 106 | playBranches: Seq[String] = Seq.empty, 107 | playRevs: Seq[String] = Seq.empty, 108 | lexicalOrder: Boolean = false, 109 | testNames: Seq[String] = Seq.empty, 110 | repeatTests: Boolean = false, 111 | testShutdownSeconds: Option[Int] = None, 112 | maxTotalMinutes: Option[Int] = None, 113 | outputFile: Option[String] = None, 114 | playHome: Option[String] = None, 115 | appsHome: Option[String] = None, 116 | testOrAppName: Option[String] = None, 117 | wrkArgs: Seq[String] = Seq.empty, 118 | playBuild: Boolean = true, 119 | appBuild: Boolean = true, 120 | verbose: Boolean = false) 121 | object Args { 122 | def parse(rawArgs: Seq[String]) = { 123 | val parser = new scopt.OptionParser[Args]("prune") { 124 | head("prune") 125 | opt[String]("config-file") action { (s, c) => 126 | c.copy(configFile = Some(s)) 127 | } 128 | opt[Unit]("verbose") action { (_, c) => 129 | c.copy(verbose = true) 130 | } 131 | cmd("pull") action { (_, c) => 132 | c.copy(command = Some(Pull)) 133 | } text("Pull from remote repositories") children( 134 | opt[Unit]("skip-db-fetch") action { (_, c) => 135 | c.copy(dbFetch = false) 136 | }, 137 | opt[Unit]("skip-play-fetch") action { (_, c) => 138 | c.copy(playFetch = false) 139 | }, 140 | opt[Unit]("skip-apps-fetch") action { (_, c) => 141 | c.copy(appsFetch = false) 142 | } 143 | ) 144 | cmd("test") action { (_, c) => 145 | c.copy(command = Some(Test)) 146 | } text("Run tests") children( 147 | opt[Int]("max-test-runs") action { (i, c) => 148 | c.copy(maxTestRuns = Some(i)) 149 | }, 150 | opt[Int]("max-wrk-duration") action { (i, c) => 151 | c.copy(maxWrkDuration = Some(i)) 152 | }, 153 | opt[Int]("max-total-minutes") action { (i, c) => 154 | c.copy(maxTotalMinutes = Some(i)) 155 | }, 156 | opt[Int]("max-build-failures") action { (i, c) => 157 | c.copy(maxBuildFailures = i) 158 | }, 159 | opt[String]("play-branch") optional() unbounded() action { (s, c) => 160 | c.copy(playBranches = c.playBranches :+ s) 161 | }, 162 | opt[String]("play-rev") optional() unbounded() action { (s, c) => 163 | c.copy(playRevs = c.playRevs :+ s) 164 | }, 165 | opt[Unit]("lexical-order") action { (_, c) => 166 | c.copy(lexicalOrder = true) 167 | } text("Run tests in commit lexical order, rather than date order"), 168 | opt[String]("test-name") optional() unbounded() action { (s, c) => 169 | c.copy(testNames = c.testNames :+ s) 170 | }, 171 | opt[Unit]("repeat-tests") action { (_, c) => 172 | c.copy(repeatTests = true) 173 | }, 174 | opt[Int]("test-shutdown-seconds") action { (i, c) => 175 | c.copy(testShutdownSeconds = Some(i)) 176 | } text("How long to wait for test processes to shutdown after they've been asked to terminate") 177 | ) 178 | cmd("push-test-results") action { (_, c) => 179 | c.copy(command = Some(PushTestResults)) 180 | } text("Push test results to remote database repository") 181 | cmd("print-report") action { (_, c) => 182 | c.copy(command = Some(PrintReport)) 183 | } text("Output a simple report of test results") children( 184 | opt[String]("play-branch") optional() unbounded() action { (s, c) => 185 | c.copy(playBranches = c.playBranches :+ s) 186 | }, 187 | opt[String]("test-name") optional() unbounded() action { (s, c) => 188 | c.copy(testNames = c.testNames :+ s) 189 | } 190 | ) 191 | cmd("generate-json-report") action { (_, c) => 192 | c.copy(command = Some(GenerateJsonReport)) 193 | } text("Generate a report of test results to a JSON file") children( 194 | arg[String]("") action { (s, c) => 195 | c.copy(outputFile = Some(s)) 196 | } 197 | ) 198 | cmd("pull-site") action { (_, c) => 199 | c.copy(command = Some(PullSite)) 200 | } text("Pull site from remote repository") 201 | cmd("generate-site-files") action { (_, c) => 202 | c.copy(command = Some(GenerateSiteFiles)) 203 | } text("Generate site files based on test results") 204 | cmd("push-site") action { (_, c) => 205 | c.copy(command = Some(PushSite)) 206 | } text("Push site to remote repository") 207 | cmd("wrk") action { (_, c) => 208 | c.copy(command = Some(Wrk)) 209 | } text("Run wrk on your local Play code") children( 210 | arg[String]("") action { (s, c) => c.copy(playHome = Some(s)) } text("Play directory"), 211 | arg[String]("") action { (s, c) => c.copy(appsHome = Some(s)) } text("App directory"), 212 | arg[String]("") action { (s, c) => c.copy(testOrAppName = Some(s)) } text("Test name or app name"), 213 | arg[String]("[...]") optional() unbounded() action { (s, c) => c.copy(wrkArgs = c.wrkArgs :+ s) } text("Wrk arguments"), 214 | opt[Int]("max-wrk-duration") action { (i, c) => 215 | c.copy(maxWrkDuration = Some(i)) 216 | }, 217 | opt[Unit]("skip-play-build") action { (_, c) => 218 | c.copy(playBuild = false) 219 | }, 220 | opt[Unit]("skip-app-build") action { (_, c) => 221 | c.copy(appBuild = false) 222 | } 223 | ) 224 | cmd("profile") action { (_, c) => 225 | c.copy(command = Some(Profile)) 226 | } text("Run profiling agent on your local Play code") children( 227 | arg[String]("") action { (s, c) => c.copy(playHome = Some(s)) } text("Play directory"), 228 | arg[String]("") action { (s, c) => c.copy(appsHome = Some(s)) } text("App directory"), 229 | arg[String]("") action { (s, c) => c.copy(testOrAppName = Some(s)) } text("Test name or app name"), 230 | arg[String]("[...]") optional() unbounded() action { (s, c) => c.copy(wrkArgs = c.wrkArgs :+ s) } text("Wrk arguments"), 231 | opt[Int]("max-wrk-duration") action { (i, c) => 232 | c.copy(maxWrkDuration = Some(i)) 233 | }, 234 | opt[Unit]("skip-play-build") action { (_, c) => 235 | c.copy(playBuild = false) 236 | }, 237 | opt[Unit]("skip-app-build") action { (_, c) => 238 | c.copy(appBuild = false) 239 | } 240 | ) 241 | } 242 | parser.parse(rawArgs, Args()).getOrElse(sys.error("Arg parse error")) 243 | } 244 | } 245 | 246 | case class PlayTestsConfig( 247 | playBranch: String, 248 | playRevisionRange: (String, String), 249 | playRevisionSampling: Double, 250 | appsBranch: String, 251 | appsRevision: String, 252 | testNames: Seq[String] 253 | ) 254 | 255 | case class TestConfig( 256 | app: String, 257 | description: String, 258 | wrkArgs: Seq[String] 259 | ) -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/play/prune/Records.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2014 Typesafe Inc. 3 | */ 4 | package com.typesafe.play.prune 5 | 6 | import java.nio.file._ 7 | import java.util.UUID 8 | import org.apache.commons.io.FileUtils 9 | import org.joda.time._ 10 | import play.api.libs.functional.syntax._ 11 | import play.api.libs.json._ 12 | import scala.collection.convert.WrapAsScala._ 13 | 14 | object Records { 15 | 16 | def readFile[T](p: Path)(implicit reads: Reads[T]): Option[T] = { 17 | if (Files.exists(p)) { 18 | val bytes = Files.readAllBytes(p) 19 | val json = Json.parse(bytes) 20 | val t = reads.reads(json).get 21 | //println("Read: "+new String(bytes)+"->"+t) 22 | Some(t) 23 | } else None 24 | } 25 | 26 | def writeFile[T](p: Path, t: T)(implicit writes: Writes[T]): Unit = { 27 | val json = writes.writes(t) 28 | val bytes = Json.prettyPrint(json).getBytes("UTF-8") 29 | //println("Write: "+t+"->"+new String(bytes)) 30 | Files.createDirectories(p.getParent) 31 | Files.write(p, bytes) 32 | } 33 | 34 | def readAll[T](dir: Path)(implicit ctx: Context, reads: Reads[T]): Map[UUID,T] = { 35 | iteratorAll(dir).toMap 36 | } 37 | 38 | def iteratorAll[T](dir: Path)(implicit ctx: Context, reads: Reads[T]): Iterator[(UUID, T)] = { 39 | val files = if (Files.exists(dir)) collectionAsScalaIterable(FileUtils.listFiles(dir.toFile, Array("json"), true)) else Seq.empty 40 | val filesIterator = files.iterator 41 | filesIterator.map { file => 42 | val p = file.toPath 43 | val id: UUID = { 44 | val fileName = p.getFileName.toString 45 | val dotIndex = fileName.lastIndexOf('.') 46 | assert(dotIndex != -1) 47 | val baseFileName = fileName.substring(0, dotIndex) 48 | UUID.fromString(baseFileName) 49 | } 50 | val record: T = Records.readFile[T](p).getOrElse(sys.error(s"Expected record at $p")) 51 | (id, record) 52 | } 53 | } 54 | 55 | } 56 | 57 | object Implicits { 58 | 59 | val DateFormat = "yyyy-MM-dd'T'HH:mm:ssz" 60 | 61 | implicit def dateReads = Reads.jodaDateReads(DateFormat) 62 | 63 | implicit def dateWrites = Writes.jodaDateWrites(DateFormat) 64 | 65 | implicit def uuidWrites = new Writes[UUID] { 66 | def writes(uuid: UUID) = JsString(uuid.toString) 67 | } 68 | implicit def uuidReads = new Reads[UUID] { 69 | def reads(json: JsValue): JsResult[UUID] = json match { 70 | case JsString(s) => try { 71 | JsSuccess(UUID.fromString(s)) 72 | } catch { 73 | case _: IllegalArgumentException => JsError("Invalid UUID string") 74 | } 75 | case _ => JsError("UUIDs must be encoded as JSON strings") 76 | } 77 | } 78 | } 79 | 80 | case class PrunePersistentState( 81 | lastPlayBuild: Option[UUID], 82 | lastAppBuilds: Map[String,UUID] 83 | ) 84 | 85 | object PrunePersistentState { 86 | 87 | implicit val writes = new Writes[PrunePersistentState] { 88 | def writes(prunePersistentState: PrunePersistentState) = Json.obj( 89 | "lastPlayBuild" -> prunePersistentState.lastPlayBuild, 90 | "lastAppBuilds" -> prunePersistentState.lastAppBuilds 91 | ) 92 | } 93 | 94 | implicit val reads: Reads[PrunePersistentState] = ( 95 | (JsPath \ "lastPlayBuild").readNullable[UUID] and 96 | (JsPath \ "lastAppBuilds").read[Map[String,UUID]] 97 | )(PrunePersistentState.apply _) 98 | 99 | def path(implicit ctx: Context): Path = { 100 | Paths.get(ctx.pruneHome).resolve("state.json") 101 | } 102 | 103 | def read(implicit ctx: Context): Option[PrunePersistentState] = { 104 | Records.readFile[PrunePersistentState](path) 105 | } 106 | 107 | def readOrElse(implicit ctx: Context): PrunePersistentState = { 108 | read.getOrElse(PrunePersistentState( 109 | lastPlayBuild = None, lastAppBuilds = Map.empty 110 | )) 111 | } 112 | 113 | def write(state: PrunePersistentState)(implicit ctx: Context): Unit = { 114 | Records.writeFile(path, state) 115 | } 116 | 117 | } 118 | 119 | object Command { 120 | 121 | implicit val writes = new Writes[Command] { 122 | def writes(command: Command) = Json.obj( 123 | "program" -> command.program, 124 | "args" -> command.args, 125 | "workingDir" -> command.workingDir, 126 | "env" -> command.env 127 | ) 128 | } 129 | 130 | implicit val reads: Reads[Command] = ( 131 | (JsPath \ "program").read[String] and 132 | (JsPath \ "args").read[Seq[String]] and 133 | (JsPath \ "workingDir").read[String] and 134 | (JsPath \ "env").read[Map[String, String]] 135 | )(Command.apply _) 136 | 137 | } 138 | 139 | case class Execution( 140 | command: Command, 141 | startTime: DateTime, 142 | endTime: DateTime, 143 | stdout: Option[String], 144 | stderr: Option[String], 145 | returnCode: Option[Int] 146 | ) 147 | 148 | 149 | object Execution { 150 | 151 | implicit val writes = new Writes[Execution] { 152 | def writes(execution: Execution) = Json.obj( 153 | "command" -> execution.command, 154 | "startTime" -> execution.startTime, 155 | "endTime" -> execution.endTime, 156 | "stdout" -> execution.stdout, 157 | "stderr" -> execution.stderr, 158 | "returnCode" -> execution.returnCode 159 | ) 160 | } 161 | 162 | implicit val reads: Reads[Execution] = ( 163 | (JsPath \ "command").read[Command] and 164 | (JsPath \ "startTime").read[DateTime] and 165 | (JsPath \ "endTime").read[DateTime] and 166 | (JsPath \ "stdout").readNullable[String] and 167 | (JsPath \ "stderr").readNullable[String] and 168 | (JsPath \ "returnCode").readNullable[Int] 169 | )(Execution.apply _) 170 | 171 | } 172 | 173 | // { 174 | // "playCommit": "a1b2...", 175 | // "javaVersion": "java version \"1.8.0_05\"\nJava(TM) SE Runtime Environment (build 1.8.0_05-b13)Java HotSpot(TM) 64-Bit Server VM (build 25.5-b02, mixed mode)", 176 | // "buildCommands": [ 177 | // ["./build", "-Dsbt.ivy.home=~/.prune/ivy", "clean"], 178 | // ["./build", "-Dsbt.ivy.home=~/.prune/ivy", "publish-local"], 179 | // ["./build", "-Dsbt.ivy.home=~/.prune/ivy", "-Dscala.version=2.11.2", "publish-local"] 180 | // ] 181 | // } 182 | 183 | case class PlayBuildRecord( 184 | pruneInstanceId: UUID, 185 | playCommit: String, 186 | javaVersionExecution: Execution, 187 | buildExecutions: Seq[Execution] 188 | ) { 189 | def successfulBuild: Boolean = buildExecutions.foldLeft(true) { 190 | case (b, execution) => b && execution.returnCode.fold(false)(_ == 0) 191 | } 192 | } 193 | 194 | object PlayBuildRecord { 195 | 196 | implicit val writes = new Writes[PlayBuildRecord] { 197 | def writes(playBuildRecord: PlayBuildRecord) = Json.obj( 198 | "pruneInstanceId" -> playBuildRecord.pruneInstanceId, 199 | "playCommit" -> playBuildRecord.playCommit, 200 | "javaVersionExecution" -> playBuildRecord.javaVersionExecution, 201 | "buildExecutions" -> playBuildRecord.buildExecutions 202 | ) 203 | } 204 | 205 | implicit val reads: Reads[PlayBuildRecord] = ( 206 | (JsPath \ "pruneInstanceId").read[UUID] and 207 | (JsPath \ "playCommit").read[String] and 208 | (JsPath \ "javaVersionExecution").read[Execution] and 209 | (JsPath \ "buildExecutions").read[Seq[Execution]] 210 | )(PlayBuildRecord.apply _) 211 | 212 | def path(id: UUID)(implicit ctx: Context): Path = { 213 | Paths.get(ctx.dbHome, "play-builds", id.toString+".json") 214 | } 215 | def write(id: UUID, record: PlayBuildRecord)(implicit ctx: Context): Unit = { 216 | Records.writeFile(path(id), record) 217 | } 218 | def read(id: UUID)(implicit ctx: Context): Option[PlayBuildRecord] = { 219 | Records.readFile[PlayBuildRecord](path(id)) 220 | } 221 | def readAll(implicit ctx: Context): Map[UUID,PlayBuildRecord] = { 222 | Records.readAll[PlayBuildRecord](Paths.get(ctx.dbHome, "play-builds")) 223 | } 224 | 225 | } 226 | 227 | case class AppBuildRecord( 228 | playBuildId: UUID, 229 | appName: String, 230 | appsCommit: String, 231 | javaVersionExecution: Execution, 232 | buildExecutions: Seq[Execution] 233 | ) { 234 | def successfulBuild: Boolean = buildExecutions.foldLeft(true) { 235 | case (b, execution) => b && execution.returnCode.fold(false)(_ == 0) 236 | } 237 | } 238 | 239 | object AppBuildRecord { 240 | 241 | implicit val writes = new Writes[AppBuildRecord] { 242 | def writes(testBuildRecord: AppBuildRecord) = Json.obj( 243 | "playBuildId" -> testBuildRecord.playBuildId, 244 | "appName" -> testBuildRecord.appName, 245 | "appsCommit" -> testBuildRecord.appsCommit, 246 | "javaVersionExecution" -> testBuildRecord.javaVersionExecution, 247 | "buildExecutions" -> testBuildRecord.buildExecutions 248 | ) 249 | } 250 | 251 | implicit val reads: Reads[AppBuildRecord] = ( 252 | (JsPath \ "playBuildId").read[UUID] and 253 | (JsPath \ "appName").read[String] and 254 | (JsPath \ "appsCommit").read[String] and 255 | (JsPath \ "javaVersionExecution").read[Execution] and 256 | (JsPath \ "buildExecutions").read[Seq[Execution]] 257 | )(AppBuildRecord.apply _) 258 | 259 | def path(id: UUID)(implicit ctx: Context): Path = { 260 | Paths.get(ctx.dbHome, "app-builds", id.toString+".json") 261 | } 262 | def write(id: UUID, record: AppBuildRecord)(implicit ctx: Context): Unit = { 263 | Records.writeFile(path(id), record) 264 | } 265 | def read(id: UUID)(implicit ctx: Context): Option[AppBuildRecord] = { 266 | Records.readFile[AppBuildRecord](path(id)) 267 | } 268 | def readAll(implicit ctx: Context): Map[UUID,AppBuildRecord] = { 269 | Records.readAll[AppBuildRecord](Paths.get(ctx.dbHome, "app-builds")) 270 | } 271 | 272 | } 273 | 274 | case class Command( 275 | program: String, 276 | args: Seq[String] = Nil, 277 | workingDir: String, 278 | env: Map[String, String] 279 | ) { 280 | def mapStrings(f: String => String): Command = { 281 | Command( 282 | program = f(program), 283 | args = args.map(f), 284 | workingDir = f(workingDir), 285 | env = env.mapValues(f) 286 | ) 287 | } 288 | } 289 | 290 | case class TestRunRecord( 291 | appBuildId: UUID, 292 | testName: String, 293 | javaVersionExecution: Execution, 294 | serverExecution: Execution, 295 | wrkExecutions: Seq[Execution] 296 | ) 297 | 298 | object TestRunRecord { 299 | 300 | implicit val writes = new Writes[TestRunRecord] { 301 | def writes(testBuildRecord: TestRunRecord) = Json.obj( 302 | "appBuildId" -> testBuildRecord.appBuildId, 303 | "testName" -> testBuildRecord.testName, 304 | "javaVersionExecution" -> testBuildRecord.javaVersionExecution, 305 | "serverExecution" -> testBuildRecord.serverExecution, 306 | "wrkExecutions" -> testBuildRecord.wrkExecutions 307 | ) 308 | } 309 | 310 | implicit val reads: Reads[TestRunRecord] = ( 311 | (JsPath \ "appBuildId").read[UUID] and 312 | (JsPath \ "testName").read[String] and 313 | (JsPath \ "javaVersionExecution").read[Execution] and 314 | (JsPath \ "serverExecution").read[Execution] and 315 | (JsPath \ "wrkExecutions").read[Seq[Execution]] 316 | )(TestRunRecord.apply _) 317 | 318 | def path(id: UUID)(implicit ctx: Context): Path = { 319 | Paths.get(ctx.dbHome, "test-runs", id.toString+".json") 320 | } 321 | def write(id: UUID, record: TestRunRecord)(implicit ctx: Context): Unit = { 322 | Records.writeFile(path(id), record) 323 | } 324 | def read(id: UUID)(implicit ctx: Context): Option[TestRunRecord] = { 325 | Records.readFile[TestRunRecord](path(id)) 326 | } 327 | def readAll(implicit ctx: Context): Map[UUID,TestRunRecord] = { 328 | Records.readAll[TestRunRecord](Paths.get(ctx.dbHome, "test-runs")) 329 | } 330 | 331 | } 332 | 333 | case class DB( 334 | playBuilds: Map[UUID, PlayBuildRecord], 335 | appBuilds: Map[UUID, AppBuildRecord], 336 | testRuns: Map[UUID, TestRunRecord] 337 | ) 338 | object DB { 339 | def read(implicit ctx: Context): DB = { 340 | val dbPath = Paths.get(ctx.dbHome) 341 | FileUtils.forceMkdir(dbPath.resolve("play-builds").toFile) 342 | FileUtils.forceMkdir(dbPath.resolve("app-builds").toFile) 343 | FileUtils.forceMkdir(dbPath.resolve("test-runs").toFile) 344 | DB( 345 | PlayBuildRecord.readAll, 346 | AppBuildRecord.readAll, 347 | TestRunRecord.readAll 348 | ) 349 | } 350 | case class Join( 351 | pruneInstanceId: UUID, 352 | playBuildId: UUID, 353 | playBuildRecord: PlayBuildRecord, 354 | appBuildId: UUID, 355 | appBuildRecord: AppBuildRecord, 356 | testRunId: UUID, 357 | testRunRecord: TestRunRecord 358 | ) 359 | def iterator(implicit ctx: Context): Iterator[Join] = { 360 | val testRunsDir = Paths.get(ctx.dbHome, "test-runs") 361 | Records.iteratorAll[TestRunRecord](testRunsDir).flatMap { 362 | case (testRunId, testRunRecord) => 363 | val appBuildId = testRunRecord.appBuildId 364 | val optJoin: Option[Join] = for { 365 | appBuildRecord <- AppBuildRecord.read(appBuildId) 366 | playBuildId = appBuildRecord.playBuildId 367 | playBuildRecord <- PlayBuildRecord.read(playBuildId) 368 | pruneInstanceId = playBuildRecord.pruneInstanceId 369 | } yield Join( 370 | pruneInstanceId, 371 | playBuildId, 372 | playBuildRecord, 373 | appBuildId, 374 | appBuildRecord, 375 | testRunId, 376 | testRunRecord 377 | ) 378 | optJoin.iterator 379 | } 380 | } 381 | 382 | } 383 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Prune — *"Keeping Play moving"* 2 | 3 | Prune is a tool for automatically testing the performance of Play Framework. It automates the process of checking out different versions of Play, compiling apps against those versions and then running load tests. It saves all results into files in a Git repository. It also pushes a summary of results to a website. The name *Prune* comes from "Play runner". 4 | 5 | ## Daily test results 6 | 7 | [**You can see graphs of the latest performance results here.**](http://playframework.github.io/prune/) 8 | 9 | Prune is run each day on a dedicated server donated by [Lightbend](https://www.lightbend.com/). The server is a [Xeon E5-2430L v2 2.4GHz](http://ark.intel.com/products/75785/Intel-Xeon-Processor-E5-2430-v2-15M-Cache-2_50-GHz) with [Turbo Boost disabled](http://www.brendangregg.com/blog/2014-09-15/the-msrs-of-ec2.html). The JVM uses 1GB heap and no other settings. The load testing client is run on the same machine as the Play server. Raw test results are pushed in the [*database*](https://github.com/playframework/prune/tree/database) branch of the Prune Github repository. 10 | 11 | ## Tests 12 | 13 | Prune currently runs the following tests. More tests are planned in the future, e.g. tests for non-GET request, tests for WS, etc. 14 | 15 | ### scala-simple / java-simple 16 | 17 | Tests an action that sends a plain text response of `Hello world.`. 18 | 19 | ### scala-simple-form / java-simple-form 20 | 21 | Tests an action that receives a `POST` request, parses it and sends a plain text response. 22 | 23 | ### scala-simple-upload / java-simple-upload 24 | 25 | Tests an action that receives a `PUT` multipart-form (file upload) request, parses it and sends a plain text response. 26 | 27 | ### scala-download-50k / java-download-50k 28 | 29 | Tests an action that parses an integer parameter of 51200 (50k) then sends a binary response of that length. 30 | 31 | ### scala-download-chunked-50k / java-download-50k 32 | 33 | Tests an action that parses an integer parameter of 51200 (50k) then sends a binary response in 4k chunks. 34 | 35 | ### scala-json-encode / java-json-encode 36 | 37 | Test an action that encodes and returns a JSON object of `{"message":"Hello World!"}`. 38 | 39 | ### scala-template-simple / java-template-simple 40 | 41 | Test an action that returns a short HTML page generated from a template that takes a single parameter. 42 | 43 | ### scala-template-lang / java-template-lang 44 | 45 | Test an action that returns a short HTML page generated from a template that takes an implicit language. 46 | 47 | ---------------- 48 | 49 | All the tests above run for three distinct configurations: 50 | 51 | 1. Using Netty server 52 | 2. Using [Akka HTTP](https://github.com/akka/akka-http) Server with default filters 53 | 3. Using [Akka HTTP](https://github.com/akka/akka-http) Server **without** default filters 54 | 55 | ## Example Prune workflow 56 | 57 | A typical Prune workflow involves pulling the latest information from the *play* and *apps* repositories (and rebasing the *db* repositories), running some tests, then pushing results back to the *db* repository. The commands below illustrate this: 58 | 59 | ``` 60 | prune pull 61 | prune test 62 | prune push-test-results 63 | ``` 64 | 65 | As a separate workflow you can also update the website. This will update the site with the latest results contained in the test results database. 66 | 67 | ``` 68 | prune pull-site 69 | prune generate-site-files 70 | prune push-site 71 | ``` 72 | 73 | You can get a quick report of test results in the database and any results that are still waiting on tests to run. 74 | 75 | ``` 76 | prune print-report 77 | ``` 78 | 79 | Finally, you can run a Prune test manually on your local Play code. 80 | 81 | ``` 82 | prune wrk /my/projects/play /my/projects/apps scala-simple 83 | ``` 84 | 85 | ## How Prune works 86 | 87 | Prune is configured to watch several Play branches and run tests on those branches automatically. It records all test runs in a Git repository called the "database". 88 | 89 | When Prune is invoked, it will look at the revisions on the branches it is monitoring and compare them to its record of the tests that it has already run. Then it will work out which tests are missing from its records and run them. 90 | 91 | This means Prune's behavior is completely declarative. You don't tell Prune to "run tests on HEAD". Instead you just declare the revisions you want tested, then let Prune figure out how to run them. 92 | 93 | There are two benefits of this approach: 94 | 95 | * Once a test has been run and its test record written to the database, then Prune will not run that test again. This means that its safe to stop and start Prune at any time and it will not repeat work that it has already done. 96 | 97 | * If Prune configuration changes between runs, say to add a new test, then Prune will automatically run that test on all relevant revisions of Play, even if it means going back in time and testing old versions of Play again. This means it is possible to use Prune for backtesting performance. 98 | 99 | To run a test, Prune will build Play, build a test application, start the application, then run [wrk](https://github.com/wg/wrk). All of this is recorded and written to the database repository. 100 | 101 | ### Prune Git repositories 102 | 103 | There are four logical Git repositories used by Prune. Each repository has a remote location and one or more branches. All repositories have a local directory, usually located within `~/.prune`. 104 | 105 | * *play* – The Play Framework respository. Prune uses this to work out which revisions of Play to test and to get the Play source code. Generally this will be the [main Play Framework repository](https://github.com/playframework/playframework). 106 | * *app* – The repository that contains code for test applications. Prune uses multiple branches in this repository, because different versions of Play will require different test applications. E.g. Play *master* will use the *apps-master* branch, Play *2.3.x* will use the *apps-2.3.x* branch. The default versions of these apps are stored as branches in the [main Prune repository](https://github.com/playframework/prune). 107 | * *db* (or *database*) – The repository that stores test results. The remote can be located anywhere, even a local directory, but the official test results are stored in the *database* branch in the [main Prune repository](https://github.com/playframework/prune). 108 | * *site* – The repository that contains the test results website. The official Prune test website is stored in the *gh-pages* branch in the [main Prune repository](https://github.com/playframework/prune). 109 | 110 | By default the Prune Github repository actually serves as the remote for the *apps*, *db* and *site* repositories. It is fine to use different repositories too. If the same repository is used then different branches are needed for each purpose. 111 | 112 | ## Building Prune 113 | 114 | Prune is a command line JVM application written in Scala. Use [sbt](http://www.scala-sbt.org/) to build it. 115 | 116 | $ sbt dist 117 | $ unzip prune/target/universal/prune-1.0.zip -d 118 | 119 | The script for launching Prune can be found in `/prune-1.0/bin/prune`. 120 | 121 | ## Configuring Prune 122 | 123 | Before you run Prune you need to configure it. Prune uses [Typesafe Config](https://github.com/typesafehub/config) for its configuration. Most configuration is defined in a [`reference.conf`](https://github.com/playframework/prune/blob/master/src/main/resources/reference.conf) file. The default configuration is overridden by a configuration file in `~/.prune/prune.config`. All configuration in `prune.config` is automatically prefixed with the path `com.typesafe.play.prune`. Some keys are not defined in `reference.conf` so they always need to be provided by the user in the `~/.prune/prune.config` file. 124 | 125 | Here is a template for a `~/.prune/prune.config` file. All keys are required. 126 | 127 | ``` 128 | # The UUID used to identify this instance of Prune in test records. 129 | # Each Prune instance needs a unique id. To generate a unique id, go 130 | # to http://www.famkruithof.net/uuid/uuidgen. 131 | #pruneInstanceId: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 132 | 133 | # The location of Java 8 on the system. Prune will use this JDK when 134 | # building and running tests. 135 | #java8.home: /usr/lib/jvm/java-8-oracle/jre 136 | 137 | # The remote git repository and branch to use for storing test 138 | # results. This could be a Github repository or it could just be a 139 | # path to a local repository that you've created with `git init`. If 140 | # it's a remote repository and you want to push to that repository, 141 | # be sure to configure appropriate SSH keys in `~/.ssh`. 142 | #dbRemote: "https://github.com/playframework/prune.git" 143 | #dbBranch: database 144 | 145 | # The remote git repository and branch to use as a results website. 146 | # This could be a Github repository or it could just be a path to a 147 | # local repository that you've created with `git init`. If it's a 148 | # remote repository and you want to push to that repository, be sure 149 | # to configure appropriate SSH keys in `~/.ssh`. 150 | #siteRemote: "https://github.com/playframework/prune.git" 151 | #siteBranch: gh-pages 152 | 153 | # The location of the YourKit agent used to collect profiling data. 154 | # YourKit can be downloaded from http://www.yourkit.com/download/. 155 | # For more information about the path to the YourKit agent, see 156 | # http://www.yourkit.com/docs/java/help/agent.jsp. 157 | #yourkit.agent: /local/yourkit/path/bin/linux-x86-64/libyjpagent.so 158 | #yourkit.agent: /local/yourkit/path/bin/mac/libyjpagent.jnilib 159 | ``` 160 | 161 | ## Running Prune 162 | 163 | Prune understands a number of different commands. The script for launching Prune is `prune-1.0/bin/prune`, located at the place where you extracted the `dist` zip file. 164 | 165 | ### pull 166 | 167 | Running this command pulls from the Play, app and database repositories. Local branches will be rebased, if necessary. Generally only the database repository will have any local modifications to rebase from. 168 | 169 | Examples: 170 | 171 | * Pull from all repositories. 172 | ``` 173 | prune pull 174 | ``` 175 | 176 | * Pull from the Play and app repositories, but skip the database repository. 177 | ``` 178 | prune pull --skip-db-fetch 179 | ``` 180 | 181 | * Only pull from the database repository. 182 | ``` 183 | prune pull --skip-play-fetch --skip-apps-fetch 184 | ``` 185 | 186 | ### test 187 | 188 | This command runs all remaining tests. Prune will: 189 | 190 | 1. Look at its configuration to see which Play branches and revisions it is interested in. 191 | 1. Look at the Play repository to get a list of revisions. 192 | 1. Look at the database repository to see which tests have already been run. 193 | 1. Create a plan of all the tests that need to be run. 194 | 1. Run each test, one at a time. 195 | 196 | For each test, Prune will: 197 | 198 | 1. Check out revision P of Play and `publish-local` to a private local Ivy directory (usually in `~/.prune/ivy`). 199 | 1. Write the Play build information to the database. 200 | 1. Check out and build the test app needed for the test. It uses `stage` to get a script for running tha app. 201 | 1. Write the app build information to the database. 202 | 1. Start the test app. 203 | 1. Execute [wrk](https://github.com/wg/wrk) twice with the command line arguments needed for the test. The first run is a warmup of 30 seconds. The second is the real test, which runs for 2 minutes. 204 | 1. Write the test results to the database. 205 | 1. Stop the test app. 206 | 207 | Actually, this process isn't completely accurate. Prune doesn't recompile Play or the test apps on every test run. It only recompiles when a new version is needed. 208 | 209 | All results are written to the database repository. 210 | 211 | Examples: 212 | 213 | * Run all tests. 214 | 215 | ``` 216 | prune test 217 | ``` 218 | 219 | * Run at most one test. 220 | 221 | ``` 222 | prune test --max-test-runs 1 223 | ``` 224 | 225 | * Run tests for the master branch. 226 | 227 | ``` 228 | test --play-branch master 229 | ``` 230 | 231 | * Run only the `scala-di-simple` test. 232 | 233 | ``` 234 | test --test-name scala-di-simple 235 | ``` 236 | 237 | * Run only the `scala-di-simple` test for the HEAD revision of master. 238 | 239 | ``` 240 | test --play-branch master --play-rev HEAD --test-name scala-di-simple 241 | ``` 242 | 243 | * Run tests until 30 minutes have passed. Prune will run as many tests as it can until this time limit is reached. Note: Prune may run slightly longer than the given time, because it only checks the time at the start of each new test run. 244 | 245 | ``` 246 | prune test --max-total-minutes 30 247 | ``` 248 | 249 | * Run all tests, but limit the length of time that [wrk](https://github.com/wg/wrk) is run for to 5 seconds. This is very useful for debugging Prune, but **it will produce invalid test results because wrk will not be running for the correct amount of time.** 250 | 251 | ``` 252 | prune test --max-wrk-duration 5 253 | ``` 254 | 255 | ### push-test-results 256 | 257 | This command pushes the test results in the database repository to the remote repository. This command doesn't need to be run unless you wish to back up or share files via the remote database repository. 258 | 259 | * Push the test results to the database repository. 260 | 261 | ``` 262 | prune push-test-results 263 | ``` 264 | 265 | ### print-report 266 | 267 | This command prints out a simple report of test results. 268 | 269 | Examples: 270 | 271 | * Print out report. 272 | 273 | ``` 274 | prune print-report 275 | ``` 276 | 277 | ### pull-site 278 | 279 | This command pulls the files from the remote site repository to the local repository. 280 | 281 | Examples: 282 | 283 | * Pull the remote site files. 284 | 285 | ``` 286 | prune pull-site 287 | ``` 288 | 289 | ### generate-site-files 290 | 291 | This command generates a new JSON file containing the test results and saves it in the local copy of the site repository. 292 | 293 | Examples: 294 | 295 | * Generate new site files based on the latest test results. 296 | 297 | ``` 298 | prune generate-site-files 299 | ``` 300 | 301 | ### push-site 302 | 303 | This command pushes the local site files to the remote repository 304 | 305 | Examples: 306 | 307 | * Push the local site files. 308 | 309 | ``` 310 | prune push-site 311 | ``` 312 | 313 | ### wrk 314 | 315 | This command runs a wrk performance test against your local code and prints out the results. The results are *not* recorded in the database. 316 | 317 | * Build the local Play instance and the local app needed for the scala-simple test (scala-benchmark) then run the scala-simple test and print out the results. 318 | 319 | ``` 320 | prune wrk /my/projects/play /my/projects/apps scala-simple 321 | ``` 322 | 323 | * Build the local Play instance and the local app called my-app inside the apps dir. Start up the local app and run wrk against the path */mycontroller*. Note: `` must be typed in exactly as shown. Prune will replace the string `` with the base URL of the server that it starts. 324 | 325 | ``` 326 | prune wrk /my/projects/play /my/projects/apps my-app '/mycontroller' 327 | ``` 328 | 329 | * Run the scala-simple test and print out the results without rebuilding Play or the app. 330 | 331 | ``` 332 | prune wrk /my/projects/play /my/projects/apps scala-simple --skip-play-build --skip-app-build 333 | ``` 334 | 335 | ### profile 336 | 337 | This command runs a performance test against your local code while capturing data with the YourKit agent. The results are *not* recorded in the database. Results are saved in `~/.prune/yourkit` using a randomly generated UUID as the given session name. The UUID is printed out when the test runs so that you can locate the session files later. 338 | 339 | * Build the local Play instance and the local app needed for the scala-simple test (scala-benchmark) then run the scala-simple test and capture a YourKit snapshot. 340 | 341 | ``` 342 | prune profile /my/projects/play /my/projects/apps scala-simple 343 | ``` 344 | 345 | * Run the scala-simple test and capture a profiling snapshot without rebuilding Play or the app. 346 | 347 | ``` 348 | prune profile /my/projects/play /my/projects/apps scala-simple --skip-play-build --skip-app-build 349 | ``` 350 | -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/play/prune/Prune.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2014 Typesafe Inc. 3 | */ 4 | package com.typesafe.play.prune 5 | 6 | import com.typesafe.config.{ Config, ConfigFactory } 7 | import java.nio.file._ 8 | import java.util.UUID 9 | import com.typesafe.play.prune.PruneGit.LogEntry 10 | import org.eclipse.jgit.lib._ 11 | import org.joda.time.{Duration, DateTime} 12 | import scala.annotation.tailrec 13 | 14 | case class TestTask( 15 | info: TestTaskInfo, 16 | playCommitTime: DateTime, 17 | playBranch: String, 18 | appsBranch: String, 19 | appsCommit: String 20 | ) 21 | 22 | case class TestTaskInfo( 23 | testName: String, 24 | playCommit: String, 25 | appName: String 26 | ) 27 | 28 | object Prune { 29 | 30 | def main(rawArgs: Array[String]): Unit = { 31 | val args = Args.parse(rawArgs) 32 | 33 | val defaultConfig = ConfigFactory.load().getConfig("com.typesafe.play.prune") 34 | val userConfigFile = Paths.get(args.configFile.getOrElse(defaultConfig.getString("defaultUserConfig"))) 35 | 36 | def configError(message: String): Unit = { 37 | println(s""" 38 | |$message 39 | | 40 | |# The UUID used to identify this instance of Prune in test records. 41 | |# Each Prune instance needs a unique id. To generate a unique id, go 42 | |# to http://www.famkruithof.net/uuid/uuidgen. 43 | |#pruneInstanceId: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 44 | | 45 | |# The location of Java 8 on the system. Prune will use this JDK when 46 | |# building and running tests. 47 | |#java8.home: /usr/lib/jvm/java-8-oracle/jre 48 | | 49 | |# The remote git repository and branch to use for storing test 50 | |# results. This could be a Github repository or it could just be a 51 | |# path to a local repository that you've created with `git init`. If 52 | |# it's a remote repository and you want to push to that repository, 53 | |# be sure to configure appropriate SSH keys in `~/.ssh`. 54 | |#dbRemote: "https://github.com/playframework/prune.git" 55 | |#dbBranch: database 56 | | 57 | |# The remote git repository and branch to use as a results website. 58 | |# This could be a Github repository or it could just be a path to a 59 | |# local repository that you've created with `git init`. If it's a 60 | |# remote repository and you want to push to that repository, be sure 61 | |# to configure appropriate SSH keys in `~/.ssh`. 62 | |#siteRemote: "https://github.com/playframework/prune.git" 63 | |#siteBranch: gh-pages 64 | | 65 | |# The location of the YourKit agent used to collect profiling data. 66 | |# YourKit can be downloaded from http://www.yourkit.com/download/. 67 | |# For more information about the path to the YourKit agent, see 68 | |# http://www.yourkit.com/docs/java/help/agent.jsp. 69 | |#yourkit.agent: /local/yourkit/path/bin/linux-x86-64/libyjpagent.so 70 | |#yourkit.agent: /local/yourkit/path/bin/mac/libyjpagent.jnilib 71 | |""".stripMargin) 72 | System.exit(1) 73 | } 74 | if (Files.notExists(userConfigFile)) configError(s"Please create a Prune configuration file at $userConfigFile.") 75 | 76 | val userConfig: Config = ConfigFactory.parseFile(userConfigFile.toFile) 77 | val config = userConfig.withFallback(defaultConfig) 78 | 79 | val neededPaths = Seq( 80 | "pruneInstanceId", 81 | "java8.home", 82 | "dbRemote", 83 | "dbBranch", 84 | "siteRemote", 85 | "siteBranch", 86 | "yourkit.agent") 87 | neededPaths.foreach { path => 88 | if (!config.hasPath(path)) configError(s"Missing setting `$path` from your Prune configuration file.") 89 | } 90 | 91 | implicit val ctx = Context( 92 | args = args, 93 | config = config 94 | ) 95 | 96 | println(s"Prune instance id is ${ctx.pruneInstanceId}") 97 | 98 | args.command match { 99 | case None => println("Please provide a command for Prune to execute.") 100 | case Some(Pull) => pull 101 | case Some(Test) => test 102 | case Some(PushTestResults) => pushTestResults 103 | case Some(PrintReport) => printReport 104 | case Some(GenerateJsonReport) => generateJsonReport 105 | case Some(PullSite) => pullSite 106 | case Some(GenerateSiteFiles) => generateSiteFiles 107 | case Some(PushSite) => pushSite 108 | case Some(Wrk) => wrk 109 | case Some(Profile) => profile 110 | } 111 | 112 | } 113 | 114 | def pull(implicit ctx: Context): Unit = { 115 | def pull0(desc: String, switch: Boolean, remote: String, branches: Seq[String], checkedOutBranch: Option[String], localDir: String): Unit = { 116 | if (switch) { 117 | println(s"Fetching $desc from remote") 118 | PruneGit.gitCloneOrRebaseBranches( 119 | remote = remote, 120 | branches = branches, 121 | checkedOutBranch = checkedOutBranch, 122 | localDir = localDir) 123 | } else { 124 | println(s"Skipping fetch of $desc from remote") 125 | } 126 | } 127 | pull0("Prune database records", ctx.args.dbFetch, ctx.dbRemote, Seq(ctx.dbBranch), Some(ctx.dbBranch), ctx.dbHome) 128 | pull0("Play source code", ctx.args.playFetch, ctx.playRemote, ctx.playBranches, None, ctx.playHome) 129 | pull0("apps source code", ctx.args.appsFetch, ctx.appsRemote, ctx.appsBranches, None, ctx.appsHome) 130 | } 131 | 132 | private def playCommitsToTest(playTestConfig: PlayTestsConfig)(implicit ctx: Context): Seq[LogEntry] = { 133 | val commitsInLog = PruneGit.gitFirstParentsLog( 134 | ctx.playHome, 135 | playTestConfig.playBranch, 136 | playTestConfig.playRevisionRange._1, 137 | playTestConfig.playRevisionRange._2) 138 | 139 | if (commitsInLog.length <= 1) commitsInLog else { 140 | // Use randomness of hashes to do random sampling. Using hashes for sampling is 141 | // better than using a random number because it makes sampling stable across 142 | // multiple runs of Prune. It also means that changing the sampling value 143 | // will still make use of any commits that have already been sampled. 144 | val maxPrefix = (1 << (7 * 4)) - 1 // fffffff or 268435455 145 | val samplePrefixCeiling = Math.round(maxPrefix * playTestConfig.playRevisionSampling).toInt 146 | val filteredPart: Seq[LogEntry] = commitsInLog.init.filter { commit => 147 | val commitPrefix = java.lang.Integer.parseInt(commit.id.substring(0, 7), 16) 148 | commitPrefix <= samplePrefixCeiling 149 | } 150 | // Force the last commit onto the list so we can see the start point of the sampling 151 | val unfilteredPart: LogEntry = commitsInLog.last 152 | filteredPart :+ unfilteredPart 153 | } 154 | } 155 | 156 | private def filterBySeq[A,B](input: Seq[A], filters: Seq[B], extract: A => B): Seq[A] = { 157 | if (filters.isEmpty) input else input.filter(a => filters.contains(extract(a))) 158 | } 159 | 160 | def test(implicit ctx: Context): Unit = { 161 | 162 | // Calculate how long we have to run tests 163 | val deadline: Option[DateTime] = ctx.args.maxTotalMinutes.map { mins => 164 | DateTime.now.plusMinutes(mins) 165 | } 166 | 167 | // Work out the commit id of the Play build that we last compiled 168 | val lastPlayBuildCommit: Option[String] = BuildPlay.lastBuild().map { 169 | case (_, buildRecord) => buildRecord.playCommit 170 | } 171 | 172 | // Choose how to prioritize/order the Play commits for testing 173 | val sortFunction: (TestTask, TestTask) => Boolean = if (ctx.args.lexicalOrder) { 174 | case (task1, task2) => task1.playCommitTime.compareTo(task2.playCommitTime) > 0 // Reverse date order 175 | } else { 176 | case (task1, task2) => task1.info.playCommit.compareTo(task1.info.playCommit) < 0 // Forward lexical order 177 | } 178 | 179 | val filteredPlayTests = filterBySeq(ctx.playTests, ctx.args.playBranches, (_: PlayTestsConfig).playBranch) 180 | val neededTasks: Seq[TestTask] = filteredPlayTests.flatMap { playTest => 181 | //println(s"Working out tests to run for $playTest") 182 | 183 | val appsId: AnyObjectId = PruneGit.resolveId(ctx.appsHome, playTest.appsBranch, playTest.appsRevision) 184 | val playCommits: Seq[LogEntry] = playCommitsToTest(playTest) 185 | val playCommitFilter: Seq[String] = ctx.args.playRevs.map(r => PruneGit.resolveId(ctx.playHome, playTest.playBranch, r).name) 186 | val filteredPlayCommits: Seq[LogEntry] = filterBySeq(playCommits, playCommitFilter, (_: LogEntry).id) 187 | filteredPlayCommits.flatMap { playCommit => 188 | val filteredTestNames = filterBySeq(playTest.testNames, ctx.args.testNames, identity[String]) 189 | filteredTestNames.map { testName => 190 | val testApp = ctx.testConfig.get(testName).map(_.app).getOrElse(sys.error(s"No test config for $testName")) 191 | TestTask( 192 | info = TestTaskInfo( 193 | testName = testName, 194 | playCommit = playCommit.id, 195 | appName = testApp 196 | ), 197 | playCommitTime = playCommit.time, 198 | playBranch = playTest.playBranch, 199 | appsBranch = playTest.appsBranch, 200 | appsCommit = appsId.getName 201 | ) 202 | } 203 | } 204 | }.distinct.sortBy(_.appsCommit).sortWith(sortFunction).sortWith { 205 | case (task1, task2) => lastPlayBuildCommit.fold(false) { c => 206 | (task1.info.playCommit == c) // Start with the current Play build 207 | } 208 | } 209 | 210 | val completedTaskInfos: Seq[TestTaskInfo] = DB.iterator.map { join => 211 | TestTaskInfo( 212 | testName = join.testRunRecord.testName, 213 | playCommit = join.playBuildRecord.playCommit, 214 | appName = join.appBuildRecord.appName 215 | ) 216 | }.toSeq 217 | val tasksToRun: Seq[TestTask] = if (ctx.args.repeatTests) { 218 | println("User has passed argument allowing tests to be repeated: not filtering out tests already executed") 219 | neededTasks 220 | } else { 221 | neededTasks.filter(task => !completedTaskInfos.contains(task.info)) 222 | } 223 | 224 | println(s"Prune tests already executed: ${completedTaskInfos.map(_.playCommit).distinct.size} Play revisions, ${completedTaskInfos.size} test runs") 225 | println(s"Prune tests needed: ${neededTasks.map(_.info.playCommit).distinct.size} Play revisions, ${neededTasks.size} test runs") 226 | println(s"Prune tests to run: ${tasksToRun.map(_.info.playCommit).distinct.size} Play revisions, ${tasksToRun.size} test runs") 227 | 228 | if (ctx.args.verbose) { 229 | println(s"First task to run: ${tasksToRun.headOption}") 230 | } 231 | 232 | val truncatedTasksToRun = ctx.args.maxTestRuns.fold(tasksToRun) { i => 233 | if (tasksToRun.size > i) { 234 | println(s"Overriding number of test runs down to $i") 235 | tasksToRun.take(i) 236 | } else tasksToRun 237 | } 238 | 239 | Assets.extractAssets 240 | 241 | @tailrec 242 | def loop(taskQueue: Seq[TestTask]): Unit = { 243 | if (taskQueue.isEmpty) () else { 244 | val now = DateTime.now 245 | deadline match { 246 | case Some(d) if now.isAfter(d) => 247 | val targetMins: Int = ctx.args.maxTotalMinutes.get 248 | val actualMins: Int = new Duration(d, now).getStandardMinutes.toInt 249 | println(s"Stopping tests after ${actualMins} minutes because --max-total-minutes ${targetMins} exceeded: ${taskQueue.size} tests remaining") 250 | case _ => 251 | RunTest.runTestTask(taskQueue.head) 252 | loop(taskQueue.tail) 253 | } 254 | } 255 | } 256 | loop(truncatedTasksToRun) 257 | } 258 | 259 | def printReport(implicit ctx: Context): Unit = { 260 | type PlayRev = String 261 | case class TestResult( 262 | testRunId: UUID, 263 | wrkOutput: Option[String] 264 | ) 265 | 266 | def getResults(playCommits: Seq[String], testName: String): Map[PlayRev, TestResult] = { 267 | DB.iterator.flatMap { join => 268 | if ( 269 | join.pruneInstanceId == ctx.pruneInstanceId && 270 | join.testRunRecord.testName == testName && 271 | playCommits.contains(join.playBuildRecord.playCommit)) { 272 | Iterator((join.playBuildRecord.playCommit, TestResult( 273 | testRunId = join.testRunId, 274 | wrkOutput = join.testRunRecord.wrkExecutions.last.stdout 275 | ))) 276 | } else Iterator.empty 277 | }.toMap 278 | } 279 | 280 | for { 281 | playTestConfig <- filterBySeq(ctx.playTests, ctx.args.playBranches, (_: PlayTestsConfig).playBranch) 282 | testName <- filterBySeq(playTestConfig.testNames, ctx.args.testNames, identity[String]) 283 | } { 284 | val playCommits: Seq[String] = playCommitsToTest(playTestConfig).map(_.id) 285 | val resultMap = getResults(playCommits, testName) 286 | println(s"=== Test $testName on ${playTestConfig.playBranch} - ${playCommits.size} commits ===") 287 | for (playCommit <- playCommits) { 288 | val testResult: Either[String, TestResult] = resultMap.get(playCommit).toRight("") 289 | val wrkOutput: Either[String, String] = testResult.right.flatMap(_.wrkOutput.toRight("")) 290 | val wrkResult: Either[String, WrkResult] = wrkOutput.right.flatMap(Results.parseWrkOutput) 291 | val resultDisplay: String = wrkResult.right.flatMap(_.summary.right.map(_.display)).merge 292 | println(s"${playCommit.substring(0,7)} $resultDisplay") 293 | } 294 | } 295 | } 296 | 297 | def pushTestResults(implicit ctx: Context): Unit = { 298 | PruneGit.gitPushChanges( 299 | remote = ctx.dbRemote, 300 | branch = ctx.dbBranch, 301 | localDir = ctx.dbHome, 302 | commitMessage = "Added records") 303 | } 304 | 305 | def generateJsonReport(implicit ctx: Context): Unit = { 306 | val outputFile: String = ctx.args.outputFile.getOrElse(sys.error("Please provide an output file")) 307 | val jsonString = JsonReport.generateJsonReport 308 | Files.write(Paths.get(outputFile), jsonString.getBytes("UTF-8")) 309 | } 310 | 311 | def pullSite(implicit ctx: Context): Unit = { 312 | PruneGit.gitCloneOrRebaseBranches( 313 | remote = ctx.siteRemote, 314 | branches = Seq(ctx.siteBranch), 315 | checkedOutBranch = Some(ctx.siteBranch), 316 | localDir = ctx.siteHome) 317 | } 318 | 319 | def generateSiteFiles(implicit ctx: Context): Unit = { 320 | val jsonString = JsonReport.generateJsonReport 321 | val jsString = s"var report = $jsonString;" 322 | val outputFile: Path = Paths.get(ctx.siteHome, "prune-data.js") 323 | Files.write(outputFile, jsString.getBytes("UTF-8")) 324 | } 325 | 326 | def pushSite(implicit ctx: Context): Unit = { 327 | PruneGit.gitPushChanges( 328 | remote = ctx.siteRemote, 329 | branch = ctx.siteBranch, 330 | localDir = ctx.siteHome, 331 | commitMessage = "Updated generated files") 332 | } 333 | 334 | def wrk(implicit ctx: Context): Unit = { 335 | val testOrAppName: String = ctx.args.testOrAppName.get 336 | val testConfig: Option[TestConfig] = ctx.testConfig.get(testOrAppName) 337 | val appName = testConfig.fold(testOrAppName)(_.app) 338 | val wrkArgs = testConfig.fold(Seq.empty[String])(_.wrkArgs) ++ ctx.args.wrkArgs 339 | if (ctx.args.playBuild) { 340 | BuildPlay.buildPlayDirectly() 341 | } 342 | if (ctx.args.appBuild) { 343 | BuildApp.buildAppDirectly(appName) 344 | } 345 | val testExecutions = RunTest.runTestDirectly(appName, wrkArgs) 346 | } 347 | 348 | def profile(implicit ctx: Context): Unit = { 349 | val testOrAppName: String = ctx.args.testOrAppName.get 350 | val testConfig: Option[TestConfig] = ctx.testConfig.get(testOrAppName) 351 | val appName = testConfig.fold(testOrAppName)(_.app) 352 | val wrkArgs = testConfig.fold(Seq.empty[String])(_.wrkArgs) ++ ctx.args.wrkArgs 353 | if (ctx.args.playBuild) { 354 | BuildPlay.buildPlayDirectly() 355 | } 356 | if (ctx.args.appBuild) { 357 | BuildApp.buildAppDirectly(appName) 358 | } 359 | val sessionName = UUID.randomUUID.toString 360 | println(s"Using profile session name: $sessionName") 361 | val testExecutions = RunTest.runProfileDirectly(appName, wrkArgs, sessionName) 362 | } 363 | 364 | } -------------------------------------------------------------------------------- /src/main/resources/reference.conf: -------------------------------------------------------------------------------- 1 | com.typesafe.play.prune { 2 | 3 | # The Prune home directory 4 | home: ${user.home}/.prune 5 | 6 | defaultUserConfig: ${com.typesafe.play.prune.home}/prune.config 7 | 8 | # The location of the Ivy configuration and cache 9 | ivy { 10 | home: ${com.typesafe.play.prune.home}/ivy 11 | } 12 | 13 | java8 { 14 | #home: xxx # Should be configured by user in prune.config 15 | opts: ["-Xms1g", "-Xmx1g"] # -verbose:gc 16 | } 17 | 18 | # The location of the remote Play repository 19 | playRemote: "https://github.com/playframework/playframework.git" 20 | # The place where the remote Play repository is cloned to 21 | playHome: ${com.typesafe.play.prune.home}/play 22 | 23 | # The location of the remote Prune apps repository (often the main Prune repository) 24 | appsRemote: "https://github.com/playframework/prune.git" 25 | # The place where the Prune apps are cloned to 26 | appsHome: ${com.typesafe.play.prune.home}/apps 27 | 28 | # The location of the remote database repository (often the main Prune repository) 29 | #dbRemote: "https://github.com/playframework/prune.git" # Should be configured by user in prune.config 30 | # The branch of the database repository to use for database results 31 | #dbBranch: database # Should be configured by user in prune.config 32 | # The place where the database repository is cloned to 33 | dbHome: ${com.typesafe.play.prune.home}/db 34 | 35 | # The location of the remote site repository (often the main Prune repository) 36 | #siteRemote: "https://github.com/playframework/prune.git" # Should be configured by user in prune.config 37 | # The branch of the site repository to use for site content 38 | #siteBranch: gh-pages # Should be configured by user in prune.config 39 | # The place where the database repository is cloned to 40 | siteHome: ${com.typesafe.play.prune.home}/site 41 | 42 | # The place where Prune support assets are extracted to 43 | assetsHome: ${com.typesafe.play.prune.home}/assets 44 | 45 | playTests: [ 46 | { 47 | # Tests updated to handle default lang without implicit, remove new tests in case of instability... 48 | playBranch: master 49 | playRevisionRange: 7d3aa52..HEAD 50 | playRevisionSampling: 1.0 51 | appsBranch: apps-master-11 52 | appsRevision: HEAD 53 | testNames: [ 54 | # Scala tests: 55 | # - With default filters 56 | # - With Akka HTTP Server 57 | scala-di-simple, 58 | scala-di-simple-post, 59 | scala-di-download-50k, 60 | scala-di-download-chunked-50k, 61 | scala-di-template-simple, 62 | scala-di-template-lang, 63 | scala-di-json-encode, 64 | scala-di-upload-1m, 65 | scala-di-upload-raw-1m, 66 | # Scala tests: 67 | # - With default filters 68 | # - With Netty Server 69 | scala-netty-simple, 70 | scala-netty-simple-post, 71 | scala-netty-download-50k, 72 | scala-netty-download-chunked-50k, 73 | scala-netty-template-simple, 74 | scala-netty-template-lang, 75 | scala-netty-json-encode, 76 | scala-netty-upload-1m, 77 | scala-netty-upload-raw-1m, 78 | # Scala tests: 79 | # - Without default filters 80 | # - With Akka HTTP Server 81 | scala-minimal-simple, 82 | scala-minimal-simple-post, 83 | scala-minimal-download-50k, 84 | scala-minimal-download-chunked-50k, 85 | scala-minimal-template-simple, 86 | scala-minimal-template-lang, 87 | scala-minimal-json-encode, 88 | scala-minimal-upload-1m, 89 | scala-minimal-upload-raw-1m, 90 | # Java tests: 91 | # - With default filters 92 | # - With Akka HTTP Server 93 | java-di-simple, 94 | java-di-simple-post, 95 | java-di-download-50k, 96 | java-di-download-chunked-50k, 97 | java-di-template-simple, 98 | java-di-template-lang, 99 | java-di-json-encode, 100 | java-di-upload-1m, 101 | java-di-upload-raw-1m, 102 | # Java tests: 103 | # - With default filters 104 | # - With Netty Server 105 | java-netty-simple, 106 | java-netty-simple-post, 107 | java-netty-download-50k, 108 | java-netty-download-chunked-50k, 109 | java-netty-template-simple, 110 | java-netty-template-lang, 111 | java-netty-json-encode, 112 | java-netty-upload-1m, 113 | java-netty-upload-raw-1m, 114 | # Java tests: 115 | # - Without default filters 116 | # - With Akka HTTP Server 117 | java-minimal-simple, 118 | java-minimal-simple-post, 119 | java-minimal-download-50k, 120 | java-minimal-download-chunked-50k, 121 | java-minimal-template-simple, 122 | java-minimal-template-lang, 123 | java-minimal-json-encode, 124 | java-minimal-upload-1m, 125 | java-minimal-upload-raw-1m 126 | ] 127 | } 128 | { 129 | # Tests updated to Scala 2.12, newer Play 2.6 conventions, etc 130 | playBranch: master 131 | playRevisionRange: 747eb42..9996929 132 | playRevisionSampling: 1.0 133 | appsBranch: apps-master-10 134 | appsRevision: HEAD 135 | testNames: [ 136 | # Scala tests: 137 | # - With default filters 138 | # - With Akka HTTP Server 139 | scala-di-simple, 140 | scala-di-simple-post, 141 | scala-di-download-50k, 142 | scala-di-download-chunked-50k, 143 | scala-di-template-simple, 144 | scala-di-template-lang, 145 | scala-di-json-encode, 146 | scala-di-upload-1m, 147 | scala-di-upload-raw-1m, 148 | # Scala tests: 149 | # - With default filters 150 | # - With Netty Server 151 | scala-netty-simple, 152 | scala-netty-simple-post, 153 | scala-netty-download-50k, 154 | scala-netty-download-chunked-50k, 155 | scala-netty-template-simple, 156 | scala-netty-template-lang, 157 | scala-netty-json-encode, 158 | scala-netty-upload-1m, 159 | scala-netty-upload-raw-1m, 160 | # Scala tests: 161 | # - Without default filters 162 | # - With Akka HTTP Server 163 | scala-minimal-simple, 164 | scala-minimal-simple-post, 165 | scala-minimal-download-50k, 166 | scala-minimal-download-chunked-50k, 167 | scala-minimal-template-simple, 168 | scala-minimal-template-lang, 169 | scala-minimal-json-encode, 170 | scala-minimal-upload-1m, 171 | scala-minimal-upload-raw-1m, 172 | # Java tests: 173 | # - With default filters 174 | # - With Akka HTTP Server 175 | java-di-simple, 176 | java-di-simple-post, 177 | java-di-download-50k, 178 | java-di-download-chunked-50k, 179 | java-di-template-simple, 180 | java-di-template-lang, 181 | java-di-json-encode, 182 | java-di-upload-1m, 183 | java-di-upload-raw-1m, 184 | # Java tests: 185 | # - With default filters 186 | # - With Netty Server 187 | java-netty-simple, 188 | java-netty-simple-post, 189 | java-netty-download-50k, 190 | java-netty-download-chunked-50k, 191 | java-netty-template-simple, 192 | java-netty-template-lang, 193 | java-netty-json-encode, 194 | java-netty-upload-1m, 195 | java-netty-upload-raw-1m, 196 | # Java tests: 197 | # - Without default filters 198 | # - With Akka HTTP Server 199 | java-minimal-simple, 200 | java-minimal-simple-post, 201 | java-minimal-download-50k, 202 | java-minimal-download-chunked-50k, 203 | java-minimal-template-simple, 204 | java-minimal-template-lang, 205 | java-minimal-json-encode, 206 | java-minimal-upload-1m, 207 | java-minimal-upload-raw-1m 208 | ] 209 | } 210 | 211 | # Haven't got applications working for revisions c9d2b33..2006028 yet 212 | # If we do get this working it should be branch `apps-master-9` 213 | 214 | { 215 | # Tests updated because "guiceSupport" renamed to "guice" 216 | playBranch: master 217 | playRevisionRange: 389a655..d23bc40 218 | playRevisionSampling: 1.0 219 | appsBranch: apps-master-8 220 | appsRevision: HEAD 221 | testNames: [ 222 | scala-di-simple, 223 | scala-di-download-50k, 224 | scala-di-download-chunked-50k, 225 | scala-di-template-simple, 226 | scala-di-template-lang, 227 | scala-di-json-encode, 228 | java-di-simple, 229 | java-di-download-50k, 230 | java-di-download-chunked-50k, 231 | java-di-template-simple, 232 | java-di-template-lang, 233 | java-di-json-encode 234 | ] 235 | } 236 | { 237 | # Tests updated because Guice is in a separate module 238 | playBranch: master 239 | playRevisionRange: 3e629df..14b635e 240 | playRevisionSampling: 1.0 241 | appsBranch: apps-master-7 242 | appsRevision: HEAD 243 | testNames: [ 244 | scala-di-simple, 245 | scala-di-download-50k, 246 | scala-di-download-chunked-50k, 247 | scala-di-template-simple, 248 | scala-di-template-lang, 249 | scala-di-json-encode, 250 | java-di-simple, 251 | java-di-download-50k, 252 | java-di-download-chunked-50k, 253 | java-di-template-simple, 254 | java-di-template-lang, 255 | java-di-json-encode 256 | ] 257 | } 258 | { 259 | playBranch: master 260 | playRevisionRange: 03893d8..b42ca75 261 | playRevisionSampling: 1.0 262 | appsBranch: apps-master-6 263 | appsRevision: HEAD 264 | testNames: [ 265 | scala-di-simple, 266 | scala-di-download-50k, 267 | scala-di-download-chunked-50k, 268 | scala-di-template-simple, 269 | scala-di-template-lang, 270 | scala-di-json-encode, 271 | java-di-simple, 272 | java-di-download-50k, 273 | java-di-download-chunked-50k, 274 | java-di-template-simple, 275 | java-di-template-lang, 276 | java-di-json-encode 277 | ] 278 | } 279 | { 280 | playBranch: master 281 | playRevisionRange: 9333a5a..b5482b5 # Start when updated apps branch is available 282 | playRevisionSampling: 1.0 283 | appsBranch: apps-master-5 284 | appsRevision: HEAD 285 | testNames: [ 286 | scala-di-simple, 287 | scala-di-download-50k, 288 | scala-di-download-chunked-50k, 289 | scala-di-template-simple, 290 | scala-di-template-lang, 291 | scala-di-json-encode, 292 | java-di-simple, 293 | java-di-download-50k, 294 | java-di-download-chunked-50k, 295 | java-di-template-simple, 296 | java-di-template-lang, 297 | java-di-json-encode 298 | ] 299 | } 300 | { 301 | playBranch: master 302 | playRevisionRange: 7a2ca6f..73a0953 # Start when updated apps branch is available 303 | playRevisionSampling: 1.0 304 | appsBranch: apps-master-4 305 | appsRevision: HEAD 306 | testNames: [ 307 | scala-di-simple, 308 | scala-di-download-50k, 309 | scala-di-download-chunked-50k, 310 | scala-di-template-simple, 311 | scala-di-template-lang, 312 | scala-di-json-encode, 313 | java-di-simple, 314 | java-di-download-50k, 315 | java-di-download-chunked-50k, 316 | java-di-template-simple, 317 | java-di-template-lang, 318 | java-di-json-encode 319 | ] 320 | } 321 | { 322 | playBranch: master 323 | # Start at merge-base of master and 2.4.x, end when new updated apps branch is available 324 | playRevisionRange: b57cb94..16f4041 325 | playRevisionSampling: 1.0 326 | appsBranch: apps-master-3 327 | appsRevision: HEAD 328 | testNames: [ 329 | scala-download-chunked-50k, 330 | scala-template-simple, 331 | scala-template-lang, 332 | scala-json-encode, 333 | scala-di-simple, 334 | scala-di-download-50k, 335 | java-simple, 336 | java-download-50k, 337 | java-download-chunked-50k, 338 | java-template-simple, 339 | java-template-lang, 340 | java-json-encode, 341 | ] 342 | } 343 | { 344 | playBranch: 2.5.x 345 | playRevisionRange: 69c0647..HEAD 346 | playRevisionSampling: 1.0 347 | appsBranch: apps-master-6 348 | appsRevision: HEAD 349 | testNames: [ 350 | scala-di-simple, 351 | scala-di-download-50k, 352 | scala-di-download-chunked-50k, 353 | scala-di-template-simple, 354 | scala-di-template-lang, 355 | scala-di-json-encode, 356 | java-di-simple, 357 | java-di-download-50k, 358 | java-di-download-chunked-50k, 359 | java-di-template-simple, 360 | java-di-template-lang, 361 | java-di-json-encode 362 | ] 363 | } 364 | ] 365 | 366 | tests: [ 367 | 368 | # 369 | # Scala non-dependency-injection tests 370 | # 371 | { 372 | name: scala-simple 373 | description: "Serve a small plain text response" 374 | app: scala-bench 375 | wrkArgs: ["-s/wrk_report.lua", "/simple"] 376 | } 377 | { 378 | name: scala-download-50k 379 | description: "Serve an empty 50K response" 380 | app: scala-bench 381 | wrkArgs: ["-s/wrk_report.lua", "/download/51200"] 382 | } 383 | { 384 | name: scala-download-chunked-50k 385 | description: "Serve an empty 50K response in 4K chunks" 386 | app: scala-bench 387 | wrkArgs: ["-s/wrk_report.lua", "/download-chunked/51200"] 388 | } 389 | { 390 | name: scala-template-simple 391 | description: "Serve a template that takes a title argument" 392 | app: scala-bench 393 | wrkArgs: ["-s/wrk_report.lua", "/template/simple"] 394 | } 395 | { 396 | name: scala-template-lang 397 | description: "Serve a template that takes a language argument" 398 | app: scala-bench 399 | wrkArgs: ["-s/wrk_report.lua", "/template/lang"] 400 | } 401 | { 402 | name: scala-json-encode 403 | description: "Serve a small JSON message" 404 | app: scala-bench 405 | wrkArgs: ["-s/wrk_report.lua", "/json/encode"] 406 | } 407 | 408 | # 409 | # Scala dependency injection tests 410 | # 411 | { 412 | name: scala-di-simple 413 | description: "Serve a small plain text response" 414 | app: scala-di-bench 415 | wrkArgs: ["-s/wrk_report.lua", "/simple"] 416 | } 417 | { 418 | name: scala-di-simple-post 419 | description: "Receives a POST request an serve a small plain text response" 420 | app: scala-di-bench 421 | wrkArgs: ["-s/wrk_post.lua", "/simpleForm"] 422 | } 423 | { 424 | name: scala-di-download-50k 425 | description: "Serve an empty 50K response" 426 | app: scala-di-bench 427 | wrkArgs: ["-s/wrk_report.lua", "/download/51200"] 428 | } 429 | { 430 | name: scala-di-download-chunked-50k 431 | description: "Serve an empty 50K response in 4K chunks" 432 | app: scala-di-bench 433 | wrkArgs: ["-s/wrk_report.lua", "/download-chunked/51200"] 434 | } 435 | { 436 | name: scala-di-upload-raw-1m 437 | description: "Consume a text 1m upload" 438 | app: scala-di-bench 439 | wrkArgs: ["-s/wrk_upload.lua", "/uploadRaw"] 440 | } 441 | { 442 | name: scala-di-upload-1m 443 | description: "Consume a text 1m upload" 444 | app: scala-di-bench 445 | wrkArgs: ["-s/wrk_upload.lua", "/upload"] 446 | } 447 | { 448 | name: scala-di-template-simple 449 | description: "Serve a template that takes a title argument" 450 | app: scala-di-bench 451 | wrkArgs: ["-s/wrk_report.lua", "/template/simple"] 452 | } 453 | { 454 | name: scala-di-template-lang 455 | description: "Serve a template that takes a language argument" 456 | app: scala-di-bench 457 | wrkArgs: ["-s/wrk_report.lua", "/template/lang"] 458 | } 459 | { 460 | name: scala-di-json-encode 461 | description: "Serve a small JSON message" 462 | app: scala-di-bench 463 | wrkArgs: ["-s/wrk_report.lua", "/json/encode"] 464 | } 465 | 466 | # 467 | # Scala Netty tests 468 | # 469 | { 470 | name: scala-netty-simple 471 | description: "Serve a small plain text response" 472 | app: scala-netty-bench 473 | wrkArgs: ["-s/wrk_report.lua", "/simple"] 474 | } 475 | { 476 | name: scala-netty-simple-post 477 | description: "Receives a POST request an serve a small plain text response" 478 | app: scala-netty-bench 479 | wrkArgs: ["-s/wrk_post.lua", "/simpleForm"] 480 | } 481 | { 482 | name: scala-netty-download-50k 483 | description: "Serve an empty 50K response" 484 | app: scala-netty-bench 485 | wrkArgs: ["-s/wrk_report.lua", "/download/51200"] 486 | } 487 | { 488 | name: scala-netty-download-chunked-50k 489 | description: "Serve an empty 50K response in 4K chunks" 490 | app: scala-netty-bench 491 | wrkArgs: ["-s/wrk_report.lua", "/download-chunked/51200"] 492 | } 493 | { 494 | name: scala-netty-upload-raw-1m 495 | description: "Consume a text 1m upload" 496 | app: scala-netty-bench 497 | wrkArgs: ["-s/wrk_upload.lua", "/uploadRaw"] 498 | } 499 | { 500 | name: scala-netty-upload-1m 501 | description: "Consume a text 1m upload" 502 | app: scala-netty-bench 503 | wrkArgs: ["-s/wrk_upload.lua", "/upload"] 504 | } 505 | { 506 | name: scala-netty-template-simple 507 | description: "Serve a template that takes a title argument" 508 | app: scala-netty-bench 509 | wrkArgs: ["-s/wrk_report.lua", "/template/simple"] 510 | } 511 | { 512 | name: scala-netty-template-lang 513 | description: "Serve a template that takes a language argument" 514 | app: scala-netty-bench 515 | wrkArgs: ["-s/wrk_report.lua", "/template/lang"] 516 | } 517 | { 518 | name: scala-netty-json-encode 519 | description: "Serve a small JSON message" 520 | app: scala-netty-bench 521 | wrkArgs: ["-s/wrk_report.lua", "/json/encode"] 522 | } 523 | 524 | # 525 | # Minimal Scala app tests - no default filters, etc 526 | # 527 | { 528 | name: scala-minimal-simple 529 | description: "Serve a small plain text response" 530 | app: scala-minimal-bench 531 | wrkArgs: ["-s/wrk_report.lua", "/simple"] 532 | } 533 | { 534 | name: scala-minimal-simple-post 535 | description: "Receives a POST request an serve a small plain text response" 536 | app: scala-minimal-bench 537 | wrkArgs: ["-s/wrk_post.lua", "/simpleForm"] 538 | } 539 | { 540 | name: scala-minimal-download-50k 541 | description: "Serve an empty 50K response" 542 | app: scala-minimal-bench 543 | wrkArgs: ["-s/wrk_report.lua", "/download/51200"] 544 | } 545 | { 546 | name: scala-minimal-download-chunked-50k 547 | description: "Serve an empty 50K response in 4K chunks" 548 | app: scala-minimal-bench 549 | wrkArgs: ["-s/wrk_report.lua", "/download-chunked/51200"] 550 | } 551 | { 552 | name: scala-minimal-upload-raw-1m 553 | description: "Consume a text 1m upload" 554 | app: scala-minimal-bench 555 | wrkArgs: ["-s/wrk_upload.lua", "/uploadRaw"] 556 | } 557 | { 558 | name: scala-minimal-upload-1m 559 | description: "Consume a text 1m upload" 560 | app: scala-minimal-bench 561 | wrkArgs: ["-s/wrk_upload.lua", "/upload"] 562 | } 563 | { 564 | name: scala-minimal-template-simple 565 | description: "Serve a template that takes a title argument" 566 | app: scala-minimal-bench 567 | wrkArgs: ["-s/wrk_report.lua", "/template/simple"] 568 | } 569 | { 570 | name: scala-minimal-template-lang 571 | description: "Serve a template that takes a language argument" 572 | app: scala-minimal-bench 573 | wrkArgs: ["-s/wrk_report.lua", "/template/lang"] 574 | } 575 | { 576 | name: scala-minimal-json-encode 577 | description: "Serve a small JSON message" 578 | app: scala-minimal-bench 579 | wrkArgs: ["-s/wrk_report.lua", "/json/encode"] 580 | } 581 | 582 | # 583 | # Java non-dependency-injection tests 584 | # 585 | { 586 | name: java-simple 587 | description: "Serve a small plain text response" 588 | app: java-bench 589 | wrkArgs: ["-s/wrk_report.lua", "/simple"] 590 | } 591 | { 592 | name: java-download-50k 593 | description: "Serve an empty 50K response" 594 | app: java-bench 595 | wrkArgs: ["-s/wrk_report.lua", "/download/51200"] 596 | } 597 | { 598 | name: java-download-chunked-50k 599 | description: "Serve an empty 50K response in 4K chunks" 600 | app: java-bench 601 | wrkArgs: ["-s/wrk_report.lua", "/download-chunked/51200"] 602 | } 603 | { 604 | name: java-template-simple 605 | description: "Serve a template that takes a title argument" 606 | app: java-bench 607 | wrkArgs: ["-s/wrk_report.lua", "/template/simple"] 608 | } 609 | { 610 | name: java-template-lang 611 | description: "Serve a template that takes a language argument" 612 | app: java-bench 613 | wrkArgs: ["-s/wrk_report.lua", "/template/lang"] 614 | } 615 | { 616 | name: java-json-encode 617 | description: "Serve a small JSON message" 618 | app: java-bench 619 | wrkArgs: ["-s/wrk_report.lua", "/json/encode"] 620 | } 621 | { 622 | name: java-json-encode-streaming 623 | description: "Serve a small JSON message using Jackson's Streaming API" 624 | app: java-bench 625 | wrkArgs: ["-s/wrk_report.lua", "/json/encode/streaming"] 626 | } 627 | 628 | # 629 | # Java dependency injection tests 630 | # 631 | { 632 | name: java-di-simple 633 | description: "Serve a small plain text response" 634 | app: java-di-bench 635 | wrkArgs: ["-s/wrk_report.lua", "/simple"] 636 | } 637 | { 638 | name: java-di-simple-post 639 | description: "Receives a POST request an serve a small plain text response" 640 | app: java-di-bench 641 | wrkArgs: ["-s/wrk_post.lua", "/simpleForm"] 642 | } 643 | { 644 | name: java-di-download-50k 645 | description: "Serve an empty 50K response" 646 | app: java-di-bench 647 | wrkArgs: ["-s/wrk_report.lua", "/download/51200"] 648 | } 649 | { 650 | name: java-di-download-chunked-50k 651 | description: "Serve an empty 50K response in 4K chunks" 652 | app: java-di-bench 653 | wrkArgs: ["-s/wrk_report.lua", "/download-chunked/51200"] 654 | } 655 | { 656 | name: java-di-upload-raw-1m 657 | description: "Consume a text 1m upload" 658 | app: java-di-bench 659 | wrkArgs: ["-s/wrk_upload.lua", "/uploadRaw"] 660 | } 661 | { 662 | name: java-di-upload-1m 663 | description: "Consume a text 1m upload" 664 | app: java-di-bench 665 | wrkArgs: ["-s/wrk_upload.lua", "/upload"] 666 | } 667 | { 668 | name: java-di-template-simple 669 | description: "Serve a template that takes a title argument" 670 | app: java-di-bench 671 | wrkArgs: ["-s/wrk_report.lua", "/template/simple"] 672 | } 673 | { 674 | name: java-di-template-lang 675 | description: "Serve a template that takes a language argument" 676 | app: java-di-bench 677 | wrkArgs: ["-s/wrk_report.lua", "/template/lang"] 678 | } 679 | { 680 | name: java-di-json-encode 681 | description: "Serve a small JSON message" 682 | app: java-di-bench 683 | wrkArgs: ["-s/wrk_report.lua", "/json/encode"] 684 | } 685 | { 686 | name: java-di-json-encode-streaming 687 | description: "Serve a small JSON message using Jackson's Streaming API" 688 | app: java-di-bench 689 | wrkArgs: ["-s/wrk_report.lua", "/json/encode/streaming"] 690 | } 691 | 692 | # 693 | # Java Netty tests 694 | # 695 | { 696 | name: java-netty-simple 697 | description: "Serve a small plain text response" 698 | app: java-netty-bench 699 | wrkArgs: ["-s/wrk_report.lua", "/simple"] 700 | } 701 | { 702 | name: java-netty-simple-post 703 | description: "Receives a POST request an serve a small plain text response" 704 | app: java-netty-bench 705 | wrkArgs: ["-s/wrk_post.lua", "/simpleForm"] 706 | } 707 | { 708 | name: java-netty-download-50k 709 | description: "Serve an empty 50K response" 710 | app: java-netty-bench 711 | wrkArgs: ["-s/wrk_report.lua", "/download/51200"] 712 | } 713 | { 714 | name: java-netty-download-chunked-50k 715 | description: "Serve an empty 50K response in 4K chunks" 716 | app: java-netty-bench 717 | wrkArgs: ["-s/wrk_report.lua", "/download-chunked/51200"] 718 | } 719 | { 720 | name: java-netty-upload-raw-1m 721 | description: "Consume a text 1m upload" 722 | app: java-netty-bench 723 | wrkArgs: ["-s/wrk_upload.lua", "/uploadRaw"] 724 | } 725 | { 726 | name: java-netty-upload-1m 727 | description: "Consume a text 1m upload" 728 | app: java-netty-bench 729 | wrkArgs: ["-s/wrk_upload.lua", "/upload"] 730 | } 731 | { 732 | name: java-netty-template-simple 733 | description: "Serve a template that takes a title argument" 734 | app: java-netty-bench 735 | wrkArgs: ["-s/wrk_report.lua", "/template/simple"] 736 | } 737 | { 738 | name: java-netty-template-lang 739 | description: "Serve a template that takes a language argument" 740 | app: java-netty-bench 741 | wrkArgs: ["-s/wrk_report.lua", "/template/lang"] 742 | } 743 | { 744 | name: java-netty-json-encode 745 | description: "Serve a small JSON message" 746 | app: java-netty-bench 747 | wrkArgs: ["-s/wrk_report.lua", "/json/encode"] 748 | } 749 | { 750 | name: java-netty-json-encode-streaming 751 | description: "Serve a small JSON message using Jackson's Streaming API" 752 | app: java-netty-bench 753 | wrkArgs: ["-s/wrk_report.lua", "/json/encode/streaming"] 754 | } 755 | 756 | # 757 | # Java Minimal tests 758 | # 759 | { 760 | name: java-minimal-simple 761 | description: "Serve a small plain text response" 762 | app: java-minimal-bench 763 | wrkArgs: ["-s/wrk_report.lua", "/simple"] 764 | } 765 | { 766 | name: java-minimal-simple-post 767 | description: "Receives a POST request an serve a small plain text response" 768 | app: java-minimal-bench 769 | wrkArgs: ["-s/wrk_post.lua", "/simpleForm"] 770 | } 771 | { 772 | name: java-minimal-download-50k 773 | description: "Serve an empty 50K response" 774 | app: java-minimal-bench 775 | wrkArgs: ["-s/wrk_report.lua", "/download/51200"] 776 | } 777 | { 778 | name: java-minimal-download-chunked-50k 779 | description: "Serve an empty 50K response in 4K chunks" 780 | app: java-minimal-bench 781 | wrkArgs: ["-s/wrk_report.lua", "/download-chunked/51200"] 782 | } 783 | { 784 | name: java-minimal-upload-raw-1m 785 | description: "Consume a text 1m upload" 786 | app: java-minimal-bench 787 | wrkArgs: ["-s/wrk_upload.lua", "/uploadRaw"] 788 | } 789 | { 790 | name: java-minimal-upload-1m 791 | description: "Consume a text 1m upload" 792 | app: java-minimal-bench 793 | wrkArgs: ["-s/wrk_upload.lua", "/upload"] 794 | } 795 | { 796 | name: java-minimal-template-simple 797 | description: "Serve a template that takes a title argument" 798 | app: java-minimal-bench 799 | wrkArgs: ["-s/wrk_report.lua", "/template/simple"] 800 | } 801 | { 802 | name: java-minimal-template-lang 803 | description: "Serve a template that takes a language argument" 804 | app: java-minimal-bench 805 | wrkArgs: ["-s/wrk_report.lua", "/template/lang"] 806 | } 807 | { 808 | name: java-minimal-json-encode 809 | description: "Serve a small JSON message" 810 | app: java-minimal-bench 811 | wrkArgs: ["-s/wrk_report.lua", "/json/encode"] 812 | } 813 | { 814 | name: java-minimal-json-encode-streaming 815 | description: "Serve a small JSON message using Jackson's Streaming API" 816 | app: java-minimal-bench 817 | wrkArgs: ["-s/wrk_report.lua", "/json/encode/streaming"] 818 | } 819 | 820 | ] 821 | 822 | testShutdown: 30 seconds 823 | 824 | wrk { 825 | warmupTime: 30 seconds 826 | testTime: 2 minutes 827 | connections: 32 828 | threads: 16 829 | } 830 | 831 | yourkit { 832 | home: ${com.typesafe.play.prune.home}/yourkit 833 | testTime: 20 seconds 834 | wrkDelayPaddingTime: 10 seconds 835 | javaOpts: ["-agentpath:=sessionname=#session.name#,dir=/snapshots,logdir=/logs,delay=#delay#,onlylocal,sampling,alloceach=20,allocsampled,monitors"] 836 | } 837 | 838 | jsonReport: { 839 | duration: 300 days 840 | playBranches: [master, 2.5.x, 2.4.x] 841 | } 842 | 843 | } -------------------------------------------------------------------------------- /src/main/resources/com/typesafe/play/prune/assets/50k.bin: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------