~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/revfile.py

  • Committer: Martin Pool
  • Date: 2010-01-12 02:00:23 UTC
  • mto: This revision was merged to the branch mainline in revision 4949.
  • Revision ID: mbp@sourcefrog.net-20100112020023-ib3ii1wcpvljmprk
Update bug handling doc to deprecate fixcommitted and to explain other states better

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
#! /usr/bin/env python
2
 
 
3
 
# (C) 2005 Canonical Ltd
4
 
 
5
 
# based on an idea by Matt Mackall
6
 
# modified to squish into bzr by Martin Pool
7
 
 
8
 
# This program is free software; you can redistribute it and/or modify
9
 
# it under the terms of the GNU General Public License as published by
10
 
# the Free Software Foundation; either version 2 of the License, or
11
 
# (at your option) any later version.
12
 
 
13
 
# This program is distributed in the hope that it will be useful,
14
 
# but WITHOUT ANY WARRANTY; without even the implied warranty of
15
 
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16
 
# GNU General Public License for more details.
17
 
 
18
 
# You should have received a copy of the GNU General Public License
19
 
# along with this program; if not, write to the Free Software
20
 
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
21
 
 
22
 
 
23
 
"""Packed file revision storage.
24
 
 
25
 
A Revfile holds the text history of a particular source file, such
26
 
as Makefile.  It can represent a tree of text versions for that
27
 
file, allowing for microbranches within a single repository.
28
 
 
29
 
This is stored on disk as two files: an index file, and a data file.
30
 
The index file is short and always read completely into memory; the
31
 
data file is much longer and only the relevant bits of it,
32
 
identified by the index file, need to be read.
33
 
 
34
 
Each text version is identified by the SHA-1 of the full text of
35
 
that version.  It also has a sequence number within the file.
36
 
 
37
 
The index file has a short header and then a sequence of fixed-length
38
 
records:
39
 
 
40
 
* byte[20]    SHA-1 of text (as binary, not hex)
41
 
* uint32      sequence number this is based on, or -1 for full text
42
 
* uint32      flags: 1=zlib compressed
43
 
* uint32      offset in text file of start
44
 
* uint32      length of compressed delta in text file
45
 
* uint32[3]   reserved
46
 
 
47
 
total 48 bytes.
48
 
 
49
 
The header is also 48 bytes for tidyness and easy calculation.
50
 
 
51
 
Both the index and the text are only ever appended to; a consequence
52
 
is that sequence numbers are stable references.  But not every
53
 
repository in the world will assign the same sequence numbers,
54
 
therefore the SHA-1 is the only universally unique reference.
55
 
 
56
 
This is meant to scale to hold 100,000 revisions of a single file, by
57
 
which time the index file will be ~4.8MB and a bit big to read
58
 
sequentially.
59
 
 
60
 
Some of the reserved fields could be used to implement a (semi?)
61
 
balanced tree indexed by SHA1 so we can much more efficiently find the
62
 
index associated with a particular hash.  For 100,000 revs we would be
63
 
able to find it in about 17 random reads, which is not too bad.
64
 
 
65
 
This performs pretty well except when trying to calculate deltas of
66
 
really large files.  For that the main thing would be to plug in
67
 
something faster than difflib, which is after all pure Python.
68
 
Another approach is to just store the gzipped full text of big files,
69
 
though perhaps that's too perverse?
70
 
 
71
 
The iter method here will generally read through the whole index file
72
 
in one go.  With readahead in the kernel and python/libc (typically
73
 
128kB) this means that there should be no seeks and often only one
74
 
read() call to get everything into memory.
75
 
"""
76
 
 
77
 
 
78
 
# TODO: Something like pread() would make this slightly simpler and
79
 
# perhaps more efficient.
80
 
 
81
 
# TODO: Could also try to mmap things...  Might be faster for the
82
 
# index in particular?
83
 
 
84
 
# TODO: Some kind of faster lookup of SHAs?  The bad thing is that probably means
85
 
# rewriting existing records, which is not so nice.
86
 
 
87
 
# TODO: Something to check that regions identified in the index file
88
 
# completely butt up and do not overlap.  Strictly it's not a problem
89
 
# if there are gaps and that can happen if we're interrupted while
90
 
# writing to the datafile.  Overlapping would be very bad though.
91
 
 
92
 
 
93
 
 
94
 
import sys, zlib, struct, mdiff, stat, os, sha
95
 
from binascii import hexlify, unhexlify
96
 
 
97
 
_RECORDSIZE = 48
98
 
 
99
 
_HEADER = "bzr revfile v1\n"
100
 
_HEADER = _HEADER + ('\xff' * (_RECORDSIZE - len(_HEADER)))
101
 
_NO_RECORD = 0xFFFFFFFFL
102
 
 
103
 
# fields in the index record
104
 
I_SHA = 0
105
 
I_BASE = 1
106
 
I_FLAGS = 2
107
 
I_OFFSET = 3
108
 
I_LEN = 4
109
 
 
110
 
FL_GZIP = 1
111
 
 
112
 
# maximum number of patches in a row before recording a whole text.
113
 
CHAIN_LIMIT = 25
114
 
 
115
 
 
116
 
class RevfileError(Exception):
117
 
    pass
118
 
 
119
 
class LimitHitException(Exception):
120
 
    pass
121
 
 
122
 
class Revfile(object):
123
 
    def __init__(self, basename, mode):
124
 
        # TODO: Lock file  while open
125
 
 
126
 
        # TODO: advise of random access
127
 
 
128
 
        self.basename = basename
129
 
 
130
 
        if mode not in ['r', 'w']:
131
 
            raise RevfileError("invalid open mode %r" % mode)
132
 
        self.mode = mode
133
 
        
134
 
        idxname = basename + '.irev'
135
 
        dataname = basename + '.drev'
136
 
 
137
 
        idx_exists = os.path.exists(idxname)
138
 
        data_exists = os.path.exists(dataname)
139
 
 
140
 
        if idx_exists != data_exists:
141
 
            raise RevfileError("half-assed revfile")
142
 
        
143
 
        if not idx_exists:
144
 
            if mode == 'r':
145
 
                raise RevfileError("Revfile %r does not exist" % basename)
146
 
            
147
 
            self.idxfile = open(idxname, 'w+b')
148
 
            self.datafile = open(dataname, 'w+b')
149
 
            
150
 
            print 'init empty file'
151
 
            self.idxfile.write(_HEADER)
152
 
            self.idxfile.flush()
153
 
        else:
154
 
            if mode == 'r':
155
 
                diskmode = 'rb'
156
 
            else:
157
 
                diskmode = 'r+b'
158
 
                
159
 
            self.idxfile = open(idxname, diskmode)
160
 
            self.datafile = open(dataname, diskmode)
161
 
            
162
 
            h = self.idxfile.read(_RECORDSIZE)
163
 
            if h != _HEADER:
164
 
                raise RevfileError("bad header %r in index of %r"
165
 
                                   % (h, self.basename))
166
 
 
167
 
 
168
 
    def _check_index(self, idx):
169
 
        if idx < 0 or idx > len(self):
170
 
            raise RevfileError("invalid index %r" % idx)
171
 
 
172
 
    def _check_write(self):
173
 
        if self.mode != 'w':
174
 
            raise RevfileError("%r is open readonly" % self.basename)
175
 
 
176
 
 
177
 
    def find_sha(self, s):
178
 
        assert isinstance(s, str)
179
 
        assert len(s) == 20
180
 
        
181
 
        for idx, idxrec in enumerate(self):
182
 
            if idxrec[I_SHA] == s:
183
 
                return idx
184
 
        else:
185
 
            return _NO_RECORD
186
 
 
187
 
 
188
 
 
189
 
    def _add_compressed(self, text_sha, data, base, compress):
190
 
        # well, maybe compress
191
 
        flags = 0
192
 
        if compress:
193
 
            data_len = len(data)
194
 
            if data_len > 50:
195
 
                # don't do compression if it's too small; it's unlikely to win
196
 
                # enough to be worthwhile
197
 
                compr_data = zlib.compress(data)
198
 
                compr_len = len(compr_data)
199
 
                if compr_len < data_len:
200
 
                    data = compr_data
201
 
                    flags = FL_GZIP
202
 
                    ##print '- compressed %d -> %d, %.1f%%' \
203
 
                    ##      % (data_len, compr_len, float(compr_len)/float(data_len) * 100.0)
204
 
        return self._add_raw(text_sha, data, base, flags)
205
 
        
206
 
 
207
 
 
208
 
    def _add_raw(self, text_sha, data, base, flags):
209
 
        """Add pre-processed data, can be either full text or delta.
210
 
 
211
 
        This does the compression if that makes sense."""
212
 
        idx = len(self)
213
 
        self.datafile.seek(0, 2)        # to end
214
 
        self.idxfile.seek(0, 2)
215
 
        assert self.idxfile.tell() == _RECORDSIZE * (idx + 1)
216
 
        data_offset = self.datafile.tell()
217
 
 
218
 
        assert isinstance(data, str) # not unicode or anything weird
219
 
 
220
 
        self.datafile.write(data)
221
 
        self.datafile.flush()
222
 
 
223
 
        assert isinstance(text_sha, str)
224
 
        entry = text_sha
225
 
        entry += struct.pack(">IIII12x", base, flags, data_offset, len(data))
226
 
        assert len(entry) == _RECORDSIZE
227
 
 
228
 
        self.idxfile.write(entry)
229
 
        self.idxfile.flush()
230
 
 
231
 
        return idx
232
 
        
233
 
 
234
 
 
235
 
    def _add_full_text(self, text, text_sha, compress):
236
 
        """Add a full text to the file.
237
 
 
238
 
        This is not compressed against any reference version.
239
 
 
240
 
        Returns the index for that text."""
241
 
        return self._add_compressed(text_sha, text, _NO_RECORD, compress)
242
 
 
243
 
 
244
 
    # NOT USED
245
 
    def _choose_base(self, seed, base):
246
 
        while seed & 3 == 3:
247
 
            if base == _NO_RECORD:
248
 
                return _NO_RECORD
249
 
            idxrec = self[base]
250
 
            if idxrec[I_BASE] == _NO_RECORD:
251
 
                return base
252
 
 
253
 
            base = idxrec[I_BASE]
254
 
            seed >>= 2
255
 
                
256
 
        return base        # relative to this full text
257
 
        
258
 
 
259
 
 
260
 
    def _add_delta(self, text, text_sha, base, compress):
261
 
        """Add a text stored relative to a previous text."""
262
 
        self._check_index(base)
263
 
 
264
 
        try:
265
 
            base_text = self.get(base, CHAIN_LIMIT)
266
 
        except LimitHitException:
267
 
            return self._add_full_text(text, text_sha, compress)
268
 
        
269
 
        data = mdiff.bdiff(base_text, text)
270
 
        
271
 
        # If the delta is larger than the text, we might as well just
272
 
        # store the text.  (OK, the delta might be more compressible,
273
 
        # but the overhead of applying it probably still makes it
274
 
        # bad, and I don't want to compress both of them to find out.)
275
 
        if len(data) >= len(text):
276
 
            return self._add_full_text(text, text_sha, compress)
277
 
        else:
278
 
            return self._add_compressed(text_sha, data, base, compress)
279
 
 
280
 
 
281
 
    def add(self, text, base=_NO_RECORD, compress=True):
282
 
        """Add a new text to the revfile.
283
 
 
284
 
        If the text is already present them its existing id is
285
 
        returned and the file is not changed.
286
 
 
287
 
        If compress is true then gzip compression will be used if it
288
 
        reduces the size.
289
 
 
290
 
        If a base index is specified, that text *may* be used for
291
 
        delta compression of the new text.  Delta compression will
292
 
        only be used if it would be a size win and if the existing
293
 
        base is not at too long of a delta chain already.
294
 
        """
295
 
        self._check_write()
296
 
        
297
 
        text_sha = sha.new(text).digest()
298
 
 
299
 
        idx = self.find_sha(text_sha)
300
 
        if idx != _NO_RECORD:
301
 
            # TODO: Optional paranoid mode where we read out that record and make sure
302
 
            # it's the same, in case someone ever breaks SHA-1.
303
 
            return idx                  # already present
304
 
        
305
 
        # base = self._choose_base(ord(text_sha[0]), base)
306
 
 
307
 
        if base == _NO_RECORD:
308
 
            return self._add_full_text(text, text_sha, compress)
309
 
        else:
310
 
            return self._add_delta(text, text_sha, base, compress)
311
 
 
312
 
 
313
 
 
314
 
    def get(self, idx, recursion_limit=None):
315
 
        """Retrieve text of a previous revision.
316
 
 
317
 
        If recursion_limit is an integer then walk back at most that
318
 
        many revisions and then raise LimitHitException, indicating
319
 
        that we ought to record a new file text instead of another
320
 
        delta.  Don't use this when trying to get out an existing
321
 
        revision."""
322
 
        
323
 
        idxrec = self[idx]
324
 
        base = idxrec[I_BASE]
325
 
        if base == _NO_RECORD:
326
 
            text = self._get_full_text(idx, idxrec)
327
 
        else:
328
 
            text = self._get_patched(idx, idxrec, recursion_limit)
329
 
 
330
 
        if sha.new(text).digest() != idxrec[I_SHA]:
331
 
            raise RevfileError("corrupt SHA-1 digest on record %d"
332
 
                               % idx)
333
 
 
334
 
        return text
335
 
 
336
 
 
337
 
 
338
 
    def _get_raw(self, idx, idxrec):
339
 
        flags = idxrec[I_FLAGS]
340
 
        if flags & ~FL_GZIP:
341
 
            raise RevfileError("unsupported index flags %#x on index %d"
342
 
                               % (flags, idx))
343
 
        
344
 
        l = idxrec[I_LEN]
345
 
        if l == 0:
346
 
            return ''
347
 
 
348
 
        self.datafile.seek(idxrec[I_OFFSET])
349
 
 
350
 
        data = self.datafile.read(l)
351
 
        if len(data) != l:
352
 
            raise RevfileError("short read %d of %d "
353
 
                               "getting text for record %d in %r"
354
 
                               % (len(data), l, idx, self.basename))
355
 
 
356
 
        if flags & FL_GZIP:
357
 
            data = zlib.decompress(data)
358
 
 
359
 
        return data
360
 
        
361
 
 
362
 
    def _get_full_text(self, idx, idxrec):
363
 
        assert idxrec[I_BASE] == _NO_RECORD
364
 
 
365
 
        text = self._get_raw(idx, idxrec)
366
 
 
367
 
        return text
368
 
 
369
 
 
370
 
    def _get_patched(self, idx, idxrec, recursion_limit):
371
 
        base = idxrec[I_BASE]
372
 
        assert base >= 0
373
 
        assert base < idx    # no loops!
374
 
 
375
 
        if recursion_limit == None:
376
 
            sub_limit = None
377
 
        else:
378
 
            sub_limit = recursion_limit - 1
379
 
            if sub_limit < 0:
380
 
                raise LimitHitException()
381
 
            
382
 
        base_text = self.get(base, sub_limit)
383
 
        patch = self._get_raw(idx, idxrec)
384
 
 
385
 
        text = mdiff.bpatch(base_text, patch)
386
 
 
387
 
        return text
388
 
 
389
 
 
390
 
 
391
 
    def __len__(self):
392
 
        """Return number of revisions."""
393
 
        l = os.fstat(self.idxfile.fileno())[stat.ST_SIZE]
394
 
        if l % _RECORDSIZE:
395
 
            raise RevfileError("bad length %d on index of %r" % (l, self.basename))
396
 
        if l < _RECORDSIZE:
397
 
            raise RevfileError("no header present in index of %r" % (self.basename))
398
 
        return int(l / _RECORDSIZE) - 1
399
 
 
400
 
 
401
 
    def __getitem__(self, idx):
402
 
        """Index by sequence id returns the index field"""
403
 
        ## TODO: Can avoid seek if we just moved there...
404
 
        self._seek_index(idx)
405
 
        idxrec = self._read_next_index()
406
 
        if idxrec == None:
407
 
            raise IndexError("no index %d" % idx)
408
 
        else:
409
 
            return idxrec
410
 
 
411
 
 
412
 
    def _seek_index(self, idx):
413
 
        if idx < 0:
414
 
            raise RevfileError("invalid index %r" % idx)
415
 
        self.idxfile.seek((idx + 1) * _RECORDSIZE)
416
 
 
417
 
 
418
 
 
419
 
    def __iter__(self):
420
 
        """Read back all index records.
421
 
 
422
 
        Do not seek the index file while this is underway!"""
423
 
        ## sys.stderr.write(" ** iter called ** \n")
424
 
        self._seek_index(0)
425
 
        while True:
426
 
            idxrec = self._read_next_index()
427
 
            if not idxrec:
428
 
                break
429
 
            yield idxrec
430
 
        
431
 
 
432
 
    def _read_next_index(self):
433
 
        rec = self.idxfile.read(_RECORDSIZE)
434
 
        if not rec:
435
 
            return None
436
 
        elif len(rec) != _RECORDSIZE:
437
 
            raise RevfileError("short read of %d bytes getting index %d from %r"
438
 
                               % (len(rec), idx, self.basename))
439
 
        
440
 
        return struct.unpack(">20sIIII12x", rec)
441
 
 
442
 
        
443
 
    def dump(self, f=sys.stdout):
444
 
        f.write('%-8s %-40s %-8s %-8s %-8s %-8s\n' 
445
 
                % tuple('idx sha1 base flags offset len'.split()))
446
 
        f.write('-------- ---------------------------------------- ')
447
 
        f.write('-------- -------- -------- --------\n')
448
 
 
449
 
        for i, rec in enumerate(self):
450
 
            f.write("#%-7d %40s " % (i, hexlify(rec[0])))
451
 
            if rec[1] == _NO_RECORD:
452
 
                f.write("(none)   ")
453
 
            else:
454
 
                f.write("#%-7d " % rec[1])
455
 
                
456
 
            f.write("%8x %8d %8d\n" % (rec[2], rec[3], rec[4]))
457
 
 
458
 
 
459
 
    def total_text_size(self):
460
 
        """Return the sum of sizes of all file texts.
461
 
 
462
 
        This is how much space they would occupy if they were stored without
463
 
        delta and gzip compression.
464
 
 
465
 
        As a side effect this completely validates the Revfile, checking that all
466
 
        texts can be reproduced with the correct SHA-1."""
467
 
        t = 0L
468
 
        for idx in range(len(self)):
469
 
            t += len(self.get(idx))
470
 
        return t
471
 
        
472
 
 
473
 
 
474
 
def main(argv):
475
 
    try:
476
 
        cmd = argv[1]
477
 
        filename = argv[2]
478
 
    except IndexError:
479
 
        sys.stderr.write("usage: revfile dump REVFILE\n"
480
 
                         "       revfile add REVFILE < INPUT\n"
481
 
                         "       revfile add-delta REVFILE BASE < INPUT\n"
482
 
                         "       revfile add-series REVFILE BASE FILE...\n"
483
 
                         "       revfile get REVFILE IDX\n"
484
 
                         "       revfile find-sha REVFILE HEX\n"
485
 
                         "       revfile total-text-size REVFILE\n"
486
 
                         "       revfile last REVFILE\n")
487
 
        return 1
488
 
 
489
 
    def rw():
490
 
        return Revfile(filename, 'w')
491
 
 
492
 
    def ro():
493
 
        return Revfile(filename, 'r')
494
 
 
495
 
    if cmd == 'add':
496
 
        print rw().add(sys.stdin.read())
497
 
    elif cmd == 'add-delta':
498
 
        print rw().add(sys.stdin.read(), int(argv[3]))
499
 
    elif cmd == 'add-series':
500
 
        r = rw()
501
 
        rev = int(argv[3])
502
 
        for fn in argv[4:]:
503
 
            print rev
504
 
            rev = r.add(file(fn).read(), rev)
505
 
    elif cmd == 'dump':
506
 
        ro().dump()
507
 
    elif cmd == 'get':
508
 
        try:
509
 
            idx = int(argv[3])
510
 
        except IndexError:
511
 
            sys.stderr.write("usage: revfile get FILE IDX\n")
512
 
            return 1
513
 
 
514
 
        r = ro()
515
 
 
516
 
        if idx < 0 or idx >= len(r):
517
 
            sys.stderr.write("invalid index %r\n" % idx)
518
 
            return 1
519
 
 
520
 
        sys.stdout.write(r.get(idx))
521
 
    elif cmd == 'find-sha':
522
 
        try:
523
 
            s = unhexlify(argv[3])
524
 
        except IndexError:
525
 
            sys.stderr.write("usage: revfile find-sha FILE HEX\n")
526
 
            return 1
527
 
 
528
 
        idx = ro().find_sha(s)
529
 
        if idx == _NO_RECORD:
530
 
            sys.stderr.write("no such record\n")
531
 
            return 1
532
 
        else:
533
 
            print idx
534
 
    elif cmd == 'total-text-size':
535
 
        print ro().total_text_size()
536
 
    elif cmd == 'last':
537
 
        print len(ro())-1
538
 
    else:
539
 
        sys.stderr.write("unknown command %r\n" % cmd)
540
 
        return 1
541
 
    
542
 
 
543
 
if __name__ == '__main__':
544
 
    import sys
545
 
    sys.exit(main(sys.argv) or 0)