view hgext/convert/darcs.py @ 40021:c537144fdbef

wireprotov2: support response caching One of the things I've learned from managing VCS servers over the years is that they are hard to scale. It is well known that some companies have very beefy (read: very expensive) servers to power their VCS needs. It is also known that specialized servers for various VCS exist in order to facilitate scaling servers. (Mercurial is in this boat.) One of the aspects that make a VCS server hard to scale is the high CPU load incurred by constant client clone/pull operations. To alleviate the scaling pain associated with data retrieval operations, I want to integrate caching into the Mercurial wire protocol server as robustly as possible such that servers can aggressively cache responses and defer as much server load as possible. This commit represents the initial implementation of a general caching layer in wire protocol version 2. We define a new interface and behavior for a wire protocol cacher in repository.py. (This is probably where a reviewer should look first to understand what is going on.) The bulk of the added code is in wireprotov2server.py, where we define how a command can opt in to being cached and integrate caching into command dispatching. From a very high-level: * A command can declare itself as cacheable by providing a callable that can be used to derive a cache key. * At dispatch time, if a command is cacheable, we attempt to construct a cacher and use it for serving the request and/or caching the request. * The dispatch layer handles the bulk of the business logic for caching, making cachers mostly "dumb content stores." * The mechanism for invalidating cached entries (one of the harder parts about caching in general) is by varying the cache key when state changes. As such, cachers don't need to be concerned with cache invalidation. Initially, we've hooked up support for caching "manifestdata" and "filedata" commands. These are the simplest to cache, as they should be immutable over time. Caching of commands related to changeset data is a bit harder (because cache validation is impacted by changes to bookmarks, phases, etc). This will be implemented later. (Strictly speaking, censoring a file should invalidate caches. I've added an inline TODO to track this edge case.) To prove it works, this commit implements a test-only extension providing in-memory caching backed by an lrucachedict. A new test showing this extension behaving properly is added. FWIW, the cacher is ~50 lines of code, demonstrating the relative ease with which a cache can be added to a server. While the test cacher is not suitable for production workloads, just for kicks I performed a clone of just the changeset and manifest data for the mozilla-unified repository. With a fully warmed cache (of just the manifest data since changeset data is not cached), server-side CPU usage dropped from ~73s to ~28s. That's pretty significant and demonstrates the potential that response caching has on server scalability! Differential Revision: https://phab.mercurial-scm.org/D4773
author Gregory Szorc <gregory.szorc@gmail.com>
date Wed, 26 Sep 2018 17:16:56 -0700
parents 2c2e82469b89
children 2372284d9457
line wrap: on
line source

# darcs.py - darcs support for the convert extension
#
#  Copyright 2007-2009 Matt Mackall <mpm@selenic.com> and others
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.
from __future__ import absolute_import

import errno
import os
import re
import shutil

from mercurial.i18n import _
from mercurial import (
    error,
    pycompat,
    util,
)
from mercurial.utils import dateutil
from . import common
NoRepo = common.NoRepo

# The naming drift of ElementTree is fun!

try:
    import xml.etree.cElementTree.ElementTree as ElementTree
    import xml.etree.cElementTree.XMLParser as XMLParser
except ImportError:
    try:
        import xml.etree.ElementTree.ElementTree as ElementTree
        import xml.etree.ElementTree.XMLParser as XMLParser
    except ImportError:
        try:
            import elementtree.cElementTree.ElementTree as ElementTree
            import elementtree.cElementTree.XMLParser as XMLParser
        except ImportError:
            try:
                import elementtree.ElementTree.ElementTree as ElementTree
                import elementtree.ElementTree.XMLParser  as XMLParser
            except ImportError:
                pass

class darcs_source(common.converter_source, common.commandline):
    def __init__(self, ui, repotype, path, revs=None):
        common.converter_source.__init__(self, ui, repotype, path, revs=revs)
        common.commandline.__init__(self, ui, 'darcs')

        # check for _darcs, ElementTree so that we can easily skip
        # test-convert-darcs if ElementTree is not around
        if not os.path.exists(os.path.join(path, '_darcs')):
            raise NoRepo(_("%s does not look like a darcs repository") % path)

        common.checktool('darcs')
        version = self.run0('--version').splitlines()[0].strip()
        if version < '2.1':
            raise error.Abort(_('darcs version 2.1 or newer needed (found %r)')
                              % version)

        if "ElementTree" not in globals():
            raise error.Abort(_("Python ElementTree module is not available"))

        self.path = os.path.realpath(path)

        self.lastrev = None
        self.changes = {}
        self.parents = {}
        self.tags = {}

        # Check darcs repository format
        format = self.format()
        if format:
            if format in ('darcs-1.0', 'hashed'):
                raise NoRepo(_("%s repository format is unsupported, "
                               "please upgrade") % format)
        else:
            self.ui.warn(_('failed to detect repository format!'))

    def before(self):
        self.tmppath = pycompat.mkdtemp(
            prefix='convert-' + os.path.basename(self.path) + '-')
        output, status = self.run('init', repodir=self.tmppath)
        self.checkexit(status)

        tree = self.xml('changes', xml_output=True, summary=True,
                        repodir=self.path)
        tagname = None
        child = None
        for elt in tree.findall('patch'):
            node = elt.get('hash')
            name = elt.findtext('name', '')
            if name.startswith('TAG '):
                tagname = name[4:].strip()
            elif tagname is not None:
                self.tags[tagname] = node
                tagname = None
            self.changes[node] = elt
            self.parents[child] = [node]
            child = node
        self.parents[child] = []

    def after(self):
        self.ui.debug('cleaning up %s\n' % self.tmppath)
        shutil.rmtree(self.tmppath, ignore_errors=True)

    def recode(self, s, encoding=None):
        if isinstance(s, pycompat.unicode):
            # XMLParser returns unicode objects for anything it can't
            # encode into ASCII. We convert them back to str to get
            # recode's normal conversion behavior.
            s = s.encode('latin-1')
        return super(darcs_source, self).recode(s, encoding)

    def xml(self, cmd, **kwargs):
        # NOTE: darcs is currently encoding agnostic and will print
        # patch metadata byte-for-byte, even in the XML changelog.
        etree = ElementTree()
        # While we are decoding the XML as latin-1 to be as liberal as
        # possible, etree will still raise an exception if any
        # non-printable characters are in the XML changelog.
        parser = XMLParser(encoding='latin-1')
        p = self._run(cmd, **kwargs)
        etree.parse(p.stdout, parser=parser)
        p.wait()
        self.checkexit(p.returncode)
        return etree.getroot()

    def format(self):
        output, status = self.run('show', 'repo', repodir=self.path)
        self.checkexit(status)
        m = re.search(r'^\s*Format:\s*(.*)$', output, re.MULTILINE)
        if not m:
            return None
        return ','.join(sorted(f.strip() for f in m.group(1).split(',')))

    def manifest(self):
        man = []
        output, status = self.run('show', 'files', no_directories=True,
                                  repodir=self.tmppath)
        self.checkexit(status)
        for line in output.split('\n'):
            path = line[2:]
            if path:
                man.append(path)
        return man

    def getheads(self):
        return self.parents[None]

    def getcommit(self, rev):
        elt = self.changes[rev]
        dateformat = '%a %b %d %H:%M:%S %Z %Y'
        date = dateutil.strdate(elt.get('local_date'), dateformat)
        desc = elt.findtext('name') + '\n' + elt.findtext('comment', '')
        # etree can return unicode objects for name, comment, and author,
        # so recode() is used to ensure str objects are emitted.
        newdateformat = '%Y-%m-%d %H:%M:%S %1%2'
        return common.commit(author=self.recode(elt.get('author')),
                             date=dateutil.datestr(date, newdateformat),
                             desc=self.recode(desc).strip(),
                             parents=self.parents[rev])

    def pull(self, rev):
        output, status = self.run('pull', self.path, all=True,
                                  match='hash %s' % rev,
                                  no_test=True, no_posthook=True,
                                  external_merge='/bin/false',
                                  repodir=self.tmppath)
        if status:
            if output.find('We have conflicts in') == -1:
                self.checkexit(status, output)
            output, status = self.run('revert', all=True, repodir=self.tmppath)
            self.checkexit(status, output)

    def getchanges(self, rev, full):
        if full:
            raise error.Abort(_("convert from darcs does not support --full"))
        copies = {}
        changes = []
        man = None
        for elt in self.changes[rev].find('summary').getchildren():
            if elt.tag in ('add_directory', 'remove_directory'):
                continue
            if elt.tag == 'move':
                if man is None:
                    man = self.manifest()
                source, dest = elt.get('from'), elt.get('to')
                if source in man:
                    # File move
                    changes.append((source, rev))
                    changes.append((dest, rev))
                    copies[dest] = source
                else:
                    # Directory move, deduce file moves from manifest
                    source = source + '/'
                    for f in man:
                        if not f.startswith(source):
                            continue
                        fdest = dest + '/' + f[len(source):]
                        changes.append((f, rev))
                        changes.append((fdest, rev))
                        copies[fdest] = f
            else:
                changes.append((elt.text.strip(), rev))
        self.pull(rev)
        self.lastrev = rev
        return sorted(changes), copies, set()

    def getfile(self, name, rev):
        if rev != self.lastrev:
            raise error.Abort(_('internal calling inconsistency'))
        path = os.path.join(self.tmppath, name)
        try:
            data = util.readfile(path)
            mode = os.lstat(path).st_mode
        except IOError as inst:
            if inst.errno == errno.ENOENT:
                return None, None
            raise
        mode = (mode & 0o111) and 'x' or ''
        return data, mode

    def gettags(self):
        return self.tags