blob: 19ae52ef3b837a2af3399ef87eb636aef2b16a96 [file] [log] [blame]
Nicolas Nobleddef2462015-01-06 18:08:25 -08001"""Run a group of subprocesses and then finish."""
2
Craig Tiller71735182015-01-15 17:07:13 -08003import hashlib
Nicolas Nobleddef2462015-01-06 18:08:25 -08004import multiprocessing
Craig Tiller71735182015-01-15 17:07:13 -08005import os
Nicolas Nobleddef2462015-01-06 18:08:25 -08006import random
7import subprocess
8import sys
ctiller3040cb72015-01-07 12:13:17 -08009import tempfile
10import time
Nicolas Nobleddef2462015-01-06 18:08:25 -080011
ctiller3040cb72015-01-07 12:13:17 -080012
ctiller94e5dde2015-01-09 10:41:59 -080013_DEFAULT_MAX_JOBS = 16 * multiprocessing.cpu_count()
Nicolas Nobleddef2462015-01-06 18:08:25 -080014
15
16def shuffle_iteratable(it):
17 """Return an iterable that randomly walks it"""
18 # take a random sampling from the passed in iterable
19 # we take an element with probablity 1/p and rapidly increase
20 # p as we take elements - this gives us a somewhat random set of values before
21 # we've seen all the values, but starts producing values without having to
22 # compute ALL of them at once, allowing tests to start a little earlier
23 nextit = []
24 p = 1
25 for val in it:
26 if random.randint(0, p) == 0:
ctiller3040cb72015-01-07 12:13:17 -080027 p = min(p*2, 100)
Nicolas Nobleddef2462015-01-06 18:08:25 -080028 yield val
29 else:
30 nextit.append(val)
31 # after taking a random sampling, we shuffle the rest of the elements and
32 # yield them
33 random.shuffle(nextit)
34 for val in nextit:
35 yield val
36
37
ctiller3040cb72015-01-07 12:13:17 -080038_SUCCESS = object()
39_FAILURE = object()
40_RUNNING = object()
41_KILLED = object()
42
43
Craig Tiller3b083062015-01-12 13:51:28 -080044_COLORS = {
Nicolas Noble044db742015-01-14 16:57:24 -080045 'red': [ 31, 0 ],
46 'green': [ 32, 0 ],
47 'yellow': [ 33, 0 ],
48 'lightgray': [ 37, 0],
49 'gray': [ 30, 1 ],
Craig Tiller3b083062015-01-12 13:51:28 -080050 }
51
52
53_BEGINNING_OF_LINE = '\x1b[0G'
54_CLEAR_LINE = '\x1b[2K'
55
56
57_TAG_COLOR = {
58 'FAILED': 'red',
59 'PASSED': 'green',
Nicolas Noble044db742015-01-14 16:57:24 -080060 'START': 'gray',
Craig Tiller3b083062015-01-12 13:51:28 -080061 'WAITING': 'yellow',
Nicolas Noble044db742015-01-14 16:57:24 -080062 'SUCCESS': 'green',
63 'IDLE': 'gray',
Craig Tiller3b083062015-01-12 13:51:28 -080064 }
65
66
Nicolas Noble044db742015-01-14 16:57:24 -080067def message(tag, message, explanatory_text=None, do_newline=False):
68 sys.stdout.write('%s%s%s\x1b[%d;%dm%s\x1b[0m: %s%s' % (
Craig Tiller3b083062015-01-12 13:51:28 -080069 _BEGINNING_OF_LINE,
70 _CLEAR_LINE,
Nicolas Noble044db742015-01-14 16:57:24 -080071 '\n%s' % explanatory_text if explanatory_text is not None else '',
72 _COLORS[_TAG_COLOR[tag]][1],
73 _COLORS[_TAG_COLOR[tag]][0],
Craig Tiller3b083062015-01-12 13:51:28 -080074 tag,
75 message,
Nicolas Noble044db742015-01-14 16:57:24 -080076 '\n' if do_newline or explanatory_text is not None else ''))
Craig Tiller3b083062015-01-12 13:51:28 -080077 sys.stdout.flush()
78
79
Craig Tiller71735182015-01-15 17:07:13 -080080def which(filename):
81 if '/' in filename:
82 return filename
83 for path in os.environ['PATH'].split(os.pathsep):
84 if os.path.exists(os.path.join(path, filename)):
85 return os.path.join(path, filename)
86 raise Exception('%s not found' % filename)
87
88
Craig Tiller547db2b2015-01-30 14:08:39 -080089class JobSpec(object):
90 """Specifies what to run for a job."""
91
92 def __init__(self, cmdline, shortname=None, environ={}, hash_targets=[]):
93 """
94 Arguments:
95 cmdline: a list of arguments to pass as the command line
96 environ: a dictionary of environment variables to set in the child process
97 hash_targets: which files to include in the hash representing the jobs version
98 (or empty, indicating the job should not be hashed)
99 """
100 self.cmdline = cmdline
101 self.environ = environ
102 self.shortname = cmdline[0] if shortname is None else shortname
103 self.hash_targets = hash_targets or []
104
105 def identity(self):
106 return '%r %r %r' % (self.cmdline, self.environ, self.hash_targets)
107
108 def __hash__(self):
109 return hash(self.identity())
110
111 def __cmp__(self, other):
112 return self.identity() == other.identity()
113
114
ctiller3040cb72015-01-07 12:13:17 -0800115class Job(object):
116 """Manages one job."""
117
Craig Tiller547db2b2015-01-30 14:08:39 -0800118 def __init__(self, spec, bin_hash, newline_on_success):
119 self._spec = spec
Craig Tiller71735182015-01-15 17:07:13 -0800120 self._bin_hash = bin_hash
ctiller3040cb72015-01-07 12:13:17 -0800121 self._tempfile = tempfile.TemporaryFile()
Craig Tiller547db2b2015-01-30 14:08:39 -0800122 env = os.environ.copy()
123 for k, v in spec.environ.iteritems():
124 env[k] = v
125 self._process = subprocess.Popen(args=spec.cmdline,
ctiller3040cb72015-01-07 12:13:17 -0800126 stderr=subprocess.STDOUT,
Craig Tiller547db2b2015-01-30 14:08:39 -0800127 stdout=self._tempfile,
128 env=env)
ctiller3040cb72015-01-07 12:13:17 -0800129 self._state = _RUNNING
Nicolas Noble044db742015-01-14 16:57:24 -0800130 self._newline_on_success = newline_on_success
Craig Tiller547db2b2015-01-30 14:08:39 -0800131 message('START', spec.shortname)
ctiller3040cb72015-01-07 12:13:17 -0800132
Craig Tiller71735182015-01-15 17:07:13 -0800133 def state(self, update_cache):
ctiller3040cb72015-01-07 12:13:17 -0800134 """Poll current state of the job. Prints messages at completion."""
135 if self._state == _RUNNING and self._process.poll() is not None:
136 if self._process.returncode != 0:
137 self._state = _FAILURE
138 self._tempfile.seek(0)
139 stdout = self._tempfile.read()
Craig Tiller71735182015-01-15 17:07:13 -0800140 message('FAILED', '%s [ret=%d]' % (
Craig Tiller547db2b2015-01-30 14:08:39 -0800141 self._spec.shortname, self._process.returncode), stdout)
ctiller3040cb72015-01-07 12:13:17 -0800142 else:
143 self._state = _SUCCESS
Craig Tiller547db2b2015-01-30 14:08:39 -0800144 message('PASSED', self._spec.shortname,
Craig Tiller71735182015-01-15 17:07:13 -0800145 do_newline=self._newline_on_success)
Craig Tiller547db2b2015-01-30 14:08:39 -0800146 if self._bin_hash:
147 update_cache.finished(self._spec.identity(), self._bin_hash)
ctiller3040cb72015-01-07 12:13:17 -0800148 return self._state
149
150 def kill(self):
151 if self._state == _RUNNING:
152 self._state = _KILLED
153 self._process.terminate()
154
155
Nicolas Nobleddef2462015-01-06 18:08:25 -0800156class Jobset(object):
157 """Manages one run of jobs."""
158
Craig Tiller71735182015-01-15 17:07:13 -0800159 def __init__(self, check_cancelled, maxjobs, newline_on_success, cache):
ctiller3040cb72015-01-07 12:13:17 -0800160 self._running = set()
161 self._check_cancelled = check_cancelled
162 self._cancelled = False
Nicolas Nobleddef2462015-01-06 18:08:25 -0800163 self._failures = 0
Craig Tiller738c3342015-01-12 14:28:33 -0800164 self._completed = 0
ctiller94e5dde2015-01-09 10:41:59 -0800165 self._maxjobs = maxjobs
Nicolas Noble044db742015-01-14 16:57:24 -0800166 self._newline_on_success = newline_on_success
Craig Tiller71735182015-01-15 17:07:13 -0800167 self._cache = cache
Nicolas Nobleddef2462015-01-06 18:08:25 -0800168
Craig Tiller547db2b2015-01-30 14:08:39 -0800169 def start(self, spec):
ctiller3040cb72015-01-07 12:13:17 -0800170 """Start a job. Return True on success, False on failure."""
ctiller94e5dde2015-01-09 10:41:59 -0800171 while len(self._running) >= self._maxjobs:
ctiller3040cb72015-01-07 12:13:17 -0800172 if self.cancelled(): return False
173 self.reap()
174 if self.cancelled(): return False
Craig Tiller547db2b2015-01-30 14:08:39 -0800175 if spec.hash_targets:
176 bin_hash = hashlib.sha1()
177 for fn in spec.hash_targets:
178 with open(which(fn)) as f:
179 bin_hash.update(f.read())
180 bin_hash = bin_hash.hexdigest()
181 should_run = self._cache.should_run(spec.identity(), bin_hash)
182 else:
183 bin_hash = None
184 should_run = True
185 if should_run:
186 self._running.add(Job(spec,
187 bin_hash,
188 self._newline_on_success))
ctiller3040cb72015-01-07 12:13:17 -0800189 return True
Nicolas Nobleddef2462015-01-06 18:08:25 -0800190
ctiller3040cb72015-01-07 12:13:17 -0800191 def reap(self):
192 """Collect the dead jobs."""
193 while self._running:
194 dead = set()
195 for job in self._running:
Craig Tiller71735182015-01-15 17:07:13 -0800196 st = job.state(self._cache)
ctiller3040cb72015-01-07 12:13:17 -0800197 if st == _RUNNING: continue
198 if st == _FAILURE: self._failures += 1
199 dead.add(job)
200 for job in dead:
Craig Tiller738c3342015-01-12 14:28:33 -0800201 self._completed += 1
ctiller3040cb72015-01-07 12:13:17 -0800202 self._running.remove(job)
Craig Tiller3b083062015-01-12 13:51:28 -0800203 if dead: return
Craig Tiller6f5e2c42015-01-21 18:05:31 -0800204 message('WAITING', '%d jobs running, %d complete, %d failed' % (
205 len(self._running), self._completed, self._failures))
ctiller3040cb72015-01-07 12:13:17 -0800206 time.sleep(0.1)
207
208 def cancelled(self):
209 """Poll for cancellation."""
210 if self._cancelled: return True
211 if not self._check_cancelled(): return False
212 for job in self._running:
213 job.kill()
214 self._cancelled = True
215 return True
216
217 def finish(self):
218 while self._running:
219 if self.cancelled(): pass # poll cancellation
220 self.reap()
221 return not self.cancelled() and self._failures == 0
Nicolas Nobleddef2462015-01-06 18:08:25 -0800222
223
ctiller3040cb72015-01-07 12:13:17 -0800224def _never_cancelled():
225 return False
226
227
Craig Tiller71735182015-01-15 17:07:13 -0800228# cache class that caches nothing
229class NoCache(object):
230 def should_run(self, cmdline, bin_hash):
231 return True
232
233 def finished(self, cmdline, bin_hash):
234 pass
235
236
Nicolas Nobleb09078f2015-01-14 18:06:05 -0800237def run(cmdlines,
238 check_cancelled=_never_cancelled,
239 maxjobs=None,
Craig Tiller71735182015-01-15 17:07:13 -0800240 newline_on_success=False,
241 cache=None):
ctiller94e5dde2015-01-09 10:41:59 -0800242 js = Jobset(check_cancelled,
Nicolas Noble044db742015-01-14 16:57:24 -0800243 maxjobs if maxjobs is not None else _DEFAULT_MAX_JOBS,
Craig Tiller71735182015-01-15 17:07:13 -0800244 newline_on_success,
245 cache if cache is not None else NoCache())
ctiller3040cb72015-01-07 12:13:17 -0800246 for cmdline in shuffle_iteratable(cmdlines):
247 if not js.start(cmdline):
248 break
249 return js.finish()