##// END OF EJS Templates
clone-bundles: add a basic first version of automatic bundle generation...
marmoute -
r51299:5ae30ff7 default
parent child Browse files
Show More
@@ -0,0 +1,70 b''
1
2 #require no-reposimplestore no-chg
3
4 initial setup
5
6 $ hg init server
7 $ cat >> server/.hg/hgrc << EOF
8 > [extensions]
9 > clonebundles =
10 >
11 > [clone-bundles]
12 > auto-generate.formats = v2
13 > upload-command = cp "\$HGCB_BUNDLE_PATH" "$TESTTMP"/final-upload/
14 > url-template = file://$TESTTMP/final-upload/{basename}
15 >
16 > [devel]
17 > debug.clonebundles=yes
18 > EOF
19
20 $ mkdir final-upload
21 $ hg clone server client
22 updating to branch default
23 0 files updated, 0 files merged, 0 files removed, 0 files unresolved
24 $ cd client
25
26 Test bundles are generated on push
27 ==================================
28
29 $ touch foo
30 $ hg -q commit -A -m 'add foo'
31 $ touch bar
32 $ hg -q commit -A -m 'add bar'
33 $ hg push
34 pushing to $TESTTMP/server
35 searching for changes
36 adding changesets
37 adding manifests
38 adding file changes
39 2 changesets found
40 added 2 changesets with 2 changes to 2 files
41 clone-bundles: starting bundle generation: v2
42 $ cat ../server/.hg/clonebundles.manifest
43 file:/*/$TESTTMP/final-upload/full-v2-2_revs-aaff8d2ffbbf_tip-*_txn.hg BUNDLESPEC=v2 REQUIRESNI=true (glob)
44 $ ls -1 ../final-upload
45 full-v2-2_revs-aaff8d2ffbbf_tip-*_txn.hg (glob)
46 $ ls -1 ../server/.hg/tmp-bundles
47
48 Newer bundles are generated with more pushes
49 --------------------------------------------
50
51 $ touch baz
52 $ hg -q commit -A -m 'add baz'
53 $ touch buz
54 $ hg -q commit -A -m 'add buz'
55 $ hg push
56 pushing to $TESTTMP/server
57 searching for changes
58 adding changesets
59 adding manifests
60 adding file changes
61 4 changesets found
62 added 2 changesets with 2 changes to 2 files
63 clone-bundles: starting bundle generation: v2
64
65 $ cat ../server/.hg/clonebundles.manifest
66 file:/*/$TESTTMP/final-upload/full-v2-4_revs-6427147b985a_tip-*_txn.hg BUNDLESPEC=v2 REQUIRESNI=true (glob)
67 $ ls -1 ../final-upload
68 full-v2-2_revs-aaff8d2ffbbf_tip-*_txn.hg (glob)
69 full-v2-4_revs-6427147b985a_tip-*_txn.hg (glob)
70 $ ls -1 ../server/.hg/tmp-bundles
This diff has been collapsed as it changes many lines, (604 lines changed) Show them Hide them
@@ -200,15 +200,72 b' message informing them how to bypass the'
200 200 occurs. So server operators should prepare for some people to follow these
201 201 instructions when a failure occurs, thus driving more load to the original
202 202 Mercurial server when the bundle hosting service fails.
203
204
205 auto-generation of clone bundles
206 --------------------------------
207
208 It is possible to set Mercurial to automatically re-generate clone bundles when
209 new content is available.
210
211 Mercurial will take care of the process asynchronously. The defined list of
212 bundle type will be generated, uploaded, and advertised.
213
214 Bundles Generation:
215 ...................
216
217 The extension can generate multiple variants of the clone bundle. Each
218 different variant will be defined by the "bundle-spec" they use::
219
220 [clone-bundles]
221 auto-generate.formats= zstd-v2, gzip-v2
222
223 See `hg help bundlespec` for details about available options.
224
225 Bundles Upload and Serving:
226 ...........................
227
228 The generated bundles need to be made available to users through a "public" URL.
229 This should be donne through `clone-bundles.upload-command` configuration. The
230 value of this command should be a shell command. It will have access to the
231 bundle file path through the `$HGCB_BUNDLE_PATH` variable. And the expected
232 basename in the "public" URL is accessible at::
233
234 [clone-bundles]
235 upload-command=sftp put $HGCB_BUNDLE_PATH \
236 sftp://bundles.host/clone-bundles/$HGCB_BUNDLE_BASENAME
237
238 After upload, the file should be available at an url defined by
239 `clone-bundles.url-template`.
240
241 [clone-bundles]
242 url-template=https://bundles.host/cache/clone-bundles/{basename}
203 243 """
204 244
205 245
246 import os
247 import weakref
248
249 from mercurial.i18n import _
250
206 251 from mercurial import (
207 252 bundlecaches,
253 commands,
254 error,
208 255 extensions,
256 localrepo,
257 lock,
258 node,
259 registrar,
260 util,
209 261 wireprotov1server,
210 262 )
211 263
264
265 from mercurial.utils import (
266 procutil,
267 )
268
212 269 testedwith = b'ships-with-hg-core'
213 270
214 271
@@ -226,3 +283,550 b' def capabilities(orig, repo, proto):'
226 283
227 284 def extsetup(ui):
228 285 extensions.wrapfunction(wireprotov1server, b'_capabilities', capabilities)
286
287
288 # logic for bundle auto-generation
289
290
291 configtable = {}
292 configitem = registrar.configitem(configtable)
293
294 cmdtable = {}
295 command = registrar.command(cmdtable)
296
297 configitem(b'clone-bundles', b'auto-generate.formats', default=list)
298
299
300 configitem(b'clone-bundles', b'upload-command', default=None)
301
302 configitem(b'clone-bundles', b'url-template', default=None)
303
304 configitem(b'devel', b'debug.clonebundles', default=False)
305
306
307 # category for the post-close transaction hooks
308 CAT_POSTCLOSE = b"clonebundles-autobundles"
309
310 # template for bundle file names
311 BUNDLE_MASK = (
312 b"full-%(bundle_type)s-%(revs)d_revs-%(tip_short)s_tip-%(op_id)s.hg"
313 )
314
315
316 # file in .hg/ use to track clonebundles being auto-generated
317 AUTO_GEN_FILE = b'clonebundles.auto-gen'
318
319
320 class BundleBase(object):
321 """represents the core of properties that matters for us in a bundle
322
323 :bundle_type: the bundlespec (see hg help bundlespec)
324 :revs: the number of revisions in the repo at bundle creation time
325 :tip_rev: the rev-num of the tip revision
326 :tip_node: the node id of the tip-most revision in the bundle
327
328 :ready: True if the bundle is ready to be served
329 """
330
331 ready = False
332
333 def __init__(self, bundle_type, revs, tip_rev, tip_node):
334 self.bundle_type = bundle_type
335 self.revs = revs
336 self.tip_rev = tip_rev
337 self.tip_node = tip_node
338
339 def valid_for(self, repo):
340 """is this bundle applicable to the current repository
341
342 This is useful for detecting bundles made irrelevant by stripping.
343 """
344 tip_node = node.bin(self.tip_node)
345 return repo.changelog.index.get_rev(tip_node) == self.tip_rev
346
347 def __eq__(self, other):
348 left = (self.ready, self.bundle_type, self.tip_rev, self.tip_node)
349 right = (other.ready, other.bundle_type, other.tip_rev, other.tip_node)
350 return left == right
351
352 def __neq__(self, other):
353 return not self == other
354
355 def __cmp__(self, other):
356 if self == other:
357 return 0
358 return -1
359
360
361 class RequestedBundle(BundleBase):
362 """A bundle that should be generated.
363
364 Additional attributes compared to BundleBase
365 :heads: list of head revisions (as rev-num)
366 :op_id: a "unique" identifier for the operation triggering the change
367 """
368
369 def __init__(self, bundle_type, revs, tip_rev, tip_node, head_revs, op_id):
370 self.head_revs = head_revs
371 self.op_id = op_id
372 super(RequestedBundle, self).__init__(
373 bundle_type,
374 revs,
375 tip_rev,
376 tip_node,
377 )
378
379 @property
380 def suggested_filename(self):
381 """A filename that can be used for the generated bundle"""
382 data = {
383 b'bundle_type': self.bundle_type,
384 b'revs': self.revs,
385 b'heads': self.head_revs,
386 b'tip_rev': self.tip_rev,
387 b'tip_node': self.tip_node,
388 b'tip_short': self.tip_node[:12],
389 b'op_id': self.op_id,
390 }
391 return BUNDLE_MASK % data
392
393 def generate_bundle(self, repo, file_path):
394 """generate the bundle at `filepath`"""
395 commands.bundle(
396 repo.ui,
397 repo,
398 file_path,
399 base=[b"null"],
400 rev=self.head_revs,
401 type=self.bundle_type,
402 quiet=True,
403 )
404
405 def generating(self, file_path, hostname=None, pid=None):
406 """return a GeneratingBundle object from this object"""
407 if pid is None:
408 pid = os.getpid()
409 if hostname is None:
410 hostname = lock._getlockprefix()
411 return GeneratingBundle(
412 self.bundle_type,
413 self.revs,
414 self.tip_rev,
415 self.tip_node,
416 hostname,
417 pid,
418 file_path,
419 )
420
421
422 class GeneratingBundle(BundleBase):
423 """A bundle being generated
424
425 extra attributes compared to BundleBase:
426
427 :hostname: the hostname of the machine generating the bundle
428 :pid: the pid of the process generating the bundle
429 :filepath: the target filename of the bundle
430
431 These attributes exist to help detect stalled generation processes.
432 """
433
434 ready = False
435
436 def __init__(
437 self, bundle_type, revs, tip_rev, tip_node, hostname, pid, filepath
438 ):
439 self.hostname = hostname
440 self.pid = pid
441 self.filepath = filepath
442 super(GeneratingBundle, self).__init__(
443 bundle_type, revs, tip_rev, tip_node
444 )
445
446 @classmethod
447 def from_line(cls, line):
448 """create an object by deserializing a line from AUTO_GEN_FILE"""
449 assert line.startswith(b'PENDING-v1 ')
450 (
451 __,
452 bundle_type,
453 revs,
454 tip_rev,
455 tip_node,
456 hostname,
457 pid,
458 filepath,
459 ) = line.split()
460 hostname = util.urlreq.unquote(hostname)
461 filepath = util.urlreq.unquote(filepath)
462 revs = int(revs)
463 tip_rev = int(tip_rev)
464 pid = int(pid)
465 return cls(
466 bundle_type, revs, tip_rev, tip_node, hostname, pid, filepath
467 )
468
469 def to_line(self):
470 """serialize the object to include as a line in AUTO_GEN_FILE"""
471 templ = b"PENDING-v1 %s %d %d %s %s %d %s"
472 data = (
473 self.bundle_type,
474 self.revs,
475 self.tip_rev,
476 self.tip_node,
477 util.urlreq.quote(self.hostname),
478 self.pid,
479 util.urlreq.quote(self.filepath),
480 )
481 return templ % data
482
483 def __eq__(self, other):
484 if not super(GeneratingBundle, self).__eq__(other):
485 return False
486 left = (self.hostname, self.pid, self.filepath)
487 right = (other.hostname, other.pid, other.filepath)
488 return left == right
489
490 def uploaded(self, url, basename):
491 """return a GeneratedBundle from this object"""
492 return GeneratedBundle(
493 self.bundle_type,
494 self.revs,
495 self.tip_rev,
496 self.tip_node,
497 url,
498 basename,
499 )
500
501
502 class GeneratedBundle(BundleBase):
503 """A bundle that is done being generated and can be served
504
505 extra attributes compared to BundleBase:
506
507 :file_url: the url where the bundle is available.
508 :basename: the "basename" used to upload (useful for deletion)
509
510 These attributes exist to generate a bundle manifest
511 (.hg/pullbundles.manifest)
512 """
513
514 ready = True
515
516 def __init__(
517 self, bundle_type, revs, tip_rev, tip_node, file_url, basename
518 ):
519 self.file_url = file_url
520 self.basename = basename
521 super(GeneratedBundle, self).__init__(
522 bundle_type, revs, tip_rev, tip_node
523 )
524
525 @classmethod
526 def from_line(cls, line):
527 """create an object by deserializing a line from AUTO_GEN_FILE"""
528 assert line.startswith(b'DONE-v1 ')
529 (
530 __,
531 bundle_type,
532 revs,
533 tip_rev,
534 tip_node,
535 file_url,
536 basename,
537 ) = line.split()
538 revs = int(revs)
539 tip_rev = int(tip_rev)
540 file_url = util.urlreq.unquote(file_url)
541 return cls(bundle_type, revs, tip_rev, tip_node, file_url, basename)
542
543 def to_line(self):
544 """serialize the object to include as a line in AUTO_GEN_FILE"""
545 templ = b"DONE-v1 %s %d %d %s %s %s"
546 data = (
547 self.bundle_type,
548 self.revs,
549 self.tip_rev,
550 self.tip_node,
551 util.urlreq.quote(self.file_url),
552 self.basename,
553 )
554 return templ % data
555
556 def manifest_line(self):
557 """serialize the object to include as a line in pullbundles.manifest"""
558 templ = b"%s BUNDLESPEC=%s REQUIRESNI=true"
559 return templ % (self.file_url, self.bundle_type)
560
561 def __eq__(self, other):
562 if not super(GeneratedBundle, self).__eq__(other):
563 return False
564 return self.file_url == other.file_url
565
566
567 def parse_auto_gen(content):
568 """parse the AUTO_GEN_FILE to return a list of Bundle object"""
569 bundles = []
570 for line in content.splitlines():
571 if line.startswith(b'PENDING-v1 '):
572 bundles.append(GeneratingBundle.from_line(line))
573 elif line.startswith(b'DONE-v1 '):
574 bundles.append(GeneratedBundle.from_line(line))
575 return bundles
576
577
578 def dumps_auto_gen(bundles):
579 """serialize a list of Bundle as a AUTO_GEN_FILE content"""
580 lines = []
581 for b in bundles:
582 lines.append(b"%s\n" % b.to_line())
583 lines.sort()
584 return b"".join(lines)
585
586
587 def read_auto_gen(repo):
588 """read the AUTO_GEN_FILE for the <repo> a list of Bundle object"""
589 data = repo.vfs.tryread(AUTO_GEN_FILE)
590 if not data:
591 return []
592 return parse_auto_gen(data)
593
594
595 def write_auto_gen(repo, bundles):
596 """write a list of Bundle objects into the repo's AUTO_GEN_FILE"""
597 assert repo._cb_lock_ref is not None
598 data = dumps_auto_gen(bundles)
599 with repo.vfs(AUTO_GEN_FILE, mode=b'wb', atomictemp=True) as f:
600 f.write(data)
601
602
603 def generate_manifest(bundles):
604 """write a list of Bundle objects into the repo's AUTO_GEN_FILE"""
605 bundles = list(bundles)
606 bundles.sort(key=lambda b: b.bundle_type)
607 lines = []
608 for b in bundles:
609 lines.append(b"%s\n" % b.manifest_line())
610 return b"".join(lines)
611
612
613 def update_ondisk_manifest(repo):
614 """update the clonebundle manifest with latest url"""
615 with repo.clonebundles_lock():
616 bundles = read_auto_gen(repo)
617
618 per_types = {}
619 for b in bundles:
620 if not (b.ready and b.valid_for(repo)):
621 continue
622 current = per_types.get(b.bundle_type)
623 if current is not None and current.revs >= b.revs:
624 continue
625 per_types[b.bundle_type] = b
626 manifest = generate_manifest(per_types.values())
627 with repo.vfs(
628 bundlecaches.CB_MANIFEST_FILE, mode=b"wb", atomictemp=True
629 ) as f:
630 f.write(manifest)
631
632
633 def update_bundle_list(repo, new_bundles=(), del_bundles=()):
634 """modify the repo's AUTO_GEN_FILE
635
636 This method also regenerates the clone bundle manifest when needed"""
637 with repo.clonebundles_lock():
638 bundles = read_auto_gen(repo)
639 if del_bundles:
640 bundles = [b for b in bundles if b not in del_bundles]
641 new_bundles = [b for b in new_bundles if b not in bundles]
642 bundles.extend(new_bundles)
643 write_auto_gen(repo, bundles)
644 all_changed = []
645 all_changed.extend(new_bundles)
646 all_changed.extend(del_bundles)
647 if any(b.ready for b in all_changed):
648 update_ondisk_manifest(repo)
649
650
651 def cleanup_tmp_bundle(repo, target):
652 """remove a GeneratingBundle file and entry"""
653 assert not target.ready
654 with repo.clonebundles_lock():
655 repo.vfs.tryunlink(target.filepath)
656 update_bundle_list(repo, del_bundles=[target])
657
658
659 def finalize_one_bundle(repo, target):
660 """upload a generated bundle and advertise it in the clonebundles.manifest"""
661 with repo.clonebundles_lock():
662 bundles = read_auto_gen(repo)
663 if target in bundles and target.valid_for(repo):
664 result = upload_bundle(repo, target)
665 update_bundle_list(repo, new_bundles=[result])
666 cleanup_tmp_bundle(repo, target)
667
668
669 def upload_bundle(repo, bundle):
670 """upload the result of a GeneratingBundle and return a GeneratedBundle
671
672 The upload is done using the `clone-bundles.upload-command`
673 """
674 cmd = repo.ui.config(b'clone-bundles', b'upload-command')
675 url = repo.ui.config(b'clone-bundles', b'url-template')
676 basename = repo.vfs.basename(bundle.filepath)
677 filepath = procutil.shellquote(bundle.filepath)
678 variables = {
679 b'HGCB_BUNDLE_PATH': filepath,
680 b'HGCB_BUNDLE_BASENAME': basename,
681 }
682 env = procutil.shellenviron(environ=variables)
683 ret = repo.ui.system(cmd, environ=env)
684 if ret:
685 raise error.Abort(b"command returned status %d: %s" % (ret, cmd))
686 url = (
687 url.decode('utf8')
688 .format(basename=basename.decode('utf8'))
689 .encode('utf8')
690 )
691 return bundle.uploaded(url, basename)
692
693
694 def auto_bundle_needed_actions(repo, bundles, op_id):
695 """find the list of bundles that need action
696
697 returns a list of RequestedBundle objects that need to be generated and
698 uploaded."""
699 create_bundles = []
700 repo = repo.filtered(b"immutable")
701 targets = repo.ui.configlist(b'clone-bundles', b'auto-generate.formats')
702 revs = len(repo.changelog)
703 generic_data = {
704 'revs': revs,
705 'head_revs': repo.changelog.headrevs(),
706 'tip_rev': repo.changelog.tiprev(),
707 'tip_node': node.hex(repo.changelog.tip()),
708 'op_id': op_id,
709 }
710 for t in targets:
711 data = generic_data.copy()
712 data['bundle_type'] = t
713 b = RequestedBundle(**data)
714 create_bundles.append(b)
715 return create_bundles
716
717
718 def start_one_bundle(repo, bundle):
719 """start the generation of a single bundle file
720
721 the `bundle` argument should be a RequestedBundle object.
722
723 This data is passed to the `debugmakeclonebundles` "as is".
724 """
725 data = util.pickle.dumps(bundle)
726 cmd = [procutil.hgexecutable(), b'--cwd', repo.path, INTERNAL_CMD]
727 env = procutil.shellenviron()
728 msg = b'clone-bundles: starting bundle generation: %s\n'
729 stdout = None
730 stderr = None
731 waits = []
732 record_wait = None
733 if repo.ui.configbool(b'devel', b'debug.clonebundles'):
734 stdout = procutil.stdout
735 stderr = procutil.stderr
736 repo.ui.write(msg % bundle.bundle_type)
737 record_wait = waits.append
738 else:
739 repo.ui.debug(msg % bundle.bundle_type)
740 bg = procutil.runbgcommand
741 bg(
742 cmd,
743 env,
744 stdin_bytes=data,
745 stdout=stdout,
746 stderr=stderr,
747 record_wait=record_wait,
748 )
749 for f in waits:
750 f()
751
752
753 INTERNAL_CMD = b'debug::internal-make-clone-bundles'
754
755
756 @command(INTERNAL_CMD, [], b'')
757 def debugmakeclonebundles(ui, repo):
758 """Internal command to auto-generate debug bundles"""
759 requested_bundle = util.pickle.load(procutil.stdin)
760 procutil.stdin.close()
761
762 fname = requested_bundle.suggested_filename
763 fpath = repo.vfs.makedirs(b'tmp-bundles')
764 fpath = repo.vfs.join(b'tmp-bundles', fname)
765 bundle = requested_bundle.generating(fpath)
766 update_bundle_list(repo, new_bundles=[bundle])
767
768 requested_bundle.generate_bundle(repo, fpath)
769
770 repo.invalidate()
771 finalize_one_bundle(repo, bundle)
772
773
774 def make_auto_bundler(source_repo):
775 reporef = weakref.ref(source_repo)
776
777 def autobundle(tr):
778 repo = reporef()
779 assert repo is not None
780 bundles = read_auto_gen(repo)
781 new = auto_bundle_needed_actions(repo, bundles, b"%d_txn" % id(tr))
782 for data in new:
783 start_one_bundle(repo, data)
784 return None
785
786 return autobundle
787
788
789 def reposetup(ui, repo):
790 """install the two pieces needed for automatic clonebundle generation
791
792 - add a "post-close" hook that fires bundling when needed
793 - introduce a clone-bundle lock to let multiple processes meddle with the
794 state files.
795 """
796 if not repo.local():
797 return
798
799 class autobundlesrepo(repo.__class__):
800 def transaction(self, *args, **kwargs):
801 tr = super(autobundlesrepo, self).transaction(*args, **kwargs)
802 targets = repo.ui.configlist(
803 b'clone-bundles', b'auto-generate.formats'
804 )
805 if targets:
806 tr.addpostclose(CAT_POSTCLOSE, make_auto_bundler(self))
807 return tr
808
809 @localrepo.unfilteredmethod
810 def clonebundles_lock(self, wait=True):
811 '''Lock the repository file related to clone bundles'''
812 if not util.safehasattr(self, '_cb_lock_ref'):
813 self._cb_lock_ref = None
814 l = self._currentlock(self._cb_lock_ref)
815 if l is not None:
816 l.lock()
817 return l
818
819 l = self._lock(
820 vfs=self.vfs,
821 lockname=b"clonebundleslock",
822 wait=wait,
823 releasefn=None,
824 acquirefn=None,
825 desc=_(b'repository %s') % self.origroot,
826 )
827 self._cb_lock_ref = weakref.ref(l)
828 return l
829
830 repo._wlockfreeprefix.add(AUTO_GEN_FILE)
831 repo._wlockfreeprefix.add(bundlecaches.CB_MANIFEST_FILE)
832 repo.__class__ = autobundlesrepo
General Comments 0
You need to be logged in to leave comments. Login now