├── .github └── dependabot.yml ├── .gitignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── Scientist4JCore ├── pom.xml └── src │ ├── main │ └── java │ │ └── com │ │ └── github │ │ └── rawls238 │ │ └── scientist4j │ │ ├── Experiment.java │ │ ├── ExperimentBuilder.java │ │ ├── IncompatibleTypesExperiment.java │ │ ├── IncompatibleTypesExperimentResult.java │ │ ├── Observation.java │ │ ├── Result.java │ │ ├── exceptions │ │ ├── LaboratoryException.java │ │ └── MismatchException.java │ │ └── metrics │ │ ├── DropwizardMetricsProvider.java │ │ ├── MetricsProvider.java │ │ └── MicrometerMetricsProvider.java │ └── test │ └── java │ └── com │ └── github │ └── rawls238 │ └── scientist4j │ ├── ExperimentAsyncCandidateOnlyTest.java │ ├── ExperimentAsyncTest.java │ ├── ExperimentTest.java │ ├── IncompatibleTypesExperimentAsyncTest.java │ ├── TestPublishExperiment.java │ ├── TestPublishIncompatibleTypesExperiment.java │ └── metrics │ └── NoopMetricsProvider.java └── pom.xml /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "maven" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | target/ 3 | *.iml 4 | .idea/ 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: java 2 | jdk: 3 | - oraclejdk8 4 | sudo: false 5 | install: true 6 | dist: trusty 7 | script: mvn clean test 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The Scientist4J package is licensed under the MIT "Expat" License: 2 | 3 | > Copyright (c) 2016: Guy Aridor. 4 | > 5 | > Permission is hereby granted, free of charge, to any person obtaining 6 | > a copy of this software and associated documentation files (the 7 | > "Software"), to deal in the Software without restriction, including 8 | > without limitation the rights to use, copy, modify, merge, publish, 9 | > distribute, sublicense, and/or sell copies of the Software, and to 10 | > permit persons to whom the Software is furnished to do so, subject to 11 | > the following conditions: 12 | > 13 | > The above copyright notice and this permission notice shall be 14 | > included in all copies or substantial portions of the Software. 15 | > 16 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | > EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | > MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | > IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | > CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | > TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | > SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scientist4J 2 | 3 | *This project is no longer actively maintained.* 4 | 5 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.github.rawls238/Scientist4J/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.github.rawls238/Scientist4J) 6 | 7 | A port of Github's refactoring tool [Scientist](https://github.com/github/scientist) in Java 8 | 9 | # Installation 10 | 11 | ```xml 12 | 13 | com.github.rawls238 14 | Scientist4JCore 15 | 1.0 16 | 17 | ``` 18 | # Usage 19 | 20 | This Java port supports most of the functionality of the original Scientist library in Ruby, however its interface is a bit different. 21 | 22 | The core component of this library is the `Experiment` class. It's recommended to use this class as a Singleton. The main usage is as follows: 23 | 24 | ## Basic Usage 25 | 26 | You can either run a synchronous experiment or an asynchronous experiment. 27 | 28 | For a synchronous experiment, the order in which control and candidate functions are run is randomized. 29 | 30 | To run a synchronous experiment: 31 | 32 | ```java 33 | Experiment e = new Experiment("foo"); 34 | e.run(this::controlFunction, this::candidateFunction); 35 | ``` 36 | 37 | For an asynchronous experiment, the two functions are run asynchronously. 38 | 39 | To run an asynchronous experiment: 40 | 41 | ```java 42 | Experiment e = new Experiment("foo"); 43 | e.runAsync(this::controlFunction, this::candidateFunction); 44 | ``` 45 | 46 | There could be a situation where you want to continue to perform the work of existing functions on the same thread and run an experiment on a different thread.For example, if you deploy a web application with Tomcat Tomcat has its own thread pool, 47 | and you don't want the experiment to affect existing functionality. 48 | 49 | To run control on the same thread but different for candidate: 50 | ```java 51 | Experiment e = new Experiment("foo"); 52 | e.runAsyncCandidateOnly(this::controlFunction, this::candidateFunction); 53 | ``` 54 | 55 | Behind the scenes the following occurs in both cases: 56 | * It decides whether or not to run the candidate function 57 | * Measures the durations of all behaviors 58 | * Compares the result of the two 59 | * Swallows (but records) any exceptions raised by the candidate 60 | * Publishes all this information. 61 | 62 | 63 | ## Metrics 64 | 65 | Scientist4J ships with support for two common metrics libraries—[Dropwizard metrics](https://dropwizard.github.io/metrics/) 66 | and [Micrometer](https://micrometer.io). As each of these is optional, you’ll need to add your choice as an explicit dependency to your project: 67 | 68 | ```xml 69 | 70 | io.dropwizard.metrics5 71 | metrics-core 72 | 73 | ``` 74 | or 75 | ```xml 76 | 77 | io.micrometer 78 | micrometer-core 79 | 80 | ``` 81 | 82 | The following metrics are reported, with the form `scientist.[experiment name].*`: 83 | 84 | * duration of default (control) behavior in ns 85 | * duration of candidate behavior in ns 86 | * counter of total number of users going through the codepath 87 | * counter of number of mismatches 88 | * counter of candidate exceptions 89 | 90 | You may also implement your own `MetricsProvider`, to meet your specific needs. 91 | 92 | ## Optional Configuration 93 | 94 | Users can optionally override the following functions: 95 | 96 | * `publish` (to publish results of an experiment, if you want to supplement the `MetricsProvider`’s publishing mechanism) 97 | * `compareResults` (by default this library just uses `equals` between objects for equality, but in case you want to special case equality between objects) 98 | * `enabled` (to limit what % of users get exposed to the new code path - by default it's 100%) 99 | * `runIf` (to enforce conditional behavior on who should be exposed to the new code path) 100 | * `isAsync` (force using the async for legacy code or move to `runAsync` method) 101 | 102 | 103 | License: MIT 104 | -------------------------------------------------------------------------------- /Scientist4JCore/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | 6 | com.github.rawls238 7 | Scientist4J 8 | 1.0 9 | 10 | Scientist4JCore 11 | 12 | 13 | 14 | io.dropwizard.metrics5 15 | metrics-core 16 | 5.0.0 17 | true 18 | 19 | 20 | io.micrometer 21 | micrometer-core 22 | 1.11.5 23 | true 24 | 25 | 26 | 27 | junit 28 | junit 29 | 4.13.2 30 | test 31 | 32 | 33 | org.assertj 34 | assertj-core 35 | 3.24.2 36 | test 37 | 38 | 39 | org.mockito 40 | mockito-core 41 | 5.5.0 42 | test 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /Scientist4JCore/src/main/java/com/github/rawls238/scientist4j/Experiment.java: -------------------------------------------------------------------------------- 1 | package com.github.rawls238.scientist4j; 2 | 3 | import com.github.rawls238.scientist4j.exceptions.MismatchException; 4 | import com.github.rawls238.scientist4j.metrics.MetricsProvider; 5 | 6 | import java.util.HashMap; 7 | import java.util.Map; 8 | import java.util.Objects; 9 | import java.util.Optional; 10 | import java.util.concurrent.Callable; 11 | import java.util.concurrent.ExecutionException; 12 | import java.util.concurrent.ExecutorService; 13 | import java.util.concurrent.Executors; 14 | import java.util.concurrent.Future; 15 | import java.util.function.BiFunction; 16 | 17 | public class Experiment { 18 | 19 | private final ExecutorService executor; 20 | private static final String NAMESPACE_PREFIX = "scientist"; 21 | private final MetricsProvider metricsProvider; 22 | private final String name; 23 | private final boolean raiseOnMismatch; 24 | private Map context; 25 | private final MetricsProvider.Timer controlTimer; 26 | private final MetricsProvider.Timer candidateTimer; 27 | private final MetricsProvider.Counter mismatchCount; 28 | private final MetricsProvider.Counter candidateExceptionCount; 29 | private final MetricsProvider.Counter totalCount; 30 | private final BiFunction comparator; 31 | 32 | public Experiment(MetricsProvider metricsProvider) { 33 | this("Experiment", metricsProvider); 34 | } 35 | 36 | public Experiment(String name, MetricsProvider metricsProvider) { 37 | this(name, false, metricsProvider); 38 | } 39 | 40 | public Experiment(String name, Map context, MetricsProvider metricsProvider) { 41 | this(name, context, false, metricsProvider); 42 | } 43 | 44 | public Experiment(String name, boolean raiseOnMismatch, MetricsProvider metricsProvider) { 45 | this(name, new HashMap<>(), raiseOnMismatch, metricsProvider); 46 | } 47 | 48 | public Experiment(String name, Map context, boolean raiseOnMismatch, MetricsProvider metricsProvider) { 49 | this(name, context, raiseOnMismatch, metricsProvider, Objects::equals); 50 | } 51 | 52 | public Experiment(String name, Map context, boolean raiseOnMismatch, 53 | MetricsProvider metricsProvider, BiFunction comparator) { 54 | this(name, context, raiseOnMismatch, metricsProvider, comparator, Executors.newFixedThreadPool(2)); 55 | } 56 | 57 | public Experiment(String name, Map context, boolean raiseOnMismatch, 58 | MetricsProvider metricsProvider, BiFunction comparator, 59 | ExecutorService executorService) { 60 | this.name = name; 61 | this.context = context; 62 | this.raiseOnMismatch = raiseOnMismatch; 63 | this.comparator = comparator; 64 | this.metricsProvider = metricsProvider; 65 | controlTimer = getMetricsProvider().timer(NAMESPACE_PREFIX, this.name, "control"); 66 | candidateTimer = getMetricsProvider().timer(NAMESPACE_PREFIX, this.name, "candidate"); 67 | mismatchCount = getMetricsProvider().counter(NAMESPACE_PREFIX, this.name, "mismatch"); 68 | candidateExceptionCount = getMetricsProvider().counter(NAMESPACE_PREFIX, this.name, "candidate.exception"); 69 | totalCount = getMetricsProvider().counter(NAMESPACE_PREFIX, this.name, "total"); 70 | executor = executorService; 71 | } 72 | 73 | /** 74 | * Allow override here if extending the class 75 | */ 76 | public MetricsProvider getMetricsProvider() { 77 | return this.metricsProvider; 78 | } 79 | 80 | /** 81 | * Note that if {@code raiseOnMismatch} is true, {@link #runAsync(Callable, Callable)} will block waiting for 82 | * the candidate function to complete before it can raise any resulting errors. In situations where the candidate 83 | * function may be significantly slower than the control, it is not recommended to raise on mismatch. 84 | */ 85 | public boolean getRaiseOnMismatch() { 86 | return raiseOnMismatch; 87 | } 88 | 89 | public String getName() { 90 | return name; 91 | } 92 | 93 | public T run(Callable control, Callable candidate) throws Exception { 94 | if (isAsyncCandidateOnly()) { 95 | return runAsyncCandidateOnly(control, candidate); 96 | } else if (isAsync()) { 97 | return runAsync(control, candidate); 98 | } else { 99 | return runSync(control, candidate); 100 | } 101 | } 102 | 103 | private T runSync(Callable control, Callable candidate) throws Exception { 104 | Observation controlObservation; 105 | Optional> candidateObservation = Optional.empty(); 106 | if (Math.random() < 0.5) { 107 | controlObservation = executeResult("control", controlTimer, control, true); 108 | if (runIf() && enabled()) { 109 | candidateObservation = Optional.of(executeResult("candidate", candidateTimer, candidate, false)); 110 | } 111 | } else { 112 | if (runIf() && enabled()) { 113 | candidateObservation = Optional.of(executeResult("candidate", candidateTimer, candidate, false)); 114 | } 115 | controlObservation = executeResult("control", controlTimer, control, true); 116 | } 117 | 118 | countExceptions(candidateObservation, candidateExceptionCount); 119 | Result result = new Result(this, controlObservation, candidateObservation, context); 120 | publish(result); 121 | return controlObservation.getValue(); 122 | } 123 | 124 | public T runAsync(Callable control, Callable candidate) throws Exception { 125 | Future>> observationFutureCandidate; 126 | Future> observationFutureControl; 127 | 128 | if (runIf() && enabled()) { 129 | if (Math.random() < 0.5) { 130 | observationFutureControl = executor.submit(() -> executeResult("control", controlTimer, control, true)); 131 | observationFutureCandidate = executor.submit(() -> Optional.of(executeResult("candidate", candidateTimer, candidate, false))); 132 | } else { 133 | observationFutureCandidate = executor.submit(() -> Optional.of(executeResult("candidate", candidateTimer, candidate, false))); 134 | observationFutureControl = executor.submit(() -> executeResult("control", controlTimer, control, true)); 135 | } 136 | } else { 137 | observationFutureControl = executor.submit(() -> executeResult("control", controlTimer, control, true)); 138 | observationFutureCandidate = null; 139 | } 140 | 141 | Observation controlObservation; 142 | try { 143 | controlObservation = observationFutureControl.get(); 144 | } catch (InterruptedException | ExecutionException e) { 145 | throw new RuntimeException(e); 146 | } 147 | 148 | Future publishedResult = executor.submit(() -> publishAsync(controlObservation, observationFutureCandidate)); 149 | 150 | if (raiseOnMismatch) { 151 | try { 152 | publishedResult.get(); 153 | } catch (ExecutionException e) { 154 | throw (Exception) e.getCause(); 155 | } 156 | } 157 | 158 | return controlObservation.getValue(); 159 | } 160 | 161 | public T runAsyncCandidateOnly(Callable control, Callable candidate) throws Exception { 162 | Future>> observationFutureCandidate; 163 | Observation controlObservation; 164 | 165 | if (runIf() && enabled()) { 166 | if (Math.random() < 0.5) { 167 | observationFutureCandidate = executor.submit(() -> Optional.of(executeResult("candidate", candidateTimer, candidate, false))); 168 | controlObservation = executeResult("control", controlTimer, control, true); 169 | } else { 170 | controlObservation = executeResult("control", controlTimer, control, true); 171 | observationFutureCandidate = executor.submit(() -> Optional.of(executeResult("candidate", candidateTimer, candidate, false))); 172 | } 173 | } else { 174 | controlObservation = executeResult("control", controlTimer, control, true); 175 | observationFutureCandidate = null; 176 | } 177 | 178 | Future publishedResult = executor.submit(() -> publishAsync(controlObservation, observationFutureCandidate)); 179 | 180 | if (raiseOnMismatch) { 181 | try { 182 | publishedResult.get(); 183 | } catch (ExecutionException e) { 184 | throw (Exception) e.getCause(); 185 | } 186 | } 187 | 188 | return controlObservation.getValue(); 189 | } 190 | 191 | private Void publishAsync(Observation controlObservation, Future>> observationFutureCandidate) throws Exception { 192 | Optional> candidateObservation = Optional.empty(); 193 | if (observationFutureCandidate != null) { 194 | candidateObservation = observationFutureCandidate.get(); 195 | } 196 | 197 | countExceptions(candidateObservation, candidateExceptionCount); 198 | Result result = new Result<>(this, controlObservation, candidateObservation, context); 199 | publish(result); 200 | return null; 201 | } 202 | 203 | private void countExceptions(Optional> observation, MetricsProvider.Counter exceptions) { 204 | if (observation.isPresent() && observation.get().getException().isPresent()) { 205 | exceptions.increment(); 206 | } 207 | } 208 | 209 | public Observation executeResult(String name, MetricsProvider.Timer timer, Callable control, boolean shouldThrow) throws Exception { 210 | Observation observation = new Observation<>(name, timer); 211 | 212 | observation.time(() -> { 213 | try { 214 | observation.setValue(control.call()); 215 | } catch (Exception e) { 216 | observation.setException(e); 217 | } 218 | }); 219 | 220 | if (shouldThrow && observation.getException().isPresent()) { 221 | throw observation.getException().get(); 222 | } 223 | 224 | return observation; 225 | } 226 | 227 | protected boolean compareResults(T controlVal, T candidateVal) { 228 | return comparator.apply(controlVal, candidateVal); 229 | } 230 | 231 | public boolean compare(Observation controlVal, Observation candidateVal) throws MismatchException { 232 | boolean resultsMatch = !candidateVal.getException().isPresent() && compareResults(controlVal.getValue(), candidateVal.getValue()); 233 | totalCount.increment(); 234 | if (!resultsMatch) { 235 | mismatchCount.increment(); 236 | handleComparisonMismatch(controlVal, candidateVal); 237 | } 238 | return true; 239 | } 240 | 241 | protected void publish(Result r) { 242 | } 243 | 244 | protected boolean runIf() { 245 | return true; 246 | } 247 | 248 | protected boolean enabled() { 249 | return true; 250 | } 251 | 252 | protected boolean isAsync() { 253 | return false; 254 | } 255 | 256 | protected boolean isAsyncCandidateOnly() { 257 | return false; 258 | } 259 | 260 | private void handleComparisonMismatch(Observation controlVal, Observation candidateVal) throws MismatchException { 261 | String msg; 262 | if (candidateVal.getException().isPresent()) { 263 | String stackTrace = candidateVal.getException().get().getStackTrace().toString(); 264 | String exceptionName = candidateVal.getException().get().getClass().getName(); 265 | msg = new StringBuilder().append(candidateVal.getName()).append(" raised an exception: ") 266 | .append(exceptionName).append(" ").append(stackTrace).toString(); 267 | } else { 268 | msg = new StringBuilder().append(candidateVal.getName()).append(" does not match control value (") 269 | .append(controlVal.getValue().toString()).append(" != ").append(candidateVal.getValue().toString()).append(")").toString(); 270 | } 271 | throw new MismatchException(msg); 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /Scientist4JCore/src/main/java/com/github/rawls238/scientist4j/ExperimentBuilder.java: -------------------------------------------------------------------------------- 1 | package com.github.rawls238.scientist4j; 2 | 3 | import com.github.rawls238.scientist4j.metrics.MetricsProvider; 4 | 5 | import java.util.HashMap; 6 | import java.util.Map; 7 | import java.util.concurrent.ExecutorService; 8 | import java.util.function.BiFunction; 9 | 10 | public class ExperimentBuilder { 11 | private String name; 12 | private MetricsProvider metricsProvider; 13 | private BiFunction comparator; 14 | private Map context; 15 | private ExecutorService executorService; 16 | 17 | public ExperimentBuilder() { 18 | context = new HashMap<>(); 19 | comparator = Object::equals; 20 | } 21 | 22 | public ExperimentBuilder withName(final String name) { 23 | this.name = name; 24 | return this; 25 | } 26 | 27 | public ExperimentBuilder withMetricsProvider(final MetricsProvider metricsProvider) { 28 | this.metricsProvider = metricsProvider; 29 | return this; 30 | } 31 | 32 | public ExperimentBuilder withComparator(final BiFunction comparator) { 33 | this.comparator = comparator; 34 | return this; 35 | } 36 | 37 | public ExperimentBuilder withExecutorService(ExecutorService executorService) { 38 | this.executorService = executorService; 39 | return this; 40 | } 41 | 42 | public Experiment build() { 43 | assert name != null; 44 | assert metricsProvider != null; 45 | return new Experiment<>(name, context, false, metricsProvider, comparator, 46 | executorService); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Scientist4JCore/src/main/java/com/github/rawls238/scientist4j/IncompatibleTypesExperiment.java: -------------------------------------------------------------------------------- 1 | package com.github.rawls238.scientist4j; 2 | 3 | import com.github.rawls238.scientist4j.exceptions.MismatchException; 4 | import com.github.rawls238.scientist4j.metrics.MetricsProvider; 5 | 6 | import java.util.Arrays; 7 | import java.util.HashMap; 8 | import java.util.Map; 9 | import java.util.Optional; 10 | import java.util.concurrent.*; 11 | import java.util.function.BiPredicate; 12 | 13 | /** 14 | * An Experiment that can handle a control and candidate function that return incompatible types. 15 | *

16 | * Note that this requires a comparator function to be passed in to the constructor because the existing default of 17 | * Objects::equals doesn't make any sense when the objects are of different types. 18 | * 19 | * @param The return type of the control function. 20 | * @param The return type of the candidate function. 21 | */ 22 | public class IncompatibleTypesExperiment { 23 | private static final String CONTROL = "control"; 24 | private static final String CANDIDATE = "candidate"; 25 | private final ExecutorService executor; 26 | private static final String NAMESPACE_PREFIX = "scientist"; 27 | private final MetricsProvider metricsProvider; 28 | private final String name; 29 | private final boolean raiseOnMismatch; 30 | private final Map context; 31 | private final MetricsProvider.Timer controlTimer; 32 | private final MetricsProvider.Timer candidateTimer; 33 | private final MetricsProvider.Counter mismatchCount; 34 | private final MetricsProvider.Counter candidateExceptionCount; 35 | private final MetricsProvider.Counter totalCount; 36 | private final BiPredicate comparator; 37 | 38 | public IncompatibleTypesExperiment(final MetricsProvider metricsProvider, final BiPredicate comparator) { 39 | this("Experiment", metricsProvider, comparator); 40 | } 41 | 42 | public IncompatibleTypesExperiment(final String name, final MetricsProvider metricsProvider, 43 | final BiPredicate comparator) { 44 | this(name, false, metricsProvider, comparator); 45 | } 46 | 47 | public IncompatibleTypesExperiment(final String name, final Map context, 48 | final MetricsProvider metricsProvider, final BiPredicate comparator) { 49 | this(name, context, false, metricsProvider, comparator); 50 | } 51 | 52 | public IncompatibleTypesExperiment(final String name, final boolean raiseOnMismatch, 53 | final MetricsProvider metricsProvider, final BiPredicate comparator) { 54 | this(name, new HashMap<>(), raiseOnMismatch, metricsProvider, comparator); 55 | } 56 | 57 | public IncompatibleTypesExperiment(final String name, final Map context, 58 | final boolean raiseOnMismatch, final MetricsProvider metricsProvider, final BiPredicate comparator) { 59 | this(name, context, raiseOnMismatch, metricsProvider, comparator, Executors.newFixedThreadPool(2)); 60 | } 61 | 62 | public IncompatibleTypesExperiment(final String name, final Map context, 63 | final boolean raiseOnMismatch, final MetricsProvider metricsProvider, final BiPredicate comparator, 64 | final ExecutorService executorService) { 65 | this.name = name; 66 | this.context = context; 67 | this.raiseOnMismatch = raiseOnMismatch; 68 | this.comparator = comparator; 69 | this.metricsProvider = metricsProvider; 70 | controlTimer = getMetricsProvider().timer(NAMESPACE_PREFIX, this.name, CONTROL); 71 | candidateTimer = getMetricsProvider().timer(NAMESPACE_PREFIX, this.name, CANDIDATE); 72 | mismatchCount = getMetricsProvider().counter(NAMESPACE_PREFIX, this.name, "mismatch"); 73 | candidateExceptionCount = getMetricsProvider().counter(NAMESPACE_PREFIX, this.name, "candidate.exception"); 74 | totalCount = getMetricsProvider().counter(NAMESPACE_PREFIX, this.name, "total"); 75 | executor = executorService; 76 | } 77 | 78 | /** 79 | * Allow override here if extending the class 80 | */ 81 | public MetricsProvider getMetricsProvider() { 82 | return this.metricsProvider; 83 | } 84 | 85 | /** 86 | * Note that if {@code raiseOnMismatch} is true, {@link #runAsync(Callable, Callable)} will block waiting for 87 | * the candidate function to complete before it can raise any resulting errors. In situations where the candidate 88 | * function may be significantly slower than the control, it is not recommended to raise on mismatch. 89 | */ 90 | public boolean getRaiseOnMismatch() { 91 | return raiseOnMismatch; 92 | } 93 | 94 | public String getName() { 95 | return name; 96 | } 97 | 98 | public T run(final Callable control, final Callable candidate) throws Exception { 99 | if (isAsync()) { 100 | return runAsync(control, candidate); 101 | } else { 102 | return runSync(control, candidate); 103 | } 104 | } 105 | 106 | private T runSync(final Callable control, final Callable candidate) throws Exception { 107 | Observation controlObservation; 108 | Optional> candidateObservation = Optional.empty(); 109 | if (Math.random() < 0.5) { 110 | controlObservation = executeResult(CONTROL, controlTimer, control, true); 111 | if (runIf() && enabled()) { 112 | candidateObservation = Optional.of(executeResult(CANDIDATE, candidateTimer, candidate, false)); 113 | } 114 | } else { 115 | if (runIf() && enabled()) { 116 | candidateObservation = Optional.of(executeResult(CANDIDATE, candidateTimer, candidate, false)); 117 | } 118 | controlObservation = executeResult(CONTROL, controlTimer, control, true); 119 | } 120 | 121 | countExceptions(candidateObservation, candidateExceptionCount); 122 | IncompatibleTypesExperimentResult result = 123 | new IncompatibleTypesExperimentResult<>(this, controlObservation, candidateObservation, context); 124 | publish(result); 125 | return controlObservation.getValue(); 126 | } 127 | 128 | public T runAsync(final Callable control, final Callable candidate) throws Exception { 129 | Future>> observationFutureCandidate; 130 | Future> observationFutureControl; 131 | 132 | if (runIf() && enabled()) { 133 | if (Math.random() < 0.5) { 134 | observationFutureControl = 135 | executor.submit(() -> executeResult(CONTROL, controlTimer, control, true)); 136 | observationFutureCandidate = executor.submit( 137 | () -> Optional.of(executeResult(CANDIDATE, candidateTimer, candidate, false))); 138 | } else { 139 | observationFutureCandidate = executor.submit( 140 | () -> Optional.of(executeResult(CANDIDATE, candidateTimer, candidate, false))); 141 | observationFutureControl = 142 | executor.submit(() -> executeResult(CONTROL, controlTimer, control, true)); 143 | } 144 | } else { 145 | observationFutureControl = executor.submit(() -> executeResult(CONTROL, controlTimer, control, true)); 146 | observationFutureCandidate = null; 147 | } 148 | 149 | Observation controlObservation; 150 | try { 151 | controlObservation = observationFutureControl.get(); 152 | } catch (InterruptedException e) { 153 | Thread.currentThread().interrupt(); 154 | throw new RuntimeException(e); 155 | } catch (ExecutionException e) { 156 | throw new RuntimeException(e); 157 | } 158 | 159 | Future publishedResult = 160 | executor.submit(() -> publishAsync(controlObservation, observationFutureCandidate)); 161 | 162 | if (raiseOnMismatch) { 163 | try { 164 | publishedResult.get(); 165 | } catch (ExecutionException e) { 166 | throw (Exception) e.getCause(); 167 | } 168 | } 169 | 170 | return controlObservation.getValue(); 171 | } 172 | 173 | private Void publishAsync(final Observation controlObservation, 174 | final Future>> observationFutureCandidate) throws Exception { 175 | Optional> candidateObservation = Optional.empty(); 176 | if (observationFutureCandidate != null) { 177 | candidateObservation = observationFutureCandidate.get(); 178 | } 179 | 180 | countExceptions(candidateObservation, candidateExceptionCount); 181 | IncompatibleTypesExperimentResult result = 182 | new IncompatibleTypesExperimentResult<>(this, controlObservation, candidateObservation, context); 183 | publish(result); 184 | return null; 185 | } 186 | 187 | private void countExceptions(final Optional> observation, final MetricsProvider.Counter exceptions) { 188 | if (observation.isPresent() && observation.get().getException().isPresent()) { 189 | exceptions.increment(); 190 | } 191 | } 192 | 193 | public Observation executeResult(final String name, final MetricsProvider.Timer timer, 194 | final Callable control, final boolean shouldThrow) throws Exception { 195 | Observation observation = new Observation<>(name, timer); 196 | 197 | observation.time(() -> 198 | { 199 | try { 200 | observation.setValue(control.call()); 201 | } catch (Exception e) { 202 | observation.setException(e); 203 | } 204 | }); 205 | 206 | Optional exception = observation.getException(); 207 | if (shouldThrow && exception.isPresent()) { 208 | throw exception.get(); 209 | } 210 | 211 | return observation; 212 | } 213 | 214 | protected boolean compareResults(final T controlVal, final U candidateVal) { 215 | return this.comparator.test(controlVal, candidateVal); 216 | } 217 | 218 | public boolean compare(final Observation controlVal, final Observation candidateVal) 219 | throws MismatchException { 220 | boolean resultsMatch = !candidateVal.getException().isPresent() && 221 | compareResults(controlVal.getValue(), candidateVal.getValue()); 222 | totalCount.increment(); 223 | if (!resultsMatch) { 224 | mismatchCount.increment(); 225 | handleComparisonMismatch(controlVal, candidateVal); 226 | } 227 | return true; 228 | } 229 | 230 | protected void publish(final IncompatibleTypesExperimentResult result) { 231 | } 232 | 233 | protected boolean runIf() { 234 | return true; 235 | } 236 | 237 | protected boolean enabled() { 238 | return true; 239 | } 240 | 241 | protected boolean isAsync() { 242 | return false; 243 | } 244 | 245 | private void handleComparisonMismatch(final Observation controlVal, final Observation candidateVal) 246 | throws MismatchException { 247 | String msg; 248 | Optional exception = candidateVal.getException(); 249 | if (exception.isPresent()) { 250 | String stackTrace = Arrays.toString(exception.get().getStackTrace()); 251 | String exceptionName = exception.get().getClass().getName(); 252 | msg = candidateVal.getName() + " raised an exception: " + exceptionName + " " + stackTrace; 253 | } else { 254 | msg = 255 | candidateVal.getName() + " does not match control value (" + controlVal.getValue().toString() + " != " + 256 | candidateVal.getValue().toString() + ")"; 257 | } 258 | throw new MismatchException(msg); 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /Scientist4JCore/src/main/java/com/github/rawls238/scientist4j/IncompatibleTypesExperimentResult.java: -------------------------------------------------------------------------------- 1 | package com.github.rawls238.scientist4j; 2 | 3 | import com.github.rawls238.scientist4j.exceptions.MismatchException; 4 | 5 | import java.util.Map; 6 | import java.util.Optional; 7 | 8 | /** 9 | * @param The return type of the control function 10 | * @param The return type of the candidate function. 11 | */ 12 | public class IncompatibleTypesExperimentResult { 13 | private final Observation control; 14 | private final Optional> candidate; 15 | private Optional match; 16 | private final Map context; 17 | 18 | public IncompatibleTypesExperimentResult(final IncompatibleTypesExperiment experiment, final Observation control, 19 | final Optional> candidate, final Map context) throws MismatchException { 20 | this.control = control; 21 | this.candidate = candidate; 22 | this.context = context; 23 | this.match = Optional.empty(); 24 | 25 | if (candidate.isPresent()) { 26 | try { 27 | this.match = Optional.of(experiment.compare(control, candidate.get())); 28 | } catch (MismatchException e) { 29 | this.match = Optional.of(false); 30 | throw e; 31 | } 32 | } 33 | } 34 | 35 | public Optional getMatch() { 36 | return match; 37 | } 38 | 39 | public Observation getControl() { 40 | return control; 41 | } 42 | 43 | public Optional> getCandidate() { 44 | return candidate; 45 | } 46 | 47 | public Map getContext() { 48 | return context; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Scientist4JCore/src/main/java/com/github/rawls238/scientist4j/Observation.java: -------------------------------------------------------------------------------- 1 | package com.github.rawls238.scientist4j; 2 | 3 | import com.github.rawls238.scientist4j.metrics.MetricsProvider.Timer; 4 | 5 | import java.util.Optional; 6 | 7 | public class Observation { 8 | 9 | private String name; 10 | private Optional exception; 11 | private T value; 12 | private Timer timer; 13 | 14 | public Observation(String name, Timer timer) { 15 | this.name = name; 16 | this.timer = timer; 17 | this.exception = Optional.empty(); 18 | } 19 | 20 | public String getName() { 21 | return name; 22 | } 23 | 24 | public void setValue(T o) { 25 | this.value = o; 26 | } 27 | 28 | public T getValue() { 29 | return value; 30 | } 31 | 32 | public void setException(Exception e) { 33 | this.exception = Optional.of(e); 34 | } 35 | 36 | public Optional getException() { 37 | return exception; 38 | } 39 | 40 | public long getDuration() { 41 | return timer.getDuration(); 42 | } 43 | 44 | public void time(Runnable runnable) { 45 | timer.record(runnable); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Scientist4JCore/src/main/java/com/github/rawls238/scientist4j/Result.java: -------------------------------------------------------------------------------- 1 | package com.github.rawls238.scientist4j; 2 | 3 | import com.github.rawls238.scientist4j.exceptions.MismatchException; 4 | 5 | import java.util.Map; 6 | import java.util.Optional; 7 | 8 | public class Result { 9 | private Experiment experiment; 10 | private Observation control; 11 | private Optional> candidate; 12 | private Optional match; 13 | private Map context; 14 | 15 | public Result(Experiment experiment, Observation control, Optional> candidate, Map context) throws MismatchException { 16 | this.experiment = experiment; 17 | this.control = control; 18 | this.candidate = candidate; 19 | this.context = context; 20 | this.match = Optional.empty(); 21 | 22 | if (candidate.isPresent()) { 23 | Optional ex = Optional.empty(); 24 | try { 25 | this.match = Optional.of(experiment.compare(control, candidate.get())); 26 | } catch (MismatchException e) { 27 | ex = Optional.of(e); 28 | this.match = Optional.of(false); 29 | } finally { 30 | if (experiment.getRaiseOnMismatch() && ex.isPresent()) { 31 | throw ex.get(); 32 | } 33 | } 34 | } 35 | } 36 | 37 | public Optional getMatch() { 38 | return match; 39 | } 40 | 41 | public Observation getControl() { 42 | return control; 43 | } 44 | 45 | public Optional> getCandidate() { 46 | return candidate; 47 | } 48 | 49 | public Map getContext() { 50 | return context; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Scientist4JCore/src/main/java/com/github/rawls238/scientist4j/exceptions/LaboratoryException.java: -------------------------------------------------------------------------------- 1 | package com.github.rawls238.scientist4j.exceptions; 2 | 3 | public class LaboratoryException extends Exception { 4 | public LaboratoryException(String msg) { 5 | super(msg); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Scientist4JCore/src/main/java/com/github/rawls238/scientist4j/exceptions/MismatchException.java: -------------------------------------------------------------------------------- 1 | package com.github.rawls238.scientist4j.exceptions; 2 | 3 | public class MismatchException extends LaboratoryException { 4 | public MismatchException(String msg) { 5 | super(msg); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Scientist4JCore/src/main/java/com/github/rawls238/scientist4j/metrics/DropwizardMetricsProvider.java: -------------------------------------------------------------------------------- 1 | package com.github.rawls238.scientist4j.metrics; 2 | 3 | import io.dropwizard.metrics5.MetricRegistry; 4 | import io.dropwizard.metrics5.Timer.Context; 5 | 6 | import java.util.Arrays; 7 | 8 | public class DropwizardMetricsProvider implements MetricsProvider { 9 | 10 | private MetricRegistry registry; 11 | 12 | public DropwizardMetricsProvider() { 13 | this(new MetricRegistry()); 14 | } 15 | 16 | public DropwizardMetricsProvider(MetricRegistry metricRegistry) { 17 | this.registry = metricRegistry; 18 | } 19 | 20 | @Override 21 | public Timer timer(String... nameComponents) { 22 | final io.dropwizard.metrics5.Timer timer = registry.timer(MetricRegistry.name(nameComponents[0], Arrays.copyOfRange(nameComponents, 1, nameComponents.length))); 23 | 24 | return new Timer() { 25 | 26 | long duration; 27 | 28 | @Override 29 | public void record(Runnable runnable) { 30 | 31 | final Context context = timer.time(); 32 | 33 | try { 34 | runnable.run(); 35 | } finally { 36 | duration = context.stop(); 37 | } 38 | } 39 | 40 | @Override 41 | public long getDuration() { 42 | return duration; 43 | } 44 | }; 45 | } 46 | 47 | @Override 48 | public Counter counter(String... nameComponents) { 49 | 50 | final io.dropwizard.metrics5.Counter counter = registry.counter(MetricRegistry.name(nameComponents[0], Arrays.copyOfRange(nameComponents, 1, nameComponents.length))); 51 | 52 | return new Counter() { 53 | 54 | @Override 55 | public void increment() { 56 | counter.inc(); 57 | } 58 | }; 59 | } 60 | 61 | @Override 62 | public MetricRegistry getRegistry() { 63 | return this.registry; 64 | } 65 | 66 | @Override 67 | public void setRegistry(MetricRegistry registry) { 68 | this.registry = registry; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Scientist4JCore/src/main/java/com/github/rawls238/scientist4j/metrics/MetricsProvider.java: -------------------------------------------------------------------------------- 1 | package com.github.rawls238.scientist4j.metrics; 2 | 3 | public interface MetricsProvider { 4 | 5 | Timer timer(String... nameComponents); 6 | 7 | Counter counter(String... nameComponents); 8 | 9 | interface Timer { 10 | 11 | void record(Runnable runnable); 12 | 13 | /** 14 | * The duration recorded by this timer 15 | * 16 | * @return timer duration in nanoseconds 17 | */ 18 | long getDuration(); 19 | } 20 | 21 | interface Counter { 22 | 23 | void increment(); 24 | } 25 | 26 | T getRegistry(); 27 | 28 | void setRegistry(T registry); 29 | } 30 | -------------------------------------------------------------------------------- /Scientist4JCore/src/main/java/com/github/rawls238/scientist4j/metrics/MicrometerMetricsProvider.java: -------------------------------------------------------------------------------- 1 | package com.github.rawls238.scientist4j.metrics; 2 | 3 | import io.micrometer.core.instrument.MeterRegistry; 4 | import io.micrometer.core.instrument.simple.SimpleMeterRegistry; 5 | 6 | import java.util.concurrent.TimeUnit; 7 | 8 | public class MicrometerMetricsProvider implements MetricsProvider { 9 | 10 | private MeterRegistry registry; 11 | 12 | public MicrometerMetricsProvider() { 13 | this(new SimpleMeterRegistry()); 14 | } 15 | 16 | public MicrometerMetricsProvider(MeterRegistry meterRegistry) { 17 | this.registry = meterRegistry; 18 | } 19 | 20 | @Override 21 | public Timer timer(String... nameComponents) { 22 | 23 | final io.micrometer.core.instrument.Timer timer = io.micrometer.core.instrument.Timer.builder(String.join(".", nameComponents)).register(this.registry); 24 | 25 | return new Timer() { 26 | @Override 27 | public void record(Runnable runnable) { 28 | timer.record(runnable); 29 | } 30 | 31 | @Override 32 | public long getDuration() { 33 | return (long)timer.totalTime(TimeUnit.NANOSECONDS); 34 | } 35 | }; 36 | } 37 | 38 | @Override 39 | public Counter counter(String... nameComponents) { 40 | 41 | final io.micrometer.core.instrument.Counter counter = io.micrometer.core.instrument.Counter.builder(String.join(".", nameComponents)).register(this.registry); 42 | 43 | return new Counter() { 44 | @Override 45 | public void increment() { 46 | counter.increment(); 47 | } 48 | }; 49 | } 50 | 51 | @Override 52 | public MeterRegistry getRegistry() { 53 | return registry; 54 | } 55 | 56 | @Override 57 | public void setRegistry(MeterRegistry registry) { 58 | this.registry = registry; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Scientist4JCore/src/test/java/com/github/rawls238/scientist4j/ExperimentAsyncCandidateOnlyTest.java: -------------------------------------------------------------------------------- 1 | package com.github.rawls238.scientist4j; 2 | 3 | import com.github.rawls238.scientist4j.exceptions.MismatchException; 4 | import com.github.rawls238.scientist4j.metrics.NoopMetricsProvider; 5 | import org.junit.Test; 6 | 7 | import java.util.Date; 8 | import java.util.concurrent.Callable; 9 | import java.util.concurrent.Executors; 10 | import java.util.concurrent.ThreadFactory; 11 | 12 | import static org.assertj.core.api.Assertions.assertThat; 13 | 14 | public class ExperimentAsyncCandidateOnlyTest { 15 | 16 | private Integer exceptionThrowingFunction() { 17 | throw new RuntimeException("throw an exception"); 18 | } 19 | 20 | private Integer sleepFunction() { 21 | try { 22 | Thread.sleep(1001); 23 | } catch (InterruptedException e) { 24 | e.printStackTrace(); 25 | Thread.currentThread().interrupt(); 26 | } 27 | return 3; 28 | } 29 | 30 | private Integer shortSleepFunction() { 31 | try { 32 | Thread.sleep(101); 33 | } catch (InterruptedException e) { 34 | e.printStackTrace(); 35 | Thread.currentThread().interrupt(); 36 | } 37 | return 3; 38 | } 39 | 40 | private Integer safeFunction() { 41 | return 3; 42 | } 43 | 44 | private Integer safeFunctionWithDifferentResult() { 45 | return 4; 46 | } 47 | 48 | @Test 49 | public void itThrowsAnExceptionWhenControlFails() { 50 | Experiment experiment = new Experiment<>("test", new NoopMetricsProvider()); 51 | boolean controlThrew = false; 52 | try { 53 | experiment.runAsyncCandidateOnly(this::exceptionThrowingFunction, this::exceptionThrowingFunction); 54 | } catch (RuntimeException e) { 55 | controlThrew = true; 56 | } catch (Exception e) { 57 | 58 | } 59 | assertThat(controlThrew).isEqualTo(true); 60 | } 61 | 62 | @Test 63 | public void itDoesntThrowAnExceptionWhenCandidateFails() { 64 | Experiment experiment = new Experiment<>("test", new NoopMetricsProvider()); 65 | boolean candidateThrew = false; 66 | Integer val = 0; 67 | try { 68 | val = experiment.runAsyncCandidateOnly(this::safeFunction, this::exceptionThrowingFunction); 69 | } catch (Exception e) { 70 | candidateThrew = true; 71 | } 72 | assertThat(candidateThrew).isEqualTo(false); 73 | assertThat(val).isEqualTo(3); 74 | } 75 | 76 | @Test 77 | public void itThrowsOnMismatch() { 78 | Experiment experiment = new Experiment<>("test", true, new NoopMetricsProvider()); 79 | boolean candidateThrew = false; 80 | try { 81 | experiment.runAsyncCandidateOnly(this::safeFunction, this::safeFunctionWithDifferentResult); 82 | } catch (MismatchException e) { 83 | candidateThrew = true; 84 | } catch (Exception e) { 85 | 86 | } 87 | 88 | assertThat(candidateThrew).isEqualTo(true); 89 | } 90 | 91 | @Test 92 | public void itDoesNotThrowOnMatch() { 93 | Experiment exp = new Experiment<>("test", true, new NoopMetricsProvider()); 94 | boolean candidateThrew = false; 95 | Integer val = 0; 96 | try { 97 | val = exp.runAsync(this::safeFunction, this::safeFunction); 98 | } catch (Exception e) { 99 | candidateThrew = true; 100 | } 101 | 102 | assertThat(val).isEqualTo(3); 103 | assertThat(candidateThrew).isEqualTo(false); 104 | } 105 | 106 | @Test 107 | public void itWorksWithAnExtendedClass() { 108 | Experiment exp = new TestPublishExperiment<>("test", new NoopMetricsProvider()); 109 | try { 110 | exp.run(this::safeFunction, this::safeFunction); 111 | } catch (Exception e) { 112 | 113 | } 114 | } 115 | 116 | @Test 117 | public void asyncRunsFaster() { 118 | Experiment exp = new Experiment<>("test", false, new NoopMetricsProvider()); 119 | boolean candidateThrew = false; 120 | Integer val = 0; 121 | Date date1 = new Date(); 122 | 123 | try { 124 | val = exp.runAsyncCandidateOnly(this::sleepFunction, this::sleepFunction); 125 | } catch (Exception e) { 126 | candidateThrew = true; 127 | } 128 | Date date2 = new Date(); 129 | long difference = date2.getTime() - date1.getTime(); 130 | 131 | assertThat(difference).isLessThan(2000); 132 | assertThat(difference).isGreaterThanOrEqualTo(1000); 133 | assertThat(val).isEqualTo(3); 134 | assertThat(candidateThrew).isEqualTo(false); 135 | } 136 | 137 | @Test 138 | public void controlRunsOnMainThreadCustomExecutorService() throws Exception { 139 | String threadName = "main"; 140 | ThreadFactory threadFactory = runnable -> new Thread(runnable, threadName); 141 | Experiment exp = new ExperimentBuilder() 142 | .withName("test") 143 | .withMetricsProvider(new NoopMetricsProvider()) 144 | .withExecutorService(Executors.newFixedThreadPool(4, threadFactory)) 145 | .build(); 146 | Callable getThreadName = () -> Thread.currentThread().getName(); 147 | 148 | String val = exp.runAsyncCandidateOnly(getThreadName, getThreadName); 149 | 150 | assertThat(val).isEqualTo(threadName); 151 | } 152 | 153 | @Test 154 | public void raiseOnMismatchRunsSlower() throws Exception { 155 | Experiment raisesOnMismatch = new Experiment<>("raise", true, new NoopMetricsProvider()); 156 | Experiment doesNotRaiseOnMismatch = new Experiment<>("does not raise", new NoopMetricsProvider()); 157 | final long raisesExecutionTime = timeExperiment(raisesOnMismatch); 158 | final long doesNotRaiseExecutionTime = timeExperiment(doesNotRaiseOnMismatch); 159 | 160 | assertThat(raisesExecutionTime).isGreaterThan(doesNotRaiseExecutionTime); 161 | assertThat(raisesExecutionTime).isGreaterThan(1000); 162 | assertThat(doesNotRaiseExecutionTime).isLessThan(200); 163 | } 164 | 165 | private long timeExperiment(final Experiment exp) throws Exception { 166 | Date date1 = new Date(); 167 | exp.runAsyncCandidateOnly(this::shortSleepFunction, this::sleepFunction); 168 | Date date2 = new Date(); 169 | return date2.getTime() - date1.getTime(); 170 | } 171 | 172 | } 173 | -------------------------------------------------------------------------------- /Scientist4JCore/src/test/java/com/github/rawls238/scientist4j/ExperimentAsyncTest.java: -------------------------------------------------------------------------------- 1 | package com.github.rawls238.scientist4j; 2 | 3 | import com.github.rawls238.scientist4j.exceptions.MismatchException; 4 | import com.github.rawls238.scientist4j.metrics.NoopMetricsProvider; 5 | import org.junit.Test; 6 | 7 | import java.util.Date; 8 | import java.util.concurrent.Callable; 9 | import java.util.concurrent.Executors; 10 | import java.util.concurrent.ThreadFactory; 11 | 12 | import static org.assertj.core.api.Assertions.assertThat; 13 | 14 | public class ExperimentAsyncTest { 15 | 16 | private Integer exceptionThrowingFunction() { 17 | throw new RuntimeException("throw an exception"); 18 | } 19 | 20 | private Integer sleepFunction() { 21 | try { 22 | Thread.sleep(1001); 23 | } catch (InterruptedException e) { 24 | e.printStackTrace(); 25 | } 26 | return 3; 27 | } 28 | 29 | private Integer shortSleepFunction() { 30 | try { 31 | Thread.sleep(101); 32 | } catch (InterruptedException e) { 33 | e.printStackTrace(); 34 | } 35 | return 3; 36 | } 37 | 38 | private Integer safeFunction() { 39 | return 3; 40 | } 41 | 42 | private Integer safeFunctionWithDifferentResult() { 43 | return 4; 44 | } 45 | 46 | @Test 47 | public void itThrowsAnExceptionWhenControlFails() { 48 | Experiment experiment = new Experiment<>("test", new NoopMetricsProvider()); 49 | boolean controlThrew = false; 50 | try { 51 | experiment.runAsync(this::exceptionThrowingFunction, this::exceptionThrowingFunction); 52 | } catch (RuntimeException e) { 53 | controlThrew = true; 54 | } catch (Exception e) { 55 | 56 | } 57 | assertThat(controlThrew).isEqualTo(true); 58 | } 59 | 60 | @Test 61 | public void itDoesntThrowAnExceptionWhenCandidateFails() { 62 | Experiment experiment = new Experiment<>("test", new NoopMetricsProvider()); 63 | boolean candidateThrew = false; 64 | Integer val = 0; 65 | try { 66 | val = experiment.runAsync(this::safeFunction, this::exceptionThrowingFunction); 67 | } catch (Exception e) { 68 | candidateThrew = true; 69 | } 70 | assertThat(candidateThrew).isEqualTo(false); 71 | assertThat(val).isEqualTo(3); 72 | } 73 | 74 | @Test 75 | public void itThrowsOnMismatch() { 76 | Experiment experiment = new Experiment<>("test", true, new NoopMetricsProvider()); 77 | boolean candidateThrew = false; 78 | try { 79 | experiment.runAsync(this::safeFunction, this::safeFunctionWithDifferentResult); 80 | } catch (MismatchException e) { 81 | candidateThrew = true; 82 | } catch (Exception e) { 83 | 84 | } 85 | 86 | assertThat(candidateThrew).isEqualTo(true); 87 | } 88 | 89 | @Test 90 | public void itDoesNotThrowOnMatch() { 91 | Experiment exp = new Experiment<>("test", true, new NoopMetricsProvider()); 92 | boolean candidateThrew = false; 93 | Integer val = 0; 94 | try { 95 | val = exp.runAsync(this::safeFunction, this::safeFunction); 96 | } catch (Exception e) { 97 | candidateThrew = true; 98 | } 99 | 100 | assertThat(val).isEqualTo(3); 101 | assertThat(candidateThrew).isEqualTo(false); 102 | } 103 | 104 | @Test 105 | public void itWorksWithAnExtendedClass() { 106 | Experiment exp = new TestPublishExperiment<>("test", new NoopMetricsProvider()); 107 | try { 108 | exp.run(this::safeFunction, this::safeFunction); 109 | } catch (Exception e) { 110 | 111 | } 112 | } 113 | 114 | @Test 115 | public void asyncRunsFaster() { 116 | Experiment exp = new Experiment<>("test", true, new NoopMetricsProvider()); 117 | boolean candidateThrew = false; 118 | Integer val = 0; 119 | Date date1 = new Date(); 120 | 121 | try { 122 | val = exp.runAsync(this::sleepFunction, this::sleepFunction); 123 | } catch (Exception e) { 124 | candidateThrew = true; 125 | } 126 | Date date2 = new Date(); 127 | long difference = date2.getTime() - date1.getTime(); 128 | 129 | assertThat(difference).isLessThan(2000); 130 | assertThat(difference).isGreaterThanOrEqualTo(1000); 131 | assertThat(val).isEqualTo(3); 132 | assertThat(candidateThrew).isEqualTo(false); 133 | } 134 | 135 | @Test 136 | public void allowsUsingCustomExecutorService() throws Exception { 137 | String threadName = "customThread"; 138 | ThreadFactory threadFactory = runnable -> new Thread(runnable, threadName); 139 | Experiment exp = new ExperimentBuilder() 140 | .withName("test") 141 | .withMetricsProvider(new NoopMetricsProvider()) 142 | .withExecutorService(Executors.newFixedThreadPool(4, threadFactory)) 143 | .build(); 144 | Callable getThreadName = () -> Thread.currentThread().getName(); 145 | 146 | String val = exp.runAsync(getThreadName, getThreadName); 147 | 148 | assertThat(val).isEqualTo(threadName); 149 | } 150 | 151 | @Test 152 | public void raiseOnMismatchRunsSlower() throws Exception { 153 | Experiment raisesOnMismatch = new Experiment<>("raise", true, new NoopMetricsProvider()); 154 | Experiment doesNotRaiseOnMismatch = new Experiment<>("does not raise", new NoopMetricsProvider()); 155 | final long raisesExecutionTime = timeExperiment(raisesOnMismatch); 156 | final long doesNotRaiseExecutionTime = timeExperiment(doesNotRaiseOnMismatch); 157 | 158 | assertThat(raisesExecutionTime).isGreaterThan(doesNotRaiseExecutionTime); 159 | assertThat(raisesExecutionTime).isGreaterThan(1000); 160 | assertThat(doesNotRaiseExecutionTime).isLessThan(200); 161 | } 162 | 163 | private long timeExperiment(final Experiment exp) throws Exception { 164 | Date date1 = new Date(); 165 | exp.runAsync(this::shortSleepFunction, this::sleepFunction); 166 | Date date2 = new Date(); 167 | return date2.getTime() - date1.getTime(); 168 | } 169 | 170 | } 171 | -------------------------------------------------------------------------------- /Scientist4JCore/src/test/java/com/github/rawls238/scientist4j/ExperimentTest.java: -------------------------------------------------------------------------------- 1 | package com.github.rawls238.scientist4j; 2 | 3 | import com.github.rawls238.scientist4j.exceptions.MismatchException; 4 | import com.github.rawls238.scientist4j.metrics.DropwizardMetricsProvider; 5 | import com.github.rawls238.scientist4j.metrics.MicrometerMetricsProvider; 6 | import com.github.rawls238.scientist4j.metrics.NoopMetricsProvider; 7 | import io.dropwizard.metrics5.Counter; 8 | import io.dropwizard.metrics5.MetricName; 9 | import org.junit.Test; 10 | 11 | import java.util.Date; 12 | import java.util.function.BiFunction; 13 | 14 | import static org.assertj.core.api.Assertions.assertThat; 15 | import static org.mockito.Mockito.*; 16 | 17 | public class ExperimentTest { 18 | 19 | private Integer exceptionThrowingFunction() { 20 | throw new ExpectingAnException("throw an exception"); 21 | } 22 | 23 | private Integer safeFunction() { 24 | return 3; 25 | } 26 | 27 | private Integer sleepFunction() { 28 | try { 29 | Thread.sleep(1001); 30 | } catch (InterruptedException e) { 31 | e.printStackTrace(); 32 | } 33 | return 3; 34 | } 35 | 36 | private Integer safeFunctionWithDifferentResult() { 37 | return 4; 38 | } 39 | 40 | @Test(expected = ExpectingAnException.class) 41 | public void itThrowsAnExceptionWhenControlFails() throws Exception { 42 | new Experiment("test", new NoopMetricsProvider()) 43 | .run(this::exceptionThrowingFunction, this::exceptionThrowingFunction); 44 | } 45 | 46 | @Test 47 | public void itDoesntThrowAnExceptionWhenCandidateFails() throws Exception { 48 | Experiment experiment = new Experiment<>("test", new NoopMetricsProvider()); 49 | Integer val = experiment.run(this::safeFunction, this::exceptionThrowingFunction); 50 | assertThat(val).isEqualTo(3); 51 | } 52 | 53 | @Test(expected = MismatchException.class) 54 | public void itThrowsOnMismatch() throws Exception { 55 | new Experiment("test", true, new NoopMetricsProvider()) 56 | .run(this::safeFunction, this::safeFunctionWithDifferentResult); 57 | } 58 | 59 | @Test 60 | public void itDoesNotThrowOnMatch() throws Exception { 61 | Integer val = new Experiment("test", true, new NoopMetricsProvider()) 62 | .run(this::safeFunction, this::safeFunction); 63 | 64 | assertThat(val).isEqualTo(3); 65 | } 66 | 67 | @Test 68 | public void itHandlesNullValues() throws Exception { 69 | Integer val = new Experiment("test", true, new NoopMetricsProvider()) 70 | .run(() -> null, () -> null); 71 | 72 | assertThat(val).isNull(); 73 | } 74 | 75 | @Test 76 | public void nonAsyncRunsLongTime() throws Exception { 77 | Experiment exp = new Experiment<>("test", true, new NoopMetricsProvider()); 78 | Date date1 = new Date(); 79 | Integer val = exp.run(this::sleepFunction, this::sleepFunction); 80 | Date date2 = new Date(); 81 | long difference = date2.getTime() - date1.getTime(); 82 | 83 | assertThat(difference).isGreaterThanOrEqualTo(2000); 84 | assertThat(val).isEqualTo(3); 85 | } 86 | 87 | @Test 88 | public void itWorksWithAnExtendedClass() throws Exception { 89 | Experiment exp = new TestPublishExperiment<>("test", new NoopMetricsProvider()); 90 | exp.run(this::safeFunction, this::safeFunction); 91 | } 92 | 93 | @Test 94 | public void candidateExceptionsAreCounted_dropwizard() throws Exception { 95 | final DropwizardMetricsProvider provider = new DropwizardMetricsProvider(); 96 | Experiment exp = new Experiment<>("test", provider); 97 | 98 | exp.run(() -> 1, this::exceptionThrowingFunction); 99 | 100 | Counter result = provider.getRegistry().getCounters().get(MetricName.build("scientist", "test", "candidate", "exception")); 101 | assertThat(result.getCount()).isEqualTo(1); 102 | } 103 | 104 | @Test 105 | public void candidateExceptionsAreCounted_micrometer() throws Exception { 106 | final MicrometerMetricsProvider provider = new MicrometerMetricsProvider(); 107 | Experiment exp = new Experiment<>("test", provider); 108 | 109 | exp.run(() -> 1, this::exceptionThrowingFunction); 110 | 111 | io.micrometer.core.instrument.Counter result = provider.getRegistry().get("scientist.test.candidate.exception").counter(); 112 | assertThat(result.count()).isEqualTo(1); 113 | } 114 | 115 | @Test 116 | public void shouldUseCustomComparator() throws Exception { 117 | @SuppressWarnings("unchecked") final BiFunction comparator = mock(BiFunction.class); 118 | when(comparator.apply(1, 2)).thenReturn(false); 119 | final Experiment e = new ExperimentBuilder() 120 | .withName("test") 121 | .withComparator(comparator) 122 | .withMetricsProvider(new NoopMetricsProvider()) 123 | .build(); 124 | 125 | e.run(() -> 1, () -> 2); 126 | 127 | verify(comparator).apply(1, 2); 128 | } 129 | } 130 | 131 | class ExpectingAnException extends RuntimeException { 132 | ExpectingAnException(final String message) { 133 | super(message); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Scientist4JCore/src/test/java/com/github/rawls238/scientist4j/IncompatibleTypesExperimentAsyncTest.java: -------------------------------------------------------------------------------- 1 | package com.github.rawls238.scientist4j; 2 | 3 | import com.github.rawls238.scientist4j.exceptions.MismatchException; 4 | import com.github.rawls238.scientist4j.metrics.NoopMetricsProvider; 5 | import org.junit.Test; 6 | 7 | import java.util.Date; 8 | 9 | import static org.assertj.core.api.Assertions.assertThat; 10 | 11 | public class IncompatibleTypesExperimentAsyncTest { 12 | 13 | private Integer exceptionThrowingFunction() { 14 | throw new RuntimeException("throw an exception"); 15 | } 16 | 17 | private String exceptionThrowingCandidateFunction() { 18 | throw new RuntimeException("throw an exception"); 19 | } 20 | 21 | private Integer sleepFunction() { 22 | try { 23 | Thread.sleep(1001); 24 | } catch (InterruptedException e) { 25 | e.printStackTrace(); 26 | Thread.currentThread().interrupt(); 27 | } 28 | return 3; 29 | } 30 | 31 | private String sleepClandidateFunction() { 32 | try { 33 | Thread.sleep(1001); 34 | } catch (InterruptedException e) { 35 | e.printStackTrace(); 36 | Thread.currentThread().interrupt(); 37 | } 38 | return "3"; 39 | } 40 | 41 | private Integer safeFunction() { 42 | return 3; 43 | } 44 | 45 | private String safeCandidateFunction() { 46 | return "3"; 47 | } 48 | 49 | private String safeCandidateFunctionWithDifferentResult() { 50 | return "4"; 51 | } 52 | 53 | @Test 54 | public void itThrowsAnExceptionWhenControlFails() throws Exception { 55 | IncompatibleTypesExperiment experiment = new IncompatibleTypesExperiment<>("test", 56 | new NoopMetricsProvider(), (integer, s) -> String.valueOf(integer).equals(s)); 57 | boolean controlThrew = false; 58 | try { 59 | experiment.runAsync(this::exceptionThrowingFunction, this::exceptionThrowingCandidateFunction); 60 | } catch (RuntimeException e) { 61 | controlThrew = true; 62 | } 63 | assertThat(controlThrew).isTrue(); 64 | } 65 | 66 | @Test 67 | public void itDoesntThrowAnExceptionWhenCandidateFails() { 68 | IncompatibleTypesExperiment experiment = new IncompatibleTypesExperiment<>("test", 69 | new NoopMetricsProvider(), (integer, s) -> String.valueOf(integer).equals(s)); 70 | boolean candidateThrew = false; 71 | Integer val = 0; 72 | try { 73 | val = experiment.runAsync(this::safeFunction, this::exceptionThrowingCandidateFunction); 74 | } catch (Exception e) { 75 | candidateThrew = true; 76 | } 77 | assertThat(candidateThrew).isFalse(); 78 | assertThat(val).isEqualTo(3); 79 | } 80 | 81 | @Test 82 | public void itThrowsOnMismatch() { 83 | IncompatibleTypesExperiment experiment = new IncompatibleTypesExperiment<>("test", true, 84 | new NoopMetricsProvider(), (integer, s) -> String.valueOf(integer).equals(s)); 85 | boolean candidateThrew = false; 86 | try { 87 | experiment.runAsync(this::safeFunction, this::safeCandidateFunctionWithDifferentResult); 88 | } catch (MismatchException e) { 89 | candidateThrew = true; 90 | } catch (Exception e) { 91 | 92 | } 93 | 94 | assertThat(candidateThrew).isTrue(); 95 | } 96 | 97 | @Test 98 | public void itDoesNotThrowOnMatch() { 99 | IncompatibleTypesExperiment experiment = new IncompatibleTypesExperiment<>("test", 100 | new NoopMetricsProvider(), (integer, s) -> String.valueOf(integer).equals(s)); 101 | boolean candidateThrew = false; 102 | Integer val = 0; 103 | try { 104 | val = experiment.runAsync(this::safeFunction, this::safeCandidateFunction); 105 | } catch (Exception e) { 106 | candidateThrew = true; 107 | } 108 | 109 | assertThat(val).isEqualTo(3); 110 | assertThat(candidateThrew).isFalse(); 111 | } 112 | 113 | @Test 114 | public void itWorksWithAnExtendedClass() { 115 | IncompatibleTypesExperiment exp = new TestPublishIncompatibleTypesExperiment("test", 116 | new NoopMetricsProvider(), (integer, s) -> String.valueOf(integer).equals(s)); 117 | try { 118 | exp.run(this::safeFunction, this::safeCandidateFunction); 119 | } catch (Exception e) { 120 | 121 | } 122 | } 123 | 124 | @Test 125 | public void asyncRunsFaster() { 126 | IncompatibleTypesExperiment exp = new IncompatibleTypesExperiment<>("test", 127 | new NoopMetricsProvider(), (integer, s) -> String.valueOf(integer).equals(s)); 128 | boolean candidateThrew = false; 129 | Integer val = 0; 130 | Date date1 = new Date(); 131 | 132 | try { 133 | val = exp.runAsync(this::sleepFunction, this::sleepClandidateFunction); 134 | } catch (Exception e) { 135 | candidateThrew = true; 136 | } 137 | Date date2 = new Date(); 138 | long difference = date2.getTime() - date1.getTime(); 139 | 140 | assertThat(difference).isLessThan(2000); 141 | assertThat(difference).isGreaterThanOrEqualTo(1000); 142 | assertThat(val).isEqualTo(3); 143 | assertThat(candidateThrew).isFalse(); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /Scientist4JCore/src/test/java/com/github/rawls238/scientist4j/TestPublishExperiment.java: -------------------------------------------------------------------------------- 1 | package com.github.rawls238.scientist4j; 2 | 3 | import com.github.rawls238.scientist4j.metrics.MetricsProvider; 4 | 5 | import static org.assertj.core.api.Assertions.assertThat; 6 | 7 | public class TestPublishExperiment extends Experiment { 8 | TestPublishExperiment(String name, MetricsProvider metricsProvider) { 9 | super(name, metricsProvider); 10 | } 11 | 12 | @Override 13 | protected void publish(Result r) { 14 | assertThat(r.getCandidate().get().getDuration()).isGreaterThan(0L); 15 | assertThat(r.getControl().getDuration()).isGreaterThan(0L); 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /Scientist4JCore/src/test/java/com/github/rawls238/scientist4j/TestPublishIncompatibleTypesExperiment.java: -------------------------------------------------------------------------------- 1 | package com.github.rawls238.scientist4j; 2 | 3 | import com.github.rawls238.scientist4j.metrics.MetricsProvider; 4 | 5 | import java.util.function.BiPredicate; 6 | 7 | import static org.assertj.core.api.Assertions.assertThat; 8 | 9 | public class TestPublishIncompatibleTypesExperiment extends IncompatibleTypesExperiment { 10 | TestPublishIncompatibleTypesExperiment(String name, MetricsProvider metricsProvider, BiPredicate comparator) { 11 | super(name, metricsProvider, comparator); 12 | } 13 | 14 | @Override 15 | protected void publish(IncompatibleTypesExperimentResult result) { 16 | assertThat(result.getCandidate().get().getDuration()).isGreaterThan(0L); 17 | assertThat(result.getControl().getDuration()).isGreaterThan(0L); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Scientist4JCore/src/test/java/com/github/rawls238/scientist4j/metrics/NoopMetricsProvider.java: -------------------------------------------------------------------------------- 1 | package com.github.rawls238.scientist4j.metrics; 2 | 3 | import com.github.rawls238.scientist4j.metrics.MetricsProvider; 4 | 5 | /** 6 | * A minimal in-memory {@link MetricsProvider} implementation, suitable for test environments. 7 | */ 8 | public class NoopMetricsProvider implements MetricsProvider { 9 | 10 | @Override 11 | public Timer timer(String... nameComponents) { 12 | 13 | return new Timer() { 14 | 15 | long duration; 16 | 17 | @Override 18 | public void record(Runnable runnable) { 19 | long now = System.nanoTime(); 20 | runnable.run(); 21 | 22 | duration = System.nanoTime() - now; 23 | } 24 | 25 | @Override 26 | public long getDuration() { 27 | return duration; 28 | } 29 | }; 30 | } 31 | 32 | @Override 33 | public Counter counter(String... nameComponents) { 34 | return () -> { 35 | 36 | }; 37 | } 38 | 39 | @Override 40 | public Object getRegistry() { 41 | return new Object(); 42 | } 43 | 44 | @Override 45 | public void setRegistry(Object registry) { 46 | 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | com.github.rawls238 7 | Scientist4J 8 | 1.0 9 | pom 10 | 11 | Scientist4J 12 | A Java port of Github's Scientist library for refactoring critical code paths 13 | https://www.github.com/rawls238/Scientist4J 14 | 15 | 16 | 1.8 17 | 1.8 18 | 19 | 20 | 21 | 22 | ossrh 23 | https://oss.sonatype.org/content/repositories/snapshots 24 | 25 | 26 | ossrh 27 | https://oss.sonatype.org/service/local/staging/deploy/maven2/ 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | org.apache.maven.plugins 45 | maven-gpg-plugin 46 | 3.1.0 47 | 48 | 49 | sign-artifacts 50 | verify 51 | 52 | sign 53 | 54 | 55 | 56 | 57 | 58 | org.apache.maven.plugins 59 | maven-source-plugin 60 | 3.3.0 61 | 62 | 63 | attach-sources 64 | 65 | jar 66 | 67 | 68 | 69 | 70 | 71 | org.apache.maven.plugins 72 | maven-javadoc-plugin 73 | 3.5.0 74 | 75 | 76 | attach-javadocs 77 | 78 | jar 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | MIT License 89 | http://www.opensource.org/licenses/mit-license.php 90 | 91 | 92 | 93 | 94 | 95 | Guy Aridor 96 | 97 | 98 | 99 | 100 | scm:git:git@github.com:rawls238/Scientist4J.git 101 | scm:git:git@github.com:rawls238/Scientist4J.git 102 | git@github.com:rawls238/Scientist4J.git 103 | 104 | 105 | 106 | Scientist4JCore 107 | 108 | 109 | --------------------------------------------------------------------------------