blob: 5444a6c59b6492981a6fee37e899f47f1180fa94 [file] [log] [blame]
#
# Copyright 2017 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""
3-way merge function for config rev objects.
"""
from collections import OrderedDict
from copy import copy
from voltha.core.config.config_proxy import CallbackType, OperationContext
from voltha.core.config.config_rev import children_fields
class MergeConflictException(Exception):
pass
def merge_3way(fork_rev, src_rev, dst_rev, merge_child_func, dry_run=False):
"""
Attempt to merge src_rev into dst_rev but taking into account what have
changed in both revs since the last known common point, the fork_rev.
In case of conflict, raise a MergeConflictException(). If dry run is True,
don't actually perform the merge, but detect potential conflicts.
This function recurses into all children nodes stored under the rev and
performs the merge if the children is also part of a transaction branch.
:param fork_rev: Point of forking (last known common state between branches
:param src_rev: Latest rev from which we merge to dst_rev
:param dst_rev: Target (destination) rev
:param merge_child_fun: To run a potential merge in all children that
may need merge (determined from the local changes)
:param dry_run: If True, do not perform the merge, but detect merge
conflicts.
:return: The new dst_rev (a new rev instance) the list of changes that
occurred in this node or any of its children as part of this merge.
"""
# to collect change tuples of (<callback-type>, <op-context>)
changes = []
class AnalyzeChanges(object):
def __init__(self, lst1, lst2, keyname):
self.keymap1 = OrderedDict((getattr(rev._config._data, keyname), i)
for i, rev in enumerate(lst1))
self.keymap2 = OrderedDict((getattr(rev._config._data, keyname), i)
for i, rev in enumerate(lst2))
self.added_keys = [
k for k in self.keymap2.iterkeys() if k not in self.keymap1]
self.removed_keys = [
k for k in self.keymap1.iterkeys() if k not in self.keymap2]
self.changed_keys = [
k for k in self.keymap1.iterkeys()
if k in self.keymap2 and
lst1[self.keymap1[k]]._hash != lst2[self.keymap2[k]]._hash
]
# Note: there are a couple of special cases that can be optimized
# for larer on. But since premature optimization is a bad idea, we
# defer them.
# deal with config data first
if dst_rev._config is fork_rev._config:
# no change in master, accept src if different
config_changed = dst_rev._config != src_rev._config
else:
if dst_rev._config.hash != src_rev._config.hash:
raise MergeConflictException('Config collision')
config_changed = True
# now to the external children fields
new_children = dst_rev._children.copy()
_children_fields = children_fields(fork_rev.data.__class__)
for field_name, field in _children_fields.iteritems():
fork_list = fork_rev._children[field_name]
src_list = src_rev._children[field_name]
dst_list = dst_rev._children[field_name]
if dst_list == src_list:
# we do not need to change the dst, however we still need
# to complete the branch purging in child nodes so not
# to leave dangling branches around
[merge_child_func(rev) for rev in src_list]
continue
if not field.key:
# If the list is not keyed, we really should not merge. We merely
# check for collision, i.e., if both changed (and not same)
if dst_list == fork_list:
# dst branch did not change since fork
assert src_list != fork_list, 'We should not be here otherwise'
# the incoming (src) rev changed, and we have to apply it
new_children[field_name] = [
merge_child_func(rev) for rev in src_list]
if field.is_container:
changes.append((CallbackType.POST_LISTCHANGE,
OperationContext(field_name=field_name)))
else:
if src_list != fork_list:
raise MergeConflictException(
'Cannot merge because single child node or un-keyed'
'children list has changed')
else:
if dst_list == fork_list:
# Destination did not change
# We need to analyze only the changes on the incoming rev
# since fork
src = AnalyzeChanges(fork_list, src_list, field.key)
new_list = copy(src_list) # we start from the source list
for key in src.added_keys:
idx = src.keymap2[key]
new_rev = merge_child_func(new_list[idx])
new_list[idx] = new_rev
changes.append(
(CallbackType.POST_ADD,
new_rev.data))
# OperationContext(
# field_name=field_name,
# child_key=key,
# data=new_rev.data)))
for key in src.removed_keys:
old_rev = fork_list[src.keymap1[key]]
changes.append((
CallbackType.POST_REMOVE,
old_rev.data))
# OperationContext(
# field_name=field_name,
# child_key=key,
# data=old_rev.data)))
for key in src.changed_keys:
idx = src.keymap2[key]
new_rev = merge_child_func(new_list[idx])
new_list[idx] = new_rev
# updated child gets its own change event
new_children[field_name] = new_list
else:
# For keyed fields we can really investigate what has been
# added, removed, or changed in both branches and do a
# fine-grained collision detection and merge
src = AnalyzeChanges(fork_list, src_list, field.key)
dst = AnalyzeChanges(fork_list, dst_list, field.key)
new_list = copy(dst_list) # this time we start with the dst
for key in src.added_keys:
# we cannot add if it has been added and is different
if key in dst.added_keys:
# it has been added to both, we need to check if
# they are the same
child_dst_rev = dst_list[dst.keymap2[key]]
child_src_rev = src_list[src.keymap2[key]]
if child_dst_rev.hash == child_src_rev.hash:
# they match, so we do not need to change the
# dst list, but we still need to purge the src
# branch
merge_child_func(child_dst_rev)
else:
raise MergeConflictException(
'Cannot add because it has been added and '
'different'
)
else:
# this is a brand new key, need to add it
new_rev = merge_child_func(src_list[src.keymap2[key]])
new_list.append(new_rev)
changes.append((
CallbackType.POST_ADD,
new_rev.data))
# OperationContext(
# field_name=field_name,
# child_key=key,
# data=new_rev.data)))
for key in src.changed_keys:
# we cannot change if it was removed in dst
if key in dst.removed_keys:
raise MergeConflictException(
'Cannot change because it has been removed')
# if it changed in dst as well, we need to check if they
# match (same change
elif key in dst.changed_keys:
child_dst_rev = dst_list[dst.keymap2[key]]
child_src_rev = src_list[src.keymap2[key]]
if child_dst_rev.hash == child_src_rev.hash:
# they match, so we do not need to change the
# dst list, but we still need to purge the src
# branch
merge_child_func(child_src_rev)
elif child_dst_rev._config.hash != child_src_rev._config.hash:
raise MergeConflictException(
'Cannot update because it has been changed and '
'different'
)
else:
new_rev = merge_child_func(
src_list[src.keymap2[key]])
new_list[dst.keymap2[key]] = new_rev
# no announcement for child update
else:
# it only changed in src branch
new_rev = merge_child_func(src_list[src.keymap2[key]])
new_list[dst.keymap2[key]] = new_rev
# no announcement for child update
for key in reversed(src.removed_keys): # we go from highest
# index to lowest
# we cannot remove if it has changed in dst
if key in dst.changed_keys:
raise MergeConflictException(
'Cannot remove because it has changed')
# if it has not been removed yet from dst, then remove it
if key not in dst.removed_keys:
dst_idx = dst.keymap2[key]
old_rev = new_list.pop(dst_idx)
changes.append((
CallbackType.POST_REMOVE,
old_rev.data))
# OperationContext(
# field_name=field_name,
# child_key=key,
# data=old_rev.data)))
new_children[field_name] = new_list
if not dry_run:
rev = src_rev if config_changed else dst_rev
rev = rev.update_all_children(new_children, dst_rev._branch)
if config_changed:
changes.append((CallbackType.POST_UPDATE, rev.data))
return rev, changes
else:
return None, None