jack2 codebase
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

649 lines
18KB

  1. #! /usr/bin/env python
  2. # encoding: utf-8
  3. # Thomas Nagy, 2019 (ita)
  4. """
  5. Filesystem-based cache system to share and re-use build artifacts
  6. Cache access operations (copy to and from) are delegated to
  7. independent pre-forked worker subprocesses.
  8. The following environment variables may be set:
  9. * WAFCACHE: several possibilities:
  10. - File cache:
  11. absolute path of the waf cache (~/.cache/wafcache_user,
  12. where `user` represents the currently logged-in user)
  13. - URL to a cache server, for example:
  14. export WAFCACHE=http://localhost:8080/files/
  15. in that case, GET/POST requests are made to urls of the form
  16. http://localhost:8080/files/000000000/0 (cache management is delegated to the server)
  17. - GCS, S3 or MINIO bucket
  18. gs://my-bucket/ (uses gsutil command line tool or WAFCACHE_CMD)
  19. s3://my-bucket/ (uses aws command line tool or WAFCACHE_CMD)
  20. minio://my-bucket/ (uses mc command line tool or WAFCACHE_CMD)
  21. * WAFCACHE_CMD: bucket upload/download command, for example:
  22. WAFCACHE_CMD="gsutil cp %{SRC} %{TGT}"
  23. Note that the WAFCACHE bucket value is used for the source or destination
  24. depending on the operation (upload or download). For example, with:
  25. WAFCACHE="gs://mybucket/"
  26. the following commands may be run:
  27. gsutil cp build/myprogram gs://mybucket/aa/aaaaa/1
  28. gsutil cp gs://mybucket/bb/bbbbb/2 build/somefile
  29. * WAFCACHE_NO_PUSH: if set, disables pushing to the cache
  30. * WAFCACHE_VERBOSITY: if set, displays more detailed cache operations
  31. * WAFCACHE_STATS: if set, displays cache usage statistics on exit
  32. File cache specific options:
  33. Files are copied using hard links by default; if the cache is located
  34. onto another partition, the system switches to file copies instead.
  35. * WAFCACHE_TRIM_MAX_FOLDER: maximum amount of tasks to cache (1M)
  36. * WAFCACHE_EVICT_MAX_BYTES: maximum amount of cache size in bytes (10GB)
  37. * WAFCACHE_EVICT_INTERVAL_MINUTES: minimum time interval to try
  38. and trim the cache (3 minutes)
  39. Upload specific options:
  40. * WAFCACHE_ASYNC_WORKERS: define a number of workers to upload results asynchronously
  41. this may improve build performance with many/long file uploads
  42. the default is unset (synchronous uploads)
  43. * WAFCACHE_ASYNC_NOWAIT: do not wait for uploads to complete (default: False)
  44. this requires asynchonous uploads to have an effect
  45. Usage::
  46. def build(bld):
  47. bld.load('wafcache')
  48. ...
  49. To troubleshoot::
  50. waf clean build --zone=wafcache
  51. """
  52. import atexit, base64, errno, fcntl, getpass, os, re, shutil, sys, time, threading, traceback, urllib3, shlex
  53. try:
  54. import subprocess32 as subprocess
  55. except ImportError:
  56. import subprocess
  57. base_cache = os.path.expanduser('~/.cache/')
  58. if not os.path.isdir(base_cache):
  59. base_cache = '/tmp/'
  60. default_wafcache_dir = os.path.join(base_cache, 'wafcache_' + getpass.getuser())
  61. CACHE_DIR = os.environ.get('WAFCACHE', default_wafcache_dir)
  62. WAFCACHE_CMD = os.environ.get('WAFCACHE_CMD')
  63. TRIM_MAX_FOLDERS = int(os.environ.get('WAFCACHE_TRIM_MAX_FOLDER', 1000000))
  64. EVICT_INTERVAL_MINUTES = int(os.environ.get('WAFCACHE_EVICT_INTERVAL_MINUTES', 3))
  65. EVICT_MAX_BYTES = int(os.environ.get('WAFCACHE_EVICT_MAX_BYTES', 10**10))
  66. WAFCACHE_NO_PUSH = 1 if os.environ.get('WAFCACHE_NO_PUSH') else 0
  67. WAFCACHE_VERBOSITY = 1 if os.environ.get('WAFCACHE_VERBOSITY') else 0
  68. WAFCACHE_STATS = 1 if os.environ.get('WAFCACHE_STATS') else 0
  69. WAFCACHE_ASYNC_WORKERS = os.environ.get('WAFCACHE_ASYNC_WORKERS')
  70. WAFCACHE_ASYNC_NOWAIT = os.environ.get('WAFCACHE_ASYNC_NOWAIT')
  71. OK = "ok"
  72. re_waf_cmd = re.compile('(?P<src>%{SRC})|(?P<tgt>%{TGT})')
  73. try:
  74. import cPickle
  75. except ImportError:
  76. import pickle as cPickle
  77. if __name__ != '__main__':
  78. from waflib import Task, Logs, Utils, Build
  79. def can_retrieve_cache(self):
  80. """
  81. New method for waf Task classes
  82. """
  83. if not self.outputs:
  84. return False
  85. self.cached = False
  86. sig = self.signature()
  87. ssig = Utils.to_hex(self.uid() + sig)
  88. if WAFCACHE_STATS:
  89. self.generator.bld.cache_reqs += 1
  90. files_to = [node.abspath() for node in self.outputs]
  91. proc = get_process()
  92. err = cache_command(proc, ssig, [], files_to)
  93. process_pool.append(proc)
  94. if err.startswith(OK):
  95. if WAFCACHE_VERBOSITY:
  96. Logs.pprint('CYAN', ' Fetched %r from cache' % files_to)
  97. else:
  98. Logs.debug('wafcache: fetched %r from cache', files_to)
  99. if WAFCACHE_STATS:
  100. self.generator.bld.cache_hits += 1
  101. else:
  102. if WAFCACHE_VERBOSITY:
  103. Logs.pprint('YELLOW', ' No cache entry %s' % files_to)
  104. else:
  105. Logs.debug('wafcache: No cache entry %s: %s', files_to, err)
  106. return False
  107. self.cached = True
  108. return True
  109. def put_files_cache(self):
  110. """
  111. New method for waf Task classes
  112. """
  113. if WAFCACHE_NO_PUSH or getattr(self, 'cached', None) or not self.outputs:
  114. return
  115. files_from = []
  116. for node in self.outputs:
  117. path = node.abspath()
  118. if not os.path.isfile(path):
  119. return
  120. files_from.append(path)
  121. bld = self.generator.bld
  122. old_sig = self.signature()
  123. for node in self.inputs:
  124. try:
  125. del node.ctx.cache_sig[node]
  126. except KeyError:
  127. pass
  128. delattr(self, 'cache_sig')
  129. sig = self.signature()
  130. def _async_put_files_cache(bld, ssig, files_from):
  131. proc = get_process()
  132. if WAFCACHE_ASYNC_WORKERS:
  133. with bld.wafcache_lock:
  134. if bld.wafcache_stop:
  135. process_pool.append(proc)
  136. return
  137. bld.wafcache_procs.add(proc)
  138. err = cache_command(proc, ssig, files_from, [])
  139. process_pool.append(proc)
  140. if err.startswith(OK):
  141. if WAFCACHE_VERBOSITY:
  142. Logs.pprint('CYAN', ' Successfully uploaded %s to cache' % files_from)
  143. else:
  144. Logs.debug('wafcache: Successfully uploaded %r to cache', files_from)
  145. if WAFCACHE_STATS:
  146. bld.cache_puts += 1
  147. else:
  148. if WAFCACHE_VERBOSITY:
  149. Logs.pprint('RED', ' Error caching step results %s: %s' % (files_from, err))
  150. else:
  151. Logs.debug('wafcache: Error caching results %s: %s', files_from, err)
  152. if old_sig == sig:
  153. ssig = Utils.to_hex(self.uid() + sig)
  154. if WAFCACHE_ASYNC_WORKERS:
  155. fut = bld.wafcache_executor.submit(_async_put_files_cache, bld, ssig, files_from)
  156. bld.wafcache_uploads.append(fut)
  157. else:
  158. _async_put_files_cache(bld, ssig, files_from)
  159. else:
  160. Logs.debug('wafcache: skipped %r upload due to late input modifications %r', self.outputs, self.inputs)
  161. bld.task_sigs[self.uid()] = self.cache_sig
  162. def hash_env_vars(self, env, vars_lst):
  163. """
  164. Reimplement BuildContext.hash_env_vars so that the resulting hash does not depend on local paths
  165. """
  166. if not env.table:
  167. env = env.parent
  168. if not env:
  169. return Utils.SIG_NIL
  170. idx = str(id(env)) + str(vars_lst)
  171. try:
  172. cache = self.cache_env
  173. except AttributeError:
  174. cache = self.cache_env = {}
  175. else:
  176. try:
  177. return self.cache_env[idx]
  178. except KeyError:
  179. pass
  180. v = str([env[a] for a in vars_lst])
  181. v = v.replace(self.srcnode.abspath().__repr__()[:-1], '')
  182. m = Utils.md5()
  183. m.update(v.encode())
  184. ret = m.digest()
  185. Logs.debug('envhash: %r %r', ret, v)
  186. cache[idx] = ret
  187. return ret
  188. def uid(self):
  189. """
  190. Reimplement Task.uid() so that the signature does not depend on local paths
  191. """
  192. try:
  193. return self.uid_
  194. except AttributeError:
  195. m = Utils.md5()
  196. src = self.generator.bld.srcnode
  197. up = m.update
  198. up(self.__class__.__name__.encode())
  199. for x in self.inputs + self.outputs:
  200. up(x.path_from(src).encode())
  201. self.uid_ = m.digest()
  202. return self.uid_
  203. def make_cached(cls):
  204. """
  205. Enable the waf cache for a given task class
  206. """
  207. if getattr(cls, 'nocache', None) or getattr(cls, 'has_cache', False):
  208. return
  209. full_name = "%s.%s" % (cls.__module__, cls.__name__)
  210. if full_name in ('waflib.Tools.ccroot.vnum', 'waflib.Build.inst'):
  211. return
  212. m1 = getattr(cls, 'run', None)
  213. def run(self):
  214. if getattr(self, 'nocache', False):
  215. return m1(self)
  216. if self.can_retrieve_cache():
  217. return 0
  218. return m1(self)
  219. cls.run = run
  220. m2 = getattr(cls, 'post_run', None)
  221. def post_run(self):
  222. if getattr(self, 'nocache', False):
  223. return m2(self)
  224. ret = m2(self)
  225. self.put_files_cache()
  226. return ret
  227. cls.post_run = post_run
  228. cls.has_cache = True
  229. process_pool = []
  230. def get_process():
  231. """
  232. Returns a worker process that can process waf cache commands
  233. The worker process is assumed to be returned to the process pool when unused
  234. """
  235. try:
  236. return process_pool.pop()
  237. except IndexError:
  238. filepath = os.path.dirname(os.path.abspath(__file__)) + os.sep + 'wafcache.py'
  239. cmd = [sys.executable, '-c', Utils.readf(filepath)]
  240. return subprocess.Popen(cmd, stdout=subprocess.PIPE, stdin=subprocess.PIPE, bufsize=0)
  241. def atexit_pool():
  242. for proc in process_pool:
  243. proc.kill()
  244. atexit.register(atexit_pool)
  245. def build(bld):
  246. """
  247. Called during the build process to enable file caching
  248. """
  249. if WAFCACHE_ASYNC_WORKERS:
  250. try:
  251. num_workers = int(WAFCACHE_ASYNC_WORKERS)
  252. except ValueError:
  253. Logs.warn('Invalid WAFCACHE_ASYNC_WORKERS specified: %r' % WAFCACHE_ASYNC_WORKERS)
  254. else:
  255. from concurrent.futures import ThreadPoolExecutor
  256. bld.wafcache_executor = ThreadPoolExecutor(max_workers=num_workers)
  257. bld.wafcache_uploads = []
  258. bld.wafcache_procs = set([])
  259. bld.wafcache_stop = False
  260. bld.wafcache_lock = threading.Lock()
  261. def finalize_upload_async(bld):
  262. if WAFCACHE_ASYNC_NOWAIT:
  263. with bld.wafcache_lock:
  264. bld.wafcache_stop = True
  265. for fut in reversed(bld.wafcache_uploads):
  266. fut.cancel()
  267. for proc in bld.wafcache_procs:
  268. proc.kill()
  269. bld.wafcache_procs.clear()
  270. else:
  271. Logs.pprint('CYAN', '... waiting for wafcache uploads to complete (%s uploads)' % len(bld.wafcache_uploads))
  272. bld.wafcache_executor.shutdown(wait=True)
  273. bld.add_post_fun(finalize_upload_async)
  274. if WAFCACHE_STATS:
  275. # Init counter for statistics and hook to print results at the end
  276. bld.cache_reqs = bld.cache_hits = bld.cache_puts = 0
  277. def printstats(bld):
  278. hit_ratio = 0
  279. if bld.cache_reqs > 0:
  280. hit_ratio = (bld.cache_hits / bld.cache_reqs) * 100
  281. Logs.pprint('CYAN', ' wafcache stats: %s requests, %s hits (ratio: %.2f%%), %s writes' %
  282. (bld.cache_reqs, bld.cache_hits, hit_ratio, bld.cache_puts) )
  283. bld.add_post_fun(printstats)
  284. if process_pool:
  285. # already called once
  286. return
  287. # pre-allocation
  288. processes = [get_process() for x in range(bld.jobs)]
  289. process_pool.extend(processes)
  290. Task.Task.can_retrieve_cache = can_retrieve_cache
  291. Task.Task.put_files_cache = put_files_cache
  292. Task.Task.uid = uid
  293. Build.BuildContext.hash_env_vars = hash_env_vars
  294. for x in reversed(list(Task.classes.values())):
  295. make_cached(x)
  296. def cache_command(proc, sig, files_from, files_to):
  297. """
  298. Create a command for cache worker processes, returns a pickled
  299. base64-encoded tuple containing the task signature, a list of files to
  300. cache and a list of files files to get from cache (one of the lists
  301. is assumed to be empty)
  302. """
  303. obj = base64.b64encode(cPickle.dumps([sig, files_from, files_to]))
  304. proc.stdin.write(obj)
  305. proc.stdin.write('\n'.encode())
  306. proc.stdin.flush()
  307. obj = proc.stdout.readline()
  308. if not obj:
  309. raise OSError('Preforked sub-process %r died' % proc.pid)
  310. return cPickle.loads(base64.b64decode(obj))
  311. try:
  312. copyfun = os.link
  313. except NameError:
  314. copyfun = shutil.copy2
  315. def atomic_copy(orig, dest):
  316. """
  317. Copy files to the cache, the operation is atomic for a given file
  318. """
  319. global copyfun
  320. tmp = dest + '.tmp'
  321. up = os.path.dirname(dest)
  322. try:
  323. os.makedirs(up)
  324. except OSError:
  325. pass
  326. try:
  327. copyfun(orig, tmp)
  328. except OSError as e:
  329. if e.errno == errno.EXDEV:
  330. copyfun = shutil.copy2
  331. copyfun(orig, tmp)
  332. else:
  333. raise
  334. os.rename(tmp, dest)
  335. def lru_trim():
  336. """
  337. the cache folders take the form:
  338. `CACHE_DIR/0b/0b180f82246d726ece37c8ccd0fb1cde2650d7bfcf122ec1f169079a3bfc0ab9`
  339. they are listed in order of last access, and then removed
  340. until the amount of folders is within TRIM_MAX_FOLDERS and the total space
  341. taken by files is less than EVICT_MAX_BYTES
  342. """
  343. lst = []
  344. for up in os.listdir(CACHE_DIR):
  345. if len(up) == 2:
  346. sub = os.path.join(CACHE_DIR, up)
  347. for hval in os.listdir(sub):
  348. path = os.path.join(sub, hval)
  349. size = 0
  350. for fname in os.listdir(path):
  351. try:
  352. size += os.lstat(os.path.join(path, fname)).st_size
  353. except OSError:
  354. pass
  355. lst.append((os.stat(path).st_mtime, size, path))
  356. lst.sort(key=lambda x: x[0])
  357. lst.reverse()
  358. tot = sum(x[1] for x in lst)
  359. while tot > EVICT_MAX_BYTES or len(lst) > TRIM_MAX_FOLDERS:
  360. _, tmp_size, path = lst.pop()
  361. tot -= tmp_size
  362. tmp = path + '.remove'
  363. try:
  364. shutil.rmtree(tmp)
  365. except OSError:
  366. pass
  367. try:
  368. os.rename(path, tmp)
  369. except OSError:
  370. sys.stderr.write('Could not rename %r to %r\n' % (path, tmp))
  371. else:
  372. try:
  373. shutil.rmtree(tmp)
  374. except OSError:
  375. sys.stderr.write('Could not remove %r\n' % tmp)
  376. sys.stderr.write("Cache trimmed: %r bytes in %r folders left\n" % (tot, len(lst)))
  377. def lru_evict():
  378. """
  379. Reduce the cache size
  380. """
  381. lockfile = os.path.join(CACHE_DIR, 'all.lock')
  382. try:
  383. st = os.stat(lockfile)
  384. except EnvironmentError as e:
  385. if e.errno == errno.ENOENT:
  386. with open(lockfile, 'w') as f:
  387. f.write('')
  388. return
  389. else:
  390. raise
  391. if st.st_mtime < time.time() - EVICT_INTERVAL_MINUTES * 60:
  392. # check every EVICT_INTERVAL_MINUTES minutes if the cache is too big
  393. # OCLOEXEC is unnecessary because no processes are spawned
  394. fd = os.open(lockfile, os.O_RDWR | os.O_CREAT, 0o755)
  395. try:
  396. try:
  397. fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
  398. except EnvironmentError:
  399. if WAFCACHE_VERBOSITY:
  400. sys.stderr.write('wafcache: another cleaning process is running\n')
  401. else:
  402. # now dow the actual cleanup
  403. lru_trim()
  404. os.utime(lockfile, None)
  405. finally:
  406. os.close(fd)
  407. class netcache(object):
  408. def __init__(self):
  409. self.http = urllib3.PoolManager()
  410. def url_of(self, sig, i):
  411. return "%s/%s/%s" % (CACHE_DIR, sig, i)
  412. def upload(self, file_path, sig, i):
  413. url = self.url_of(sig, i)
  414. with open(file_path, 'rb') as f:
  415. file_data = f.read()
  416. r = self.http.request('POST', url, timeout=60,
  417. fields={ 'file': ('%s/%s' % (sig, i), file_data), })
  418. if r.status >= 400:
  419. raise OSError("Invalid status %r %r" % (url, r.status))
  420. def download(self, file_path, sig, i):
  421. url = self.url_of(sig, i)
  422. with self.http.request('GET', url, preload_content=False, timeout=60) as inf:
  423. if inf.status >= 400:
  424. raise OSError("Invalid status %r %r" % (url, inf.status))
  425. with open(file_path, 'wb') as out:
  426. shutil.copyfileobj(inf, out)
  427. def copy_to_cache(self, sig, files_from, files_to):
  428. try:
  429. for i, x in enumerate(files_from):
  430. if not os.path.islink(x):
  431. self.upload(x, sig, i)
  432. except Exception:
  433. return traceback.format_exc()
  434. return OK
  435. def copy_from_cache(self, sig, files_from, files_to):
  436. try:
  437. for i, x in enumerate(files_to):
  438. self.download(x, sig, i)
  439. except Exception:
  440. return traceback.format_exc()
  441. return OK
  442. class fcache(object):
  443. def __init__(self):
  444. if not os.path.exists(CACHE_DIR):
  445. try:
  446. os.makedirs(CACHE_DIR)
  447. except OSError:
  448. pass
  449. if not os.path.exists(CACHE_DIR):
  450. raise ValueError('Could not initialize the cache directory')
  451. def copy_to_cache(self, sig, files_from, files_to):
  452. """
  453. Copy files to the cache, existing files are overwritten,
  454. and the copy is atomic only for a given file, not for all files
  455. that belong to a given task object
  456. """
  457. try:
  458. for i, x in enumerate(files_from):
  459. dest = os.path.join(CACHE_DIR, sig[:2], sig, str(i))
  460. atomic_copy(x, dest)
  461. except Exception:
  462. return traceback.format_exc()
  463. else:
  464. # attempt trimming if caching was successful:
  465. # we may have things to trim!
  466. try:
  467. lru_evict()
  468. except Exception:
  469. return traceback.format_exc()
  470. return OK
  471. def copy_from_cache(self, sig, files_from, files_to):
  472. """
  473. Copy files from the cache
  474. """
  475. try:
  476. for i, x in enumerate(files_to):
  477. orig = os.path.join(CACHE_DIR, sig[:2], sig, str(i))
  478. atomic_copy(orig, x)
  479. # success! update the cache time
  480. os.utime(os.path.join(CACHE_DIR, sig[:2], sig), None)
  481. except Exception:
  482. return traceback.format_exc()
  483. return OK
  484. class bucket_cache(object):
  485. def bucket_copy(self, source, target):
  486. if WAFCACHE_CMD:
  487. def replacer(match):
  488. if match.group('src'):
  489. return source
  490. elif match.group('tgt'):
  491. return target
  492. cmd = [re_waf_cmd.sub(replacer, x) for x in shlex.split(WAFCACHE_CMD)]
  493. elif CACHE_DIR.startswith('s3://'):
  494. cmd = ['aws', 's3', 'cp', source, target]
  495. elif CACHE_DIR.startswith('gs://'):
  496. cmd = ['gsutil', 'cp', source, target]
  497. else:
  498. cmd = ['mc', 'cp', source, target]
  499. proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  500. out, err = proc.communicate()
  501. if proc.returncode:
  502. raise OSError('Error copy %r to %r using: %r (exit %r):\n out:%s\n err:%s' % (
  503. source, target, cmd, proc.returncode, out.decode(errors='replace'), err.decode(errors='replace')))
  504. def copy_to_cache(self, sig, files_from, files_to):
  505. try:
  506. for i, x in enumerate(files_from):
  507. dest = os.path.join(CACHE_DIR, sig[:2], sig, str(i))
  508. self.bucket_copy(x, dest)
  509. except Exception:
  510. return traceback.format_exc()
  511. return OK
  512. def copy_from_cache(self, sig, files_from, files_to):
  513. try:
  514. for i, x in enumerate(files_to):
  515. orig = os.path.join(CACHE_DIR, sig[:2], sig, str(i))
  516. self.bucket_copy(orig, x)
  517. except EnvironmentError:
  518. return traceback.format_exc()
  519. return OK
  520. def loop(service):
  521. """
  522. This function is run when this file is run as a standalone python script,
  523. it assumes a parent process that will communicate the commands to it
  524. as pickled-encoded tuples (one line per command)
  525. The commands are to copy files to the cache or copy files from the
  526. cache to a target destination
  527. """
  528. # one operation is performed at a single time by a single process
  529. # therefore stdin never has more than one line
  530. txt = sys.stdin.readline().strip()
  531. if not txt:
  532. # parent process probably ended
  533. sys.exit(1)
  534. ret = OK
  535. [sig, files_from, files_to] = cPickle.loads(base64.b64decode(txt))
  536. if files_from:
  537. # TODO return early when pushing files upstream
  538. ret = service.copy_to_cache(sig, files_from, files_to)
  539. elif files_to:
  540. # the build process waits for workers to (possibly) obtain files from the cache
  541. ret = service.copy_from_cache(sig, files_from, files_to)
  542. else:
  543. ret = "Invalid command"
  544. obj = base64.b64encode(cPickle.dumps(ret))
  545. sys.stdout.write(obj.decode())
  546. sys.stdout.write('\n')
  547. sys.stdout.flush()
  548. if __name__ == '__main__':
  549. if CACHE_DIR.startswith('s3://') or CACHE_DIR.startswith('gs://') or CACHE_DIR.startswith('minio://'):
  550. if CACHE_DIR.startswith('minio://'):
  551. CACHE_DIR = CACHE_DIR[8:] # minio doesn't need the protocol part, uses config aliases
  552. service = bucket_cache()
  553. elif CACHE_DIR.startswith('http'):
  554. service = netcache()
  555. else:
  556. service = fcache()
  557. while 1:
  558. try:
  559. loop(service)
  560. except KeyboardInterrupt:
  561. break