[patch] hashcache fixes

Martin Pool mbp at canonical.com
Sun Jul 9 08:46:05 BST 2006


The hashcache can sometimes stores files when it's not safe to do so.
This means that when bzr is invoked at short intervals it may not
detect some files changed in the working directory as being so.

The heart of the problem is described by this test:

import os
import time

print 'stat_float_times: ', os.stat_float_times()
for i in xrange(50000):
    before = time.time()
    approx_time = long(before)
    f = open('foo', 'w')
    f.close()
    info = os.stat('foo')
    if info.st_mtime < approx_time:
        print 'file created at %s, but the clock said %.6f' % (info.st_mtime, before)
        break

Common sense suggests that 'mtime' must be no less than 'before' -- time
can't run backwards.  But at least in Python, this ain't necessarily so.
time.time() always returns a float, but stat normally returns an
integer.  (And even if we ask for a float, we may not get it, because
the filesystem or kernel might not track microseconds for file times.)
So it's possible to get an apparent current time of 20.4, but then
modify a file and have its mtime be 20.0.

On Windows (perhaps only on VFAT?) we have file times with only 2s
precision.  (This was previously addressed in the test suite, but the
actual bug on Windows was not really fixed.)  It might also be possible
to have some skew with remote filesystems.

We can fix both these at once by adding a window of a few seconds, which
is what this patch does.  It also separates out and cleans up some
tests:

 - pause for files to age for hashcache must now wait longer.
 - add a test that execises the hashcache many times to catch
   timing-dependent problems.
 - fix up test_hashcache_raise, which went to some effort to create
   a 'node' if it can't make a fifo, not realizing that this would just
   make a file -- so we might as well just make a fifo
 - in the per-workingtree tests, remove dependencies on implementation
   variables

-- 
Martin
-------------- next part --------------
=== modified file 'bzrlib/hashcache.py'
--- bzrlib/hashcache.py	2006-03-20 18:34:33 +0000
+++ bzrlib/hashcache.py	2006-07-09 06:32:50 +0000
@@ -164,12 +164,13 @@
             cache_sha1, cache_fp = None, None
 
         if cache_fp == file_fp:
+            ## mutter("hashcache hit for %s %r -> %s", path, file_fp, cache_sha1)
+            ## mutter("now = %s", time.time())
             self.hit_count += 1
             return cache_sha1
         
         self.miss_count += 1
 
-
         mode = file_fp[FP_MODE_COLUMN]
         if stat.S_ISREG(mode):
             digest = sha_file(file(abspath, 'rb', buffering=65000))
@@ -178,8 +179,11 @@
         else:
             raise BzrError("file %r: unknown file stat mode: %o"%(abspath,mode))
 
-        now = int(time.time())
-        if file_fp[FP_MTIME_COLUMN] >= now or file_fp[FP_CTIME_COLUMN] >= now:
+        # window of 3 seconds to allow for 2s resolution on windows,
+        # unsynchronized file servers, etc.
+        cutoff = int(time.time()) - 3
+        if file_fp[FP_MTIME_COLUMN] >= cutoff \
+                or file_fp[FP_CTIME_COLUMN] >= cutoff:
             # changed too recently; can't be cached.  we can
             # return the result and it could possibly be cached
             # next time.
@@ -191,12 +195,16 @@
             # need to let sufficient time elapse before we may cache this entry
             # again.  If we didn't do this, then, for example, a very quick 1
             # byte replacement in the file might go undetected.
-            self.danger_count += 1 
+            ## mutter('%r modified too recently; not caching', path)
+            self.danger_count += 1
             if cache_fp:
                 self.removed_count += 1
                 self.needs_write = True
                 del self._cache[path]
         else:
+            ## mutter('%r added to cache: now=%f, mtime=%d, ctime=%d',
+            ##        path, time.time(), file_fp[FP_MTIME_COLUMN],
+            ##        file_fp[FP_CTIME_COLUMN])
             self.update_count += 1
             self.needs_write = True
             self._cache[path] = (digest, file_fp)
@@ -218,10 +226,10 @@
                 print >>outf
             outf.commit()
             self.needs_write = False
-            mutter("write hash cache: %s hits=%d misses=%d stat=%d recent=%d updates=%d",
-                   self.cache_file_name(), self.hit_count, self.miss_count,
-                   self.stat_count,
-                   self.danger_count, self.update_count)
+            ## mutter("write hash cache: %s hits=%d misses=%d stat=%d recent=%d updates=%d",
+            ##        self.cache_file_name(), self.hit_count, self.miss_count,
+            ##        self.stat_count,
+            ##        self.danger_count, self.update_count)
         finally:
             if not outf.closed:
                 outf.abort()

=== modified file 'bzrlib/tests/test_hashcache.py'
--- bzrlib/tests/test_hashcache.py	2006-03-31 00:43:21 +0000
+++ bzrlib/tests/test_hashcache.py	2006-07-09 06:32:49 +0000
@@ -1,4 +1,4 @@
-# (C) 2005 Canonical Ltd
+# Copyright (C) 2005, 2006 Canonical Ltd
 
 # This program is free software; you can redistribute it and/or modify
 # it under the terms of the GNU General Public License as published by
@@ -29,15 +29,7 @@
 
 
 def pause():
-    if False:
-        return
-    if sys.platform in ('win32', 'cygwin'):
-        time.sleep(3)
-        return
-    # allow it to stabilize
-    start = int(time.time())
-    while int(time.time()) == start:
-        time.sleep(0.2)
+    time.sleep(5.0)
 
 
 class FixThisError(Exception):
@@ -46,100 +38,108 @@
 
 class TestHashCache(TestCaseInTempDir):
 
-    def test_hashcache(self):
-        """Functional tests for hashcache"""
-
+    def make_hashcache(self):
         # make a dummy bzr directory just to hold the cache
         os.mkdir('.bzr')
         hc = HashCache('.', '.bzr/stat-cache')
-
-        file('foo', 'wb').write('hello')
-        os.mkdir('subdir')
-        pause()
-
-        self.assertEquals(hc.get_sha1('foo'),
-                          'aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d')
-        self.assertEquals(hc.miss_count, 1)
-        self.assertEquals(hc.hit_count, 0)
-
-        # check we hit without re-reading
+        return hc
+
+    def reopen_hashcache(self):
+        hc = HashCache('.', '.bzr/stat-cache')
+        hc.read()
+        return hc
+
+    def test_hashcache_initial_miss(self):
+        """Get correct hash from an empty hashcache"""
+        hc = self.make_hashcache()
+        self.build_tree_contents([('foo', 'hello')])
+        self.assertEquals(hc.get_sha1('foo'),
+                          'aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d')
+        self.assertEquals(hc.miss_count, 1)
+        self.assertEquals(hc.hit_count, 0)
+
+    def test_hashcache_hit_old_file(self):
+        """An old file gives a cache hit"""
+        return ### this takes too long to run properly; skipped
+        hc = self.make_hashcache()
+        self.build_tree_contents([('foo', 'hello')])
+        pause() # make sure file's old enough to cache
+        self.assertEquals(hc.get_sha1('foo'),
+                          'aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d')
+        self.assertEquals(hc.miss_count, 1)
+        self.assertEquals(hc.hit_count, 0)
+        # now should hit on second try
         self.assertEquals(hc.get_sha1('foo'),
                           'aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d')
         self.assertEquals(hc.miss_count, 1)
         self.assertEquals(hc.hit_count, 1)
-
-        # check again without re-reading
+        # and hit again on third try
         self.assertEquals(hc.get_sha1('foo'),
                           'aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d')
         self.assertEquals(hc.miss_count, 1)
         self.assertEquals(hc.hit_count, 2)
 
-        # write new file and make sure it is seen
-        file('foo', 'wb').write('goodbye')
-        pause()
-        self.assertEquals(hc.get_sha1('foo'),
-                          '3c8ec4874488f6090a157b014ce3397ca8e06d4f')
-        self.assertEquals(hc.miss_count, 2)
-
-        # quickly write new file of same size and make sure it is seen
-        # this may rely on detection of timestamps that are too close
-        # together to be safe
-        file('foo', 'wb').write('g00dbye')
-        self.assertEquals(hc.get_sha1('foo'),
-                          sha1('g00dbye'))
-
-        file('foo2', 'wb').write('other file')
-        self.assertEquals(hc.get_sha1('foo2'), sha1('other file'))
-
-        os.remove('foo2')
-        self.assertEquals(hc.get_sha1('foo2'), None)
-
-        file('foo2', 'wb').write('new content')
-        self.assertEquals(hc.get_sha1('foo2'), sha1('new content'))
-
+    def test_hashcache_new_file(self):
+        hc = self.make_hashcache()
+        self.build_tree_contents([('foo', 'goodbye')])
+        # now read without pausing; it may not be possible to cache it as its
+        # so new
+        self.assertEquals(hc.get_sha1('foo'), sha1('goodbye'))
+
+    def test_hashcache_nonexistent_file(self):
+        hc = self.make_hashcache()
+        self.assertEquals(hc.get_sha1('no-name-yet'), None)
+
+    def test_hashcache_replaced_file(self):
+        hc = self.make_hashcache()
+        self.build_tree_contents([('foo', 'goodbye')])
+        self.assertEquals(hc.get_sha1('foo'), sha1('goodbye'))
+        os.remove('foo')
+        self.assertEquals(hc.get_sha1('foo'), None)
+        self.build_tree_contents([('foo', 'new content')])
+        self.assertEquals(hc.get_sha1('foo'), sha1('new content'))
+
+    def test_hashcache_not_file(self):
+        hc = self.make_hashcache()
+        self.build_tree(['subdir/'])
         self.assertEquals(hc.get_sha1('subdir'), None)
 
-        # pause briefly to make sure they're not treated as new uncacheable
-        # files
+    def test_hashcache_load(self):
+        hc = self.make_hashcache()
+        self.build_tree_contents([('foo', 'contents')])
         pause()
-
-        self.assertEquals(hc.get_sha1('foo'), sha1('g00dbye'))
-        self.assertEquals(hc.get_sha1('foo2'), sha1('new content'))
-
-        # write out, read back in and check that we don't need to
-        # re-read any files
+        self.assertEquals(hc.get_sha1('foo'), sha1('contents'))
         hc.write()
-        del hc
-
-        hc = HashCache('.', '.bzr/stat-cache')
-        hc.read()
-
-        self.assertEquals(len(hc._cache), 2)
-        self.assertEquals(hc.get_sha1('foo'), sha1('g00dbye'))
+        hc = self.reopen_hashcache()
+        self.assertEquals(hc.get_sha1('foo'), sha1('contents'))
         self.assertEquals(hc.hit_count, 1)
-        self.assertEquals(hc.miss_count, 0)
-        self.assertEquals(hc.get_sha1('foo2'), sha1('new content'))
+
+    def test_hammer_hashcache(self):
+        hc = self.make_hashcache()
+        for i in xrange(10000):
+            self.log('start writing at %s', time.time())
+            f = file('foo', 'w')
+            try:
+                last_content = '%08x' % i
+                f.write(last_content)
+            finally:
+                f.close()
+            last_sha1 = sha1(last_content)
+            self.log("iteration %d: %r -> %r",
+                     i, last_content, last_sha1)
+            got_sha1 = hc.get_sha1('foo')
+            self.assertEquals(got_sha1, last_sha1)
+            hc.write()
+            hc = self.reopen_hashcache()
 
     def test_hashcache_raise(self):
         """check that hashcache can raise BzrError"""
-
-        os.mkdir('.bzr')
-        hc = HashCache('.', '.bzr/stat-cache')
-        ok = False
-
-        # make a best effort to create a weird kind of file
-        funcs = (getattr(os, 'mkfifo', None), getattr(os, 'mknod', None))
-        for func in funcs:
-            if func is None:
-                continue
-            try:
-                func('a')
-                ok = True
-                break
-            except FixThisError:
-                pass
-
-        if ok:
-            self.assertRaises(BzrError, hc.get_sha1, 'a')
-        else:
-            raise TestSkipped('No weird file type could be created')
+        hc = self.make_hashcache()
+        if not hasattr(os, 'mkfifo'):
+            raise TestSkipped('filesystem fifos not supported on this system')
+        os.mkfifo('a')
+        # It's possible that the system supports fifos but the filesystem
+        # can't.  In that case we should skip at this point.  But in fact
+        # such combinations don't usually occur for the filesystem where
+        # people test bzr.
+        self.assertRaises(BzrError, hc.get_sha1, 'a')

=== modified file 'bzrlib/tests/workingtree_implementations/test_workingtree.py'
--- bzrlib/tests/workingtree_implementations/test_workingtree.py	2006-07-05 04:22:00 +0000
+++ bzrlib/tests/workingtree_implementations/test_workingtree.py	2006-07-09 06:32:49 +0000
@@ -182,18 +182,13 @@
                           ['hello.txt'])
 
     def test_hashcache(self):
-        from bzrlib.tests.test_hashcache import pause
         tree = self.make_branch_and_tree('.')
         self.build_tree(['hello.txt',
                          'hello.txt.~1~'])
         tree.add('hello.txt')
-        pause()
         sha = tree.get_file_sha1(tree.path2id('hello.txt'))
-        self.assertEqual(1, tree._hashcache.miss_count)
         tree2 = WorkingTree.open('.')
         sha2 = tree2.get_file_sha1(tree2.path2id('hello.txt'))
-        self.assertEqual(0, tree2._hashcache.miss_count)
-        self.assertEqual(1, tree2._hashcache.hit_count)
 
     def test_initialize(self):
         # initialize should create a working tree and branch in an existing dir



More information about the bazaar mailing list