Skip to content

Commit 151b9bb

Browse files
committed
#267 Improve scan results caching speed
* New scan caching implementation using simple JSON files storage instead of a sqlite-backed storage. Since we have little or no contention, the strong ACID and locking offered by sqlite were slowing things down significantly by saturating disk I/Os. The process of caching scans is a write once, read once for each scanned file and therefore locking and atomic storage is not needed. * Also improve scan errors reporting. Signed-off-by: Philippe Ombredanne <pombredanne@nexb.com>
1 parent 4251995 commit 151b9bb

6 files changed

Lines changed: 318 additions & 182 deletions

File tree

src/scancode/cache.py

Lines changed: 208 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -26,29 +26,40 @@
2626

2727
from collections import OrderedDict
2828
from functools import partial
29+
import json
30+
from hashlib import sha1
2931
import os
32+
import posixpath
3033
import sys
3134

3235
from commoncode import fileutils
36+
from commoncode.fileutils import as_posixpath
3337
from commoncode import timeutils
3438

3539
from scancode import scans_cache_dir
3640

3741
"""
38-
Caching scans on disk: A cache of all the scan results.
42+
Cache scan results for a file or directory disk using a file-based cache.
3943
40-
Each scan results for a file or directory is cached on disk.
44+
The approach is to cache the scan of a file using these files:
45+
- one "global" file contains a log of all the paths scanned.
46+
- for each file being scanned, we store a file that contains the corresponding file
47+
info data as JSON. This file is named after the hash of the path of a scanned file.
48+
- for each unique file being scanned (e.g. based on its content SHA1), we store a
49+
another JSON file that contains the corresponding scan data. This file is named
50+
after the hash of the scanned file content.
4151
42-
The approach is to use to cache:
43-
- the results of a scan, excluding file infos keyed by the hash of a scanned file
44-
- the file infos, keyed by the path of a scanned file
45-
46-
Once a scan is completed, we iterate the caches to output the scan results using this
47-
procedure: iterate the cached file infos and for each lookup the scan details in the
48-
cached scan results. This iteration is driving the final streaming of results to the
49-
output format (e.g. JSON).
52+
Once a scan is completed, we iterate the cache to output the final scan results:
53+
First iterate the global log file to get the paths, from there collect the cached
54+
file info for that file and from the path and file info collect the cached scanned
55+
result. This iterator is then streamed to the final JSON output.
5056
5157
Finally once a scan is completed the cache is destroyed to free up disk space.
58+
59+
Internally the cache is organized as a tree of directories named after the first few
60+
characters or a path hash or file hash. This is to avoid having having too many files
61+
per directory that can make some filesystems choke as well as having directories that
62+
are too deep or having file paths that are too long which problematic on some OS.
5263
"""
5364

5465
# Tracing flags
@@ -69,123 +80,221 @@ def logger_debug(*args):
6980
return logger.debug(' '.join(isinstance(a, basestring) and a or repr(a) for a in args))
7081

7182

72-
class ScanCache(object):
83+
def get_scans_cache_class(cache_dir=scans_cache_dir):
84+
"""
85+
Return a new persistent cache class configured with a unique storage directory.
86+
"""
87+
# create a unique temp directory in cache_dir
88+
fileutils.create_dir(cache_dir)
89+
cache_dir = fileutils.get_temp_dir(cache_dir, prefix=timeutils.time2tstamp() + '-')
90+
sc = ScanFileCache(cache_dir)
91+
sc.setup()
92+
return partial(ScanFileCache, cache_dir)
93+
94+
95+
def info_keys(path):
96+
"""
97+
Return a file info cache keys tripple for a path.
98+
99+
For example:
100+
>>> sha1('/w421/scancode-toolkit2').hexdigest()
101+
'fb87db2bb28e9501ac7fdc4812782118f4c94a0f'
102+
>>> info_keys('/w421/scancode-toolkit2')
103+
('f', 'b', '87db2bb28e9501ac7fdc4812782118f4c94a0f')
104+
"""
105+
return keys_from_hash(sha1(path).hexdigest())
106+
107+
108+
def scan_keys(path, file_info):
109+
"""
110+
Return a scan cache keys tripple for a path and file_info. If the file_info
111+
sha1 is empty (e.g. such as a directory), return a key based on the path instead.
112+
"""
113+
sha1_digest = file_info['sha1']
114+
if sha1_digest:
115+
return keys_from_hash(sha1_digest)
116+
else:
117+
# we may eventually store directories, in which case we use the path as a key
118+
# with some extra seed
119+
return info_keys(u'empty hash' + path)
120+
121+
122+
def keys_from_hash(hexdigest):
123+
"""
124+
Return a cache keys triple for a hash hexdigest string.
125+
126+
NOTE: since we use the first character and next two characters as directories, we
127+
create at most 16 dir at the first level and 16 dir at the second level for each
128+
first level directory for a maximum total of 16*16 = 256 directories. For a
129+
million files we would have about 4000 files per directory on average with this
130+
scheme which should keep most file systems happy and avoid some performance
131+
issues when there are too many files in a single directory.
132+
133+
For example:
134+
>>> keys_from_hash('fb87db2bb28e9501ac7fdc4812782118f4c94a0f')
135+
('f', 'b', '87db2bb28e9501ac7fdc4812782118f4c94a0f')
136+
"""
137+
return hexdigest[0], hexdigest[1], hexdigest[2:]
138+
139+
140+
def paths_from_keys(base_path, keys):
141+
"""
142+
Return a tuple of (parent dir path, filename) built from a cache keys triple and
143+
a base_directory. Ensure that the parent directory exist.
144+
"""
145+
dir1, dir2, fname = keys
146+
parent = os.path.join(base_path, dir1, dir2)
147+
fileutils.create_dir(parent)
148+
return parent, fname
149+
150+
151+
class ScanFileCache(object):
73152
"""
74-
A file-based cache for scan results.
75-
This is NOT thread-safe, but is multi-process safe.
153+
A file-based cache for scan results saving results in files and using no locking.
154+
This is NOT thread-safe and NOT multi-process safe but works OK in our context:
155+
we cache the scan for a given file once and read it only a few times.
76156
"""
77157
def __init__(self, cache_dir):
78158
self.cache_base_dir = cache_dir
79-
# subdirs for infos and scans caches
80-
self.cache_infos_dir = os.path.join(self.cache_base_dir, 'infos')
81-
self.cache_scans_dir = os.path.join(self.cache_base_dir, 'scans')
159+
# subdirs for info and scans caches
160+
self.cache_infos_dir = as_posixpath(os.path.join(self.cache_base_dir, 'infos/'))
161+
self.cache_scans_dir = as_posixpath(os.path.join(self.cache_base_dir, 'scans/'))
162+
self.cache_files_log = as_posixpath(os.path.join(self.cache_base_dir, 'files_log'))
82163

83-
# workaround for https://github.com/grantjenks/python-diskcache/issues/32
84-
from diskcache import Disk
85-
class DiskWithNoHighPickleProtocol(Disk):
86-
"Subclass of diskcache.Disk that always use the lowest pickle protocol."
87-
def __init__(self, directory, size_threshold, pickle_protocol):
88-
super(DiskWithNoHighPickleProtocol, self).__init__(directory, size_threshold, pickle_protocol)
89-
self._protocol = 0
164+
def setup(self):
165+
"""
166+
Setup the cache: must be called at least once globally after cache
167+
initialization.
168+
"""
169+
os.makedirs(self.cache_infos_dir)
170+
os.makedirs(self.cache_scans_dir)
90171

91-
# and finally cache instances
92-
from diskcache import Cache
93-
self.infos = Cache(self.cache_infos_dir, disk=DiskWithNoHighPickleProtocol)
94-
self.scans = Cache(self.cache_scans_dir, disk=DiskWithNoHighPickleProtocol)
172+
@classmethod
173+
def log_file_path(cls, logfile_fd, path):
174+
"""
175+
Log file path in the cache logfile_fd **opened** file descriptor.
176+
"""
177+
# we dump the path as JSON, one per line.
178+
# JSON is to avoid any issue with weird file paths/names
179+
logfile_fd.write(json.dumps(path))
180+
logfile_fd.write('\n')
95181

96-
def scan_key(self, path, file_infos):
182+
def get_cached_info_path(self, path):
97183
"""
98-
Return a scan cache key for a path and file_infos.
184+
Return the path where to store a file info in the cache given a path.
99185
"""
100-
sha1 = file_infos['sha1']
101-
# we may eventually store directories, in which case we use the path as a key
102-
return sha1 or path
186+
keys = info_keys(path)
187+
paths = paths_from_keys(self.cache_infos_dir, keys)
188+
return posixpath.join(*paths)
103189

104-
def put_infos(self, path, file_infos):
190+
def put_info(self, path, file_info):
105191
"""
106-
Put file_infos for path in the cache and return True if the file referenced
107-
in file_infos has already been scanned or False otherwise.
192+
Put file_info for path in the cache and return True if the file referenced
193+
in file_info has already been scanned or False otherwise.
108194
"""
109-
self.infos.set(path, file_infos)
110-
is_scan_cached = self.scan_key(path, file_infos) in self.scans
195+
info_path = self.get_cached_info_path(path)
196+
with open(info_path, 'wb') as cached_infos:
197+
json.dump(file_info, cached_infos, check_circular=False)
198+
scan_path = self.get_cached_scan_path(path, file_info)
199+
is_scan_cached = os.path.exists(scan_path)
111200
if TRACE:
112-
logger_debug('put_infos:', 'path:', path, 'is_scan_cached:', is_scan_cached, 'file_infos:', file_infos, '\n')
113-
logger_debug('put_infos:', 'cached_infos:', self.infos[path], '\n')
201+
logger_debug('put_infos:', 'path:', path, 'is_scan_cached:', is_scan_cached, 'file_info:', file_info, '\n')
114202
return is_scan_cached
115203

116-
def put_scan(self, path, file_infos, scan_result):
204+
def get_info(self, path):
117205
"""
118-
Put scan_result in the cache if not already cached.
206+
Return file info from the cache for a path.
207+
Return None on failure to find the info in the cache.
119208
"""
120-
scan_key = self.scan_key(path, file_infos)
121-
self.scans.add(scan_key, scan_result)
122-
if TRACE:
123-
logger_debug('put_scan:', 'scan_key:', scan_key, 'file_infos:', file_infos, 'scan_result:', scan_result, '\n')
124-
logger_debug('put_scan:', 'cached_infos:', self.infos[path], '\n')
125-
logger_debug('put_scan:', 'scan_key:', scan_key, 'cached_scan:', self.scans[scan_key], '\n')
209+
info_path = self.get_cached_info_path(path)
210+
if os.path.exists(info_path):
211+
with open(info_path, 'rb') as ci:
212+
return json.load(ci, object_pairs_hook=OrderedDict)
126213

127-
def iterate(self, with_infos=True):
214+
def get_cached_scan_path(self, path, file_info):
128215
"""
129-
Yield scan data for all cached scans e.g. the whole cache.
130-
If a scan is missing for a given info, an error is appended to scan_errors.
216+
Return the path where to store a scan in the cache given a path and file_info.
131217
"""
132-
for path in self.infos:
133-
file_infos = self.infos[path]
134-
scan_result = OrderedDict(path=path)
135-
if with_infos:
136-
# infos is always collected but only returnedd if asked:
137-
# we flatten these as direct attributes of a file object
138-
scan_result.update(file_infos.items())
139-
else:
140-
# always include errors even if empty
141-
scan_result['scan_errors'] = file_infos.get('scan_errors', [])
218+
keys = scan_keys(path, file_info)
219+
paths = paths_from_keys(self.cache_scans_dir, keys)
220+
return posixpath.join(*paths)
142221

143-
no_scan_details = dict(scan_errors=[
144-
('ERROR: Requested scan details unavailable in cache.',
145-
'This is either a bug or processing was aborted with CTRL-C.')]
146-
)
147-
148-
scan_key = self.scan_key(path, file_infos)
149-
scan_details = self.scans.get(scan_key, no_scan_details)
150-
if TRACE:
151-
logger_debug('iterate:', 'scan_details:', scan_details, 'for path:', path, 'scan_key:', scan_key, '\n')
152-
153-
# append errors to other top level errors if any
154-
scan_errors = scan_details.pop('scan_errors', [])
155-
scan_result['scan_errors'].extend(scan_errors)
222+
def put_scan(self, path, file_info, scan_result):
223+
"""
224+
Put scan_result in the cache if not already cached.
225+
"""
226+
scan_path = self.get_cached_scan_path(path, file_info)
227+
if not os.path.exists(scan_path):
228+
with open(scan_path, 'wb') as cached_scan:
229+
json.dump(scan_result, cached_scan, check_circular=False)
230+
if TRACE:
231+
logger_debug('put_scan:', 'scan_path:', scan_path, 'file_info:', file_info, 'scan_result:', scan_result, '\n')
156232

157-
scan_result.update(scan_details)
158-
yield scan_result
233+
def get_scan(self, path, file_info):
234+
"""
235+
Return scan results from the cache for a path and file_info.
236+
Return None on failure to find the scan results in the cache.
237+
"""
238+
scan_path = self.get_cached_scan_path(path, file_info)
239+
if os.path.exists(scan_path):
240+
with open(scan_path, 'rb') as cs:
241+
return json.load(cs, object_pairs_hook=OrderedDict)
159242

160-
def close(self):
243+
def iterate(self, scan_names):
161244
"""
162-
Close the underlying caches.
245+
Yield scan data for all cached scans e.g. the whole cache given a list of
246+
scan names.
247+
248+
The logfile MUST have been closed before calling this method.
163249
"""
164-
if self.infos:
165-
self.infos.close()
166-
if self.scans:
167-
self.scans.close()
250+
with open(self.cache_files_log, 'rb') as cached_files:
251+
# iterate the list of (path, (info keys)), one by line
252+
for file_log in cached_files:
253+
path = json.loads(file_log)
254+
file_info = self.get_info(path)
255+
256+
# rare but possible corner case
257+
if file_info is None:
258+
no_info = ('ERROR: file info unavailable in cache: '
259+
'This is either a bug or processing was aborted with CTRL-C.')
260+
scan_result = OrderedDict(path=path)
261+
scan_result['scan_errors'] = [no_info]
262+
if TRACE:
263+
logger_debug('iterate:', 'scan_result:', scan_result, 'for path:', path, '\n')
264+
yield scan_result
265+
continue
266+
267+
path = file_info.pop('path')
268+
scan_result = OrderedDict(path=path)
269+
270+
if 'infos' in scan_names:
271+
# infos is always collected but only returned if requested
272+
# we flatten these as direct attributes of a file object
273+
scan_result.update(file_info.items())
274+
if not scan_result.get('scan_errors'):
275+
scan_result['scan_errors'] = []
276+
277+
# check if we have more than just infos
278+
if ['infos'] != scan_names:
279+
errors = scan_result['scan_errors']
280+
scan_details = self.get_scan(path, file_info)
281+
if scan_details is None:
282+
no_scan_details = (
283+
'ERROR: scan details unavailable in cache: '
284+
'This is either a bug or processing was aborted with CTRL-C.')
285+
errors.append(no_scan_details)
286+
else:
287+
# append errors to other top level errors if any
288+
scan_errors = scan_details.pop('scan_errors', [])
289+
errors.extend(scan_errors)
290+
scan_result.update(scan_details)
291+
292+
if TRACE:
293+
logger_debug('iterate:', 'scan_result:', scan_result, 'for path:', path, '\n')
294+
yield scan_result
168295

169296
def clear(self, *args):
170297
"""
171298
Purge the cache by deleting the corresponding cached data files.
172299
"""
173-
self.close()
174300
fileutils.delete(self.cache_base_dir)
175-
176-
177-
def get_scans_cache(cache_dir=scans_cache_dir):
178-
"""
179-
Return a new unique persistent cache instance.
180-
"""
181-
return ScanCache(cache_dir)
182-
183-
184-
def get_scans_cache_class(cache_dir=scans_cache_dir):
185-
"""
186-
Return a new unique persistent cache instance.
187-
"""
188-
fileutils.create_dir(cache_dir)
189-
# create a unique temp directory in cache_dir
190-
cache_dir = fileutils.get_temp_dir(cache_dir, prefix=timeutils.time2tstamp() + '-')
191-
return partial(ScanCache, cache_dir)

0 commit comments

Comments
 (0)