Mercurial > hg
view hgext/censor.py @ 29830:92ac2baaea86
revlog: use an LRU cache for delta chain bases
Profiling using statprof revealed a hotspot during changegroup
application calculating delta chain bases on generaldelta repos.
Essentially, revlog._addrevision() was performing a lot of redundant
work tracing the delta chain as part of determining when the chain
distance was acceptable. This was most pronounced when adding
revisions to manifests, which can have delta chains thousands of
revisions long.
There was a delta chain base cache on revlogs before, but it only
captured a single revision. This was acceptable before generaldelta,
when _addrevision would build deltas from the previous revision and
thus we'd pretty much guarantee a cache hit when resolving the delta
chain base on a subsequent _addrevision call. However, it isn't
suitable for generaldelta because parent revisions aren't necessarily
the last processed revision.
This patch converts the delta chain base cache to an LRU dict cache.
The cache can hold multiple entries, so generaldelta repos have a
higher chance of getting a cache hit.
The impact of this change when processing changegroup additions is
significant. On a generaldelta conversion of the "mozilla-unified"
repo (which contains heads of the main Firefox repositories in
chronological order - this means there are lots of transitions between
heads in revlog order), this change has the following impact when
performing an `hg unbundle` of an uncompressed bundle of the repo:
before: 5:42 CPU time
after: 4:34 CPU time
Most of this time is saved when applying the changelog and manifest
revlogs:
before: 2:30 CPU time
after: 1:17 CPU time
That nearly a 50% reduction in CPU time applying changesets and
manifests!
Applying a gzipped bundle of the same repo (effectively simulating a
`hg clone` over HTTP) showed a similar speedup:
before: 5:53 CPU time
after: 4:46 CPU time
Wall time improvements were basically the same as CPU time.
I didn't measure explicitly, but it feels like most of the time
is saved when processing manifests. This makes sense, as large
manifests tend to have very long delta chains and thus benefit the
most from this cache.
So, this change effectively makes changegroup application (which is
used by `hg unbundle`, `hg clone`, `hg pull`, `hg unshelve`, and
various other commands) significantly faster when delta chains are
long (which can happen on repos with large numbers of files and thus
large manifests).
In theory, this change can result in more memory utilization. However,
we're caching a dict of ints. At most we have 200 ints + Python object
overhead per revlog. And, the cache is really only populated when
performing read-heavy operations, such as adding changegroups or
scanning an individual revlog. For memory bloat to be an issue, we'd
need to scan/read several revisions from several revlogs all while
having active references to several revlogs. I don't think there are
many operations that do this, so I don't think memory bloat from the
cache will be an issue.
author | Gregory Szorc <gregory.szorc@gmail.com> |
---|---|
date | Mon, 22 Aug 2016 21:48:50 -0700 |
parents | 5166b7a84b72 |
children | d5883fd055c6 |
line wrap: on
line source
# Copyright (C) 2015 - Mike Edgar <adgar@google.com> # # This extension enables removal of file content at a given revision, # rewriting the data/metadata of successive revisions to preserve revision log # integrity. """erase file content at a given revision The censor command instructs Mercurial to erase all content of a file at a given revision *without updating the changeset hash.* This allows existing history to remain valid while preventing future clones/pulls from receiving the erased data. Typical uses for censor are due to security or legal requirements, including:: * Passwords, private keys, cryptographic material * Licensed data/code/libraries for which the license has expired * Personally Identifiable Information or other private data Censored nodes can interrupt mercurial's typical operation whenever the excised data needs to be materialized. Some commands, like ``hg cat``/``hg revert``, simply fail when asked to produce censored data. Others, like ``hg verify`` and ``hg update``, must be capable of tolerating censored data to continue to function in a meaningful way. Such commands only tolerate censored file revisions if they are allowed by the "censor.policy=ignore" config option. """ from __future__ import absolute_import from mercurial.i18n import _ from mercurial.node import short from mercurial import ( cmdutil, error, filelog, lock as lockmod, revlog, scmutil, util, ) cmdtable = {} command = cmdutil.command(cmdtable) # Note for extension authors: ONLY specify testedwith = 'internal' for # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should # be specifying the version(s) of Mercurial they are tested with, or # leave the attribute unspecified. testedwith = 'internal' @command('censor', [('r', 'rev', '', _('censor file from specified revision'), _('REV')), ('t', 'tombstone', '', _('replacement tombstone data'), _('TEXT'))], _('-r REV [-t TEXT] [FILE]')) def censor(ui, repo, path, rev='', tombstone='', **opts): wlock = lock = None try: wlock = repo.wlock() lock = repo.lock() return _docensor(ui, repo, path, rev, tombstone, **opts) finally: lockmod.release(lock, wlock) def _docensor(ui, repo, path, rev='', tombstone='', **opts): if not path: raise error.Abort(_('must specify file path to censor')) if not rev: raise error.Abort(_('must specify revision to censor')) wctx = repo[None] m = scmutil.match(wctx, (path,)) if m.anypats() or len(m.files()) != 1: raise error.Abort(_('can only specify an explicit filename')) path = m.files()[0] flog = repo.file(path) if not len(flog): raise error.Abort(_('cannot censor file with no history')) rev = scmutil.revsingle(repo, rev, rev).rev() try: ctx = repo[rev] except KeyError: raise error.Abort(_('invalid revision identifier %s') % rev) try: fctx = ctx.filectx(path) except error.LookupError: raise error.Abort(_('file does not exist at revision %s') % rev) fnode = fctx.filenode() headctxs = [repo[c] for c in repo.heads()] heads = [c for c in headctxs if path in c and c.filenode(path) == fnode] if heads: headlist = ', '.join([short(c.node()) for c in heads]) raise error.Abort(_('cannot censor file in heads (%s)') % headlist, hint=_('clean/delete and commit first')) wp = wctx.parents() if ctx.node() in [p.node() for p in wp]: raise error.Abort(_('cannot censor working directory'), hint=_('clean/delete/update first')) flogv = flog.version & 0xFFFF if flogv != revlog.REVLOGNG: raise error.Abort( _('censor does not support revlog version %d') % (flogv,)) tombstone = filelog.packmeta({"censored": tombstone}, "") crev = fctx.filerev() if len(tombstone) > flog.rawsize(crev): raise error.Abort(_( 'censor tombstone must be no longer than censored data')) # Using two files instead of one makes it easy to rewrite entry-by-entry idxread = repo.svfs(flog.indexfile, 'r') idxwrite = repo.svfs(flog.indexfile, 'wb', atomictemp=True) if flog.version & revlog.REVLOGNGINLINEDATA: dataread, datawrite = idxread, idxwrite else: dataread = repo.svfs(flog.datafile, 'r') datawrite = repo.svfs(flog.datafile, 'wb', atomictemp=True) # Copy all revlog data up to the entry to be censored. rio = revlog.revlogio() offset = flog.start(crev) for chunk in util.filechunkiter(idxread, limit=crev * rio.size): idxwrite.write(chunk) for chunk in util.filechunkiter(dataread, limit=offset): datawrite.write(chunk) def rewriteindex(r, newoffs, newdata=None): """Rewrite the index entry with a new data offset and optional new data. The newdata argument, if given, is a tuple of three positive integers: (new compressed, new uncompressed, added flag bits). """ offlags, comp, uncomp, base, link, p1, p2, nodeid = flog.index[r] flags = revlog.gettype(offlags) if newdata: comp, uncomp, nflags = newdata flags |= nflags offlags = revlog.offset_type(newoffs, flags) e = (offlags, comp, uncomp, r, link, p1, p2, nodeid) idxwrite.write(rio.packentry(e, None, flog.version, r)) idxread.seek(rio.size, 1) def rewrite(r, offs, data, nflags=revlog.REVIDX_DEFAULT_FLAGS): """Write the given full text to the filelog with the given data offset. Returns: The integer number of data bytes written, for tracking data offsets. """ flag, compdata = flog.compress(data) newcomp = len(flag) + len(compdata) rewriteindex(r, offs, (newcomp, len(data), nflags)) datawrite.write(flag) datawrite.write(compdata) dataread.seek(flog.length(r), 1) return newcomp # Rewrite censored revlog entry with (padded) tombstone data. pad = ' ' * (flog.rawsize(crev) - len(tombstone)) offset += rewrite(crev, offset, tombstone + pad, revlog.REVIDX_ISCENSORED) # Rewrite all following filelog revisions fixing up offsets and deltas. for srev in xrange(crev + 1, len(flog)): if crev in flog.parentrevs(srev): # Immediate children of censored node must be re-added as fulltext. try: revdata = flog.revision(srev) except error.CensoredNodeError as e: revdata = e.tombstone dlen = rewrite(srev, offset, revdata) else: # Copy any other revision data verbatim after fixing up the offset. rewriteindex(srev, offset) dlen = flog.length(srev) for chunk in util.filechunkiter(dataread, limit=dlen): datawrite.write(chunk) offset += dlen idxread.close() idxwrite.close() if dataread is not idxread: dataread.close() datawrite.close()