Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Initial public release

  • Loading branch information...
commit 54fcf8a467a05cf00b5508aad392999186ecd049 0 parents
Josh Wills jwills authored
Showing with 6,924 additions and 0 deletions.
  1. +8 −0 .gitignore
  2. +72 −0 LICENSE.txt
  3. +37 −0 README.md
  4. +77 −0 avro/pom.xml
  5. +216 −0 avro/src/main/avro/experiments.avdl
  6. +363 −0 avro/src/main/java/com/cloudera/gertrude/space/AvroExperimentSpaceDeserializer.java
  7. +138 −0 avro/src/test/java/com/cloudera/gertrude/space/AvroDataUtils.java
  8. +252 −0 avro/src/test/java/com/cloudera/gertrude/space/AvroExperimentSpaceDeserializerTest.java
  9. +67 −0 core/pom.xml
  10. +90 −0 core/src/main/java/com/cloudera/gertrude/AbstractExperimentState.java
  11. +146 −0 core/src/main/java/com/cloudera/gertrude/Condition.java
  12. +43 −0 core/src/main/java/com/cloudera/gertrude/ConditionFactory.java
  13. +107 −0 core/src/main/java/com/cloudera/gertrude/DiversionCriterion.java
  14. +112 −0 core/src/main/java/com/cloudera/gertrude/ExperimentFlag.java
  15. +92 −0 core/src/main/java/com/cloudera/gertrude/ExperimentFlagSettings.java
  16. +103 −0 core/src/main/java/com/cloudera/gertrude/ExperimentHandler.java
  17. +147 −0 core/src/main/java/com/cloudera/gertrude/ExperimentSpace.java
  18. +83 −0 core/src/main/java/com/cloudera/gertrude/ExperimentSpaceDeserializer.java
  19. +74 −0 core/src/main/java/com/cloudera/gertrude/ExperimentSpaceLoader.java
  20. +116 −0 core/src/main/java/com/cloudera/gertrude/ExperimentState.java
  21. +208 −0 core/src/main/java/com/cloudera/gertrude/Experiments.java
  22. +78 −0 core/src/main/java/com/cloudera/gertrude/FlagTypeParser.java
  23. +86 −0 core/src/main/java/com/cloudera/gertrude/FlagValue.java
  24. +33 −0 core/src/main/java/com/cloudera/gertrude/FlagValueCalculator.java
  25. +54 −0 core/src/main/java/com/cloudera/gertrude/Layer.java
  26. +79 −0 core/src/main/java/com/cloudera/gertrude/Segment.java
  27. +191 −0 core/src/main/java/com/cloudera/gertrude/calculate/AssociativeOperator.java
  28. +88 −0 core/src/main/java/com/cloudera/gertrude/calculate/BasicModifier.java
  29. +72 −0 core/src/main/java/com/cloudera/gertrude/calculate/FlagValueCalculatorImpl.java
  30. +59 −0 core/src/main/java/com/cloudera/gertrude/calculate/FlagValueOverride.java
  31. +22 −0 core/src/main/java/com/cloudera/gertrude/calculate/Modifier.java
  32. +62 −0 core/src/main/java/com/cloudera/gertrude/condition/AbstractPropertyCondition.java
  33. +128 −0 core/src/main/java/com/cloudera/gertrude/condition/BooleanConditions.java
  34. +53 −0 core/src/main/java/com/cloudera/gertrude/condition/CompositeConditionFactory.java
  35. +51 −0 core/src/main/java/com/cloudera/gertrude/condition/ReflectionConditionFactory.java
  36. +67 −0 core/src/main/java/com/cloudera/gertrude/space/Domain.java
  37. +91 −0 core/src/main/java/com/cloudera/gertrude/space/ExperimentInfo.java
  38. +199 −0 core/src/main/java/com/cloudera/gertrude/space/ExperimentSpaceBuilder.java
  39. +31 −0 core/src/main/java/com/cloudera/gertrude/space/FlagValueData.java
  40. +181 −0 core/src/main/java/com/cloudera/gertrude/space/LayerBuilder.java
  41. +138 −0 core/src/main/java/com/cloudera/gertrude/space/LayerImpl.java
  42. +103 −0 core/src/main/java/com/cloudera/gertrude/space/LayerInfo.java
  43. +62 −0 core/src/main/java/com/cloudera/gertrude/space/SegmentInfo.java
  44. +56 −0 core/src/test/java/com/cloudera/gertrude/TestCondition.java
  45. +44 −0 core/src/test/java/com/cloudera/gertrude/TestConditionFactory.java
  46. +39 −0 core/src/test/java/com/cloudera/gertrude/TestExperimentSpaceDeserializer.java
  47. +64 −0 core/src/test/java/com/cloudera/gertrude/TestExperimentSpaceLoader.java
  48. +64 −0 core/src/test/java/com/cloudera/gertrude/TestExperimentState.java
  49. +52 −0 core/src/test/java/com/cloudera/gertrude/TestExperiments.java
  50. +51 −0 core/src/test/java/com/cloudera/gertrude/calculate/BasicModifierTest.java
  51. +43 −0 core/src/test/java/com/cloudera/gertrude/calculate/FlagValueCalculatorTest.java
  52. +60 −0 core/src/test/java/com/cloudera/gertrude/calculate/FlagValueOverrideTest.java
  53. +50 −0 core/src/test/java/com/cloudera/gertrude/condition/BooleanConditionsTest.java
  54. +83 −0 core/src/test/java/com/cloudera/gertrude/condition/ConditionsTest.java
  55. +99 −0 core/src/test/java/com/cloudera/gertrude/defaults/DefaultSettingsTest.java
  56. +79 −0 curator/pom.xml
  57. +60 −0 curator/src/main/java/com/cloudera/gertrude/curator/NodeCacheExperimentSpaceLoader.java
  58. +72 −0 curator/src/test/java/com/cloudera/gertrude/curator/NodeCacheExperimentSpaceLoaderTest.java
  59. +121 −0 deploy/pom.xml
  60. +230 −0 deploy/src/main/java/com/cloudera/gertrude/deploy/AvroSupport.java
  61. +54 −0 deploy/src/main/java/com/cloudera/gertrude/deploy/CuratorSupport.java
  62. +66 −0 deploy/src/main/java/com/cloudera/gertrude/deploy/Cyclone.java
  63. +44 −0 deploy/src/test/com/cloudera/gertrude/deploy/DeployTest.java
  64. +178 −0 deploy/src/test/resources/gertrude.conf
  65. +73 −0 file/pom.xml
  66. +72 −0 file/src/main/java/com/cloudera/gertrude/file/FileExperimentSpaceLoader.java
  67. +60 −0 file/src/test/java/com/cloudera/gertrude/file/FileExperimentSpaceLoaderTest.java
  68. +225 −0 pom.xml
  69. +79 −0 server/pom.xml
  70. +54 −0 server/src/main/java/com/cloudera/gertrude/server/ExampleServlet.java
  71. +74 −0 server/src/main/java/com/cloudera/gertrude/server/GertrudeFilter.java
  72. +52 −0 server/src/main/java/com/cloudera/gertrude/server/GertrudeUtils.java
  73. +22 −0 server/src/main/java/com/cloudera/gertrude/server/HttpServletExperimentState.java
  74. +64 −0 server/src/main/java/com/cloudera/gertrude/server/HttpServletExperimentStateImpl.java
  75. +61 −0 server/src/main/java/com/cloudera/gertrude/server/condition/IntHeaderCondition.java
8 .gitignore
@@ -0,0 +1,8 @@
+.classpath
+.project
+.settings
+.cache
+target
+*.iml
+.idea
+gen
72 LICENSE.txt
@@ -0,0 +1,72 @@
+Apache License
+
+Version 2.0, January 2004
+
+http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
+
+"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
+
+"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
+
+"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
+
+"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
+
+"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
+
+"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
+
+"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
+
+"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
+
+"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
+
+You must give any other recipients of the Work or Derivative Works a copy of this License; and
+
+You must cause any modified files to carry prominent notices stating that You changed the files; and
+
+You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
+
+If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work
+To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
37 README.md
@@ -0,0 +1,37 @@
+Gertrude: A Multilayer and Multivariate Experiment Framework for the JVM
+------------------------------------------------------------------------
+
+Gertrude is a Java implementation of the overlapping experiments infrastructure used at
+Google and first described in [Tang et al. (2010)](http://research.google.com/pubs/pub36500.html).
+It is designed to be powerful enough to support the types of experiments that data scientists,
+machine learning researchers, and software engineers need to run when developing _data products_
+(e.g., recommendation engines, search ranking algorithms, and large-scale classifiers), although
+it can also be used for testing new features and UI treatments.
+
+The core of Gertrude is a Java library that allows developers to add _experiment flags_ to their
+code to control the value of certain scalar parameters (booleans, ints, doubles, and strings) and
+an external _configuration file_ that defines rules for setting the values of experiment flags on
+every request to the server based on attributes of the request (such as a user's cookie or anonymous
+login id.)
+
+Gertrude has minimal dependencies and is intended to be used as a component library for production
+servers. The components of the framework are:
+
+* **core**: Core API definitions and experiment diversion logic
+* **avro**: Support for serializing experiment configurations as Apache Avro records
+* **curator**: Support for loading experiment configurations via Apache Curator, a library of
+patterns for Apache Zookeeper
+* **file**: Support for loading experiment configurations from a file that is monitored for changes
+* **server**: Example code for creating core experiment classes and configuring them for use with
+a Java server, a good place to start to see how the framework is used
+* **deploy**: Simple commandline tool for parsing an experiment configuration from a JSON or
+[HOCON](https://github.com/typesafehub/config) file, serializing it as an Avro object, and then
+deploying the serialized object to a Zookeeper node or file.
+
+Gertrude is alpha code and is under active development, and we welcome new contributors. We will
+be co-developing Gertrude with [Oryx](http://github.com/cloudera/oryx), but Gertrude will remain
+a stand-alone library.
+
+Gertrude is named for [Gertrude Cox](http://en.wikipedia.org/wiki/Gertrude_Mary_Cox), the founder of
+the department of Experimental Statistics at North Carolina State University and co-author of one of the classic texts
+in the field, [Experimental Designs](http://www.amazon.com/Experimental-Designs-Edition-William-Cochran/dp/0471545678).
77 avro/pom.xml
@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Copyright (c) 2013, Cloudera, Inc. All Rights Reserved.
+
+ Cloudera, Inc. licenses this file to you under the Apache License,
+ Version 2.0 (the "License"). You may not use this file except in
+ compliance with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ CONDITIONS OF ANY KIND, either express or implied. See the License for
+ the specific language governing permissions and limitations under the
+ License.
+ -->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>com.cloudera.gertrude</groupId>
+ <artifactId>gertrude-parent</artifactId>
+ <version>0.1.0</version>
+ <relativePath>../</relativePath>
+ </parent>
+
+ <artifactId>gertrude-avro</artifactId>
+ <name>Gertrude Experiment Framework Avro Support</name>
+
+ <dependencies>
+ <dependency>
+ <groupId>com.cloudera.gertrude</groupId>
+ <artifactId>gertrude-core</artifactId>
+ </dependency>
+
+ <dependency>
+ <groupId>com.google.guava</groupId>
+ <artifactId>guava</artifactId>
+ </dependency>
+
+ <dependency>
+ <groupId>org.apache.avro</groupId>
+ <artifactId>avro</artifactId>
+ </dependency>
+
+ <dependency>
+ <groupId>com.cloudera.gertrude</groupId>
+ <artifactId>gertrude-core</artifactId>
+ <type>test-jar</type>
+ <scope>test</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-compiler-plugin</artifactId>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.avro</groupId>
+ <artifactId>avro-maven-plugin</artifactId>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-surefire-plugin</artifactId>
+ </plugin>
+ </plugins>
+ </build>
+</project>
+
216 avro/src/main/avro/experiments.avdl
@@ -0,0 +1,216 @@
+/**
+ * Copyright (c) 2013, Cloudera, Inc. All Rights Reserved.
+ *
+ * Cloudera, Inc. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"). You may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for
+ * the specific language governing permissions and limitations under the
+ * License.
+ */
+@namespace("com.cloudera.gertrude.experiments.avro")
+protocol Experiments {
+
+ // Conditions specify the business logic that decide how experiments and parameters
+ // should be configured for a given request.
+ record ConditionDefinition {
+ // The name of the condition function to use. This name must map to a condition
+ // function that is registered with the experiment handler.
+ string name;
+
+ // An optional list of arguments for the condition function.
+ union{array<string>, null} args;
+
+ // Applies the not operator to the result of the condition function call.
+ boolean negate = false;
+ }
+
+ enum ModifierOperator { OVERRIDE, ADD, MULTIPLY }
+ enum ConditionOperator { AND, OR }
+ // Note, INT in this context is a 64-bit integer (also known as a long)
+ enum FlagType { BOOL, INT, DOUBLE, STRING }
+
+ record ModifierDefinition {
+ ModifierOperator operator;
+ string value;
+ union{array<ModifierDefinition>, null} modifiers;
+ union{array<ConditionDefinition>, null} conditions;
+ union{ConditionOperator, null} condition_merge_operator;
+ }
+
+ // Represents a single configurable parameter within
+ // the experiment framework.
+ record ExperimentFlagDefinition {
+ // The name of the flag, which must be unique within a single server configuration.
+ string name;
+
+ // A longer description of the purpose of this flag.
+ string description;
+
+ // The initial value for this flag that is the starting point for
+ // calculations perfomed by the modifiers, as a string.
+ string base_value;
+
+ // The data type of this flag, which is used for converting the base_value and
+ // all modifier values to the appropriate type.
+ FlagType flag_type;
+
+ // A list of modifiers to the base value for this flag. Modifiers define
+ // if-then logic for altering the value of this flag based on attributes
+ // of the request.
+ union{array<ModifierDefinition>, null} modifiers;
+ }
+
+ enum OverrideOperator { REPLACE, APPEND, PREPEND }
+ record OverrideDefinition {
+ // The name of the flag we want to modify.
+ string name;
+
+ // The strategy to use for modifying the experiment flag. By default,
+ // we will append our modifiers to the existing modifiers in the
+ // flag definition for the experiment. We can also replace the
+ // existing modifiers entirely by using the REPLACE operator, or
+ // prefix the existing modifiers with our modifiers by using the
+ // PREPEND operator.
+ OverrideOperator operator = "REPLACE";
+
+ // A new base value to use. Only valid when the operator is REPLACE.
+ union{string, null} base_value;
+
+ // The modifiers that define the changes to the flag
+ // that are defined in this experiment.
+ union{array<ModifierDefinition>, null} modifiers;
+ }
+
+ // For bucket diversions, the BucketRange record provides a shorthand
+ // way of specifying that an entire range of buckets should be
+ // assigned to an experiment.
+ record BucketRange {
+ // The start of the range, inclusive. Must be greater than or equal to 0.
+ int start;
+
+ // The end of the range, exclusive. Must be larger than the start value.
+ int end;
+ }
+
+ record ExperimentDefinition {
+ // A short name to identify this experiment, not required to be unique.
+ string name;
+
+ // An optional textual description of the purpose of this experiment.
+ string description = "";
+
+ // The person to contact for issues related to this experiment.
+ string owner;
+
+ // A globally unique integer identifier for this experiment, used for logging
+ // which experiments a request was in and for forcing a request to use a
+ // particular experiment.
+ int id;
+
+ // The ID of the layer this experiment is assigned to.
+ int layer_id;
+
+ // Indicates that this experiment defines a new domain that may contain other
+ // layers and experiments inside of it.
+ boolean domain = false;
+
+ // The identifier of the control for this experiment. A control experiment ID
+ // MUST be specified. If this experiment represents a control for other experiments or
+ // defines a domain, then the experiment_id should be equal to the control_experiment_id.
+ int control_id;
+
+ // The conditions that trigger this experiment, if any. An experiment and its controls
+ // should have the same triggering conditions.
+ union{array<ConditionDefinition>, null} conditions;
+
+ // The rule for combining conditions if there is more than one.
+ union{ConditionOperator, null} condition_merge_operator;
+
+ // The modifications to parameters associated with this experiment. This should
+ // only be absent for control experiments.
+ union{array<OverrideDefinition>, null} overrides;
+
+ // The identifier of the diversion criteria for this experiment. It may be possible to
+ // divert an experiment in multiple ways on a given request: by a login ID, by a cookie,
+ // or simply at random. The experiment handler should be configured with the different
+ // diversion types and their rank ordering in processing requests depending on what
+ // information is allocated to each request.
+ int diversion_id;
+
+ // Each diversion criteria defines a fixed number of buckets that can be allocated to
+ // experiments. These fields specify which buckets for the diversion criteria are
+ // allocated to this experiment.
+ union{array<int>, null} buckets;
+ union{array<BucketRange>, null} bucket_ranges;
+ }
+
+ record DiversionDefinition {
+ // A unique identifier for this diversion type.
+ int id;
+
+ // A short description of what features of the request are used by this
+ // diversion criteria (e.g., "UserID", "Cookie", etc.)
+ string name;
+
+ // Indicates that this is a random diversion criteria that is not linked to
+ // any fixed parameter in the request, such as a cookie or user identifier.
+ boolean random = false;
+
+ // The number of buckets for this type of diversion. Valid bucket ranges for
+ // experiments using this bucket criteria are values between 0 (inclusive) and
+ // num_buckets (exclusive).
+ int num_buckets;
+ }
+
+ record LayerDefinition {
+ // A unique integer for each layer.
+ int id;
+
+ // The domain that contains this layer. Zero is the default (top-level) domain.
+ int domain_id = 0;
+
+ // A descriptive name that identifies the purpose of this layer.
+ string name;
+
+ // Indicates whether this is a temporary layer for launching a new feature (true)
+ // or a permanent layer for running experiments (false). If launch is true, then
+ // the domain_id must be zero.
+ boolean launch = false;
+
+ // A unique identifier to use for requests that did not divert into any buckets within this layer.
+ int unbiased_id;
+
+ // A unique identifier to use for requests that diverted into a bucket based on a fixed
+ // identifier (like a cookie or login ID) but that did not satisfy the conditions associated
+ // with the experiment/domain that owned that bucket. Only required if there are diversion
+ // criteria that allow fixed diversion.
+ int fixed_biased_id = 0;
+
+ // A unique identifier to use for requests that diverted into a bucket based on a random identifier
+ // but did not satisfy the conditions associated with the experiment that owned that bucket.
+ // Only required if there are diversion criteria that allow random diversion.
+ int random_biased_id = 0;
+ }
+
+ // A single record that contains all of the records we need to define
+ // an experiment configuration.
+ record ExperimentDeployment {
+ // First, we load the experiment flag definitions.
+ array<ExperimentFlagDefinition> flag_definitions;
+
+ // Followed by the diversion criteria.
+ array<DiversionDefinition> diversions;
+
+ // And then we cycle through the layer definitions, starting with those
+ // in the default domain (domain_id = 0), followed by the experimens/domains
+ // defined within those layers, and then back through the layers that are
+ // defined in subdomains, etc., until all of the configs have been loaded.
+ array<LayerDefinition> layers;
+ array<ExperimentDefinition> experiments;
+ }
+}
363 avro/src/main/java/com/cloudera/gertrude/space/AvroExperimentSpaceDeserializer.java
@@ -0,0 +1,363 @@
+/**
+ * Copyright (c) 2013, Cloudera, Inc. All Rights Reserved.
+ *
+ * Cloudera, Inc. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"). You may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for
+ * the specific language governing permissions and limitations under the
+ * License.
+ */
+package com.cloudera.gertrude.space;
+
+import com.cloudera.gertrude.Condition;
+import com.cloudera.gertrude.DiversionCriterion;
+import com.cloudera.gertrude.ExperimentSpace;
+import com.cloudera.gertrude.ExperimentSpaceDeserializer;
+import com.cloudera.gertrude.ExperimentState;
+import com.cloudera.gertrude.FlagTypeParser;
+import com.cloudera.gertrude.calculate.AssociativeOperator;
+import com.cloudera.gertrude.calculate.BasicModifier;
+import com.cloudera.gertrude.calculate.FlagValueOverride;
+import com.cloudera.gertrude.calculate.Modifier;
+import com.cloudera.gertrude.condition.BooleanConditions;
+import com.cloudera.gertrude.experiments.avro.BucketRange;
+import com.cloudera.gertrude.experiments.avro.ConditionDefinition;
+import com.cloudera.gertrude.experiments.avro.ConditionOperator;
+import com.cloudera.gertrude.experiments.avro.DiversionDefinition;
+import com.cloudera.gertrude.experiments.avro.ExperimentDefinition;
+import com.cloudera.gertrude.experiments.avro.ExperimentDeployment;
+import com.cloudera.gertrude.experiments.avro.ExperimentFlagDefinition;
+import com.cloudera.gertrude.experiments.avro.FlagType;
+import com.cloudera.gertrude.experiments.avro.LayerDefinition;
+import com.cloudera.gertrude.experiments.avro.ModifierDefinition;
+import com.cloudera.gertrude.experiments.avro.ModifierOperator;
+import com.cloudera.gertrude.experiments.avro.OverrideDefinition;
+import com.google.common.base.Function;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+import com.google.common.io.ByteStreams;
+import com.google.common.io.InputSupplier;
+import org.apache.avro.file.DataFileReader;
+import org.apache.avro.file.FileReader;
+import org.apache.avro.file.SeekableByteArrayInput;
+import org.apache.avro.file.SeekableInput;
+import org.apache.avro.io.BinaryDecoder;
+import org.apache.avro.io.DatumReader;
+import org.apache.avro.io.DecoderFactory;
+import org.apache.avro.specific.SpecificDatumReader;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+import java.util.SortedSet;
+
+public class AvroExperimentSpaceDeserializer extends ExperimentSpaceDeserializer {
+
+ private static final Logger log = LoggerFactory.getLogger(AvroExperimentSpaceDeserializer.class);
+
+ private final DatumReader<ExperimentDeployment> reader;
+ private final boolean avroFileInput;
+
+ public AvroExperimentSpaceDeserializer(boolean avroFileInput) {
+ this.reader = new SpecificDatumReader<ExperimentDeployment>(ExperimentDeployment.class);
+ this.avroFileInput = avroFileInput;
+ }
+
+ @Override
+ protected Optional<ExperimentSpace> deserialize(ExperimentSpace.Serialized serialized) throws IOException {
+ ExperimentDeployment merged = null;
+ ExperimentDeployment curr = null;
+ if (avroFileInput) {
+ for (InputSupplier<? extends InputStream> is : serialized.getSerializedData()) {
+ SeekableInput si = new SeekableByteArrayInput(ByteStreams.toByteArray(is));
+ FileReader<ExperimentDeployment> dfr = DataFileReader.openReader(si, reader);
+ while (dfr.hasNext()) {
+ merged = merge(merged, dfr.next(curr));
+ }
+ }
+ } else {
+ BinaryDecoder decoder = null;
+ for (InputSupplier<? extends InputStream> is : serialized.getSerializedData()) {
+ decoder = DecoderFactory.get().binaryDecoder(is.getInput(), decoder);
+ merged = merge(merged, reader.read(curr, decoder));
+ }
+ }
+
+ return merged == null ? Optional.<ExperimentSpace>absent() :
+ Optional.fromNullable(load(merged, serialized.getVersionIdentifier()));
+ }
+
+ private static ExperimentDeployment merge(ExperimentDeployment one, ExperimentDeployment two) {
+ if (one == null && two != null) {
+ return two;
+ } else {
+ one.setFlagDefinitions(mergeLists(one.getFlagDefinitions(), two.getFlagDefinitions()));
+ one.setDiversions(mergeLists(one.getDiversions(), two.getDiversions()));
+ one.setLayers(mergeLists(one.getLayers(), two.getLayers()));
+ one.setExperiments(mergeLists(one.getExperiments(), two.getExperiments()));
+ return one;
+ }
+ }
+
+ private static <S> List<S> mergeLists(List<S> one, List<S> two) {
+ if (one == null) {
+ one = Lists.newArrayList();
+ }
+ if (two != null) {
+ one.addAll(two);
+ }
+ return one;
+ }
+
+ private static <S> List<S> emptyIfNull(List<S> list) {
+ if (list == null) {
+ return ImmutableList.of();
+ }
+ return list;
+ }
+
+ // Need to merge any existing deployment configs together before calling this
+ ExperimentSpace load(ExperimentDeployment deployment, String versionIdentifier) {
+ if (deployment.getFlagDefinitions() == null || deployment.getFlagDefinitions().isEmpty()) {
+ throw new IllegalArgumentException("No flags defined in deployment");
+ }
+
+ ExperimentSpaceBuilder builder = new ExperimentSpaceBuilder(getExperimentFlags(), new Random());
+ Map<String, FlagTypeParser<Object>> parsers = Maps.newHashMap();
+ for (ExperimentFlagDefinition flagDef : deployment.getFlagDefinitions()) {
+ addFlagDefinition(flagDef, parsers, builder);
+ }
+
+ for (DiversionDefinition divDef : emptyIfNull(deployment.getDiversions())) {
+ addDiversionCriterion(divDef, builder);
+ }
+
+ Set<Integer> configuredLayerIds = Sets.newHashSet();
+ Set<Integer> configuredSegmentId = Sets.newHashSet();
+ configuredSegmentId.add(0); // The default domain
+ List<LayerDefinition> layerDefs = emptyIfNull(deployment.getLayers());
+ while (configuredLayerIds.size() < layerDefs.size()) {
+ boolean layersAdded = false;
+
+ for (LayerDefinition layerDef : layerDefs) {
+ if (configuredSegmentId.contains(layerDef.getDomainId()) &&
+ !configuredLayerIds.contains(layerDef.getId())) {
+ addLayer(layerDef, builder);
+ configuredLayerIds.add(layerDef.getId());
+ layersAdded = true;
+ }
+ }
+
+ if (!layersAdded) {
+ throw new IllegalStateException("Invalid deployment configuration; infinite loop detected");
+ }
+
+ for (ExperimentDefinition exptDef : emptyIfNull(deployment.getExperiments())) {
+ if (configuredLayerIds.contains(exptDef.getLayerId()) &&
+ !configuredSegmentId.contains(exptDef.getId())) {
+ addExperiment(exptDef, parsers, builder);
+ configuredSegmentId.add(exptDef.getId());
+ }
+ }
+ }
+
+ return builder.build(versionIdentifier);
+ }
+
+ void addFlagDefinition(
+ ExperimentFlagDefinition definition,
+ Map<String, FlagTypeParser<Object>> parsers,
+ ExperimentSpaceBuilder builder) {
+ String flagName = definition.getName().toString();
+ FlagTypeParser<Object> parser = (FlagTypeParser<Object>) getParser(definition.getFlagType());
+ builder.addFlagDefinition(
+ flagName,
+ parser.parse(definition.getBaseValue()),
+ getModifiers(definition.getModifiers(), parser));
+ parsers.put(flagName, parser);
+ }
+
+ static void addDiversionCriterion(DiversionDefinition diversion, ExperimentSpaceBuilder builder) {
+ DiversionCriterion dc = new DiversionCriterion(
+ diversion.getId(),
+ diversion.getNumBuckets(),
+ diversion.getRandom());
+ builder.addDiversionCriterion(dc);
+ }
+
+ static void addLayer(LayerDefinition layerDefinition, ExperimentSpaceBuilder builder) {
+ LayerInfo info = LayerInfo.builder(layerDefinition.getId())
+ .domainId(layerDefinition.getDomainId())
+ .launchLayer(layerDefinition.getLaunch())
+ .unbiasedId(layerDefinition.getUnbiasedId())
+ .fixedBiasedId(layerDefinition.getFixedBiasedId())
+ .randomBiasedId(layerDefinition.getRandomBiasedId())
+ .build();
+ builder.addLayer(info);
+ }
+
+ void addExperiment(ExperimentDefinition exptDef,
+ Map<String, FlagTypeParser<Object>> parsers,
+ ExperimentSpaceBuilder builder) {
+ // Needs to be checked against existing bucket ranges
+ SortedSet<Integer> buckets = getBuckets(exptDef.getBuckets(), exptDef.getBucketRanges());
+ Condition condition = getCondition(exptDef.getConditions(), exptDef.getConditionMergeOperator());
+ SegmentInfo info = new SegmentInfo(exptDef.getId(), exptDef.getLayerId(), exptDef.getDiversionId(),
+ buckets, condition);
+ Map<String, FlagValueOverride<Object>> overrides = getOverrides(exptDef.getOverrides(),
+ parsers, exptDef.getId());
+ builder.addExperimentInfo(info, exptDef.getDomain(), overrides);
+ }
+
+ static FlagTypeParser<?> getParser(FlagType flagType) {
+ switch (flagType) {
+ case BOOL:
+ return FlagTypeParser.BOOLEAN_PARSER;
+ case INT:
+ return FlagTypeParser.LONG_PARSER;
+ case DOUBLE:
+ return FlagTypeParser.DOUBLE_PARSER;
+ case STRING:
+ return FlagTypeParser.STRING_PARSER;
+ default:
+ throw new IllegalArgumentException("Unknown flag type: " + flagType);
+ }
+ }
+
+ protected Map<String, FlagValueOverride<Object>> getOverrides(
+ List<OverrideDefinition> overrides,
+ Map<String, FlagTypeParser<Object>> parsers,
+ int experimentId) {
+ ImmutableMap.Builder<String, FlagValueOverride<Object>> b = ImmutableMap.builder();
+ if (overrides != null) {
+ for (OverrideDefinition definition : overrides) {
+ String flagName = definition.getName().toString();
+ FlagTypeParser<Object> parser = parsers.get(flagName);
+ if (parser == null) {
+ throw new IllegalStateException(String.format(
+ "Unknown experiment flag %s in experiment %d", flagName, experimentId));
+ }
+ List<Modifier<Object>> mods;
+ try {
+ mods = getModifiers(definition.getModifiers(), parser);
+ } catch (RuntimeException re) { // TODO make me a more specific parsing exception
+ log.error("Invalid modifier in overrides for flag {} in experiment {}", flagName, experimentId);
+ throw re;
+ }
+
+ FlagValueOverride<Object> flagOverride;
+ switch (definition.getOperator()) {
+ case REPLACE:
+ if (definition.getBaseValue() == null) {
+ throw new IllegalStateException(String.format(
+ "REPLACE must have non-null base value for flag %s in experiment %d", flagName, experimentId));
+ }
+ Object baseValue = parser.parse(definition.getBaseValue());
+ flagOverride = FlagValueOverride.createReplace(baseValue, mods);
+ break;
+ case APPEND:
+ flagOverride = FlagValueOverride.createAppend(mods);
+ break;
+ case PREPEND:
+ flagOverride = FlagValueOverride.createPrepend(mods);
+ break;
+ default:
+ throw new IllegalStateException("Unknown override operator: " + definition.getOperator());
+ }
+ b.put(flagName, flagOverride);
+ }
+ }
+ return b.build();
+ }
+
+ protected static SortedSet<Integer> getBuckets(List<Integer> buckets, List<BucketRange> bucketRanges) {
+ SortedSet<Integer> ret = Sets.newTreeSet();
+ if (buckets != null) {
+ ret.addAll(buckets);
+ }
+ if (bucketRanges != null) {
+ for (BucketRange br : bucketRanges) {
+ for (int i = br.getStart(); i < br.getEnd(); i++) {
+ ret.add(i);
+ }
+ }
+ }
+ return ret;
+ }
+
+ protected <T> List<Modifier<T>> getModifiers(List<ModifierDefinition> definitions, FlagTypeParser<T> parser) {
+ if (definitions == null || definitions.isEmpty()) {
+ return ImmutableList.of();
+ } else {
+ return Lists.transform(definitions, createModifierFunction(parser));
+ }
+ }
+
+ protected <T> Function<ModifierDefinition, Modifier<T>> createModifierFunction(final FlagTypeParser<T> parser) {
+ return new Function<ModifierDefinition, Modifier<T>>() {
+ @Override
+ public Modifier<T> apply(ModifierDefinition definition) {
+ List<Modifier<T>> mods = definition.getModifiers() != null ?
+ Lists.transform(definition.getModifiers(), this) :
+ ImmutableList.<Modifier<T>>of();
+ Condition condition = getCondition(definition.getConditions(), definition.getConditionMergeOperator());
+ return new BasicModifier<T>(
+ parser.parse(definition.getValue()),
+ getOperatorFunction(definition.getOperator(), parser),
+ condition,
+ mods);
+ }
+ };
+ }
+
+ protected Condition getCondition(List<ConditionDefinition> definitions, ConditionOperator operator) {
+ if (definitions == null || definitions.isEmpty()) {
+ return Condition.TRUE;
+ } else {
+ List<Condition<ExperimentState>> conditions = Lists.newArrayList();
+ for (ConditionDefinition definition : definitions) {
+ List<String> args = Lists.transform(emptyIfNull(definition.getArgs()), new Function<Object, String>() {
+ @Override
+ public String apply(Object in) {
+ return in.toString();
+ }
+ });
+ Condition c = getConditionFactory().create(definition.getName().toString());
+ c.initialize(args);
+ if (definition.getNegate() != null && definition.getNegate()) {
+ c = BooleanConditions.not(c);
+ }
+ conditions.add(c);
+ }
+ // May want to cache the c + args mappings to the instances...
+ if (conditions.size() == 1) {
+ return conditions.get(0);
+ } else if (operator == null || operator == ConditionOperator.AND) {
+ return BooleanConditions.and(conditions);
+ } else if (operator == ConditionOperator.OR) {
+ return BooleanConditions.or(conditions);
+ }
+ throw new IllegalArgumentException("Unknown condition operator: " + operator);
+ }
+ }
+
+ protected static <T> AssociativeOperator<T> getOperatorFunction(
+ ModifierOperator operator,
+ FlagTypeParser<T> parser) {
+ return AssociativeOperator.get(operator.name(), parser);
+ }
+}
138 avro/src/test/java/com/cloudera/gertrude/space/AvroDataUtils.java
@@ -0,0 +1,138 @@
+/**
+ * Copyright (c) 2013, Cloudera, Inc. All Rights Reserved.
+ *
+ * Cloudera, Inc. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"). You may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for
+ * the specific language governing permissions and limitations under the
+ * License.
+ */
+package com.cloudera.gertrude.space;
+
+import com.cloudera.gertrude.experiments.avro.DiversionDefinition;
+import com.cloudera.gertrude.experiments.avro.ExperimentDefinition;
+import com.cloudera.gertrude.experiments.avro.ExperimentFlagDefinition;
+import com.cloudera.gertrude.experiments.avro.FlagType;
+import com.cloudera.gertrude.experiments.avro.LayerDefinition;
+import com.cloudera.gertrude.experiments.avro.ModifierDefinition;
+import com.cloudera.gertrude.experiments.avro.ModifierOperator;
+import com.cloudera.gertrude.experiments.avro.OverrideDefinition;
+import com.cloudera.gertrude.experiments.avro.OverrideOperator;
+import com.google.common.collect.Lists;
+
+import java.util.Arrays;
+
+public final class AvroDataUtils {
+
+ private AvroDataUtils() {
+ }
+
+ public static ExperimentFlagDefinition flagDef(String name, String baseValue, FlagType flagType) {
+ return ExperimentFlagDefinition.newBuilder()
+ .setName(name)
+ .setDescription("")
+ .setBaseValue(baseValue)
+ .setFlagType(flagType)
+ .setModifiers(null)
+ .build();
+ }
+
+ public static DiversionDefinition divDef(int diversionId, int numBuckets, boolean random) {
+ return DiversionDefinition.newBuilder()
+ .setId(diversionId)
+ .setName("Diversion Criteria " + diversionId)
+ .setNumBuckets(numBuckets)
+ .setRandom(random)
+ .build();
+ }
+
+ public static LayerDefinition layerDef(int layerId, int domainId, boolean launchLayer, int baseId) {
+ return LayerDefinition.newBuilder()
+ .setName("Layer " + layerId)
+ .setId(layerId)
+ .setDomainId(domainId)
+ .setLaunch(launchLayer)
+ .setUnbiasedId(baseId)
+ .setFixedBiasedId(baseId + 1)
+ .setRandomBiasedId(baseId + 2)
+ .build();
+ }
+
+ public static ExperimentDefinition domainDef(SegmentInfo info) {
+ return ExperimentDefinition.newBuilder()
+ .setName("Domain " + info.getId())
+ .setDescription("Domain " + info.getId())
+ .setId(info.getId())
+ .setControlId(info.getId())
+ .setDomain(true)
+ .setLayerId(info.getLayerId())
+ .setDiversionId(info.getDiversionId())
+ .setConditions(null)
+ .setConditionMergeOperator(null)
+ .setOwner("")
+ .setOverrides(null)
+ .setBucketRanges(null)
+ .setBuckets(Lists.newArrayList(info.getBuckets()))
+ .build();
+ }
+
+ public static ExperimentDefinition exptDef(SegmentInfo info, int controlId, OverrideDefinition... overrides) {
+ return ExperimentDefinition.newBuilder()
+ .setName("Domain " + info.getId())
+ .setDescription("Domain " + info.getId())
+ .setId(info.getId())
+ .setControlId(controlId)
+ .setDomain(false)
+ .setLayerId(info.getLayerId())
+ .setDiversionId(info.getDiversionId())
+ .setConditions(null)
+ .setConditionMergeOperator(null)
+ .setOwner("")
+ .setOverrides(Arrays.asList(overrides))
+ .setBucketRanges(null)
+ .setBuckets(Lists.newArrayList(info.getBuckets()))
+ .build();
+ }
+
+ public static OverrideDefinition replaceDef(String flagName, String baseValue, ModifierDefinition... m) {
+ return OverrideDefinition.newBuilder()
+ .setName(flagName)
+ .setOperator(OverrideOperator.REPLACE)
+ .setBaseValue(baseValue)
+ .setModifiers(Arrays.asList(m))
+ .build();
+ }
+
+ public static <T> OverrideDefinition appendDef(String flagName, ModifierDefinition... m) {
+ return OverrideDefinition.newBuilder()
+ .setName(flagName)
+ .setOperator(OverrideOperator.APPEND)
+ .setBaseValue(null)
+ .setModifiers(Arrays.asList(m))
+ .build();
+ }
+
+ public static <T> OverrideDefinition prependDef(String flagName, ModifierDefinition... m) {
+ return OverrideDefinition.newBuilder()
+ .setName(flagName)
+ .setOperator(OverrideOperator.PREPEND)
+ .setBaseValue(null)
+ .setModifiers(Arrays.asList(m))
+ .build();
+ }
+
+ public static ModifierDefinition mod(String value, ModifierOperator op, ModifierDefinition... m) {
+ return ModifierDefinition.newBuilder()
+ .setValue(value)
+ .setOperator(op)
+ .setModifiers(Arrays.asList(m))
+ .setConditionMergeOperator(null)
+ .setConditions(null)
+ .build();
+ }
+}
252 avro/src/test/java/com/cloudera/gertrude/space/AvroExperimentSpaceDeserializerTest.java
@@ -0,0 +1,252 @@
+/**
+ * Copyright (c) 2013, Cloudera, Inc. All Rights Reserved.
+ *
+ * Cloudera, Inc. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"). You may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for
+ * the specific language governing permissions and limitations under the
+ * License.
+ */
+package com.cloudera.gertrude.space;
+
+import com.cloudera.gertrude.ExperimentFlag;
+import com.cloudera.gertrude.Experiments;
+import com.cloudera.gertrude.TestExperimentState;
+import com.cloudera.gertrude.TestExperiments;
+import com.cloudera.gertrude.condition.ReflectionConditionFactory;
+import com.cloudera.gertrude.experiments.avro.ExperimentDefinition;
+import com.cloudera.gertrude.experiments.avro.ExperimentDeployment;
+import com.cloudera.gertrude.experiments.avro.ExperimentFlagDefinition;
+import com.cloudera.gertrude.experiments.avro.FlagType;
+import com.cloudera.gertrude.experiments.avro.ModifierOperator;
+import com.cloudera.gertrude.experiments.avro.OverrideDefinition;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.common.collect.Sets;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.util.List;
+import java.util.Map;
+import java.util.SortedSet;
+
+import static com.cloudera.gertrude.space.AvroDataUtils.*;
+import static org.junit.Assert.assertEquals;
+
+public final class AvroExperimentSpaceDeserializerTest {
+
+ private static final ExperimentFlag<Long> foo = Experiments.declare("foo", 12L);
+ private static final ExperimentFlag<String> bar = Experiments.declare("bar", "zzz");
+ private static final ExperimentFlag<Boolean> baz = Experiments.declare("baz", false);
+ private static final AvroExperimentSpaceDeserializer aedp = new AvroExperimentSpaceDeserializer(false);
+
+ private final List<ExperimentFlagDefinition> flagDefs = ImmutableList.of(
+ flagDef("foo", "17", FlagType.INT),
+ flagDef("bar", "aaa", FlagType.STRING),
+ flagDef("baz", "true", FlagType.BOOL));
+
+ @BeforeClass
+ public static void setUp() throws Exception {
+ Map<String, ExperimentFlag<?>> flags = ImmutableMap.<String, ExperimentFlag<?>>of(
+ "foo", foo, "bar", bar, "baz", baz);
+ aedp.initialize(flags, new ReflectionConditionFactory());
+ }
+
+ @Test
+ public void testJustFlagDefs() throws Exception {
+ ExperimentDeployment deployment = new ExperimentDeployment();
+ deployment.setFlagDefinitions(flagDefs);
+ TestExperiments.setExperimentSpace(aedp.load(deployment, ""));
+ TestExperimentState state = new TestExperimentState();
+ TestExperiments.getHandler().handle(state);
+ assertEquals(17L, state.get(foo).longValue());
+ assertEquals("aaa", state.get(bar));
+ assertEquals(Boolean.TRUE, state.get(baz));
+ }
+
+ @Test
+ public void testEmptyLayer() throws Exception {
+ int numBuckets = 100;
+ ExperimentDeployment deployment = ExperimentDeployment.newBuilder()
+ .setDiversions(ImmutableList.of(divDef(0, numBuckets, false)))
+ .setFlagDefinitions(flagDefs)
+ .setLayers(ImmutableList.of(layerDef(1, 0, false, 1)))
+ .setExperiments(ImmutableList.<ExperimentDefinition>of())
+ .build();
+ TestExperiments.setExperimentSpace(aedp.load(deployment, ""));
+
+ // Test unbiased assignment
+ TestExperimentState state = new TestExperimentState().setDiversionIdentifier(0, "cookie");
+ TestExperiments.getHandler().handle(state);
+ assertEquals(ImmutableSet.of(1), state.getExperimentIds());
+ }
+
+ @Test
+ public void testOneLayer() throws Exception {
+ int numBuckets = 100;
+ SortedSet<Integer> buckets = ImmutableSortedSet.of(82);
+ SegmentInfo s1 = new SegmentInfo(10, 1, 0, buckets);
+ ExperimentDeployment deployment = ExperimentDeployment.newBuilder()
+ .setDiversions(ImmutableList.of(divDef(0, numBuckets, false)))
+ .setFlagDefinitions(flagDefs)
+ .setLayers(ImmutableList.of(layerDef(1, 0, false, 1)))
+ .setExperiments(ImmutableList.of(exptDef(s1, 10)))
+ .build();
+ TestExperiments.setExperimentSpace(aedp.load(deployment, ""));
+
+ // Test experiment assignment
+ TestExperimentState state = new TestExperimentState().setDiversionIdentifier(0, "cookie");
+ TestExperiments.getHandler().handle(state);
+ assertEquals(ImmutableSet.of(10), state.getExperimentIds());
+ }
+
+ @Test
+ public void testUnbiasedAssignment() throws Exception {
+ int numBuckets = 100;
+ SortedSet<Integer> buckets = ImmutableSortedSet.of(82);
+ SegmentInfo s1 = new SegmentInfo(10, 1, 0, buckets);
+ ExperimentDeployment deployment = ExperimentDeployment.newBuilder()
+ .setDiversions(ImmutableList.of(divDef(0, numBuckets, false)))
+ .setFlagDefinitions(flagDefs)
+ .setLayers(ImmutableList.of(layerDef(1, 0, false, 1)))
+ .setExperiments(ImmutableList.of(exptDef(s1, 10)))
+ .build();
+ TestExperiments.setExperimentSpace(aedp.load(deployment, ""));
+
+ // Test unbiased assignment
+ TestExperimentState state = new TestExperimentState().setDiversionIdentifier(0, "mod");
+ TestExperiments.getHandler().handle(state);
+ assertEquals(ImmutableSet.of(1), state.getExperimentIds());
+ }
+
+ @Test
+ public void testTwoLayers() throws Exception {
+ int numBuckets = 100;
+ //TODO: validate control experiment IDs
+ SegmentInfo s1 = new SegmentInfo(10, 1, 0, ImmutableSortedSet.of(80));
+ OverrideDefinition o1 = appendDef("foo", mod("2", ModifierOperator.MULTIPLY));
+ SegmentInfo s2 = new SegmentInfo(20, 2, 0, ImmutableSortedSet.of(77));
+ OverrideDefinition o2 = replaceDef("bar", "qqq");
+
+ ExperimentDeployment deployment = ExperimentDeployment.newBuilder()
+ .setDiversions(ImmutableList.of(divDef(0, numBuckets, false)))
+ .setFlagDefinitions(flagDefs)
+ .setLayers(ImmutableList.of(layerDef(1, 0, false, 1), layerDef(2, 0, false, 4)))
+ .setExperiments(ImmutableList.of(exptDef(s1, 10, o1), exptDef(s2, 20, o2)))
+ .build();
+ TestExperiments.setExperimentSpace(aedp.load(deployment, ""));
+
+ // Test experiment assignment
+ TestExperimentState state = new TestExperimentState().setDiversionIdentifier(0, "mod");
+ TestExperiments.getHandler().handle(state);
+ assertEquals(ImmutableSet.of(10, 20), state.getExperimentIds());
+ assertEquals(34, state.getInt(foo));
+ assertEquals("qqq", state.get(bar));
+ }
+
+ @Test
+ public void testLaunchLayer() throws Exception {
+ int numBuckets = 100;
+ SegmentInfo s1 = new SegmentInfo(10, 1, 0, ImmutableSortedSet.of(80));
+ OverrideDefinition o1 = appendDef("foo", mod("2", ModifierOperator.MULTIPLY));
+
+ ExperimentDeployment deployment = ExperimentDeployment.newBuilder()
+ .setDiversions(ImmutableList.of(divDef(0, numBuckets, false)))
+ .setFlagDefinitions(flagDefs)
+ .setLayers(ImmutableList.of(layerDef(1, 0, true, 1)))
+ .setExperiments(ImmutableList.of(exptDef(s1, 10, o1)))
+ .build();
+ TestExperiments.setExperimentSpace(aedp.load(deployment, ""));
+
+ // Test experiment assignment
+ TestExperimentState state = new TestExperimentState().setDiversionIdentifier(0, "mod");
+ TestExperiments.getHandler().handle(state);
+ assertEquals(ImmutableSet.of(10), state.getExperimentIds());
+ assertEquals(34, state.getInt(foo));
+ }
+
+ @Test
+ public void testTwoLayersWithLaunch() throws Exception {
+ int numBuckets = 100;
+ SegmentInfo s1 = new SegmentInfo(10, 1, 0, ImmutableSortedSet.of(80));
+ OverrideDefinition o1 = replaceDef("foo", "29");
+ SegmentInfo s2 = new SegmentInfo(20, 2, 0, ImmutableSortedSet.of(77));
+ OverrideDefinition o2 = appendDef("foo", mod("2", ModifierOperator.MULTIPLY));
+
+ ExperimentDeployment deployment = ExperimentDeployment.newBuilder()
+ .setDiversions(ImmutableList.of(divDef(0, numBuckets, false)))
+ .setFlagDefinitions(flagDefs)
+ .setLayers(ImmutableList.of(layerDef(1, 0, true, 1), layerDef(2, 0, false, 4)))
+ .setExperiments(ImmutableList.of(exptDef(s1, 10, o1), exptDef(s2, 20, o2)))
+ .build();
+ TestExperiments.setExperimentSpace(aedp.load(deployment, ""));
+
+ // Test experiment assignment
+ TestExperimentState state = new TestExperimentState().setDiversionIdentifier(0, "mod");
+ TestExperiments.getHandler().handle(state);
+ assertEquals(ImmutableSet.of(10, 20), state.getExperimentIds());
+ assertEquals(29 * 2, state.getInt(foo));
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testTwoLayersWithIllegalOverrides() throws Exception {
+ int numBuckets = 100;
+ SegmentInfo s1 = new SegmentInfo(10, 1, 0, ImmutableSortedSet.of(80));
+ OverrideDefinition o1 = replaceDef("foo", "29");
+ SegmentInfo s2 = new SegmentInfo(20, 2, 0, ImmutableSortedSet.of(77));
+ OverrideDefinition o2 = appendDef("foo", mod("2", ModifierOperator.MULTIPLY));
+
+ ExperimentDeployment deployment = ExperimentDeployment.newBuilder()
+ .setDiversions(ImmutableList.of(divDef(0, numBuckets, false)))
+ .setFlagDefinitions(flagDefs)
+ .setLayers(ImmutableList.of(layerDef(1, 0, false, 1), layerDef(2, 0, false, 4)))
+ .setExperiments(ImmutableList.of(exptDef(s1, 10, o1), exptDef(s2, 20, o2)))
+ .build();
+ aedp.load(deployment, "");
+ }
+
+ @Test
+ public void testTwoLayersWithValidOverrides() throws Exception {
+ int numBuckets = 100;
+ SortedSet<Integer> one = Sets.newTreeSet(), two = Sets.newTreeSet();
+ for (int i = 0; i < 100; i++) {
+ if (i % 2 == 0) {
+ one.add(i);
+ } else {
+ two.add(i);
+ }
+ }
+ // Two domains within layer_id = 1, each with half of the traffic allocation
+ SegmentInfo d1 = new SegmentInfo(10, 1, 0, one);
+ SegmentInfo d2 = new SegmentInfo(20, 1, 0, two);
+
+ // One experiment in layer_id = 2, which will live under domain_id = 10
+ SegmentInfo s1 = new SegmentInfo(100, 2, 0, ImmutableSortedSet.of(77));
+ OverrideDefinition o1 = replaceDef("foo", "29");
+ // One experiment in layer_id = 3, which lives under domain_id = 20
+ SegmentInfo s2 = new SegmentInfo(200, 3, 0, ImmutableSortedSet.of(77));
+ OverrideDefinition o2 = prependDef("foo", mod("2", ModifierOperator.MULTIPLY));
+
+ ExperimentDeployment deployment = ExperimentDeployment.newBuilder()
+ .setDiversions(ImmutableList.of(divDef(0, numBuckets, false)))
+ .setFlagDefinitions(flagDefs)
+ .setLayers(ImmutableList.of(layerDef(1, 0, false, 1), layerDef(2, 10, false, 4), layerDef(3, 20, false, 7)))
+ .setExperiments(ImmutableList.of(domainDef(d1), domainDef(d2), exptDef(s1, 100, o1), exptDef(s2, 200, o2)))
+ .build();
+
+ TestExperiments.setExperimentSpace(aedp.load(deployment, ""));
+
+ // Test experiment assignment
+ TestExperimentState state = new TestExperimentState().setDiversionIdentifier(0, "mod");
+ TestExperiments.getHandler().handle(state);
+ assertEquals(ImmutableSet.of(100), state.getExperimentIds());
+ assertEquals(29, state.getInt(foo));
+ }
+}
67 core/pom.xml
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Copyright (c) 2013, Cloudera, Inc. All Rights Reserved.
+
+ Cloudera, Inc. licenses this file to you under the Apache License,
+ Version 2.0 (the "License"). You may not use this file except in
+ compliance with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ CONDITIONS OF ANY KIND, either express or implied. See the License for
+ the specific language governing permissions and limitations under the
+ License.
+ -->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>com.cloudera.gertrude</groupId>
+ <artifactId>gertrude-parent</artifactId>
+ <version>0.1.0</version>
+ <relativePath>../</relativePath>
+ </parent>
+
+ <artifactId>gertrude-core</artifactId>
+ <name>Gertrude Experiment Framework Core</name>
+
+ <dependencies>
+ <dependency>
+ <groupId>com.google.guava</groupId>
+ <artifactId>guava</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>com.codahale.metrics</groupId>
+ <artifactId>metrics-core</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-api</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-compiler-plugin</artifactId>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-jar-plugin</artifactId>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-surefire-plugin</artifactId>
+ </plugin>
+ </plugins>
+ </build>
+</project>
+
90 core/src/main/java/com/cloudera/gertrude/AbstractExperimentState.java
@@ -0,0 +1,90 @@
+/**
+ * Copyright (c) 2013, Cloudera, Inc. All Rights Reserved.
+ *
+ * Cloudera, Inc. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"). You may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for
+ * the specific language governing permissions and limitations under the
+ * License.
+ */
+package com.cloudera.gertrude;
+
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Abstract base class that implements the core functionality of the {@link ExperimentState} interface.
+ * Clients should create their own {@code ExperimentState} implementations that use this instance as a base
+ * class, since there are core methods in the framework (such as {@link ExperimentHandler#handle(AbstractExperimentState)}
+ * that expect to operate on a subclass of {@code AbstractExperimentState}. More details on the purpose of this
+ * split are described in the documentation of the {@link ExperimentState} interface.
+ */
+public abstract class AbstractExperimentState implements ExperimentState {
+
+ private final Map<ExperimentFlag<?>, Object> valueCache = Maps.newHashMap();
+ private final Set<Integer> experimentIds = Sets.newHashSet();
+
+ private ExperimentFlagSettings flagSettings;
+
+ @Override
+ public abstract Optional<String> getDiversionIdentifier(int diversionId);
+
+ @Override
+ public Set<Integer> forceExperimentIds() {
+ return ImmutableSet.of();
+ }
+
+ @Override
+ public <T> T get(ExperimentFlag<T> flag) {
+ if (valueCache.containsKey(flag)) {
+ return (T) valueCache.get(flag);
+ } else if (flagSettings != null) {
+ FlagValue<T> value = flagSettings.getValue(flag, this);
+ if (value.getCacheLevel() != Condition.CacheLevel.NONE) {
+ valueCache.put(flag, value.getValue());
+ }
+ return value.getValue();
+ } else {
+ return flag.getDefaultValue();
+ }
+ }
+
+ @Override
+ public int getInt(ExperimentFlag<Long> longFlag) {
+ return get(longFlag).intValue();
+ }
+
+ @Override
+ public float getFloat(ExperimentFlag<Double> doubleFlag) {
+ return get(doubleFlag).floatValue();
+ }
+
+ @Override
+ public Set<Integer> getExperimentIds() {
+ return ImmutableSet.copyOf(experimentIds);
+ }
+
+ @Override
+ public boolean isDiverted() {
+ return flagSettings != null;
+ }
+
+ void setFlagSettings(ExperimentFlagSettings flagSettings) {
+ this.valueCache.clear();
+ this.flagSettings = flagSettings;
+ }
+
+ void addExperimentId(int experimentId) {
+ this.experimentIds.add(experimentId);
+ }
+}
146 core/src/main/java/com/cloudera/gertrude/Condition.java
@@ -0,0 +1,146 @@
+/**
+ * Copyright (c) 2013, Cloudera, Inc. All Rights Reserved.
+ *
+ * Cloudera, Inc. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"). You may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for
+ * the specific language governing permissions and limitations under the
+ * License.
+ */
+package com.cloudera.gertrude;
+
+import java.util.List;
+
+/**
+ * Determines a true or false value for a given {@link ExperimentState}.
+ *
+ * <p>The {@code Condition} interface allows Gertrude clients to specify rules
+ * that determine which experiments and flag value modifiers are active for a
+ * request.
+ *
+ * <p>For example, a client could implement a {@code Condition} that tested
+ * whether or not a request was in a particular country, or one based on a
+ * regular expression matching the hostname of the machine running this instance.
+ *
+ * <p>Classes that implement the {@code Condition} interface should be registered
+ * with the {@link ConditionFactory} for the current session so that Gertrude can
+ * create new instances of those classes from the information contained in the
+ * serialized version of the current {@link ExperimentSpace}.
+ */
+public interface Condition<S extends ExperimentState> {
+
+ /**
+ * Initialize this instance with an optional list of arguments provided in
+ * the serialized {@code ExperimentSpace}.
+ *
+ * @param args the provided arguments
+ */
+ void initialize(List<String> args);
+
+ /**
+ * Returns a true or false value for the given {@code ExperimentState}.
+ *
+ * @param state the {@code ExperimentState} that contains information about the request
+ * @return true or false, depending on the logic of this instance and the provided state
+ */
+ boolean evaluate(S state);
+
+ /**
+ * Returns an indication of the level at which this {@code Condition} instance may be
+ * cached.
+ * @return the cache level of this instance
+ */
+ CacheLevel getCacheLevel();
+
+ /**
+ * An indicator for how often the value of a {@code Condition} will change when the
+ * {@link #evaluate(ExperimentState)} method is called repeatedly.
+ */
+ enum CacheLevel {
+
+ /**
+ * Indicates that the result of a call to {@link #evaluate(ExperimentState)} may never
+ * be cached because the result is allowed to change during the lifetime of a single
+ * request.
+ */
+ NONE {
+ @Override
+ public CacheLevel merge(CacheLevel other) {
+ return CacheLevel.NONE;
+ }
+ },
+
+ /**
+ * Indicates that the result of a call to {@link #evaluate(ExperimentState)} will not change
+ * when the same {@code ExperimentState} instance is passed to the {@code Condition} multiple
+ * times.
+ */
+ REQUEST {
+ @Override
+ public CacheLevel merge(CacheLevel other) {
+ if (other == NONE) {
+ return NONE;
+ } else {
+ return CacheLevel.REQUEST;
+ }
+ }
+ },
+
+ /**
+ * Indicates that the result of a call to {@link #evaluate(ExperimentState)} will not change
+ * at all for the current {@code ExperimentSpace} configuration, independent of the value of
+ * the {@code ExperimentState} argument.
+ */
+ RELOAD {
+ @Override
+ public CacheLevel merge(CacheLevel other) {
+ return other;
+ }
+ };
+
+ public abstract CacheLevel merge(CacheLevel other);
+ }
+
+ /**
+ * A {@code Condition} instance that is always true.
+ */
+ Condition<ExperimentState> TRUE = new Condition<ExperimentState>() {
+ @Override
+ public void initialize(List<String> args) {
+ }
+
+ @Override
+ public boolean evaluate(ExperimentState state) {
+ return true;
+ }
+
+ @Override
+ public CacheLevel getCacheLevel() {
+ return CacheLevel.RELOAD;
+ }
+ };
+
+ /**
+ * A {@code Condition} instance that is always false.
+ */
+ Condition<ExperimentState> FALSE = new Condition<ExperimentState>() {
+ @Override
+ public void initialize(List<String> args) {
+ }
+
+ @Override
+ public boolean evaluate(ExperimentState state) {
+ return false;
+ }
+
+ @Override
+ public CacheLevel getCacheLevel() {
+ return CacheLevel.RELOAD;
+ }
+ };
+}
43 core/src/main/java/com/cloudera/gertrude/ConditionFactory.java
@@ -0,0 +1,43 @@
+/**
+ * Copyright (c) 2013, Cloudera, Inc. All Rights Reserved.
+ *
+ * Cloudera, Inc. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"). You may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for
+ * the specific language governing permissions and limitations under the
+ * License.
+ */
+package com.cloudera.gertrude;
+
+import java.util.Set;
+
+/**
+ * Associates a set of names with implementations of the {@link Condition} interface and
+ * creates new instances of those implementations on request from a client.
+ *
+ * <p>A {@code ConditionFactory} must be registered in the {@link Experiments} namespace before
+ * the {@link ExperimentHandler} for this server can be used by clients.
+ */
+public interface ConditionFactory {
+
+ /**
+ * Returns the set of names that have been associated with subclasses of the {@code Condition}
+ * interface in this instance.
+ *
+ * @return the set of known names that will return a valid result from the {@link #create} method
+ */
+ Set<String> supportedNames();
+
+ /**
+ * Creates a new {@code Condition} instance that is associated with the given name.
+ *
+ * @param name a short name used to describe the {@code Condition} instance to create
+ * @return a new {@code Condition} instance, or null if the name is unknown
+ */
+ Condition<? extends ExperimentState> create(String name);
+}
107 core/src/main/java/com/cloudera/gertrude/DiversionCriterion.java
@@ -0,0 +1,107 @@
+/**
+ * Copyright (c) 2013, Cloudera, Inc. All Rights Reserved.
+ *
+ * Cloudera, Inc. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"). You may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for
+ * the specific language governing permissions and limitations under the
+ * License.
+ */
+package com.cloudera.gertrude;
+
+import com.google.common.base.Preconditions;
+
+/**
+ * A rule for diverting an {@link ExperimentState} into a {@link Segment} defined within the
+ * set of {@link Layer} instances in the current {@link ExperimentSpace}.
+ *
+ * <p>For a web application, there may be multiple diversion criteria available, such as
+ * a user's login ID, a cookie stored in a browser, or some other attribute of the request,
+ * such as a search query. The ids of the diversion critera imply a priority ordering of the
+ * criteria (from lowest to highest), so that the diversion rules for a request that contain
+ * multiple identifiers (such as both a login ID and a browser cookie) are unambiguous.
+ *
+ * <p>It is also possible to define a random diversion criterion that is independent of the
+ * request. If one is defined, it should be the lowest priority criteria.
+ *
+ * <p>Each criterion (either fixed or random) must have an associated number of buckets available.
+ * {@code Segment} instances claim buckets for a diversion criterion, and {@code ExperimentState}
+ * instances are assigned to {@code Segment} instances based on how their identifiers are mapped into
+ * buckets.
+ *
+ * <p>See the comments on the {@link ExperimentState#getDiversionIdentifier(int)} method for more
+ * information about working with {@code DiversionCriterion}.
+ */
+public final class DiversionCriterion implements Comparable<DiversionCriterion> {
+ private final int id;
+ private final int numBuckets;
+ private final boolean random;
+
+ public DiversionCriterion(int id, int numBuckets, boolean random) {
+ Preconditions.checkArgument(numBuckets > 0, String.format("Non-positive bucket count for diversion id %d: %d",
+ id, numBuckets));
+ this.id = id;
+ this.numBuckets = numBuckets;
+ this.random = random;
+ }
+
+ /**
+ * Returns the unique id of this diversion criteria that is referenced in
+ * the definition of a {@code Segment}.
+ *
+ * @return the id of this diversion criteria
+ */
+ public int getId() {
+ return id;
+ }
+
+ /**
+ * Returns the number of buckets for this criteria that may be allocated to {@code Segment} instances.
+ * @return the number of buckets for this criteria
+ */
+ public int getNumBuckets() {
+ return numBuckets;
+ }
+
+ /**
+ * Returns true if this is a random criteria that is independent of any information about
+ * the request contained in the {@code ExperimentState}.
+ *
+ * @return true for request-independent diversion
+ */
+ public boolean isRandom() {
+ return random;
+ }
+
+ @Override
+ public int compareTo(DiversionCriterion other) {
+ return id - other.id;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ DiversionCriterion that = (DiversionCriterion) o;
+
+ if (id != that.id) return false;
+ if (numBuckets != that.numBuckets) return false;
+ if (random != that.random) return false;
+
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = id;
+ result = 31 * result + numBuckets;
+ result = 31 * result + (random ? 1 : 0);
+ return result;
+ }
+}
112 core/src/main/java/com/cloudera/gertrude/ExperimentFlag.java
@@ -0,0 +1,112 @@
+/**
+ * Copyright (c) 2013, Cloudera, Inc. All Rights Reserved.
+ *
+ * Cloudera, Inc. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"). You may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for
+ * the specific language governing permissions and limitations under the
+ * License.
+ */
+package com.cloudera.gertrude;
+
+import com.google.common.base.Preconditions;
+
+/**
+ * A named, typed parameter whose value for an {@link ExperimentState} can change depending on
+ * the experiments that the {@code ExperimentState} has been diverted into.
+ *
+ * <p>Instances of this class are created via the static factory methods defined in the
+ * {@link Experiments} namespace:
+ * <pre> {@code
+ *
+ * ExperimentFlag<Double> foo = Experiments.declare("foo_param", 17.29);
+ * ExperimentFlag<Long> bar = Experiments.declare("bar_field", 13);
+ * ExperimentFlag<String> baz = Experiments.declare("baz", "");}</pre>
+ * <p>The value of an {@code ExperimentFlag} for a request is retrieved from an
+ * {@code ExperimentState}:
+ * <pre> {@code
+ *
+ * ExperimentState myState = ...;
+ * double fooValue = myState.get(foo);
+ * float fooFloat = myState.getFloat(foo);
+ * long barValue = myState.get(bar);
+ * int barInt = myState.getInt(bar);
+ * String bazStr = myState.get(baz);}</pre>
+ */
+public final class ExperimentFlag<T> {
+
+ private final String name;
+ private final FlagTypeParser<T> flagTypeParser;
+ private final T defaultValue;
+
+ ExperimentFlag(String name, FlagTypeParser<T> flagTypeParser, T defaultValue) {
+ this.name = Preconditions.checkNotNull(name);
+ this.flagTypeParser = Preconditions.checkNotNull(flagTypeParser);
+ this.defaultValue = Preconditions.checkNotNull(defaultValue);
+ }
+
+ /**
+ * Returns the name of this experiment flag, which should correspond to the name of
+ * a parameter defined in the {@link ExperimentSpace}.
+ *
+ * @return the name of this flag
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Converts the given string into an instance of the type {@code T} of this flag.
+ *
+ * @param value the string form of the value
+ * @return the value as an instance of type {@code T}
+ */
+ public T parse(String value) {
+ return flagTypeParser.parse(value);
+ }
+
+ /**
+ * Returns the default value of this flag, which is what will be returned when a call
+ * to {@link ExperimentState#get(ExperimentFlag)} is made before the {@code ExperimentState}
+ * has been diverted by the {@link ExperimentHandler}.
+ *
+ * @return the default value of this flag
+ */
+ public T getDefaultValue() {
+ return defaultValue;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ ExperimentFlag that = (ExperimentFlag) o;
+
+ if (!defaultValue.equals(that.defaultValue)) return false;
+ if (!flagTypeParser.equals(that.flagTypeParser)) return false;
+ if (!name.equals(that.name)) return false;
+
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = name.hashCode();
+ result = 31 * result + flagTypeParser.hashCode();
+ result = 31 * result + defaultValue.hashCode();
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append(flagTypeParser.toString()).append(" ").append(name).append(" = ").append(defaultValue);
+ return sb.toString();
+ }
+}
92 core/src/main/java/com/cloudera/gertrude/ExperimentFlagSettings.java
@@ -0,0 +1,92 @@
+/**
+ * Copyright (c) 2013, Cloudera, Inc. All Rights Reserved.
+ *
+ * Cloudera, Inc. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"). You may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for
+ * the specific language governing permissions and limitations under the
+ * License.
+ */
+package com.cloudera.gertrude;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Map;
+
+/**
+ * A mapping between experiment flag names and the rules for calculating the value of a flag.
+ *
+ * <p>Clients should usually not interact with this class directly, but rather access its
+ * contents on a per-request basis via the {@link ExperimentState#get} method.
+ */
+public final class ExperimentFlagSettings {
+
+ private static final Logger log = LoggerFactory.getLogger(ExperimentFlagSettings.class);
+
+ private final ExperimentFlagSettings delegate;
+ private final Map<String, ? extends FlagValueCalculator<Object>> entries;
+
+ ExperimentFlagSettings() {
+ this(ImmutableMap.<String, FlagValueCalculator<Object>>of());
+ }
+
+ ExperimentFlagSettings(Map<String, ? extends FlagValueCalculator<Object>> entries) {
+ this(null, entries);
+ }
+
+ ExperimentFlagSettings(
+ ExperimentFlagSettings delegate,
+ Map<String, ? extends FlagValueCalculator<Object>> entries) {
+ this.delegate = delegate;
+ this.entries = Preconditions.checkNotNull(entries);
+ }
+
+ <T> FlagValue<T> getValue(ExperimentFlag<T> flag, ExperimentState state) {
+ FlagValueCalculator<T> calc = (FlagValueCalculator<T>) entries.get(flag.getName());
+ if (calc == null) {
+ if (delegate == null) {
+ log.warn("No calculator defined for experiment flag: " + flag);
+ return FlagValue.of(flag.getDefaultValue(), Condition.CacheLevel.RELOAD);
+ } else {
+ return delegate.getValue(flag, state);
+ }
+ } else {
+ return calc.apply(state);
+ }
+ }
+
+ ExperimentFlagSettings withOverrides(Map<String, ? extends FlagValueCalculator<Object>> overrides) {
+ if (overrides.isEmpty()) {
+ return this;
+ }
+ return new ExperimentFlagSettings(this, overrides);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ ExperimentFlagSettings that = (ExperimentFlagSettings) o;
+
+ if (delegate != null ? !delegate.equals(that.delegate) : that.delegate != null) return false;
+ if (!entries.equals(that.entries)) return false;
+
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = delegate != null ? delegate.hashCode() : 0;
+ result = 31 * result + entries.hashCode();
+ return result;
+ }
+}
103 core/src/main/java/com/cloudera/gertrude/ExperimentHandler.java
@@ -0,0 +1,103 @@
+/**
+ * Copyright (c) 2013, Cloudera, Inc. All Rights Reserved.
+ *
+ * Cloudera, Inc. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"). You may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for
+ * the specific language governing permissions and limitations under the
+ * License.
+ */
+package com.cloudera.gertrude;
+
+import com.codahale.metrics.Meter;
+import com.codahale.metrics.MetricRegistry;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.Sets;
+
+import java.util.Set;
+
+import static com.codahale.metrics.MetricRegistry.name;
+
+/**
+ * Manages experiment diversion for an {@link ExperimentState} based on the data contained in the current
+ * {@link ExperimentSpace}.
+ *
+ * <p>The handler is the link between an {@code ExperimentState} and the currently configured {@link ExperimentSpace}.
+ * As new requests are passed to the {@link #handle(AbstractExperimentState)} method, the {@code ExperimentHandler}
+ * diverts them into experiments that modifies their flag value calculators that are used for the rest of the request
+ * by calling the {@link ExperimentState#get(ExperimentFlag)} methods:
+ *
+ * <pre> {@code
+ * static ExperimentFlag<Boolean> AWESOME_FEATURE_ON = Experiments.declare("awesome", false);
+ * public void doStuff(Request request) {
+ * MyExperimentState state = new MyExperimentState(request);
+ * Experiments.getHandler().handle(state);
+ * if (state.get(AWESOME_FEATURE_ON)) {
+ * // enable *AWESOME* feature
+ * } else {
+ * // enable feature suggested by product manager
+ * }
+ * }}</pre>
+ *
+ * <p>New {@code ExperimentSpace} configurations may be updated asynchronously by the {@link ExperimentSpaceLoader}
+ * that is configured for use with this {@code ExperimentHandler} in the {@link Experiments} namespace. Subsequent
+ * calls to the {@link #handle(AbstractExperimentState)} method will use the latest updates to the
+ * {@code ExperimentSpace} for processing requests, and the {@code ExperimentHandler} is thread-safe.
+ */
+public final class ExperimentHandler {
+
+ private final MetricRegistry metrics;
+ private final Meter requests;
+
+ private volatile ExperimentSpace experimentSpace = new ExperimentSpace();
+
+ ExperimentHandler(MetricRegistry metrics) {
+ this.metrics = Preconditions.checkNotNull(metrics);
+ this.requests = this.metrics.meter(name(ExperimentHandler.class, "requests"));
+ }
+
+ /**
+ * Diverts the given {@code ExperimentState} into one or more experiments across the
+ * {@link Layer} instances in the current {@link ExperimentSpace}.
+ *
+ * <p>A given {@code ExperimentState} can only be diverted into one experiment per layer;
+ * attempting to re-divert an instance that has already been diverted has no effect on
+ * the flag values or experiment ids associated with the state.
+ *
+ * @param state the request to divert
+ */
+ public void handle(AbstractExperimentState state) {
+ requests.mark();
+
+ Set<Integer> newExperimentIds = Sets.newHashSet();
+ experimentSpace.diversion(state, newExperimentIds);
+
+ if (newExperimentIds.isEmpty()) {
+ metrics.meter(name(ExperimentHandler.class, "nodiversion")).mark();
+ } else {
+ for (Integer id : newExperimentIds) {
+ metrics.meter(name(ExperimentHandler.class, String.valueOf(id))).mark();
+ state.addExperimentId(id);
+ }
+ }
+ }
+
+ /**
+ * Returns the version string for the {@code ExperimentSpace} that is currently being used
+ * by this instance to handle requests.
+ *
+ * @return the version string for the current {@code ExperimentSpace}
+ */
+ public String getVersionIdentifier() {
+ return experimentSpace.getVersionIdentifier();
+ }
+
+ void update(ExperimentSpace experimentSpace) {
+ this.experimentSpace = experimentSpace;
+ }
+}
147 core/src/main/java/com/cloudera/gertrude/ExperimentSpace.java
@@ -0,0 +1,147 @@
+/**
+ * Copyright (c) 2013, Cloudera, Inc. All Rights Reserved.
+ *
+ * Cloudera, Inc. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"). You may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for
+ * the specific language governing permissions and limitations under the
+ * License.
+ */
+package com.cloudera.gertrude;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.io.InputSupplier;
+
+import java.io.InputStream;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Defines the space of {@link ExperimentFlagSettings}, {@link Layer}s and {@link Segment}s that are available
+ * for an {@link ExperimentState} to be diverted into by the {@link ExperimentHandler}.
+ *
+ * <p>Clients should not use this class directly; the framework creates a new {@code ExperimentSpace} instance
+ * by analyzing and validating the serialized definition of flags, layers, experiments, and domains that is
+ * configured for this binary by the {@link ExperimentSpaceLoader} and {@link ExperimentSpaceDeserializer},
+ * validates the configuration, and then swaps it in to the {@code ExperimentHandler} for serving new requests
+ * based on any new experiment definitions.
+ */
+public final class ExperimentSpace {
+ private final String versionIdentifier;
+ private final ExperimentFlagSettings baseSettings;
+ private final Map<Integer, Segment> allSegments;
+ private final List<DiversionCriterion> diversionCriteria;
+ private final List<Layer> launchLayers;
+ private final List<Layer> permanentLayers;
+
+ /**
+ * A container for the serialized form of the configuration data used to create a new {@code ExperimentSpace}.
+ * Instances of this class are provided by a subclass of {@link ExperimentSpaceLoader} and are processed into
+ * a valid {@code ExperimentSpace} instance by a subclass of {@link ExperimentSpaceDeserializer}.
+ */
+ public static class Serialized {
+ private final String versionIdentifier;
+ private final List<InputSupplier<? extends InputStream>> serializedData;
+
+ public Serialized(String versionIdentifier, InputSupplier<? extends InputStream> supplier) {
+ this(versionIdentifier, ImmutableList.<InputSupplier<? extends InputStream>>of(supplier));
+ }
+
+ public Serialized(String versionIdentifier, List<InputSupplier<? extends InputStream>> serialized) {
+ this.versionIdentifier = Preconditions.checkNotNull(versionIdentifier);
+ this.serializedData = Preconditions.checkNotNull(serialized);
+ }
+
+ public String getVersionIdentifier() {
+ return versionIdentifier;
+ }
+
+ public List<InputSupplier<? extends InputStream>> getSerializedData() {
+ return serializedData;
+ }
+ }
+
+ ExperimentSpace() {
+ this("");
+ }
+
+ ExperimentSpace(String versionIdentifier) {
+ this.versionIdentifier = versionIdentifier;
+ this.baseSettings = new ExperimentFlagSettings();
+ this.allSegments = ImmutableMap.of();
+ this.diversionCriteria = ImmutableList.of();
+ this.launchLayers = ImmutableList.of();
+ this.permanentLayers = ImmutableList.of();
+ }
+
+ public ExperimentSpace(
+ String versionIdentifier,
+ Map<String, ? extends FlagValueCalculator<Object>> baseSettings,
+ Map<Integer, Segment> allSegments,
+ List<DiversionCriterion> diversionCriteria,
+ List<Layer> allLayers) {
+ this.versionIdentifier = versionIdentifier;
+ this.baseSettings = new ExperimentFlagSettings(baseSettings);
+ this.allSegments = ImmutableMap.copyOf(allSegments);
+ this.diversionCriteria = ImmutableList.copyOf(diversionCriteria);
+ this.launchLayers = Lists.newArrayList();
+ this.permanentLayers = Lists.newArrayList();
+ for (Layer layer : allLayers) {
+ if (layer.isLaunchLayer()) {
+ launchLayers.add(layer);
+ } else {
+ permanentLayers.add(layer);
+ }
+ }
+ }
+
+ String getVersionIdentifier() {
+ return versionIdentifier;
+ }
+
+ void diversion(AbstractExperimentState state, Set<Integer> newExperimentIds) {
+ if (state.forceExperimentIds().isEmpty()) {
+ randomDiversion(state, newExperimentIds);
+ } else {
+ forceDiversion(state, newExperimentIds);
+ }
+ }
+
+ private void randomDiversion(AbstractExperimentState state, Set<Integer> newExperimentIds) {
+ ExperimentFlagSettings llSettings = assignFrom(launchLayers, state, baseSettings, newExperimentIds);
+ state.setFlagSettings(assignFrom(permanentLayers, state, llSettings, newExperimentIds));
+ }
+
+ private void forceDiversion(AbstractExperimentState state, Set<Integer> experimentIds) {
+ Map<String, FlagValueCalculator<Object>> overrides = Maps.newHashMap();
+ for (int forceId : state.forceExperimentIds()) {
+ Segment s = allSegments.get(forceId);
+ if (s != null) {
+ s.handle(state, diversionCriteria, overrides, experimentIds);
+ }
+ }
+ state.setFlagSettings(baseSettings.withOverrides(overrides));
+ }
+
+ private ExperimentFlagSettings assignFrom(
+ List<Layer> experimentsByLayer,
+ ExperimentState state,
+ ExperimentFlagSettings currentSettings,
+ Set<Integer> experimentIds) {
+ Map<String, FlagValueCalculator<Object>> overrides = Maps.newHashMap();
+ for (Layer layer : experimentsByLayer) {
+ layer.assign(state, diversionCriteria, overrides, experimentIds);
+ }
+ return currentSettings.withOverrides(overrides);
+ }
+}
83 core/src/main/java/com/cloudera/gertrude/ExperimentSpaceDeserializer.java
@@ -0,0 +1,83 @@
+/**
+ * Copyright (c) 2013, Cloudera, Inc. All Rights Reserved.
+ *
+ * Cloudera, Inc. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"). You may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for
+ * the specific language governing permissions and limitations under the
+ * License.
+ */
+package com.cloudera.gertrude;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Optional;
+import com.google.common.base.Preconditions;
+
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * Converts an {@link ExperimentSpace.Serialized} instance to an {@link ExperimentSpace} and performs any other
+ * validation and verification checks that are necessary to ensure that the {@code ExperimentSpace} is ready
+ * to be used to serve requests.
+ *
+ * <p>Different binaries may want to handle data deserialization in different ways; the Gertrude framework
+ * attempts to make as few assumptions as possible about dependencies, up to and including serialization
+ * frameworks (such as protocol buffers, Apache Avro, etc.) Other modules in the Gertrude framework provide
+ * concrete implementations of particular serialization formats and associated deserialization code for use
+ * by clients.
+ */
+public abstract class ExperimentSpaceDeserializer {
+ private Map<String, ExperimentFlag<?>> experimentFlags;
+ private ConditionFactory conditionFactory;
+
+ /**
+ * Initialize this instance with the compiled experiment flags and {@code ConditionFactory} needed to
+ * validate and parse the serialized {@code ExperimentSpace}.
+ *
+ * <p>This is only public for testing new serialization frameworks; clients should not use this method
+ * directly.
+ *
+ * @param experimentFlags the experiment flags that have been registered with this binary
+ * @param conditionFactory the {@code ConditionFactory} configured for this binary
+ */
+ @VisibleForTesting
+ public void initialize(Map<String, ExperimentFlag<?>> experimentFlags, ConditionFactory conditionFactory) {
+ if (this.experimentFlags == null && this.conditionFactory == null) {
+ this.experimentFlags = Preconditions.checkNotNull(experimentFlags);
+ this.conditionFactory = Preconditions.checkNotNull(conditionFactory);
+ } else {
+ throw new IllegalStateException("ExperimentSpaceDeserializer has already been initialized");
+ }
+ }
+
+ protected Map<String, ExperimentFlag<?>> getExperimentFlags() {
+ if (experimentFlags == null) {
+ throw new IllegalStateException("ExperimentSpaceDeserializer has not been initialized");
+ }
+ return experimentFlags;
+ }
+
+ protected ConditionFactory getConditionFactory() {
+ if (conditionFactory == null) {
+ throw new IllegalStateException("ExperimentSpaceDeserializer has not been initialized");
+ }
+ return conditionFactory;
+ }
+
+ /**
+ * Attempts to convert the {@code ExperimentSpace.Serialized} data to an {@code ExperimentSpace}, returning
+ * {@link com.google.common.base.Optional#absent()} in the case that the deserialization could not be
+ * performed.
+ *
+ * @param data the serialized form of an {@code ExperimentSpace}
+ * @return a valid {@code ExperimentSpace} or an {@code Optional.absent()} instance
+ * @throws IOException if there is an issue reading the serialized data
+ */
+ protected abstract Optional<ExperimentSpace> deserialize(ExperimentSpace.Serialized data) throws IOException;
+}
74 core/src/main/java/com/cloudera/gertrude/ExperimentSpaceLoader.java
@@ -0,0 +1,74 @@
+/**
+ * Copyright (c) 2013, Cloudera, Inc. All Rights Reserved.
+ *
+ * Cloudera, Inc. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"). You may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for
+ * the specific language governing permissions and limitations under the
+ * License.
+ */
+package com.cloudera.gertrude;
+
+import com.google.common.base.Optional;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+/**
+ * Manages loading and deserializing new {@link ExperimentSpace} instances and configuring the
+ * {@link ExperimentHandler} to start using them once they have been verified and validated.
+ *
+ * <p>There are many ways to provide data updates to a running binary, such as monitoring a file
+ * or watching a Zookeepeer node. Clients should configure an instance of this class with the
+ * {@link Experiments} namespace at server startup time, and several implementations are provided
+ * in other framework modules.
+ */
+public abstract class ExperimentSpaceLoader {
+
+ private static final Logger log = LoggerFactory.getLogger(ExperimentSpaceLoader.class);
+
+ private ExperimentHandler handler;
+ private ExperimentSpaceDeserializer deserializer;
+
+ void initialize(ExperimentHandler handler, ExperimentSpaceDeserializer deserializer) {
+ this.handler = handler;
+ this.deserializer = deserializer;
+ }
+
+ protected synchronized boolean reload(boolean force) {
+ Optional<ExperimentSpace.Serialized> serialized = getSerialized();
+ if (!serialized.isPresent()) {
+ log.warn("No space returned from experiment space supplier, skipping reload");
+ return false;
+ }
+
+ if (!force && serialized.get().getVersionIdentifier().equals(handler.getVersionIdentifier())) {
+ log.info("Skipping reload because experiment space versions match: {}", serialized.get().getVersionIdentifier());
+ return false;
+ }
+
+ Optional<ExperimentSpace> data;
+ try {
+ data = deserializer.deserialize(serialized.get());
+ } catch (IOException e) {
+ log.warn("Unable to reload space", e);
+ return false;
+ }
+
+ if (data.isPresent()) {
+ handler.update(data.get());
+ return true;
+ } else {
+ log.warn("Deserializer could not convert serialized data to a new ExperimentSpace");
+ return false;
+ }
+ }
+
+ protected abstract Optional<ExperimentSpace.Serialized> getSerialized();
+}
116 core/src/main/java/com/cloudera/gertrude/ExperimentState.java
@@ -0,0 +1,116 @@
+/**
+ * Copyright (c) 2013, Cloudera, Inc. All Rights Reserved.
+ *
+ * Cloudera, Inc. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"). You may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for
+ * the specific language governing permissions and limitations under the
+ * License.
+ */
+package com.cloudera.gertrude;
+
+import com.google.common.base.Optional;
+
+import java.util.Set;
+
+/**
+ * Container for all of the information about how to divert a request into experiments
+ * and what the values of experiment flags are for the current request.
+ *
+ * <p>Most of the logic for managing experiment ids and flag value calculations is defined in the
+ * {@link AbstractExperimentState} class, which clients should extend in order to provide a
+ * definition of the {@link #getDiversionIdentifier(int)} method that is appropriate for their
+ * application.
+ *
+ * <p>{@code ExperimentState} is an interface so that clients may create re-usable families of
+ * {@link Condition} functions that expect a specific sub-interface of ExperimentState and are
+ * aware of how to process them. For example, there could be a {@code HttpServletRequestExperimentState}
+ * sub-interface that provided a {@code getServletRequest()} method, and an associated set of
+ * {@code Condition} functions that could process the {@code HttpServletRequest} and make decisions
+ * based on its values. Then any client could access and use those {@code Condition} functions simply
+ * by extending the {@code AbstractExperimentState} and implementing the {@code HttpServletRequestExperimentState}
+ * interface, without having to re-write their own {@code Condition} implementations for every application.
+ */
+public interface ExperimentState {
+
+ /**
+ * Return the identifier to use for this instance with the diversion criterion that
+ * has the given identifer.
+ *
+ * <p>Clients must override this method in order to specify the identifiers that they
+ * support for a given request, and what the values of those identifiers are for each
+ * request.
+ *
+ * <p>Remember that these identifiers are used for randomly diverting requests into
+ * experiments, so they should be unique for each entity that we are experimenting on.
+ * For example, using the country that a request comes from is a relatively poor choice
+ * of a diversion identifier compared to a random account ID or browser cookie.
+ *
+ * @param diversionId The unique id of the requested diversion criterion
+ * @return an identifier to use for diverting this request into an experiment or domain
+ */
+ Optional<String> getDiversionIdentifier(int diversionId);
+
+ /**
+ * Indicate a set of experiment IDs that this {@code ExperimentState} should be forced
+ * into by the {@link ExperimentHandler#handle(AbstractExperimentState)} method.
+ *
+ * <p>By default, this method returns an empty set. Subclasses may override this
+ * method to provide clients with a way to force a request to be in a certain experiment
+ * in order to test or debug a scenario.
+ *
+ * @return the ids of the experiments that this request should be in
+ */
+ Set<Integer> forceExperimentIds();
+
+ /**
+ * Returns the value of the given {@code ExperimentFlag} for this {@code ExperimentState}.
+ *
+ * <p>If the {@link FlagValue} that was calculated for this flag can be cached on a per-request
+ * basis, then this instance will keep the calculated value in a cache so that it is not
+ * re-computed unnecessarily during the same request.
+ *
+ * <p>If this state has not yet been diverted by the {@code ExperimentHandler}, then the
+ * default value of the flag is returned, but not stored in the cache.
+ *
+ * @param flag the flag whose value is returned
+ * @return the value of the flag for this instance
+ */
+ <T> T get(ExperimentFlag<T> flag);
+
+ /**
+ * A convenience method for acccessing the value of a {@code ExperimentFlag<Long>} as an {@code int}.
+ *
+ * @param longFlag the flag
+ * @return the integer point value of the flag
+ */
+ int getInt(ExperimentFlag<Long> longFlag);
+
+ /**
+ * A convenience method for acccessing the value of a {@code ExperimentFlag<Double>} as a {@code float}.
+ *
+ * @param doubleFlag the flag
+ * @return the floating point value of the flag
+ */
+ float getFloat(ExperimentFlag<Double> doubleFlag);
+
+ /**
+ * Returns the integer identifiers for the experiments that this state was diverted into.
+ *
+ * @return the integer identifiers for the experiments that this state was diverted into
+ */
+ Set<Integer> getExperimentIds();
+
+ /**
+ * Indicates whether or not this {@code ExperimentState} has been passed to {@link ExperimentHandler#handle}.
+ *
+ * @return true if this instance has already been diverted, false otherwise
+ */
+ boolean isDiverted();
+
+}
208 core/src/main/java/com/cloudera/gertrude/Experiments.java
@@ -0,0 +1,208 @@
+/**
+ * Copyright (c) 2013, Cloudera, Inc. All Rights Reserved.
+ *
+ * Cloudera, Inc. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"). You may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for
+ * the specific language governing permissions and limitations under the
+ * License.
+ */
+package com.cloudera.gertrude;
+
+import com.codahale.metrics.MetricRegistry;
+import com.google.common.collect.Maps;
+
+import java.util.Map;
+
+/**
+ * A namespace for declaring, configuring, and accessing the core classes in the Gertrude framework.
+ *
+ * <p>Most clients will get started with Gertrude by declaring {@link ExperimentFlag} instances in
+ * their code that reference parameters that can be calculated by the framework:
+ * <pre> {@code
+ * ExperimentFlag<Boolean> featureOn = Experiments.declare("feature", false);
+ * ExperimentFlag<Long> upperLimit = Experiments.declare("limit", 1729L);
+ * ExperimentFlag<Double> threshold = Experiments.declare("threshold_for_model", 0.05);
+ * ExperimentFlag<String> background = Experiments.declare("background_color", "white"); }</pre>
+ *
+ * <p>At server startup time, Gertrude needs some additional classes configured so that the framework
+ * can load and process new experiments from external sources:
+ * <ol>
+ * <li>A {@link ConditionFactory} for mapping from names of {@link Condition} functions to implementations,
+ * <li>a {@link ExperimentSpaceDeserializer} for processing the serialized form of an {@link ExperimentSpace},
+ * <li>a {@link ExperimentSpaceLoader} that is configured with the location of the serialized {@link ExperimentSpace},
+ * <li>and an optional {@link MetricRegistry} for tracking experiment requests and diversions.
+ * </ol>