224 return caps |
281 return caps |
225 |
282 |
226 |
283 |
227 def extsetup(ui): |
284 def extsetup(ui): |
228 extensions.wrapfunction(wireprotov1server, b'_capabilities', capabilities) |
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 |