Matt Jeanneret | f1e9c5d | 2019-02-08 07:41:29 -0500 | [diff] [blame] | 1 | # |
| 2 | # Copyright 2017 the original author or authors. |
| 3 | # |
| 4 | # Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | # you may not use this file except in compliance with the License. |
| 6 | # You may obtain a copy of the License at |
| 7 | # |
| 8 | # http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | # |
| 10 | # Unless required by applicable law or agreed to in writing, software |
| 11 | # distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | # See the License for the specific language governing permissions and |
| 14 | # limitations under the License. |
| 15 | # |
| 16 | |
| 17 | """ |
| 18 | 3-way merge function for config rev objects. |
| 19 | """ |
| 20 | from collections import OrderedDict |
| 21 | from copy import copy |
| 22 | |
| 23 | from voltha.core.config.config_proxy import CallbackType, OperationContext |
| 24 | from voltha.core.config.config_rev import children_fields |
| 25 | |
| 26 | |
| 27 | class MergeConflictException(Exception): |
| 28 | pass |
| 29 | |
| 30 | |
| 31 | def merge_3way(fork_rev, src_rev, dst_rev, merge_child_func, dry_run=False): |
| 32 | """ |
| 33 | Attempt to merge src_rev into dst_rev but taking into account what have |
| 34 | changed in both revs since the last known common point, the fork_rev. |
| 35 | In case of conflict, raise a MergeConflictException(). If dry run is True, |
| 36 | don't actually perform the merge, but detect potential conflicts. |
| 37 | |
| 38 | This function recurses into all children nodes stored under the rev and |
| 39 | performs the merge if the children is also part of a transaction branch. |
| 40 | |
| 41 | :param fork_rev: Point of forking (last known common state between branches |
| 42 | :param src_rev: Latest rev from which we merge to dst_rev |
| 43 | :param dst_rev: Target (destination) rev |
| 44 | :param merge_child_fun: To run a potential merge in all children that |
| 45 | may need merge (determined from the local changes) |
| 46 | :param dry_run: If True, do not perform the merge, but detect merge |
| 47 | conflicts. |
| 48 | :return: The new dst_rev (a new rev instance) the list of changes that |
| 49 | occurred in this node or any of its children as part of this merge. |
| 50 | """ |
| 51 | |
| 52 | # to collect change tuples of (<callback-type>, <op-context>) |
| 53 | changes = [] |
| 54 | |
| 55 | class AnalyzeChanges(object): |
| 56 | def __init__(self, lst1, lst2, keyname): |
| 57 | self.keymap1 = OrderedDict((getattr(rev._config._data, keyname), i) |
| 58 | for i, rev in enumerate(lst1)) |
| 59 | self.keymap2 = OrderedDict((getattr(rev._config._data, keyname), i) |
| 60 | for i, rev in enumerate(lst2)) |
| 61 | self.added_keys = [ |
| 62 | k for k in self.keymap2.iterkeys() if k not in self.keymap1] |
| 63 | self.removed_keys = [ |
| 64 | k for k in self.keymap1.iterkeys() if k not in self.keymap2] |
| 65 | self.changed_keys = [ |
| 66 | k for k in self.keymap1.iterkeys() |
| 67 | if k in self.keymap2 and |
| 68 | lst1[self.keymap1[k]]._hash != lst2[self.keymap2[k]]._hash |
| 69 | ] |
| 70 | |
| 71 | # Note: there are a couple of special cases that can be optimized |
| 72 | # for larer on. But since premature optimization is a bad idea, we |
| 73 | # defer them. |
| 74 | |
| 75 | # deal with config data first |
| 76 | if dst_rev._config is fork_rev._config: |
| 77 | # no change in master, accept src if different |
| 78 | config_changed = dst_rev._config != src_rev._config |
| 79 | else: |
| 80 | if dst_rev._config.hash != src_rev._config.hash: |
| 81 | raise MergeConflictException('Config collision') |
| 82 | config_changed = True |
| 83 | |
| 84 | # now to the external children fields |
| 85 | new_children = dst_rev._children.copy() |
| 86 | _children_fields = children_fields(fork_rev.data.__class__) |
| 87 | |
| 88 | for field_name, field in _children_fields.iteritems(): |
| 89 | |
| 90 | fork_list = fork_rev._children[field_name] |
| 91 | src_list = src_rev._children[field_name] |
| 92 | dst_list = dst_rev._children[field_name] |
| 93 | |
| 94 | if dst_list == src_list: |
| 95 | # we do not need to change the dst, however we still need |
| 96 | # to complete the branch purging in child nodes so not |
| 97 | # to leave dangling branches around |
| 98 | [merge_child_func(rev) for rev in src_list] |
| 99 | continue |
| 100 | |
| 101 | if not field.key: |
| 102 | # If the list is not keyed, we really should not merge. We merely |
| 103 | # check for collision, i.e., if both changed (and not same) |
| 104 | if dst_list == fork_list: |
| 105 | # dst branch did not change since fork |
| 106 | |
| 107 | assert src_list != fork_list, 'We should not be here otherwise' |
| 108 | |
| 109 | # the incoming (src) rev changed, and we have to apply it |
| 110 | new_children[field_name] = [ |
| 111 | merge_child_func(rev) for rev in src_list] |
| 112 | |
| 113 | if field.is_container: |
| 114 | changes.append((CallbackType.POST_LISTCHANGE, |
| 115 | OperationContext(field_name=field_name))) |
| 116 | |
| 117 | else: |
| 118 | if src_list != fork_list: |
| 119 | raise MergeConflictException( |
| 120 | 'Cannot merge because single child node or un-keyed' |
| 121 | 'children list has changed') |
| 122 | |
| 123 | else: |
| 124 | |
| 125 | if dst_list == fork_list: |
| 126 | # Destination did not change |
| 127 | |
| 128 | # We need to analyze only the changes on the incoming rev |
| 129 | # since fork |
| 130 | src = AnalyzeChanges(fork_list, src_list, field.key) |
| 131 | |
| 132 | new_list = copy(src_list) # we start from the source list |
| 133 | |
| 134 | for key in src.added_keys: |
| 135 | idx = src.keymap2[key] |
| 136 | new_rev = merge_child_func(new_list[idx]) |
| 137 | new_list[idx] = new_rev |
| 138 | changes.append( |
| 139 | (CallbackType.POST_ADD, |
| 140 | new_rev.data)) |
| 141 | # OperationContext( |
| 142 | # field_name=field_name, |
| 143 | # child_key=key, |
| 144 | # data=new_rev.data))) |
| 145 | |
| 146 | for key in src.removed_keys: |
| 147 | old_rev = fork_list[src.keymap1[key]] |
| 148 | changes.append(( |
| 149 | CallbackType.POST_REMOVE, |
| 150 | old_rev.data)) |
| 151 | # OperationContext( |
| 152 | # field_name=field_name, |
| 153 | # child_key=key, |
| 154 | # data=old_rev.data))) |
| 155 | |
| 156 | for key in src.changed_keys: |
| 157 | idx = src.keymap2[key] |
| 158 | new_rev = merge_child_func(new_list[idx]) |
| 159 | new_list[idx] = new_rev |
| 160 | # updated child gets its own change event |
| 161 | |
| 162 | new_children[field_name] = new_list |
| 163 | |
| 164 | else: |
| 165 | |
| 166 | # For keyed fields we can really investigate what has been |
| 167 | # added, removed, or changed in both branches and do a |
| 168 | # fine-grained collision detection and merge |
| 169 | |
| 170 | src = AnalyzeChanges(fork_list, src_list, field.key) |
| 171 | dst = AnalyzeChanges(fork_list, dst_list, field.key) |
| 172 | |
| 173 | new_list = copy(dst_list) # this time we start with the dst |
| 174 | |
| 175 | for key in src.added_keys: |
| 176 | # we cannot add if it has been added and is different |
| 177 | if key in dst.added_keys: |
| 178 | # it has been added to both, we need to check if |
| 179 | # they are the same |
| 180 | child_dst_rev = dst_list[dst.keymap2[key]] |
| 181 | child_src_rev = src_list[src.keymap2[key]] |
| 182 | if child_dst_rev.hash == child_src_rev.hash: |
| 183 | # they match, so we do not need to change the |
| 184 | # dst list, but we still need to purge the src |
| 185 | # branch |
| 186 | merge_child_func(child_dst_rev) |
| 187 | else: |
| 188 | raise MergeConflictException( |
| 189 | 'Cannot add because it has been added and ' |
| 190 | 'different' |
| 191 | ) |
| 192 | else: |
| 193 | # this is a brand new key, need to add it |
| 194 | new_rev = merge_child_func(src_list[src.keymap2[key]]) |
| 195 | new_list.append(new_rev) |
| 196 | changes.append(( |
| 197 | CallbackType.POST_ADD, |
| 198 | new_rev.data)) |
| 199 | # OperationContext( |
| 200 | # field_name=field_name, |
| 201 | # child_key=key, |
| 202 | # data=new_rev.data))) |
| 203 | |
| 204 | for key in src.changed_keys: |
| 205 | # we cannot change if it was removed in dst |
| 206 | if key in dst.removed_keys: |
| 207 | raise MergeConflictException( |
| 208 | 'Cannot change because it has been removed') |
| 209 | |
| 210 | # if it changed in dst as well, we need to check if they |
| 211 | # match (same change |
| 212 | elif key in dst.changed_keys: |
| 213 | child_dst_rev = dst_list[dst.keymap2[key]] |
| 214 | child_src_rev = src_list[src.keymap2[key]] |
| 215 | if child_dst_rev.hash == child_src_rev.hash: |
| 216 | # they match, so we do not need to change the |
| 217 | # dst list, but we still need to purge the src |
| 218 | # branch |
| 219 | merge_child_func(child_src_rev) |
| 220 | elif child_dst_rev._config.hash != child_src_rev._config.hash: |
| 221 | raise MergeConflictException( |
| 222 | 'Cannot update because it has been changed and ' |
| 223 | 'different' |
| 224 | ) |
| 225 | else: |
| 226 | new_rev = merge_child_func( |
| 227 | src_list[src.keymap2[key]]) |
| 228 | new_list[dst.keymap2[key]] = new_rev |
| 229 | # no announcement for child update |
| 230 | |
| 231 | else: |
| 232 | # it only changed in src branch |
| 233 | new_rev = merge_child_func(src_list[src.keymap2[key]]) |
| 234 | new_list[dst.keymap2[key]] = new_rev |
| 235 | # no announcement for child update |
| 236 | |
| 237 | for key in reversed(src.removed_keys): # we go from highest |
| 238 | # index to lowest |
| 239 | |
| 240 | # we cannot remove if it has changed in dst |
| 241 | if key in dst.changed_keys: |
| 242 | raise MergeConflictException( |
| 243 | 'Cannot remove because it has changed') |
| 244 | |
| 245 | # if it has not been removed yet from dst, then remove it |
| 246 | if key not in dst.removed_keys: |
| 247 | dst_idx = dst.keymap2[key] |
| 248 | old_rev = new_list.pop(dst_idx) |
| 249 | changes.append(( |
| 250 | CallbackType.POST_REMOVE, |
| 251 | old_rev.data)) |
| 252 | # OperationContext( |
| 253 | # field_name=field_name, |
| 254 | # child_key=key, |
| 255 | # data=old_rev.data))) |
| 256 | |
| 257 | new_children[field_name] = new_list |
| 258 | |
| 259 | if not dry_run: |
| 260 | rev = src_rev if config_changed else dst_rev |
| 261 | rev = rev.update_all_children(new_children, dst_rev._branch) |
| 262 | if config_changed: |
| 263 | changes.append((CallbackType.POST_UPDATE, rev.data)) |
| 264 | return rev, changes |
| 265 | |
| 266 | else: |
| 267 | return None, None |