<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[247628] trunk/Tools</title>
</head>
<body>

<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt;  }
#msg dl a { font-weight: bold}
#msg dl a:link    { color:#fc3; }
#msg dl a:active  { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff  {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta">
<dt>Revision</dt> <dd><a href="http://trac.webkit.org/projects/webkit/changeset/247628">247628</a></dd>
<dt>Author</dt> <dd>jbedard@apple.com</dd>
<dt>Date</dt> <dd>2019-07-18 18:34:38 -0700 (Thu, 18 Jul 2019)</dd>
</dl>

<h3>Log Message</h3>
<pre>results.webkit.org: Move resultsdbpy to WebKit
https://bugs.webkit.org/show_bug.cgi?id=199837
<rdar://problem/53172130>

Rubber-stamped by Aakash Jain.

Moving the entirety of the resultsdbpy library, which provides utilities to build
a database designed to store, visualize and organize test results, into WebKit.

* Scripts/webkitpy/style/checker.py:
(CheckerDispatcher._create_checker): resulltsdbpy is a Python 3 library.
* resultsdbpy: Added.
* resultsdbpy/MANIFEST.in: Added.
* resultsdbpy/README.md: Added.
* resultsdbpy/resultsdbpy: Added.
* resultsdbpy/resultsdbpy/__init__.py: Added.
* resultsdbpy/resultsdbpy/controller: Added.
* resultsdbpy/resultsdbpy/controller/__init__.py: Added.
* resultsdbpy/resultsdbpy/controller/api_routes.py: Added.
* resultsdbpy/resultsdbpy/controller/ci_controller.py: Added.
* resultsdbpy/resultsdbpy/controller/ci_controller_unittest.py: Added.
* resultsdbpy/resultsdbpy/controller/commit.py: Added.
* resultsdbpy/resultsdbpy/controller/commit_controller.py: Added.
* resultsdbpy/resultsdbpy/controller/commit_controller_unittest.py: Added.
* resultsdbpy/resultsdbpy/controller/commit_unittest.py: Added.
* resultsdbpy/resultsdbpy/controller/configuration.py: Added.
* resultsdbpy/resultsdbpy/controller/configuration_controller.py: Added.
* resultsdbpy/resultsdbpy/controller/configuration_controller_unittest.py: Added.
* resultsdbpy/resultsdbpy/controller/configuration_unittest.py: Added.
* resultsdbpy/resultsdbpy/controller/suite_controller.py: Added.
* resultsdbpy/resultsdbpy/controller/suite_controller_unittest.py: Added.
* resultsdbpy/resultsdbpy/controller/test_controller.py: Added.
* resultsdbpy/resultsdbpy/controller/test_controller_unittest.py: Added.
* resultsdbpy/resultsdbpy/controller/upload_controller.py: Added.
* resultsdbpy/resultsdbpy/controller/upload_controller_unittest.py: Added.
* resultsdbpy/resultsdbpy/flask_support: Added.
* resultsdbpy/resultsdbpy/flask_support/__init__.py: Added.
* resultsdbpy/resultsdbpy/flask_support/authed_blueprint.py: Added.
* resultsdbpy/resultsdbpy/flask_support/flask_test_context.py: Added.
* resultsdbpy/resultsdbpy/flask_support/flask_testcase.py: Added.
* resultsdbpy/resultsdbpy/flask_support/util.py: Added.
* resultsdbpy/resultsdbpy/flask_support/util_unittest.py: Added.
* resultsdbpy/resultsdbpy/model: Added.
* resultsdbpy/resultsdbpy/model/__init__.py: Added.
* resultsdbpy/resultsdbpy/model/cassandra_context.py: Added.
* resultsdbpy/resultsdbpy/model/cassandra_context_unittest.py: Added.
* resultsdbpy/resultsdbpy/model/casserole.py: Added.
* resultsdbpy/resultsdbpy/model/casserole_unittest.py: Added.
* resultsdbpy/resultsdbpy/model/ci_context.py: Added.
* resultsdbpy/resultsdbpy/model/ci_context_unittest.py: Added.
* resultsdbpy/resultsdbpy/model/commit_context.py: Added.
* resultsdbpy/resultsdbpy/model/commit_context_unittest.py: Added.
* resultsdbpy/resultsdbpy/model/configuration_context.py: Added.
* resultsdbpy/resultsdbpy/model/configuration_context_unittest.py: Added.
* resultsdbpy/resultsdbpy/model/docker-compose.yml: Added.
* resultsdbpy/resultsdbpy/model/docker.py: Added.
* resultsdbpy/resultsdbpy/model/docker_unittest.py: Added.
* resultsdbpy/resultsdbpy/model/mock_cassandra_context.py: Added.
* resultsdbpy/resultsdbpy/model/mock_model_factory.py: Added.
* resultsdbpy/resultsdbpy/model/mock_repository.py: Added.
* resultsdbpy/resultsdbpy/model/model.py: Added.
* resultsdbpy/resultsdbpy/model/partitioned_redis.py: Added.
* resultsdbpy/resultsdbpy/model/partitioned_redis_unittest.py: Added.
* resultsdbpy/resultsdbpy/model/redis_unittest.py: Added.
* resultsdbpy/resultsdbpy/model/repository.py: Added.
* resultsdbpy/resultsdbpy/model/repository_unittest.py: Added.
* resultsdbpy/resultsdbpy/model/suite_context.py: Added.
* resultsdbpy/resultsdbpy/model/suite_context_unittest.py: Added.
* resultsdbpy/resultsdbpy/model/test_context.py: Added.
* resultsdbpy/resultsdbpy/model/test_context_unittest.py: Added.
* resultsdbpy/resultsdbpy/model/upload_context.py: Added.
* resultsdbpy/resultsdbpy/model/upload_context_unittest.py: Added.
* resultsdbpy/resultsdbpy/model/wait_for_docker_test_case.py: Added.
* resultsdbpy/resultsdbpy/run-tests: Added.
* resultsdbpy/resultsdbpy/view: Added.
* resultsdbpy/resultsdbpy/view/__init__.py: Added.
* resultsdbpy/resultsdbpy/view/ci_view.py: Added.
* resultsdbpy/resultsdbpy/view/commit_view.py: Added.
* resultsdbpy/resultsdbpy/view/commit_view_unittest.py: Added.
* resultsdbpy/resultsdbpy/view/site_menu.py: Added.
* resultsdbpy/resultsdbpy/view/static: Added.
* resultsdbpy/resultsdbpy/view/static/css: Added.
* resultsdbpy/resultsdbpy/view/static/css/commit.css: Added.
* resultsdbpy/resultsdbpy/view/static/css/drawer.css: Added.
* resultsdbpy/resultsdbpy/view/static/css/search.css: Added.
* resultsdbpy/resultsdbpy/view/static/css/timeline.css: Added.
* resultsdbpy/resultsdbpy/view/static/js: Added.
* resultsdbpy/resultsdbpy/view/static/js/commit.js: Added.
* resultsdbpy/resultsdbpy/view/static/js/common.js: Added.
* resultsdbpy/resultsdbpy/view/static/js/configuration.js: Added.
* resultsdbpy/resultsdbpy/view/static/js/drawer.js: Added.
* resultsdbpy/resultsdbpy/view/static/js/search.js: Added.
* resultsdbpy/resultsdbpy/view/static/js/timeline.js: Added.
* resultsdbpy/resultsdbpy/view/suite_view.py: Added.
* resultsdbpy/resultsdbpy/view/templates: Added.
* resultsdbpy/resultsdbpy/view/templates/base.html: Added.
* resultsdbpy/resultsdbpy/view/templates/commit.html: Added.
* resultsdbpy/resultsdbpy/view/templates/commits.html: Added.
* resultsdbpy/resultsdbpy/view/templates/documentation.html: Added.
* resultsdbpy/resultsdbpy/view/templates/error.html: Added.
* resultsdbpy/resultsdbpy/view/templates/search.html: Added.
* resultsdbpy/resultsdbpy/view/templates/suite_results.html: Added.
* resultsdbpy/resultsdbpy/view/view_routes.py: Added.
* resultsdbpy/resultsdbpy/view/view_routes_unittest.py: Added.
* resultsdbpy/setup.py: Added.</pre>

<h3>Modified Paths</h3>
<ul>
<li><a href="#trunkToolsChangeLog">trunk/Tools/ChangeLog</a></li>
<li><a href="#trunkToolsScriptswebkitpystylecheckerpy">trunk/Tools/Scripts/webkitpy/style/checker.py</a></li>
</ul>

<h3>Added Paths</h3>
<ul>
<li>trunk/Tools/resultsdbpy/</li>
<li><a href="#trunkToolsresultsdbpyMANIFESTin">trunk/Tools/resultsdbpy/MANIFEST.in</a></li>
<li><a href="#trunkToolsresultsdbpyREADMEmd">trunk/Tools/resultsdbpy/README.md</a></li>
<li>trunk/Tools/resultsdbpy/resultsdbpy/</li>
<li><a href="#trunkToolsresultsdbpyresultsdbpy__init__py">trunk/Tools/resultsdbpy/resultsdbpy/__init__.py</a></li>
<li>trunk/Tools/resultsdbpy/resultsdbpy/controller/</li>
<li><a href="#trunkToolsresultsdbpyresultsdbpycontroller__init__py">trunk/Tools/resultsdbpy/resultsdbpy/controller/__init__.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpycontrollerapi_routespy">trunk/Tools/resultsdbpy/resultsdbpy/controller/api_routes.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpycontrollerci_controllerpy">trunk/Tools/resultsdbpy/resultsdbpy/controller/ci_controller.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpycontrollerci_controller_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/controller/ci_controller_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpycontrollercommitpy">trunk/Tools/resultsdbpy/resultsdbpy/controller/commit.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpycontrollercommit_controllerpy">trunk/Tools/resultsdbpy/resultsdbpy/controller/commit_controller.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpycontrollercommit_controller_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/controller/commit_controller_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpycontrollercommit_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/controller/commit_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpycontrollerconfigurationpy">trunk/Tools/resultsdbpy/resultsdbpy/controller/configuration.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpycontrollerconfiguration_controllerpy">trunk/Tools/resultsdbpy/resultsdbpy/controller/configuration_controller.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpycontrollerconfiguration_controller_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/controller/configuration_controller_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpycontrollerconfiguration_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/controller/configuration_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpycontrollersuite_controllerpy">trunk/Tools/resultsdbpy/resultsdbpy/controller/suite_controller.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpycontrollersuite_controller_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/controller/suite_controller_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpycontrollertest_controllerpy">trunk/Tools/resultsdbpy/resultsdbpy/controller/test_controller.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpycontrollertest_controller_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/controller/test_controller_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpycontrollerupload_controllerpy">trunk/Tools/resultsdbpy/resultsdbpy/controller/upload_controller.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpycontrollerupload_controller_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/controller/upload_controller_unittest.py</a></li>
<li>trunk/Tools/resultsdbpy/resultsdbpy/flask_support/</li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyflask_support__init__py">trunk/Tools/resultsdbpy/resultsdbpy/flask_support/__init__.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyflask_supportauthed_blueprintpy">trunk/Tools/resultsdbpy/resultsdbpy/flask_support/authed_blueprint.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyflask_supportflask_test_contextpy">trunk/Tools/resultsdbpy/resultsdbpy/flask_support/flask_test_context.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyflask_supportflask_testcasepy">trunk/Tools/resultsdbpy/resultsdbpy/flask_support/flask_testcase.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyflask_supportutilpy">trunk/Tools/resultsdbpy/resultsdbpy/flask_support/util.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyflask_supportutil_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/flask_support/util_unittest.py</a></li>
<li>trunk/Tools/resultsdbpy/resultsdbpy/model/</li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodel__init__py">trunk/Tools/resultsdbpy/resultsdbpy/model/__init__.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelcassandra_contextpy">trunk/Tools/resultsdbpy/resultsdbpy/model/cassandra_context.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelcassandra_context_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/model/cassandra_context_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelcasserolepy">trunk/Tools/resultsdbpy/resultsdbpy/model/casserole.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelcasserole_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/model/casserole_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelci_contextpy">trunk/Tools/resultsdbpy/resultsdbpy/model/ci_context.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelci_context_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/model/ci_context_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelcommit_contextpy">trunk/Tools/resultsdbpy/resultsdbpy/model/commit_context.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelcommit_context_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/model/commit_context_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelconfiguration_contextpy">trunk/Tools/resultsdbpy/resultsdbpy/model/configuration_context.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelconfiguration_context_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/model/configuration_context_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodeldockercomposeyml">trunk/Tools/resultsdbpy/resultsdbpy/model/docker-compose.yml</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodeldockerpy">trunk/Tools/resultsdbpy/resultsdbpy/model/docker.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodeldocker_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/model/docker_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelmock_cassandra_contextpy">trunk/Tools/resultsdbpy/resultsdbpy/model/mock_cassandra_context.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelmock_model_factorypy">trunk/Tools/resultsdbpy/resultsdbpy/model/mock_model_factory.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelmock_repositorypy">trunk/Tools/resultsdbpy/resultsdbpy/model/mock_repository.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelmodelpy">trunk/Tools/resultsdbpy/resultsdbpy/model/model.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelpartitioned_redispy">trunk/Tools/resultsdbpy/resultsdbpy/model/partitioned_redis.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelpartitioned_redis_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/model/partitioned_redis_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelredis_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/model/redis_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelrepositorypy">trunk/Tools/resultsdbpy/resultsdbpy/model/repository.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelrepository_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/model/repository_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelsuite_contextpy">trunk/Tools/resultsdbpy/resultsdbpy/model/suite_context.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelsuite_context_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/model/suite_context_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodeltest_contextpy">trunk/Tools/resultsdbpy/resultsdbpy/model/test_context.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodeltest_context_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/model/test_context_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelupload_contextpy">trunk/Tools/resultsdbpy/resultsdbpy/model/upload_context.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelupload_context_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/model/upload_context_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpymodelwait_for_docker_test_casepy">trunk/Tools/resultsdbpy/resultsdbpy/model/wait_for_docker_test_case.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyruntests">trunk/Tools/resultsdbpy/resultsdbpy/run-tests</a></li>
<li>trunk/Tools/resultsdbpy/resultsdbpy/view/</li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyview__init__py">trunk/Tools/resultsdbpy/resultsdbpy/view/__init__.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewci_viewpy">trunk/Tools/resultsdbpy/resultsdbpy/view/ci_view.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewcommit_viewpy">trunk/Tools/resultsdbpy/resultsdbpy/view/commit_view.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewcommit_view_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/view/commit_view_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewsite_menupy">trunk/Tools/resultsdbpy/resultsdbpy/view/site_menu.py</a></li>
<li>trunk/Tools/resultsdbpy/resultsdbpy/view/static/</li>
<li>trunk/Tools/resultsdbpy/resultsdbpy/view/static/css/</li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewstaticcsscommitcss">trunk/Tools/resultsdbpy/resultsdbpy/view/static/css/commit.css</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewstaticcssdrawercss">trunk/Tools/resultsdbpy/resultsdbpy/view/static/css/drawer.css</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewstaticcsssearchcss">trunk/Tools/resultsdbpy/resultsdbpy/view/static/css/search.css</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewstaticcsstimelinecss">trunk/Tools/resultsdbpy/resultsdbpy/view/static/css/timeline.css</a></li>
<li>trunk/Tools/resultsdbpy/resultsdbpy/view/static/js/</li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewstaticjscommitjs">trunk/Tools/resultsdbpy/resultsdbpy/view/static/js/commit.js</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewstaticjscommonjs">trunk/Tools/resultsdbpy/resultsdbpy/view/static/js/common.js</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewstaticjsconfigurationjs">trunk/Tools/resultsdbpy/resultsdbpy/view/static/js/configuration.js</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewstaticjsdrawerjs">trunk/Tools/resultsdbpy/resultsdbpy/view/static/js/drawer.js</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewstaticjssearchjs">trunk/Tools/resultsdbpy/resultsdbpy/view/static/js/search.js</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewstaticjstimelinejs">trunk/Tools/resultsdbpy/resultsdbpy/view/static/js/timeline.js</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewsuite_viewpy">trunk/Tools/resultsdbpy/resultsdbpy/view/suite_view.py</a></li>
<li>trunk/Tools/resultsdbpy/resultsdbpy/view/templates/</li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewtemplatesbasehtml">trunk/Tools/resultsdbpy/resultsdbpy/view/templates/base.html</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewtemplatescommithtml">trunk/Tools/resultsdbpy/resultsdbpy/view/templates/commit.html</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewtemplatescommitshtml">trunk/Tools/resultsdbpy/resultsdbpy/view/templates/commits.html</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewtemplatesdocumentationhtml">trunk/Tools/resultsdbpy/resultsdbpy/view/templates/documentation.html</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewtemplateserrorhtml">trunk/Tools/resultsdbpy/resultsdbpy/view/templates/error.html</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewtemplatessearchhtml">trunk/Tools/resultsdbpy/resultsdbpy/view/templates/search.html</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewtemplatessuite_resultshtml">trunk/Tools/resultsdbpy/resultsdbpy/view/templates/suite_results.html</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewview_routespy">trunk/Tools/resultsdbpy/resultsdbpy/view/view_routes.py</a></li>
<li><a href="#trunkToolsresultsdbpyresultsdbpyviewview_routes_unittestpy">trunk/Tools/resultsdbpy/resultsdbpy/view/view_routes_unittest.py</a></li>
<li><a href="#trunkToolsresultsdbpysetuppy">trunk/Tools/resultsdbpy/setup.py</a></li>
</ul>

</div>
<div id="patch">
<h3>Diff</h3>
<a id="trunkToolsChangeLog"></a>
<div class="modfile"><h4>Modified: trunk/Tools/ChangeLog (247627 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/ChangeLog    2019-07-19 00:23:31 UTC (rev 247627)
+++ trunk/Tools/ChangeLog       2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -1,3 +1,111 @@
</span><ins>+2019-07-18  Jonathan Bedard  <jbedard@apple.com>
+
+        results.webkit.org: Move resultsdbpy to WebKit
+        https://bugs.webkit.org/show_bug.cgi?id=199837
+        <rdar://problem/53172130>
+
+        Rubber-stamped by Aakash Jain.
+
+        Moving the entirety of the resultsdbpy library, which provides utilities to build
+        a database designed to store, visualize and organize test results, into WebKit.
+
+        * Scripts/webkitpy/style/checker.py:
+        (CheckerDispatcher._create_checker): resulltsdbpy is a Python 3 library.
+        * resultsdbpy: Added.
+        * resultsdbpy/MANIFEST.in: Added.
+        * resultsdbpy/README.md: Added.
+        * resultsdbpy/resultsdbpy: Added.
+        * resultsdbpy/resultsdbpy/__init__.py: Added.
+        * resultsdbpy/resultsdbpy/controller: Added.
+        * resultsdbpy/resultsdbpy/controller/__init__.py: Added.
+        * resultsdbpy/resultsdbpy/controller/api_routes.py: Added.
+        * resultsdbpy/resultsdbpy/controller/ci_controller.py: Added.
+        * resultsdbpy/resultsdbpy/controller/ci_controller_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/controller/commit.py: Added.
+        * resultsdbpy/resultsdbpy/controller/commit_controller.py: Added.
+        * resultsdbpy/resultsdbpy/controller/commit_controller_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/controller/commit_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/controller/configuration.py: Added.
+        * resultsdbpy/resultsdbpy/controller/configuration_controller.py: Added.
+        * resultsdbpy/resultsdbpy/controller/configuration_controller_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/controller/configuration_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/controller/suite_controller.py: Added.
+        * resultsdbpy/resultsdbpy/controller/suite_controller_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/controller/test_controller.py: Added.
+        * resultsdbpy/resultsdbpy/controller/test_controller_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/controller/upload_controller.py: Added.
+        * resultsdbpy/resultsdbpy/controller/upload_controller_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/flask_support: Added.
+        * resultsdbpy/resultsdbpy/flask_support/__init__.py: Added.
+        * resultsdbpy/resultsdbpy/flask_support/authed_blueprint.py: Added.
+        * resultsdbpy/resultsdbpy/flask_support/flask_test_context.py: Added.
+        * resultsdbpy/resultsdbpy/flask_support/flask_testcase.py: Added.
+        * resultsdbpy/resultsdbpy/flask_support/util.py: Added.
+        * resultsdbpy/resultsdbpy/flask_support/util_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/model: Added.
+        * resultsdbpy/resultsdbpy/model/__init__.py: Added.
+        * resultsdbpy/resultsdbpy/model/cassandra_context.py: Added.
+        * resultsdbpy/resultsdbpy/model/cassandra_context_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/model/casserole.py: Added.
+        * resultsdbpy/resultsdbpy/model/casserole_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/model/ci_context.py: Added.
+        * resultsdbpy/resultsdbpy/model/ci_context_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/model/commit_context.py: Added.
+        * resultsdbpy/resultsdbpy/model/commit_context_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/model/configuration_context.py: Added.
+        * resultsdbpy/resultsdbpy/model/configuration_context_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/model/docker-compose.yml: Added.
+        * resultsdbpy/resultsdbpy/model/docker.py: Added.
+        * resultsdbpy/resultsdbpy/model/docker_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/model/mock_cassandra_context.py: Added.
+        * resultsdbpy/resultsdbpy/model/mock_model_factory.py: Added.
+        * resultsdbpy/resultsdbpy/model/mock_repository.py: Added.
+        * resultsdbpy/resultsdbpy/model/model.py: Added.
+        * resultsdbpy/resultsdbpy/model/partitioned_redis.py: Added.
+        * resultsdbpy/resultsdbpy/model/partitioned_redis_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/model/redis_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/model/repository.py: Added.
+        * resultsdbpy/resultsdbpy/model/repository_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/model/suite_context.py: Added.
+        * resultsdbpy/resultsdbpy/model/suite_context_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/model/test_context.py: Added.
+        * resultsdbpy/resultsdbpy/model/test_context_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/model/upload_context.py: Added.
+        * resultsdbpy/resultsdbpy/model/upload_context_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/model/wait_for_docker_test_case.py: Added.
+        * resultsdbpy/resultsdbpy/run-tests: Added.
+        * resultsdbpy/resultsdbpy/view: Added.
+        * resultsdbpy/resultsdbpy/view/__init__.py: Added.
+        * resultsdbpy/resultsdbpy/view/ci_view.py: Added.
+        * resultsdbpy/resultsdbpy/view/commit_view.py: Added.
+        * resultsdbpy/resultsdbpy/view/commit_view_unittest.py: Added.
+        * resultsdbpy/resultsdbpy/view/site_menu.py: Added.
+        * resultsdbpy/resultsdbpy/view/static: Added.
+        * resultsdbpy/resultsdbpy/view/static/css: Added.
+        * resultsdbpy/resultsdbpy/view/static/css/commit.css: Added.
+        * resultsdbpy/resultsdbpy/view/static/css/drawer.css: Added.
+        * resultsdbpy/resultsdbpy/view/static/css/search.css: Added.
+        * resultsdbpy/resultsdbpy/view/static/css/timeline.css: Added.
+        * resultsdbpy/resultsdbpy/view/static/js: Added.
+        * resultsdbpy/resultsdbpy/view/static/js/commit.js: Added.
+        * resultsdbpy/resultsdbpy/view/static/js/common.js: Added.
+        * resultsdbpy/resultsdbpy/view/static/js/configuration.js: Added.
+        * resultsdbpy/resultsdbpy/view/static/js/drawer.js: Added.
+        * resultsdbpy/resultsdbpy/view/static/js/search.js: Added.
+        * resultsdbpy/resultsdbpy/view/static/js/timeline.js: Added.
+        * resultsdbpy/resultsdbpy/view/suite_view.py: Added.
+        * resultsdbpy/resultsdbpy/view/templates: Added.
+        * resultsdbpy/resultsdbpy/view/templates/base.html: Added.
+        * resultsdbpy/resultsdbpy/view/templates/commit.html: Added.
+        * resultsdbpy/resultsdbpy/view/templates/commits.html: Added.
+        * resultsdbpy/resultsdbpy/view/templates/documentation.html: Added.
+        * resultsdbpy/resultsdbpy/view/templates/error.html: Added.
+        * resultsdbpy/resultsdbpy/view/templates/search.html: Added.
+        * resultsdbpy/resultsdbpy/view/templates/suite_results.html: Added.
+        * resultsdbpy/resultsdbpy/view/view_routes.py: Added.
+        * resultsdbpy/resultsdbpy/view/view_routes_unittest.py: Added.
+        * resultsdbpy/setup.py: Added.
+
</ins><span class="cx"> 2019-07-18  Alex Christensen  <achristensen@webkit.org>
</span><span class="cx"> 
</span><span class="cx">         Add and test _WKWebsiteDataStoreConfiguration.deviceIdHashSaltsStorageDirectory SPI
</span></span></pre></div>
<a id="trunkToolsScriptswebkitpystylecheckerpy"></a>
<div class="modfile"><h4>Modified: trunk/Tools/Scripts/webkitpy/style/checker.py (247627 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/Scripts/webkitpy/style/checker.py    2019-07-19 00:23:31 UTC (rev 247627)
+++ trunk/Tools/Scripts/webkitpy/style/checker.py       2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -702,7 +702,7 @@
</span><span class="cx">             else:
</span><span class="cx">                 checker = JSONChecker(file_path, handle_style_error)
</span><span class="cx">         elif file_type == FileType.PYTHON:
</span><del>-            python3_paths = []
</del><ins>+            python3_paths = ['Tools/resultsdbpy']
</ins><span class="cx">             for partial in python3_paths:
</span><span class="cx">                 if file_path.startswith(partial):
</span><span class="cx">                     return Python3Checker(file_path, handle_style_error)
</span></span></pre></div>
<a id="trunkToolsresultsdbpyMANIFESTin"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/MANIFEST.in (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/MANIFEST.in                              (rev 0)
+++ trunk/Tools/resultsdbpy/MANIFEST.in 2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1 @@
</span><ins>+include README.md
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyREADMEmd"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/README.md (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/README.md                                (rev 0)
+++ trunk/Tools/resultsdbpy/README.md   2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,79 @@
</span><ins>+# resultsdbpy
+
+Large projects (like [WebKit](https://webkit.org)) often have 10's of thousands of tests running on dozens of platforms. Making sense of results from theses tests is difficult. resultsdbpy aims to make visualizing, processing and storing those results easier.
+
+## Requirements
+
+For local testing and basic prototyping, nothing is required. All data can be managed in-memory. Note, however, that running the database in this mode will not save results to disk.
+
+If leveraging Docker, Redis and Cassandra will be automatically installed and can be used to make results more persistent.
+
+For production instances, the Cassandra and Redis instances should be hosted seperatly from the web-app.
+
+
+## Usage
+
+resultsdbpy requires fairly extensive configuration before being used. Below is an example of configuring resultsdbpy for testing and basic prototyping for Webkit, along with some comments explaining the environment setup:
+
+```
+import os
+
+from fakeredis import FakeStrictRedis
+from flask import Flask, request
+from resultsdbpy.controller.api_routes import APIRoutes
+from resultsdbpy.model.mock_cassandra_context import MockCassandraContext
+from resultsdbpy.model.model import Model
+from resultsdbpy.model.repository import WebKitRepository
+from resultsdbpy.view.view_routes import ViewRoutes
+
+# By default, Cassandra forbids schema management
+os.environ['CQLENG_ALLOW_SCHEMA_MANAGEMENT'] = '1'
+
+# An in-memory Cassandra database for testing
+cassandra=MockCassandraContext(
+       nodes=['localhost'],
+       keyspace='testing-kespace',
+       create_keyspace=True,
+)
+
+model = Model(
+       redis=FakeStrictRedis(),                 # An in-memory Redis database for testing
+       cassandra=cassandra,
+       repositories=[WebKitRepository()],       # This should be replaced with a class for your project's repository
+       default_ttl_seconds=Model.TTL_WEEK * 4,  # Retain 4 weeks of results
+       async_processing=False,                  # Processing asynchronously requires instantiating worker processes
+)
+
+app = Flask(__name__)
+api_routes = APIRoutes(model=model, import_name=__name__)
+view_routes = ViewRoutes(
+    title='WebKit Results Database',
+    model=model, controller=api_routes, import_name=__name__,
+)
+
+
+@app.route('/__health', methods=('GET',))
+def health():
+    return 'ok'
+
+
+@app.errorhandler(404)
+@app.errorhandler(405)
+def handle_errors(error):
+    if request.path.startswith('/api/'):
+        return api_routes.error_response(error)
+    return view_routes.error(error=error)
+
+
+app.register_blueprint(api_routes)
+app.register_blueprint(view_routes)
+
+
+def main():
+    app.run(host='0.0.0.0', port=5000)
+
+
+if __name__ == '__main__':
+    main()
+
+```
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpy__init__py"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/__init__.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/__init__.py                          (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/__init__.py     2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,23 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+name = 'resultsdbpy'
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpycontroller__init__py"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/controller/__init__.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/controller/__init__.py                               (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/controller/__init__.py  2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1 @@
</span><ins>+# DO NOTHING
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpycontrollerapi_routespy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/controller/api_routes.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/controller/api_routes.py                             (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/controller/api_routes.py        2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,88 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import traceback
+
+from flask import abort, jsonify
+from resultsdbpy.flask_support.authed_blueprint import AuthedBlueprint
+from resultsdbpy.controller.commit_controller import CommitController
+from resultsdbpy.controller.ci_controller import CIController
+from resultsdbpy.controller.suite_controller import SuiteController
+from resultsdbpy.controller.test_controller import TestController
+from resultsdbpy.controller.upload_controller import UploadController
+from werkzeug.exceptions import HTTPException
+
+
+class APIRoutes(AuthedBlueprint):
+    def __init__(self, model, import_name=__name__, auth_decorator=None):
+        super(APIRoutes, self).__init__('controller', import_name, url_prefix='/api', auth_decorator=auth_decorator)
+
+        self.commit_controller = CommitController(commit_context=model.commit_context)
+        self.upload_controller = UploadController(commit_controller=self.commit_controller, upload_context=model.upload_context)
+
+        self.suite_controller = SuiteController(suite_context=model.suite_context)
+        self.test_controller = TestController(test_context=model.test_context)
+
+        self.ci_controller = CIController(ci_context=model.ci_context, upload_context=model.upload_context)
+
+        for code in [400, 404, 405]:
+            self.register_error_handler(code, self.error_response)
+        self.register_error_handler(500, self.response_500)
+
+        self.add_url_rule('/commits', 'commit_controller', self.commit_controller.default, methods=('GET', 'POST'))
+        self.add_url_rule('/commits/find', 'commit_controller_find', self.commit_controller.find, methods=('GET',))
+        self.add_url_rule('/commits/repositories', 'commit_controller_repositories', self.commit_controller.repositories, methods=('GET',))
+        self.add_url_rule('/commits/branches', 'commit_controller_branches',  self.commit_controller.branches, methods=('GET',))
+        self.add_url_rule('/commits/siblings', 'commit_controller_siblings', self.commit_controller.siblings, methods=('GET',))
+        self.add_url_rule('/commits/next', 'commit_controller_next', self.commit_controller.next, methods=('GET',))
+        self.add_url_rule('/commits/previous', 'commit_controller_previous', self.commit_controller.previous, methods=('GET',))
+        self.add_url_rule('/commits/register', 'commit_controller_register', self.commit_controller.register, methods=('POST',))
+
+        self.add_url_rule('/upload', 'upload', self.upload_controller.upload, methods=('GET', 'POST'))
+        self.add_url_rule('/upload/process', 'process', self.upload_controller.process, methods=('POST',))
+        self.add_url_rule('/suites', 'suites', self.upload_controller.suites, methods=('GET',))
+        self.add_url_rule('/<path:suite>/tests', 'tests-in-suite', self.test_controller.list_tests, methods=('GET',))
+
+        self.add_url_rule('/results/<path:suite>', 'suite-results', self.suite_controller.find_run_results, methods=('GET',))
+        self.add_url_rule('/results/<path:suite>/<path:test>', 'test-results', self.test_controller.find_test_result, methods=('GET',))
+
+        self.add_url_rule('/urls/queue', 'queue-urls', self.ci_controller.urls_for_queue_endpoint, methods=('GET',))
+        self.add_url_rule('/urls', 'build-urls', self.ci_controller.urls_for_builds_endpoint, methods=('GET',))
+
+    def error_response(self, error):
+        response = jsonify(status='error', error=error.name, description=error.description)
+        response.status = f'error.{error.name}'
+        response.status_code = error.code
+        return response
+
+    def response_500(cls, error):
+        if isinstance(error, HTTPException):
+            print(traceback.format_stack())
+            response = jsonify(status='error', error=error.name, description=error.description)
+            response.status = f'error.{error.name}'
+            response.status_code = error.code
+            return response
+
+        response = jsonify(status='error', error='Internal Server Error', description=str(error))
+        response.status = 'error.Internal Server Error'
+        response.status_code = 500
+        abort(response)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpycontrollerci_controllerpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/controller/ci_controller.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/controller/ci_controller.py                          (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/controller/ci_controller.py     2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,140 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from flask import abort, jsonify
+from resultsdbpy.controller.commit_controller import uuid_range_for_query, HasCommitContext
+from resultsdbpy.controller.configuration import Configuration
+from resultsdbpy.controller.configuration_controller import configuration_for_query
+from resultsdbpy.controller.suite_controller import time_range_for_query
+from resultsdbpy.flask_support.util import AssertRequest, query_as_kwargs, limit_for_query, boolean_query
+
+
+class CIController(HasCommitContext):
+    DEFAULT_QUERY_LIMIT = 100
+
+    def __init__(self, ci_context, upload_context):
+        super(CIController, self).__init__(ci_context.commit_context)
+        self.ci_context = ci_context
+        self.upload_context = upload_context
+
+    def _suites_for_query_arguments(self, suite=None, configurations=None, is_recent=True):
+        # The user may have specified a set of suites to search by, or we need to determine the
+        # suites based on the current configuration.
+        if suite:
+            return suite
+
+        with self.upload_context:
+            suites = set()
+            # Returns a dictionary where the keys are configuration objects and the values are strings
+            # representing suites associated with that configuration.
+            for candidate_suites in self.upload_context.find_suites(configurations=configurations, recent=is_recent).values():
+                for candidate in candidate_suites:
+                    suites.add(candidate)
+
+            return sorted(suites)
+
+    @limit_for_query(DEFAULT_QUERY_LIMIT)
+    @configuration_for_query()
+    def urls_for_queue(self, suite=None, branch=None, configurations=None, recent=None, limit=None, **kwargs):
+        AssertRequest.is_type(['GET'])
+        AssertRequest.query_kwargs_empty(**kwargs)
+
+        is_recent = True
+        if recent:
+            is_recent = boolean_query(*recent)[0]
+
+        with self.ci_context, self.upload_context:
+            suites = self._suites_for_query_arguments(suite=suite, configurations=configurations, is_recent=is_recent)
+            if not branch:
+                branch = [None]
+
+            results = []
+            for suite in suites:
+                for config, url in self.ci_context.find_urls_by_queue(
+                    configurations=configurations, recent=is_recent,
+                    branch=branch[0], suite=suite, limit=limit,
+                ).items():
+                    configuration_dict = Configuration.Encoder().default(config)
+                    configuration_dict['suite'] = suite
+                    results.append(dict(configuration=configuration_dict, url=url))
+
+            return results
+
+    @uuid_range_for_query()
+    @limit_for_query(DEFAULT_QUERY_LIMIT)
+    @configuration_for_query()
+    @time_range_for_query()
+    def urls_for_builds(
+        self, suite=None,
+        configurations=None, recent=None,
+        branch=None, begin=None, end=None,
+        begin_query_time=None, end_query_time=None,
+        limit=None, **kwargs
+    ):
+        AssertRequest.is_type(['GET'])
+        AssertRequest.query_kwargs_empty(**kwargs)
+
+        is_recent = True
+        if recent:
+            is_recent = boolean_query(*recent)[0]
+
+        with self.ci_context, self.upload_context:
+            suites = self._suites_for_query_arguments(suite=suite, configurations=configurations, is_recent=is_recent)
+            if not branch:
+                branch = [None]
+
+            query_dict = dict(
+                configurations=configurations, recent=is_recent,
+                branch=branch[0], begin=begin, end=end,
+                begin_query_time=begin_query_time, end_query_time=end_query_time,
+                limit=limit,
+            )
+            num_uuid_query_args = sum([1 if element else 0 for element in [begin, end]])
+            num_timestamp_query_args = sum([1 if element else 0 for element in [begin_query_time, end_query_time]])
+
+            if num_uuid_query_args >= num_timestamp_query_args:
+                find_function = self.ci_context.find_urls_by_commit
+
+                def sort_function(result):
+                    return result['uuid']
+            else:
+                find_function = self.ci_context.find_urls_by_start_time
+
+                def sort_function(result):
+                    return result['start_time']
+
+            results = []
+            for suite in suites:
+                for config, urls in find_function(suite=suite, **query_dict).items():
+                    configuration_dict = Configuration.Encoder().default(config)
+                    configuration_dict['suite'] = suite
+                    results.append(dict(configuration=configuration_dict, urls=sorted(urls, key=sort_function)))
+
+            return results
+
+    @query_as_kwargs()
+    def urls_for_queue_endpoint(self, **kwargs):
+        return jsonify(self.urls_for_queue(**kwargs))
+
+    @query_as_kwargs()
+    def urls_for_builds_endpoint(self, **kwargs):
+        return jsonify(self.urls_for_builds(**kwargs))
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpycontrollerci_controller_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/controller/ci_controller_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/controller/ci_controller_unittest.py                         (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/controller/ci_controller_unittest.py    2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,132 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import time
+
+from fakeredis import FakeStrictRedis
+from redis import StrictRedis
+from resultsdbpy.controller.api_routes import APIRoutes
+from resultsdbpy.controller.configuration import Configuration
+from resultsdbpy.flask_support.flask_testcase import FlaskTestCase
+from resultsdbpy.model.cassandra_context import CassandraContext
+from resultsdbpy.model.ci_context import BuildbotURLFactory
+from resultsdbpy.model.ci_context_unittest import URLFactoryTest
+from resultsdbpy.model.mock_cassandra_context import MockCassandraContext
+from resultsdbpy.model.mock_model_factory import MockModelFactory
+from resultsdbpy.model.test_context import Expectations
+from resultsdbpy.model.wait_for_docker_test_case import WaitForDockerTestCase
+
+
+class CIControllerTest(FlaskTestCase, WaitForDockerTestCase):
+    KEYSPACE = 'ci_controller_test_keyspace'
+
+    @classmethod
+    def setup_webserver(cls, app, redis=StrictRedis, cassandra=CassandraContext):
+        with URLFactoryTest.mock():
+            cassandra.drop_keyspace(keyspace=cls.KEYSPACE)
+            model = MockModelFactory.create(redis=redis(), cassandra=cassandra(keyspace=cls.KEYSPACE, create_keyspace=True))
+            model.ci_context.add_url_factory(BuildbotURLFactory(master='build.webkit.org', redis=model.redis))
+            app.register_blueprint(APIRoutes(model))
+
+            with model.upload_context:
+                # Mock results are more complicated because we want to attach results to builders
+                for configuration in [
+                    Configuration(platform='Mac', version_name='Catalina', version='10.15.0', sdk='19A500', is_simulator=False, architecture='x86_64', style='Release', flavor='wk1'),
+                    Configuration(platform='Mac', version_name='Catalina', version='10.15.0', sdk='19A500', is_simulator=False, architecture='x86_64', style='Release', flavor='wk2'),
+                    Configuration(platform='Mac', version_name='Mojave', version='10.14.0', sdk='18A500', is_simulator=False, architecture='x86_64', style='Release', flavor='wk1'),
+                    Configuration(platform='Mac', version_name='Mojave', version='10.14.0', sdk='18A500', is_simulator=False, architecture='x86_64', style='Release', flavor='wk2'),
+                ]:
+                    build_count = [1]
+
+                    def callback(commits, model=model, configuration=configuration, count=build_count):
+                        results = MockModelFactory.layout_test_results()
+                        results['details'] = {
+                            'buildbot-master': URLFactoryTest.BUILD_MASTER,
+                            'builder-name': f'{configuration.version_name}-{configuration.style}-{configuration.flavor.upper()}-Tests',
+                            'build-number': str(count[0]),
+                            'buildbot-worker': {
+                                'Mojave': 'bot1',
+                                'Catalina': 'bot2',
+                            }.get(configuration.version_name, None),
+                        }
+                        model.upload_context.upload_test_results(configuration, commits, suite='layout-tests', timestamp=time.time(), test_results=results)
+                        count[0] += 1
+
+                    MockModelFactory.iterate_all_commits(model, callback)
+                    MockModelFactory.process_results(model, configuration)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_all_queue_list(self, client, **kwargs):
+        response = client.get(self.URL + '/api/urls/queue')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.json()), 4)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_single_queue_list(self, client, **kwargs):
+        response = client.get(self.URL + '/api/urls/queue?version_name=Catalina&flavor=wk2')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), [
+            dict(
+                configuration=dict(
+                    is_simulator=False, platform='Mac',
+                    architecture='x86_64', style='Release', flavor='wk2',
+                    sdk='19A500', suite='layout-tests', version=10015000, version_name='Catalina',
+                ),
+                link='https://build.webkit.org/#/builders/5',
+            )])
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_all_builds_list(self, client, **kwargs):
+        response = client.get(self.URL + '/api/urls')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.json()), 4)
+        for data in response.json():
+            self.assertEqual(len(data['urls']), 5)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_single_queue_list(self, client, **kwargs):
+        response = client.get(self.URL + '/api/urls?version_name=Catalina&flavor=wk2&id=236542')
+        self.assertEqual(response.status_code, 200)
+        print(response.json()[0]['urls'])
+        self.assertEqual(response.json()[0]['urls'][0]['build'], 'https://build.webkit.org/#/builders/5/builds/3')
+        self.assertEqual(response.json()[0]['urls'][0]['queue'], 'https://build.webkit.org/#/builders/5')
+        self.assertEqual(response.json()[0]['urls'][0]['worker'], 'https://build.webkit.org/#/workers/4')
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_all_builds_list_by_time(self, client, **kwargs):
+        response = client.get(self.URL + f'/api/urls?after_time={time.time() - 2}')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.json()), 4)
+        for data in response.json():
+            self.assertEqual(len(data['urls']), 5)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_no_builds_list_by_time(self, client, **kwargs):
+        response = client.get(self.URL + f'/api/urls?after_time={time.time() + 2}')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.json()), 0)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpycontrollercommitpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/controller/commit.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/controller/commit.py                         (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/controller/commit.py    2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,118 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import calendar
+import json
+
+from datetime import datetime
+from resultsdbpy.flask_support.util import FlaskJSONEncoder
+
+
+class Commit(object):
+    TIMESTAMP_TO_UUID_MULTIPLIER = 100
+
+    @classmethod
+    def from_json(cls, data):
+        data = data if isinstance(data, dict) else json.loads(data)
+        return cls(
+            repository_id=data.get('repository_id'),
+            branch=data.get('branch'),
+            id=data.get('id'),
+            timestamp=data.get('timestamp'),
+            order=data.get('order'),
+            committer=data.get('committer'),
+            message=data.get('message'),
+        )
+
+    def __init__(self, repository_id, branch, id, timestamp=None, order=None, committer=None, message=None):
+        for argument in [('repository_id', repository_id), ('branch', branch), ('id', id), ('timestamp', timestamp)]:
+            if argument[1] is None:
+                raise ValueError(f'{argument[0]} is not defined for commit')
+
+        self.repository_id = str(repository_id)
+        self.branch = str(branch)
+
+        # An id is either a git commit or SVN revision.
+        self.id = str(id)
+
+        if isinstance(timestamp, datetime):
+            self.timestamp = timestamp
+        else:
+            self.timestamp = datetime.utcfromtimestamp(int(timestamp))
+        self.order = int(order) if order else 0
+
+        self.committer = committer if committer else None
+        self.message = message if message else None
+
+    def timestamp_as_epoch(self):
+        return calendar.timegm(self.timestamp.timetuple())
+
+    @property
+    def uuid(self):
+        # Rebase-based workflows in git can cause commits to have the same commit timestamp. To uniquely recognize them,
+        # multiply each timestamp by the `TIMESTAMP_TO_UUID_MULTIPLIER`.
+        return self.timestamp_as_epoch() * self.TIMESTAMP_TO_UUID_MULTIPLIER + self.order
+
+    def __eq__(self, other):
+        return (isinstance(other, type(self)) and (self.repository_id, self.branch, self.id) == (other.repository_id, other.branch, other.id))
+
+    def __ne__(self, other):
+        return not (self == other)
+
+    def __lt__(self, other):
+        if self.timestamp < other.timestamp:
+            return True
+        if self.timestamp > other.timestamp:
+            return False
+        return self.order < other.order
+
+    def __le__(self, other):
+        return self < other or self == other
+
+    def __gt__(self, other):
+        if self.timestamp > other.timestamp:
+            return True
+        if self.timestamp < other.timestamp:
+            return False
+        return self.order > other.order
+
+    def __ge__(self, other):
+        return self > other or self == other
+
+    def __hash__(self):
+        return hash(self.repository_id) ^ hash(self.branch) ^ hash(self.id)
+
+    def to_json(self, pretty_print=False):
+        return json.dumps(self, cls=self.Encoder, sort_keys=pretty_print, indent=4 if pretty_print else None)
+
+    class Encoder(FlaskJSONEncoder):
+
+        def default(self, obj):
+            if isinstance(obj, Commit):
+                result = {}
+                for key, value in obj.__dict__.items():
+                    if value is None:
+                        continue
+                    result[key] = value
+                result['timestamp'] = obj.timestamp_as_epoch()
+                return result
+            return super(Commit.Encoder, self).default(obj)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpycontrollercommit_controllerpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/controller/commit_controller.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/controller/commit_controller.py                              (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/controller/commit_controller.py 2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,305 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import json
+
+from collections import defaultdict
+from flask import abort, jsonify, request
+from resultsdbpy.flask_support.util import AssertRequest, query_as_kwargs, limit_for_query
+from resultsdbpy.model.repository import SCMException
+from resultsdbpy.controller.commit import Commit
+
+
+def _find_comparison(commit_context, repository_id, branch, id, uuid, timestamp, priority=min):
+    if bool(id) + bool(uuid) + bool(timestamp) > 1:
+        abort(400, description='Can only search by one of [commit id, commit uuid, timestamp] in a single request')
+
+    try:
+        if uuid:
+            # We don't need real commit to search by uuid and CommitContexts have trouble diffrentiating between uuids and timestamps.
+            uuid = priority([int(element) for element in uuid])
+            return Commit('?', '?', '?', uuid // Commit.TIMESTAMP_TO_UUID_MULTIPLIER, uuid % Commit.TIMESTAMP_TO_UUID_MULTIPLIER)
+        if timestamp:
+            return priority([round(float(element)) for element in timestamp])
+    except ValueError:
+        abort(400, description='Timestamp and uuid must be integers')
+
+    if not repository_id and not id:
+        return None
+    if not repository_id:
+        repository_id = commit_context.repositories.keys()
+    for repository in repository_id:
+        if repository not in commit_context.repositories.keys():
+            abort(404, description=f"\'{repository}\' is not a registered repository")
+
+    result = []
+    for repository in repository_id:
+        for b in branch:
+            if id:
+                for elm in id:
+                    result += commit_context.find_commits_by_id(repository, b, elm)
+            else:
+                result += commit_context.find_commits_in_range(repository, b, limit=1)
+
+    if not result:
+        abort(404, description='No commits found matching the specified criteria')
+    return priority(result)
+
+
+def uuid_range_for_commit_range_query():
+    def decorator(method):
+        def real_method(obj, branch=None,
+                        after_repository_id=None, after_branch=None, after_id=None, after_uuid=None,
+                        after_timestamp=None,
+                        before_repository_id=None, before_branch=None, before_id=None, before_uuid=None,
+                        before_timestamp=None, **kwargs):
+            # We're making an asumption that the class using this decorator actually has a commit_context, if it does not,
+            # this decorator will fail spectacularly
+            with obj.commit_context:
+                if not branch:
+                    branch = [None]
+                begin = _find_comparison(
+                    obj.commit_context, repository_id=after_repository_id, branch=after_branch or branch,
+                    id=after_id, uuid=after_uuid, timestamp=after_timestamp, priority=min,
+                )
+                end = _find_comparison(
+                    obj.commit_context, repository_id=before_repository_id, branch=before_branch or branch,
+                    id=before_id, uuid=before_uuid, timestamp=before_timestamp, priority=max,
+                )
+                return method(obj, begin=begin, end=end, branch=branch, **kwargs)
+
+        real_method.__name__ = method.__name__
+        return real_method
+
+    return decorator
+
+
+def uuid_range_for_query():
+    def decorator(method):
+        @uuid_range_for_commit_range_query()
+        def real_method(obj, repository_id=None, branch=None, id=None, uuid=None, timestamp=None, begin=None, end=None, **kwargs):
+            # We're making an asumption that the class using this decorator actually has a commit_context, if it does not,
+            # this decorator will fail spectacularly
+            with obj.commit_context:
+                index = _find_comparison(
+                    obj.commit_context, repository_id=repository_id, branch=branch,
+                    id=id, uuid=uuid, timestamp=timestamp, priority=max,
+                )
+                if index:
+                    if begin or end:
+                        abort(400, description='Cannot define commit and range when searching')
+                    begin = index
+                    end = index
+
+                if isinstance(end, Commit) and end.repository_id != '?':
+                    repositories = list(obj.commit_context.repositories.keys())
+                    repositories.remove(end.repository_id)
+                    commits = [end]
+                    [commits.extend(siblings) for siblings in obj.commit_context.sibling_commits(end, repositories).values()]
+                    end = max(commits)
+
+                return method(obj, branch=branch, begin=begin, end=end, **kwargs)
+
+        real_method.__name__ = method.__name__
+        return real_method
+
+    return decorator
+
+
+class HasCommitContext(object):
+    def __init__(self, commit_context):
+        self.commit_context = commit_context
+
+
+class CommitController(HasCommitContext):
+    DEFAULT_LIMIT = 100
+
+    def default(self):
+        if request.method == 'POST':
+            return self.register()
+        return self.find()
+
+    @uuid_range_for_commit_range_query()
+    @limit_for_query(DEFAULT_LIMIT)
+    def _find(self, repository_id=None, branch=None, id=None, uuid=None, timestamp=None, limit=None, begin=None, end=None, **kwargs):
+        AssertRequest.query_kwargs_empty(**kwargs)
+
+        with self.commit_context:
+            if not repository_id:
+                repository_id = self.commit_context.repositories.keys()
+            for repository in repository_id:
+                if repository not in self.commit_context.repositories.keys():
+                    abort(404, description=f"\'{repository}\' is not a registered repository")
+            if not branch:
+                branch = [None]
+
+            if bool(id) + bool(uuid) + bool(timestamp) > 1:
+                abort(400, description='Can only search by one of [commit id, commit uuid, timestamp] in a single request')
+
+            result = []
+            for repository in repository_id:
+                # Limit makes most sense on a per-repository basis
+                results_for_repo = []
+
+                for b in branch:
+                    if len(results_for_repo) >= limit:
+                        continue
+
+                    if id:
+                        for elm in id:
+                            results_for_repo += self.commit_context.find_commits_by_id(repository, b, elm, limit=limit - len(results_for_repo))
+                    elif uuid:
+                        for elm in uuid:
+                            results_for_repo += self.commit_context.find_commits_by_uuid(repository, b, int(elm), limit=limit - len(results_for_repo))
+                    elif timestamp:
+                        for elm in timestamp:
+                            results_for_repo += self.commit_context.find_commits_by_timestamp(repository, b, round(float(elm)), limit=limit - len(results_for_repo))
+                    else:
+                        results_for_repo += self.commit_context.find_commits_in_range(repository, b, limit=limit - len(results_for_repo), begin=begin, end=end)
+                result += results_for_repo
+
+            return sorted(result)
+
+    def find(self):
+        AssertRequest.is_type()
+        result = self._find(**request.args.to_dict(flat=False))
+        if not result:
+            abort(404, description='No commits found matching the specified criteria')
+        return jsonify(Commit.Encoder().default(result))
+
+    def repositories(self):
+        AssertRequest.is_type()
+        AssertRequest.no_query()
+        return jsonify(sorted(self.commit_context.repositories.keys()))
+
+    @query_as_kwargs()
+    @limit_for_query(DEFAULT_LIMIT)
+    def branches(self, repository_id=None, branch=None, limit=None, **kwargs):
+        AssertRequest.is_type()
+        AssertRequest.query_kwargs_empty(**kwargs)
+
+        result = defaultdict(list)
+        with self.commit_context:
+            for repository in repository_id or self.commit_context.repositories.keys():
+                limit_for_repo = limit
+                for b in branch or [None]:
+                    if not limit_for_repo:
+                        continue
+                    matching_branches = self.commit_context.branches(repository, branch=b, limit=limit_for_repo)
+                    if not matching_branches:
+                        continue
+                    limit_for_repo -= len(matching_branches)
+                    result[repository] += matching_branches
+
+        return jsonify(result)
+
+    @query_as_kwargs()
+    def siblings(self, limit=None, **kwargs):
+        AssertRequest.is_type()
+        AssertRequest.query_kwargs_empty(limit=limit)
+
+        with self.commit_context:
+            commits = self._find(**kwargs)
+            if not commits:
+                abort(404, description='No commits found matching the specified criteria')
+            if len(commits) > 1:
+                abort(404, description=f'{len(commits)} commits found matching the specified criteria')
+
+            repositories = sorted(self.commit_context.repositories.keys())
+            repositories.remove(commits[0].repository_id)
+            return jsonify(Commit.Encoder().default(self.commit_context.sibling_commits(commits[0], repositories)))
+
+    @query_as_kwargs()
+    def next(self, limit=None, **kwargs):
+        AssertRequest.is_type()
+        AssertRequest.query_kwargs_empty(limit=limit)
+
+        with self.commit_context:
+            commits = self._find(**kwargs)
+            if not commits:
+                abort(404, description='No commits found matching the specified criteria')
+            if len(commits) > 1:
+                abort(404, description=f'{len(commits)} commits found matching the specified criteria')
+
+            commit = self.commit_context.next_commit(commits[0])
+            return jsonify(Commit.Encoder().default([commit] if commit else []))
+
+    @query_as_kwargs()
+    def previous(self, limit=None, **kwargs):
+        AssertRequest.is_type()
+        AssertRequest.query_kwargs_empty(limit=limit)
+
+        with self.commit_context:
+            commits = self._find(**kwargs)
+            if not commits:
+                abort(404, description='No commits found matching the specified criteria')
+            if len(commits) > 1:
+                abort(404, description=f'{len(commits)} commits found matching the specified criteria')
+
+            commit = self.commit_context.previous_commit(commits[0])
+            return jsonify(Commit.Encoder().default([commit] if commit else []))
+
+    def register(self, commit=None):
+        is_endpoint = not bool(commit)
+        if is_endpoint:
+            AssertRequest.is_type(['POST'])
+            AssertRequest.no_query()
+
+        if is_endpoint:
+            try:
+                commit = request.form or json.loads(request.get_data())
+            except ValueError:
+                abort(400, description='Expected uploaded data to be json')
+
+        try:
+            self.commit_context.register_commit(Commit.from_json(commit))
+            if is_endpoint:
+                return jsonify({'status': 'ok'})
+            return Commit.from_json(commit)
+        except ValueError:
+            pass
+
+        required_args = ['repository_id', 'id']
+        optional_args = ['branch']
+        for arg in required_args:
+            if arg not in commit:
+                abort(400, description=f"'{arg}' required to define commit")
+
+        for arg in commit.keys():
+            if arg in required_args + optional_args:
+                continue
+            if arg in ['timestamp', 'order', 'committer', 'message']:
+                abort(400, description='Not enough arguments provided to define a commit, but too many to search for a commit')
+            abort(400, description=f"'{arg}' is not valid for defining commits")
+
+        try:
+            commit = self.commit_context.register_commit_with_repo_and_id(
+                repository_id=commit.get('repository_id'),
+                branch=commit.get('branch'),
+                commit_id=commit.get('id'),
+            )
+        except (RuntimeError, SCMException) as error:
+            abort(404, description=str(error))
+
+        if is_endpoint:
+            return jsonify({'status': 'ok'})
+        return commit
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpycontrollercommit_controller_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/controller/commit_controller_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/controller/commit_controller_unittest.py                             (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/controller/commit_controller_unittest.py        2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,275 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from fakeredis import FakeStrictRedis
+from redis import StrictRedis
+from resultsdbpy.controller.api_routes import APIRoutes
+from resultsdbpy.controller.commit import Commit
+from resultsdbpy.flask_support.flask_testcase import FlaskTestCase
+from resultsdbpy.model.cassandra_context import CassandraContext
+from resultsdbpy.model.mock_cassandra_context import MockCassandraContext
+from resultsdbpy.model.mock_repository import MockStashRepository, MockSVNRepository
+from resultsdbpy.model.model import Model
+from resultsdbpy.model.wait_for_docker_test_case import WaitForDockerTestCase
+
+
+class CommitControllerTest(FlaskTestCase, WaitForDockerTestCase):
+    KEYSPACE = 'commit_controller_test_keyspace'
+
+    @classmethod
+    def setup_webserver(cls, app, redis=StrictRedis, cassandra=CassandraContext):
+        redis_instance = redis()
+        safari = MockStashRepository.safari(redis_instance)
+        webkit = MockSVNRepository.webkit(redis_instance)
+
+        cassandra.drop_keyspace(keyspace=cls.KEYSPACE)
+        cassandra_instance = cassandra(keyspace=cls.KEYSPACE, create_keyspace=True)
+
+        app.register_blueprint(APIRoutes(Model(redis=redis_instance, cassandra=cassandra_instance, repositories=[safari, webkit])))
+
+    def register_all_commits(self, client):
+        for mock_repository in [MockStashRepository.safari(), MockSVNRepository.webkit()]:
+            for commit_list in mock_repository.commits.values():
+                for commit in commit_list:
+                    self.assertEqual(200, client.post(self.URL + '/api/commits/register', data=Commit.Encoder().default(commit)).status_code)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_repositories(self, client, **kwargs):
+        response = client.get(self.URL + '/api/commits/repositories')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(['safari', 'webkit'], sorted(response.json()))
+
+        self.assertEqual(400, client.get(self.URL + '/api/commits/repositories?repository_id=safari').status_code)
+        self.assertEqual(405, client.post(self.URL + '/api/commits/repositories').status_code)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_branches(self, client, **kwargs):
+        self.register_all_commits(client)
+        response = client.get(self.URL + '/api/commits/branches')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(dict(webkit=['safari-606-branch', 'trunk'], safari=['master', 'safari-606-branch']), response.json())
+
+        response = client.get(self.URL + '/api/commits/branches?branch=safari')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(dict(webkit=['safari-606-branch'], safari=['safari-606-branch']), response.json())
+
+        response = client.get(self.URL + '/api/commits/branches?repository_id=safari')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(dict(safari=['master', 'safari-606-branch']), response.json())
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_register_errors(self, client, **kwargs):
+        self.assertEqual(400, client.post(self.URL + '/api/commits/register').status_code)
+        self.assertEqual(405, client.get(self.URL + '/api/commits/register').status_code)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_register_via_post(self, client, **kwargs):
+        self.assertEqual(200, client.post(self.URL + '/api/commits', data=dict(repository_id='safari', id='bb6bda5f44dd2')).status_code)
+        response = client.get(self.URL + '/api/commits?repository_id=safari')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(
+            Commit.from_json(response.json()[0]),
+            MockStashRepository.safari().commit_for_id('bb6bda5f44dd2'),
+        )
+
+        self.assertEqual(200, client.post(self.URL + '/api/commits', data=dict(repository_id='webkit', id='236544')).status_code)
+        response = client.get(self.URL + '/api/commits?repository_id=webkit')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(
+            Commit.from_json(response.json()[0]),
+            MockSVNRepository.webkit().commit_for_id('236544'),
+        )
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_register_with_partial_commit(self, client, **kwargs):
+        self.assertEqual(200, client.post(self.URL + '/api/commits', data=dict(repository_id='safari', id='bb6bda5f44dd2')).status_code)
+        response = client.get(self.URL + '/api/commits?repository_id=safari')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(
+            Commit.from_json(response.json()[0]),
+            MockStashRepository.safari().commit_for_id('bb6bda5f44dd2'),
+        )
+        self.assertEqual(404, client.post(self.URL + '/api/commits', data=dict(repository_id='safari', id='aaaaaaaaaaaaa')).status_code)
+
+        self.assertEqual(200, client.post(self.URL + '/api/commits', data=dict(repository_id='webkit', id='236544')).status_code)
+        response = client.get(self.URL + '/api/commits?repository_id=webkit')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(
+            Commit.from_json(response.json()[0]),
+            MockSVNRepository.webkit().commit_for_id('236544'),
+        )
+        self.assertEqual(404, client.post(self.URL + '/api/commits', data=dict(repository_id='webkit', id='0')).status_code)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_register_with_full_commit(self, client, **kwargs):
+        git_commit = MockStashRepository.safari().commit_for_id('bb6bda5f44dd2')
+        self.assertEqual(200, client.post(self.URL + '/api/commits', data=Commit.Encoder().default(git_commit)).status_code)
+        response = client.get(self.URL + '/api/commits?repository_id=safari')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(Commit.from_json(response.json()[0]), git_commit)
+
+        svn_commit = MockSVNRepository.webkit().commit_for_id('236544')
+        self.assertEqual(200, client.post(self.URL + '/api/commits', data=Commit.Encoder().default(svn_commit)).status_code)
+        response = client.get(self.URL + '/api/commits?repository_id=webkit')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(Commit.from_json(response.json()[0]), svn_commit)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_find_no_commit(self, client, **kwargs):
+        self.register_all_commits(client)
+        self.assertEqual(404, client.get(self.URL + '/api/commits?repository_id=safari&id=0').status_code)
+        self.assertEqual(404, client.get(self.URL + '/api/commits?repository_id=webkit&id=0').status_code)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_find_id(self, client, **kwargs):
+        self.register_all_commits(client)
+        response = client.get(self.URL + '/api/commits?id=336610a8')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(1, len(response.json()))
+        self.assertEqual(Commit.from_json(response.json()[0]), MockStashRepository.safari().commit_for_id(id='336610a8'))
+
+        response = client.get(self.URL + '/api/commits?id=236540')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(1, len(response.json()))
+        self.assertEqual(Commit.from_json(response.json()[0]), MockSVNRepository.webkit().commit_for_id(id=236540))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_find_timestamp(self, client, **kwargs):
+        self.register_all_commits(client)
+        response = client.get(self.URL + '/api/commits?timestamp=1537550685')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(2, len(response.json()))
+        self.assertEqual([Commit.from_json(element) for element in response.json()], [
+            MockStashRepository.safari().commit_for_id(id='e64810a4'),
+            MockStashRepository.safari().commit_for_id(id='7be40842'),
+        ])
+
+        response = client.get(self.URL + '/api/commits?timestamp=1538041791.8')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(1, len(response.json()))
+        self.assertEqual(Commit.from_json(response.json()[0]), MockSVNRepository.webkit().commit_for_id(id=236541))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_find_uuid(self, client, **kwargs):
+        self.register_all_commits(client)
+        response = client.get(self.URL + '/api/commits?uuid=153755068501')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(1, len(response.json()))
+        self.assertEqual(Commit.from_json(response.json()[0]), MockStashRepository.safari().commit_for_id(id='7be40842'))
+
+        response = client.get(self.URL + '/api/commits?uuid=153804179200')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(1, len(response.json()))
+        self.assertEqual(Commit.from_json(response.json()[0]), MockSVNRepository.webkit().commit_for_id(id=236541))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_find_range_id(self, client, **kwargs):
+        self.register_all_commits(client)
+        response = client.get(self.URL + '/api/commits?after_id=336610a8&before_id=236540')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual([Commit.from_json(element) for element in response.json()], [
+            MockStashRepository.safari().commit_for_id(id='336610a8'),
+            MockStashRepository.safari().commit_for_id(id='bb6bda5f'),
+            MockSVNRepository.webkit().commit_for_id(id=236540),
+        ])
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_find_range_timestamp(self, client, **kwargs):
+        self.register_all_commits(client)
+        response = client.get(self.URL + '/api/commits?after_timestamp=1538041792.3&before_timestamp=1538049108')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(
+            [Commit.from_json(element) for element in response.json()],
+            [MockSVNRepository.webkit().commit_for_id(id=236541), MockSVNRepository.webkit().commit_for_id(id=236542)],
+        )
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_find_range_uuid(self, client, **kwargs):
+        self.register_all_commits(client)
+        response = client.get(self.URL + '/api/commits?after_uuid=153755068501&before_uuid=153756638602')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(
+            [Commit.from_json(element) for element in response.json()],
+            [MockStashRepository.safari().commit_for_id(id='7be40842'), MockStashRepository.safari().commit_for_id(id='336610a4')],
+        )
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_next(self, client, **kwargs):
+        self.register_all_commits(client)
+        response = client.get(self.URL + '/api/commits/next?id=336610a4')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(1, len(response.json()))
+        self.assertEqual(MockStashRepository.safari().commit_for_id(id='336610a8'), Commit.from_json(response.json()[0]))
+
+        response = client.get(self.URL + '/api/commits/next?id=236542')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(1, len(response.json()))
+        self.assertEqual(MockSVNRepository.webkit().commit_for_id(id=236543), Commit.from_json(response.json()[0]))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_previous(self, client, **kwargs):
+        self.register_all_commits(client)
+        response = client.get(self.URL + '/api/commits/previous?id=336610a4')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(1, len(response.json()))
+        self.assertEqual(MockStashRepository.safari().commit_for_id(id='7be40842'), Commit.from_json(response.json()[0]))
+
+        response = client.get(self.URL + '/api/commits/previous?id=236542')
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(1, len(response.json()))
+        self.assertEqual(MockSVNRepository.webkit().commit_for_id(id=236541), Commit.from_json(response.json()[0]))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_siblings(self, client, **kwargs):
+        self.register_all_commits(client)
+
+        response = client.get(self.URL + '/api/commits/siblings?repository_id=webkit&id=236542')
+        self.assertEqual(200, response.status_code)
+        commits = {key: [Commit.from_json(element) for element in values] for key, values in response.json().items()}
+        self.assertEqual(commits, {'safari': [MockStashRepository.safari().commit_for_id(id='bb6bda5f44dd24')]})
+
+        response = client.get(self.URL + '/api/commits/siblings?repository_id=safari&id=bb6bda5f44dd24')
+        self.assertEqual(200, response.status_code)
+        commits = {key: [Commit.from_json(element) for element in values] for key, values in response.json().items()}
+        self.assertEqual(commits, {'webkit': [
+            MockSVNRepository.webkit().commit_for_id(id=236544),
+            MockSVNRepository.webkit().commit_for_id(id=236543),
+            MockSVNRepository.webkit().commit_for_id(id=236542),
+            MockSVNRepository.webkit().commit_for_id(id=236541),
+            MockSVNRepository.webkit().commit_for_id(id=236540),
+        ]})
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpycontrollercommit_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/controller/commit_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/controller/commit_unittest.py                                (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/controller/commit_unittest.py   2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,108 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+
+from resultsdbpy.controller.commit import Commit
+
+
+class CommitUnittest(unittest.TestCase):
+
+    def test_compare(self):
+        commit1 = Commit(
+            repository_id='safari', branch='master',
+            id='e64810a40c3fecb728871e12ca31482ca715b383',
+            timestamp=1537550685,
+        )
+        commit2 = Commit(
+            repository_id='safari', branch='master',
+            id='7be4084258a452e8fe22f36287c5b321e9c8249b',
+            timestamp=1537550685, order=1,
+        )
+        commit3 = Commit(
+            repository_id='safari', branch='master',
+            id='bb6bda5f44dd24d0b54539b8ff6e8c17f519249a',
+            timestamp=1537810281,
+        )
+        commit4 = Commit(
+            repository_id='webkit', branch='master',
+            id=236522,
+            timestamp=1537826614,
+        )
+
+        self.assertTrue(commit2 > commit1)
+        self.assertTrue(commit2 >= commit1)
+        self.assertTrue(commit1 >= commit1)
+        self.assertTrue(commit1 < commit2)
+        self.assertTrue(commit1 <= commit2)
+        self.assertTrue(commit1 <= commit1)
+        self.assertTrue(commit1 == commit1)
+
+        self.assertTrue(commit2 < commit3)
+        self.assertTrue(commit3 > commit2)
+        self.assertEqual(commit1.timestamp, commit2.timestamp)
+
+        self.assertTrue(commit4 > commit3)
+        self.assertNotEqual(commit3.repository_id, commit4.repository_id)
+
+    def test_encoding(self):
+        commits_to_test = [
+            Commit(
+                repository_id='safari', branch='master',
+                id='e64810a40c3fecb728871e12ca31482ca715b383',
+                timestamp=1537550685,
+            ), Commit(
+                repository_id='safari', branch='master',
+                id='7be4084258a452e8fe22f36287c5b321e9c8249b',
+                timestamp=1537550685, order=1,
+                committer='email1@webkit.org', message='Changelog',
+            ), Commit(
+                repository_id='webkit', branch='master',
+                id=236522,
+                timestamp=1537826614,
+            ),
+        ]
+        for commit in commits_to_test:
+            converted_commit = Commit.from_json(commit.to_json())
+
+            self.assertEqual(commit, converted_commit)
+            self.assertEqual(commit.repository_id, converted_commit.repository_id)
+            self.assertEqual(commit.id, converted_commit.id)
+            self.assertEqual(commit.timestamp, converted_commit.timestamp)
+            self.assertEqual(commit.order, converted_commit.order)
+            self.assertEqual(commit.committer, converted_commit.committer)
+            self.assertEqual(commit.message, converted_commit.message)
+
+    def test_udid(self):
+        self.assertEqual(Commit(
+            repository_id='safari', branch='master',
+            id='7be4084258a452e8fe22f36287c5b321e9c8249b',
+            timestamp=1537550685, order=1,
+        ).uuid, 153755068501)
+
+    def test_invalid(self):
+        with self.assertRaises(ValueError):
+            Commit(
+                repository_id='safari', branch='master',
+                id='7be4084258a452e8fe22f36287c5b321e9c8249b',
+                timestamp=None,
+            )
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpycontrollerconfigurationpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/controller/configuration.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/controller/configuration.py                          (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/controller/configuration.py     2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,197 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import json
+
+from resultsdbpy.flask_support.util import FlaskJSONEncoder
+
+
+class Configuration(object):
+    """
+    This class is designed to use a partial configuration to match more complete configurations. Generally, an instance
+    of this class will match another instance if all members match or are None. This means that, for example, a
+    Configuration object with a platform of 'Mac' and all other members set to None will match any Configuration object
+    with a platform of 'Mac', regardless of the values of the Configuration's other members.
+    """
+
+    VERSION_OFFSET_CONSTANT = 1000
+
+    REQUIRED_MEMBERS = ['platform', 'is_simulator', 'version', 'architecture']
+    OPTIONAL_MEMBERS = ['version_name', 'model', 'style', 'flavor']
+    FILTERING_MEMBERS = ['sdk']
+
+    @classmethod
+    def from_json(cls, data):
+        data = data if isinstance(data, dict) else json.loads(data)
+        return Configuration(**{key: data.get(key) for key in cls.REQUIRED_MEMBERS + cls.OPTIONAL_MEMBERS + ['sdk']})
+
+    def __init__(self, platform=None, version=None, sdk=None, version_name=None, is_simulator=None, architecture=None, model=None, style=None, flavor=None):
+        self.platform = None if platform is None else str(platform)
+        self.version = None if version is None else self.version_to_integer(version)
+        self.version_name = None if version_name is None else str(version_name)
+        self.is_simulator = None if is_simulator is None else bool(is_simulator)
+        self.architecture = None if architecture is None else str(architecture)
+        self.model = None if model is None else str(model)
+        self.style = None if style is None else str(style)
+        self.flavor = None if flavor is None else str(flavor)
+
+        # Configurations which are identical, except for their sdk, should be considered identical
+        self.sdk = None
+        if sdk and sdk != '?':
+            self.sdk = str(sdk)
+
+    @classmethod
+    def version_to_integer(cls, version):
+        if isinstance(version, int):
+            return version
+        elif isinstance(version, str):
+            version_list = version.split('.')
+        elif isinstance(version, list) or isinstance(version, tuple):
+            version_list = version
+        else:
+            raise TypeError(f'{type(version)} cannot be converted to a version integer')
+        if len(version_list) < 1 or len(version_list) > 3:
+            raise ValueError(f'{version} cannot be converted to a version integer')
+
+        index = 0
+        result = 0
+        while index < 3:
+            result *= cls.VERSION_OFFSET_CONSTANT
+            if index < len(version_list):
+                result += int(version_list[index])
+            index += 1
+
+        return result
+
+    @classmethod
+    def integer_to_version(cls, version):
+        assert isinstance(version, int)
+        result = [0, 0, 0]
+        for index in range(3):
+            result[-(index + 1)] = version % cls.VERSION_OFFSET_CONSTANT
+            version //= cls.VERSION_OFFSET_CONSTANT
+        return f'{result[0]}.{result[1]}.{result[2]}'
+
+    def is_complete(self):
+        return not any([getattr(self, member) is None for member in self.REQUIRED_MEMBERS])
+
+    def __eq__(self, other):
+        if not isinstance(other, type(self)):
+            return False
+        for member in self.REQUIRED_MEMBERS + self.OPTIONAL_MEMBERS:
+            if getattr(self, member) is None or getattr(other, member) is None:
+                continue
+            if getattr(self, member) == getattr(other, member):
+                continue
+            return False
+        return True
+
+    def __ne__(self, other):
+        return not (self == other)
+
+    def __lt__(self, other):
+        if not isinstance(other, type(self)):
+            raise TypeError(f'Cannot compare {type(other)} and {type(self)}')
+        for member in ['version', 'platform', 'version_name', 'is_simulator', 'model', 'architecture', 'style', 'flavor']:
+            mine = getattr(self, member)
+            theirs = getattr(other, member)
+            if mine is None and theirs is None:
+                continue
+            if mine is None and theirs is not None:
+                return True
+            if mine is not None and theirs is None:
+                return False
+            if mine < theirs:
+                return True
+            if mine > theirs:
+                return False
+        return False
+
+    def __le__(self, other):
+        return self.__lt__(other) or self.__eq__(other)
+
+    def __gt__(self, other):
+        if not isinstance(other, type(self)):
+            raise TypeError(f'Cannot compare {type(other)} and {type(self)}')
+        for member in ['version', 'platform', 'version_name', 'is_simulator', 'model', 'architecture', 'style', 'flavor']:
+            mine = getattr(self, member)
+            theirs = getattr(other, member)
+            if mine is None and theirs is None:
+                continue
+            if mine is None and theirs is not None:
+                return False
+            if mine is not None and theirs is None:
+                return True
+            if mine < theirs:
+                return False
+            if mine > theirs:
+                return True
+        return False
+
+    def __ge__(self, other):
+        return self.__gt__(other) or self.__eq__(other)
+
+    def __hash__(self):
+        result = 0
+        for member in self.REQUIRED_MEMBERS + self.OPTIONAL_MEMBERS + self.FILTERING_MEMBERS:
+            if member == 'version_name' and getattr(self, 'version'):
+                continue
+            result ^= hash(getattr(self, member))
+        return result
+
+    def __repr__(self):
+        result = ''
+        if self.platform is not None:
+            result += ' ' + self.platform
+        if self.version is not None:
+            result += ' ' + self.integer_to_version(self.version)
+        elif self.version_name is not None:
+            result += ' ' + self.version_name
+        if self.sdk:
+            result += f' ({self.sdk})'
+        if self.is_simulator is not None and self.is_simulator:
+            result += ' Simulator'
+        if self.style is not None:
+            result += ' ' + self.style
+        if self.flavor is not None:
+            result += ' ' + self.flavor
+        if self.model is not None:
+            result += ' running on ' + self.model
+        if self.architecture is not None:
+            result += ' using ' + self.architecture
+
+        return result[1:]
+
+    def to_json(self, pretty_print=False):
+        return json.dumps(self, cls=self.Encoder, sort_keys=pretty_print, indent=4 if pretty_print else None)
+
+    class Encoder(FlaskJSONEncoder):
+
+        def default(self, obj):
+            if isinstance(obj, Configuration):
+                result = {}
+                for key, value in obj.__dict__.items():
+                    if value is None:
+                        continue
+                    result[key] = value
+                return result
+            return super(Configuration.Encoder, self).default(obj)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpycontrollerconfiguration_controllerpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/controller/configuration_controller.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/controller/configuration_controller.py                               (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/controller/configuration_controller.py  2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,58 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from resultsdbpy.flask_support.util import boolean_query
+from resultsdbpy.controller.configuration import Configuration
+
+
+def configuration_for_query():
+    def decorator(method):
+        def real_method(
+                self=None, platform=None, version=None, sdk=None, version_name=None, is_simulator=None,
+                architecture=None, model=None, style=None, flavor=None, **kwargs):
+            args = dict(
+                platform=platform or [], version=version or [], sdk=sdk or [], version_name=version_name or [],
+                is_simulator=boolean_query(*(is_simulator or [])), architecture=architecture or [], model=model or [],
+                style=style or [], flavor=flavor or [],
+            )
+
+            def recursive_callback(keys, **kwargs):
+                if not keys:
+                    return {Configuration(**kwargs)}
+
+                if not args[keys[-1]]:
+                    return recursive_callback(keys[:-1], **kwargs)
+                result = set()
+                for element in args[keys[-1]]:
+                    forwarded_args = {key: value for key, value in kwargs.items()}
+                    forwarded_args[keys[-1]] = element
+                    result = result.union(recursive_callback(keys[:-1], **forwarded_args))
+                return result
+
+            if self is None:
+                return method(configurations=list(recursive_callback(list(args.keys()))) or [Configuration()], **kwargs)
+            return method(self, configurations=list(recursive_callback(list(args.keys()))) or [Configuration()], **kwargs)
+
+        real_method.__name__ = method.__name__
+        return real_method
+
+    return decorator
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpycontrollerconfiguration_controller_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/controller/configuration_controller_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/controller/configuration_controller_unittest.py                              (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/controller/configuration_controller_unittest.py 2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,91 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+
+from resultsdbpy.controller.configuration import Configuration
+from resultsdbpy.controller.configuration_controller import configuration_for_query
+
+
+class ConfigurationControllerTest(unittest.TestCase):
+    @configuration_for_query()
+    def func(self, configurations=None):
+        return configurations
+
+    def test_configuration_from_no_query(self):
+        self.assertEqual([Configuration()], self.func())
+
+    def test_configurations_from_query_expansion(self):
+        self.assertEqual(
+            sorted([Configuration(platform='iOS'), Configuration(platform='Mac')]),
+            sorted(self.func(platform=['iOS', 'Mac'])),
+        )
+        self.assertEqual(
+            sorted([
+                Configuration(platform='iOS', style='Release'),
+                Configuration(platform='Mac', style='Debug'),
+                Configuration(platform='iOS', style='Debug'),
+                Configuration(platform='Mac', style='Release'),
+            ]), sorted(self.func(platform=['iOS', 'Mac'], style=['Debug', 'Release'])),
+        )
+
+    def test_configurations_from_query_platform(self):
+        self.assertEqual(
+            [Configuration(platform='iOS')],
+            self.func(platform=['iOS']),
+        )
+
+    def test_configuration_from_query_version(self):
+        self.assertEqual(
+            [Configuration(version='1.0.1')],
+            self.func(version=['1.0.1']),
+        )
+
+    def test_configuration_from_query_version_name(self):
+        self.assertEqual(
+            [Configuration(version_name='High Sierra')],
+            self.func(version_name=['High Sierra']),
+        )
+
+    def test_configuration_from_query_is_simulator(self):
+        self.assertEqual(
+            [Configuration(is_simulator=True)],
+            self.func(is_simulator=['true']),
+        )
+
+    def test_configuration_from_query_architecture(self):
+        self.assertEqual(
+            [Configuration(architecture='arm64')],
+            self.func(architecture=['arm64']),
+        )
+
+    def test_configuration_from_query_type(self):
+        self.assertEqual(
+            [Configuration(style='Debug')],
+            self.func(style=['Debug']),
+        )
+
+    def test_configuration_from_query_flavor(self):
+        self.assertEqual(
+            [Configuration(flavor='wk2')],
+            self.func(flavor=['wk2']),
+        )
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpycontrollerconfiguration_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/controller/configuration_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/controller/configuration_unittest.py                         (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/controller/configuration_unittest.py    2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,80 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+
+from resultsdbpy.controller.configuration import Configuration
+
+
+class ConfigurationUnittest(unittest.TestCase):
+
+    def test_version_integer_conversion(self):
+        self.assertEqual(1002003, Configuration.version_to_integer([1, 2, 3]))
+        self.assertEqual(1000000, Configuration.version_to_integer([1]))
+        self.assertEqual(1002003, Configuration.version_to_integer('1.2.3'))
+        self.assertEqual(1000000, Configuration.version_to_integer('1'))
+        self.assertEqual(1, Configuration.version_to_integer(1))
+
+        self.assertEqual('1.2.3', Configuration.integer_to_version(1002003))
+        self.assertEqual('1.2.0', Configuration.integer_to_version(1002000))
+        self.assertEqual('1.0.0', Configuration.integer_to_version(1000000))
+        self.assertEqual('0.1.2', Configuration.integer_to_version(1002))
+        self.assertEqual('0.0.1', Configuration.integer_to_version(1))
+
+    def test_wildcard_compare(self):
+        reference = Configuration(platform='Mac', version='10.14', is_simulator=False, architecture='x86_64', style='Debug', flavor='wk2')
+
+        should_match = [
+            Configuration(platform='Mac'),
+            Configuration(version='10.14'),
+            Configuration(is_simulator=False),
+            Configuration(architecture='x86_64'),
+            Configuration(style='Debug'),
+            Configuration(flavor='wk2'),
+        ]
+        for element in should_match:
+            self.assertEqual(element, reference)
+            self.assertFalse(element != reference)
+
+        shouldnt_match = [
+            Configuration(platform='iOS'),
+            Configuration(version='10.13'),
+            Configuration(is_simulator=True),
+            Configuration(architecture='arm64'),
+            Configuration(style='Release'),
+            Configuration(flavor='wk1'),
+        ]
+        for element in shouldnt_match:
+            self.assertNotEqual(element, reference)
+            self.assertFalse(element == reference)
+
+    def test_jsonify(self):
+        configs = [
+            Configuration(platform='Mac', version='10.14', is_simulator=False, architecture='x86_64', style='Debug', flavor='wk2'),
+            Configuration(platform='Mac', version='10.14', style='Production', flavor='wk2'),
+            Configuration(platform='iOS', version='11', is_simulator=True, architecture='x86_64', style='Debug'),
+            Configuration(platform='iOS', version='12', is_simulator=False, architecture='arm64', style='Release'),
+        ]
+
+        for config in configs:
+            converted_config = Configuration.from_json(config.to_json())
+            self.assertEqual(config, converted_config)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpycontrollersuite_controllerpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/controller/suite_controller.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/controller/suite_controller.py                               (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/controller/suite_controller.py  2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,106 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from flask import abort, jsonify, request
+from resultsdbpy.controller.commit_controller import uuid_range_for_query, HasCommitContext
+from resultsdbpy.controller.configuration import Configuration
+from resultsdbpy.controller.configuration_controller import configuration_for_query
+from resultsdbpy.flask_support.util import AssertRequest, query_as_kwargs, limit_for_query, boolean_query
+
+
+def time_range_for_query():
+    def decorator(method):
+        def real_method(self=None, method=method, **kwargs):
+            for query_key, kwarg_key in [('after_time', 'begin_query_time'), ('before_time', 'end_query_time')]:
+                if not kwargs.get(query_key):
+                    continue
+                try:
+                    kwargs[kwarg_key] = round(float(kwargs[query_key][-1]))
+                    del kwargs[query_key]
+                    if kwargs[kwarg_key] <= 0:
+                        raise ValueError()
+                except ValueError:
+                    abort(400, description='Start time must be a positive integer')
+            if self:
+                return method(self=self, **kwargs)
+            return method(**kwargs)
+
+        real_method.__name__ = method.__name__
+        return real_method
+    return decorator
+
+
+class SuiteController(HasCommitContext):
+    DEFAULT_LIMIT = 100
+
+    def __init__(self, suite_context):
+        super(SuiteController, self).__init__(suite_context.commit_context)
+        self.suite_context = suite_context
+
+    @query_as_kwargs()
+    @uuid_range_for_query()
+    @limit_for_query(DEFAULT_LIMIT)
+    @configuration_for_query()
+    @time_range_for_query()
+    def find_run_results(
+        self, suite=None, configurations=None, recent=None,
+        branch=None, begin=None, end=None,
+        begin_query_time=None, end_query_time=None,
+        limit=None, **kwargs
+    ):
+        AssertRequest.is_type(['GET'])
+        AssertRequest.query_kwargs_empty(**kwargs)
+
+        recent = boolean_query(*recent)[0] if recent else True
+
+        with self.suite_context:
+            if not suite:
+                abort(400, description='No suite specified')
+
+            query_dict = dict(
+                suite=suite, configurations=configurations, recent=recent,
+                branch=branch[0], begin=begin, end=end,
+                begin_query_time=begin_query_time, end_query_time=end_query_time,
+                limit=limit,
+            )
+            specified_commits = sum([1 if element else 0 for element in [begin, end]])
+            specified_timestamps = sum([1 if element else 0 for element in [begin_query_time, end_query_time]])
+
+            if specified_commits >= specified_timestamps:
+                find_function = self.suite_context.find_by_commit
+
+                def sort_function(result):
+                    return result['uuid']
+
+            else:
+                find_function = self.suite_context.find_by_start_time
+
+                def sort_function(result):
+                    return result['start_time']
+
+            response = []
+            for config, results in find_function(**query_dict).items():
+                response.append(dict(
+                    configuration=Configuration.Encoder().default(config),
+                    results=sorted(results, key=sort_function),
+                ))
+            return jsonify(response)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpycontrollersuite_controller_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/controller/suite_controller_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/controller/suite_controller_unittest.py                              (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/controller/suite_controller_unittest.py 2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,104 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import time
+
+from fakeredis import FakeStrictRedis
+from redis import StrictRedis
+from resultsdbpy.controller.api_routes import APIRoutes
+from resultsdbpy.flask_support.flask_testcase import FlaskTestCase
+from resultsdbpy.model.cassandra_context import CassandraContext
+from resultsdbpy.model.mock_cassandra_context import MockCassandraContext
+from resultsdbpy.model.mock_model_factory import MockModelFactory
+from resultsdbpy.model.wait_for_docker_test_case import WaitForDockerTestCase
+
+
+class SuiteControllerTest(FlaskTestCase, WaitForDockerTestCase):
+    KEYSPACE = 'suite_controller_test_keyspace'
+
+    @classmethod
+    def setup_webserver(cls, app, redis=StrictRedis, cassandra=CassandraContext):
+        cassandra.drop_keyspace(keyspace=cls.KEYSPACE)
+        model = MockModelFactory.create(redis=redis(), cassandra=cassandra(keyspace=cls.KEYSPACE, create_keyspace=True))
+        app.register_blueprint(APIRoutes(model))
+
+        MockModelFactory.add_mock_results(model)
+        MockModelFactory.process_results(model)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_results_no_suite(self, client, **kwargs):
+        response = client.get(self.URL + '/api/results')
+        self.assertEqual(response.status_code, 404)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_results(self, client, **kwargs):
+        response = client.get(self.URL + '/api/results/layout-tests?platform=iOS&style=Debug')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.json()), 2)
+        for i in range(2):
+            self.assertEqual(len(response.json()[i]['results']), 5)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_results_by_sdk(self, client, **kwargs):
+        response = client.get(self.URL + '/api/results/layout-tests?platform=iOS&style=Debug&is_simulator=True&recent=False')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.json()), 2)
+
+        response = client.get(self.URL + '/api/results/layout-tests?platform=iOS&style=Debug&is_simulator=True&recent=False&sdk=15A432')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.json()), 1)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_results_by_commit(self, client, **kwargs):
+        response = client.get(self.URL + '/api/results/layout-tests?platform=iOS&style=Debug&after_id=336610a84&before_id=236542')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.json()), 2)
+        for i in range(2):
+            self.assertEqual(len(response.json()[i]['results']), 3)
+            last_udid = 0
+            for result in response.json()[i]['results']:
+                self.assertGreaterEqual(result['uuid'], last_udid)
+                last_udid = result['uuid']
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_results_by_time(self, client, **kwargs):
+        response = client.get(f'{self.URL}/api/results/layout-tests?platform=iOS&style=Debug&recent=False&after_time={time.time() - 60 * 60}')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.json()), 2)
+        for i in range(2):
+            self.assertEqual(len(response.json()[i]['results']), 5)
+            last_start_time = 0
+            for result in response.json()[i]['results']:
+                self.assertGreaterEqual(result['start_time'], last_start_time)
+                last_start_time = result['start_time']
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_no_results_by_time(self, client, **kwargs):
+        response = client.get(self.URL + f'/api/results/layout-tests?platform=iOS&style=Debug&recent=False&after_time={time.time() + 1}')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.json()), 0)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpycontrollertest_controllerpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/controller/test_controller.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/controller/test_controller.py                                (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/controller/test_controller.py   2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,104 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from flask import abort, jsonify
+from resultsdbpy.controller.commit_controller import uuid_range_for_query, HasCommitContext
+from resultsdbpy.controller.configuration import Configuration
+from resultsdbpy.controller.configuration_controller import configuration_for_query
+from resultsdbpy.controller.suite_controller import time_range_for_query
+from resultsdbpy.flask_support.util import AssertRequest, query_as_kwargs, limit_for_query, boolean_query
+
+
+class TestController(HasCommitContext):
+    DEFAULT_LIMIT = 100
+
+    def __init__(self, test_context):
+        super(TestController, self).__init__(test_context.commit_context)
+        self.test_context = test_context
+
+    @query_as_kwargs()
+    @limit_for_query(DEFAULT_LIMIT)
+    def list_tests(self, suite=None, test=None, limit=None, **kwargs):
+        AssertRequest.is_type(['GET'])
+        AssertRequest.query_kwargs_empty(**kwargs)
+
+        if not suite:
+            abort(400, description='No suite specified')
+
+        with self.test_context:
+            matching_tests = set()
+            for t in test or [None]:
+                matching_tests.update(self.test_context.names(suite=suite, test=t, limit=limit - len(matching_tests)))
+            return jsonify(sorted(matching_tests))
+
+    @query_as_kwargs()
+    @uuid_range_for_query()
+    @limit_for_query(DEFAULT_LIMIT)
+    @configuration_for_query()
+    @time_range_for_query()
+    def find_test_result(
+        self, suite=None, test=None,
+        configurations=None, recent=None,
+        branch=None, begin=None, end=None,
+        begin_query_time=None, end_query_time=None,
+        limit=None, **kwargs
+    ):
+        AssertRequest.is_type(['GET'])
+        AssertRequest.query_kwargs_empty(**kwargs)
+
+        recent = boolean_query(*recent)[0] if recent else True
+
+        if not suite:
+            abort(400, description='No suite specified')
+        if not test:
+            abort(400, description='No test specified')
+
+        with self.test_context:
+            query_dict = dict(
+                suite=suite, test=test,
+                configurations=configurations, recent=recent,
+                branch=branch[0], begin=begin, end=end,
+                begin_query_time=begin_query_time, end_query_time=end_query_time,
+                limit=limit,
+            )
+            specified_commits = sum([1 if element else 0 for element in [begin, end]])
+            specified_timestamps = sum([1 if element else 0 for element in [begin_query_time, end_query_time]])
+
+            if specified_commits >= specified_timestamps:
+                find_function = self.test_context.find_by_commit
+
+                def sort_function(result):
+                    return result['uuid']
+
+            else:
+                find_function = self.test_context.find_by_start_time
+
+                def sort_function(result):
+                    return result['start_time']
+
+            response = []
+            for config, results in find_function(**query_dict).items():
+                response.append(dict(
+                    configuration=Configuration.Encoder().default(config),
+                    results=sorted(results, key=sort_function),
+                ))
+            return jsonify(response)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpycontrollertest_controller_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/controller/test_controller_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/controller/test_controller_unittest.py                               (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/controller/test_controller_unittest.py  2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,137 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import time
+
+from fakeredis import FakeStrictRedis
+from redis import StrictRedis
+from resultsdbpy.controller.api_routes import APIRoutes
+from resultsdbpy.flask_support.flask_testcase import FlaskTestCase
+from resultsdbpy.model.cassandra_context import CassandraContext
+from resultsdbpy.model.mock_cassandra_context import MockCassandraContext
+from resultsdbpy.model.mock_model_factory import MockModelFactory
+from resultsdbpy.model.test_context import Expectations
+from resultsdbpy.model.wait_for_docker_test_case import WaitForDockerTestCase
+
+
+class TestControllerTest(FlaskTestCase, WaitForDockerTestCase):
+    KEYSPACE = 'test_controller_test_keyspace'
+
+    @classmethod
+    def setup_webserver(cls, app, redis=StrictRedis, cassandra=CassandraContext):
+        cassandra.drop_keyspace(keyspace=cls.KEYSPACE)
+        model = MockModelFactory.create(redis=redis(), cassandra=cassandra(keyspace=cls.KEYSPACE, create_keyspace=True))
+        app.register_blueprint(APIRoutes(model))
+
+        MockModelFactory.add_mock_results(model)
+        MockModelFactory.process_results(model)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_list_all(self, client, **kwargs):
+        response = client.get(self.URL + '/api/layout-tests/tests')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), [
+            'fast/encoding/css-cached-bom.html',
+            'fast/encoding/css-charset-default.xhtml',
+            'fast/encoding/css-charset.html',
+            'fast/encoding/css-link-charset.html',
+        ])
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_list_all(self, client, **kwargs):
+        response = client.get(self.URL + '/api/layout-tests/tests?limit=2')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), [
+            'fast/encoding/css-cached-bom.html',
+            'fast/encoding/css-charset-default.xhtml',
+        ])
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_list_partial(self, client, **kwargs):
+        response = client.get(self.URL + '/api/layout-tests/tests?test=fast/encoding/css-charset')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), [
+            'fast/encoding/css-charset-default.xhtml',
+            'fast/encoding/css-charset.html',
+        ])
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_results(self, client, **kwargs):
+        response = client.get(self.URL + '/api/results/layout-tests/fast/encoding/css-charset.html?platform=iOS&style=Debug')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.json()), 2)
+
+        for i in range(2):
+            self.assertEqual(len(response.json()[i]['results']), 5)
+            for result in response.json()[i]['results']:
+                self.assertEqual(result['actual'], Expectations.PASS)
+                self.assertEqual(result['expected'], Expectations.PASS)
+                self.assertEqual(result['time'], 1.2)
+                self.assertEqual(result['modifiers'], '')
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_results_by_sdk(self, client, **kwargs):
+        response = client.get(self.URL + '/api/results/layout-tests/fast/encoding/css-cached-bom.html?platform=iOS&style=Debug&is_simulator=True&recent=False')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.json()), 2)
+
+        response = client.get(self.URL + '/api/results/layout-tests/fast/encoding/css-cached-bom.html?platform=iOS&style=Debug&is_simulator=True&recent=False&sdk=15A432')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.json()), 1)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_results_by_commit(self, client, **kwargs):
+        response = client.get(self.URL + '/api/results/layout-tests/fast/encoding/css-link-charset.html?platform=iOS&style=Debug&after_id=336610a84&before_id=236542')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.json()), 2)
+        for i in range(2):
+            self.assertEqual(len(response.json()[i]['results']), 3)
+            last_udid = 0
+            for result in response.json()[i]['results']:
+                self.assertGreaterEqual(result['uuid'], last_udid)
+                last_udid = result['uuid']
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_results_by_time(self, client, **kwargs):
+        response = client.get(f'{self.URL}/api/results/layout-tests/fast/encoding/css-link-charset.html?platform=iOS&style=Debug&recent=False&after_time={time.time() - 60 * 60}')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.json()), 2)
+        for i in range(2):
+            self.assertEqual(len(response.json()[i]['results']), 5)
+            last_start_time = 0
+            for result in response.json()[i]['results']:
+                self.assertGreaterEqual(result['start_time'], last_start_time)
+                last_start_time = result['start_time']
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_no_results_by_time(self, client, **kwargs):
+        response = client.get(self.URL + f'/api/results/layout-tests/fast/encoding/css-link-charset.html?platform=iOS&style=Debug&recent=False&after_time={time.time() + 1}')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.json()), 0)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpycontrollerupload_controllerpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/controller/upload_controller.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/controller/upload_controller.py                              (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/controller/upload_controller.py 2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,176 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import json
+import time
+
+from collections import defaultdict
+from flask import abort, jsonify, request
+from resultsdbpy.flask_support.util import AssertRequest, query_as_kwargs, limit_for_query, boolean_query
+from resultsdbpy.controller.commit import Commit
+from resultsdbpy.controller.commit_controller import uuid_range_for_query, HasCommitContext
+from resultsdbpy.controller.configuration import Configuration
+from resultsdbpy.controller.configuration_controller import configuration_for_query
+
+
+class UploadController(HasCommitContext):
+    DEFAULT_LIMIT = 100
+
+    def __init__(self, commit_controller, upload_context):
+        super(UploadController, self).__init__(commit_controller.commit_context)
+        self.commit_controller = commit_controller
+        self.upload_context = upload_context
+
+    @query_as_kwargs()
+    @uuid_range_for_query()
+    @limit_for_query(DEFAULT_LIMIT)
+    @configuration_for_query()
+    def _find_uploads_for_query(self, configurations=None, suite=None, branch=None, begin=None, end=None, recent=None, limit=None, **kwargs):
+        AssertRequest.query_kwargs_empty(**kwargs)
+        recent = boolean_query(*recent)[0] if recent else True
+
+        with self.upload_context:
+            if not suite:
+                suites = set()
+                for config_suites in self.upload_context.find_suites(configurations=configurations, recent=recent).values():
+                    [suites.add(suite) for suite in config_suites]
+            else:
+                suites = set(suite)
+
+            current_uploads = 0
+            result = defaultdict(dict)
+            for suite in suites:
+                if current_uploads >= limit:
+                    break
+                results_dict = self.upload_context.find_test_results(
+                    configurations=configurations, suite=suite, branch=branch[0],
+                    begin=begin, end=end, recent=recent, limit=(limit - current_uploads),
+                )
+                for config, results in results_dict.items():
+                    current_uploads += len(results)
+                    result[config][suite] = results
+            return result
+
+    def download(self):
+        AssertRequest.is_type(['GET'])
+
+        with self.upload_context:
+            uploads = self._find_uploads_for_query()
+
+            response = []
+            for config, suite_results in uploads.items():
+                for suite, results in suite_results.items():
+                    for result in results:
+                        config.sdk = result.get('sdk')
+                        response.append(dict(
+                            configuration=Configuration.Encoder().default(config),
+                            suite=suite,
+                            commits=Commit.Encoder().default(result['commits']),
+                            timestamp=result['timestamp'],
+                            test_results=result['test_results'],
+                        ))
+
+            return jsonify(response)
+
+    def upload(self):
+        if request.method == 'GET':
+            return self.download()
+
+        AssertRequest.is_type(['POST'])
+        AssertRequest.no_query()
+
+        with self.upload_context:
+            try:
+                data = request.form or json.loads(request.get_data())
+            except ValueError:
+                abort(400, description='Expected uploaded data to be json')
+
+            try:
+                configuration = Configuration.from_json(data.get('configuration', {}))
+            except (ValueError, TypeError):
+                abort(400, description='Invalid configuration')
+
+            suite = data.get('suite')
+            if not suite:
+                abort(400, description='No test suite specified')
+
+            commits = [self.commit_controller.register(commit=commit) for commit in data.get('commits', [])]
+
+            test_results = data.get('test_results', {})
+            if not test_results:
+                abort(400, description='No test results specified')
+
+            timestamp = data.get('timestamp', time.time())
+            version = data.get('version', 0)
+
+            try:
+                self.upload_context.upload_test_results(configuration, commits, suite, test_results, timestamp, version=version)
+            except (TypeError, ValueError) as error:
+                abort(400, description=str(error))
+
+            processing_results = self.upload_context.process_test_results(configuration, commits, suite, test_results, timestamp)
+            return jsonify(dict(status='ok', processing=processing_results))
+
+    def process(self):
+        AssertRequest.is_type(['POST'])
+
+        with self.upload_context:
+            uploads = self._find_uploads_for_query()
+            if not uploads:
+                abort(404, description='No uploads matching the specified criteria')
+
+            response = []
+            for config, suite_results in uploads.items():
+                for suite, results in suite_results.items():
+                    for result in results:
+                        config.sdk = result.get('sdk')
+                        processing_results = self.upload_context.process_test_results(
+                            configuration=config, commits=result['commits'], suite=suite,
+                            test_results=result['test_results'], timestamp=result['timestamp'],
+                        )
+                        response.append(dict(
+                            configuration=Configuration.Encoder().default(config),
+                            suite=suite,
+                            commits=Commit.Encoder().default(result['commits']),
+                            timestamp=result['timestamp'],
+                            processing=processing_results,
+                        ))
+
+            return jsonify(response)
+
+    @query_as_kwargs()
+    @configuration_for_query()
+    def suites(self, configurations=None, recent=None, suite=None, **kwargs):
+        AssertRequest.is_type(['GET'])
+        AssertRequest.query_kwargs_empty(**kwargs)
+
+        with self.upload_context:
+            suites_by_config = self.upload_context.find_suites(configurations=configurations,
+                                                               recent=boolean_query(*recent)[0] if recent else True)
+            result = []
+            for config, candidate_suites in suites_by_config.items():
+                suites_for_config = [s for s in candidate_suites if not suite or s in suite]
+                if suites_for_config:
+                    result.append([config, suites_for_config])
+            if not result:
+                abort(404, description='No suites matching the specified criteria')
+            return jsonify(Configuration.Encoder().default(result))
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpycontrollerupload_controller_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/controller/upload_controller_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/controller/upload_controller_unittest.py                             (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/controller/upload_controller_unittest.py        2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,206 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import json
+import time
+from fakeredis import FakeStrictRedis
+from redis import StrictRedis
+from resultsdbpy.controller.api_routes import APIRoutes
+from resultsdbpy.controller.commit import Commit
+from resultsdbpy.controller.configuration import Configuration
+from resultsdbpy.flask_support.flask_testcase import FlaskTestCase
+from resultsdbpy.model.cassandra_context import CassandraContext
+from resultsdbpy.model.mock_cassandra_context import MockCassandraContext
+from resultsdbpy.model.mock_model_factory import MockModelFactory
+from resultsdbpy.model.mock_repository import MockStashRepository, MockSVNRepository
+from resultsdbpy.model.wait_for_docker_test_case import WaitForDockerTestCase
+
+
+class UploadControllerPostTest(FlaskTestCase, WaitForDockerTestCase):
+    KEYSPACE = 'upload_controller_test_keyspace'
+
+    @classmethod
+    def setup_webserver(cls, app, redis=StrictRedis, cassandra=CassandraContext):
+        cassandra.drop_keyspace(keyspace=cls.KEYSPACE)
+        model = MockModelFactory.create(redis=redis(), cassandra=cassandra(keyspace=cls.KEYSPACE, create_keyspace=True))
+        model.upload_context.register_upload_callback('python-tests', lambda **kwargs: dict(status='ok'))
+        app.register_blueprint(APIRoutes(model))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_upload(self, client, **kwargs):
+        upload_dict = dict(
+            suite='layout-tests',
+            commits=[MockStashRepository.safari().commit_for_id('bb6bda5f'), MockSVNRepository.webkit().commit_for_id(236542)],
+            configuration=Configuration.Encoder().default(Configuration(
+                platform='Mac', version='10.14.0', sdk='18A391',
+                is_simulator=False, architecture='x86_64',
+                style='Release', flavor='wk2',
+            )),
+            test_results=MockModelFactory.layout_test_results(),
+            timestamp=int(time.time()),
+        )
+        response = client.post(self.URL + '/api/upload', data=str(json.dumps(upload_dict, cls=Commit.Encoder)))
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json()['status'], 'ok')
+        self.assertEqual(response.json()['processing']['python-tests'], dict(status='ok'))
+
+        response = client.get(self.URL + '/api/upload')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json()[0], Commit.Encoder().default(upload_dict))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_upload_partial(self, client, **kwargs):
+        upload_dict = dict(
+            suite='layout-tests',
+            commits=[dict(repository_id='safari', id='bb6bda5f'), dict(repository_id='webkit', id=236542)],
+            configuration=Configuration.Encoder().default(Configuration(
+                platform='iOS', version='12.0.0', sdk='16A404',
+                is_simulator=True, architecture='x86_64',
+                style='Release', flavor='wk2',
+            )),
+            test_results=MockModelFactory.layout_test_results(),
+            timestamp=int(time.time()),
+        )
+        response = client.post(self.URL + '/api/upload', data=str(json.dumps(upload_dict)))
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json()['status'], 'ok')
+        self.assertEqual(response.json()['processing']['python-tests'], dict(status='ok'))
+
+        response = client.get(self.URL + '/api/upload')
+        self.assertEqual(response.status_code, 200)
+        for key in ['suite', 'configuration', 'test_results', 'timestamp']:
+            self.assertEqual(response.json()[0][key], upload_dict[key])
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_no_download(self, client, **kwargs):
+        response = client.get(self.URL + '/api/upload')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), [])
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_no_process(self, client, **kwargs):
+        response = client.post(self.URL + '/api/process')
+        self.assertEqual(response.status_code, 404)
+
+
+class UploadControllerTest(FlaskTestCase, WaitForDockerTestCase):
+    KEYSPACE = 'upload_controller_test_keyspace'
+
+    @classmethod
+    def setup_webserver(cls, app, redis=StrictRedis, cassandra=CassandraContext):
+        cassandra.drop_keyspace(keyspace=cls.KEYSPACE)
+        model = MockModelFactory.create(redis=redis(), cassandra=cassandra(keyspace=cls.KEYSPACE, create_keyspace=True))
+        model.upload_context.register_upload_callback('python-tests', lambda **kwargs: dict(status='ok'))
+        MockModelFactory.add_mock_results(model)
+        app.register_blueprint(APIRoutes(model))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_suites(self, client, **kwargs):
+        response = client.get(self.URL + '/api/suites')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(12, len(response.json()))
+        for element in response.json():
+            self.assertEqual(1, len(element[1]))
+            self.assertEqual('layout-tests', element[1][0])
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_suites_with_filter(self, client, **kwargs):
+        response = client.get(self.URL + '/api/suites?platform=Mac&style=Release&flavor=wk2')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(1, len(response.json()))
+        self.assertEqual(1, len(response.json()[0][1]))
+        self.assertEqual('layout-tests', response.json()[0][1][0])
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_suites_with_suite_filter(self, client, **kwargs):
+        response = client.get(self.URL + '/api/suites?suite=layout-tests')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(12, len(response.json()))
+
+        response = client.get(self.URL + '/api/suites?suite=api-tests')
+        self.assertEqual(response.status_code, 404)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_download_with_filter(self, client, **kwargs):
+        response = client.get(self.URL + '/api/upload?platform=Mac&style=Release&flavor=wk2')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(5, len(response.json()))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_download_with_duel_filter(self, client, **kwargs):
+        response = client.get(self.URL + '/api/upload?platform=Mac&style=Release&flavor=wk1&flavor=wk2')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(10, len(response.json()))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_download_with_limit(self, client, **kwargs):
+        response = client.get(self.URL + '/api/upload?platform=Mac&style=Release&flavor=wk2&limit=2')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(2, len(response.json()))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_download_with_range(self, client, **kwargs):
+        response = client.get(self.URL + '/api/upload?platform=Mac&style=Release&flavor=wk2&after_id=236542&before_id=236544')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(3, len(response.json()))
+        self.assertEqual(sorted(['236544', '236543', '236542']), sorted([result['commits'][0]['id'] for result in response.json()]))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_download_for_commit(self, client, **kwargs):
+        response = client.get(self.URL + '/api/upload?platform=Mac&style=Release&flavor=wk2&id=236542')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(1, len(response.json()))
+        self.assertEqual(['236542'], [result['commits'][0]['id'] for result in response.json()])
+
+        response = client.get(self.URL + '/api/upload?platform=Mac&style=Release&flavor=wk2&id=bb6bda5f')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(5, len(response.json()))
+        self.assertEqual(['bb6bda5f44dd24d0b54539b8ff6e8c17f519249a'] * 5, [result['commits'][1]['id'] for result in response.json()])
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_process(self, client, **kwargs):
+        response = client.post(self.URL + '/api/upload/process?platform=Mac&style=Release&flavor=wk2')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(5, len(response.json()))
+        self.assertEqual([dict(status='ok')] * 5, [element['processing']['python-tests'] for element in response.json()])
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @FlaskTestCase.run_with_webserver()
+    def test_process_commit(self, client, **kwargs):
+        response = client.post(self.URL + '/api/upload/process?platform=Mac&style=Release&flavor=wk2&id=236543')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(1, len(response.json()))
+        self.assertEqual(['236543'], [result['commits'][0]['id'] for result in response.json()])
+        self.assertEqual([dict(status='ok')], [element['processing']['python-tests'] for element in response.json()])
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpyflask_support__init__py"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/flask_support/__init__.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/flask_support/__init__.py                            (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/flask_support/__init__.py       2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1 @@
</span><ins>+# DO NOTHING
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpyflask_supportauthed_blueprintpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/flask_support/authed_blueprint.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/flask_support/authed_blueprint.py                            (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/flask_support/authed_blueprint.py       2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,34 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from flask import Blueprint
+
+
+class AuthedBlueprint(Blueprint):
+    def __init__(self, name, import_name, auth_decorator=None, **kwargs):
+        super(AuthedBlueprint, self).__init__(name, import_name, **kwargs)
+        self.auth_decorator = auth_decorator
+
+    def add_url_rule(self, rule, endpoint=None, view_func=None, authed=True, **options):
+        if not self.auth_decorator or not authed:
+            return super(AuthedBlueprint, self).add_url_rule(rule, endpoint=endpoint, view_func=view_func, **options)
+        return super(AuthedBlueprint, self).add_url_rule(rule, endpoint=endpoint, view_func=self.auth_decorator(view_func), **options)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpyflask_supportflask_test_contextpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/flask_support/flask_test_context.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/flask_support/flask_test_context.py                          (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/flask_support/flask_test_context.py     2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,72 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import requests
+import time
+
+from flask import Flask
+from multiprocessing import Process, Semaphore
+
+
+class FlaskTestContext(object):
+    PORT = 5001
+
+    @classmethod
+    def start_webserver(cls, method, semaphore):
+        try:
+            app = Flask('testing')
+            method(app)
+            app.add_url_rule('/__health', 'health', lambda: 'ok', methods=('GET',))
+        finally:
+            semaphore.release()
+        return app.run(host='0.0.0.0', port=cls.PORT)
+
+    def __init__(self, method):
+        self.method = method
+        self.process = None
+
+    def __enter__(self):
+        semaphore = Semaphore(0)
+        self.process = Process(target=self.start_webserver, args=(self.method, semaphore))
+        self.process.start()
+
+        with semaphore:
+            for attempt in range(3):
+                if not self.process.is_alive():
+                    raise RuntimeError('Exception raised when starting web-server')
+
+                try:
+                    response = requests.get(f'http://localhost:{self.PORT}/__health')
+                    if response.text != 'ok':
+                        raise RuntimeError('Health check failed')
+                    return
+                except requests.ConnectionError as e:
+                    time.sleep(.05)
+
+        raise RuntimeError('Failed to connect to server for health check')
+
+    def __exit__(self, *args):
+        if not self.process:
+            return
+        if not self.process.is_alive():
+            raise RuntimeError('Web-server has crashed')
+        self.process.terminate()
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpyflask_supportflask_testcasepy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/flask_support/flask_testcase.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/flask_support/flask_testcase.py                              (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/flask_support/flask_testcase.py 2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,140 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import atexit
+import json
+import os
+import requests
+import unittest
+
+from flask import Flask
+from flask.wrappers import Response
+from selenium import webdriver
+from selenium.common.exceptions import WebDriverException
+from resultsdbpy.flask_support.flask_test_context import FlaskTestContext
+
+
+class FlaskRequestsResponse(Response):
+    @property
+    def text(self):
+        return self.data.decode('utf-8')
+
+    def json(self):
+        return json.loads(self.text)
+
+
+class FlaskTestCase(unittest.TestCase):
+    URL = f'http://localhost:{FlaskTestContext.PORT}'
+
+    _driver = None
+    _cached_driver = False
+    _printed_webserver_warning = False
+
+    @classmethod
+    def driver(cls):
+        if cls._cached_driver:
+            return cls._driver
+
+        try:
+            result = webdriver.Safari()
+            result.get('about:blank')
+            atexit.register(result.close)
+            cls._driver = result
+        except WebDriverException as e:
+            print(e.msg)
+
+        except ImportError:
+            print('Selenium is not installed, run \'pip install selenium\'')
+
+        cls._cached_driver = True
+        return cls._driver
+
+    @classmethod
+    def setup_webserver(cls, app, **kwargs):
+        raise NotImplementedError()
+
+    @classmethod
+    def combine(cls, *args):
+        def decorator(func):
+            for elm in reversed(args):
+                func = elm(func)
+            return func
+
+        return decorator
+
+    @classmethod
+    def run_with_real_webserver(cls):
+        def decorator(method):
+            def real_method(val, method=method, **kwargs):
+                with FlaskTestContext(lambda app: val.setup_webserver(app, **kwargs)):
+                    return method(val, client=requests, **kwargs)
+            real_method.__name__ = method.__name__
+            return real_method
+
+        return cls.combine(
+            unittest.skipIf(not int(os.environ.get('web_server', '0')), 'WebServer tests disabled'),
+            decorator,
+        )
+
+    @classmethod
+    def run_with_mock_webserver(cls):
+        def decorator(method):
+            def real_method(val, method=method, **kwargs):
+                app = Flask('testing')
+                app.response_class = FlaskRequestsResponse
+                app.config['TESTING'] = True
+                val.setup_webserver(app, **kwargs)
+                app.add_url_rule('/__health', 'health', lambda: 'ok', methods=('GET',))
+                return method(val, client=app.test_client(), **kwargs)
+
+            real_method.__name__ = method.__name__
+            return real_method
+
+        return decorator
+
+    @classmethod
+    def run_with_webserver(cls):
+        if int(os.environ.get('web_server', '0') and int(os.environ.get('slow_tests', '0'))):
+            if not cls._printed_webserver_warning:
+                print('Using real web server, requests routed through requests library')
+            cls._printed_webserver_warning = True
+            return cls.run_with_real_webserver()
+        if not cls._printed_webserver_warning:
+            print('Using mock web server, requests routed through flask testing framework')
+        cls._printed_webserver_warning = True
+        return cls.run_with_mock_webserver()
+
+    @classmethod
+    def run_with_selenium(cls):
+
+        def decorator(method):
+            def real_method(val, method=method, **kwargs):
+                return method(val, driver=cls.driver(), **kwargs)
+            real_method.__name__ = method.__name__
+            return real_method
+
+        return cls.combine(
+            cls.run_with_real_webserver(),
+            unittest.skipIf(not int(os.environ.get('selenium', '0')), 'Selenium tests disabled'),
+            unittest.skipIf(not cls.driver(), 'Selenium not available for testing'),
+            decorator,
+        )
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpyflask_supportutilpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/flask_support/util.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/flask_support/util.py                                (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/flask_support/util.py   2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,108 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS `"AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import json
+from flask import abort, request
+
+
+class FlaskJSONEncoder(json.JSONEncoder):
+    # Flask's jsonify only accepts a dictionary and does not accept a JSON encoder. When overriding the default JSON
+    # encoder, encoder.default(...) will return a dictionary to be serialized. However, the default encoder will raise
+    # an exception if default(...) is called on primative JSON types. Implement a version of the default encoder which
+    # passes primative JSON types back to the caller.
+    def default(self, obj):
+        if isinstance(obj, dict):
+            return {key: self.default(value) for key, value in obj.items()}
+        if isinstance(obj, list):
+            return [self.default(value) for value in obj]
+        return obj
+
+
+class AssertRequest(object):
+    @classmethod
+    def is_type(cls, supported_requests=None):
+        if supported_requests is None:
+            supported_requests = ['GET']
+        if not supported_requests:
+            abort(500, description='Endpoint does not support any requests')
+        if request.method not in supported_requests:
+            abort(405, description='Endpoint only supports {} requests'.format(
+                supported_requests[0] if len(supported_requests) == 1 else ', '.join(supported_requests[:-1]) + ' and ' + supported_requests[-1],
+            ))
+
+    @classmethod
+    def no_query(cls):
+        if request.query_string:
+            abort(400, description='Queries not supported on this endpoint')
+
+    @classmethod
+    def query_kwargs_empty(cls, **kwargs):
+        for key, value in kwargs.items():
+            if value:
+                abort(400, description=f"'{key}' not supported in queries by this endpoint")
+
+
+def query_as_kwargs():
+    def decorator(method):
+        def real_method(val, method=method, **kwargs):
+            for key, value in request.args.to_dict(flat=False).items():
+                if key in kwargs:
+                    abort(400, description=f'{key} is not a valid query parameter on this endpoint')
+                kwargs[key] = tuple(value)
+            return method(val, **kwargs)
+
+        real_method.__name__ = method.__name__
+        return real_method
+    return decorator
+
+
+def boolean_query(*args):
+
+    def func(string):
+        if string.lower() in ['true', 'yes']:
+            return True
+        try:
+            return bool(int(string))
+        except ValueError:
+            return False
+
+    return [func(arg) for arg in args]
+
+
+def limit_for_query(default_limit=100):
+    def decorator(method):
+        def real_method(self=None, method=method, limit=None, **kwargs):
+            limit_to_use = default_limit
+            if limit:
+                try:
+                    limit_to_use = int(limit[-1])
+                    if limit_to_use <= 0:
+                        raise ValueError()
+                except ValueError:
+                    abort(400, description='Limit must be a positive integer')
+            if self:
+                return method(self=self, limit=limit_to_use, **kwargs)
+            return method(limit=limit_to_use, **kwargs)
+
+        real_method.__name__ = method.__name__
+        return real_method
+    return decorator
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpyflask_supportutil_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/flask_support/util_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/flask_support/util_unittest.py                               (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/flask_support/util_unittest.py  2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,50 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+
+from werkzeug.exceptions import BadRequest
+from resultsdbpy.flask_support.util import boolean_query, limit_for_query
+
+
+class UtilTest(unittest.TestCase):
+
+    def test_boolean_query(self):
+        self.assertTrue(all(boolean_query('True', 'true')))
+        self.assertTrue(all(boolean_query('Yes', 'yes')))
+        self.assertTrue(all(boolean_query('1', '100')))
+
+        self.assertFalse(any(boolean_query('False', 'false')))
+        self.assertFalse(any(boolean_query('No', 'no')))
+        self.assertFalse(any(boolean_query('0', 'any string')))
+
+    def test_limit_decorator(self):
+        @limit_for_query()
+        def func(limit=None):
+            return limit
+
+        self.assertEqual(func(), 100)
+        self.assertEqual(func(limit=['10']), 10)
+        self.assertEqual(func(limit=['10', '1']), 1)
+        self.assertRaises(BadRequest, func, limit=['string'])
+        self.assertRaises(BadRequest, func, limit=['0'])
+        self.assertRaises(BadRequest, func, limit=['-1'])
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodel__init__py"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/__init__.py ( => )</h4>
<pre class="diff"><span>
<span class="info">Added: trunk/Tools/resultsdbpy/resultsdbpy/model/cassandra_context.py
===================================================================
</span><del>--- trunk/Tools/resultsdbpy/resultsdbpy/model/cassandra_context.py                              (rev 0)
</del><ins>+++ trunk/Tools/resultsdbpy/resultsdbpy/model/cassandra_context.py       2019-07-19 01:34:38 UTC (rev 247628)
</ins><span class="lines">@@ -0,0 +1,324 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import contextlib
+import os
+import re
+import uuid
+
+from cassandra.cluster import Cluster
+from cassandra.cqlengine.columns import Text
+from cassandra.cqlengine.connection import register_connection, unregister_connection
+from cassandra.cqlengine.management import CQLENG_ALLOW_SCHEMA_MANAGEMENT, get_cluster, create_keyspace_network_topology, create_keyspace_simple, drop_keyspace, sync_table
+from cassandra.cqlengine.models import Model
+from cassandra.cqlengine.query import BatchQuery
+
+
+class RegexCluster(Text):
+
+    def __init__(self, min_length=1, max_length=None, primary_key=True, partition_key=False, **kwargs):
+        assert primary_key
+        assert not partition_key
+        super(RegexCluster, self).__init__(min_length=min_length, max_length=max_length, primary_key=primary_key, partition_key=partition_key, **kwargs)
+
+
+class CountedBatchQuery(BatchQuery):
+    DEFAULT_LIMIT = 100
+
+    def __init__(self, limit=DEFAULT_LIMIT, **kwargs):
+        super(CountedBatchQuery, self).__init__(**kwargs)
+        self.limit = limit
+
+    def add_query(self, query):
+        if len(self.queries) >= self.limit:
+            self.execute()
+            self._executed = False
+        return super(CountedBatchQuery, self).add_query(query)
+
+
+class CassandraContext(object):
+
+    @classmethod
+    def can_modify_schema(cls):
+        return os.getenv(CQLENG_ALLOW_SCHEMA_MANAGEMENT, False)
+
+    @classmethod
+    def drop_keyspace(cls, nodes=None, keyspace='results_database', auth_provider=None):
+        nodes = nodes if nodes else ['localhost']
+        connection_id = uuid.uuid4()
+
+        try:
+            register_connection(name=str(connection_id), session=Cluster(nodes, auth_provider=auth_provider).connect())
+            does_keyspace_exist = keyspace in get_cluster(str(connection_id)).metadata.keyspaces
+            if does_keyspace_exist:
+                drop_keyspace(keyspace, connections=[str(connection_id)])
+        finally:
+            unregister_connection(name=str(connection_id))
+
+    def __init__(self, nodes=None, keyspace='results_database', auth_provider=None, create_keyspace=False, replication_map=None):
+        self.keyspace = keyspace
+        self._depth = 0
+        self._connection_id = uuid.uuid4()
+        self._models = {}
+        self._nodes = nodes if nodes else ['localhost']
+        self._auth_provider = auth_provider
+        self._batch = []
+
+        try:
+            register_connection(name=str(self._connection_id), session=Cluster(self._nodes, auth_provider=self._auth_provider).connect())
+            does_keyspace_exist = self.keyspace in get_cluster(str(self._connection_id)).metadata.keyspaces
+
+            if create_keyspace and not does_keyspace_exist:
+                if not self.can_modify_schema():
+                    raise Exception('Cannot create keyspace, Schema modification is disabled')
+                if replication_map is None:
+                    create_keyspace_simple(self.keyspace, replication_factor=1, connections=[str(self._connection_id)])
+                else:
+                    create_keyspace_network_topology(self.keyspace, dc_replication_map=replication_map, connections=[str(self._connection_id)])
+            elif not does_keyspace_exist:
+                raise Exception(f'Keyspace {self.keyspace} does not exist and will not be created')
+        finally:
+            unregister_connection(name=str(self._connection_id))
+
+    def __enter__(self):
+        if self._depth == 0:
+            register_connection(name=str(self._connection_id), session=Cluster(self._nodes, auth_provider=self._auth_provider).connect(keyspace=self.keyspace))
+        self._depth += 1
+
+    def __exit__(self, *args, **kwargs):
+        self._depth -= 1
+        if self._depth <= 0:
+            unregister_connection(name=str(self._connection_id))
+
+    def assert_connected(self):
+        if self._depth <= 0:
+            raise AssertionError('No Cassandra session available')
+
+    class AssertConnectedDecorator():
+
+        def __call__(self, function):
+            def decorator(obj, *args, **kwargs):
+                obj.assert_connected()
+                return function(obj, *args, **kwargs)
+            return decorator
+
+    @property
+    @AssertConnectedDecorator()
+    def cluster(self):
+        return get_cluster(str(self._connection_id))
+
+    def schema_for_table(self, table_name):
+        if not self.cluster.metadata:
+            return None
+        keyspace_metadata = self.cluster.metadata.keyspaces.get(self.keyspace, None)
+        if not keyspace_metadata:
+            return None
+        return keyspace_metadata.tables.get(table_name)
+
+    @AssertConnectedDecorator()
+    def create_table(self, model):
+        does_schema_match = self.does_table_model_match_schema(model)
+        if does_schema_match is False:
+            raise self.SchemaException('Existing schema does not match provided model')
+
+        table_name = model._raw_column_family_name()
+        self._models[table_name] = model
+        self._models[table_name].__connection__ = str(self._connection_id)
+        self._models[table_name].__keyspace__ = self.keyspace
+
+        if does_schema_match:
+            return
+
+        assert self.can_modify_schema()
+
+        # We have a special model named RegexCluster which allows LIKE operations to be preformed on a primary key quickly.
+        # This was specifically intended for git commits, although has a few other potential uses.
+        sasi_index_column = None
+        for attr in dir(model):
+            if isinstance(getattr(getattr(model, attr, None), 'column', None), RegexCluster):
+                if sasi_index_column:
+                    raise self.SchemaException('Only one RegexCluster allowed')
+                sasi_index_column = attr
+
+        sync_table(model, keyspaces=[self.keyspace], connections=[str(self._connection_id)])
+        if sasi_index_column:
+            for session in self.cluster.sessions:
+                if session.keyspace != self.keyspace:
+                    continue
+                # https://docs.datastax.com/en/dse/5.1/cql/cql/cql_using/useSASIIndex.html
+                session.execute(f"""CREATE CUSTOM INDEX index_{table_name}_{sasi_index_column} ON {table_name} ({sasi_index_column}) USING \
+'org.apache.cassandra.index.sasi.SASIIndex' WITH OPTIONS = {{ \
+'analyzer_class': 'org.apache.cassandra.index.sasi.analyzer.StandardAnalyzer', \
+'case_sensitive': 'true'}}""")
+                break
+
+    @AssertConnectedDecorator()
+    def does_table_model_match_schema(self, model):
+        if not issubclass(model, Model):
+            raise self.SchemaException('Models must be derived from base Model.')
+        if model.__abstract__:
+            raise self.SchemaException('Cannot create table from abstract model')
+
+        schema = self.schema_for_table(model._raw_column_family_name())
+        if schema is None:
+            return None
+
+        primary_columns = []
+        data_columns = []
+        for key in model._columns.keys():
+            if getattr(model, key).column.partition_key or getattr(model, key).column.primary_key:
+                primary_columns.append(key)
+            else:
+                data_columns.append(key)
+
+        schema_columns = []
+        for column in schema.columns:
+            schema_columns.append(column)
+
+        if len(primary_columns) + len(data_columns) != len(schema_columns):
+            return False
+
+        for i in range(len(primary_columns)):
+            if primary_columns[i] != schema_columns[i]:
+                return False
+        for element in data_columns:
+            if element not in schema_columns:
+                return False
+
+        partition_keys = [column.name for column in schema.partition_key]
+        primary_keys = [column.name for column in schema.primary_key]
+
+        for column in primary_columns + data_columns:
+            model_column = getattr(model, column).column
+            if schema.columns[column].cql_type != model_column.db_type:
+                return False
+
+            if model_column.partition_key and column not in partition_keys:
+                return False
+            if model_column.primary_key and column not in primary_keys:
+                return False
+
+            if model_column.clustering_order == 'DESC' and not schema.columns[column].is_reversed:
+                return False
+            if (model_column.clustering_order == 'ASC' or model_column.clustering_order is None) and schema.columns[column].is_reversed:
+                return False
+
+        return True
+
+    @AssertConnectedDecorator()
+    @contextlib.contextmanager
+    def batch_query_context(self, limit=CountedBatchQuery.DEFAULT_LIMIT):
+        self._batch.append(CountedBatchQuery(limit=limit, connection=str(self._connection_id)))
+        try:
+            with self._batch[-1]:
+                yield
+        finally:
+            del self._batch[-1]
+
+    @AssertConnectedDecorator()
+    def insert_row(self, table_name, ttl=None, **kwargs):
+        if table_name not in self._models:
+            raise self.SchemaException(f'{table_name} does not exist in the database')
+
+        # If the ttl has already expired, don't even bother sending the data.
+        if ttl and ttl < 0:
+            return
+
+        if len(self._batch):
+            self._models[table_name].batch(self._batch[-1]).ttl(ttl).create(**kwargs)
+        else:
+            self._models[table_name].ttl(ttl).create(**kwargs)
+
+    @staticmethod
+    def filter_for_argument(key, value):
+        key_value = key.split('__')[0]
+        operator = None if len(key.split('__')) == 1 else key.split('__')[1]
+
+        if operator == 'in':
+            return lambda v, key_value=key_value, value=value: getattr(v, key_value) in value
+        elif operator == 'gt':
+            return lambda v, key_value=key_value, value=value: getattr(v, key_value) > value
+        elif operator == 'gte':
+            return lambda v, key_value=key_value, value=value: getattr(v, key_value) >= value
+        elif operator == 'lt':
+            return lambda v, key_value=key_value, value=value: getattr(v, key_value) < value
+        elif operator == 'lte':
+            return lambda v, key_value=key_value, value=value: getattr(v, key_value) <= value
+        elif operator == 'like':
+
+            def regex_filter(v, key_value=key_value, value=value):
+                regex = re.escape(value)
+                regex = regex.replace(re.escape('%'), '.*')
+                return bool(re.match(r'\A' + regex + r'\Z', getattr(v, key_value)))
+
+            return regex_filter
+
+        elif operator is None or operator is 'in':
+            return lambda v, key_value=key_value, value=value: getattr(v, key_value) == value
+
+        raise self.SelectException('Unrecognized operator {}'.format(operator))
+
+    @AssertConnectedDecorator()
+    def select_from_table(self, table_name, limit=10000, **kwargs):
+        if table_name not in self._models:
+            raise self.SchemaException(f'{table_name} does not exist in the database')
+
+        create_args = {}
+        for name, column in self._models[table_name]._columns.items():
+            if not column.partition_key and not column.primary_key:
+                continue
+            did_find_column_name = False
+            using_range_query = False
+            for arg, value in kwargs.items():
+                # cqlengine will use arguments like '<column_name>__like' to indicate that an argument will have some kind of
+                # operation performed. We're looking for column name here.
+                if name == arg.split('__')[0] and value is not None:
+                    create_args[arg] = value
+                    did_find_column_name = True
+                    using_range_query = '__' in arg
+            if not did_find_column_name or using_range_query:
+                break
+
+        # Not all versions of Cassandra support filtering. Since filtering is inefficient in Cassandra anyways, queries which rely
+        # on filtering should be able to retrieve the data from the database and filter it server-side.
+        filters = []
+        for arg, value in kwargs.items():
+            if arg not in create_args and value is not None:
+                filters.append(self.filter_for_argument(arg, value))
+
+        query_to_be_run = self._models[table_name].objects(**create_args).limit(limit)
+
+        # Forces cqlengine to dispatch the query before exiting the function
+        result = []
+        for element in query_to_be_run:
+            for f in filters:
+                if not f(element):
+                    break
+            else:
+                result.append(element)
+        return result
+
+    class SchemaException(RuntimeError):
+        pass
+
+    class SelectException(RuntimeError):
+        pass
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelcassandra_context_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/cassandra_context_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/cassandra_context_unittest.py                          (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/cassandra_context_unittest.py     2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,439 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import time
+
+from cassandra.cqlengine import columns
+from cassandra.cqlengine.models import Model
+from resultsdbpy.model.cassandra_context import CassandraContext, RegexCluster
+from resultsdbpy.model.mock_cassandra_context import MockCassandraContext
+from resultsdbpy.model.wait_for_docker_test_case import WaitForDockerTestCase
+
+
+class CassandraTest(WaitForDockerTestCase):
+
+    REAL_CASSANDRA_REQUIRED = 'Real Cassandra instance required'
+    KEYSPACE = 'keyspace_for_testing'
+    TABLE = 'example_table'
+
+    def init_database(self, cassandra=CassandraContext):
+        cassandra.drop_keyspace(keyspace=self.KEYSPACE)
+        self.database = cassandra(keyspace=self.KEYSPACE, create_keyspace=True)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_keyspace_creation_failure(self, cassandra=CassandraContext):
+        with self.assertRaises(Exception):
+            cassandra(keyspace='does_not_exist')
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_session_assertion(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.assertRaises(AssertionError):
+            self.database.assert_connected()
+
+    def create_testing_table(self):
+        with self.database:
+
+            class ExampleTable(Model):
+                __table_name__ = self.TABLE
+                value1 = columns.Text(partition_key=True)
+                value2 = columns.Integer(primary_key=True)
+                value3 = columns.Integer()
+
+            self.database.create_table(ExampleTable)
+            return ExampleTable
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_create_table(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+            example_table = self.create_testing_table()
+            self.assertTrue(self.database.does_table_model_match_schema(example_table))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_existing_table_more_columns(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+            self.create_testing_table()
+            with self.assertRaises(CassandraContext.SchemaException):
+
+                class ExampleTable(Model):
+                    __table_name__ = self.TABLE
+                    value1 = columns.Text(partition_key=True)
+                    value2 = columns.Integer(primary_key=True)
+                    value3 = columns.Integer()
+                    value4 = columns.Text()
+
+                self.database.create_table(ExampleTable)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_existing_table_different_columns(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+            self.create_testing_table()
+            with self.assertRaises(CassandraContext.SchemaException):
+
+                class ExampleTable(Model):
+                    __table_name__ = self.TABLE
+                    value1 = columns.Text(partition_key=True)
+                    value2 = columns.Integer(primary_key=True)
+                    value3 = columns.Text()
+
+                self.database.create_table(ExampleTable)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_existing_table_different_primary_key(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+            self.create_testing_table()
+            with self.assertRaises(CassandraContext.SchemaException):
+
+                class ExampleTable(Model):
+                    __table_name__ = self.TABLE
+                    value1 = columns.Text(partition_key=True)
+                    value2 = columns.Integer()
+                    value3 = columns.Integer(primary_key=True)
+
+                self.database.create_table(ExampleTable)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_existing_table_different_clustering_order(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+            self.create_testing_table()
+            with self.assertRaises(CassandraContext.SchemaException):
+
+                class ExampleTable(Model):
+                    __table_name__ = self.TABLE
+                    value1 = columns.Text(partition_key=True)
+                    value2 = columns.Integer(primary_key=True, clustering_order='DESC')
+                    value3 = columns.Integer()
+
+                self.database.create_table(ExampleTable)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_existing_table_column_order(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+
+            class ExampleTable(Model):
+                __table_name__ = self.TABLE
+                value1 = columns.Text(partition_key=True)
+                value2 = columns.Integer(primary_key=True)
+                value3 = columns.Text(primary_key=True)
+                value4 = columns.Integer()
+
+            self.database.create_table(ExampleTable)
+
+            with self.assertRaises(CassandraContext.SchemaException):
+                class ExampleTable(Model):
+                    __table_name__ = self.TABLE
+                    value1 = columns.Text(partition_key=True)
+                    value3 = columns.Text(primary_key=True)
+                    value2 = columns.Integer(primary_key=True)
+                    value4 = columns.Integer()
+
+                self.database.create_table(ExampleTable)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_insert_rows(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+
+            class ExampleTable(Model):
+                __table_name__ = self.TABLE
+                value1 = columns.Text(partition_key=True)
+                value2 = columns.Integer(primary_key=True)
+                value3 = columns.Text()
+
+            self.database.create_table(ExampleTable)
+
+            for i in range(10):
+                for j in range(10):
+                    self.database.insert_row(self.TABLE, value1=f'key{i}', value2=j, value3=f'value{i}-{j}')
+
+            for i in range(10):
+                for j in range(10):
+                    result = self.database.select_from_table(self.TABLE, value1=f'key{i}', value2=j)
+                    self.assertEqual(1, len(result))
+                    self.assertEqual(result[0], ExampleTable(value1=f'key{i}', value2=j, value3=f'value{i}-{j}'))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_duplicate_row(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+
+            class ExampleTable(Model):
+                __table_name__ = self.TABLE
+                value1 = columns.Text(partition_key=True)
+                value2 = columns.Integer(primary_key=True)
+                value3 = columns.Text()
+
+            self.database.create_table(ExampleTable)
+
+            self.database.insert_row(self.TABLE, value1='key', value2=1, value3='duplicate-1')
+            self.database.insert_row(self.TABLE, value1='key', value2=1, value3='duplicate-2')
+
+            result = self.database.select_from_table(self.TABLE, value1='key', value2=1)
+            self.assertEqual(1, len(result))
+            self.assertEqual(result[0], ExampleTable(value1='key', value2=1, value3='duplicate-2'))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_search_by_other_value(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+
+            class ExampleTable(Model):
+                __table_name__ = self.TABLE
+                value1 = columns.Text(partition_key=True)
+                value2 = RegexCluster(primary_key=True)
+                value3 = columns.Integer()
+
+            self.database.create_table(ExampleTable)
+
+            self.database.insert_row(self.TABLE, value1='key', value2='value 1', value3=1)
+            self.database.insert_row(self.TABLE, value1='key', value2='other 1', value3=2)
+            self.database.insert_row(self.TABLE, value1='key', value2='other 2', value3=3)
+
+            result = self.database.select_from_table(self.TABLE, value2__like='value%', value1='key')
+            self.assertEqual(1, len(result))
+            self.assertEqual(result[0], ExampleTable(value1='key', value2='value 1', value3=1))
+
+            result = self.database.select_from_table(self.TABLE, value2__like='other%', value1='key')
+            self.assertEqual(2, len(result))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_search_with_multiple_keys(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+
+            class ExampleTable(Model):
+                __table_name__ = self.TABLE
+                value1 = columns.Text(partition_key=True)
+                value2 = columns.Text(primary_key=True)
+                value3 = columns.Integer()
+
+            self.database.create_table(ExampleTable)
+
+            self.database.insert_row(self.TABLE, value1='key1', value2='value1', value3=1)
+            self.database.insert_row(self.TABLE, value1='key2', value2='value1', value3=2)
+            self.database.insert_row(self.TABLE, value1='key3', value2='value1', value3=3)
+
+            result = self.database.select_from_table(self.TABLE, value1__in=['key1', 'key2'], value2='value1')
+            self.assertEqual(2, len(result))
+            self.assertIn(ExampleTable(value1='key1', value2='value1', value3=1), result)
+            self.assertIn(ExampleTable(value1='key2', value2='value1', value3=2), result)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    @WaitForDockerTestCase.run_if_slow()
+    def test_time_to_live(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+
+            class ExampleTable(Model):
+                __table_name__ = self.TABLE
+                value1 = columns.Text(partition_key=True)
+                value2 = columns.Integer(primary_key=True)
+                value3 = columns.Text()
+
+            self.database.create_table(ExampleTable)
+
+            self.database.insert_row(self.TABLE, value1='key', value2=1, value3='value-to-kill', ttl=1)
+            self.assertEqual(1, len(self.database.select_from_table(self.TABLE, value1='key', value2=1)))
+            time.sleep(1)
+            self.assertEqual(0, len(self.database.select_from_table(self.TABLE, value1='key', value2=1)))
+
+    def populate_comparison_table(self, clustering_order='ASC'):
+        with self.database:
+
+            class ExampleTable(Model):
+                __table_name__ = self.TABLE
+                value1 = columns.Text(partition_key=True)
+                value2 = columns.Integer(primary_key=True, clustering_order=clustering_order)
+                value3 = columns.Text()
+
+            self.database.create_table(ExampleTable)
+            keys = ['one', 'two', 'three', 'four', 'five']
+            for index in range(len(keys)):
+                for value in range(index + 1):
+                    self.database.insert_row(
+                        self.TABLE, value1=keys[index], value2=value + 1,
+                        value3=f'{keys[index]}-{value + 1}',
+                    )
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_limit(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+            self.populate_comparison_table()
+            self.assertEqual(len(self.database.select_from_table(self.TABLE, value1='five')), 5)
+            self.assertEqual(len(self.database.select_from_table(self.TABLE, value1__in=['four', 'five'])), 9)
+            self.assertEqual(len(self.database.select_from_table(self.TABLE, value1='five', limit=3)), 3)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_order(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+            self.populate_comparison_table()
+            previous = None
+            results = self.database.select_from_table(self.TABLE, value1='five')
+            for result in results:
+                if previous:
+                    self.assertLess(previous.value2, result.value2)
+                previous = result
+
+            previous = None
+            results = self.database.select_from_table(self.TABLE, value1='five', limit=3)
+            for result in results:
+                if previous:
+                    self.assertLess(previous.value2, result.value2)
+                previous = result
+
+            self.assertEqual(results[0].value2, 1)
+            self.assertEqual(results[-1].value2, 3)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_reverse_order(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+            self.populate_comparison_table(clustering_order='DESC')
+            previous = None
+            results = self.database.select_from_table(self.TABLE, value1='five')
+            for result in results:
+                if previous:
+                    self.assertGreater(previous.value2, result.value2)
+                previous = result
+
+            previous = None
+            results = self.database.select_from_table(self.TABLE, value1='five', limit=3)
+            for result in results:
+                if previous:
+                    self.assertGreater(previous.value2, result.value2)
+                previous = result
+
+            self.assertEqual(results[0].value2, 5)
+            self.assertEqual(results[-1].value2, 3)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_gt(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+            self.populate_comparison_table()
+            self.assertEqual(len(self.database.select_from_table(self.TABLE, value1='one', value2__gt=1)), 0)
+            self.assertEqual(len(self.database.select_from_table(self.TABLE, value1='two', value2__gt=1)), 1)
+            self.assertEqual(len(self.database.select_from_table(self.TABLE, value1='three', value2__gt=1)), 2)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_lt(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+            self.populate_comparison_table()
+            self.assertEqual(len(self.database.select_from_table(self.TABLE, value1='one', value2__lt=1)), 0)
+            self.assertEqual(len(self.database.select_from_table(self.TABLE, value1='two', value2__lt=2)), 1)
+            self.assertEqual(len(self.database.select_from_table(self.TABLE, value1='three', value2__lt=3)), 2)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_gte(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+            self.populate_comparison_table()
+            self.assertEqual(len(self.database.select_from_table(self.TABLE, value1='one', value2__gte=2)), 0)
+            self.assertEqual(len(self.database.select_from_table(self.TABLE, value1='two', value2__gte=2)), 1)
+            self.assertEqual(len(self.database.select_from_table(self.TABLE, value1='three', value2__gte=2)), 2)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_lte(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+            self.populate_comparison_table()
+            self.assertEqual(len(self.database.select_from_table(self.TABLE, value1='one', value2__lte=0)), 0)
+            self.assertEqual(len(self.database.select_from_table(self.TABLE, value1='two', value2__lte=1)), 1)
+            self.assertEqual(len(self.database.select_from_table(self.TABLE, value1='three', value2__lte=2)), 2)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_between(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+            self.populate_comparison_table()
+            self.assertEqual(len(self.database.select_from_table(self.TABLE, value1='five', value2__gt=1, value2__lt=5)), 3)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_multiple_primary(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+
+            class ExampleTable(Model):
+                __table_name__ = self.TABLE
+                value1 = columns.Text(partition_key=True)
+                value2 = columns.Integer(primary_key=True)
+                value3 = columns.Integer(primary_key=True)
+                value4 = columns.Text()
+
+            self.database.create_table(ExampleTable)
+            self.database.insert_row(self.TABLE, value1='key', value2=0, value3=0, value4='0-0')
+            self.database.insert_row(self.TABLE, value1='key', value2=0, value3=1, value4='0-1')
+            self.database.insert_row(self.TABLE, value1='key', value2=1, value3=0, value4='1-0')
+
+            results = self.database.select_from_table(self.TABLE, value1='key', value2=0, value3=0)
+            self.assertEqual(1, len(results))
+            self.assertEqual('0-0', results[0].value4)
+
+            results = self.database.select_from_table(self.TABLE, value1='key', value2=0)
+            self.assertEqual(2, len(results))
+            self.assertEqual('0-0', results[0].value4)
+            self.assertEqual('0-1', results[1].value4)
+
+            results = self.database.select_from_table(self.TABLE, value1='key', value3=0)
+            self.assertEqual(2, len(results))
+            self.assertEqual('0-0', results[0].value4)
+            self.assertEqual('1-0', results[1].value4)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_query_with_none(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        self.create_testing_table()
+
+        with self.database:
+            self.database.insert_row(self.TABLE, value1='key', value2=0, value3=0)
+            self.database.insert_row(self.TABLE, value1='key', value2=1, value3=1)
+
+            results = self.database.select_from_table(self.TABLE, value1='key', value2=None)
+            self.assertEqual(2, len(results))
+            self.assertEqual(0, results[0].value3)
+            self.assertEqual(1, results[1].value3)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_cassandra=MockCassandraContext)
+    def test_batch_query(self, cassandra=CassandraContext):
+        self.init_database(cassandra=cassandra)
+        with self.database:
+            self.create_testing_table()
+
+            with self.database.batch_query_context():
+                for value1 in ['key1', 'key2', 'key3']:
+                    for value2 in range(40):
+                        self.database.insert_row(self.TABLE, value1=value1, value2=value2, value3=value2)
+
+            for value1 in ['key1', 'key2', 'key3']:
+                results = self.database.select_from_table(self.TABLE, value1=value1)
+                for index in range(len(results)):
+                    self.assertEqual(index, results[index].value2)
+                    self.assertEqual(index, results[index].value3)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelcasserolepy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/casserole.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/casserole.py                           (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/casserole.py      2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,156 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import random
+import requests
+import threading
+import time
+
+from redis import StrictRedis
+
+
+class CasseroleNodes(object):
+    DEFAULT_INTERVAL = 120  # Refresh every 2 minutes
+
+    def __init__(self, url, interval_seconds=DEFAULT_INTERVAL, asynchronous=True):
+        self.url = url
+        self.interval = interval_seconds
+        self._epoch = 0
+        self.asynchronous = asynchronous
+        self._nodes = []
+        self.retrieve_nodes()
+        if not self._nodes:
+            raise RuntimeError(f'Cannot communicate with Casserole url {self.url}')
+
+    def retrieve_nodes(self):
+        casserole_response = requests.get(self.url)
+        if casserole_response.status_code != 200:
+            return
+        self._epoch = time.time()
+        self._nodes = casserole_response.text.split(',')
+
+    @property
+    def nodes(self):
+        if self._epoch + self.interval > time.time():
+            return self._nodes
+
+        if self.asynchronous:
+            request_thread = threading.Thread(target=self.retrieve_nodes)
+            request_thread.daemon = True
+            request_thread.start()
+        else:
+            self.retrieve_nodes()
+        return self._nodes
+
+    def __len__(self):
+        return len(self.nodes)
+
+    def __iter__(self):
+        return iter(self.nodes)
+
+
+class CasseroleRedis(object):
+    DEFAULT_INTERVAL = 120  # Refresh every 2 minutes
+
+    def __init__(
+        self, url, port=6379, password=None, interval_seconds=DEFAULT_INTERVAL, asynchronous=True,
+        ssl_keyfile=None, ssl_certfile=None, ssl_cert_reqs='required', ssl_ca_certs=None,
+    ):
+        self.url = url
+        self.interval = interval_seconds
+        self.asynchronous = asynchronous
+
+        self._redis = None
+        self._redis_url = None
+        self._redis_kwargs = dict(
+            port=port,
+            password=password,
+            ssl_keyfile=ssl_keyfile,
+            ssl_certfile=ssl_certfile,
+            ssl_cert_reqs=ssl_cert_reqs,
+            ssl_ca_certs=ssl_ca_certs,
+        )
+
+        self._epoch = time.time()
+        self.connect()
+
+    def connect(self):
+        if self._redis and self._epoch + self.interval > time.time():
+            return
+
+        def do_connection(obj=self):
+            casserole_response = requests.get(obj.url)
+            if casserole_response.status_code != 200:
+                return
+
+            candidates = {}
+            for candidate in casserole_response.json():
+                if not candidate['isMaster']:
+                    continue
+                candidates[candidate['host']] = candidate
+            if self._redis and self._redis_url in candidates:
+                # We're still connected, we don't need to do anything
+                return
+
+            self._redis_url = random.choice(list(candidates.keys()))
+            port = candidates[self._redis_url].get('sslPort')
+            kwargs = dict(**self._redis_kwargs)
+            kwargs['port'] = port or kwargs['port']
+
+            self._redis = StrictRedis(
+                host=self._redis_url,
+                ssl=True if port else False,
+                **kwargs
+            )
+
+            obj._epoch = time.time()
+
+        if self.asynchronous and self._redis:
+            request_thread = threading.Thread(target=do_connection)
+            request_thread.daemon = True
+            request_thread.start()
+        else:
+            do_connection()
+
+    def ping(self):
+        self.connect()
+        return self._redis.ping()
+
+    def get(self, name):
+        self.connect()
+        return self._redis.get(name)
+
+    def set(self, *args, **kwargs):
+        self.connect()
+        return self._redis.set(*args, **kwargs)
+
+    def lock(self, name, **kwargs):
+        self.connect()
+        return self._redis.lock(name, **kwargs)
+
+    def delete(self, *names):
+        self.connect()
+        return self._redis.delete(*names)
+
+    def scan_iter(self, match=None, **kwargs):
+        self.connect()
+        return self._redis.scan_iter(match=match, **kwargs)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelcasserole_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/casserole_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/casserole_unittest.py                          (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/casserole_unittest.py     2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,70 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import mock
+import time
+import unittest
+
+from resultsdbpy.model.casserole import CasseroleNodes
+
+
+class MockRequest(object):
+
+    def __init__(self, text='', status_code=200):
+        time.sleep(.1)  # This split second acts like an actual request
+        self.text = text
+        self.status_code = status_code
+
+
+class CasseroleTest(unittest.TestCase):
+    def test_synchronous(self):
+        with mock.patch('requests.get', new=lambda *args, **kwargs: MockRequest('start')):
+            nodes = CasseroleNodes('some-url', interval_seconds=10, asynchronous=False)
+            self.assertEqual(['start'], nodes.nodes)
+
+            with mock.patch('requests.get', new=lambda *args, **kwargs: MockRequest('url1,url2')):
+                self.assertEqual(['start'], nodes.nodes)
+                current_time = time.time()
+
+                with mock.patch('time.time', new=lambda: current_time + 15):
+                    self.assertEqual(['url1', 'url2'], nodes.nodes)
+
+    def test_asynchronous(self):
+        with mock.patch('requests.get', new=lambda *args, **kwargs: MockRequest('start')):
+            nodes = CasseroleNodes('some-url', interval_seconds=10, asynchronous=True)
+            self.assertEqual(['start'], nodes.nodes)
+
+            with mock.patch('requests.get', new=lambda *args, **kwargs: MockRequest('url1,url2')):
+                self.assertEqual(['start'], nodes.nodes)
+                current_time = time.time()
+
+                with mock.patch('time.time', new=lambda: current_time + 15):
+                    self.assertEqual(['start'], nodes.nodes)
+                    time.sleep(.15)  # Wait for the asynchronous thread to finish
+                    self.assertEqual(['url1', 'url2'], nodes.nodes)
+
+    def test_list_like(self):
+        with mock.patch('requests.get', new=lambda *args, **kwargs: MockRequest('url1,url2,url3')):
+            nodes = CasseroleNodes('some-url')
+            self.assertEqual(['url1', 'url2', 'url3'], [node for node in nodes])
+            self.assertTrue(nodes)
+            self.assertTrue('url1' in nodes)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelci_contextpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/ci_context.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/ci_context.py                          (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/ci_context.py     2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,277 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import calendar
+import json
+import requests
+import sys
+import time
+
+from cassandra.cqlengine import columns
+from datetime import datetime
+from fakeredis import FakeStrictRedis
+from resultsdbpy.controller.commit import Commit
+from resultsdbpy.model.commit_context import CommitContext
+from resultsdbpy.model.configuration_context import ClusteredByConfiguration
+from resultsdbpy.model.upload_context import UploadCallbackContext
+
+
+class CIContext(UploadCallbackContext):
+    DEFAULT_CASSANDRA_LIMIT = 100
+
+    class URLsBase(ClusteredByConfiguration):
+        suite = columns.Text(partition_key=True, required=True)
+        branch = columns.Text(partition_key=True, required=True)
+        urls = columns.Text(required=False)
+
+        def unpack(self):
+            result = dict(
+                uuid=self.uuid,
+                start_time=calendar.timegm(self.start_time.timetuple()),
+            )
+            for key, value in json.loads(self.urls).items():
+                if value is not None:
+                    result[key] = value
+            return result
+
+    class URLsByCommit(URLsBase):
+        __table_name__ = 'ci_urls_by_commit'
+        uuid = columns.BigInt(primary_key=True, required=True, clustering_order='DESC')
+        sdk = columns.Text(primary_key=True, required=True)
+        start_time = columns.DateTime(primary_key=True, required=True)
+
+    class URLsByStartTime(URLsBase):
+        __table_name__ = 'ci_urls_by_start_time'
+        start_time = columns.DateTime(primary_key=True, required=True, clustering_order='DESC')
+        sdk = columns.Text(primary_key=True, required=True)
+        uuid = columns.BigInt(primary_key=True, required=True)
+
+    class URLByQueue(ClusteredByConfiguration):
+        __table_name__ = 'ci_url_by_queue'
+        branch = columns.Text(partition_key=True, required=True)
+        suite = columns.Text(primary_key=True, required=True)
+        sdk = columns.Text(primary_key=True, required=True)
+        url = columns.Text(required=False)
+
+        def unpack(self):
+            return self.url
+
+    def __init__(self, configuration_context, commit_context, ttl_seconds=None, url_factories=None):
+        super(CIContext, self).__init__(
+            name='ci-urls',
+            configuration_context=configuration_context,
+            commit_context=commit_context,
+            ttl_seconds=ttl_seconds,
+        )
+        self._url_factories = {}
+        for factory in url_factories or []:
+            self.add_url_factory(factory)
+
+        with self:
+            self.cassandra.create_table(self.URLsByCommit)
+            self.cassandra.create_table(self.URLsByStartTime)
+            self.cassandra.create_table(self.URLByQueue)
+
+    def add_url_factory(self, factory):
+        self._url_factories[factory.master] = factory
+
+    def url(self, master, should_fetch=False, **kwargs):
+        factory = self._url_factories.get(master)
+        if not factory:
+            return None
+        return factory.url(fetch=should_fetch, **kwargs)
+
+    def register(self, configuration, commits, suite, test_results, timestamp=None):
+        timestamp = timestamp or time.time()
+        if not isinstance(timestamp, datetime):
+            timestamp = datetime.utcfromtimestamp(int(timestamp))
+
+        try:
+            details = test_results.get('details', {})
+            urls = {}
+
+            url_factory = self._url_factories.get(details.get('buildbot-master'))
+            if url_factory:
+                urls['queue'] = url_factory.url(builder_name=details.get('builder-name'))
+                urls['build'] = url_factory.url(
+                    builder_name=details.get('builder-name'),
+                    build_number=details.get('build-number'),
+                    should_fetch=True,
+                )
+                urls['worker'] = url_factory.url(
+                    worker_name=details.get('buildbot-worker'),
+                    should_fetch=True,
+                )
+            elif details.get('buildbot-master') and 'link' not in details:
+                raise ValueError(f"No link defined or URL factory for {details['buildbot-master']}")
+
+            # Custom build links override constructed buildbot links
+            if 'link' in details:
+                urls['build'] = details['link']
+
+            for key in details.keys():
+                if details[key] is None:
+                    del details[key]
+
+            uuid = self.commit_context.uuid_for_commits(commits)
+            ttl = int((uuid / Commit.TIMESTAMP_TO_UUID_MULTIPLIER) + self.ttl_seconds - time.time()) if self.ttl_seconds else None
+
+            with self, self.cassandra.batch_query_context():
+                for branch in self.commit_context.branch_keys_for_commits(commits):
+                    if urls.get('queue'):
+                        self.configuration_context.insert_row_with_configuration(
+                            self.URLByQueue.__table_name__, configuration=configuration, suite=suite,
+                            branch=branch, sdk=configuration.sdk or '?', ttl=ttl,
+                            url=urls['queue'],
+                        )
+
+                    for table in [self.URLsByCommit, self.URLsByStartTime]:
+                        self.configuration_context.insert_row_with_configuration(
+                            table.__table_name__, configuration=configuration, suite=suite,
+                            branch=branch, uuid=uuid, ttl=ttl,
+                            sdk=configuration.sdk or '?', start_time=timestamp,
+                            urls=json.dumps(urls),
+                        )
+        except Exception as e:
+            return self.partial_status(e)
+        return self.partial_status()
+
+    def find_urls_by_queue(self, configurations, suite, recent=True, branch=None, limit=DEFAULT_CASSANDRA_LIMIT):
+        if not isinstance(suite, str):
+            raise TypeError(f'Expected type {str}, got {type(suite)}')
+
+        with self:
+            result = {}
+            for configuration in configurations:
+                result.update({config: values[0].unpack() for config, values in self.configuration_context.select_from_table_with_configurations(
+                    self.URLByQueue.__table_name__, configurations=[configuration], recent=recent,
+                    suite=suite, sdk=configuration.sdk, branch=branch or self.commit_context.DEFAULT_BRANCH_KEY,
+                    limit=limit,
+                ).items()})
+            return result
+
+    def _find_urls(
+            self, table, configurations, suite, recent=True,
+            branch=None, begin=None, end=None,
+            begin_query_time=None, end_query_time=None,
+            limit=DEFAULT_CASSANDRA_LIMIT,
+    ):
+        if not isinstance(suite, str):
+            raise TypeError(f'Expected type {str}, got {type(suite)}')
+
+        def get_time(time):
+            if isinstance(time, datetime):
+                return time
+            elif time:
+                return datetime.utcfromtimestamp(int(time))
+            return None
+
+        with self:
+            result = {}
+            for configuration in configurations:
+                data = self.configuration_context.select_from_table_with_configurations(
+                    table.__table_name__, configurations=[configuration], recent=recent,
+                    suite=suite, sdk=configuration.sdk, branch=branch or self.commit_context.DEFAULT_BRANCH_KEY,
+                    uuid__gte=CommitContext.convert_to_uuid(begin),
+                    uuid__lte=CommitContext.convert_to_uuid(end, CommitContext.timestamp_to_uuid()),
+                    start_time__gte=get_time(begin_query_time), start_time__lte=get_time(end_query_time),
+                    limit=limit,
+                ).items()
+                result.update({config: [value.unpack() for value in values] for config, values in data})
+            return result
+
+    def find_urls_by_commit(self, *args, **kwargs):
+        return self._find_urls(self.URLsByCommit, *args, **kwargs)
+
+    def find_urls_by_start_time(self, *args, **kwargs):
+        return self._find_urls(self.URLsByStartTime, *args, **kwargs)
+
+
+class BuildbotURLFactory(object):
+    SCHEME = 'https://'
+    BUILDER_KEY = '_builder_'
+    WORKER_KEY = '_worker_'
+    FETCH_RATE = 60 * 60  # Allow a re-fetch every hour
+
+    def __init__(self, master, redis=None):
+        self.master = master
+        self.redis = redis or FakeStrictRedis()
+
+    def fetch(self):
+        self.redis.set(f'{self.master}_refreshed', 0, ex=self.FETCH_RATE)
+
+        response = requests.get(f'{self.SCHEME}{self.master}/api/v2/builders')
+        if response.status_code == 200:
+            for builder in response.json()['builders']:
+                self.redis.set(
+                    f"{self.master}_{self.BUILDER_KEY}_{builder['name']}",
+                    str(builder['builderid']),
+                )
+        else:
+            sys.stderr.write(f'Failed to retrieve builders information from {self.master}')
+
+        response = requests.get(f'{self.SCHEME}{self.master}/api/v2/workers')
+        if response.status_code == 200:
+            for worker in response.json()['workers']:
+                self.redis.set(
+                    f"{self.master}_{self.WORKER_KEY}_{worker['name']}",
+                    str(worker['workerid']),
+                )
+        else:
+            sys.stderr.write(f'Failed to retrieve workers information from {self.master}')
+
+    def _builder_url(self, builder_id):
+        return f'{self.SCHEME}{self.master}/#/builders/{builder_id}'
+
+    def _build_url(self, builder_id, build_number):
+        return f'{self._builder_url(builder_id=builder_id)}/builds/{build_number}'
+
+    def _worker_url(self, worker_id):
+        return f'{self.SCHEME}{self.master}/#/workers/{worker_id}'
+
+    def url(self, builder_name=None, build_number=None, worker_name=None, should_fetch=False):
+        if should_fetch and self.redis.get(f'{self.master}_refreshed'):
+            should_fetch = False
+
+        while True:
+            if builder_name and not worker_name:
+                builder_id = self.redis.get(f'{self.master}_{self.BUILDER_KEY}_{builder_name}')
+                if builder_id:
+                    if build_number:
+                        return self._build_url(builder_id=builder_id.decode('utf-8'), build_number=build_number)
+                    return self._builder_url(builder_id=builder_id.decode('utf-8'))
+
+            elif worker_name and not builder_name and not build_number:
+                worker_id = self.redis.get(f'{self.master}_{self.WORKER_KEY}_{worker_name}')
+                if worker_id:
+                    return self._worker_url(worker_id=worker_id.decode('utf-8'))
+
+            else:
+                # We can't generate a URL for the provided combination
+                return None
+
+            if not should_fetch:
+                break
+            self.fetch()
+            should_fetch = False
+
+        return None
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelci_context_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/ci_context_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/ci_context_unittest.py                         (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/ci_context_unittest.py    2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,341 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import contextlib
+import json
+import mock
+import time
+import unittest
+
+from fakeredis import FakeStrictRedis
+from redis import StrictRedis
+from resultsdbpy.model.cassandra_context import CassandraContext
+from resultsdbpy.model.ci_context import BuildbotURLFactory, CIContext
+from resultsdbpy.controller.configuration import Configuration
+from resultsdbpy.model.mock_cassandra_context import MockCassandraContext
+from resultsdbpy.model.mock_model_factory import MockModelFactory
+from resultsdbpy.model.mock_repository import MockSVNRepository
+from resultsdbpy.model.wait_for_docker_test_case import WaitForDockerTestCase
+
+
+class URLFactoryTest(WaitForDockerTestCase):
+
+    class MockRequest(object):
+
+        def __init__(self, text='', status_code=200):
+            self.text = text
+            self.status_code = status_code
+
+        def json(self):
+            return json.loads(self.text)
+
+    BUILD_MASTER = 'build.webkit.org'
+
+    BUILDERS = dict(
+        builders=[
+            dict(
+                builderid=1,
+                name='Mojave-Release-Builder',
+                description=None,
+                masterids=[1],
+                tags=[
+                    'Mojave',
+                    'Release',
+                ],
+            ),
+            dict(
+                builderid=2,
+                name='Mojave-Release-WK2-Tests',
+                description=None,
+                masterids=[1],
+                tags=[
+                    'Mojave',
+                    'Release',
+                    'Tests',
+                    'WK2',
+                ],
+            ),
+            dict(
+                builderid=3,
+                name='Mojave-Release-WK1-Tests',
+                description=None,
+                masterids=[1],
+                tags=[
+                    'Mojave',
+                    'Release',
+                    'Tests',
+                    'WK1',
+                ],
+            ),
+            dict(
+                builderid=4,
+                name='Catalina-Release-Builder',
+                description=None,
+                masterids=[1],
+                tags=[
+                    'Catalina',
+                    'Release',
+                ],
+            ),
+            dict(
+                builderid=5,
+                name='Catalina-Release-WK2-Tests',
+                description=None,
+                masterids=[1],
+                tags=[
+                    'Catalina',
+                    'Release',
+                    'Tests',
+                    'WK2',
+                ],
+            ),
+            dict(
+                builderid=6,
+                name='Catalina-Release-WK1-Tests',
+                description=None,
+                masterids=[1],
+                tags=[
+                    'Catalina',
+                    'Release',
+                    'Tests',
+                    'WK1',
+                ],
+            ),
+        ],
+        meta=dict(total=6),
+    )
+    WORKERS = dict(
+        workers=[
+            dict(
+                workerid=1,
+                name='builder1',
+                configured_on=[
+                    dict(
+                        builderid=1,
+                        masterid=1,
+                    ),
+                ],
+                connected_to=[dict(masterid=1)],
+                graceful=False,
+                paused=False,
+                workerinfo=dict(
+                    access_uri='ssh://buildbot@builder1',
+                    admin='WebKit Operations <email@webkit.org>',
+                    host='?',
+                    version='1.1.1'
+                ),
+            ),
+            dict(
+                workerid=2,
+                name='builder2',
+                configured_on=[
+                    dict(
+                        builderid=4,
+                        masterid=1,
+                    ),
+                ],
+                connected_to=[dict(masterid=1)],
+                graceful=False,
+                paused=False,
+                workerinfo=dict(
+                    access_uri='ssh://buildbot@builder2',
+                    admin='WebKit Operations <email@webkit.org>',
+                    host='?',
+                    version='1.1.1'
+                ),
+            ),
+            dict(
+                workerid=3,
+                name='bot1',
+                configured_on=[
+                    dict(
+                        builderid=2,
+                        masterid=1,
+                    ),
+                    dict(
+                        builderid=3,
+                        masterid=1,
+                    ),
+                ],
+                connected_to=[dict(masterid=1)],
+                graceful=False,
+                paused=False,
+                workerinfo=dict(
+                    access_uri='ssh://buildbot@bot1',
+                    admin='WebKit Operations <email@webkit.org>',
+                    host='?',
+                    version='1.1.1'
+                ),
+            ),
+            dict(
+                workerid=4,
+                name='bot2',
+                configured_on=[
+                    dict(
+                        builderid=5,
+                        masterid=1,
+                    ),
+                    dict(
+                        builderid=6,
+                        masterid=1,
+                    ),
+                ],
+                connected_to=[dict(masterid=1)],
+                graceful=False,
+                paused=False,
+                workerinfo=dict(
+                    access_uri='ssh://buildbot@bot2',
+                    admin='WebKit Operations <email@webkit.org>',
+                    host='?',
+                    version='1.1.1'
+                ),
+            ),
+        ],
+        meta=dict(total=4),
+    )
+
+    @classmethod
+    def get(cls, url, *args, **kwargs):
+        if url == f'https://{cls.BUILD_MASTER}/api/v2/builders':
+            return cls.MockRequest(text=json.dumps(cls.BUILDERS))
+        if url == f'https://{cls.BUILD_MASTER}/api/v2/workers':
+            return cls.MockRequest(text=json.dumps(cls.WORKERS))
+        return cls.MockRequest(status_code=500)
+
+    @classmethod
+    @contextlib.contextmanager
+    def mock(cls):
+        with mock.patch('requests.get', new=cls.get):
+            yield
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis)
+    def test_builder_url(self, redis=StrictRedis):
+        with self.mock():
+            factory = BuildbotURLFactory(master='build.webkit.org', redis=redis())
+            self.assertIsNone(factory.url(builder_name='Mojave-Release-Builder'))
+            self.assertEqual('https://build.webkit.org/#/builders/1', factory.url(builder_name='Mojave-Release-Builder', should_fetch=True))
+            self.assertEqual('https://build.webkit.org/#/builders/4', factory.url(builder_name='Catalina-Release-Builder'))
+            self.assertIsNone(factory.url(builder_name='HighSierra-Release-Builder', should_fetch=True))
+
+            self.assertEqual('https://build.webkit.org/#/builders/4/builds/8', factory.url(builder_name='Catalina-Release-Builder', build_number=8))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis)
+    def test_worker_url(self, redis=StrictRedis):
+        with self.mock():
+            factory = BuildbotURLFactory(master='build.webkit.org', redis=redis())
+            self.assertIsNone(factory.url(worker_name='builder1'))
+            self.assertEqual('https://build.webkit.org/#/workers/1', factory.url(worker_name='builder1', should_fetch=True))
+            self.assertEqual('https://build.webkit.org/#/workers/3', factory.url(worker_name='bot1'))
+            self.assertIsNone(factory.url(worker_name='bot3', should_fetch=True))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis)
+    def test_invalid_combinations(self, redis=StrictRedis):
+        with self.mock():
+            factory = BuildbotURLFactory(master='build.webkit.org', redis=redis())
+            self.assertIsNone(factory.url(builder_name='Mojave-Release-Builder', worker_name='builder1', should_fetch=True))
+            self.assertIsNone(factory.url(builder_name='Mojave-Release-Builder', build_number=1, worker_name='builder1', should_fetch=True))
+            self.assertIsNone(factory.url(build_number=1, worker_name='builder1', should_fetch=True))
+
+
+class CIContextTest(WaitForDockerTestCase):
+    KEYSPACE = 'suite_context_test_keyspace'
+
+    def init_database(self, redis=StrictRedis, cassandra=CassandraContext):
+        with URLFactoryTest.mock():
+            cassandra.drop_keyspace(keyspace=self.KEYSPACE)
+            self.model = MockModelFactory.create(redis=redis(), cassandra=cassandra(keyspace=self.KEYSPACE, create_keyspace=True))
+            self.model.ci_context.add_url_factory(BuildbotURLFactory(master='build.webkit.org', redis=self.model.redis))
+
+            with self.model.upload_context:
+                # Mock results are more complicated because we want to attach results to builders
+                for configuration in [
+                    Configuration(platform='Mac', version_name='Catalina', version='10.15.0', sdk='19A500', is_simulator=False, architecture='x86_64', style='Release', flavor='wk1'),
+                    Configuration(platform='Mac', version_name='Catalina', version='10.15.0', sdk='19A500', is_simulator=False, architecture='x86_64', style='Release', flavor='wk2'),
+                    Configuration(platform='Mac', version_name='Mojave', version='10.14.0', sdk='18A500', is_simulator=False, architecture='x86_64', style='Release', flavor='wk1'),
+                    Configuration(platform='Mac', version_name='Mojave', version='10.14.0', sdk='18A500', is_simulator=False, architecture='x86_64', style='Release', flavor='wk2'),
+                ]:
+                    build_count = [1]
+
+                    def callback(commits, model=self.model, configuration=configuration, count=build_count):
+                        results = MockModelFactory.layout_test_results()
+                        results['details'] = {
+                            'buildbot-master': URLFactoryTest.BUILD_MASTER,
+                            'builder-name': f'{configuration.version_name}-{configuration.style}-{configuration.flavor.upper()}-Tests',
+                            'build-number': str(count[0]),
+                            'buildbot-worker': {
+                                'Mojave': 'bot1',
+                                'Catalina': 'bot2',
+                            }.get(configuration.version_name, None),
+                        }
+                        model.upload_context.upload_test_results(configuration, commits, suite='layout-tests', timestamp=time.time(), test_results=results)
+                        count[0] += 1
+
+                    MockModelFactory.iterate_all_commits(self.model, callback)
+                    MockModelFactory.process_results(self.model, configuration)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_builder_for_single_configuration(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        urls = self.model.ci_context.find_urls_by_queue(configurations=[Configuration(version_name='Mojave', flavor='wk2')], suite='layout-tests')
+        self.assertEqual(sorted(urls.values()), ['https://build.webkit.org/#/builders/2'])
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_builder_for_multiple_configuration(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        urls = self.model.ci_context.find_urls_by_queue(configurations=[Configuration(version_name='Catalina')], suite='layout-tests')
+        self.assertEqual(sorted(urls.values()), ['https://build.webkit.org/#/builders/5', 'https://build.webkit.org/#/builders/6'])
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_url_for_commit(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        urls = self.model.ci_context.find_urls_by_commit(
+            configurations=[Configuration(version_name='Mojave', flavor='wk2')],
+            suite='layout-tests',
+            begin=MockSVNRepository.webkit().commit_for_id(236542),
+            end=MockSVNRepository.webkit().commit_for_id(236542),
+        )
+
+        self.assertEqual(next(iter(urls.values()))[0]['queue'], 'https://build.webkit.org/#/builders/2')
+        self.assertEqual(next(iter(urls.values()))[0]['build'], 'https://build.webkit.org/#/builders/2/builds/3')
+        self.assertEqual(next(iter(urls.values()))[0]['worker'], 'https://build.webkit.org/#/workers/3')
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_find_by_time(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        urls = self.model.ci_context.find_urls_by_commit(
+            configurations=[Configuration(version_name='Catalina')],
+            suite='layout-tests',
+            begin_query_time=(time.time() - 60 * 60),
+        )
+        self.assertEqual(len(urls), 2)
+        for urls_for_config in urls.values():
+            self.assertEqual(len(urls_for_config), 5)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_find_no_tests_by_time(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        urls = self.model.ci_context.find_urls_by_commit(
+            configurations=[Configuration(version_name='Mojave')],
+            suite='layout-tests',
+            end_query_time=(time.time() - 60 * 60),
+        )
+        self.assertEqual(len(urls), 0)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelcommit_contextpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/commit_context.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/commit_context.py                              (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/commit_context.py 2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,342 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import calendar
+import json
+import time
+
+from cassandra.cqlengine import columns
+from cassandra.cqlengine.models import Model
+from datetime import datetime
+from resultsdbpy.controller.commit import Commit
+from resultsdbpy.model.repository import Repository, SCMException
+
+
+class CommitContext(object):
+
+    class CommitModel(Model):
+        repository_id = columns.Text(partition_key=True, required=True)
+        branch = columns.Text(partition_key=True, required=True)
+        committer = columns.Text()
+        message = columns.Text()
+
+        def to_commit(self):
+            return Commit(
+                repository_id=self.repository_id, branch=self.branch,
+                id=self.commit_id,
+                timestamp=self.uuid // Commit.TIMESTAMP_TO_UUID_MULTIPLIER,
+                order=self.uuid % Commit.TIMESTAMP_TO_UUID_MULTIPLIER,
+                committer=self.committer, message=self.message,
+            )
+
+    class CommitByID(CommitModel):
+        __table_name__ = 'commits_id_to_timestamp_uuid'
+        commit_id = columns.Text(primary_key=True, required=True)
+        uuid = columns.BigInt(required=True)
+
+    class CommitByUuidAscending(CommitModel):
+        __table_name__ = 'commits_timestamp_uuid_to_id_ascending'
+        uuid = columns.BigInt(primary_key=True, required=True, clustering_order='ASC')
+        commit_id = columns.Text(required=True)
+
+    class CommitByUuidDescending(CommitModel):
+        __table_name__ = 'commits_timestamp_uuid_to_id_descending'
+        uuid = columns.BigInt(primary_key=True, required=True, clustering_order='DESC')
+        commit_id = columns.Text(required=True)
+
+    class Branches(Model):
+        __table_name__ = 'commit_branches'
+        repository_id = columns.Text(partition_key=True, required=True)
+        branch = columns.Text(primary_key=True, required=True)
+
+    DEFAULT_BRANCH_KEY = 'default'
+
+    def __init__(self, redis, cassandra, cache_timeout=60 * 60 * 24 * 2):
+        assert redis
+        assert cassandra
+
+        self.redis = redis
+        self.cassandra = cassandra
+        self.repositories = {}
+        self.cache_timeout = cache_timeout
+
+        with self:
+            self.cassandra.create_table(self.CommitByID)
+            self.cassandra.create_table(self.CommitByUuidAscending)
+            self.cassandra.create_table(self.CommitByUuidDescending)
+            self.cassandra.create_table(self.Branches)
+
+    def __enter__(self):
+        self.cassandra.__enter__()
+
+    def __exit__(self, *args, **kwargs):
+        self.cassandra.__exit__(*args, **kwargs)
+
+    @classmethod
+    def timestamp_to_uuid(cls, timestamp=None):
+        if timestamp is None:
+            return int(time.time()) * Commit.TIMESTAMP_TO_UUID_MULTIPLIER
+        elif isinstance(timestamp, datetime):
+            return calendar.timegm(timestamp.timetuple()) * Commit.TIMESTAMP_TO_UUID_MULTIPLIER
+        return int(timestamp) * Commit.TIMESTAMP_TO_UUID_MULTIPLIER
+
+    @classmethod
+    def convert_to_uuid(cls, value, default=0):
+        if value is None:
+            return default
+        elif isinstance(value, Commit):
+            return value.uuid
+        else:
+            return cls.timestamp_to_uuid(value)
+
+    @classmethod
+    def uuid_for_commits(cls, commits):
+        return max([commit.uuid for commit in commits])
+
+    def branch_keys_for_commits(self, commits):
+        branches = set()
+
+        # This covers a mostly theoretical edge case where a multiple commits are provided on different branches. In
+        # this case, track branches which are not the default branch independently.
+        for commit in commits:
+            repo = self.repositories.get(commit.repository_id, Repository(key=commit.repository_id))
+            if commit.branch != repo.DEFAULT_BRANCH:
+                branches.add(commit.branch)
+        if len(branches) == 0:
+            branches.add(self.DEFAULT_BRANCH_KEY)
+        return list(branches)
+
+    def run_function_through_redis_cache(self, key, function):
+        result = self.redis.get('commit_mapping:' + key)
+        if result:
+            try:
+                result = [Commit.from_json(element) for element in json.loads(result)]
+            except ValueError:
+                result = None
+        if result is None:
+            result = function()
+        if result:
+            self.redis.set('commit_mapping:' + key, json.dumps(result, cls=Commit.Encoder), ex=self.cache_timeout)
+        return result
+
+    def register_repository(self, repository):
+        if not isinstance(repository, Repository):
+            raise TypeError(f'Expected type {Repository}, got {type(repository)}')
+        self.repositories[repository.key] = repository
+
+    def find_commits_by_id(self, repository_id, branch, commit_id, limit=100):
+        if branch is None:
+            branch = self.repositories[repository_id].DEFAULT_BRANCH
+
+        def callback(commit_id=commit_id):
+            with self:
+                if isinstance(commit_id, int) or commit_id.isdigit():
+                    return [model.to_commit() for model in self.cassandra.select_from_table(
+                        self.CommitByID.__table_name__, limit=limit,
+                        repository_id=repository_id, branch=branch, commit_id=str(commit_id),
+                    )]
+
+                # FIXME: SASI indecies are the canoical way to solve this problem, but require Cassandra 3.4 which
+                # hasn't been deployed to our datacenters yet. This works for commits, but is less transparent.
+                return [model.to_commit() for model in self.cassandra.select_from_table(
+                    self.CommitByID.__table_name__, limit=limit,
+                    repository_id=repository_id, branch=branch, commit_id__gte=commit_id.lower(), commit_id__lte=(commit_id.lower() + 'g'),
+                )]
+
+        return self.run_function_through_redis_cache(
+            f'repository_id={repository_id}:branch={branch}:commit_id={str(commit_id).lower()}',
+            callback,
+        )
+
+    def find_commits_by_uuid(self, repository_id, branch, uuid, limit=100):
+        if branch is None:
+            branch = self.repositories[repository_id].DEFAULT_BRANCH
+
+        def callback():
+            with self:
+                return [model.to_commit() for model in self.cassandra.select_from_table(
+                    self.CommitByUuidDescending.__table_name__, limit=limit,
+                    repository_id=repository_id, branch=branch, uuid=uuid,
+                )]
+
+        return self.run_function_through_redis_cache(
+            f'repository_id={repository_id}:branch={branch}:uuid={uuid}',
+            callback,
+        )
+
+    def find_commits_by_timestamp(self, repository_id, branch, timestamp, limit=100):
+        if branch is None:
+            branch = self.repositories[repository_id].DEFAULT_BRANCH
+
+        if isinstance(timestamp, datetime):
+            timestamp = calendar.timegm(timestamp.timetuple())
+        else:
+            timestamp = int(timestamp)
+
+        def callback():
+            with self.cassandra:
+                return [model.to_commit() for model in self.cassandra.select_from_table(
+                    self.CommitByUuidDescending.__table_name__, limit=limit,
+                    repository_id=repository_id, branch=branch,
+                    uuid__gte=self.timestamp_to_uuid(timestamp),
+                    uuid__lt=self.timestamp_to_uuid(timestamp + 1),
+                )]
+
+        return self.run_function_through_redis_cache(
+            f'repository_id={repository_id}:branch={branch}:timestamp={timestamp}',
+            callback,
+        )
+
+    def find_commits_in_range(self, repository_id, branch=None, begin=None, end=None, limit=100):
+        if branch is None:
+            branch = self.repositories[repository_id].DEFAULT_BRANCH
+
+        begin = self.convert_to_uuid(begin)
+        end = self.convert_to_uuid(end, self.timestamp_to_uuid())
+
+        with self:
+            return [model.to_commit() for model in self.cassandra.select_from_table(
+                self.CommitByUuidDescending.__table_name__, limit=limit,
+                repository_id=repository_id, branch=branch,
+                uuid__gte=begin,
+                uuid__lte=end,
+            )]
+
+    def _adjacent_commit(self, commit, ascending=True):
+        if not isinstance(commit, Commit):
+            raise TypeError(f'Expected type {Commit}, got {type(commit)}')
+
+        table = self.CommitByUuidAscending.__table_name__ if ascending else self.CommitByUuidDescending.__table_name__
+
+        with self.cassandra:
+            adjacent_commits = [model.to_commit() for model in self.cassandra.select_from_table(
+                table, limit=1,
+                repository_id=commit.repository_id, branch=commit.branch,
+                uuid__lt=self.timestamp_to_uuid() if ascending else commit.uuid,
+                uuid__gt=commit.uuid if ascending else 0,
+            )]
+            if len(adjacent_commits) == 0:
+                return None
+            return adjacent_commits[0]
+
+    def next_commit(self, commit):
+        return self._adjacent_commit(commit, ascending=True)
+
+    def previous_commit(self, commit):
+        return self._adjacent_commit(commit, ascending=False)
+
+    def sibling_commits(self, commit, repository_ids):
+        if not isinstance(commit, Commit):
+            raise TypeError(f'Expected type {Commit}, got {type(commit)}')
+
+        begin_timestamp_uuid = self.timestamp_to_uuid(commit.timestamp)
+        end_timestamp_uuid = self.timestamp_to_uuid()
+
+        with self.cassandra:
+            next_commit = self.next_commit(commit)
+            if next_commit:
+                end_timestamp_uuid = self.timestamp_to_uuid(next_commit.timestamp)
+
+            siblings = {}
+            for id in repository_ids:
+                siblings[id] = []
+                if id not in self.repositories:
+                    continue
+
+                if commit.branch == self.repositories[commit.repository_id].DEFAULT_BRANCH or not self.branches(id, commit.branch):
+                    branch = self.repositories[id].DEFAULT_BRANCH
+                else:
+                    branch = commit.branch
+
+                siblings[id] = [model.to_commit() for model in self.cassandra.select_from_table(
+                    self.CommitByUuidDescending.__table_name__,
+                    repository_id=id, branch=branch,
+                    uuid__gt=begin_timestamp_uuid,
+                    uuid__lt=end_timestamp_uuid,
+                )]
+                previous = [model.to_commit() for model in self.cassandra.select_from_table(
+                    self.CommitByUuidDescending.__table_name__, limit=1,
+                    repository_id=id, branch=branch,
+                    uuid__lte=begin_timestamp_uuid,
+                )]
+                if previous:
+                    siblings[id].append(previous[0])
+            return siblings
+
+    def branches(self, repository_id, branch=None, limit=100):
+        with self:
+            if branch:
+                # FIXME: SASI indecies are the canoical way to solve this problem, but require Cassandra 3.4 which
+                # hasn't been deployed to our datacenters yet. This works for branches, but is less transparent.
+                return [model.branch for model in self.cassandra.select_from_table(
+                    self.Branches.__table_name__, limit=limit,
+                    repository_id=repository_id, branch__gte=branch, branch__lte=(branch + '~'),
+                )]
+
+            return [model.branch for model in self.cassandra.select_from_table(
+                self.Branches.__table_name__, limit=limit, repository_id=repository_id,
+            )]
+
+    def register_commit(self, commit):
+        if not isinstance(commit, Commit):
+            raise TypeError(f'Expected type {Commit}, got {type(commit)}')
+
+        with self:
+            if commit.repository_id not in self.repositories:
+                self.repositories[commit.repository_id] = Repository(key=commit.repository_id)
+
+            for table in [self.CommitByID, self.CommitByUuidAscending, self.CommitByUuidDescending]:
+                self.cassandra.insert_row(
+                    table.__table_name__,
+                    repository_id=commit.repository_id, branch=commit.branch,
+                    commit_id=str(commit.id).lower(), uuid=commit.uuid,
+                    committer=commit.committer, message=commit.message,
+                )
+            self.cassandra.insert_row(
+                self.Branches.__table_name__,
+                repository_id=commit.repository_id, branch=commit.branch,
+            )
+            return commit
+
+    def register_commit_with_repo_and_id(self, repository_id, branch, commit_id):
+        if branch is None:
+            branch = self.repositories[repository_id].DEFAULT_BRANCH
+        if repository_id not in self.repositories:
+            raise RuntimeError('{} is not a recognized repository')
+
+        with self:
+            commits = self.find_commits_by_id(repository_id=repository_id, branch=branch, commit_id=commit_id)
+            if len(commits) > 1:
+                raise SCMException(f'Multiple commits with the id {commit_id} exist in {repository_id} on {branch}')
+            if commits:
+                return commits[0]
+            commit = self.repositories[repository_id].commit_for_id(commit_id, branch)
+            return self.register_commit(commit)
+
+    def url(self, commit):
+        if not isinstance(commit, Commit):
+            raise TypeError(f'Expected type {Commit}, got {type(commit)}')
+
+        repo = self.repositories.get(commit.repository_id)
+        if repo:
+            return repo.url_for_commit(commit.id)
+        return None
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelcommit_context_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/commit_context_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/commit_context_unittest.py                             (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/commit_context_unittest.py        2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,282 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from redis import StrictRedis
+from fakeredis import FakeStrictRedis
+from resultsdbpy.model.cassandra_context import CassandraContext
+from resultsdbpy.model.commit_context import CommitContext
+from resultsdbpy.model.mock_cassandra_context import MockCassandraContext
+from resultsdbpy.model.mock_repository import MockStashRepository, MockSVNRepository
+from resultsdbpy.model.wait_for_docker_test_case import WaitForDockerTestCase
+
+
+class CommitContextTest(WaitForDockerTestCase):
+    KEYSPACE = 'commit_mapping_test_keyspace'
+
+    def init_database(self, redis=StrictRedis, cassandra=CassandraContext):
+        redis_instance = redis()
+
+        self.stash_repository = MockStashRepository.safari(redis_instance)
+        self.svn_repository = MockSVNRepository.webkit(redis_instance)
+
+        cassandra.drop_keyspace(keyspace=self.KEYSPACE)
+        self.database = CommitContext(
+            redis=redis_instance,
+            cassandra=cassandra(keyspace=self.KEYSPACE, create_keyspace=True),
+        )
+        self.database.register_repository(self.stash_repository)
+        self.database.register_repository(self.svn_repository)
+
+    def add_all_commits_to_database(self):
+        for mock_repository in [self.stash_repository, self.svn_repository]:
+            for commit_list in mock_repository.commits.values():
+                for commit in commit_list:
+                    self.database.register_commit(commit)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_verify_table(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        CommitContext(redis=redis(), cassandra=cassandra(keyspace=self.KEYSPACE))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_commit_by_id(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.add_all_commits_to_database()
+
+        self.assertEqual(
+            [self.stash_repository.commit_for_id(id='bb6bda5f44', branch='master')],
+            self.database.find_commits_by_id(repository_id='safari', branch='master', commit_id='bb6bda5f44'),
+        )
+        self.assertEqual(2, len(self.database.find_commits_by_id(repository_id='safari', branch='master', commit_id='336610a')))
+
+        self.assertEqual(
+            [self.svn_repository.commit_for_id(id=236544, branch='trunk')],
+            self.database.find_commits_by_id(repository_id='webkit', branch='trunk', commit_id=236544),
+        )
+        self.assertEqual(0, len(self.database.find_commits_by_id(repository_id='webkit', branch='trunk', commit_id='23654')))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_commit_by_uuid(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.add_all_commits_to_database()
+
+        self.assertEqual(
+            [self.stash_repository.commit_for_id(id='7be4084258', branch='master')],
+            self.database.find_commits_by_uuid(repository_id='safari', branch='master', uuid=153755068501),
+        )
+        self.assertEqual(
+            [self.svn_repository.commit_for_id(id=236540, branch='trunk')],
+            self.database.find_commits_by_uuid(repository_id='webkit', branch='trunk', uuid=153802947900),
+        )
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_commit_by_timestamp(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.add_all_commits_to_database()
+
+        self.assertEqual(
+            [self.stash_repository.commit_for_id(id='336610a84f', branch='master')],
+            self.database.find_commits_by_timestamp(repository_id='safari', branch='master', timestamp=1537809818),
+        )
+        self.assertEqual(
+            [self.svn_repository.commit_for_id(id=236540, branch='trunk')],
+            self.database.find_commits_by_timestamp(repository_id='webkit', branch='trunk', timestamp=1538029479),
+        )
+        self.assertEqual(2, len(self.database.find_commits_by_timestamp(repository_id='safari', branch='master', timestamp=1537550685)))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_all_commits_stash(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.add_all_commits_to_database()
+        self.assertEqual(5, len(self.database.find_commits_in_range(repository_id='safari', branch='master')))
+        self.assertEqual(
+            [self.stash_repository.commit_for_id(id='bb6bda5f44', branch='master'), self.stash_repository.commit_for_id(id='336610a84f', branch='master')],
+            self.database.find_commits_in_range(repository_id='safari', branch='master', limit=2),
+        )
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_all_commits_svn(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.add_all_commits_to_database()
+        self.assertEqual(5, len(self.database.find_commits_in_range(repository_id='webkit', branch='trunk')))
+        self.assertEqual(
+            [self.svn_repository.commit_for_id(id=236544, branch='trunk'), self.svn_repository.commit_for_id(id=236543, branch='trunk')],
+            self.database.find_commits_in_range(repository_id='webkit', branch='trunk', limit=2),
+        )
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_stash_commits_in_range(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.add_all_commits_to_database()
+        self.assertEqual(
+            [self.stash_repository.commit_for_id(id='bb6bda5f44', branch='master'), self.stash_repository.commit_for_id(id='336610a84f', branch='master')],
+            self.database.find_commits_in_range(repository_id='safari', branch='master', begin=1537809818, end=1537810281),
+        )
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_svn_commits_in_range(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.add_all_commits_to_database()
+        self.assertEqual(
+            [self.svn_repository.commit_for_id(id=236544, branch='trunk'), self.svn_repository.commit_for_id(id=236543, branch='trunk')],
+            self.database.find_commits_in_range(repository_id='webkit', branch='trunk', begin=1538050458, end=1538052408),
+        )
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_stash_commits_between(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.add_all_commits_to_database()
+
+        commits = [
+            self.stash_repository.commit_for_id(id='bb6bda5f', branch='master'),
+            self.stash_repository.commit_for_id(id='336610a8', branch='master'),
+            self.stash_repository.commit_for_id(id='336610a4', branch='master'),
+        ]
+        self.assertEqual(commits, self.database.find_commits_in_range(repository_id='safari', branch='master',  begin=commits[-1], end=commits[0]))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_svn_commits_between(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.add_all_commits_to_database()
+
+        commits = [
+            self.svn_repository.commit_for_id(id=236544, branch='trunk'),
+            self.svn_repository.commit_for_id(id=236543, branch='trunk'),
+            self.svn_repository.commit_for_id(id=236542, branch='trunk'),
+        ]
+        self.assertEqual(commits, self.database.find_commits_in_range(repository_id='webkit', branch='trunk', begin=commits[-1], end=commits[0]))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_commit_from_stash_repo(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.database.register_commit_with_repo_and_id('safari', 'master', 'bb6bda5f44')
+        self.assertEqual(
+            [self.stash_repository.commit_for_id(id='bb6bda5f44', branch='master')],
+            self.database.find_commits_by_id(repository_id='safari', branch='master', commit_id='bb6bda5f44'),
+        )
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_commit_from_svn_repo(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.database.register_commit_with_repo_and_id('webkit', 'trunk', 236544)
+        self.assertEqual(
+            [self.svn_repository.commit_for_id(id=236544, branch='trunk')],
+            self.database.find_commits_by_id(repository_id='webkit', branch='trunk', commit_id=236544),
+        )
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_branches(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.add_all_commits_to_database()
+        self.assertEqual(['master', 'safari-606-branch'], self.database.branches(repository_id='safari'))
+        self.assertEqual(['safari-606-branch', 'trunk'], self.database.branches(repository_id='webkit'))
+        self.assertEqual(['safari-606-branch'], self.database.branches(repository_id='safari', branch='safari'))
+        self.assertEqual(['safari-606-branch'], self.database.branches(repository_id='webkit', branch='safari-606-branch'))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_next_commit(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.add_all_commits_to_database()
+
+        self.assertEqual(
+            self.database.next_commit(self.svn_repository.commit_for_id(id=236542)),
+            self.svn_repository.commit_for_id(id=236543),
+        )
+        self.assertEqual(
+            self.database.next_commit(self.stash_repository.commit_for_id(id='336610a40c3fecb728871e12ca31482ca715b383')),
+            self.stash_repository.commit_for_id(id='336610a84fdcf14ddcf1db65075af95480516fda'),
+        )
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_previous_commit(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.add_all_commits_to_database()
+
+        self.assertEqual(
+            self.svn_repository.commit_for_id(id=236542),
+            self.database.previous_commit(self.svn_repository.commit_for_id(id=236543)),
+        )
+        self.assertEqual(
+            self.stash_repository.commit_for_id(id='336610a40c3fecb728871e12ca31482ca715b383'),
+            self.database.previous_commit(self.stash_repository.commit_for_id(id='336610a84fdcf14ddcf1db65075af95480516fda')),
+        )
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_sibling_commits(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.add_all_commits_to_database()
+
+        self.assertEqual(
+            self.database.sibling_commits(self.svn_repository.commit_for_id(id=236542), ['safari']),
+            {'safari': [self.stash_repository.commit_for_id(id='bb6bda5f44dd24d0b54539b8ff6e8c17f519249a')]},
+        )
+        self.assertEqual(
+            self.database.sibling_commits(self.stash_repository.commit_for_id(id='bb6bda5f44dd24d0b54539b8ff6e8c17f519249a'), ['webkit']),
+            {'webkit': [
+                self.svn_repository.commit_for_id(id=236544),
+                self.svn_repository.commit_for_id(id=236543),
+                self.svn_repository.commit_for_id(id=236542),
+                self.svn_repository.commit_for_id(id=236541),
+                self.svn_repository.commit_for_id(id=236540),
+            ]},
+        )
+        self.assertEqual(
+            self.database.sibling_commits(self.stash_repository.commit_for_id(id='336610a84fdcf14ddcf1db65075af95480516fda'), ['webkit']),
+            {'webkit': []},
+        )
+
+    def test_uuid_for_commits(self):
+        uuid = CommitContext.uuid_for_commits([MockStashRepository.safari().commit_for_id(id='bb6bda5f'), MockSVNRepository.webkit().commit_for_id(id=236544)])
+        self.assertEqual(uuid, 153805240800)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_branch_keys_for_commits(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        branches = self.database.branch_keys_for_commits([
+            MockStashRepository.safari().commit_for_id(id='bb6bda5f'),
+            MockSVNRepository.webkit().commit_for_id(id=236544),
+        ])
+        self.assertEqual(branches, ['default'])
+
+        branches = self.database.branch_keys_for_commits([
+            MockStashRepository.safari().commit_for_id(id='79256c32', branch='safari-606-branch'),
+            MockSVNRepository.webkit().commit_for_id(id=236544),
+        ])
+        self.assertEqual(branches, ['safari-606-branch'])
+
+        branches = self.database.branch_keys_for_commits([
+            MockStashRepository.safari().commit_for_id(id='79256c32', branch='safari-606-branch'),
+            MockSVNRepository.webkit().commit_for_id(id=236335, branch='safari-606-branch'),
+        ])
+        self.assertEqual(branches, ['safari-606-branch'])
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_commit_url(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.assertEqual(
+            'https://fake-stash-instance.apple.com/projects/BROWSER/repos/safari/commits/bb6bda5f44dd24d0b54539b8ff6e8c17f519249a',
+            self.database.url(MockStashRepository.safari().commit_for_id(id='bb6bda5f')),
+        )
+        self.assertEqual(
+            'https://trac.webkit.org/changeset/236544/webkit',
+            self.database.url(MockSVNRepository.webkit().commit_for_id(id=236544)),
+        )
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelconfiguration_contextpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/configuration_context.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/configuration_context.py                               (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/configuration_context.py  2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,281 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import calendar
+import json
+import time
+
+from cassandra.cqlengine import columns
+from cassandra.cqlengine.models import Model
+from collections import Iterable, OrderedDict
+from datetime import datetime
+from resultsdbpy.controller.configuration import Configuration
+
+
+class ClusteredByConfiguration(Model):
+    platform = columns.Text(partition_key=True, required=True)
+    version = columns.Integer(partition_key=True, required=True)
+    is_simulator = columns.Boolean(partition_key=True, required=True)
+    architecture = columns.Text(partition_key=True, required=True)
+    attributes = columns.Text(partition_key=True, required=True)
+
+    def to_configuration(self):
+        return Configuration(
+            platform=self.platform,
+            version=self.version,
+            is_simulator=self.is_simulator,
+            architecture=self.architecture,
+            sdk=getattr(self, 'sdk', None) or None,
+            **json.loads(self.attributes)
+        )
+
+
+class ConfigurationContext(object):
+
+    class ConfigurationModel(object):
+
+        def to_configuration(self):
+            return Configuration(
+                platform=self.platform,
+                version=self.version, version_name=self.version_name or None,
+                sdk=getattr(self, 'sdk', None) or None,
+                is_simulator=self.is_simulator,
+                architecture=self.architecture, model=self.model or None,
+                style=self.style or None, flavor=self.flavor or None,
+            )
+
+    class ByPlatform(Model, ConfigurationModel):
+        __table_name__ = 'configurations_by_platform'
+        platform = columns.Text(partition_key=True, required=True)
+        style = columns.Text(primary_key=True, required=False)
+        flavor = columns.Text(primary_key=True, required=False)
+        architecture = columns.Text(primary_key=True, required=True)
+        model = columns.Text(primary_key=True, required=False)
+        is_simulator = columns.Boolean(primary_key=True, required=True)
+        version = columns.Integer(primary_key=True, required=True)
+        version_name = columns.Text(primary_key=True, required=False)
+        last_run = columns.DateTime(required=True)
+
+    class ByPlatformAndVersion(Model, ConfigurationModel):
+        __table_name__ = 'configurations_by_platform_and_version'
+        platform = columns.Text(partition_key=True, required=True)
+        version = columns.Integer(partition_key=True, required=True)
+        style = columns.Text(primary_key=True, required=False)
+        flavor = columns.Text(primary_key=True, required=False)
+        architecture = columns.Text(primary_key=True, required=True)
+        model = columns.Text(primary_key=True, required=False)
+        is_simulator = columns.Boolean(primary_key=True, required=True)
+        version_name = columns.Text(primary_key=True, required=False)
+        last_run = columns.DateTime(required=True)
+
+    class ByArchitecture(Model, ConfigurationModel):
+        __table_name__ = 'configurations_by_architecture'
+        architecture = columns.Text(partition_key=True, required=True)
+        style = columns.Text(primary_key=True, required=False)
+        platform = columns.Text(primary_key=True, required=True)
+        version = columns.Integer(primary_key=True, required=True)
+        model = columns.Text(primary_key=True, required=False)
+        flavor = columns.Text(primary_key=True, required=False)
+        is_simulator = columns.Boolean(primary_key=True, required=True)
+        version_name = columns.Text(primary_key=True, required=False)
+        last_run = columns.DateTime(required=True)
+
+    class ByModel(Model, ConfigurationModel):
+        __table_name__ = 'configurations_by_model'
+        model = columns.Text(partition_key=True, required=True)
+        version = columns.Integer(primary_key=True, required=True)
+        style = columns.Text(primary_key=True, required=False)
+        platform = columns.Text(primary_key=True, required=True)
+        flavor = columns.Text(primary_key=True, required=False)
+        architecture = columns.Text(primary_key=True, required=True)
+        is_simulator = columns.Boolean(primary_key=True, required=True)
+        version_name = columns.Text(primary_key=True, required=False)
+        last_run = columns.DateTime(required=True)
+
+    def __init__(self, redis, cassandra, cache_timeout=60 * 60 * 24 * 14):
+        assert redis
+        assert cassandra
+
+        self.redis = redis
+        self.cassandra = cassandra
+        self.repositories = {}
+        self.cache_timeout = cache_timeout
+
+        with self:
+            self.cassandra.create_table(self.ByPlatform)
+            self.cassandra.create_table(self.ByPlatformAndVersion)
+            self.cassandra.create_table(self.ByArchitecture)
+            self.cassandra.create_table(self.ByModel)
+
+            for configuration in self.cassandra.select_from_table(self.ByPlatform.__table_name__):
+                if configuration.last_run >= datetime.utcfromtimestamp(int(time.time() - cache_timeout)):
+                    self._register_in_redis(configuration.to_configuration(), configuration.last_run)
+
+    def __enter__(self):
+        self.cassandra.__enter__()
+
+    def __exit__(self, *args, **kwargs):
+        self.cassandra.__exit__(*args, **kwargs)
+
+    @classmethod
+    def _convert_to_redis_key(cls, configuration):
+        return 'configurations:{}:{}:{}:{}:{}:{}:{}:{}'.format(
+            configuration.platform or '*', '*' if configuration.is_simulator is None else (1 if configuration.is_simulator else 0),
+            '*' if configuration.version is None else Configuration.integer_to_version(configuration.version),
+            configuration.version_name or '*',
+            configuration.architecture or '*', configuration.model or '*',
+            configuration.style or '*', configuration.flavor or '*',
+        )
+
+    def _register_in_redis(self, configuration, timestamp):
+        if isinstance(timestamp, datetime):
+            timestamp = calendar.timegm(timestamp.timetuple())
+        expiration = int(self.cache_timeout - (time.time() - int(timestamp)))
+        if expiration > 0:
+            sdk = configuration.sdk
+            try:
+                configuration.sdk = None
+                self.redis.set(self._convert_to_redis_key(configuration), configuration.to_json(), ex=expiration)
+            finally:
+                configuration.sdk = sdk
+
+    def register_configuration(self, configuration, timestamp=time.time()):
+        if not isinstance(configuration, Configuration):
+            raise TypeError(f'Expected type {Configuration}, got {type(configuration)}')
+        if not configuration.is_complete():
+            raise TypeError('Register a partial configuration')
+        if not isinstance(timestamp, datetime):
+            timestamp = datetime.utcfromtimestamp(int(timestamp))
+
+        with self:
+            tables_to_insert_to = [self.ByPlatform.__table_name__, self.ByPlatformAndVersion.__table_name__, self.ByArchitecture.__table_name__]
+            if configuration.model is not None:
+                tables_to_insert_to.append(self.ByModel.__table_name__)
+            for table in tables_to_insert_to:
+                self.cassandra.insert_row(
+                    table,
+                    platform=configuration.platform, is_simulator=configuration.is_simulator,
+                    version=configuration.version, version_name=configuration.version_name or '',
+                    architecture=configuration.architecture, model=configuration.model or '',
+                    style=configuration.style or '', flavor=configuration.flavor or '',
+                    last_run=timestamp,
+                )
+
+        self._register_in_redis(configuration, timestamp)
+
+    def search_for_configuration(self, configuration):
+        if not isinstance(configuration, Configuration):
+            raise TypeError(f'Expected type {Configuration}, got {type(configuration)}')
+
+        kwargs = {}
+        for member in Configuration.REQUIRED_MEMBERS + Configuration.OPTIONAL_MEMBERS:
+            if getattr(configuration, member):
+                kwargs[member] = getattr(configuration, member)
+
+        if 'platform' in kwargs and 'version' in kwargs:
+            table = self.ByPlatformAndVersion
+        elif 'platform' in kwargs:
+            table = self.ByPlatform
+        elif 'architecture' in kwargs:
+            table = self.ByArchitecture
+        elif 'model' in kwargs:
+            table = self.ByModel
+        else:
+            raise TypeError(f'{configuration} is not specific enough to be searched by')
+
+        with self:
+            return [model.to_configuration() for model in self.cassandra.select_from_table(table.__table_name__, **kwargs)]
+
+    def search_for_recent_configuration(self, configuration=Configuration()):
+        if not isinstance(configuration, Configuration):
+            raise TypeError(f'Expected type {Configuration}, got {type(configuration)}')
+
+        configurations = []
+        for key in self.redis.scan_iter(self._convert_to_redis_key(configuration)):
+            candidate = Configuration.from_json(self.redis.get(key.decode('utf-8')).decode('utf-8'))
+            if candidate == configuration:
+                configurations.append(candidate)
+        return configurations
+
+    def insert_row_with_configuration(self, table_name, configuration, **kwargs):
+        if not isinstance(configuration, Configuration):
+            raise TypeError(f'Expected type {Configuration}, got {type(configuration)}')
+        if not configuration.is_complete():
+            raise TypeError(f'Cannot insert to {table_name} with a partial configuration')
+
+        with self:
+            attributes_dict = OrderedDict()
+            for member in Configuration.OPTIONAL_MEMBERS:
+                if getattr(configuration, member) is not None:
+                    attributes_dict[member] = getattr(configuration, member)
+
+            return self.cassandra.insert_row(
+                table_name,
+                platform=configuration.platform, is_simulator=configuration.is_simulator,
+                version=configuration.version,
+                architecture=configuration.architecture,
+                attributes=json.dumps(attributes_dict),
+                **kwargs)
+
+    def select_from_table_with_configurations(self, table_name, configurations=None, recent=True, limit=100, **kwargs):
+        if not isinstance(configurations, Iterable):
+            raise TypeError('Expected configurations to be iterable')
+        if not configurations:
+            configurations.append(Configuration())
+
+        with self:
+            complete_configurations = set()
+            for config in configurations:
+                if not isinstance(config, Configuration):
+                    raise TypeError(f'Expected type {Configuration}, got {type(config)}')
+                if config.is_complete():
+                    complete_configurations.add(config)
+                elif recent:
+                    [complete_configurations.add(element) for element in self.search_for_recent_configuration(config)]
+                else:
+                    [complete_configurations.add(element) for element in self.search_for_configuration(config)]
+
+            results = {}
+            for configuration in complete_configurations:
+                attributes_dict = OrderedDict()
+                for member in Configuration.OPTIONAL_MEMBERS:
+                    if getattr(configuration, member) is not None:
+                        attributes_dict[member] = getattr(configuration, member)
+
+                rows = self.cassandra.select_from_table(
+                    table_name,
+                    platform=configuration.platform, is_simulator=configuration.is_simulator,
+                    version=configuration.version,
+                    architecture=configuration.architecture,
+                    attributes=json.dumps(attributes_dict),
+                    limit=limit,
+                    **kwargs)
+                if len(rows) == 0:
+                    continue
+
+                for row in rows:
+                    full_config = row.to_configuration()
+                    if not results.get(full_config):
+                        results[full_config] = []
+                    results[full_config].append(row)
+
+            return results
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelconfiguration_context_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/configuration_context_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/configuration_context_unittest.py                              (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/configuration_context_unittest.py 2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,234 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import time
+
+from cassandra.cqlengine import columns
+from resultsdbpy.controller.configuration import Configuration
+from fakeredis import FakeStrictRedis
+from redis import StrictRedis
+from resultsdbpy.model.cassandra_context import CassandraContext
+from resultsdbpy.model.configuration_context import ConfigurationContext, ClusteredByConfiguration
+from resultsdbpy.model.mock_cassandra_context import MockCassandraContext
+from resultsdbpy.model.wait_for_docker_test_case import WaitForDockerTestCase
+
+
+class ConfigurationContextTest(WaitForDockerTestCase):
+    KEYSPACE = 'configuration_context_test_keyspace'
+    CONFIGURATIONS = [
+        Configuration(platform='Mac', version='10.13.0', sdk='17A405', is_simulator=False, architecture='x86_64', style='Debug', flavor='wk1'),
+        Configuration(platform='Mac', version='10.13.0', sdk='17A405', is_simulator=False, architecture='x86_64', style='Debug', flavor='wk2'),
+        Configuration(platform='Mac', version='10.13.0', sdk='17A405', is_simulator=False, architecture='x86_64', style='Release', flavor='wk1'),
+        Configuration(platform='Mac', version='10.13.0', sdk='17A405', is_simulator=False, architecture='x86_64', style='Release', flavor='wk2'),
+        Configuration(platform='Mac', version='10.13.0', sdk='17A405', is_simulator=False, architecture='x86_64', style='Asan', flavor='wk1'),
+        Configuration(platform='Mac', version='10.13.0', sdk='17A405', is_simulator=False, architecture='x86_64', style='Asan', flavor='wk2'),
+        Configuration(platform='Mac', version='10.14.0', sdk='18A391', is_simulator=False, architecture='x86_64', style='Debug', flavor='wk1'),
+        Configuration(platform='Mac', version='10.14.0', sdk='18A391', is_simulator=False, architecture='x86_64', style='Debug', flavor='wk2'),
+        Configuration(platform='Mac', version='10.14.0', sdk='18A391', is_simulator=False, architecture='x86_64', style='Release', flavor='wk1'),
+        Configuration(platform='Mac', version='10.14.0', sdk='18A391', is_simulator=False, architecture='x86_64', style='Release', flavor='wk2'),
+        Configuration(platform='Mac', version='10.14.0', sdk='18A391', is_simulator=False, architecture='x86_64', style='Asan', flavor='wk1'),
+        Configuration(platform='Mac', version='10.14.0', sdk='18A391', is_simulator=False, architecture='x86_64', style='Asan', flavor='wk2'),
+        Configuration(platform='iOS', version='11.0.0', sdk='15A432', is_simulator=True, architecture='x86_64', style='Debug'),
+        Configuration(platform='iOS', version='11.0.0', sdk='15A432', is_simulator=True, architecture='x86_64', style='Release'),
+        Configuration(platform='iOS', version='11.0.0', sdk='15A432', is_simulator=True, architecture='x86_64', style='Asan'),
+        Configuration(platform='iOS', version='11.0.0', sdk='15A432', is_simulator=False, architecture='arm64', model='iPhone 8', style='Debug'),
+        Configuration(platform='iOS', version='11.0.0', sdk='15A432', is_simulator=False, architecture='arm64', model='iPhone 8', style='Release'),
+        Configuration(platform='iOS', version='11.0.0', sdk='15A432', is_simulator=False, architecture='arm64', model='iPhone 8', style='Asan'),
+        Configuration(platform='iOS', version='12.0.0', sdk='16A404', is_simulator=True, architecture='x86_64', style='Debug'),
+        Configuration(platform='iOS', version='12.0.0', sdk='16A404', is_simulator=True, architecture='x86_64', style='Release'),
+        Configuration(platform='iOS', version='12.0.0', sdk='16A404', is_simulator=True, architecture='x86_64', style='Asan'),
+        Configuration(platform='iOS', version='12.0.0', sdk='16A404', is_simulator=False, architecture='arm64', model='iPhone Xs', style='Debug'),
+        Configuration(platform='iOS', version='12.0.0', sdk='16A404', is_simulator=False, architecture='arm64', model='iPhone 8', style='Release'),
+        Configuration(platform='iOS', version='12.0.0', sdk='16A404', is_simulator=False, architecture='arm64', model='iPhone Xs', style='Asan'),
+    ]
+
+    def init_database(self, redis=StrictRedis, cassandra=CassandraContext):
+        cassandra.drop_keyspace(keyspace=self.KEYSPACE)
+        self.database = ConfigurationContext(
+            redis=redis(),
+            cassandra=cassandra(keyspace=self.KEYSPACE, create_keyspace=True),
+        )
+
+    def register_configurations(self):
+        current = time.time()
+        old = current - 60 * 60 * 24 * 21
+
+        for configuration in self.CONFIGURATIONS:
+            if (configuration.platform == 'Mac' and configuration.version <= Configuration.version_to_integer('10.13')) \
+               or (configuration.platform == 'iOS' and configuration.version <= Configuration.version_to_integer('11')):
+                self.database.register_configuration(configuration, old)
+            else:
+                self.database.register_configuration(configuration, current)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_invalid_configuration(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+
+        with self.assertRaises(TypeError):
+            self.database.register_configuration('invalid object')
+        with self.assertRaises(TypeError):
+            self.database.register_configuration(Configuration(platform='iOS'))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_no_style_configuration(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+
+        self.database.register_configuration(Configuration(
+            platform='Mac', version='10.13.0', sdk='17A405', is_simulator=False, architecture='x86_64',
+        ))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_configuration_by_platform(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.register_configurations()
+
+        configuration_to_search_for = Configuration(platform='Mac', style='Debug')
+        matching_configurations = self.database.search_for_configuration(configuration_to_search_for)
+        self.assertEqual(4, len(matching_configurations))
+        for config in matching_configurations:
+            self.assertEqual(configuration_to_search_for, config)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_configuration_by_platform_with_flavor(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.register_configurations()
+
+        configuration_to_search_for = Configuration(platform='Mac', style='Debug', flavor='wk1')
+        matching_configurations = self.database.search_for_configuration(configuration_to_search_for)
+        self.assertEqual(2, len(matching_configurations))
+        for config in matching_configurations:
+            self.assertEqual(configuration_to_search_for, config)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_configuration_by_platform_and_version(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.register_configurations()
+
+        configuration_to_search_for = Configuration(platform='Mac', version='10.13', style='Release')
+        matching_configurations = self.database.search_for_configuration(configuration_to_search_for)
+        self.assertEqual(2, len(matching_configurations))
+        for config in matching_configurations:
+            self.assertEqual(configuration_to_search_for, config)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_configuration_by_model(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.register_configurations()
+        configuration_to_search_for = Configuration(model='iPhone 8')
+        matching_configurations = self.database.search_for_configuration(configuration_to_search_for)
+        self.assertEqual(4, len(matching_configurations))
+        for config in matching_configurations:
+            self.assertEqual(configuration_to_search_for, config)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_configuration_by_architecture(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.register_configurations()
+
+        configuration_to_search_for = Configuration(architecture='x86_64', style='Release')
+        matching_configurations = self.database.search_for_configuration(configuration_to_search_for)
+        self.assertEqual(6, len(matching_configurations))
+        for config in matching_configurations:
+            self.assertEqual(configuration_to_search_for, config)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_recent_configurations(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.register_configurations()
+
+        recent_configurations = self.database.search_for_recent_configuration()
+        self.assertEqual(12, len(recent_configurations))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_recent_configurations_constrained(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.register_configurations()
+
+        configuration_to_search_for = Configuration(architecture='arm64', style='Release')
+        matching_configurations = self.database.search_for_recent_configuration(configuration_to_search_for)
+        self.assertEqual(1, len(matching_configurations))
+        self.assertEqual(matching_configurations[0], configuration_to_search_for)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_recent_configurations_constrained_by_version(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.register_configurations()
+
+        configuration_to_search_for = Configuration(version='12', is_simulator=True)
+        matching_configurations = self.database.search_for_recent_configuration(configuration_to_search_for)
+        self.assertEqual(3, len(matching_configurations))
+        for config in matching_configurations:
+            self.assertEqual(configuration_to_search_for, config)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_repopulate_recent(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.register_configurations()
+
+        self.database.redis.flushdb()
+
+        database = ConfigurationContext(redis=self.database.redis, cassandra=self.database.cassandra)
+        self.assertEqual(12, len(database.search_for_recent_configuration()))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_partition_by_configuration(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.register_configurations()
+
+        class ExampleModel(ClusteredByConfiguration):
+            __table_name__ = 'example_table'
+            index = columns.Integer(primary_key=True, required=True)
+            sdk = columns.Text(primary_key=True, required=True)
+            json = columns.Text()
+
+        with self.database:
+            self.database.cassandra.create_table(ExampleModel)
+            for configuration in self.CONFIGURATIONS:
+                for i in range(5):
+                    self.database.insert_row_with_configuration(ExampleModel.__table_name__, configuration, index=i, sdk=configuration.sdk, json=configuration.to_json())
+
+            for configuration in self.CONFIGURATIONS:
+                result = self.database.select_from_table_with_configurations(ExampleModel.__table_name__, [configuration], index=2).get(configuration, [])
+                self.assertEqual(1, len(result), f'Searching by {configuration} failed, found {len(result)} elements and expected 1')
+                self.assertEqual(result[0].json, configuration.to_json())
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_partition_by_partial_configuration(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        self.register_configurations()
+
+        class ExampleModel(ClusteredByConfiguration):
+            __table_name__ = 'example_table'
+            index = columns.Integer(primary_key=True, required=True)
+            json = columns.Text()
+
+        with self.database:
+            self.database.cassandra.create_table(ExampleModel)
+            for configuration in self.CONFIGURATIONS:
+                for i in range(5):
+                    self.database.insert_row_with_configuration(ExampleModel.__table_name__, configuration, index=i, json=configuration.to_json())
+
+            configuration_to_search_for = Configuration(model='iPhone Xs')
+            results = self.database.select_from_table_with_configurations(ExampleModel.__table_name__, [configuration_to_search_for], index=4)
+            self.assertEqual(2, len(results), f'Searching by {configuration_to_search_for} failed, found {len(results)} elements and expected 2')
+            for key, value in results.items():
+                self.assertEqual(Configuration.from_json(value[0].json), Configuration.from_json(key.to_json()))
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodeldockercomposeyml"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/docker-compose.yml (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/docker-compose.yml                             (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/docker-compose.yml        2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,42 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+version: "3"
+services:
+
+  cassandra:
+    image: cassandra
+    container_name: resultsdbpy-cassandra
+    ports:
+      - "9042:9042"
+      - "7199:7199"
+    volumes:
+      - cassandradata:/data/cassandra
+
+  redis:
+    image: redis
+    container_name: resultsdbpy-redis
+    ports:
+      - "6379:6379"
+
+volumes:
+  cassandradata:
</ins><span class="cx">\ No newline at end of file
</span></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodeldockerpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/docker.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/docker.py                              (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/docker.py 2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,210 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import atexit
+import os
+import re
+import socket
+import subprocess
+import time
+
+from cassandra.cluster import Cluster, NoHostAvailable
+
+
+class Docker(object):
+
+    DOCKER_BIN = '/usr/local/bin/docker'
+    DOCKER_COMPOSE = '/usr/local/bin/docker-compose'
+    DEFAULT_PROJECT = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'docker-compose.yml')
+    PS_PORT = re.compile(r'0.0.0.0:(?P<port>\d+)\-\>\d+/tcp')
+
+    _has_docker = None
+    _docker_stack = 0
+    _project_stack = {}
+    _instance_for_project = {}
+
+    @classmethod
+    def installed(cls):
+        if cls._has_docker is not None:
+            return cls._has_docker
+
+        try:
+            with open(os.devnull, 'w') as devnull:
+                subprocess.check_call([cls.DOCKER_BIN, '-v'], stdout=devnull, stderr=devnull)
+            cls._has_docker = True
+        except (OSError, subprocess.CalledProcessError):
+            print('Docker not installed, required resources will be mocked. Download it at https://download.docker.com/mac/stable/Docker.dmg')
+            cls._has_docker = False
+        return cls._has_docker
+
+    @classmethod
+    def is_running(cls):
+        try:
+            with open(os.devnull, 'w') as devnull:
+                subprocess.check_call([cls.DOCKER_BIN, 'container', 'ls', '-a'], stdout=devnull, stderr=devnull)
+            return True
+        except (OSError, subprocess.CalledProcessError):
+            return False
+
+    @classmethod
+    def start(cls, timeout=60):
+        if cls.is_running():
+            return
+
+        if not cls.installed():
+            raise RuntimeError('Docker not installed')
+
+        with open(os.devnull, 'w') as devnull:
+            subprocess.check_call(['/usr/bin/open', '--background', '-a', 'Docker'], stdout=devnull, stderr=devnull)
+
+        deadline = None if timeout is None else (time.time() + timeout)
+        while deadline is None or time.time() < deadline:
+            if cls.is_running():
+                return
+            time.sleep(3)
+        raise RuntimeError('Docker failed to start')
+
+    @classmethod
+    def stop(cls):
+        if not cls.is_running():
+            return
+        with open(os.devnull, 'w') as devnull:
+            try:
+                subprocess.check_call(['/usr/bin/killall', 'Docker'], stdout=devnull, stderr=devnull)
+            except subprocess.CalledProcessError:
+                raise RuntimeError('Failed to shutdown Docker, maybe it was in the process of quiting?')
+
+    @classmethod
+    def start_project(cls, project):
+        project = os.path.abspath(project)
+        if not os.path.isfile(project):
+            raise RuntimeError(f'Cannot find Docker project: {project}')
+        if not cls.is_running():
+            raise RuntimeError('Cannot start project if Docker is not running')
+
+        with open(os.devnull, 'w') as devnull:
+            subprocess.check_call([cls.DOCKER_COMPOSE, '-f', project, 'up', '-d', '--quiet-pull', '--no-color'], stdout=devnull, stderr=devnull)
+
+    @classmethod
+    def wait_for_project(cls, project):
+        project = os.path.abspath(project)
+        if not os.path.isfile(project):
+            raise RuntimeError(f'Cannot find Docker project: {project}')
+        if not cls.is_running():
+            raise RuntimeError(f'Docker is not running, cannot wait for {project}')
+
+        has_cassandra = False
+        ports = []
+        with open(os.devnull, 'w') as devnull:
+            output = subprocess.check_output([cls.DOCKER_COMPOSE, '-f', project, 'ps'], stderr=devnull).decode('utf-8')
+            if len(output.splitlines()) <= 2:
+                raise RuntimeError(f'{project} has not been started, cannot wait for it')
+            for line in output.splitlines()[2:]:
+                if 'cassandra' in line:
+                    has_cassandra = True
+                for match in cls.PS_PORT.findall(line):
+                    ports.append(int(match))
+
+        while True:
+            all_ports_open = True
+            for port in ports:
+                soc = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+                try:
+                    soc.connect(('localhost', port))
+                    soc.shutdown(2)
+                    continue
+                except BaseException:
+                    all_ports_open = False
+                    break
+            if all_ports_open:
+                try:
+                    if has_cassandra:
+                        connection = Cluster(['localhost']).connect()
+                        connection.cluster.shutdown()
+                    return
+                except NoHostAvailable:
+                    pass
+            time.sleep(3)
+
+    @classmethod
+    def is_project_running(cls, project):
+        project = os.path.abspath(project)
+        if not os.path.isfile(project):
+            raise RuntimeError(f'Cannot find Docker project: {project}')
+
+        if not cls.is_running():
+            return False
+        with open(os.devnull, 'w') as devnull:
+            output = subprocess.check_output([cls.DOCKER_COMPOSE, '-f', project, 'ps'], stderr=devnull)
+            return len(output.splitlines()) > 2
+
+    @classmethod
+    def stop_project(cls, project):
+        project = os.path.abspath(project)
+        if not os.path.isfile(project):
+            raise RuntimeError(f'Cannot find Docker project: {project}')
+        if not cls.is_running():
+            raise RuntimeError('Cannot stop project if Docker is not running')
+
+        with open(os.devnull, 'w') as devnull:
+            subprocess.check_call([cls.DOCKER_COMPOSE, '-f', project, 'down'], stdout=devnull, stderr=devnull)
+
+    @classmethod
+    def instance(cls, project=DEFAULT_PROJECT):
+        project = os.path.abspath(project)
+        if project not in cls._instance_for_project:
+            cls._instance_for_project[project] = cls(project)
+            cls._instance_for_project[project].__enter__()
+            atexit.register(cls._instance_for_project[project].__exit__)
+        return cls._instance_for_project[project]
+
+    def __init__(self, project=DEFAULT_PROJECT):
+        if not self.installed():
+            raise RuntimeError('Cannot manage a Docker instance if Docker is not installed')
+        self.project = os.path.abspath(project)
+        if not os.path.isfile(self.project):
+            raise RuntimeError(f'Cannot find Docker project: {self.project}')
+
+    def __enter__(self):
+        if Docker._docker_stack == 0:
+            if Docker.is_running():
+                Docker._docker_stack += 1
+            else:
+                Docker.start()
+        Docker._docker_stack += 1
+
+        if Docker._project_stack.get(self.project, 0) == 0:
+            if Docker.is_project_running(self.project):
+                Docker._project_stack[self.project] = Docker._project_stack.get(self.project, 0) + 1
+            else:
+                Docker.start_project(self.project)
+            Docker.wait_for_project(self.project)
+        Docker._project_stack[self.project] = Docker._project_stack.get(self.project, 0) + 1
+
+    def __exit__(self, *args):
+        Docker._project_stack[self.project] = Docker._project_stack.get(self.project, 0) - 1
+        if Docker._project_stack[self.project] <= 0:
+            Docker.stop_project(self.project)
+
+        Docker._docker_stack -= 1
+        if Docker._docker_stack <= 0:
+            Docker.stop()
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodeldocker_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/docker_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/docker_unittest.py                             (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/docker_unittest.py        2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,50 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import mock
+
+from resultsdbpy.model.docker import Docker
+from resultsdbpy.model.wait_for_docker_test_case import WaitForDockerTestCase
+
+
+class DockerUnittest(WaitForDockerTestCase):
+
+    @WaitForDockerTestCase.run_if_has_docker()
+    def test_start(self):
+        with Docker.instance():
+            self.assertTrue(Docker.is_running())
+
+    @WaitForDockerTestCase.run_if_has_docker()
+    def test_stack(self):
+        with Docker.instance():
+            def subprocess_callback(*args, **kwargs):
+                return self.fail('Docker instances not correctly stacking')
+
+            # The test here is that Docker is correctly stacking and no subprocess calls are being made after Docker is already running.
+            with mock.patch('subprocess.check_call', new=subprocess_callback), mock.patch('subprocess.check_output', new=subprocess_callback):
+                with Docker.instance():
+                    pass
+
+    @WaitForDockerTestCase.run_if_has_docker()
+    def test_project_running(self):
+        with Docker.instance():
+            self.assertTrue(Docker.is_project_running(Docker.DEFAULT_PROJECT))
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelmock_cassandra_contextpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/mock_cassandra_context.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/mock_cassandra_context.py                              (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/mock_cassandra_context.py 2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,276 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import mock
+import re
+import time
+
+from cassandra.metadata import ColumnMetadata, KeyspaceMetadata, Metadata, TableMetadataV3
+from cassandra.util import OrderedDict
+from collections import defaultdict
+from resultsdbpy.model.cassandra_context import CassandraContext
+
+
+class MockCluster(object):
+
+    metadata = Metadata()
+    database = {}
+
+    def __init__(self, contact_points=["127.0.0.1"], port=9042, auth_provider=None):
+        assert isinstance(contact_points, list)
+        self.contact_points = contact_points
+        self.port = port
+        self.sessions = []
+
+    def connect(self, keyspace=None):
+        result = MockSession(self, keyspace)
+        self.sessions.append(result)
+        return result
+
+    def shutdown(self):
+        self.sessions = []
+
+
+class MockSession(object):
+
+    def __init__(self, cluster, keyspace=None):
+        self.cluster = cluster
+        self.keyspace = keyspace
+
+    def execute(self, *args):
+        pass
+
+
+class MockCQLEngineContext():
+
+    connections = {}
+
+    def __init__(self):
+        self._depth = 0
+        self.patches = [
+            mock.patch('resultsdbpy.model.cassandra_context.Cluster', new=MockCluster),
+            mock.patch('resultsdbpy.model.cassandra_context.register_connection', new=self.register_connection),
+            mock.patch('resultsdbpy.model.cassandra_context.unregister_connection', new=self.unregister_connection),
+            mock.patch('resultsdbpy.model.cassandra_context.get_cluster', new=self.get_cluster),
+            mock.patch('resultsdbpy.model.cassandra_context.create_keyspace_simple', new=self.create_keyspace_simple),
+            mock.patch('resultsdbpy.model.cassandra_context.create_keyspace_network_topology', new=self.create_keyspace_network_topology),
+            mock.patch('resultsdbpy.model.cassandra_context.drop_keyspace', new=self.drop_keyspace),
+            mock.patch('resultsdbpy.model.cassandra_context.sync_table', new=self.sync_table),
+        ]
+
+    def __enter__(self):
+        if self._depth == 0:
+            for patch in self.patches:
+                patch.__enter__()
+        self._depth += 1
+
+    def __exit__(self, *args, **kwargs):
+        self._depth -= 1
+        if self._depth <= 0:
+            for patch in self.patches:
+                patch.__exit__(*args, **kwargs)
+
+    @classmethod
+    def register_connection(cls, name, session, **kwargs):
+        if session is None:
+            session = MockCluster().connect()
+        cls.connections[name] = (session.cluster, session)
+
+    @classmethod
+    def unregister_connection(cls, name):
+        if name in cls.connections:
+            del cls.connections[name]
+
+    @classmethod
+    def get_cluster(cls, connection):
+        return cls.connections.get(connection, [None])[0]
+
+    @classmethod
+    def create_keyspace_simple(cls, name, replication_factor=1, durable_writes=True, connections=[]):
+        for connection in connections:
+            assert connection in cls.connections
+            if name not in cls.get_cluster(connection).metadata.keyspaces:
+                cls.get_cluster(connection).metadata.keyspaces[name] = KeyspaceMetadata(name, durable_writes, None, None)
+                MockCluster.database[name] = {}
+
+    @classmethod
+    def create_keyspace_network_topology(cls, name, dc_replication_map={}, durable_writes=True, connections=[]):
+        return cls.create_keyspace_simple(name, durable_writes=durable_writes, connections=connections)
+
+    @classmethod
+    def drop_keyspace(cls, name, connections=[]):
+        for connection in connections:
+            assert connection in cls.connections
+            if name in cls.get_cluster(connection).metadata.keyspaces:
+                del cls.get_cluster(connection).metadata.keyspaces[name]
+                del MockCluster.database[name]
+
+    @classmethod
+    def sync_table(cls, model, keyspaces=[], connections=[]):
+        for connection in connections:
+            assert connection in cls.connections
+
+            for keyspace in keyspaces:
+                keyspace_metadata = MockCQLEngineContext.get_cluster(connection).metadata.keyspaces.get(keyspace)
+                assert keyspace_metadata is not None
+
+                primary_keys = []
+                column_keys = []
+                for key, value in model._columns.items():
+                    if value.partition_key or value.primary_key:
+                        primary_keys.append(key)
+                    else:
+                        column_keys.append(key)
+
+                columns = OrderedDict()
+                partition_keys = []
+                clustering_keys = []
+                for key in primary_keys + column_keys:
+                    value = model._columns[key]
+                    meta_data = ColumnMetadata(None, key, value.db_type, is_reversed=(value.clustering_order == 'DESC'))
+                    columns[key] = meta_data
+
+                    if value.partition_key:
+                        partition_keys.append(meta_data)
+                    if value.primary_key:
+                        clustering_keys.append(meta_data)
+
+                MockCluster.database[keyspace][model._raw_column_family_name()] = defaultdict(lambda: {})
+                keyspace_metadata.tables[model._raw_column_family_name()] = TableMetadataV3(
+                    keyspace, model._raw_column_family_name(),
+                    columns=columns, partition_key=partition_keys, clustering_key=clustering_keys,
+                )
+
+
+class MockCassandraContext(CassandraContext):
+
+    @classmethod
+    def drop_keyspace(cls, *args, **kwargs):
+        with MockCQLEngineContext():
+            CassandraContext.drop_keyspace(*args, **kwargs)
+
+    def __init__(self, *args, **kwargs):
+        self._mock_cql_engine = MockCQLEngineContext()
+        with self._mock_cql_engine:
+            super(MockCassandraContext, self).__init__(*args, **kwargs)
+
+    def __enter__(self):
+        self._mock_cql_engine.__enter__()
+        super(MockCassandraContext, self).__enter__()
+
+    def __exit__(self, *args, **kwargs):
+        super(MockCassandraContext, self).__exit__(*args, **kwargs)
+        self._mock_cql_engine.__exit__(*args, **kwargs)
+
+    @CassandraContext.AssertConnectedDecorator()
+    def insert_row(self, table_name, ttl=None, **kwargs):
+        if table_name not in self._models:
+            raise self.SchemaException(f'{table_name} does not exist in the database')
+
+        item = self._models[table_name](**kwargs)
+        schema = self.schema_for_table(table_name)
+
+        partition = []
+        for column in schema.partition_key:
+            partition.append(getattr(item, column.name))
+        cluster = []
+        for column in schema.primary_key:
+            if getattr(item, column.name) not in partition:
+                cluster.append(getattr(item, column.name))
+
+        self.cluster.database[self.keyspace][table_name][tuple(partition)][tuple(cluster)] = (None if ttl is None else time.time() + ttl, item)
+
+    @CassandraContext.AssertConnectedDecorator()
+    def select_from_table(self, table_name, limit=10000, **kwargs):
+        result = []
+        schema = self.schema_for_table(table_name)
+
+        partition_names = []
+        for column in schema.partition_key:
+            partition_names.append(column.name)
+
+        # Sort kwargs so they are in the same order as the columns.
+        ordered_kwargs = OrderedDict()
+        for name in self._models[table_name]._columns.keys():
+            for key, value in kwargs.items():
+                if key.split('__')[0] == name and value is not None:
+                    ordered_kwargs[key] = value
+
+        # Convert arguments to filters
+        partitions = [[]]
+        filters = []
+        for key, value in ordered_kwargs.items():
+            key_value = key.split('__')[0]
+            operator = None if len(key.split('__')) == 1 else key.split('__')[1]
+
+            if key_value in partition_names:
+                if operator == 'in':
+                    original_partitions = list(partitions)
+                    partitions = []
+                    for _ in range(len(value)):
+                        for element in original_partitions:
+                            partitions.append(list(element))
+                    for multiplier in range(len(value)):
+                        for index in range(len(original_partitions)):
+                            partitions[multiplier * len(original_partitions) + index].append(value[multiplier])
+                else:
+                    for partition in partitions:
+                        partition.append(value)
+
+            filters.append(self.filter_for_argument(key, value))
+
+        if len(partitions[0]) == 0:
+            partitions = self.cluster.database[self.keyspace][table_name].keys()
+
+        candidate_elements = []
+        for partition in partitions:
+            for key, row in self.cluster.database[self.keyspace][table_name][tuple(partition)].items():
+                does_match = True
+                for filter in filters:
+                    if not filter(row[1]):
+                        does_match = False
+                        break
+                if not does_match:
+                    continue
+                candidate_elements.append((partition, key))
+
+        # To reverse, they all need to agree. This might not work with compound clustering keys, but we really
+        # shouldn't use those anyways
+        is_reversed = True
+        for column in schema.primary_key:
+            if column in schema.partition_key:
+                continue
+            if not column.is_reversed:
+                is_reversed = False
+        candidate_elements.sort(reverse=is_reversed)
+
+        for candidate in candidate_elements:
+            element = self.cluster.database[self.keyspace][table_name][tuple(candidate[0])][candidate[1]]
+            deadline = element[0]
+            if deadline is None or deadline > time.time():
+                result.append(element[1])
+                limit -= 1
+                if limit <= 0:
+                    return result
+            else:
+                del self.cluster.database[self.keyspace][table_name][tuple(candidate[0])][candidate[1]]
+        return result
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelmock_model_factorypy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/mock_model_factory.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/mock_model_factory.py                          (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/mock_model_factory.py     2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,149 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import time
+
+import calendar
+from resultsdbpy.controller.configuration import Configuration
+from resultsdbpy.model.configuration_context_unittest import ConfigurationContextTest
+from resultsdbpy.model.mock_repository import MockStashRepository, MockSVNRepository
+from resultsdbpy.model.model import Model
+
+
+class MockModelFactory(object):
+
+    @classmethod
+    def create(cls, redis, cassandra, async_processing=False):
+        oldest_commit = time.time()
+        for repo in [MockStashRepository.safari(), MockSVNRepository.webkit()]:
+            for commits in repo.commits.values():
+                for commit in commits:
+                    oldest_commit = min(oldest_commit, calendar.timegm(commit.timestamp.timetuple()))
+
+        model = Model(
+            redis=redis,
+            cassandra=cassandra,
+            repositories=[
+                MockStashRepository.safari(redis=redis),
+                MockSVNRepository.webkit(redis=redis),
+            ],
+            default_ttl_seconds=time.time() - oldest_commit + Model.TTL_WEEK,
+            async_processing=async_processing,
+        )
+        with model.commit_context, model.commit_context.cassandra.batch_query_context():
+            for repository in model.commit_context.repositories.values():
+                for branch_commits in repository.commits.values():
+                    for commit in branch_commits:
+                        model.commit_context.register_commit(commit)
+        return model
+
+    @classmethod
+    def layout_test_results(cls):
+        default_result = {'expected': 'PASS', 'modifiers': '', 'actual': 'PASS', 'time': 1.2}
+        return dict(
+            details=dict(link='dummy-link'),
+            run_stats=dict(tests_skipped=0),
+            results={
+                'fast': {
+                    'encoding': {
+                        'css-cached-bom.html': default_result,
+                        'css-charset-default.xhtml': default_result,
+                        'css-charset.html': default_result,
+                        'css-link-charset.html': default_result,
+                    }
+                }
+            },
+        )
+
+    @classmethod
+    def iterate_all_commits(cls, model, callback):
+        repos = ('webkit', 'safari')
+        branches = (None, 'safari-606-branch')
+        for branch in branches:
+            commit_index = {repo: 0 for repo in repos}
+            commits_for_repo = {repo: sorted(model.commit_context.find_commits_in_range(repo, branch)) for repo in repos}
+            with model.upload_context.cassandra.batch_query_context():
+                for repo in repos:
+                    while max([commits_for_repo[r][commit_index[r]] for r in repos]) > commits_for_repo[repo][commit_index[repo]]:
+                        if commit_index[repo] + 1 >= len(commits_for_repo[repo]):
+                            break
+                        commit_index[repo] += 1
+
+                while True:
+                    commits = []
+                    for repo in repos:
+                        commits.append(commits_for_repo[repo][commit_index[repo]])
+                    callback(commits)
+
+                    youngest_next_repo = None
+                    for repo in repos:
+                        if commit_index[repo] + 1 >= len(commits_for_repo[repo]):
+                            continue
+                        if not youngest_next_repo:
+                            youngest_next_repo = repo
+                            continue
+                        if commits_for_repo[youngest_next_repo][commit_index[youngest_next_repo] + 1] > commits_for_repo[repo][commit_index[repo] + 1]:
+                            youngest_next_repo = repo
+                    if not youngest_next_repo:
+                        break
+                    commit_index[youngest_next_repo] += 1
+
+    @classmethod
+    def add_mock_results(cls, model, configuration=Configuration(), suite='layout-tests', test_results=None):
+        if test_results is None:
+            test_results = cls.layout_test_results()
+
+        configurations = [configuration] if configuration.is_complete() else ConfigurationContextTest.CONFIGURATIONS
+
+        with model.upload_context:
+            current = time.time()
+            old = current - 60 * 60 * 24 * 21
+            for complete_configuration in configurations:
+                if complete_configuration != configuration:
+                    continue
+
+                timestamp_to_use = current
+                if (complete_configuration.platform == 'Mac' and complete_configuration.version <= Configuration.version_to_integer('10.13')) \
+                   or (complete_configuration.platform == 'iOS' and complete_configuration.version <= Configuration.version_to_integer('11')):
+                    timestamp_to_use = old
+
+                cls.iterate_all_commits(model, lambda commits: model.upload_context.upload_test_results(complete_configuration, commits, suite=suite, test_results=test_results, timestamp=timestamp_to_use))
+
+    @classmethod
+    def process_results(self, model, configuration=Configuration(), suite='layout-tests'):
+        configurations = [configuration] if configuration.is_complete() else ConfigurationContextTest.CONFIGURATIONS
+
+        with model.upload_context:
+            for complete_configuration in configurations:
+                if complete_configuration != configuration:
+                    continue
+                for branch in (None, 'safari-606-branch'):
+                    results_dict = model.upload_context.find_test_results(
+                        configurations=[complete_configuration], suite=suite,
+                        branch=branch, recent=False,
+                    )
+                    for config, results in results_dict.items():
+                        for result in results:
+                            model.upload_context.process_test_results(
+                                configuration=config, commits=result['commits'], suite=suite,
+                                test_results=result['test_results'], timestamp=result['timestamp'],
+                            )
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelmock_repositorypy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/mock_repository.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/mock_repository.py                             (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/mock_repository.py        2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,330 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import contextlib
+import json
+import re
+import urllib
+import xmltodict
+
+from collections import defaultdict
+from resultsdbpy.controller.commit import Commit
+from resultsdbpy.model.repository import StashRepository, SVNRepository, WebKitRepository
+
+
+class MockRequest(object):
+
+    def __init__(self, text='', status_code=200):
+        self.text = text
+        self.status_code = status_code
+
+
+class MockStashRepository(StashRepository):
+
+    COMMIT_RE = re.compile(r'commits/(?P<hash>[0-9a-f]+)')
+    COMMIT_FOR_BRANCH_RE = re.compile(r'commits\?since\=(?P<hash>[0-9a-f]+)\&until\=(?P<branch>[^&]+)\&limit\=1')
+    COMMIT_AT_HEAD_RE = re.compile(r'commits\?until\=(?P<branch>[^&]+)\&limit\=\d+')
+    DOES_NOT_EXIST_RESULT = {'errors': [{
+        'context': None,
+        'message': 'Commit \'?\' does not exist in repository.',
+        'exceptionName': 'com.atlassian.bitbucket.commit.NoSuchCommitException',
+    }]}
+
+    @staticmethod
+    def safari(redis=None):
+        result = MockStashRepository('https://fake-stash-instance.apple.com/projects/BROWSER/repos/safari', name='safari', redis=redis)
+        result.add_commit(Commit(
+            repository_id=result.name, branch='master', id='bb6bda5f44dd24d0b54539b8ff6e8c17f519249a',
+            timestamp=1537810281, order=0,
+            committer='person1@apple.com',
+            message='Change 1 description.',
+        ))
+        result.add_commit(Commit(
+            repository_id=result.name, branch='master', id='336610a84fdcf14ddcf1db65075af95480516fda',
+            timestamp=1537809818, order=0,
+            committer='person2@apple.com',
+            message='Change 2 description.',
+        ))
+        result.add_commit(Commit(
+            repository_id=result.name, branch='master', id='336610a40c3fecb728871e12ca31482ca715b383',
+            timestamp=1537566386, order=0,
+            committer='person3@apple.com',
+            message='<rdar://problem/99999999> Change 3 description.',
+        ))
+        result.add_commit(Commit(
+            repository_id=result.name, branch='master', id='e64810a40c3fecb728871e12ca31482ca715b383',
+            timestamp=1537550685, order=0,
+            committer='person4@apple.com',
+            message=u'Change 4 \u2014 (Part 1) description.',
+        ))
+        result.add_commit(Commit(
+            repository_id=result.name, branch='master', id='7be4084258a452e8fe22f36287c5b321e9c8249b',
+            timestamp=1537550685, order=1,
+            committer='person4@apple.com',
+            message=u'Change 4 \u2014 (Part 2) description.\nReviewed by person.',
+        ))
+
+        result.add_commit(Commit(
+            repository_id=result.name, branch='safari-606-branch', id='79256c32a855ac8612112279008334d90e901c55',
+            timestamp=1537897367, order=0,
+            committer='person5@apple.com',
+            message='Change 5 description.',
+        ))
+        result.add_commit(Commit(
+            repository_id=result.name, branch='safari-606-branch', id='d85222d9407fdbbf47406509400a9cecb73ac6de',
+            timestamp=1537563383, order=0,
+            committer='person6@apple.com',
+            message='Change 6 description.',
+        ))
+        return result
+
+    def __init__(self, url, name, **kwargs):
+        self.name = name
+        self.commits = defaultdict(list)
+
+        super(MockStashRepository, self).__init__(url, **kwargs)
+
+    @contextlib.contextmanager
+    def session(self):
+        self._session_depth += 1
+        yield
+        self._session_depth -= 1
+
+    def add_commit(self, commit):
+        copied_commit = Commit.from_json(commit.to_json())
+        copied_commit.repository_id = self.name
+        self.commits[copied_commit.branch].append(copied_commit)
+        self.commits[copied_commit.branch] = sorted(self.commits[copied_commit.branch])
+
+    def request(self, url, method='GET', **kwargs):
+        if not url.startswith(self.url) or method != 'GET':
+            return MockRequest(status_code=404)
+
+        path = url[len(self.url) + 1:]
+        if not path:
+            return MockRequest(json.dumps({'slug': self.name}))
+
+        match = self.COMMIT_RE.match(path)
+        if match:
+            index = -1
+            branch = None
+            for key, commits in self.commits.items():
+                for i in range(len(commits)):
+                    if commits[i].id.startswith(match.group('hash')):
+                        if index != -1:
+                            return MockRequest(json.dumps(self.DOES_NOT_EXIST_RESULT), status_code=401)
+                        index = i
+                        branch = key
+            if index == -1 or not branch:
+                return MockRequest(json.dumps(self.DOES_NOT_EXIST_RESULT), status_code=401)
+            return MockRequest(json.dumps({
+                'id': self.commits[branch][index].id,
+                'committer': {'emailAddress': self.commits[branch][index].committer},
+                'committerTimestamp': self.commits[branch][index].timestamp_as_epoch() * self.COMMIT_TIMESTAMP_CONVERSION,
+                'message': self.commits[branch][index].message,
+                'parents': [] if index == 0 else [{
+                    'id': self.commits[branch][index - 1].id,
+                    'committerTimestamp': self.commits[branch][index - 1].timestamp_as_epoch() * self.COMMIT_TIMESTAMP_CONVERSION,
+                }],
+            }))
+
+        match = self.COMMIT_FOR_BRANCH_RE.match(path)
+        if match:
+            branch = urllib.parse.unquote(match.group('branch'))[len('refs/heads') + 1:]
+            has_found = False
+            if self.commits.get(branch):
+                for commit in self.commits[branch][:-1]:
+                    if commit.id.startswith(match.group('hash')):
+                        has_found = True
+                        break
+            return MockRequest(json.dumps({'size': 1 if has_found else 0, 'isLastPage': True}))
+
+        match = self.COMMIT_AT_HEAD_RE.match(path)
+        if match:
+            branch = match.group('branch').replace('%2F', '/')[len('refs/heads') + 1:]
+            if not self.commits.get(branch, []):
+                return MockRequest(json.dumps(self.DOES_NOT_EXIST_RESULT), status_code=401)
+            return MockRequest(json.dumps({'values': [{'id': self.commits[branch][-1].id}], 'size': 1, 'isLastPage': True}))
+
+        return MockRequest(status_code=404)
+
+
+# Uses multiple inheritance instead of duplicating the functions in this class.
+class _SVNMock(object):
+    SVN_URL_RE = re.compile(r'\!svn/rvr/(?P<revision>[0-9]+)/(?P<branch>.*)')
+
+    def __init__(self, name, url=None):
+        self.commits = defaultdict(list)
+        self.name = name
+
+        # Should be overriden by the SVNRepository class
+        self.url = url if url else ''
+        self._session_depth = 0
+
+    @contextlib.contextmanager
+    def session(self):
+        self._session_depth += 1
+        yield
+        self._session_depth -= 1
+
+    def add_commit(self, commit):
+        copied_commit = Commit.from_json(commit.to_json())
+        copied_commit.repository_id = self.name
+        self.commits[copied_commit.branch].append(copied_commit)
+        self.commits[copied_commit.branch] = sorted(self.commits[copied_commit.branch])
+
+    def request(self, url, method='GET', **kwargs):
+        if not url.startswith(self.url):
+            return MockRequest(status_code=404)
+
+        if method == 'GET' and url == self.url:
+            return MockRequest(text=xmltodict.unparse({'svn': {'index': {'@base': self.name}}}), status_code=200)
+        if method != 'PROPFIND':
+            return MockRequest(status_code=404)
+
+        path = url[len(self.url):]
+        match = self.SVN_URL_RE.match(path)
+        if not match:
+            return MockRequest(status_code=404)
+        revision = match.group('revision')
+        branch = match.group('branch')
+        if branch != 'trunk':
+            branch = branch[len('branches/'):]
+
+        if branch not in self.commits:
+            return MockRequest(status_code=404)
+
+        for commit in self.commits[branch]:
+            if commit.id == revision:
+                return MockRequest(
+                    text=xmltodict.unparse({
+                        'D:multistatus': {'D:response': {'D:propstat': [
+                            {'D:prop': {
+                                'lp1:version-name': commit.id,
+                                'lp1:creationdate': commit.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
+                                'lp1:creator-displayname': commit.committer,
+                            }},
+                            {'D:status': 'HTTP/1.1 200 OK'},
+                        ]}},
+                    }),
+                    status_code=207,
+                )
+        return MockRequest(status_code=404)
+
+
+class MockSVNRepository(_SVNMock, SVNRepository):
+
+    @staticmethod
+    def webkit(redis=None):
+        result = MockWebKitRepository(redis=redis)
+        result.add_commit(Commit(
+            repository_id=result.name, branch='trunk', id=236544,
+            timestamp=1538052408, order=0,
+            committer='person1@webkit.org',
+            message='Change 1 description.',
+        ))
+        result.add_commit(Commit(
+            repository_id=result.name, branch='trunk', id=236543,
+            timestamp=1538050458, order=0,
+            committer='person2@webkit.org',
+            message='Change 2 description.',
+        ))
+        result.add_commit(Commit(
+            repository_id=result.name, branch='trunk', id=236542,
+            timestamp=1538049108, order=0,
+            committer='person3@webkit.org',
+            message='Change 3 description.',
+        ))
+        result.add_commit(Commit(
+            repository_id=result.name, branch='trunk', id=236541,
+            timestamp=1538041792, order=0,
+            committer='person4@webkit.org',
+            message='Change 4 (Part 2) description.',
+        ))
+        result.add_commit(Commit(
+            repository_id=result.name, branch='trunk', id=236540,
+            timestamp=1538029479, order=0,
+            committer='person4@webkit.org',
+            message='Change 4 (Part 1) description.',
+        ))
+
+        result.add_commit(Commit(
+            repository_id=result.name, branch='safari-606-branch', id=236335,
+            timestamp=1538029480, order=0,
+            committer='integrator@webkit.org',
+            message='Integration 1.',
+        ))
+        result.add_commit(Commit(
+            repository_id=result.name, branch='safari-606-branch', id=236334,
+            timestamp=1538029479, order=0,
+            committer='integrator@webkit.org',
+            message='Integration 2.',
+        ))
+        return result
+
+    def __init__(self, url, name, **kwargs):
+        _SVNMock.__init__(self, name=name, url=url)
+        SVNRepository.__init__(self, url=url, **kwargs)
+
+
+class MockWebKitRepository(_SVNMock, WebKitRepository):
+
+    CHANGELOG_URL_RE = re.compile(r'(?P<branch>trunk|branches\/.+)\/(?P<path>.+)\/\?p=(?P<revision>[0-9]+)')
+
+    def __init__(self, **kwargs):
+        _SVNMock.__init__(self, name='webkit')
+        WebKitRepository.__init__(self, **kwargs)
+
+    def request(self, url, method='GET', **kwargs):
+        if not url.startswith(self.url):
+            return MockRequest(status_code=404)
+
+        # The default behavior is to fallback to _SVNMock
+        if method != 'GET':
+            return _SVNMock.request(self, url, method=method, **kwargs)
+
+        path = url[len(self.url):]
+        match = self.CHANGELOG_URL_RE.match(path)
+        if not match:
+            return _SVNMock.request(self, url, method=method, **kwargs)
+
+        if match.group('path') not in WebKitRepository.CHANGELOGS:
+            return MockRequest(status_code=404)
+        if match.group('path') not in ('ChangeLog', 'Tools/ChangeLog'):
+            return MockRequest(status_code=200)
+
+        revision = match.group('revision')
+        branch = match.group('branch')
+        if branch != 'trunk':
+            branch = branch[len('branches/'):]
+
+        changelog_for_commit = ''
+        for commit in self.commits[branch]:
+            changelog_for_commit = f"""{commit.timestamp.strftime('%Y-%m-%d')}  Jon Committer  <{commit.committer}>
+
+        {commit.message}
+""" + changelog_for_commit
+
+            if commit.id == revision:
+                return MockRequest(text=changelog_for_commit, status_code=200)
+
+        return MockRequest(status_code=404)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelmodelpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/model.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/model.py                               (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/model.py  2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,85 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import traceback
+import sys
+
+from resultsdbpy.model.ci_context import CIContext
+from resultsdbpy.model.commit_context import CommitContext
+from resultsdbpy.model.configuration_context import ConfigurationContext
+from resultsdbpy.model.upload_context import UploadContext
+from resultsdbpy.model.suite_context import SuiteContext
+from resultsdbpy.model.test_context import TestContext
+
+
+class Model(object):
+    TTL_DAY = 60 * 60 * 24
+    TTL_WEEK = 7 * TTL_DAY
+    TTL_YEAR = 365 * TTL_DAY
+
+    def __init__(self, redis, cassandra, repositories=[], default_ttl_seconds=TTL_YEAR * 5, async_processing=False):
+        if default_ttl_seconds is not None and default_ttl_seconds < 4 * self.TTL_WEEK:
+            raise ValueError('TTL must be at least 4 weeks')
+        self.default_ttl_seconds = default_ttl_seconds
+        self._async_processing = async_processing
+
+        self.redis = redis
+        self.cassandra = cassandra
+        self.commit_context = CommitContext(redis, cassandra)
+        for repository in repositories:
+            self.commit_context.register_repository(repository)
+        self.configuration_context = ConfigurationContext(redis, cassandra)
+        self.upload_context = UploadContext(
+            configuration_context=self.configuration_context,
+            commit_context=self.commit_context,
+            ttl_seconds=self.default_ttl_seconds,
+            async_processing=async_processing,
+        )
+
+        self.suite_context = SuiteContext(
+            configuration_context=self.configuration_context,
+            commit_context=self.commit_context,
+            ttl_seconds=self.default_ttl_seconds,
+        )
+        self.test_context = TestContext(
+            configuration_context=self.configuration_context,
+            commit_context=self.commit_context,
+            ttl_seconds=self.default_ttl_seconds,
+        )
+        self.ci_context = CIContext(
+            configuration_context=self.configuration_context,
+            commit_context=self.commit_context,
+            ttl_seconds=self.default_ttl_seconds,
+        )
+
+        for context in [self.suite_context, self.test_context, self.ci_context]:
+            self.upload_context.register_upload_callback(context.name, context.register)
+
+    def do_work(self):
+        if not self._async_processing:
+            raise RuntimeError('No work to be done, asynchronous processing disabled')
+
+        try:
+            self.upload_context.do_processing_work()
+        except Exception as e:
+            sys.stderr.write(f'{traceback.format_exc()}\n')
+            sys.stderr.write(f'{e}\n')
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelpartitioned_redispy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/partitioned_redis.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/partitioned_redis.py                           (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/partitioned_redis.py      2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,51 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+# This isn't a perfect implementation, but it covers the cases needed by results databases
+class PartitionedRedis(object):
+
+    def __init__(self, redis, partition):
+        self._redis = redis
+        self._partition = partition
+        if not self._partition:
+            raise ValueError('KeyedRedis object requires a defined partition key')
+
+    def ping(self):
+        return self._redis.ping()
+
+    def get(self, name):
+        return self._redis.get(self._partition + ':' + name)
+
+    def set(self, name, value, **kwargs):
+        return self._redis.set(self._partition + ':' + name, value, **kwargs)
+
+    def lock(self, name, **kwargs):
+        return self._redis.lock(self._partition + ':' + name, **kwargs)
+
+    def delete(self, *names):
+        names_to_delete = [self._partition + ':' + name for name in names]
+        return self._redis.delete(*names_to_delete)
+
+    def scan_iter(self, match=None, **kwargs):
+        for key in self._redis.scan_iter(match=self._partition + ':' + match, **kwargs):
+            yield key[len(self._partition) + 1:]
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelpartitioned_redis_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/partitioned_redis_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/partitioned_redis_unittest.py                          (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/partitioned_redis_unittest.py     2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,82 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from fakeredis import FakeStrictRedis
+from redis import StrictRedis
+from resultsdbpy.model.partitioned_redis import PartitionedRedis
+from resultsdbpy.model.wait_for_docker_test_case import WaitForDockerTestCase
+
+
+class PartitionedRedisUnittest(WaitForDockerTestCase):
+    PARTITION_1 = 'partition_1'
+    PARTITION_2 = 'partition_2'
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis)
+    def test_get(self, redis=StrictRedis):
+        redis_1 = PartitionedRedis(redis(), self.PARTITION_1)
+        redis_2 = PartitionedRedis(redis(), self.PARTITION_2)
+        redis_1.set('key', 'value')
+        redis_2.set('key', 'other')
+
+        self.assertEqual(redis_1.get('key').decode('utf-8'), 'value')
+        self.assertEqual(redis_2.get('key').decode('utf-8'), 'other')
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis)
+    def test_delete(self, redis=StrictRedis):
+        redis_1 = PartitionedRedis(redis(), self.PARTITION_1)
+        redis_2 = PartitionedRedis(redis(), self.PARTITION_2)
+        redis_1.set('key-a', 'value-a')
+        redis_1.set('key-b', 'value-b')
+        redis_2.set('key-a', 'other-a')
+        redis_2.set('key-b', 'other-b')
+
+        redis_1.delete('key-a', 'key-b')
+        self.assertEqual(redis_1.get('key-a'), None)
+        self.assertEqual(redis_1.get('key-b'), None)
+        self.assertEqual(redis_2.get('key-a').decode('utf-8'), 'other-a')
+        self.assertEqual(redis_2.get('key-b').decode('utf-8'), 'other-b')
+
+        redis_2.delete('key-a')
+        self.assertEqual(redis_2.get('key-a'), None)
+        self.assertEqual(redis_2.get('key-b').decode('utf-8'), 'other-b')
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis)
+    def test_lock(self, redis=StrictRedis):
+        redis_1 = PartitionedRedis(redis(), self.PARTITION_1)
+        redis_2 = PartitionedRedis(redis(), self.PARTITION_2)
+        with redis_1.lock(name='lock', blocking_timeout=.5):
+            with redis_2.lock(name='lock', blocking_timeout=.5):
+                pass
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis)
+    def test_scan(self, redis=StrictRedis):
+        redis_1 = PartitionedRedis(redis(), self.PARTITION_1)
+        redis_2 = PartitionedRedis(redis(), self.PARTITION_2)
+        redis_1.set('iter-a', 'value-a')
+        redis_1.set('iter-b', 'value-b')
+        redis_2.set('iter-c', 'value-c')
+        redis_2.set('iter-d', 'value-d')
+
+        for key in redis_1.scan_iter('iter*'):
+            self.assertIn(key.decode('utf-8'), ['iter-a', 'iter-b'])
+        for key in redis_2.scan_iter('iter*'):
+            self.assertIn(key.decode('utf-8'), ['iter-c', 'iter-d'])
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelredis_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/redis_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/redis_unittest.py                              (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/redis_unittest.py 2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,46 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import time
+from redis import StrictRedis
+from fakeredis import FakeStrictRedis
+from resultsdbpy.model.wait_for_docker_test_case import WaitForDockerTestCase
+
+
+class RedisUnittest(WaitForDockerTestCase):
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis)
+    def test_basic(self, redis=StrictRedis):
+        db = redis()
+        self.assertEqual(db.set('example_key', 'value'), True)
+        self.assertEqual(db.get('example_key').decode('utf-8'), 'value')
+        self.assertEqual(db.delete('example_key'), True)
+        self.assertEqual(db.get('example_key'), None)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis)
+    @WaitForDockerTestCase.run_if_slow()
+    def test_timeout(self, redis=StrictRedis):
+        db = redis()
+        self.assertEqual(db.set('example_key', 'value', ex=1), True)
+        self.assertEqual(db.get('example_key').decode('utf-8'), 'value')
+        time.sleep(2)
+        self.assertEqual(db.get('example_key'), None)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelrepositorypy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/repository.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/repository.py                          (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/repository.py     2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,386 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import datetime
+import contextlib
+import json
+import re
+import requests
+import threading
+import urllib
+import xmltodict
+
+from resultsdbpy.controller.commit import Commit
+from xml.parsers.expat import ExpatError
+
+
+class SCMException(RuntimeError):
+    pass
+
+
+class Repository(object):
+    DEFAULT_BRANCH = 'master'
+
+    def __init__(self, key):
+        self.key = key
+
+    def commit_for_id(self, id, branch=None):
+        raise NotImplementedError()
+
+    def url_for_commit(self, commit):
+        return None
+
+
+class HTTPRepository(Repository):
+
+    def __init__(self, key, url, redis=None, username=None, password=None):
+        super(HTTPRepository, self).__init__(key)
+        self.url = url
+        self._username = username
+        self._password = password
+        self._session = None
+        self._session_depth = 0
+        self.redis = redis  # In many cases, we can greatly reduce the number of requests made by caching requests in Redis.
+
+    @contextlib.contextmanager
+    def session(self):
+        if not self._session:
+            self._session = requests.Session()
+            if self._username and self._password:
+                self._session.auth = (self._username, self._password)
+            self._session.mount(self.url, requests.adapters.HTTPAdapter(max_retries=5))
+        self._session_depth += 1
+
+        yield
+
+        self._session_depth -= 1
+        if self._session_depth <= 0:
+            self._session.close()
+            self._session = None
+
+    def request(self, url, method='GET', **kwargs):
+        with self.session():
+            return self._session.request(method=method, url=url, **kwargs)
+
+    def cache_result(self, key, function, ex=60 * 60 * 24 * 2):
+        result = None
+        if self.redis:
+            result = self.redis.get(key)
+        if result is None:
+            result = function()
+        else:
+            result = result.decode('utf-8')
+        if result and self.redis:
+            self.redis.set(key, result, ex=ex)
+        return result
+
+
+class StashRepository(HTTPRepository):
+
+    # Stash's timestamps are accurate to the millisecond.
+    COMMIT_TIMESTAMP_CONVERSION = 1000
+
+    def __init__(self, url, **kwargs):
+        PROJECTS = '/projects'
+        if len(url.split(PROJECTS)) != 2:
+            raise Exception(f'{url} is not a Stash URL')
+        self.base_url = url
+        super(StashRepository, self).__init__(None, ('/rest/api/1.0' + PROJECTS).join(url.split(PROJECTS)), **kwargs)
+
+        with self.session():
+            try:
+                stash_data = self.get()
+            except ValueError:
+                stash_data = {}
+            self.key = stash_data.get('slug', None)
+            if not self.key:
+                raise Exception(f'{self.url} is not an Stash repository')
+
+    def get(self, args=None, cache=True):
+        url = self.url + ('' if not args else f'/{args}')
+
+        def callback(url=url, obj=self):
+            response = obj.request(url)
+            if response.status_code == 200:
+                return response.text
+            return None
+
+        result = self.cache_result(
+            f'repository:{url}',
+            callback,
+        ) if cache else callback(url)
+        if result:
+            return json.loads(result)
+        return {}
+
+    def commit_for_id(self, id, branch=Repository.DEFAULT_BRANCH):
+        try:
+            with self.session():
+                commit_data = self.get(f'commits/{id}')
+                if not commit_data:
+                    raise SCMException(f'Commit {id} does not exist on branch {branch}')
+                timestamp = int(commit_data['committerTimestamp']) // self.COMMIT_TIMESTAMP_CONVERSION
+
+                branch_filter = urllib.parse.quote(f'refs/heads/{branch}')
+                commits_between_commit_and_branch_head = self.get(f"commits?since={commit_data['id']}&until={branch_filter}&limit=1", cache=False)
+                if commits_between_commit_and_branch_head['isLastPage'] and commits_between_commit_and_branch_head['size'] == 0:
+                    # We may have missed the case the commit in question is the HEAD of the branch
+                    commits_at_branch_head = self.get(f'commits?until={branch_filter}&limit=20', cache=False)
+                    if all([commit['id'] != commit_data['id'] for commit in commits_at_branch_head['values']]):
+                        raise SCMException(f'{id} exists, but not on {branch}')
+
+                # Ordering commits by timestamp in git is a bit problematic because multiple commits can share a timestamp.
+                # Generally, if your parent shares your timestamp, your order needs to be incremented.
+                order = 0
+                loop_data = commit_data
+                while len(loop_data['parents']) == 1 and int(loop_data['parents'][0]['committerTimestamp']) // self.COMMIT_TIMESTAMP_CONVERSION == timestamp:
+                    order += 1
+                    loop_data = self.get(f"commits/{loop_data['parents'][0]['id']}")
+
+                return Commit(
+                    repository_id=self.key,
+                    id=commit_data['id'],
+                    branch=branch,
+                    timestamp=timestamp,
+                    order=order,
+                    committer=commit_data['committer']['emailAddress'],
+                    message=commit_data['message'],
+                )
+        except ValueError:
+            raise SCMException(f'Failed to connect to {self.url}')
+
+    def url_for_commit(self, commit):
+        return f'{self.base_url}/commits/{commit}'
+
+
+class SVNRepository(HTTPRepository):
+
+    DEFAULT_BRANCH = 'trunk'
+    DAV_XML = """<?xml version="1.0" encoding="utf-8"?>
+<propfind xmlns="DAV:">
+<prop>
+<resourcetype xmlns="DAV:"/>
+<getcontentlength xmlns="DAV:"/>
+<deadprop-count xmlns="http://subversion.tigris.org/xmlns/dav/"/>
+<version-name xmlns="DAV:"/>
+<creationdate xmlns="DAV:"/>
+<creator-displayname xmlns="DAV:"/>
+</prop></propfind>"""
+
+    def get(self, branch=DEFAULT_BRANCH, revision=None):
+        url = self.url
+        if revision:
+            url = url + f"!svn/rvr/{revision}/{branch if branch == self.DEFAULT_BRANCH else 'branches/' + branch}"
+
+        def callback(url=url, obj=self, with_revision=True if revision else False):
+            if with_revision:
+                # Constructed this from WireShark and an svn info command, modify with caution!
+                response = self.request(
+                    method='PROPFIND',
+                    url=url,
+                    headers={
+                        'Content-Type': 'text/xml',
+                        'Accept-Encoding': 'gzip',
+                        'Depth': '0',
+                    },
+                    data=obj.DAV_XML)
+            else:
+                response = self.request(self.url)
+
+            if response.status_code in [200, 207]:
+                return response.text
+            return None
+
+        result = self.cache_result(
+            f'repository:{url}',
+            callback,
+        )
+        if result:
+            return xmltodict.parse(result)
+        return {}
+
+    def __init__(self, url, trac_url=None, **kwargs):
+        super(SVNRepository, self).__init__(None, url, **kwargs)
+        self.trac_url = trac_url
+        with self.session():
+            self.key = self.get().get('svn', {}).get('index').get('@base')
+            if not self.key:
+                raise Exception(f'{self.url} is not an SVN repository')
+
+    def commit_for_id(self, id, branch=DEFAULT_BRANCH):
+        with self.session():
+            try:
+                commit_data = self.get(branch=branch, revision=id)['D:multistatus']['D:response']['D:propstat'][0]['D:prop']
+                if commit_data['lp1:version-name'] != str(id):
+                    raise SCMException(f'Revision {id} does not exist on branch {branch}')
+
+                # Of the form '2018-09-24T22:03:34.436217Z'
+                timestamp = datetime.datetime.strptime(commit_data['lp1:creationdate'], '%Y-%m-%dT%H:%M:%S.%fZ')
+
+                return Commit(
+                    repository_id=self.key,
+                    id=str(id),
+                    branch=branch,
+                    timestamp=timestamp,
+                    order=0,
+                    committer=commit_data['lp1:creator-displayname'],
+                )
+            except ExpatError:
+                raise SCMException(f'Failed to connect to {self.url}')
+            except KeyError:
+                raise SCMException(f'Revision {id} does not exist on branch {branch}')
+
+    def url_for_commit(self, commit):
+        return f'{self.url}/!svn/bc/{commit}/'
+
+
+class WebKitRepository(SVNRepository):
+    CHANGELOGS = (
+        'ChangeLog',
+        'Source/bmalloc/ChangeLog',
+        'Source/JavaScriptCore/ChangeLog',
+        'Source/ThirdParty/ANGLE/ChangeLog',
+        'Source/ThirdParty/ChangeLog',
+        'Source/ThirdParty/libwebrtc/ChangeLog',
+        'Source/WebCore/ChangeLog',
+        'Source/WebCore/PAL/ChangeLog',
+        'Source/WebCore/platform/gtk/po/ChangeLog',
+        'Source/WebDriver/ChangeLog',
+        'Source/WebInspectorUI/ChangeLog',
+        'Source/WebKit/ChangeLog',
+        'Source/WebKitLegacy/cf/ChangeLog',
+        'Source/WebKitLegacy/ChangeLog',
+        'Source/WebKitLegacy/ios/ChangeLog',
+        'Source/WebKitLegacy/mac/ChangeLog',
+        'Source/WebKitLegacy/win/ChangeLog',
+        'Source/WTF/ChangeLog',
+        'Tools/ChangeLog',
+        'Examples/ChangeLog',
+        'JSTests/ChangeLog',
+        'LayoutTests/ChangeLog',
+        'LayoutTests/imported/mozilla/ChangeLog',
+        'LayoutTests/imported/w3c/ChangeLog',
+        'PerformanceTests/ChangeLog',
+        'PerformanceTests/SunSpider/ChangeLog',
+        'WebDriverTests/ChangeLog',
+        'WebKitLibraries/ChangeLog',
+        'Websites/browserbench.org/ChangeLog',
+        'Websites/bugs.webkit.org/ChangeLog',
+        'Websites/perf.webkit.org/ChangeLog',
+        'Websites/planet.webkit.org/ChangeLog',
+        'Websites/webkit.org/ChangeLog',
+        'Websites/webkit.org/specs/CSSVisualEffects/ChangeLog',
+    )
+    CHANGE_LOG_THREADS = 6
+    FIRST_LINE_REGEX = re.compile(r'(?P<date>\d+-\d+-\d+)\s+(?P<name>\S.+\S)\s+<(?P<email>\S+)>')
+
+    def __init__(self, **kwargs):
+        super(WebKitRepository, self).__init__('https://svn.webkit.org/repository/webkit/', **kwargs)
+
+    def commit_for_id(self, id, branch=SVNRepository.DEFAULT_BRANCH):
+        with self.session():
+            result = super(WebKitRepository, self).commit_for_id(id, branch=branch)
+
+            # Find the last commit on the given branch so we can diff the changelogs
+            previous_id = self.get(branch=branch, revision=int(id) - 1).get(
+                'D:multistatus', {}).get('D:response', {}).get('D:propstat', [{}])[0].get('D:prop', {}).get('lp1:version-name', None)
+            branch_url = f"{self.url}{branch if branch == self.DEFAULT_BRANCH else 'branches/' + branch}"
+
+            changelogs = {}
+            request_threads = []
+
+            # Compare changelog from previous commit to the changelog from this commit
+            def diff_changelogs(changelog_list):
+                for changelog in changelog_list:
+                    current_url = f'{branch_url}/{changelog}/?p={id}'
+                    previous_url = f'{branch_url}/{changelog}/?p={previous_id}' if previous_id else None
+
+                    current_response = self.request(current_url)
+                    if current_response.status_code != 200:
+                        continue
+                    previous_response = self.request(previous_url) if previous_url else None
+                    if previous_response and previous_response.status_code != 200:
+                        continue
+                    if len(current_response.text) == (len(previous_response.text) if previous_response else 0):
+                        continue
+
+                    if len(current_response.text) < (len(previous_response.text) if previous_response else 0):
+                        changelogs[changelog] = current_response.text
+                    else:
+                        changelogs[changelog] = current_response.text[0:-len(previous_response.text)]
+
+            # Because we're mostly blocked on network I/O, threads can speed this up ~4x
+            shards = [[] for _ in range(self.CHANGE_LOG_THREADS)]
+            index = 0
+            for changelog in self.CHANGELOGS:
+                shards[index].append(changelog)
+                index += 1
+                if index >= len(shards):
+                    index = 0
+            for shard in shards:
+                request_threads.append(threading.Thread(
+                    target=diff_changelogs,
+                    kwargs=dict(changelog_list=shard),
+                ))
+                request_threads[-1].start()
+            for thread in request_threads:
+                thread.join()
+
+            commit_message = []
+            for changelog in self.CHANGELOGS:
+                partial_message = changelogs.get(changelog, None)
+                if not partial_message:
+                    continue
+                lines_in_message = partial_message.split('\n')
+
+                # The first line of the commit message might contain the real committer, in the case
+                # commit queue committed a change on the behalf of someone
+                start_of_message_shard = 0
+                match = self.FIRST_LINE_REGEX.match(lines_in_message[0])
+                if match:
+                    if match.group('email') != result.committer:
+                        result.committer = match.group('email')
+                    start_of_message_shard += 1
+                    while not lines_in_message[start_of_message_shard]:
+                        start_of_message_shard += 1
+
+                # WebKit commit messages are often split across multiple changelogs with the first few lines duplicated.
+                start_of_full_message = 0
+                while start_of_message_shard < len(lines_in_message) and start_of_full_message < len(commit_message):
+                    if lines_in_message[start_of_message_shard].startswith(' ' * 8):
+                        line = lines_in_message[start_of_message_shard][8:]
+                    else:
+                        line = lines_in_message[start_of_message_shard]
+                    if line != commit_message[start_of_full_message]:
+                        break
+                    start_of_message_shard += 1
+                    start_of_full_message += 1
+
+                for line in lines_in_message[start_of_message_shard:]:
+                    commit_message.append(line[8:] if line.startswith(' ' * 8) else line)
+
+            result.message = str('\n'.join(commit_message)) if commit_message else None
+
+            return result
+
+    def url_for_commit(self, commit):
+        return f'https://trac.webkit.org/changeset/{commit}/{self.key}'
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelrepository_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/repository_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/repository_unittest.py                         (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/repository_unittest.py    2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,103 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import json
+
+from fakeredis import FakeStrictRedis
+from redis import StrictRedis
+from resultsdbpy.controller.commit import Commit
+from resultsdbpy.model.mock_repository import MockStashRepository, MockSVNRepository
+from resultsdbpy.model.repository import SCMException
+from resultsdbpy.model.wait_for_docker_test_case import WaitForDockerTestCase
+
+
+class RepositoryTest(WaitForDockerTestCase):
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis)
+    def test_svn(self, redis=StrictRedis):
+        svn_repo = MockSVNRepository.webkit(redis=redis())
+        self.assertTrue('webkit', svn_repo.key)
+        commit = svn_repo.commit_for_id(236544, branch='trunk')
+        self.assertEqual(commit.uuid, 153805240800)
+        self.assertEqual(commit.message, 'Change 1 description.\n')
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis)
+    def test_branch_svn(self, redis=StrictRedis):
+        svn_repo = MockSVNRepository.webkit(redis=redis())
+        with self.assertRaises(SCMException):
+            svn_repo.commit_for_id(236335, branch='trunk')
+        commit = svn_repo.commit_for_id(236335, branch='safari-606-branch')
+        self.assertEqual(commit.uuid, 153802948000)
+        self.assertEqual(commit.message, 'Integration 1.\n')
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis)
+    def test_basic_svn(self, redis=StrictRedis):
+        svn_repo = MockSVNRepository(url='svn.webkit.org/repository/webkit/', name='webkit', redis=redis())
+        svn_repo.add_commit(Commit(
+            repository_id=svn_repo.name, branch='trunk', id=236544,
+            timestamp=1538052408, order=0,
+            committer='person1@webkit.org',
+            message='Change 1 description.',
+        ))
+        commit = svn_repo.commit_for_id(236544, branch='trunk')
+        self.assertEqual(commit.uuid, 153805240800)
+        self.assertIsNone(commit.message, None)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis)
+    def test_stash(self, redis=StrictRedis):
+        git_repo = MockStashRepository.safari(redis=redis())
+        self.assertEqual('safari', git_repo.key)
+        commit = git_repo.commit_for_id('bb6bda5f44dd24d', branch='master')
+        self.assertEqual(commit.uuid, 153781028100)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis)
+    def test_colliding_timestamps_stash(self, redis=StrictRedis):
+        git_repo = MockStashRepository.safari(redis=redis())
+        commit1 = git_repo.commit_for_id('e64810a40', branch='master')
+        commit2 = git_repo.commit_for_id('7be408425', branch='master')
+
+        self.assertEqual(commit1.timestamp, commit2.timestamp)
+        self.assertNotEqual(commit1.uuid, commit2.uuid)
+        self.assertTrue(commit1 < commit2)
+        self.assertEqual(commit1.order, 0)
+        self.assertEqual(commit2.order, 1)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis)
+    def test_branch_stash(self, redis=StrictRedis):
+        git_repo = MockStashRepository.safari(redis=redis())
+        with self.assertRaises(SCMException):
+            git_repo.commit_for_id('d85222d9407fd', branch='master')
+        commit = git_repo.commit_for_id('d85222d9407fd', branch='safari-606-branch')
+        self.assertEqual(commit.uuid, 153756338300)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis)
+    def test_stash_already_cached(self, redis=StrictRedis):
+        redis_instance = redis()
+        git_repo = MockStashRepository.safari(redis=redis_instance)
+        redis_instance.set(
+            'repository:' + git_repo.url + '/commits?since=336610a40c3fecb728871e12ca31482ca715b383&until=refs%2Fheads%2Fmaster&limit=1',
+            json.dumps(dict(values=[], size=0, isLastPage=True)),
+        )
+
+        self.assertEqual('safari', git_repo.key)
+        commit = git_repo.commit_for_id('336610a4', branch='master')
+        self.assertEqual(commit.uuid, 153756638600)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelsuite_contextpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/suite_context.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/suite_context.py                               (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/suite_context.py  2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,168 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import calendar
+import json
+import time
+
+from cassandra.cqlengine import columns
+from datetime import datetime
+from resultsdbpy.controller.commit import Commit
+from resultsdbpy.model.commit_context import CommitContext
+from resultsdbpy.model.configuration_context import ClusteredByConfiguration
+from resultsdbpy.model.test_context import Expectations
+from resultsdbpy.model.upload_context import UploadCallbackContext
+
+
+class SuiteContext(UploadCallbackContext):
+    DEFAULT_LIMIT = 100
+
+    class SuiteResultsBase(ClusteredByConfiguration):
+        suite = columns.Text(partition_key=True, required=True)
+        branch = columns.Text(partition_key=True, required=True)
+        details = columns.Text(required=False)
+        stats = columns.Text(required=False)
+
+        def unpack(self):
+            return dict(
+                uuid=self.uuid,
+                start_time=calendar.timegm(self.start_time.timetuple()),
+                details=json.loads(self.details) if self.details else {},
+                stats=json.loads(self.stats) if self.stats else {},
+            )
+
+    class SuiteResultsByCommit(SuiteResultsBase):
+        __table_name__ = 'suite_results_by_commit'
+        uuid = columns.BigInt(primary_key=True, required=True, clustering_order='DESC')
+        sdk = columns.Text(primary_key=True, required=True)
+        start_time = columns.DateTime(primary_key=True, required=True)
+
+    class SuiteResultsByStartTime(SuiteResultsBase):
+        __table_name__ = 'suite_results_by_start_time'
+        start_time = columns.DateTime(primary_key=True, required=True, clustering_order='DESC')
+        sdk = columns.Text(primary_key=True, required=True)
+        uuid = columns.BigInt(primary_key=True, required=True)
+
+    def __init__(self, *args, **kwargs):
+        super(SuiteContext, self).__init__('suite-results', *args, **kwargs)
+
+        with self:
+            self.cassandra.create_table(self.SuiteResultsByCommit)
+            self.cassandra.create_table(self.SuiteResultsByStartTime)
+
+    def register(self, configuration, commits, suite, test_results, timestamp=None):
+        timestamp = timestamp or time.time()
+        if not isinstance(timestamp, datetime):
+            timestamp = datetime.utcfromtimestamp(int(timestamp))
+
+        try:
+            if not isinstance(suite, str):
+                raise TypeError(f'Expected type {str}, got {type(suite)}')
+
+            if isinstance(timestamp, datetime):
+                timestamp = calendar.timegm(timestamp.timetuple())
+
+            run_stats = test_results.get('run_stats', {})
+            failure_trigger_points = dict(
+                failed=Expectations.STRING_TO_STATE_ID[Expectations.ERROR],
+                timedout=Expectations.STRING_TO_STATE_ID[Expectations.TIMEOUT],
+                crashed=Expectations.STRING_TO_STATE_ID[Expectations.CRASH],
+            )
+            run_stats['tests_run'] = 0
+            for key in failure_trigger_points.keys():
+                run_stats[f'tests_{key}'] = 0
+                run_stats[f'tests_unexpected_{key}'] = 0
+
+            def callback(test, result):
+                run_stats['tests_run'] += 1
+                actual_results = Expectations.string_to_state_ids(result.get('actual', ''))
+                expected_results = set(Expectations.string_to_state_ids(result.get('expected', '')))
+
+                # FAIL is a special case, we want to treat tests with TEXT, AUDIO and IMAGE diffs as failures
+                if Expectations.STRING_TO_STATE_ID[Expectations.FAIL] in expected_results:
+                    expected_results.add(Expectations.STRING_TO_STATE_ID[Expectations.TEXT])
+                    expected_results.add(Expectations.STRING_TO_STATE_ID[Expectations.AUDIO])
+                    expected_results.add(Expectations.STRING_TO_STATE_ID[Expectations.IMAGE])
+
+                worst_result = min(actual_results)
+                unexpected_results = set(actual_results) - expected_results
+                worst_unexpected_result = min(unexpected_results or [Expectations.STRING_TO_STATE_ID[Expectations.PASS]])
+
+                for key, point in failure_trigger_points.items():
+                    if worst_result <= point:
+                        run_stats[f'tests_{key}'] += 1
+                    if worst_unexpected_result <= point:
+                        run_stats[f'tests_unexpected_{key}'] += 1
+
+            Expectations.iterate_through_nested_results(test_results.get('results'), callback)
+
+            uuid = self.commit_context.uuid_for_commits(commits)
+            ttl = int((uuid // Commit.TIMESTAMP_TO_UUID_MULTIPLIER) + self.ttl_seconds - time.time()) if self.ttl_seconds else None
+
+            with self, self.cassandra.batch_query_context():
+                for branch in self.commit_context.branch_keys_for_commits(commits):
+                    for table in [self.SuiteResultsByCommit, self.SuiteResultsByStartTime]:
+                        self.configuration_context.insert_row_with_configuration(
+                            table.__table_name__, configuration=configuration, suite=suite,
+                            branch=branch, uuid=uuid, ttl=ttl,
+                            sdk=configuration.sdk or '?', start_time=timestamp,
+                            details=json.dumps(test_results.get('details', {})),
+                            stats=json.dumps(run_stats),
+                        )
+        except Exception as e:
+            return self.partial_status(e)
+        return self.partial_status()
+
+    def _find_results(
+            self, table, configurations, suite, recent=True,
+            branch=None, begin=None, end=None,
+            begin_query_time=None, end_query_time=None,
+            limit=DEFAULT_LIMIT,
+    ):
+        if not isinstance(suite, str):
+            raise TypeError(f'Expected type {str}, got {type(suite)}')
+
+        def get_time(time):
+            if isinstance(time, datetime):
+                return time
+            elif time:
+                return datetime.utcfromtimestamp(int(time))
+            return None
+
+        with self:
+            result = {}
+            for configuration in configurations:
+                result.update({config: [value.unpack() for value in values] for config, values in self.configuration_context.select_from_table_with_configurations(
+                    table.__table_name__, configurations=[configuration], recent=recent,
+                    suite=suite, sdk=configuration.sdk, branch=branch or self.commit_context.DEFAULT_BRANCH_KEY,
+                    uuid__gte=CommitContext.convert_to_uuid(begin),
+                    uuid__lte=CommitContext.convert_to_uuid(end, CommitContext.timestamp_to_uuid()),
+                    start_time__gte=get_time(begin_query_time), start_time__lte=get_time(end_query_time),
+                    limit=limit,
+                ).items()})
+            return result
+
+    def find_by_commit(self, *args, **kwargs):
+        return self._find_results(self.SuiteResultsByCommit, *args, **kwargs)
+
+    def find_by_start_time(self, *args, **kwargs):
+        return self._find_results(self.SuiteResultsByStartTime, *args, **kwargs)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelsuite_context_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/suite_context_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/suite_context_unittest.py                              (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/suite_context_unittest.py 2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,175 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import time
+
+from fakeredis import FakeStrictRedis
+from redis import StrictRedis
+from resultsdbpy.controller.configuration import Configuration
+from resultsdbpy.model.cassandra_context import CassandraContext
+from resultsdbpy.model.mock_cassandra_context import MockCassandraContext
+from resultsdbpy.model.mock_model_factory import MockModelFactory
+from resultsdbpy.model.mock_repository import MockSVNRepository
+from resultsdbpy.model.test_context import Expectations
+from resultsdbpy.model.wait_for_docker_test_case import WaitForDockerTestCase
+
+
+class SuiteContextTest(WaitForDockerTestCase):
+    KEYSPACE = 'suite_context_test_keyspace'
+
+    def init_database(self, redis=StrictRedis, cassandra=CassandraContext, test_results=None):
+        cassandra.drop_keyspace(keyspace=self.KEYSPACE)
+        self.model = MockModelFactory.create(redis=redis(), cassandra=cassandra(keyspace=self.KEYSPACE, create_keyspace=True))
+        MockModelFactory.add_mock_results(self.model, test_results=test_results)
+        MockModelFactory.process_results(self.model)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_find_all(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+
+        results = self.model.suite_context.find_by_commit(
+            configurations=[Configuration(platform='Mac', style='Release', flavor='wk1')],
+            suite='layout-tests', recent=True,
+        )
+        self.assertEqual(len(results), 1)
+        self.assertEqual(len(next(iter(results.values()))), 5)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_find_by_commit(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+
+        results = self.model.suite_context.find_by_commit(
+            configurations=[Configuration(platform='Mac', style='Release', flavor='wk1')],
+            suite='layout-tests', recent=False,
+            begin=MockSVNRepository.webkit().commit_for_id(236542), end=MockSVNRepository.webkit().commit_for_id(236543),
+        )
+        self.assertEqual(len(results), 2)
+        for results_for_config in results.values():
+            self.assertEqual(len(results_for_config), 2)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_find_by_time(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+
+        results = self.model.suite_context.find_by_start_time(
+            configurations=[Configuration(platform='Mac', style='Release', flavor='wk1')],
+            suite='layout-tests', recent=False,
+            begin_query_time=(time.time() - 60 * 60),
+        )
+
+        self.assertEqual(len(results), 1)
+        self.assertEqual(len(next(iter(results.values()))), 5)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_all_successful(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+
+        results = self.model.suite_context.find_by_start_time(
+            configurations=[Configuration(platform='Mac', style='Release', flavor='wk1')],
+            suite='layout-tests',
+            begin=MockSVNRepository.webkit().commit_for_id(236542), end=MockSVNRepository.webkit().commit_for_id(236542),
+        )
+
+        self.assertEqual(
+            next(iter(results.values()))[0]['stats'],
+            dict(
+                tests_run=4,
+                tests_skipped=0,
+                tests_failed=0,
+                tests_timedout=0,
+                tests_crashed=0,
+                tests_unexpected_failed=0,
+                tests_unexpected_timedout=0,
+                tests_unexpected_crashed=0,
+            ),
+        )
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_failure_expectations(self, redis=StrictRedis, cassandra=CassandraContext):
+        test_results = MockModelFactory.layout_test_results()
+        test_results['results']['fast']['encoding']['css-cached-bom.html'] = dict(
+            actual=Expectations.FAIL,
+            time=1.2,
+        )
+        test_results['results']['fast']['encoding']['css-charset-default.xhtml'] = dict(
+            actual=Expectations.TIMEOUT,
+            expected=Expectations.TIMEOUT,
+            time=1.2,
+        )
+        self.init_database(redis=redis, cassandra=cassandra, test_results=test_results)
+
+        results = self.model.suite_context.find_by_start_time(
+            configurations=[Configuration(platform='Mac', style='Release', flavor='wk1')],
+            suite='layout-tests',
+            begin=MockSVNRepository.webkit().commit_for_id(236542),
+            end=MockSVNRepository.webkit().commit_for_id(236542),
+        )
+
+        self.assertEqual(
+            next(iter(results.values()))[0]['stats'],
+            dict(
+                tests_run=4,
+                tests_skipped=0,
+                tests_failed=2,
+                tests_timedout=1,
+                tests_crashed=0,
+                tests_unexpected_failed=1,
+                tests_unexpected_timedout=0,
+                tests_unexpected_crashed=0,
+            ),
+        )
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_image_diff_as_failure_expectations(self, redis=StrictRedis, cassandra=CassandraContext):
+        test_results = MockModelFactory.layout_test_results()
+        test_results['results']['fast']['encoding']['css-cached-bom.html'] = dict(
+            actual=Expectations.TIMEOUT,
+            expected=Expectations.FAIL,
+            time=1.2,
+        )
+        test_results['results']['fast']['encoding']['css-charset-default.xhtml'] = dict(
+            actual=Expectations.IMAGE,
+            expected=Expectations.FAIL,
+            time=1.2,
+        )
+        self.init_database(redis=redis, cassandra=cassandra, test_results=test_results)
+
+        results = self.model.suite_context.find_by_start_time(
+            configurations=[Configuration(platform='Mac', style='Release', flavor='wk1')],
+            suite='layout-tests',
+            begin=MockSVNRepository.webkit().commit_for_id(236542),
+            end=MockSVNRepository.webkit().commit_for_id(236542),
+        )
+
+        self.assertEqual(
+            next(iter(results.values()))[0]['stats'],
+            dict(
+                tests_run=4,
+                tests_skipped=0,
+                tests_failed=2,
+                tests_timedout=1,
+                tests_crashed=0,
+                tests_unexpected_failed=1,
+                tests_unexpected_timedout=1,
+                tests_unexpected_crashed=0,
+            ),
+        )
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodeltest_contextpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/test_context.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/test_context.py                                (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/test_context.py   2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,225 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import calendar
+import collections
+import json
+import time
+
+from cassandra.cqlengine import columns
+from cassandra.cqlengine.models import Model
+from datetime import datetime
+from resultsdbpy.controller.commit import Commit
+from resultsdbpy.model.commit_context import CommitContext
+from resultsdbpy.model.configuration_context import ClusteredByConfiguration
+from resultsdbpy.model.upload_context import UploadCallbackContext
+
+
+class Expectations:
+    # These are ordered by priority, meaning that a test which both crashes and has
+    # a warning should be considered to have crashed.
+    STATE_ID_TO_STRING = collections.OrderedDict()
+    STATE_ID_TO_STRING[0x00] = 'CRASH'
+    STATE_ID_TO_STRING[0x08] = 'TIMEOUT'
+    STATE_ID_TO_STRING[0x10] = 'IMAGE'
+    STATE_ID_TO_STRING[0x18] = 'AUDIO'
+    STATE_ID_TO_STRING[0x20] = 'TEXT'
+    STATE_ID_TO_STRING[0x28] = 'FAIL'
+    STATE_ID_TO_STRING[0x30] = 'ERROR'
+    STATE_ID_TO_STRING[0x38] = 'WARNING'
+    STATE_ID_TO_STRING[0x40] = 'PASS'
+
+    STRING_TO_STATE_ID = {string: id for id, string in STATE_ID_TO_STRING.items()}
+
+    CRASH, TIMEOUT, IMAGE, AUDIO, TEXT, FAIL, ERROR, WARNING, PASS = STATE_ID_TO_STRING.values()
+
+    @classmethod
+    def string_to_state_ids(cls, string):
+        result = set([elm for elm in [cls.STRING_TO_STATE_ID.get(str) for str in string.split(' ')] if elm is not None])
+        return sorted(result) or [cls.STRING_TO_STATE_ID[cls.PASS]]
+
+    @classmethod
+    def state_ids_to_string(cls, state_ids):
+        if isinstance(state_ids, str):
+            return ' '.join([cls.STATE_ID_TO_STRING[ord(state)] for state in sorted(state_ids)])
+        return ' '.join([cls.STATE_ID_TO_STRING[int(state)] for state in sorted(state_ids)])
+
+    @classmethod
+    def iterate_through_nested_results(cls, results, callback=lambda test, results: None):
+        def recurse(partial_test, results):
+            potential_base_case = True
+            for key, value in results.items():
+                if isinstance(value, dict):
+                    potential_base_case = False
+                    recurse(partial_test + '/' + key, value)
+                elif not potential_base_case:
+                    raise ValueError('Incorrectly formatted nested results dictionary')
+
+            if potential_base_case:
+                # If we don't have a dictionary of dictionaries, that means this is a leaf node
+                callback(partial_test, results)
+
+        for key, value in results.items():
+            recurse(key, value)
+
+
+class TestContext(UploadCallbackContext):
+    DEFAULT_LIMIT = 100
+
+    class TestResultsBase(ClusteredByConfiguration):
+        suite = columns.Text(partition_key=True, required=True)
+        branch = columns.Text(partition_key=True, required=True)
+        test = columns.Text(partition_key=True, required=True)
+        expected = columns.Blob(required=True)
+        actual = columns.Blob(required=True)
+        details = columns.Text(required=False)
+
+        def unpack(self):
+            result = dict(
+                uuid=self.uuid,
+                start_time=calendar.timegm(self.start_time.timetuple()),
+                expected=Expectations.state_ids_to_string(self.expected),
+                actual=Expectations.state_ids_to_string(self.actual),
+            )
+            for key, value in (json.loads(self.details) if self.details else {}).items():
+                if key in result:
+                    continue
+                result[key] = value
+            return result
+
+    class TestResultsByCommit(TestResultsBase):
+        __table_name__ = 'test_results_by_commit'
+        uuid = columns.BigInt(primary_key=True, required=True, clustering_order='DESC')
+        sdk = columns.Text(primary_key=True, required=True)
+        start_time = columns.DateTime(primary_key=True, required=True)
+
+    class TestResultsByStartTime(TestResultsBase):
+        __table_name__ = 'test_results_by_start_time'
+        start_time = columns.DateTime(primary_key=True, required=True, clustering_order='DESC')
+        sdk = columns.Text(primary_key=True, required=True)
+        uuid = columns.BigInt(primary_key=True, required=True)
+
+    class TestNameBySuite(Model):
+        __table_name__ = 'test_names_by_suite'
+        suite = columns.Text(partition_key=True, required=True)
+        test = columns.Text(primary_key=True, required=True)
+
+    def __init__(self, *args, **kwargs):
+        super(TestContext, self).__init__('test-results', *args, **kwargs)
+
+        with self:
+            self.cassandra.create_table(self.TestResultsByCommit)
+            self.cassandra.create_table(self.TestResultsByStartTime)
+            self.cassandra.create_table(self.TestNameBySuite)
+
+    def names(self, suite, test=None, limit=DEFAULT_LIMIT):
+        with self:
+            if test:
+                # FIXME: SASI indecies are the cannoical way to solve this problem, but require Cassandra 3.4 which
+                # hasn't been deployed to our datacenters yet. This works for commits, but is less transparent.
+                return [model.test for model in self.cassandra.select_from_table(
+                    self.TestNameBySuite.__table_name__, limit=limit, suite=suite,
+                    test__gte=test, test__lte=(test + '~'),
+                )]
+
+            return [model.test for model in self.cassandra.select_from_table(
+                self.TestNameBySuite.__table_name__, limit=limit, suite=suite,
+            )]
+
+    def register(self, configuration, commits, suite, test_results, timestamp=None):
+        timestamp = timestamp or time.time()
+        if not isinstance(timestamp, datetime):
+            timestamp = datetime.utcfromtimestamp(int(timestamp))
+
+        try:
+            if not isinstance(suite, str):
+                raise TypeError(f'Expected type {str}, got {type(suite)}')
+
+            if isinstance(timestamp, datetime):
+                timestamp = calendar.timegm(timestamp.timetuple())
+
+            with self:
+                uuid = self.commit_context.uuid_for_commits(commits)
+                ttl = int((uuid // Commit.TIMESTAMP_TO_UUID_MULTIPLIER) + self.ttl_seconds - time.time()) if self.ttl_seconds else None
+
+                def callback(test, result, branch):
+                    self.cassandra.insert_row(self.TestNameBySuite.__table_name__, suite=suite, test=test, ttl=ttl)
+
+                    args_to_write = dict(
+                        actual=bytearray(Expectations.string_to_state_ids(result.get('actual', ''))),
+                        expected=bytearray(Expectations.string_to_state_ids(result.get('expected', ''))),
+                        details=json.dumps({key: value for key, value in result.items() if key not in ['actual', 'expected']}),
+                    )
+                    for table in [self.TestResultsByCommit, self.TestResultsByStartTime]:
+                        self.configuration_context.insert_row_with_configuration(
+                            table.__table_name__, configuration=configuration, suite=suite,
+                            branch=branch, uuid=uuid, ttl=ttl,
+                            test=test, sdk=configuration.sdk or '?', start_time=timestamp,
+                            **args_to_write)
+
+                with self.cassandra.batch_query_context():
+                    for branch in self.commit_context.branch_keys_for_commits(commits):
+                        Expectations.iterate_through_nested_results(
+                            test_results.get('results'),
+                            lambda test, result: callback(test, result, branch=branch),
+                        )
+
+        except Exception as e:
+            return self.partial_status(e)
+        return self.partial_status()
+
+    def _find_results(
+            self, table, configurations, suite, test, recent=True,
+            branch=None, begin=None, end=None,
+            begin_query_time=None, end_query_time=None,
+            limit=DEFAULT_LIMIT,
+    ):
+        if not isinstance(suite, str):
+            raise TypeError(f'Expected type {str}, got {type(suite)}')
+        if not isinstance(test, str):
+            raise TypeError(f'Expected type {str}, got {type(suite)}')
+
+        def get_time(time):
+            if isinstance(time, datetime):
+                return time
+            elif time:
+                return datetime.utcfromtimestamp(int(time))
+            return None
+
+        with self:
+            result = {}
+            for configuration in configurations:
+                result.update({config: [value.unpack() for value in values] for config, values in self.configuration_context.select_from_table_with_configurations(
+                    table.__table_name__, configurations=[configuration], recent=recent,
+                    suite=suite, test=test, sdk=configuration.sdk, branch=branch or self.commit_context.DEFAULT_BRANCH_KEY,
+                    uuid__gte=CommitContext.convert_to_uuid(begin),
+                    uuid__lte=CommitContext.convert_to_uuid(end, CommitContext.timestamp_to_uuid()),
+                    start_time__gte=get_time(begin_query_time), start_time__lte=get_time(end_query_time),
+                    limit=limit,
+                ).items()})
+            return result
+
+    def find_by_commit(self, *args, **kwargs):
+        return self._find_results(self.TestResultsByCommit, *args, **kwargs)
+
+    def find_by_start_time(self, *args, **kwargs):
+        return self._find_results(self.TestResultsByStartTime, *args, **kwargs)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodeltest_context_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/test_context_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/test_context_unittest.py                               (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/test_context_unittest.py  2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,104 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import time
+
+from fakeredis import FakeStrictRedis
+from redis import StrictRedis
+from resultsdbpy.controller.configuration import Configuration
+from resultsdbpy.model.cassandra_context import CassandraContext
+from resultsdbpy.model.mock_cassandra_context import MockCassandraContext
+from resultsdbpy.model.mock_model_factory import MockModelFactory
+from resultsdbpy.model.mock_repository import MockSVNRepository
+from resultsdbpy.model.test_context import Expectations
+from resultsdbpy.model.wait_for_docker_test_case import WaitForDockerTestCase
+
+
+class TestContextTest(WaitForDockerTestCase):
+    KEYSPACE = 'test_context_test_keyspace'
+
+    def init_database(self, redis=StrictRedis, cassandra=CassandraContext):
+        cassandra.drop_keyspace(keyspace=self.KEYSPACE)
+        self.model = MockModelFactory.create(redis=redis(), cassandra=cassandra(keyspace=self.KEYSPACE, create_keyspace=True))
+        MockModelFactory.add_mock_results(self.model)
+        MockModelFactory.process_results(self.model)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_list_all(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        results = self.model.test_context.names(suite='layout-tests')
+        self.assertEqual(results, [
+            'fast/encoding/css-cached-bom.html',
+            'fast/encoding/css-charset-default.xhtml',
+            'fast/encoding/css-charset.html',
+            'fast/encoding/css-link-charset.html',
+        ])
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_list_partial(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        results = self.model.test_context.names(suite='layout-tests', test='fast/encoding/css-charset')
+        self.assertEqual(results, [
+            'fast/encoding/css-charset-default.xhtml',
+            'fast/encoding/css-charset.html',
+        ])
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_find_all(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        results = self.model.test_context.find_by_commit(
+            configurations=[Configuration(platform='Mac', style='Release', flavor='wk1')],
+            suite='layout-tests', test='fast/encoding/css-link-charset.html', recent=True,
+        )
+
+        self.assertEqual(len(results), 1)
+        self.assertEqual(len(next(iter(results.values()))), 5)
+        for result in next(iter(results.values())):
+            self.assertEqual(result['actual'], Expectations.PASS)
+            self.assertEqual(result['expected'], Expectations.PASS)
+            self.assertEqual(result['time'], 1.2)
+            self.assertEqual(result['modifiers'], '')
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_find_by_commit(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        results = self.model.test_context.find_by_commit(
+            configurations=[Configuration(platform='Mac', style='Release', flavor='wk1')],
+            suite='layout-tests', test='fast/encoding/css-cached-bom.html', recent=False,
+            begin=MockSVNRepository.webkit().commit_for_id(236542),
+            end=MockSVNRepository.webkit().commit_for_id(236543),
+        )
+        self.assertEqual(len(results), 2)
+        for results_for_config in results.values():
+            self.assertEqual(len(results_for_config), 2)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_find_by_time(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        results = self.model.test_context.find_by_start_time(
+            configurations=[Configuration(platform='Mac', style='Release', flavor='wk1')],
+            suite='layout-tests', test='fast/encoding/css-charset.html', recent=False,
+            begin_query_time=(time.time() - 60 * 60),
+        )
+
+        self.assertEqual(len(results), 1)
+        self.assertEqual(len(next(iter(results.values()))), 5)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelupload_contextpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/upload_context.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/upload_context.py                              (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/upload_context.py 2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,295 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import calendar
+import io
+import json
+import time
+import zipfile
+
+from cassandra.cqlengine import columns
+from collections import defaultdict
+from datetime import datetime
+from resultsdbpy.controller.commit import Commit
+from resultsdbpy.controller.configuration import Configuration
+from resultsdbpy.model.commit_context import CommitContext
+from resultsdbpy.model.configuration_context import ClusteredByConfiguration
+
+
+class UploadContext(object):
+    QUEUE_NAME = 'upload_queue'
+    PROCESS_TIMEOUT = 24 * 60 * 60
+    MAX_ATTEMPTS = 3
+    RETRY_TIME = 5 * 60  # After 5 minutes, re-try a task even if it's in-flight
+    MAX_TASKS_IN_SCAN = 10
+
+    class SuitesByConfiguration(ClusteredByConfiguration):
+        __table_name__ = 'suites_by_configuration'
+        suite = columns.Text(primary_key=True, required=True)
+
+    class UploadsByConfiguration(ClusteredByConfiguration):
+        __table_name__ = 'uploads_by_configuration_01'
+        suite = columns.Text(partition_key=True, required=True)
+        branch = columns.Text(partition_key=True, required=True)
+        uuid = columns.BigInt(primary_key=True, required=True, clustering_order='DESC')
+        sdk = columns.Text(primary_key=True, required=True)
+        commits = columns.Blob(required=True)
+        test_results = columns.Blob(required=True)
+        time_uploaded = columns.DateTime(required=True)
+        upload_version = columns.Integer(required=True)
+
+        def unpack(self):
+            return dict(
+                commits=[Commit.from_json(element) for element in json.loads(UploadContext.from_zip(bytearray(self.commits)))],
+                sdk=None if self.sdk == '?' else self.sdk,
+                test_results=json.loads(UploadContext.from_zip(bytearray(self.test_results))),
+                timestamp=calendar.timegm(self.time_uploaded.timetuple()),
+                version=self.upload_version,
+            )
+
+    def __init__(self, configuration_context, commit_context, ttl_seconds=None, async_processing=False):
+        self.ttl_seconds = ttl_seconds
+        self.configuration_context = configuration_context
+        self.commit_context = commit_context
+        self.cassandra = self.configuration_context.cassandra
+        self._process_upload_callbacks = defaultdict(dict)
+        with self:
+            self.cassandra.create_table(self.SuitesByConfiguration)
+            self.cassandra.create_table(self.UploadsByConfiguration)
+
+        self._async_processing = async_processing
+        self.redis = self.configuration_context.redis
+
+    def __enter__(self):
+        self.configuration_context.__enter__()
+        self.commit_context.__enter__()
+
+    def __exit__(self, *args, **kwargs):
+        self.commit_context.__exit__(*args, **kwargs)
+        self.configuration_context.__exit__(*args, **kwargs)
+
+    def register_upload_callback(self, name, callback, suite=None):
+        # If no suite is specified, all uploads from all suites will trigger the callback
+        self._process_upload_callbacks[suite][name] = callback
+
+    @classmethod
+    def to_zip(cls, value, archive_name='archive'):
+        if not isinstance(value, str):
+            raise TypeError(f'Expected type {str}, got {type(value)}')
+
+        compressed_file = io.BytesIO()
+        with zipfile.ZipFile(compressed_file, mode='w', compression=zipfile.ZIP_DEFLATED) as zip_file:
+            zip_file.writestr(archive_name, value)
+        return bytearray(compressed_file.getvalue())
+
+    @classmethod
+    def from_zip(cls, value, archive_name='archive'):
+        if not isinstance(value, bytearray):
+            raise TypeError(f'Expected type {bytearray}, got {type(value)}')
+
+        compressed_file = io.BytesIO()
+        compressed_file.write(value)
+        with zipfile.ZipFile(compressed_file, mode='r') as zip_file:
+            return zip_file.read(archive_name).decode('utf-8')
+
+    def synchronously_process_test_results(self, configuration, commits, suite, test_results, timestamp=None):
+        timestamp = timestamp or time.time()
+
+        # Allows partial errors to be forwarded back to the caller
+        result = {}
+        for name, callback in self._process_upload_callbacks[suite].items():
+            result[name] = callback(configuration=configuration, commits=commits, suite=suite, test_results=test_results, timestamp=timestamp)
+        for name, callback in self._process_upload_callbacks[None].items():
+            result[name] = callback(configuration=configuration, commits=commits, suite=suite, test_results=test_results, timestamp=timestamp)
+        return result
+
+    def _find_job_with_attempts(self):
+        now = int(time.time())
+        are_jobs_left = False
+        will_attempt = 0
+
+        with self.redis.lock(name=f'lock_{self.QUEUE_NAME}'):
+            for key in self.redis.scan_iter(match=f'{self.QUEUE_NAME}*', count=self.MAX_TASKS_IN_SCAN):
+                are_jobs_left = True
+                key = key.decode('utf-8')
+                try:
+                    value = json.loads(self.redis.get(key))
+                    if now > value.get('started_processing', 0) + self.RETRY_TIME:
+                        will_attempt = value.get('attempts', 0) + 1
+                except Exception:
+                    will_attempt = 1
+
+                if will_attempt:
+                    self.redis.set(
+                        key,
+                        json.dumps(dict(started_processing=now, attempts=will_attempt)),
+                        ex=self.PROCESS_TIMEOUT,
+                    )
+                    return are_jobs_left, key, will_attempt
+
+        return are_jobs_left, None, None
+
+    def _do_job_for_key(self, key, attempts=1):
+        job_complete = False
+        try:
+            data = json.loads(self.redis.get(f'data_for_{key}'))
+            self.synchronously_process_test_results(
+                configuration=Configuration.from_json(data['configuration']),
+                commits=[Commit.from_json(commit_json) for commit_json in data['commits']],
+                suite=data['suite'],
+                timestamp=data['timestamp'],
+                test_results=data['test_results'],
+            )
+            job_complete = True
+        finally:
+            if job_complete or attempts >= self.MAX_ATTEMPTS:
+                self.redis.delete(key)
+                self.redis.delete(f'data_for_{key}')
+            else:
+                self.redis.set(
+                    key,
+                    json.dumps(dict(started_processing=0, attempts=attempts)),
+                    ex=self.PROCESS_TIMEOUT,
+                )
+
+    def do_processing_work(self):
+        jobs_left = True
+
+        while jobs_left:
+            jobs_left, key, attempts = self._find_job_with_attempts()
+
+            if key:
+                self._do_job_for_key(key, attempts=attempts)
+            elif jobs_left:
+                time.sleep(10)  # There are jobs, but other workers are processing them.
+
+    def process_test_results(self, configuration, commits, suite, test_results, timestamp=None):
+        timestamp = timestamp or time.time()
+
+        if not self._async_processing:
+            return self.synchronously_process_test_results(configuration, commits, suite, test_results=test_results, timestamp=timestamp)
+
+        for branch in self.commit_context.branch_keys_for_commits(commits):
+            hash_key = hash(configuration) ^ hash(branch) ^ hash(self.commit_context.uuid_for_commits(commits)) ^ hash(
+                suite)
+            self.redis.set(
+                f'{self.QUEUE_NAME}:{hash_key}',
+                json.dumps(dict(started_processing=0, attempts=0)),
+                ex=self.PROCESS_TIMEOUT,
+            )
+            self.redis.set(
+                f'data_for_{self.QUEUE_NAME}:{hash_key}',
+                json.dumps(dict(
+                    configuration=Configuration.Encoder().default(configuration),
+                    suite=suite,
+                    commits=Commit.Encoder().default(commits),
+                    timestamp=timestamp,
+                    test_results=test_results,
+                )),
+                ex=self.PROCESS_TIMEOUT,
+            )
+        return {key: dict(status='Queued') for key in list(self._process_upload_callbacks[suite].keys()) + list(self._process_upload_callbacks[None].keys())}
+
+    def upload_test_results(self, configuration, commits, suite, test_results, timestamp=None, version=0):
+        if not isinstance(suite, str):
+            raise TypeError(f'Expected type {str}, got {type(suite)}')
+        for commit in commits:
+            if not isinstance(commit, Commit):
+                raise TypeError(f'Expected type {Commit}, got {type(commit)}')
+        if len(commits) < 1:
+            raise ValueError('Each test result must have at least 1 associated commit')
+        if not isinstance(test_results, dict):
+            raise TypeError(f'Expected type {dict}, got {type(test_results)}')
+        timestamp = timestamp or time.time()
+        if not isinstance(timestamp, datetime):
+            timestamp = datetime.utcfromtimestamp(int(timestamp))
+
+        uuid = self.commit_context.uuid_for_commits(commits)
+        branches = self.commit_context.branch_keys_for_commits(commits)
+
+        with self:
+            for branch in branches:
+                self.configuration_context.register_configuration(configuration, timestamp=timestamp)
+                self.configuration_context.insert_row_with_configuration(
+                    self.SuitesByConfiguration.__table_name__, configuration, suite=suite,
+                    ttl=int((uuid // Commit.TIMESTAMP_TO_UUID_MULTIPLIER) + self.ttl_seconds - time.time()) if self.ttl_seconds else None,
+                )
+                self.configuration_context.insert_row_with_configuration(
+                    self.UploadsByConfiguration.__table_name__, configuration=configuration,
+                    suite=suite, branch=branch, uuid=uuid, sdk=configuration.sdk or '?', time_uploaded=timestamp,
+                    commits=self.to_zip(json.dumps(commits, cls=Commit.Encoder)),
+                    test_results=self.to_zip(json.dumps(test_results)),
+                    upload_version=version,
+                    ttl=int((uuid // Commit.TIMESTAMP_TO_UUID_MULTIPLIER) + self.ttl_seconds - time.time()) if self.ttl_seconds else None,
+                )
+
+    def find_test_results(self, configurations, suite, branch=None, begin=None, end=None, recent=True, limit=100):
+        if not isinstance(suite, str):
+            raise TypeError(f'Expected type {str}, got {type(suite)}')
+
+        with self:
+            result = {}
+            for configuration in configurations:
+                result.update({config: [value.unpack() for value in values] for config, values in self.configuration_context.select_from_table_with_configurations(
+                    self.UploadsByConfiguration.__table_name__, configurations=[configuration], recent=recent,
+                    suite=suite, sdk=configuration.sdk, branch=branch or self.commit_context.DEFAULT_BRANCH_KEY,
+                    uuid__gte=CommitContext.convert_to_uuid(begin),
+                    uuid__lte=CommitContext.convert_to_uuid(end, CommitContext.timestamp_to_uuid()), limit=limit,
+                ).items()})
+            return result
+
+    def find_suites(self, configurations, recent=True, limit=100):
+        with self:
+            return {
+                config: [row.suite for row in rows] for config, rows in self.configuration_context.select_from_table_with_configurations(
+                    self.SuitesByConfiguration.__table_name__, configurations=configurations, recent=recent, limit=limit,
+                ).items()
+            }
+
+
+class UploadCallbackContext(object):
+    @classmethod
+    def partial_status(cls, exception=None):
+        if exception:
+            return dict(
+                status='error',
+                description=str(exception),
+            )
+        return dict(status='ok')
+
+    def __init__(self, name, configuration_context, commit_context, ttl_seconds=None):
+        self.name = name
+        self.configuration_context = configuration_context
+        self.commit_context = commit_context
+        self.cassandra = self.configuration_context.cassandra
+        self.ttl_seconds = ttl_seconds
+
+    def __enter__(self):
+        self.configuration_context.__enter__()
+        self.commit_context.__enter__()
+
+    def __exit__(self, *args, **kwargs):
+        self.commit_context.__exit__(*args, **kwargs)
+        self.configuration_context.__exit__(*args, **kwargs)
+
+    def register(self, *args, **kwargs):
+        return dict(status='error', description=f'No register implemented for {self.name}')
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelupload_context_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/upload_context_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/upload_context_unittest.py                             (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/upload_context_unittest.py        2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,168 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from fakeredis import FakeStrictRedis
+from redis import StrictRedis
+from resultsdbpy.controller.configuration import Configuration
+from resultsdbpy.model.cassandra_context import CassandraContext
+from resultsdbpy.model.mock_cassandra_context import MockCassandraContext
+from resultsdbpy.model.mock_model_factory import MockModelFactory
+from resultsdbpy.model.upload_context import UploadContext
+from resultsdbpy.model.wait_for_docker_test_case import WaitForDockerTestCase
+
+
+class UploadContextTest(WaitForDockerTestCase):
+    KEYSPACE = 'upload_context_test_keyspace'
+
+    def init_database(self, redis=StrictRedis, cassandra=CassandraContext, async_processing=False):
+        cassandra.drop_keyspace(keyspace=self.KEYSPACE)
+        self.model = MockModelFactory.create(
+            redis=redis(), cassandra=cassandra(keyspace=self.KEYSPACE, create_keyspace=True),
+            async_processing=async_processing,
+        )
+
+    def test_zipping(self):
+        zipped_bytes = UploadContext.to_zip('somestring' * 100)
+        self.assertNotEqual(zipped_bytes, 'somestring' * 100)
+        self.assertEqual(UploadContext.from_zip(zipped_bytes), 'somestring' * 100)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_suite_list(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+
+        MockModelFactory.add_mock_results(self.model)
+        for suites in self.model.upload_context.find_suites(configurations=[Configuration()], recent=True).values():
+            self.assertEqual(suites, ['layout-tests'])
+
+        MockModelFactory.add_mock_results(self.model, suite='api_tests')
+        for suites in self.model.upload_context.find_suites(configurations=[Configuration()], recent=True).values():
+            self.assertEqual(suites, ['api_tests', 'layout-tests'])
+
+        MockModelFactory.add_mock_results(self.model, configuration=Configuration(is_simulator=True), suite='webkitpy')
+        for suites in self.model.upload_context.find_suites(configurations=[Configuration(is_simulator=True)], recent=True).values():
+            self.assertEqual(suites, ['api_tests', 'layout-tests', 'webkitpy'])
+        for suites in self.model.upload_context.find_suites(configurations=[Configuration(is_simulator=False)], recent=True).values():
+            self.assertEqual(suites, ['api_tests', 'layout-tests'])
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_result_retrieval(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        MockModelFactory.add_mock_results(self.model)
+
+        results = self.model.upload_context.find_test_results(configurations=[Configuration(platform='Mac')], suite='layout-tests', recent=True)
+        self.assertEqual(6, len(results))
+        for config, values in results.items():
+            self.assertEqual(config, Configuration(platform='Mac'))
+            self.assertEqual(5, len(values))
+            for value in values:
+                self.assertEqual(value['test_results'], MockModelFactory.layout_test_results())
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_result_retrieval_limit(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        MockModelFactory.add_mock_results(self.model)
+
+        results = self.model.upload_context.find_test_results(configurations=[Configuration(platform='Mac')], suite='layout-tests', limit=2, recent=True)
+        self.assertEqual(sum([len(value) for value in results.values()]), 12)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_result_retrieval_branch(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        MockModelFactory.add_mock_results(self.model)
+
+        results = self.model.upload_context.find_test_results(configurations=[Configuration(platform='iOS', is_simulator=True)], suite='layout-tests', branch='safari-606-branch', recent=True)
+        self.assertEqual(3, len(results))
+        for config, values in results.items():
+            self.assertEqual(config, Configuration(platform='iOS', is_simulator=True))
+            self.assertEqual(2, len(values))
+            for value in values:
+                self.assertEqual(value['test_results'], MockModelFactory.layout_test_results())
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_result_retrieval_by_sdk(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        MockModelFactory.add_mock_results(self.model)
+
+        self.assertEqual(0, len(self.model.upload_context.find_test_results(configurations=[Configuration(platform='iOS', sdk='15A432')], suite='layout-tests', recent=True)))
+        results = self.model.upload_context.find_test_results(configurations=[Configuration(platform='iOS', sdk='15A432')], suite='layout-tests', recent=False)
+        self.assertEqual(6, len(results))
+        for config, values in results.items():
+            self.assertEqual(config.version, 11000000)
+            for value in values:
+                self.assertEqual(value['sdk'], '15A432')
+                self.assertEqual(value['test_results'], MockModelFactory.layout_test_results())
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_sdk_differentiation(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        MockModelFactory.add_mock_results(self.model)
+
+        configuration_to_search = Configuration(platform='iOS', version='12.0.0', is_simulator=True, style='Asan')
+        results = self.model.upload_context.find_test_results(configurations=[configuration_to_search], suite='layout-tests', recent=False)
+        self.assertEqual(1, len(results))
+
+        MockModelFactory.add_mock_results(self.model, configuration=Configuration(
+            platform='iOS', version='12.0.0', sdk='16A405', is_simulator=True, architecture='x86_64', style='Asan',
+        ))
+
+        results = self.model.upload_context.find_test_results(configurations=[configuration_to_search], suite='layout-tests', recent=False)
+        self.assertEqual(2, len(results))
+        results = self.model.upload_context.find_test_results(configurations=[Configuration(platform='iOS', sdk='16A405')], suite='layout-tests', recent=False)
+        self.assertEqual(1, len(results))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def test_callback(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra)
+        MockModelFactory.add_mock_results(self.model)
+
+        configuration_to_search = Configuration(platform='iOS', version='12.0.0', is_simulator=True, style='Asan')
+        configuration, uploads = next(iter(self.model.upload_context.find_test_results(configurations=[configuration_to_search], suite='layout-tests', recent=False).items()))
+        self.model.upload_context.process_test_results(
+            configuration=configuration,
+            commits=uploads[0]['commits'],
+            suite='layout-tests',
+            test_results=uploads[0]['test_results'],
+            timestamp=uploads[0]['timestamp'],
+        )
+
+        # Using suite results as a proxy to tell if callbacks were triggered
+        self.assertEqual(1, len(self.model.suite_context.find_by_commit(configurations=[Configuration()], suite='layout-tests')))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    def _test_async_callback(self, redis=StrictRedis, cassandra=CassandraContext):
+        self.init_database(redis=redis, cassandra=cassandra, async_processing=True)
+        MockModelFactory.add_mock_results(self.model)
+
+        configuration_to_search = Configuration(platform='iOS', version='12.0.0', is_simulator=True, style='Asan')
+        configuration, uploads = self.model.upload_context.find_test_results(configurations=[configuration_to_search], suite='layout-tests', recent=False).items()[0]
+        self.model.upload_context.process_test_results(
+            configuration=configuration,
+            commits=uploads[0]['commits'],
+            suite='layout-tests',
+            test_results=uploads[0]['test_results'],
+            timestamp=uploads[0]['timestamp'],
+        )
+
+        # Using suite results as a proxy to tell if callbacks were triggered
+        self.assertEqual(0, len(self.model.suite_context.find_by_commit(configurations=[Configuration()], suite='layout-tests')))
+        self.model.upload_context.do_processing_work()
+        self.assertEqual(1, len(self.model.suite_context.find_by_commit(configurations=[Configuration()], suite='layout-tests')))
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpymodelwait_for_docker_test_casepy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/model/wait_for_docker_test_case.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/model/wait_for_docker_test_case.py                           (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/model/wait_for_docker_test_case.py      2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,75 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import os
+import sys
+import unittest
+
+from fakeredis import FakeStrictRedis
+from redis import StrictRedis
+from resultsdbpy.model.docker import Docker
+
+
+class WaitForDockerTestCase(unittest.TestCase):
+
+    def setUp(self):
+        if Docker.is_running() and int(os.environ.get('slow_tests', '0')):
+            with Docker.instance():
+                StrictRedis().flushdb()
+        FakeStrictRedis().flushdb()
+
+    @classmethod
+    def combine(cls, *args):
+        def decorator(func):
+            for elm in reversed(args):
+                func = elm(func)
+            return func
+
+        return decorator
+
+    @classmethod
+    def run_if_slow(cls):
+        return unittest.skipIf(not int(os.environ.get('slow_tests', '0')), 'Slow tests disabled')
+
+    @classmethod
+    def run_if_has_docker(cls):
+        return cls.combine(cls.run_if_slow(), unittest.skipIf(not Docker.installed(), 'Docker not installed'))
+
+    @classmethod
+    def mock_if_no_docker(cls, **kwargs):
+        mock_args = {}
+        for key, value in kwargs.items():
+            if not key.startswith('mock_'):
+                raise Exception('{} does not start with \'mock_\' and is an invalid argument')
+            mock_args[key[len('mock_'):]] = value
+
+        def decorator(method, mock_args=mock_args):
+            # Use frame introspection to create a method in the containing frame.
+            frame = sys._getframe(1)  # pylint: disable-msg=W0212
+            frame.f_locals[method.__name__ + '_mock'] = lambda val: method(val, **mock_args)
+
+            def real_method(val, method=method):
+                with Docker.instance():
+                    return method(val)
+            return cls.run_if_has_docker()(real_method)
+
+        return decorator
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpyruntests"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/run-tests (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/run-tests                            (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/run-tests       2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,77 @@
</span><ins>+#!/usr/bin/env python3
+
+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import sys
+sys.dont_write_bytecode = True
+
+import argparse
+import os
+import sys
+import unittest
+
+from cassandra.cqlengine.management import CQLENG_ALLOW_SCHEMA_MANAGEMENT
+
+def main():
+    parser = argparse.ArgumentParser(description='Run unit tests for resultsdbpy')
+    parser.add_argument('-v', '--verbose',
+                      default=False, action='store_true',
+                      help='Verbose output')
+    parser.add_argument('--stop-on-fail',
+                      default=False, action='store_true',
+                      help='Stop on first fail or error')
+    parser.add_argument('modules_to_test', nargs='*',
+                      help='Modules to be tested. By default, this is the database, flask_support, model and view modules',
+                      default=['controller', 'flask_support', 'model', 'view'])
+    parser.add_argument('-f', '--fast-tests',
+                      default=False, action='store_true',
+                      help='Some tests require a docker instance and are slow, optionally skip these')
+    parser.add_argument('--no-web-server',
+                        dest='web_server', default=True, action='store_false',
+                        help='Some tests use a Flask webserver, optionally skip these')
+    parser.add_argument('--no-selenium',
+                        dest='selenium', default=True, action='store_false',
+                        help='Some tests use Selenium to test the UI of the site, optionally skip these')
+    options = parser.parse_args()
+
+    os.environ['slow_tests'] = '0' if options.fast_tests else '1'
+    os.environ['web_server'] = '1' if options.web_server else '0'
+    os.environ['selenium'] = '1' if options.selenium else '0'
+    os.environ[CQLENG_ALLOW_SCHEMA_MANAGEMENT] = '1'
+
+    root = os.path.dirname(os.path.abspath(__file__))
+
+    suite = unittest.TestSuite()
+    for module_name in options.modules_to_test:
+        module_suite = unittest.defaultTestLoader.discover(os.path.join(root, module_name.replace('.', '/')), pattern='*unittest.py', top_level_dir=os.path.join(root, '..'))
+        for tst in module_suite if module_suite else []:
+            suite.addTest(tst)
+
+    if suite.countTestCases() == 0:
+        raise RuntimeError('No tests matching...')
+
+    result = unittest.TextTestRunner(verbosity=2 if options.verbose else 1, failfast=options.stop_on_fail, buffer=not options.verbose).run(suite)
+    return len(result.errors)
+
+if __name__ == '__main__':
+    sys.exit(main())
</ins><span class="cx">Property changes on: trunk/Tools/resultsdbpy/resultsdbpy/run-tests
</span><span class="cx">___________________________________________________________________
</span></span></pre></div>
<a id="svnexecutable"></a>
<div class="addfile"><h4>Added: svn:executable</h4></div>
<ins>+*
</ins><span class="cx">\ No newline at end of property
</span><a id="trunkToolsresultsdbpyresultsdbpyview__init__py"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/view/__init__.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/view/__init__.py                             (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/view/__init__.py        2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1 @@
</span><ins>+# DO NOTHING
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpyviewci_viewpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/view/ci_view.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/view/ci_view.py                              (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/view/ci_view.py 2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,101 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from flask import abort, redirect, request
+from resultsdbpy.controller.commit import Commit
+from resultsdbpy.flask_support.util import AssertRequest, query_as_kwargs
+from resultsdbpy.view.site_menu import SiteMenu
+
+
+class CIView(object):
+    def __init__(self, environment, ci_controller, site_menu=None):
+        self.environment = environment
+        self.ci_controller = ci_controller
+        self.site_menu = site_menu
+
+    @query_as_kwargs()
+    def queue(self, **kwargs):
+        queue_urls = self.ci_controller.urls_for_queue(**kwargs)
+
+        candidate = None
+        for element in queue_urls:
+            if not candidate:
+                candidate = element['url']
+            elif candidate != element['url']:
+                abort(404, description='Too many eligible queues')
+
+        if not candidate:
+            abort(404, description='No queue URLs for provided arguments')
+        return redirect(candidate)
+
+    @query_as_kwargs()
+    def worker(self, **kwargs):
+        all_urls = self.ci_controller.urls_for_builds(**kwargs)
+        worker_urls = []
+        for pair in all_urls:
+            urls_for_config = []
+            for urls in pair['urls']:
+                if not urls.get('worker', None):
+                    continue
+                urls_for_config.append(dict(
+                    worker=urls['worker'],
+                    uuid=urls['uuid'],
+                    start_time=urls['start_time'],
+                ))
+            if urls_for_config:
+                worker_urls.append(dict(configuration=dict(pair['configuration']), urls=urls_for_config))
+
+        if len(worker_urls) == 0:
+            abort(404, description='No worker URLs for provided arguments')
+        if len(worker_urls) == 1 and len(worker_urls[0]['urls']) == 1:
+            return redirect(worker_urls[0]['urls'][0]['worker'])
+
+        abort(404, description='Too many eligible workers')
+
+    @query_as_kwargs()
+    def build(self, **kwargs):
+        all_urls = self.ci_controller.urls_for_builds(**kwargs)
+        worker_urls = []
+        for pair in all_urls:
+            urls_for_config = []
+            for urls in pair['urls']:
+                if not urls.get('build', None):
+                    continue
+                urls_for_config.append(dict(
+                    build=urls['build'],
+                    uuid=urls['uuid'],
+                    start_time=urls['start_time'],
+                ))
+            if urls_for_config:
+                worker_urls.append(dict(configuration=dict(pair['configuration']), urls=urls_for_config))
+
+        candidate = None
+        for element in worker_urls:
+            for build in element['urls']:
+                if not candidate:
+                    candidate = build['build']
+                elif candidate != build['build']:
+                    abort(404, description='Too many eligible builds')
+
+        if not candidate:
+            abort(404, description='No queue URLs for provided arguments')
+        return redirect(candidate)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpyviewcommit_viewpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/view/commit_view.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/view/commit_view.py                          (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/view/commit_view.py     2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,94 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from flask import abort, redirect, request
+from resultsdbpy.controller.commit import Commit
+from resultsdbpy.flask_support.util import AssertRequest, query_as_kwargs
+from resultsdbpy.view.site_menu import SiteMenu
+
+
+class CommitView(object):
+    def __init__(self, environment, commit_controller, site_menu=None):
+        self.environment = environment
+        self.commit_context = commit_controller.commit_context
+        self.commit_controller = commit_controller
+        self.site_menu = site_menu
+
+    @query_as_kwargs()
+    def _single_commit(self, limit=None, **kwargs):
+        AssertRequest.is_type()
+        AssertRequest.query_kwargs_empty(limit=limit)
+
+        with self.commit_context:
+            commits = self.commit_controller._find(**kwargs)
+            if not commits:
+                abort(404, description='No commits found matching the specified criteria')
+            if len(commits) > 1:
+                abort(404, description=f'{len(commits)} commits found matching the specified criteria')
+            return commits[0]
+
+    @SiteMenu.render_with_site_menu()
+    def commit(self, **kwargs):
+        with self.commit_context:
+            commit = self._single_commit()
+            repositories = list(self.commit_context.repositories.keys())
+            repositories.remove(commit.repository_id)
+            siblings = self.commit_context.sibling_commits(commit, repositories)
+        repositories = [commit.repository_id] + sorted([str(key) for key, lst in siblings.items() if lst])
+
+        return self.environment.get_template('commit.html').render(
+            title=self.site_menu.title + ': ' + str(commit.id),
+            commit=commit,
+            repository_ids=repositories,
+            commits=Commit.Encoder().encode([commit] + [item for lst in siblings.values() for item in lst]),
+            **kwargs)
+
+    def info(self):
+        commit = self._single_commit()
+        info_url = self.commit_context.repositories[commit.repository_id].url_for_commit(commit.id)
+        if not info_url:
+            abort(410, description=f'Found commit {len(commit.id)}, but no info url found')
+        return redirect(info_url)
+
+    def previous(self):
+        with self.commit_context:
+            original_commit = self._single_commit()
+            commit = self.commit_context.previous_commit(original_commit)
+            if not commit:
+                abort(404, description=f'{original_commit.id} has no registered previous commit')
+        return redirect(f'/commit?repository_id={commit.repository_id}&id={commit.id}')
+
+    def next(self):
+        with self.commit_context:
+            original_commit = self._single_commit()
+            commit = self.commit_context.next_commit(original_commit)
+            if not commit:
+                abort(404, description=f'{original_commit.id} has no registered subsequent commit')
+            return redirect(f'/commit?repository_id={commit.repository_id}&id={commit.id}')
+
+    @SiteMenu.render_with_site_menu()
+    def commits(self, **kwargs):
+
+        return self.environment.get_template('commits.html').render(
+            title=self.site_menu.title + ': Commits',
+            query=request.query_string,
+            **kwargs)
</ins></span></pre></div>
<a id="trunkToolsresultsdbpyresultsdbpyviewcommit_view_unittestpy"></a>
<div class="addfile"><h4>Added: trunk/Tools/resultsdbpy/resultsdbpy/view/commit_view_unittest.py (0 => 247628)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Tools/resultsdbpy/resultsdbpy/view/commit_view_unittest.py                         (rev 0)
+++ trunk/Tools/resultsdbpy/resultsdbpy/view/commit_view_unittest.py    2019-07-19 01:34:38 UTC (rev 247628)
</span><span class="lines">@@ -0,0 +1,256 @@
</span><ins>+# Copyright (C) 2019 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import time
+
+from fakeredis import FakeStrictRedis
+from resultsdbpy.controller.commit import Commit
+from resultsdbpy.model.mock_cassandra_context import MockCassandraContext
+from resultsdbpy.model.mock_repository import MockStashRepository, MockSVNRepository
+from resultsdbpy.model.wait_for_docker_test_case import WaitForDockerTestCase
+from resultsdbpy.view.view_routes_unittest import WebSiteTestCase
+from selenium.webdriver.support.select import Select
+
+
+class CommitViewUnittest(WebSiteTestCase):
+    def register_all_commits(self, client):
+        for repo in [MockStashRepository.safari(), MockSVNRepository.webkit()]:
+            for commits in repo.commits.values():
+                for commit in commits:
+                    self.assertEqual(200, client.post(self.URL + '/api/commits/register', data=Commit.Encoder().default(commit)).status_code)
+
+    def unpack_commit_table(self, commit_table):
+        headers = commit_table.find_elements_by_tag_name('th')
+        repos = [header.text for header in headers if header.text]
+        indecies = [1 for _ in range(len(headers))]
+        commits = {repo: [] for repo in repos}
+
+        rows = commit_table.find_element_by_tag_name('tbody').find_elements_by_tag_name('tr')
+        for row in rows:
+            indecies = [i - 1 for i in indecies]
+            cells = row.find_elements_by_tag_name('td')
+            i = 0
+            for cell in cells:
+                while i < len(indecies) and indecies[i] != 0:
+                    i += 1
+                self.assertLess(i, len(indecies))
+                indecies[i] = int(cell.get_attribute('rowspan') or 1)
+                if i == 0 or not cell.text:
+                    continue
+                commits[repos[i - 1]].append(cell)
+        return commits
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @WebSiteTestCase.decorator()
+    def test_drawer(self, driver, **kwargs):
+        driver.get(self.URL + '/commits')
+        time.sleep(.2)
+        self.assertNotIn('display', driver.find_element_by_class_name('drawer').get_attribute('class'))
+
+        self.toggle_drawer(driver, assert_displayed=True)
+        self.toggle_drawer(driver, assert_displayed=False)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @WebSiteTestCase.decorator()
+    def test_commit_table(self, driver, client, **kwargs):
+        self.register_all_commits(client)
+        driver.get(self.URL + '/commits')
+
+        while not driver.find_elements_by_class_name('commit-table'):
+            time.sleep(.1)
+        commit_table = driver.find_element_by_class_name('commit-table')
+
+        commits = self.unpack_commit_table(commit_table)
+        self.assertEqual(2, len(commits.keys()))
+        self.assertIn('safari', commits)
+        self.assertIn('webkit', commits)
+
+        self.assertEqual(5, len(commits['safari']))
+        self.assertEqual(5, len(commits['webkit']))
+
+        rows = commit_table.find_element_by_tag_name('tbody').find_elements_by_tag_name('tr')
+        self.assertNotEqual(
+            rows[1].find_elements_by_tag_name('td')[0].text,
+            rows[2].find_elements_by_tag_name('td')[0].text,
+        )
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @WebSiteTestCase.decorator()
+    def test_commit(self, driver, client, **kwargs):
+        self.register_all_commits(client)
+        driver.get(self.URL + '/commits')
+
+        while not driver.find_elements_by_class_name('commit-table'):
+            time.sleep(.1)
+        commit_table = driver.find_element_by_class_name('commit-table')
+
+        commits = self.unpack_commit_table(commit_table)
+        links = commits['safari'][0].find_elements_by_tag_name('a')
+        for link in links:
+            if link.text != 'More Info':
+                link.click()
+                break
+
+        time.sleep(.5)
+
+        while not driver.find_elements_by_class_name('commit-table'):
+            time.sleep(.1)
+        commit_table = driver.find_element_by_class_name('commit-table')
+
+        commits = self.unpack_commit_table(commit_table)
+        self.assertEqual(2, len(commits.keys()))
+        self.assertIn('safari', commits)
+        self.assertIn('webkit', commits)
+
+        self.assertEqual(1, len(commits['safari']))
+        self.assertEqual(5, len(commits['webkit']))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @WebSiteTestCase.decorator()
+    def test_radar_strings(self, driver, client, **kwargs):
+        self.register_all_commits(client)
+        driver.get(self.URL + '/commits?id=336610a4')
+
+        while not driver.find_elements_by_class_name('commit-table'):
+            time.sleep(.1)
+        commit_table = driver.find_element_by_class_name('commit-table')
+
+        commits = self.unpack_commit_table(commit_table)
+        changelog = commits['safari'][0].find_element_by_tag_name('div')
+
+        radar = '<rdar://problem/99999999>'
+        self.assertEqual(changelog.text[:len(radar)], radar)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @WebSiteTestCase.decorator()
+    def test_range_slider(self, driver, client, **kwargs):
+        self.register_all_commits(client)
+        driver.get(self.URL + '/commits')
+
+        while not driver.find_elements_by_class_name('commit-table'):
+            time.sleep(.1)
+        commit_table = driver.find_element_by_class_name('commit-table')
+
+        commits = self.unpack_commit_table(commit_table)
+        self.assertEqual(2, len(commits.keys()))
+        self.assertIn('safari', commits)
+        self.assertIn('webkit', commits)
+
+        self.assertEqual(5, len(commits['safari']))
+        self.assertEqual(5, len(commits['webkit']))
+
+        self.toggle_drawer(driver, assert_displayed=True)
+
+        controls = self.find_input_with_name(driver, 'Limit:').find_elements_by_tag_name('input')
+        self.assertEqual(3, len(controls))
+        input = [control for control in controls if control.get_attribute('type') == 'number'][0]
+        self.assertIsNotNone(input)
+
+        input.clear()
+        input.send_keys('3')
+
+        self.toggle_drawer(driver, assert_displayed=False)
+
+        while not driver.find_elements_by_class_name('commit-table'):
+            time.sleep(.1)
+        commit_table = driver.find_element_by_class_name('commit-table')
+
+        commits = self.unpack_commit_table(commit_table)
+        self.assertEqual(2, len(commits.keys()))
+        self.assertIn('safari', commits)
+        self.assertIn('webkit', commits)
+
+        self.assertEqual(3, len(commits['safari']))
+        self.assertEqual(3, len(commits['webkit']))
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @WebSiteTestCase.decorator()
+    def test_one_line_switch(self, driver, client, **kwargs):
+        self.register_all_commits(client)
+        driver.get(self.URL + '/commits?id=7be40842')
+
+        while not driver.find_elements_by_class_name('commit-table'):
+            time.sleep(.1)
+        commit_table = driver.find_element_by_class_name('commit-table')
+
+        line_1 = u'Change 4 \u2014 (Part 2) description.'
+        line_2 = 'Reviewed by person.'
+
+        commits = self.unpack_commit_table(commit_table)
+        changelog = commits['safari'][0].find_element_by_tag_name('div')
+        self.assertEqual(line_1, changelog.text)
+
+        self.toggle_drawer(driver, assert_displayed=True)
+
+        controls = self.find_input_with_name(driver, 'One-line:').find_elements_by_tag_name('span')
+        self.assertEqual(1, len(controls))
+        input = [control for control in controls if control.get_attribute('class') == 'slider'][0]
+        self.assertIsNotNone(input)
+        input.click()
+
+        self.toggle_drawer(driver, assert_displayed=False)
+
+        while not driver.find_elements_by_class_name('commit-table'):
+            time.sleep(.1)
+        commit_table = driver.find_element_by_class_name('commit-table')
+
+        commits = self.unpack_commit_table(commit_table)
+        changelog = commits['safari'][0].find_element_by_tag_name('div')
+        self.assertEqual(line_1 + line_2, changelog.text)
+
+    @WaitForDockerTestCase.mock_if_no_docker(mock_redis=FakeStrictRedis, mock_cassandra=MockCassandraContext)
+    @WebSiteTestCase.decorator()
+    def test_branch_selection(self, driver, client, **kwargs):
+        self.register_all_commits(client)
+        driver.get(self.URL + '/commits')
+
+        while not driver.find_elements_by_class_name('commit-table'):
+            time.sleep(.1)
+        commit_table = driver.find_element_by_class_name('commit-table')
+
+        commits = self.unpack_commit_table(commit_table)
+        self.assertEqual(2, len(commits.keys()))
+        self.assertIn('safari', commits)
+        self.assertIn('webkit', commits)
+
+        self.assertEqual(5, len(commits['safari']))
+        self.assertEqual(5, len(commits['webkit']))
+
+        self.toggle_drawer(driver, assert_displayed=True)
+
+        controls = self.find_input_with_name(driver, 'Branch').find_elements_by_tag_name('select')
+        self.assertEqual(1, len(controls))
+        Select(controls[0]).select_by_visible_text('safari-606-branch')
+
+        self.toggle_drawer(driver, assert_displayed=False)
+
+        while not driver.find_elements_by_class_name('commit-table'):
+            time.sleep(.1)