[MERGE] Streaming-friendly container APIs

Andrew Bennetts andrew at canonical.com
Mon Oct 22 08:06:46 BST 2007


This bundle adds some new APIs to bzrlib/packs.py (the container format
implementation) that remove the impedence mismatch between the smart protocol
chunking and the container API.  I'm about to resubmit the chunked encoding work
using this to remove the nasty hackery I needed before.

The main addition is an alternative to ContainerReader, called
ContainerPushParser.  It's not a great name, but hopefully it clearly
communicates that unlike ContainerReader (which depends on being able to “pull”
more bytes via read/readlines as it needs them), you “push” bytes into it as
they become available, which is much more appropriate to network-driven work.

Obviously it would be good to get rid of the duplication in having both
ContainerPushParser and ContainerReader, so I'll work towards that.  I haven't
done that yet, because a simple-but-safe iter_records replacement based on
ContainerPushParser is slightly slower according to the bundle benchmarks.  I
think it's possible to do much better (I have some experimental code that is
better in all benchmarks, some by a factor of 2x), but some care needs to be
taken so I'll mail about that seperately.  I also haven't reimplemented the
container validation either, so that would also need to be taken care of as
well.

-Andrew.

-------------- next part --------------
# Bazaar merge directive format 2 (Bazaar 0.90)
# revision_id: andrew.bennetts at canonical.com-20071022051209-\
#   qyq4vnvw447hzxvl
# target_branch: http://bazaar-vcs.org/bzr/bzr.dev
# testament_sha1: 219961bd1835e45d17fbe5580d956ca13e378fa7
# timestamp: 2007-10-22 15:47:38 +1000
# source_branch: http://people.ubuntu.com/~andrew/bzr/chunked-\
#   body/streamable-containers
# base_revision_id: pqm at pqm.ubuntu.com-20071018040514-3hc1k2nj1umg3tig
# 
# Begin patch
=== modified file 'bzrlib/pack.py'
--- bzrlib/pack.py	2007-10-05 05:52:45 +0000
+++ bzrlib/pack.py	2007-10-22 05:12:09 +0000
@@ -58,8 +58,54 @@
         raise errors.InvalidRecordError(str(e))
 
 
+class ContainerSerialiser(object):
+    """A helper class for serialising containers.
+    
+    It simply returns bytes from method calls to 'begin', 'end' and
+    'bytes_record'.  You may find ContainerWriter to be a more convenient
+    interface.
+    """
+
+    def begin(self):
+        """Return the bytes to begin a container."""
+        return FORMAT_ONE + "\n"
+
+    def end(self):
+        """Return the bytes to finish a container."""
+        return "E"
+
+    def bytes_record(self, bytes, names):
+        """Return the bytes for a Bytes record with the given name and
+        contents.
+        """
+        # Kind marker
+        byte_sections = ["B"]
+        # Length
+        byte_sections.append(str(len(bytes)) + "\n")
+        # Names
+        for name_tuple in names:
+            # Make sure we're writing valid names.  Note that we will leave a
+            # half-written record if a name is bad!
+            for name in name_tuple:
+                _check_name(name)
+            byte_sections.append('\x00'.join(name_tuple) + "\n")
+        # End of headers
+        byte_sections.append("\n")
+        # Finally, the contents.
+        byte_sections.append(bytes)
+        # XXX: This causes a memory copy of bytes in size, but is usually
+        # faster than two write calls (12 vs 13 seconds to output a gig of
+        # 1k records.) - results may differ on significantly larger records
+        # like .iso's but as they should be rare in any case and thus not
+        # likely to be the common case. The biggest issue is causing extreme
+        # memory pressure in that case. One possibly improvement here is to
+        # check the size of the content before deciding to join here vs call
+        # write twice.
+        return ''.join(byte_sections)
+
+
 class ContainerWriter(object):
-    """A class for writing containers.
+    """A class for writing containers to a file.
 
     :attribute records_written: The number of user records added to the
         container. This does not count the prelude or suffix of the container
@@ -75,10 +121,11 @@
         self._write_func = write_func
         self.current_offset = 0
         self.records_written = 0
+        self._serialiser = ContainerSerialiser()
 
     def begin(self):
         """Begin writing a container."""
-        self.write_func(FORMAT_ONE + "\n")
+        self.write_func(self._serialiser.begin())
 
     def write_func(self, bytes):
         self._write_func(bytes)
@@ -86,7 +133,7 @@
 
     def end(self):
         """Finish writing a container."""
-        self.write_func("E")
+        self.write_func(self._serialiser.end())
 
     def add_bytes_record(self, bytes, names):
         """Add a Bytes record with the given names.
@@ -103,30 +150,8 @@
             and thus are only suitable for use by a ContainerReader.
         """
         current_offset = self.current_offset
-        # Kind marker
-        byte_sections = ["B"]
-        # Length
-        byte_sections.append(str(len(bytes)) + "\n")
-        # Names
-        for name_tuple in names:
-            # Make sure we're writing valid names.  Note that we will leave a
-            # half-written record if a name is bad!
-            for name in name_tuple:
-                _check_name(name)
-            byte_sections.append('\x00'.join(name_tuple) + "\n")
-        # End of headers
-        byte_sections.append("\n")
-        # Finally, the contents.
-        byte_sections.append(bytes)
-        # XXX: This causes a memory copy of bytes in size, but is usually
-        # faster than two write calls (12 vs 13 seconds to output a gig of
-        # 1k records.) - results may differ on significantly larger records
-        # like .iso's but as they should be rare in any case and thus not
-        # likely to be the common case. The biggest issue is causing extreme
-        # memory pressure in that case. One possibly improvement here is to
-        # check the size of the content before deciding to join here vs call
-        # write twice.
-        self.write_func(''.join(byte_sections))
+        serialised_record = self._serialiser.bytes_record(bytes, names)
+        self.write_func(serialised_record)
         self.records_written += 1
         # return a memo of where we wrote data to allow random access.
         return current_offset, self.current_offset - current_offset
@@ -355,3 +380,93 @@
                 _check_name_encoding(name)
         read_bytes(None)
 
+
+class ContainerPushParser(object):
+
+    def __init__(self):
+        self._buffer = ''
+        self._state_handler = self._state_expecting_format_line
+        self._parsed_records = []
+        self._reset_current_record()
+
+    def _reset_current_record(self):
+        self._current_record_length = None
+        self._current_record_names = []
+
+    def accept_bytes(self, bytes):
+        self._buffer += bytes
+        # Keep iterating the state machine until it stops consuming bytes from
+        # the buffer.
+        buffer_length = None
+        while len(self._buffer) != buffer_length:
+            buffer_length = len(self._buffer)
+            self._state_handler()
+
+    def read_pending_records(self):
+        records = self._parsed_records
+        self._parsed_records = []
+        return records
+    
+    def _consume_until_byte(self, byte):
+        """Take all bytes up to the given out of the buffer, and return it.
+
+        If the specified byte is not found in the buffer, the buffer is
+        unchanged and this returns None instead.
+        """
+        newline_pos = self._buffer.find('\n')
+        if newline_pos != -1:
+            line = self._buffer[:newline_pos]
+            self._buffer = self._buffer[newline_pos+1:]
+            return line
+        else:
+            return None
+
+    def _consume_line(self):
+        return self._consume_until_byte('\n')
+
+    def _state_expecting_format_line(self):
+        line = self._consume_line()
+        if line is not None:
+            if line != FORMAT_ONE:
+                raise errors.UnknownContainerFormatError(line)
+            self._state_handler = self._state_expecting_record_type
+
+    def _state_expecting_record_type(self):
+        if len(self._buffer) >= 1:
+            record_type = self._buffer[0]
+            self._buffer = self._buffer[1:]
+            if record_type != 'B':
+                raise NotImplementedError('XXX')
+            self._state_handler = self._state_expecting_length
+
+    def _state_expecting_length(self):
+        line = self._consume_line()
+        if line is not None:
+            try:
+                self._current_record_length = int(line)
+            except ValueError:
+                raise errors.InvalidRecordError(
+                    "%r is not a valid length." % (line,))
+            self._state_handler = self._state_expecting_name
+
+    def _state_expecting_name(self):
+        encoded_name_parts = self._consume_line()
+        if encoded_name_parts is not None:
+            if encoded_name_parts == '':
+                self._state_handler = self._state_expecting_body
+            else:
+                name_parts = tuple(encoded_name_parts.split('\x00'))
+                for name_part in name_parts:
+                    _check_name(name_part)
+                self._current_record_names.append(name_parts)
+            
+    def _state_expecting_body(self):
+        if len(self._buffer) >= self._current_record_length:
+            body_bytes = self._buffer[:self._current_record_length]
+            self._buffer = self._buffer[self._current_record_length:]
+            record = (self._current_record_names, body_bytes)
+            self._parsed_records.append(record)
+            self._reset_current_record()
+            self._state_handler = self._state_expecting_record_type
+
+

=== modified file 'bzrlib/tests/test_pack.py'
--- bzrlib/tests/test_pack.py	2007-08-28 05:17:06 +0000
+++ bzrlib/tests/test_pack.py	2007-10-19 08:42:06 +0000
@@ -523,3 +523,189 @@
         results.append(f.readline())
         results.append(f.read(4))
         self.assertEqual(['0', '\n', '2\n4\n'], results)
+
+
+class PushParserTestCase(tests.TestCase):
+
+    def make_parser_expecting_record_type(self):
+        parser = pack.ContainerPushParser()
+        parser.accept_bytes("Bazaar pack format 1 (introduced in 0.18)\n")
+        return parser
+
+    def make_parser_expecting_bytes_record(self):
+        parser = pack.ContainerPushParser()
+        parser.accept_bytes("Bazaar pack format 1 (introduced in 0.18)\nB")
+        return parser
+
+    def assertRecordParsing(self, expected_record, bytes):
+        parser = self.make_parser_expecting_bytes_record()
+        parser.accept_bytes(bytes)
+        parsed_records = parser.read_pending_records()
+        self.assertEqual([expected_record], parsed_records)
+
+        
+class TestContainerPushParser(PushParserTestCase):
+    """Tests for ContainerPushParser."""
+
+    def test_construct(self):
+        """ContainerPushParser can be constructed."""
+        pack.ContainerPushParser()
+
+    def test_multiple_records_at_once(self):
+        """If multiple records worth of data are fed to the parser in one
+        string, the parser will correctly parse all the records.
+
+        (A naive implementation might stop after parsing the first record.)
+        """
+        parser = self.make_parser_expecting_record_type()
+        parser.accept_bytes("B5\nname1\n\nbody1B5\nname2\n\nbody2")
+        self.assertEqual(
+            [([('name1',)], 'body1'), ([('name2',)], 'body2')],
+            parser.read_pending_records())
+
+
+class TestContainerPushParserBytesParsing(PushParserTestCase):
+    """Tests for reading Bytes records with ContainerPushParser."""
+
+    def test_record_with_no_name(self):
+        """Reading a Bytes record with no name returns an empty list of
+        names.
+        """
+        self.assertRecordParsing(([], 'aaaaa'), "5\n\naaaaa")
+
+    def test_record_with_one_name(self):
+        """Reading a Bytes record with one name returns a list of just that
+        name.
+        """
+        self.assertRecordParsing(
+            ([('name1', )], 'aaaaa'),
+            "5\nname1\n\naaaaa")
+
+    def test_record_with_two_names(self):
+        """Reading a Bytes record with two names returns a list of both names.
+        """
+        self.assertRecordParsing(
+            ([('name1', ), ('name2', )], 'aaaaa'),
+            "5\nname1\nname2\n\naaaaa")
+
+    def test_record_with_two_part_names(self):
+        """Reading a Bytes record with a two_part name reads both."""
+        self.assertRecordParsing(
+            ([('name1', 'name2')], 'aaaaa'),
+            "5\nname1\x00name2\n\naaaaa")
+
+    def test_invalid_length(self):
+        """If the length-prefix is not a number, parsing raises
+        InvalidRecordError.
+        """
+        parser = self.make_parser_expecting_bytes_record()
+        self.assertRaises(
+            errors.InvalidRecordError, parser.accept_bytes, "not a number\n")
+
+    def test_incomplete_record(self):
+        """If the bytes seen so far don't form a complete record, then there
+        will be nothing returned by read_pending_records.
+        """
+        parser = self.make_parser_expecting_bytes_record()
+        parser.accept_bytes("5\n\nabcd")
+        self.assertEqual([], parser.read_pending_records())
+
+    def test_accept_nothing(self):
+        """The edge case of parsing an empty string causes no error."""
+        parser = self.make_parser_expecting_bytes_record()
+        parser.accept_bytes("")
+
+    def assertInvalidRecord(self, bytes):
+        """Assert that parsing the given bytes will raise an
+        InvalidRecordError.
+        """
+        parser = self.make_parser_expecting_bytes_record()
+        self.assertRaises(
+            errors.InvalidRecordError, parser.accept_bytes, bytes)
+
+    def test_read_invalid_name_whitespace(self):
+        """Names must have no whitespace."""
+        # A name with a space.
+        self.assertInvalidRecord("0\nbad name\n\n")
+
+        # A name with a tab.
+        self.assertInvalidRecord("0\nbad\tname\n\n")
+
+        # A name with a vertical tab.
+        self.assertInvalidRecord("0\nbad\vname\n\n")
+
+    def test_repeated_read_pending_records(self):
+        """read_pending_records will not return the same record twice."""
+        parser = self.make_parser_expecting_bytes_record()
+        parser.accept_bytes("6\n\nabcdef")
+        self.assertEqual([([], 'abcdef')], parser.read_pending_records())
+        self.assertEqual([], parser.read_pending_records())
+
+
+class TestMakeReadvReader(tests.TestCaseWithTransport):
+
+    def test_read_skipping_records(self):
+        pack_data = StringIO()
+        writer = pack.ContainerWriter(pack_data.write)
+        writer.begin()
+        memos = []
+        memos.append(writer.add_bytes_record('abc', names=[]))
+        memos.append(writer.add_bytes_record('def', names=[('name1', )]))
+        memos.append(writer.add_bytes_record('ghi', names=[('name2', )]))
+        memos.append(writer.add_bytes_record('jkl', names=[]))
+        writer.end()
+        transport = self.get_transport()
+        transport.put_bytes('mypack', pack_data.getvalue())
+        requested_records = [memos[0], memos[2]]
+        reader = pack.make_readv_reader(transport, 'mypack', requested_records)
+        result = []
+        for names, reader_func in reader.iter_records():
+            result.append((names, reader_func(None)))
+        self.assertEqual([([], 'abc'), ([('name2', )], 'ghi')], result)
+
+
+class TestReadvFile(tests.TestCaseWithTransport):
+    """Tests of the ReadVFile class.
+
+    Error cases are deliberately undefined: this code adapts the underlying
+    transport interface to a single 'streaming read' interface as 
+    ContainerReader needs.
+    """
+
+    def test_read_bytes(self):
+        """Test reading of both single bytes and all bytes in a hunk."""
+        transport = self.get_transport()
+        transport.put_bytes('sample', '0123456789')
+        f = pack.ReadVFile(transport.readv('sample', [(0,1), (1,2), (4,1), (6,2)]))
+        results = []
+        results.append(f.read(1))
+        results.append(f.read(2))
+        results.append(f.read(1))
+        results.append(f.read(1))
+        results.append(f.read(1))
+        self.assertEqual(['0', '12', '4', '6', '7'], results)
+
+    def test_readline(self):
+        """Test using readline() as ContainerReader does.
+
+        This is always within a readv hunk, never across it.
+        """
+        transport = self.get_transport()
+        transport.put_bytes('sample', '0\n2\n4\n')
+        f = pack.ReadVFile(transport.readv('sample', [(0,2), (2,4)]))
+        results = []
+        results.append(f.readline())
+        results.append(f.readline())
+        results.append(f.readline())
+        self.assertEqual(['0\n', '2\n', '4\n'], results)
+
+    def test_readline_and_read(self):
+        """Test exercising one byte reads, readline, and then read again."""
+        transport = self.get_transport()
+        transport.put_bytes('sample', '0\n2\n4\n')
+        f = pack.ReadVFile(transport.readv('sample', [(0,6)]))
+        results = []
+        results.append(f.read(1))
+        results.append(f.readline())
+        results.append(f.read(4))
+        self.assertEqual(['0', '\n', '2\n4\n'], results)

# Begin bundle
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


More information about the bazaar mailing list