| #!/usr/bin/env vpython3 |
| # Copyright 2018 The LUCI Authors. All rights reserved. |
| # Use of this source code is governed under the Apache License, Version 2.0 |
| # that can be found in the LICENSE file. |
| |
| import hashlib |
| import json |
| import os |
| import random |
| import string |
| import subprocess |
| import sys |
| import tempfile |
| import time |
| import unittest |
| |
| # Mutates sys.path. |
| import test_env |
| |
| # third_party/ |
| from depot_tools import auto_stub |
| |
| import local_caching |
| from utils import file_path |
| from utils import fs |
| from utils import lru |
| |
| |
| def write_file(path, contents): |
| with fs.open(path, 'wb') as f: |
| f.write(contents) |
| |
| |
| def read_file(path): |
| with fs.open(path, 'rb') as f: |
| return f.read() |
| |
| |
| def read_tree(path): |
| """Returns a dict with {filepath: content}.""" |
| if not fs.isdir(path): |
| return None |
| out = {} |
| for root, _, filenames in fs.walk(path): |
| for filename in filenames: |
| p = os.path.join(root, filename) |
| out[os.path.relpath(p, path)] = read_file(p) |
| return out |
| |
| |
| def _gen_data(size): |
| return (string.digits.encode() * ((size + 9) // 10))[:size] |
| |
| |
| class TestCase(auto_stub.TestCase): |
| def setUp(self): |
| super(TestCase, self).setUp() |
| self.tempdir = tempfile.mkdtemp(prefix='local_caching') |
| self._algo = hashlib.sha256 |
| # Time mocking. |
| self._now = 1000 |
| self.mock(time, 'time', lambda: float(self._now)) |
| |
| # Free disk space mocking. |
| self._free_disk = 1000 |
| self.mock(file_path, 'get_free_space', lambda _: self._free_disk) |
| |
| # Named cache works with directories. |
| def rmtree(p): |
| self._free_disk += file_path.get_recursive_size(p) |
| return old_rmtree(p) |
| old_rmtree = self.mock(file_path, 'rmtree', rmtree) |
| |
| # Isolated cache works with files. |
| def try_remove(p): |
| try: |
| self._free_disk += fs.stat(p).st_size |
| except OSError: |
| pass |
| return old_try_remove(p) |
| old_try_remove = self.mock(file_path, 'try_remove', try_remove) |
| |
| def tearDown(self): |
| try: |
| file_path.rmtree(self.tempdir) |
| finally: |
| super(TestCase, self).tearDown() |
| |
| def _add_one_item(self, cache, size): |
| """Adds one item of |size| bytes in the cache and returns the created name. |
| """ |
| # Don't allow 0 byte items here. This doesn't work for named cache. |
| self.assertTrue(size) |
| data = _gen_data(size) |
| if isinstance(cache, local_caching.ContentAddressedCache): |
| # This covers both MemoryContentAddressedCache and |
| # DiskContentAddressedCache. |
| return cache.write(self._algo(data).hexdigest(), [data]) |
| if isinstance(cache, local_caching.NamedCache): |
| # In this case, map a named cache, add a file, unmap it. |
| dest_dir = os.path.join(self.tempdir, 'dest') |
| self.assertFalse(fs.exists(dest_dir)) |
| name = str(size) |
| cache.install(dest_dir, name) |
| # Put a file in there named 'hello', otherwise it'll stay empty. |
| with fs.open(os.path.join(dest_dir, 'hello'), 'wb') as f: |
| f.write(data) |
| cache.uninstall(dest_dir, name) |
| self.assertFalse(fs.exists(dest_dir)) |
| return name |
| self.fail('Unexpected cache type %r' % cache) |
| return None |
| |
| |
| def _get_policies( |
| max_cache_size=0, min_free_space=0, max_items=0, max_age_secs=0): |
| """Returns a CachePolicies with only the policy we want to enforce.""" |
| return local_caching.CachePolicies( |
| max_cache_size=max_cache_size, |
| min_free_space=min_free_space, |
| max_items=max_items, |
| max_age_secs=max_age_secs) |
| |
| |
| class CacheTestMixin: |
| """Adds testing for the Cache interface.""" |
| # pylint: disable=no-member |
| |
| def get_cache(self, policies): |
| raise NotImplementedError() |
| |
| def test_contains(self): |
| cache = self.get_cache(_get_policies()) |
| self.assertFalse('foo' in cache) |
| name = self._add_one_item(cache, 1) |
| self.assertFalse('foo' in cache) |
| self.assertTrue(name in cache) |
| |
| def test_iter(self): |
| cache = self.get_cache(_get_policies()) |
| self.assertEqual([], list(cache)) |
| n10 = self._add_one_item(cache, 10) |
| n3 = self._add_one_item(cache, 3) |
| self.assertEqual([n10, n3], list(cache)) |
| |
| def test_len(self): |
| cache = self.get_cache(_get_policies()) |
| self.assertEqual(0, len(cache)) |
| self._add_one_item(cache, 10) |
| self.assertEqual(1, len(cache)) |
| self._add_one_item(cache, 11) |
| self.assertEqual(2, len(cache)) |
| |
| def test_total_size(self): |
| cache = self.get_cache(_get_policies()) |
| self.assertEqual(0, cache.total_size) |
| self._add_one_item(cache, 10) |
| self.assertEqual(10, cache.total_size) |
| self._add_one_item(cache, 11) |
| self.assertEqual(21, cache.total_size) |
| |
| def test_added(self): |
| cache = self.get_cache(_get_policies()) |
| self.assertEqual([], cache.added) |
| self._add_one_item(cache, 10) |
| self.assertEqual([10], cache.added) |
| |
| def test_used(self): |
| # It depends on the implementation. |
| pass |
| |
| def test_oldest_evictable_item(self): |
| cache = self.get_cache(_get_policies()) |
| self.assertEqual(None, cache.oldest_evictable_ts()) |
| ts = self._now |
| self._add_one_item(cache, 10) |
| self._now += 10 |
| self._add_one_item(cache, 20) |
| self.assertEqual(ts, cache.oldest_evictable_ts()) |
| |
| def test_remove_oldest(self): |
| cache = self.get_cache(_get_policies()) |
| self._add_one_item(cache, 10) |
| self._add_one_item(cache, 100) |
| self.assertTrue(cache.remove_oldest_evictable_item()) |
| self._add_one_item(cache, 20) |
| # added is not yet updated. |
| self.assertEqual([10, 100, 20], cache.added) |
| |
| def test_save(self): |
| cache = self.get_cache(_get_policies()) |
| self._add_one_item(cache, 100) |
| self._add_one_item(cache, 101) |
| # Just assert it doesn't crash here. |
| cache.save() |
| |
| def test_trim(self): |
| cache = self.get_cache(_get_policies()) |
| self._add_one_item(cache, 100) |
| self._add_one_item(cache, 101) |
| self.assertEqual([], cache.trim()) |
| |
| def test_true(self): |
| cache = self.get_cache(_get_policies()) |
| self.assertEqual(0, len(cache)) |
| # Even if empty, it's still "True". |
| self.assertTrue(bool(cache)) |
| |
| |
| class ContentAddressedCacheTestMixin(CacheTestMixin): |
| """Add testing for the ContentAddressedCache interface.""" |
| # pylint: disable=abstract-method |
| _now = 0 |
| |
| # write() is indirectly tested with _add_one_item(). |
| # cleanup() requires specific setup. |
| |
| def test_touch(self): |
| cache = self.get_cache(_get_policies()) |
| self._now = 1000 |
| n1 = self._add_one_item(cache, 1) |
| self._now = 1001 |
| n2 = self._add_one_item(cache, 2) |
| self.assertEqual([n1, n2], list(cache)) |
| self.assertEqual(1000, cache.oldest_evictable_ts()) |
| self._now = 1002 |
| cache.touch(n1, None) |
| self.assertEqual([n2, n1], list(cache)) |
| self.assertEqual(1001, cache.oldest_evictable_ts()) |
| |
| def test_getfileobj(self): |
| cache = self.get_cache(_get_policies()) |
| h = self._add_one_item(cache, 1) |
| with cache.getfileobj(h) as f: |
| self.assertEqual(b'0', f.read()) |
| |
| def test_getfileobj_complete_miss(self): |
| cache = self.get_cache(_get_policies()) |
| with self.assertRaises(local_caching.CacheMiss): |
| cache.getfileobj('0'*40) |
| |
| def test_getfileobj_cache_state_missing(self): |
| # Put the file in the cache, but do NOT save cache state. |
| cache = self.get_cache(_get_policies()) |
| h = self._add_one_item(cache, 1) |
| # Since we didn't save the state, this should result in CacheMiss. |
| cache = self.get_cache(_get_policies()) |
| with self.assertRaises(local_caching.CacheMiss): |
| cache.getfileobj(h) |
| |
| |
| class MemoryContentAddressedCacheTest(TestCase, ContentAddressedCacheTestMixin): |
| def get_cache(self, policies): |
| return local_caching.MemoryContentAddressedCache(policies) |
| |
| def cleanup(self): |
| # Doesn't do anything. |
| cache = self.get_cache(_get_policies()) |
| cache.cleanup() |
| |
| |
| class DiskContentAddressedCacheTest(TestCase, ContentAddressedCacheTestMixin): |
| def setUp(self): |
| super(DiskContentAddressedCacheTest, self).setUp() |
| # If this fails on Windows, please rerun this tests as an elevated user with |
| # administrator access right. |
| self.assertEqual(True, file_path.enable_symlink()) |
| |
| def cache_dir(self): |
| return os.path.join(self.tempdir, 'cache') |
| |
| def get_cache(self, policies): |
| return local_caching.DiskContentAddressedCache( |
| self.cache_dir(), policies, trim=True) |
| |
| def test_write_policies_free_disk(self): |
| cache = self.get_cache(_get_policies(min_free_space=1000)) |
| with self.assertRaises(local_caching.NoMoreSpace): |
| self._add_one_item(cache, 1) |
| |
| def test_write_policies_fit(self): |
| self._free_disk = 1001 |
| cache = self.get_cache(_get_policies(min_free_space=1000)) |
| with self.assertRaises(local_caching.NoMoreSpace): |
| self._add_one_item(cache, 2) |
| |
| def test_write_policies_max_cache_size(self): |
| # max_cache_size is ignored while adding items. |
| cache = self.get_cache(_get_policies(max_cache_size=1)) |
| self._add_one_item(cache, 2) |
| self._add_one_item(cache, 3) |
| |
| def test_write_policies_max_items(self): |
| # max_items is ignored while adding items. |
| cache = self.get_cache(_get_policies(max_items=1)) |
| self._add_one_item(cache, 2) |
| self._add_one_item(cache, 3) |
| |
| def test_write_policies_min_free_space(self): |
| # min_free_space is enforced while adding items. |
| self._free_disk = 1005 |
| cache = self.get_cache(_get_policies(min_free_space=1000)) |
| self._add_one_item(cache, 2) |
| self._add_one_item(cache, 3) |
| # Mapping more content than the amount of free disk required. |
| with self.assertRaises(local_caching.NoMoreSpace) as cm: |
| self._add_one_item(cache, 1) |
| expected = ('Not enough space to fetch the whole isolated tree.\n ' |
| 'CachePolicies(max_cache_size=0 (0.000 GiB); max_items=0; ' |
| 'min_free_space=1000 (0.000 GiB); max_age_secs=0)\n ' |
| 'cache=6 bytes (0.000 GiB), 3 items; ' |
| '999 bytes (0.000 GiB) free_space') |
| |
| self.assertEqual(expected, str(cm.exception)) |
| |
| def test_save_disk(self): |
| cache = self.get_cache(_get_policies()) |
| self.assertEqual( |
| sorted([cache.STATE_FILE]), sorted(fs.listdir(cache.cache_dir))) |
| |
| h = self._add_one_item(cache, 2) |
| self.assertEqual( |
| sorted([h, cache.STATE_FILE]), sorted(fs.listdir(cache.cache_dir))) |
| items = lru.LRUDict.load(os.path.join(cache.cache_dir, cache.STATE_FILE)) |
| self.assertEqual(0, len(items)) |
| |
| cache.save() |
| self.assertEqual( |
| sorted([h, cache.STATE_FILE]), sorted(fs.listdir(cache.cache_dir))) |
| items = lru.LRUDict.load(os.path.join(cache.cache_dir, cache.STATE_FILE)) |
| self.assertEqual(1, len(items)) |
| self.assertEqual((h, [2, 1000]), items.get_oldest()) |
| |
| def test_cleanup_disk(self): |
| # Inject an item without a state.json, one is lost. Both will be deleted on |
| # cleanup. |
| self._free_disk = 1003 |
| cache = self.get_cache(_get_policies(min_free_space=1000)) |
| h_foo = self._algo(b'foo').hexdigest() |
| self.assertEqual([], sorted(cache._lru._items.items())) |
| cache.write(h_foo, [b'foo']) |
| self.assertEqual([], cache.trim()) |
| self.assertEqual([h_foo], [i[0] for i in cache._lru._items.items()]) |
| |
| h_a = self._algo(b'a').hexdigest() |
| local_caching.file_write(os.path.join(cache.cache_dir, h_a), [b'a']) |
| |
| # file_path.remove() explicitly handle the +R bit on Windows. |
| file_path.remove(os.path.join(cache.cache_dir, h_foo)) |
| |
| # Still hasn't realized that the file is missing. |
| self.assertEqual([h_foo], [i[0] for i in cache._lru._items.items()]) |
| self.assertEqual( |
| sorted([h_a, cache.STATE_FILE]), sorted(fs.listdir(cache.cache_dir))) |
| cache.cleanup() |
| self.assertEqual([cache.STATE_FILE], fs.listdir(cache.cache_dir)) |
| |
| def test_cleanup_disk_evict_corrupted_files(self): |
| self._free_disk = 1003 |
| cache = self.get_cache(_get_policies(min_free_space=1000)) |
| |
| # add a corrupted item |
| h_a = self._algo(b'a').hexdigest() |
| cache.write(h_a, [b'A']) |
| h_b = self._algo(b'b').hexdigest() |
| cache.write(h_b, [b'b']) |
| |
| mtime_a = self._now |
| mtime_b = self._now |
| def _get_mtime(h): |
| if h == h_a: |
| return mtime_a |
| if h == h_b: |
| return mtime_b |
| |
| self.mock(cache, '_get_mtime', _get_mtime) |
| self.assertEqual([(h_a, (1, mtime_a)), (h_b, (1, mtime_b))], |
| list(cache._lru._items.items())) |
| self.assertCountEqual(([h_a, h_b, cache.STATE_FILE]), |
| (fs.listdir(cache.cache_dir))) |
| |
| # if the mtime is same with the timestamp in state.json, |
| # the varification won't run. |
| cache.cleanup() |
| self.assertCountEqual(([h_a, h_b, cache.STATE_FILE]), |
| (fs.listdir(cache.cache_dir))) |
| |
| # if the mtime is after the timestamp in the state.json |
| # the varification will run and removed the corrupted file. |
| mtime_a += 1 |
| mtime_b += 1 |
| self.mock(cache._lru, 'time_fn', lambda: mtime_b) |
| cache.cleanup() |
| self.assertCountEqual(([h_b, cache.STATE_FILE]), |
| (fs.listdir(cache.cache_dir))) |
| self.assertCountEqual([(h_b, (1, mtime_b))], cache._lru._items.items()) |
| |
| def test_policies_active_trimming(self): |
| # Start with a larger cache, add many object. |
| # Reload the cache with smaller policies, the cache should be trimmed on |
| # load. |
| h_a = self._algo(b'a').hexdigest() |
| h_b = self._algo(b'b').hexdigest() |
| h_c = self._algo(b'c').hexdigest() |
| large = b'b' * 99 |
| h_large = self._algo(large).hexdigest() |
| |
| def assertItems(expected): |
| actual = [ |
| (digest, size) for digest, (size, _) in cache._lru._items.items()] |
| self.assertEqual(expected, actual) |
| |
| self._free_disk = 1101 |
| cache = self.get_cache(_get_policies( |
| max_cache_size=100, |
| max_items=2, |
| min_free_space=1000)) |
| cache.write(h_a, [b'a']) |
| cache.write(h_large, [large]) |
| # Cache (size and # items) is not enforced while adding items. The |
| # rationale is that a task may request more data than the size of the |
| # cache policies. As long as there is free space, this is fine. |
| cache.write(h_b, [b'b']) |
| assertItems([(h_a, 1), (h_large, len(large)), (h_b, 1)]) |
| self.assertEqual(h_a, cache._protected) |
| self.assertEqual(1000, cache._free_disk) |
| # Free disk is enforced, because otherwise we assume the task wouldn't |
| # be able to start. In this case, it throws an exception since all items |
| # are protected. The item is added since it's detected after the fact. |
| with self.assertRaises(local_caching.NoMoreSpace): |
| cache.write(h_c, [b'c']) |
| self.assertEqual([1, 99], cache.trim()) |
| |
| # At this point, after the implicit trim in __exit__(), h_a and h_large were |
| # evicted. |
| self.assertEqual(sorted([h_b, h_c, cache.STATE_FILE]), |
| sorted(fs.listdir(cache.cache_dir))) |
| |
| # Allow 3 items and 101 bytes so h_large is kept. |
| cache = self.get_cache(_get_policies( |
| max_cache_size=101, |
| min_free_space=1000, |
| max_items=3, |
| max_age_secs=0)) |
| cache.write(h_large, [large]) |
| self.assertEqual(3, len(cache)) |
| self.assertEqual(101, cache.total_size) |
| self.assertEqual([], cache.trim()) |
| |
| self.assertEqual( |
| sorted([h_b, h_c, h_large, cache.STATE_FILE]), |
| sorted(fs.listdir(cache.cache_dir))) |
| |
| # Assert that trimming is done in constructor too. |
| cache = self.get_cache(_get_policies( |
| max_cache_size=100, |
| min_free_space=1000, |
| max_items=2, |
| max_age_secs=0)) |
| assertItems([(h_c, 1), (h_large, len(large))]) |
| self.assertEqual(None, cache._protected) |
| self.assertEqual(1202, cache._free_disk) |
| self.assertEqual(2, len(cache)) |
| self.assertEqual(100, cache.total_size) |
| self.assertEqual([], cache.trim()) |
| |
| def test_trim_policies_trim_old(self): |
| # Add two items, one 3 weeks and one minute old, one recent, make sure the |
| # old one is trimmed. |
| cache = self.get_cache(_get_policies( |
| max_cache_size=1000, |
| min_free_space=0, |
| max_items=1000, |
| max_age_secs=21*24*60*60)) |
| self._now = 100 |
| # Test the very limit of 3 weeks: |
| cache.write(self._algo(b'old').hexdigest(), [b'old']) |
| self._now += 1 |
| cache.write(self._algo(b'recent').hexdigest(), [b'recent']) |
| self._now += 21*24*60*60 |
| self.assertEqual([3], cache.trim()) |
| self.assertEqual([self._algo(b'recent').hexdigest()], list(cache)) |
| |
| def test_some_file_brutally_deleted(self): |
| h_a = self._algo(b'a').hexdigest() |
| self._free_disk = 1100 |
| cache = self.get_cache(_get_policies()) |
| cache.write(h_a, [b'a']) |
| self.assertTrue(cache.touch(h_a, local_caching.UNKNOWN_FILE_SIZE)) |
| self.assertTrue(cache.touch(h_a, 1)) |
| self.assertEqual([], cache.trim()) |
| |
| # file_path.remove() explicitly handle the +R bit on Windows. |
| file_path.remove(os.path.join(cache.cache_dir, h_a)) |
| |
| cache = self.get_cache(_get_policies()) |
| # 'Ghost' entry loaded with state.json is still there. |
| self.assertEqual([h_a], list(cache)) |
| # 'touch' detects the file is missing by returning False. |
| self.assertFalse(cache.touch(h_a, local_caching.UNKNOWN_FILE_SIZE)) |
| self.assertFalse(cache.touch(h_a, 1)) |
| # 'touch' evicted the entry. |
| self.assertEqual([], list(cache)) |
| |
| def test_invalid_state(self): |
| file_path.ensure_tree(self.cache_dir()) |
| statefile = os.path.join(self.cache_dir(), |
| local_caching.DiskContentAddressedCache.STATE_FILE) |
| with open(statefile, 'w') as f: |
| f.write('invalid') |
| with open(os.path.join(self.cache_dir(), 'invalid'), 'w') as f: |
| f.write('invalid') |
| |
| _ = self.get_cache(_get_policies()) |
| self.assertEqual(fs.listdir(self.cache_dir()), ['state.json']) |
| |
| |
| class NamedCacheTest(TestCase, CacheTestMixin): |
| def setUp(self): |
| super(NamedCacheTest, self).setUp() |
| self.cache_dir = os.path.join(self.tempdir, 'cache') |
| |
| def get_cache(self, policies): |
| return local_caching.NamedCache(self.cache_dir, policies) |
| |
| def get_named_cache(self, policies, keep): |
| return local_caching.NamedCache(self.cache_dir, policies, keep=keep) |
| |
| def test_clean_cache(self): |
| dest_dir = os.path.join(self.tempdir, 'dest') |
| cache = self.get_cache(_get_policies()) |
| self.assertEqual([], fs.listdir(cache.cache_dir)) |
| |
| a_path = os.path.join(dest_dir, 'a') |
| b_path = os.path.join(dest_dir, 'b') |
| |
| self.assertEqual(0, cache.install(a_path, '1')) |
| self.assertEqual(0, cache.install(b_path, '2')) |
| self.assertEqual( |
| False, fs.exists(os.path.join(cache.cache_dir, cache.NAMED_DIR))) |
| |
| self.assertEqual({'a', 'b'}, set(fs.listdir(dest_dir))) |
| self.assertFalse(cache.available) |
| self.assertEqual([cache.STATE_FILE], fs.listdir(cache.cache_dir)) |
| |
| write_file(os.path.join(a_path, 'x'), b'x') |
| write_file(os.path.join(b_path, 'y'), b'y') |
| |
| self.assertEqual(1, cache.uninstall(a_path, '1')) |
| self.assertEqual(1, cache.uninstall(b_path, '2')) |
| |
| self.assertEqual(4, len(fs.listdir(cache.cache_dir))) |
| path1 = os.path.join(cache.cache_dir, cache._lru['1'][0]) |
| self.assertEqual(b'x', read_file(os.path.join(path1, 'x'))) |
| path2 = os.path.join(cache.cache_dir, cache._lru['2'][0]) |
| self.assertEqual(b'y', read_file(os.path.join(path2, 'y'))) |
| self.assertEqual(os.path.join('..', cache._lru['1'][0]), |
| fs.readlink(cache._get_named_path('1'))) |
| self.assertEqual(os.path.join('..', cache._lru['2'][0]), |
| fs.readlink(cache._get_named_path('2'))) |
| self.assertEqual( |
| ['1', '2'], |
| sorted(fs.listdir(os.path.join(cache.cache_dir, cache.NAMED_DIR)))) |
| |
| def test_existing_cache(self): |
| # Ensures that the code does what is expected under number use. |
| dest_dir = os.path.join(self.tempdir, 'dest') |
| cache = self.get_cache(_get_policies()) |
| # Assume test_clean passes. |
| a_path = os.path.join(dest_dir, 'a') |
| b_path = os.path.join(dest_dir, 'b') |
| |
| self.assertEqual(0, cache.install(a_path, '1')) |
| write_file(os.path.join(dest_dir, 'a', 'x'), b'x') |
| self.assertEqual(1, cache.uninstall(a_path, '1')) |
| |
| # Test starts here. |
| self.assertEqual(1, cache.install(a_path, '1')) |
| self.assertEqual(0, cache.install(b_path, '2')) |
| self.assertEqual({'a', 'b'}, set(fs.listdir(dest_dir))) |
| self.assertFalse(cache.available) |
| self.assertEqual( |
| sorted([cache.NAMED_DIR, cache.STATE_FILE]), |
| sorted(fs.listdir(cache.cache_dir))) |
| self.assertEqual( |
| [], fs.listdir(os.path.join(cache.cache_dir, cache.NAMED_DIR))) |
| |
| self.assertEqual(b'x', read_file(os.path.join(dest_dir, 'a', 'x'))) |
| write_file(os.path.join(a_path, 'x'), b'x2') |
| write_file(os.path.join(b_path, 'y'), b'y') |
| |
| self.assertEqual(2, cache.uninstall(a_path, '1')) |
| self.assertEqual(1, cache.uninstall(b_path, '2')) |
| |
| self.assertEqual(4, len(fs.listdir(cache.cache_dir))) |
| path1 = os.path.join(cache.cache_dir, cache._lru['1'][0]) |
| self.assertEqual(b'x2', read_file(os.path.join(path1, 'x'))) |
| path2 = os.path.join(cache.cache_dir, cache._lru['2'][0]) |
| self.assertEqual(b'y', read_file(os.path.join(path2, 'y'))) |
| self.assertEqual(os.path.join('..', cache._lru['1'][0]), |
| fs.readlink(cache._get_named_path('1'))) |
| self.assertEqual(os.path.join('..', cache._lru['2'][0]), |
| fs.readlink(cache._get_named_path('2'))) |
| self.assertEqual( |
| ['1', '2'], |
| sorted(fs.listdir(os.path.join(cache.cache_dir, cache.NAMED_DIR)))) |
| |
| def test_install_throws(self): |
| old_isdir = None |
| banged = [] |
| # Crashes, but only on the first call. |
| def bang(path): |
| if not banged: |
| banged.append(True) |
| raise IOError('fake') |
| return old_isdir(path) |
| |
| cache = self.get_cache(_get_policies()) |
| dest_dir = os.path.join(self.tempdir, 'dest') |
| |
| # fs.isdir() happens to be the first function called. |
| old_isdir = self.mock(fs, 'isdir', bang) |
| with self.assertRaises(local_caching.NamedCacheError): |
| cache.install(dest_dir, '1') |
| |
| def test_uninstall_throws(self): |
| old_isdir = None |
| banged = [] |
| # Crashes, but only on the first call. |
| def bang(path): |
| if not banged: |
| banged.append(True) |
| raise IOError('fake') |
| return old_isdir(path) |
| |
| cache = self.get_cache(_get_policies()) |
| dest_dir = os.path.join(self.tempdir, 'dest') |
| self.assertEqual(0, cache.install(dest_dir, '1')) |
| |
| # fs.isdir() happens to be the first function called. |
| old_isdir = self.mock(fs, 'isdir', bang) |
| with self.assertRaises(local_caching.NamedCacheError): |
| cache.uninstall(dest_dir, '1') |
| |
| def test_cycle_twice(self): |
| # Ensure that named symlink works. |
| cache = self.get_cache(_get_policies()) |
| dest_dir = os.path.join(self.tempdir, 'dest') |
| self.assertEqual(0, cache.install(dest_dir, '1')) |
| with fs.open(os.path.join(dest_dir, 'hi'), 'wb') as f: |
| f.write(b'hello') |
| self.assertEqual(5, cache.uninstall(dest_dir, '1')) |
| self.assertEqual(['1'], |
| fs.listdir(os.path.join(cache.cache_dir, cache.NAMED_DIR))) |
| self.assertEqual(True, cache.cleanup()) |
| self.assertEqual(5, cache.install(dest_dir, '1')) |
| self.assertEqual(5, cache.uninstall(dest_dir, '1')) |
| self.assertEqual(['1'], |
| fs.listdir(os.path.join(cache.cache_dir, cache.NAMED_DIR))) |
| self.assertEqual(['hi'], |
| fs.listdir( |
| os.path.join(cache.cache_dir, cache.NAMED_DIR, '1'))) |
| |
| def test_save_named(self): |
| cache = self.get_cache(_get_policies()) |
| self.assertEqual([], sorted(fs.listdir(cache.cache_dir))) |
| |
| self._add_one_item(cache, 2) |
| with fs.open(os.path.join(cache.cache_dir, cache.STATE_FILE)) as f: |
| old_content = json.load(f) |
| # It's immediately saved. |
| items = lru.LRUDict.load(os.path.join(cache.cache_dir, cache.STATE_FILE)) |
| self.assertEqual(1, len(items)) |
| _key, (v, _timestamp) = items.get_oldest() |
| # This depends on the inner format as generated by NamedCache. |
| entry_dir_name = v[0] |
| self.assertEqual( |
| sorted([entry_dir_name, cache.NAMED_DIR, cache.STATE_FILE]), |
| sorted(fs.listdir(cache.cache_dir))) |
| |
| cache.save() |
| self.assertEqual( |
| sorted([entry_dir_name, cache.NAMED_DIR, cache.STATE_FILE]), |
| sorted(fs.listdir(cache.cache_dir))) |
| with fs.open(os.path.join(cache.cache_dir, cache.STATE_FILE)) as f: |
| new_content = json.load(f) |
| # That's because uninstall() called from self._add_one_item() |
| # causes an implicit save(). See uninstall() comments for more details. |
| self.assertEqual(new_content, old_content) |
| |
| def test_touch(self): |
| cache = self.get_cache(_get_policies()) |
| caches_to_touch = [] |
| caches_to_touch.append(self._add_one_item(cache, 1)) |
| caches_to_touch.append(self._add_one_item(cache, 2)) |
| self._add_one_item(cache, 3) |
| cache.touch(*caches_to_touch) |
| cache.remove_oldest_evictable_item() |
| self.assertEqual(len(cache), 2) |
| self.assertEqual(sorted(caches_to_touch), sorted(cache)) |
| |
| def test_trim(self): |
| cache = self.get_cache(_get_policies(max_items=2)) |
| item_count = 12 |
| for i in range(item_count): |
| self._add_one_item(cache, i+1) |
| self.assertEqual(len(cache), item_count) |
| self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], cache.trim()) |
| self.assertEqual(len(cache), 2) |
| self.assertEqual( |
| ['11', '12'], |
| sorted(fs.listdir(os.path.join(cache.cache_dir, cache.NAMED_DIR)))) |
| |
| def test_trim_with_keep(self): |
| cache = self.get_named_cache(_get_policies(max_items=2), keep=['1', '2']) |
| item_count = 12 |
| for i in range(item_count): |
| self._add_one_item(cache, i + 1) |
| self.assertEqual(len(cache), item_count) |
| self.assertEqual([3, 4, 5, 6, 7, 8, 9, 10, 11, 12], cache.trim()) |
| self.assertEqual(len(cache), 2) |
| self.assertEqual( |
| ['1', '2'], |
| sorted(fs.listdir(os.path.join(cache.cache_dir, cache.NAMED_DIR)))) |
| |
| def test_trim_ignores_policy_for_keep_items(self): |
| """ |
| In this case, we specify 3 items which should not be evicted but |
| a max_items value of 2. |
| |
| Under these circumstances, trim will remove items until only the 3 items |
| remain. |
| """ |
| cache = self.get_named_cache(_get_policies(max_items=2), |
| keep=['1', '2', '3']) |
| item_count = 12 |
| for i in range(item_count): |
| self._add_one_item(cache, i + 1) |
| self.assertEqual(len(cache), item_count) |
| self.assertEqual([4, 5, 6, 7, 8, 9, 10, 11, 12], cache.trim()) |
| self.assertEqual(len(cache), 3) |
| self.assertEqual( |
| ['1', '2', '3'], |
| sorted(fs.listdir(os.path.join(cache.cache_dir, cache.NAMED_DIR)))) |
| |
| def test_trim_ignores_irrelevant_keep_items(self): |
| """ |
| Ignore dont evict if none of its items are in the cache. |
| """ |
| cache = self.get_named_cache(_get_policies(max_items=2), keep=['a', 'b']) |
| item_count = 12 |
| for i in range(item_count): |
| self._add_one_item(cache, i + 1) |
| self.assertEqual(len(cache), item_count) |
| self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], cache.trim()) |
| self.assertEqual(len(cache), 2) |
| self.assertEqual( |
| ['11', '12'], |
| sorted(fs.listdir(os.path.join(cache.cache_dir, cache.NAMED_DIR)))) |
| |
| def test_load_corrupted_state(self): |
| # cleanup() handles a broken state file. |
| fs.mkdir(self.cache_dir) |
| c = local_caching.NamedCache |
| with fs.open(os.path.join(self.cache_dir, c.STATE_FILE), 'w') as f: |
| f.write('}}}}') |
| fs.makedirs(os.path.join(self.cache_dir, '1'), 0o777) |
| |
| cache = self.get_cache(_get_policies()) |
| self._add_one_item(cache, 1) |
| self.assertTrue( |
| fs.exists(os.path.join(cache.cache_dir, cache.NAMED_DIR, '1'))) |
| self.assertTrue( |
| fs.islink(os.path.join(cache.cache_dir, cache.NAMED_DIR, '1'))) |
| self.assertEqual([], cache.trim()) |
| self.assertTrue( |
| fs.exists(os.path.join(cache.cache_dir, cache.NAMED_DIR, '1'))) |
| self.assertTrue( |
| fs.islink(os.path.join(cache.cache_dir, cache.NAMED_DIR, '1'))) |
| self.assertEqual(True, cache.cleanup()) |
| self.assertEqual( |
| sorted([cache.NAMED_DIR, cache.STATE_FILE, cache._lru['1'][0]]), |
| sorted(fs.listdir(cache.cache_dir))) |
| |
| @unittest.skipIf(sys.platform == 'win32', 'crbug.com/1148174') |
| def test_cleanup_missing(self): |
| # cleanup() detects a missing item. |
| cache = self.get_cache(_get_policies()) |
| self._add_one_item(cache, 1) |
| file_path.rmtree(os.path.join(cache.cache_dir, cache._lru['1'][0])) |
| |
| cache = self.get_cache(_get_policies()) |
| self.assertEqual(['1'], list(cache)) |
| self.assertEqual(True, cache.cleanup()) |
| self.assertEqual([], list(cache)) |
| |
| def test_cleanup_unexpected(self): |
| # cleanup() delete unexpected file in the cache directory. |
| fs.mkdir(self.cache_dir) |
| with fs.open(os.path.join(self.cache_dir, 'junk'), 'w') as f: |
| f.write('random') |
| cache = self.get_cache(_get_policies()) |
| self.assertEqual(['junk'], fs.listdir(cache.cache_dir)) |
| self.assertEqual(True, cache.cleanup()) |
| self.assertEqual([cache.STATE_FILE], fs.listdir(cache.cache_dir)) |
| |
| def test_cleanup_unexpected_named(self): |
| # cleanup() deletes unexpected symlink and directory in named/. |
| fs.mkdir(self.cache_dir) |
| c = local_caching.NamedCache |
| fs.mkdir(os.path.join(self.cache_dir, c.NAMED_DIR)) |
| p = os.path.join(self.cache_dir, c.NAMED_DIR, 'junk_file') |
| with fs.open(p, 'w') as f: |
| f.write('random') |
| fs.mkdir(os.path.join(self.cache_dir, c.NAMED_DIR, 'junk_dir')) |
| fs.symlink('invalid_dest', |
| os.path.join(self.cache_dir, c.NAMED_DIR, 'junk_link')) |
| |
| cache = self.get_cache(_get_policies()) |
| self.assertEqual([cache.NAMED_DIR], fs.listdir(cache.cache_dir)) |
| self.assertEqual( |
| ['junk_dir', 'junk_file', 'junk_link'], |
| sorted(fs.listdir(os.path.join(cache.cache_dir, cache.NAMED_DIR)))) |
| self.assertEqual(True, cache.cleanup()) |
| self.assertEqual( |
| [cache.NAMED_DIR, cache.STATE_FILE], |
| sorted(fs.listdir(cache.cache_dir))) |
| self.assertEqual( |
| [], fs.listdir(os.path.join(cache.cache_dir, cache.NAMED_DIR))) |
| |
| def test_cleanup_incorrect_link(self): |
| # cleanup() repairs broken symlink in named/. |
| cache = self.get_cache(_get_policies()) |
| self._add_one_item(cache, 1) |
| self._add_one_item(cache, 2) |
| fs.remove(os.path.join(self.cache_dir, cache.NAMED_DIR, '1')) |
| fs.remove(os.path.join(self.cache_dir, cache.NAMED_DIR, '2')) |
| fs.symlink('invalid_dest', os.path.join(self.cache_dir, cache.NAMED_DIR, |
| '1')) |
| fs.mkdir(os.path.join(self.cache_dir, cache.NAMED_DIR, '2')) |
| |
| cache = self.get_cache(_get_policies()) |
| self.assertEqual( |
| ['1', '2'], |
| sorted(fs.listdir(os.path.join(cache.cache_dir, cache.NAMED_DIR)))) |
| self.assertEqual(True, cache.cleanup()) |
| self.assertEqual( |
| [], fs.listdir(os.path.join(cache.cache_dir, cache.NAMED_DIR))) |
| |
| def test_upgrade(self): |
| # Make sure upgrading works. This is temporary as eventually all bots will |
| # be updated. |
| now = time.time() |
| fs.mkdir(self.cache_dir) |
| fs.mkdir(os.path.join(self.cache_dir, 'f1')) |
| with fs.open(os.path.join(self.cache_dir, 'f1', 'hello'), 'wb') as f: |
| f.write(b'world') |
| # v1 |
| old = { |
| 'version': lru.CURRENT_VERSION, |
| 'items': [['cache1', [['f1', len('world')], now]],], |
| } |
| c = local_caching.NamedCache |
| with fs.open(os.path.join(self.cache_dir, c.STATE_FILE), 'w') as f: |
| json.dump(old, f) |
| # It automatically upgrades to v2. |
| cache = self.get_cache(_get_policies()) |
| expected = {'cache1': [['f1', len('world')], now]} |
| self.assertEqual(expected, dict(cache._lru._items.items())) |
| self.assertEqual(['f1', cache.STATE_FILE], |
| sorted(fs.listdir(cache.cache_dir))) |
| |
| |
| def _gen_state(items): |
| state = {'items': items, 'version': lru.CURRENT_VERSION} |
| return json.dumps( |
| state, sort_keys=True, separators=(',', ':')).encode('utf-8') |
| |
| |
| class FnTest(TestCase): |
| """Test functions that leverage both DiskContentAddressedCache and |
| NamedCache. |
| """ |
| def setUp(self): |
| super(FnTest, self).setUp() |
| # Simulate that the memory cache used disk space. |
| def remove_oldest(c): |
| s = old_remove_oldest(c) |
| self._free_disk += s |
| return s |
| |
| old_remove_oldest = self.mock(local_caching.MemoryContentAddressedCache, |
| 'remove_oldest_evictable_item', remove_oldest) |
| |
| def _prepare_cache(self, cache): |
| now = self._now |
| for i in range(1, 11): |
| self._add_one_item(cache, i) |
| self._now += 1 |
| self._now = now |
| self.assertEqual([], cache.trim()) |
| |
| def _prepare_isolated_cache(self, cache): |
| self._prepare_cache(cache) |
| self._verify_isolated_cache(cache, range(1, 11)) |
| |
| def _verify_isolated_cache(self, cache, items): |
| # Isolated cache verification. |
| expected = { |
| self._algo(_gen_data(n)).hexdigest(): _gen_data(n) |
| for n in items |
| } |
| expected[cache.STATE_FILE] = _gen_state( |
| [[self._algo(_gen_data(n)).hexdigest(), [n, self._now + n - 1]] |
| for n in items]) |
| self.assertEqual(expected, read_tree(cache.cache_dir)) |
| |
| def _prepare_named_cache(self, cache): |
| self._prepare_cache(cache) |
| # Figure out the short names via the symlinks. |
| items = range(1, 11) |
| short_names = { |
| n: os.path.basename( |
| fs.readlink(os.path.join(cache.cache_dir, cache.NAMED_DIR, str(n)))) |
| for n in items |
| } |
| self._verify_named_cache(cache, short_names, items) |
| return short_names |
| |
| def _verify_named_cache(self, cache, short_names, items): |
| # Named cache verification. Ensures the cache contain the expected data. |
| actual = read_tree(cache.cache_dir) |
| # There's assumption about json encoding format but here it's good enough. |
| expected = { |
| os.path.join(short_names[n], 'hello'): _gen_data(n) |
| for n in items |
| } |
| expected[cache.STATE_FILE] = _gen_state( |
| [[str(n), [[short_names[n], n], self._now + n - 1]] for n in items]) |
| self.assertEqual(expected, actual) |
| |
| def test_clean_caches_disk(self): |
| # Create an isolated cache and a named cache each with 2 items. Ensure that |
| # one item from each is removed. |
| now = self._now |
| self._free_disk = 100000 |
| |
| # Setup caches. |
| policies = _get_policies(min_free_space=1000) |
| named_cache = local_caching.NamedCache( |
| tempfile.mkdtemp(dir=self.tempdir, prefix='nc'), policies) |
| short_names = self._prepare_named_cache(named_cache) |
| |
| isolated_cache = local_caching.DiskContentAddressedCache( |
| tempfile.mkdtemp(dir=self.tempdir, prefix='ic'), policies, trim=False) |
| self._prepare_isolated_cache(isolated_cache) |
| self.assertEqual(now, self._now) |
| |
| # Request trimming. |
| self._free_disk = 950 |
| trimmed = local_caching.trim_caches( |
| [isolated_cache, named_cache], |
| self.tempdir, |
| min_free_space=policies.min_free_space, |
| max_age_secs=policies.max_age_secs) |
| # Enough to free 50 bytes. The following sums to 56. |
| expected = [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7] |
| self.assertEqual(expected, trimmed) |
| |
| # Cache verification. |
| self._verify_named_cache(named_cache, short_names, range(8, 11)) |
| self._verify_isolated_cache(isolated_cache, range(8, 11)) |
| |
| def test_trim_named_cache_with_no_evictable_items(self): |
| """Give a named cache where every entry is on the keep list. Trim must |
| deal with this by trimming nothing.""" |
| policies = _get_policies(min_free_space=1) |
| named_cache = local_caching.NamedCache(tempfile.mkdtemp(dir=self.tempdir, |
| prefix='nc'), |
| policies, |
| keep=[str(x) for x in range(1, 12)]) |
| self._prepare_named_cache(named_cache) |
| trimmed = local_caching.trim_caches([named_cache], |
| self.tempdir, |
| min_free_space=policies.min_free_space, |
| max_age_secs=policies.max_age_secs) |
| self.assertEqual([], trimmed) |
| |
| def _get_5_caches(self): |
| # Add items from size 1 to 101 randomly into 5 caches. |
| caches = [ |
| local_caching.MemoryContentAddressedCache(), |
| local_caching.MemoryContentAddressedCache(), |
| local_caching.MemoryContentAddressedCache(), |
| local_caching.MemoryContentAddressedCache(), |
| local_caching.MemoryContentAddressedCache(), |
| ] |
| for i in range(100): |
| self._add_one_item(caches[random.randint(0, len(caches)-1)], i+1) |
| self._now += 1 |
| return caches |
| |
| def test_clean_caches_memory_size(self): |
| # Test that cleaning is correctly distributed independent of the cache |
| # location. |
| caches = self._get_5_caches() |
| # 100 bytes must be freed. |
| self._free_disk = 900 |
| trimmed = local_caching.trim_caches( |
| caches, |
| self.tempdir, |
| min_free_space=1000, |
| max_age_secs=0) |
| # sum(range(1, 15)) == 105, the first value after 100. |
| self.assertEqual(list(range(1, 15)), trimmed) |
| |
| def test_clean_caches_memory_time(self): |
| # Test that cleaning is correctly distributed independent of the cache |
| # location. |
| caches = self._get_5_caches() |
| self.mock(time, 'time', lambda: self._now) |
| trimmed = local_caching.trim_caches( |
| caches, |
| self.tempdir, |
| min_free_space=0, |
| max_age_secs=10) |
| # Only the last 10 items are kept. The first 90 items were trimmed. |
| self.assertEqual(list(range(1, 91)), trimmed) |
| |
| |
| if __name__ == '__main__': |
| test_env.main() |