blob: df83b30516bce44c8dcea425c64232bec9ff2136 [file] [log] [blame]
Craig Tillerc2c79212015-02-16 12:00:01 -08001# Copyright 2015, Google Inc.
2# All rights reserved.
3#
4# Redistribution and use in source and binary forms, with or without
5# modification, are permitted provided that the following conditions are
6# met:
7#
8# * Redistributions of source code must retain the above copyright
9# notice, this list of conditions and the following disclaimer.
10# * Redistributions in binary form must reproduce the above
11# copyright notice, this list of conditions and the following disclaimer
12# in the documentation and/or other materials provided with the
13# distribution.
14# * Neither the name of Google Inc. nor the names of its
15# contributors may be used to endorse or promote products derived from
16# this software without specific prior written permission.
17#
18# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29
Nicolas Nobleddef2462015-01-06 18:08:25 -080030"""Run a group of subprocesses and then finish."""
31
Craig Tiller71735182015-01-15 17:07:13 -080032import hashlib
Nicolas Nobleddef2462015-01-06 18:08:25 -080033import multiprocessing
Craig Tiller71735182015-01-15 17:07:13 -080034import os
Nicolas Nobleddef2462015-01-06 18:08:25 -080035import random
36import subprocess
37import sys
ctiller3040cb72015-01-07 12:13:17 -080038import tempfile
39import time
Nicolas Nobleddef2462015-01-06 18:08:25 -080040
ctiller3040cb72015-01-07 12:13:17 -080041
ctiller94e5dde2015-01-09 10:41:59 -080042_DEFAULT_MAX_JOBS = 16 * multiprocessing.cpu_count()
Nicolas Nobleddef2462015-01-06 18:08:25 -080043
44
45def shuffle_iteratable(it):
46 """Return an iterable that randomly walks it"""
47 # take a random sampling from the passed in iterable
48 # we take an element with probablity 1/p and rapidly increase
49 # p as we take elements - this gives us a somewhat random set of values before
50 # we've seen all the values, but starts producing values without having to
51 # compute ALL of them at once, allowing tests to start a little earlier
52 nextit = []
53 p = 1
54 for val in it:
55 if random.randint(0, p) == 0:
ctiller3040cb72015-01-07 12:13:17 -080056 p = min(p*2, 100)
Nicolas Nobleddef2462015-01-06 18:08:25 -080057 yield val
58 else:
59 nextit.append(val)
60 # after taking a random sampling, we shuffle the rest of the elements and
61 # yield them
62 random.shuffle(nextit)
63 for val in nextit:
64 yield val
65
66
ctiller3040cb72015-01-07 12:13:17 -080067_SUCCESS = object()
68_FAILURE = object()
69_RUNNING = object()
70_KILLED = object()
71
72
Craig Tiller3b083062015-01-12 13:51:28 -080073_COLORS = {
Nicolas Noble044db742015-01-14 16:57:24 -080074 'red': [ 31, 0 ],
75 'green': [ 32, 0 ],
76 'yellow': [ 33, 0 ],
77 'lightgray': [ 37, 0],
78 'gray': [ 30, 1 ],
Craig Tiller3b083062015-01-12 13:51:28 -080079 }
80
81
82_BEGINNING_OF_LINE = '\x1b[0G'
83_CLEAR_LINE = '\x1b[2K'
84
85
86_TAG_COLOR = {
87 'FAILED': 'red',
88 'PASSED': 'green',
Nicolas Noble044db742015-01-14 16:57:24 -080089 'START': 'gray',
Craig Tiller3b083062015-01-12 13:51:28 -080090 'WAITING': 'yellow',
Nicolas Noble044db742015-01-14 16:57:24 -080091 'SUCCESS': 'green',
92 'IDLE': 'gray',
Craig Tiller3b083062015-01-12 13:51:28 -080093 }
94
95
Nicolas Noble044db742015-01-14 16:57:24 -080096def message(tag, message, explanatory_text=None, do_newline=False):
97 sys.stdout.write('%s%s%s\x1b[%d;%dm%s\x1b[0m: %s%s' % (
Craig Tiller3b083062015-01-12 13:51:28 -080098 _BEGINNING_OF_LINE,
99 _CLEAR_LINE,
Nicolas Noble044db742015-01-14 16:57:24 -0800100 '\n%s' % explanatory_text if explanatory_text is not None else '',
101 _COLORS[_TAG_COLOR[tag]][1],
102 _COLORS[_TAG_COLOR[tag]][0],
Craig Tiller3b083062015-01-12 13:51:28 -0800103 tag,
104 message,
Nicolas Noble044db742015-01-14 16:57:24 -0800105 '\n' if do_newline or explanatory_text is not None else ''))
Craig Tiller3b083062015-01-12 13:51:28 -0800106 sys.stdout.flush()
107
108
Craig Tiller71735182015-01-15 17:07:13 -0800109def which(filename):
110 if '/' in filename:
111 return filename
112 for path in os.environ['PATH'].split(os.pathsep):
113 if os.path.exists(os.path.join(path, filename)):
114 return os.path.join(path, filename)
115 raise Exception('%s not found' % filename)
116
117
Craig Tiller547db2b2015-01-30 14:08:39 -0800118class JobSpec(object):
119 """Specifies what to run for a job."""
120
121 def __init__(self, cmdline, shortname=None, environ={}, hash_targets=[]):
122 """
123 Arguments:
124 cmdline: a list of arguments to pass as the command line
125 environ: a dictionary of environment variables to set in the child process
126 hash_targets: which files to include in the hash representing the jobs version
127 (or empty, indicating the job should not be hashed)
128 """
129 self.cmdline = cmdline
130 self.environ = environ
131 self.shortname = cmdline[0] if shortname is None else shortname
132 self.hash_targets = hash_targets or []
133
134 def identity(self):
135 return '%r %r %r' % (self.cmdline, self.environ, self.hash_targets)
136
137 def __hash__(self):
138 return hash(self.identity())
139
140 def __cmp__(self, other):
141 return self.identity() == other.identity()
142
143
ctiller3040cb72015-01-07 12:13:17 -0800144class Job(object):
145 """Manages one job."""
146
Craig Tiller547db2b2015-01-30 14:08:39 -0800147 def __init__(self, spec, bin_hash, newline_on_success):
148 self._spec = spec
Craig Tiller71735182015-01-15 17:07:13 -0800149 self._bin_hash = bin_hash
ctiller3040cb72015-01-07 12:13:17 -0800150 self._tempfile = tempfile.TemporaryFile()
Craig Tiller547db2b2015-01-30 14:08:39 -0800151 env = os.environ.copy()
152 for k, v in spec.environ.iteritems():
153 env[k] = v
154 self._process = subprocess.Popen(args=spec.cmdline,
ctiller3040cb72015-01-07 12:13:17 -0800155 stderr=subprocess.STDOUT,
Craig Tiller547db2b2015-01-30 14:08:39 -0800156 stdout=self._tempfile,
157 env=env)
ctiller3040cb72015-01-07 12:13:17 -0800158 self._state = _RUNNING
Nicolas Noble044db742015-01-14 16:57:24 -0800159 self._newline_on_success = newline_on_success
Craig Tiller547db2b2015-01-30 14:08:39 -0800160 message('START', spec.shortname)
ctiller3040cb72015-01-07 12:13:17 -0800161
Craig Tiller71735182015-01-15 17:07:13 -0800162 def state(self, update_cache):
ctiller3040cb72015-01-07 12:13:17 -0800163 """Poll current state of the job. Prints messages at completion."""
164 if self._state == _RUNNING and self._process.poll() is not None:
165 if self._process.returncode != 0:
166 self._state = _FAILURE
167 self._tempfile.seek(0)
168 stdout = self._tempfile.read()
Craig Tiller71735182015-01-15 17:07:13 -0800169 message('FAILED', '%s [ret=%d]' % (
Craig Tiller547db2b2015-01-30 14:08:39 -0800170 self._spec.shortname, self._process.returncode), stdout)
ctiller3040cb72015-01-07 12:13:17 -0800171 else:
172 self._state = _SUCCESS
Craig Tiller547db2b2015-01-30 14:08:39 -0800173 message('PASSED', self._spec.shortname,
Craig Tiller71735182015-01-15 17:07:13 -0800174 do_newline=self._newline_on_success)
Craig Tiller547db2b2015-01-30 14:08:39 -0800175 if self._bin_hash:
176 update_cache.finished(self._spec.identity(), self._bin_hash)
ctiller3040cb72015-01-07 12:13:17 -0800177 return self._state
178
179 def kill(self):
180 if self._state == _RUNNING:
181 self._state = _KILLED
182 self._process.terminate()
183
184
Nicolas Nobleddef2462015-01-06 18:08:25 -0800185class Jobset(object):
186 """Manages one run of jobs."""
187
Craig Tiller71735182015-01-15 17:07:13 -0800188 def __init__(self, check_cancelled, maxjobs, newline_on_success, cache):
ctiller3040cb72015-01-07 12:13:17 -0800189 self._running = set()
190 self._check_cancelled = check_cancelled
191 self._cancelled = False
Nicolas Nobleddef2462015-01-06 18:08:25 -0800192 self._failures = 0
Craig Tiller738c3342015-01-12 14:28:33 -0800193 self._completed = 0
ctiller94e5dde2015-01-09 10:41:59 -0800194 self._maxjobs = maxjobs
Nicolas Noble044db742015-01-14 16:57:24 -0800195 self._newline_on_success = newline_on_success
Craig Tiller71735182015-01-15 17:07:13 -0800196 self._cache = cache
Nicolas Nobleddef2462015-01-06 18:08:25 -0800197
Craig Tiller547db2b2015-01-30 14:08:39 -0800198 def start(self, spec):
ctiller3040cb72015-01-07 12:13:17 -0800199 """Start a job. Return True on success, False on failure."""
ctiller94e5dde2015-01-09 10:41:59 -0800200 while len(self._running) >= self._maxjobs:
ctiller3040cb72015-01-07 12:13:17 -0800201 if self.cancelled(): return False
202 self.reap()
203 if self.cancelled(): return False
Craig Tiller547db2b2015-01-30 14:08:39 -0800204 if spec.hash_targets:
205 bin_hash = hashlib.sha1()
206 for fn in spec.hash_targets:
207 with open(which(fn)) as f:
208 bin_hash.update(f.read())
209 bin_hash = bin_hash.hexdigest()
210 should_run = self._cache.should_run(spec.identity(), bin_hash)
211 else:
212 bin_hash = None
213 should_run = True
214 if should_run:
215 self._running.add(Job(spec,
216 bin_hash,
217 self._newline_on_success))
ctiller3040cb72015-01-07 12:13:17 -0800218 return True
Nicolas Nobleddef2462015-01-06 18:08:25 -0800219
ctiller3040cb72015-01-07 12:13:17 -0800220 def reap(self):
221 """Collect the dead jobs."""
222 while self._running:
223 dead = set()
224 for job in self._running:
Craig Tiller71735182015-01-15 17:07:13 -0800225 st = job.state(self._cache)
ctiller3040cb72015-01-07 12:13:17 -0800226 if st == _RUNNING: continue
227 if st == _FAILURE: self._failures += 1
228 dead.add(job)
229 for job in dead:
Craig Tiller738c3342015-01-12 14:28:33 -0800230 self._completed += 1
ctiller3040cb72015-01-07 12:13:17 -0800231 self._running.remove(job)
Craig Tiller3b083062015-01-12 13:51:28 -0800232 if dead: return
Craig Tiller6f5e2c42015-01-21 18:05:31 -0800233 message('WAITING', '%d jobs running, %d complete, %d failed' % (
234 len(self._running), self._completed, self._failures))
ctiller3040cb72015-01-07 12:13:17 -0800235 time.sleep(0.1)
236
237 def cancelled(self):
238 """Poll for cancellation."""
239 if self._cancelled: return True
240 if not self._check_cancelled(): return False
241 for job in self._running:
242 job.kill()
243 self._cancelled = True
244 return True
245
246 def finish(self):
247 while self._running:
248 if self.cancelled(): pass # poll cancellation
249 self.reap()
250 return not self.cancelled() and self._failures == 0
Nicolas Nobleddef2462015-01-06 18:08:25 -0800251
252
ctiller3040cb72015-01-07 12:13:17 -0800253def _never_cancelled():
254 return False
255
256
Craig Tiller71735182015-01-15 17:07:13 -0800257# cache class that caches nothing
258class NoCache(object):
259 def should_run(self, cmdline, bin_hash):
260 return True
261
262 def finished(self, cmdline, bin_hash):
263 pass
264
265
Nicolas Nobleb09078f2015-01-14 18:06:05 -0800266def run(cmdlines,
267 check_cancelled=_never_cancelled,
268 maxjobs=None,
Craig Tiller71735182015-01-15 17:07:13 -0800269 newline_on_success=False,
270 cache=None):
ctiller94e5dde2015-01-09 10:41:59 -0800271 js = Jobset(check_cancelled,
Nicolas Noble044db742015-01-14 16:57:24 -0800272 maxjobs if maxjobs is not None else _DEFAULT_MAX_JOBS,
Craig Tiller71735182015-01-15 17:07:13 -0800273 newline_on_success,
274 cache if cache is not None else NoCache())
ctiller3040cb72015-01-07 12:13:17 -0800275 for cmdline in shuffle_iteratable(cmdlines):
276 if not js.start(cmdline):
277 break
278 return js.finish()