-
Notifications
You must be signed in to change notification settings - Fork 290
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[FLOC-4452] Delay invariant checks until all diff operations have been applied #2839
Changes from 15 commits
ff5f016
835627a
2e00493
962daa5
adcf738
0bec10c
a9ce128
5502b7b
ae06a41
19d0d86
ac9e6cb
68018e1
a982d77
beac6b7
5352c86
c4e2815
f8cfdd6
1e512d5
b0549a4
ff7730f
d452558
a24841f
14e2af6
574cf92
7ec0238
7abd8a8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,6 +7,8 @@ | |
flocker configuration or the flocker state. | ||
""" | ||
|
||
from eliot import MessageType, Field | ||
|
||
from pyrsistent import ( | ||
PClass, | ||
PMap, | ||
|
@@ -15,6 +17,7 @@ | |
pvector, | ||
pvector_field, | ||
) | ||
from pyrsistent._transformations import _get | ||
|
||
from zope.interface import Interface, implementer | ||
|
||
|
@@ -76,7 +79,9 @@ class _Set(PClass): | |
value = field() | ||
|
||
def apply(self, obj): | ||
return obj.transform(self.path, self.value) | ||
return obj.transform( | ||
self.path[:-1], lambda o: o.set(self.path[-1], self.value) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It'd be nice to have docs on the interface expected of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've added new interfaces which hopefully describe the API of the object that will be supplied to the transform operation. |
||
) | ||
|
||
|
||
@implementer(_IDiffChange) | ||
|
@@ -95,6 +100,152 @@ def apply(self, obj): | |
return obj.transform(self.path, lambda x: x.add(self.item)) | ||
|
||
|
||
_sentinel = object() | ||
|
||
|
||
class _EvolverProxy(object): | ||
""" | ||
This attempts to bunch all the diff operations for a particular object into | ||
a single transaction so that related attributes can be ``set`` without | ||
triggering an in invariant error. | ||
Additionally, the leaf nodes are persisted first and in isolation, so as | ||
not to trigger invariant errors in ancestor nodes. | ||
""" | ||
def __init__(self, original): | ||
""" | ||
:param PClass original: The root object to which transformations will | ||
be applied. | ||
""" | ||
self._original = original | ||
self._evolver = original.evolver() | ||
self._children = {} | ||
self._operations = [] | ||
|
||
def _child(self, segment): | ||
child = self._children.get(segment) | ||
if child is not None: | ||
return child | ||
child = _get(self._original, segment, _sentinel) | ||
if child is _sentinel: | ||
raise KeyError( | ||
'Segment not found in path. ' | ||
'Parent: {}, ' | ||
'Segment: {}'.format(self, segment) | ||
) | ||
proxy_for_child = _EvolverProxy(child) | ||
self._children[segment] = proxy_for_child | ||
return proxy_for_child | ||
|
||
def transform(self, path, operation): | ||
""" | ||
Traverse each segment of ``path`` to create a hierarchy of | ||
``_EvolverProxy`` objects and perform the ``operation`` on the | ||
resulting leaf proxy object. This will infact perform the operation on | ||
an evolver of the original Pyrsistent object. | ||
|
||
:param PVector path: The path relative to ``original`` which will be | ||
operated on. | ||
:param callable operation: A function to be applied to an evolver of | ||
the object at ``path`` | ||
:returns: ``self`` | ||
""" | ||
target = self | ||
for segment in path: | ||
target = target._child(segment) | ||
operation(target) | ||
return self | ||
|
||
def add(self, item): | ||
""" | ||
Add ``item`` to the ``original`` ``Pset`` or if the item is itself a | ||
Pyrsistent object, add a new proxy for that item so that further | ||
operations can be performed on it without triggering invariant checks | ||
until the tree is finally committed. | ||
|
||
:param item: An object to be added to the ``PSet`` wrapped by this | ||
proxy. | ||
:returns: ``self`` | ||
""" | ||
if hasattr(item, 'evolver'): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also I can't quite figure out what this is checking for or why. Is it looking for an evolver method as a way to detect a pyrsistent type that can have an evolver for it created? That's my only guess. If it's that, some docs to that effect would help. Also, I might suggest an alternate way of detecting such things. Make a zope.interface.Interface for an object that can make an evolver (I guess this just means it has an "evolver" method, maybe it returns an object that provides another evolver interface?). Use https://docs.zope.org/zope.interface/api.html#zope-interface-declarations-classimplements to declare that the pyrsistent types you're trying to support implement that interface, then use zope.interface to check for the interface instead of checking for an evolver attribute. This makes the interface this interaction depends on explicit and removes the chance of an accidental collision with another object that just happens to have an unrelated "evolver" attribute. Alternatively, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've gone down the |
||
self._children[item] = _EvolverProxy(item) | ||
else: | ||
self._evolver.add(item) | ||
return self | ||
|
||
def set(self, key, item): | ||
""" | ||
Set the ``item`` in an evolver of the ``original`` ``PMap`` or | ||
``PClass`` or if the item is itself a Pyrsistent object, add a new | ||
proxy for that item so that further operations can be performed on it | ||
without triggering invariant checks until the tree is finally | ||
committed. | ||
|
||
:param item: An object to be added or set on the ``PMap`` wrapped by | ||
this proxy. | ||
:returns: ``self`` | ||
""" | ||
if hasattr(item, 'evolver'): | ||
# This will replace any existing proxy. | ||
self._children[key] = _EvolverProxy(item) | ||
else: | ||
self._evolver.set(key, item) | ||
return self | ||
|
||
def remove(self, item): | ||
""" | ||
Remove the ``item`` in an evolver of the ``original`` ``PMap``, | ||
``PClass``, or ``PSet`` and if the item is an uncommitted | ||
``_EvolverProxy`` remove it from the list of children so that the item | ||
is not persisted when the structure is finally committed. | ||
|
||
:param item: The object to be removed from the wrapped ``PSet`` or the | ||
key to be removed from the wrapped ``PMap`` | ||
:returns: ``self`` | ||
""" | ||
self._children.pop(item, None) | ||
# Attempt to remove the item from the evolver too. It may be something | ||
# that was replaced rather than added by a previous ``set`` operation. | ||
try: | ||
self._evolver.remove(item) | ||
except KeyError: | ||
pass | ||
return self | ||
|
||
def commit(self): | ||
""" | ||
Persist all the changes made to the descendants of this structure, then | ||
persist the resulting sub-objects and local changes to this root object | ||
and finally return the resulting immutable structure. | ||
|
||
:returns: The updated and persisted version of ``original``. | ||
""" | ||
for segment, child_evolver_proxy in self._children.items(): | ||
child = child_evolver_proxy.commit() | ||
# XXX this is ugly. Perhaps have a separate proxy for PClass, PMap | ||
# and PSet collections | ||
if hasattr(self._evolver, 'set'): | ||
self._evolver.set(segment, child) | ||
else: | ||
self._evolver.add(child) | ||
return self._evolver.persistent() | ||
|
||
|
||
TARGET_OBJECT = Field( | ||
u"target_object", repr, | ||
u"The object to which the diff was applied." | ||
) | ||
CHANGES = Field( | ||
u"changes", repr, | ||
u"The changes being applied." | ||
) | ||
|
||
DIFF_COMMIT_ERROR = MessageType( | ||
u"flocker:control:Diff:commit_error", | ||
[TARGET_OBJECT, CHANGES], | ||
u"The target and changes that failed to apply." | ||
) | ||
|
||
|
||
@implementer(_IDiffChange) | ||
class Diff(PClass): | ||
""" | ||
|
@@ -111,9 +262,23 @@ class Diff(PClass): | |
changes = pvector_field(object) | ||
|
||
def apply(self, obj): | ||
proxy = _EvolverProxy(original=obj) | ||
for c in self.changes: | ||
obj = c.apply(obj) | ||
return obj | ||
if len(c.path) > 0: | ||
proxy = c.apply(proxy) | ||
else: | ||
assert type(c) is _Set | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I got rid of it and added some unit tests for the TypeError raised. |
||
proxy = _EvolverProxy(original=c.value) | ||
try: | ||
return proxy.commit() | ||
except: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a reason to use a bare except here? As opposed to e.g. Exception or BaseException. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just that I'm re-raising the original exception after logging the failing diff. |
||
# Imported here to avoid circular dependencies. | ||
from ._persistence import wire_encode | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wasn't able to figure out the path of the circularity this avoids. Long-term it might make sense to factor the basic stuff into a separate module so there's no circularity and no nested imports are required. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I'll try and sort that out. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I had a go at re-organizing the modules in #2858 but it needs more thought. |
||
DIFF_COMMIT_ERROR( | ||
target_object=wire_encode(obj), | ||
changes=wire_encode(self.changes), | ||
).write() | ||
raise | ||
|
||
|
||
def _create_diffs_for_sets(current_path, set_a, set_b): | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,16 +7,18 @@ | |
from json import dumps | ||
from uuid import uuid4 | ||
|
||
from eliot.testing import capture_logging, assertHasMessage | ||
from hypothesis import given | ||
import hypothesis.strategies as st | ||
from pyrsistent import PClass, field, pmap, pset | ||
from pyrsistent import PClass, field, pmap, pset, InvariantException | ||
|
||
from .._diffing import create_diff, compose_diffs | ||
from .._diffing import create_diff, compose_diffs, DIFF_COMMIT_ERROR | ||
from .._persistence import wire_encode, wire_decode | ||
from .._model import Node, Port | ||
from ..testtools import ( | ||
application_strategy, | ||
deployment_strategy, | ||
node_strategy, | ||
related_deployments_strategy | ||
) | ||
|
||
|
@@ -183,7 +185,6 @@ def test_different_objects(self): | |
object_a = DiffTestObj(a=pset(xrange(1000))) | ||
object_b = pmap({'1': 34}) | ||
diff = create_diff(object_a, object_b) | ||
|
||
self.assertThat( | ||
wire_decode(wire_encode(diff)).apply(object_a), | ||
Equals(object_b) | ||
|
@@ -202,3 +203,140 @@ def test_different_uuids(self): | |
wire_decode(wire_encode(diff)).apply(object_a), | ||
Equals(object_b) | ||
) | ||
|
||
|
||
class DiffTestObjInvariant(PClass): | ||
""" | ||
Simple pyrsistent object with an invariant. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe noteworthy that the invariant spans multiple fields? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. |
||
""" | ||
_perform_invariant_check = True | ||
a = field() | ||
b = field() | ||
|
||
def __invariant__(self): | ||
if self._perform_invariant_check and self.a == self.b: | ||
return (False, "a must not equal b") | ||
else: | ||
return (True, "") | ||
|
||
|
||
class InvariantDiffTests(TestCase): | ||
""" | ||
Tests for creating and applying diffs to objects with invariant checks. | ||
""" | ||
def test_straight_swap(self): | ||
""" | ||
A diff composed of two separate ``set`` operations can be applied to an | ||
object without triggering an invariant exception. | ||
""" | ||
o1 = DiffTestObjInvariant( | ||
a=1, | ||
b=2, | ||
) | ||
o2 = DiffTestObjInvariant( | ||
a=2, | ||
b=1, | ||
) | ||
diff = create_diff(o1, o2) | ||
self.assertEqual(2, len(diff.changes)) | ||
self.assertEqual( | ||
o2, | ||
diff.apply(o1) | ||
) | ||
|
||
def test_deep_swap(self): | ||
""" | ||
A diff composed of two separate ``set`` operations can be applied to a | ||
nested object without triggering an invariant exception. | ||
""" | ||
a = DiffTestObjInvariant( | ||
a=1, | ||
b=2, | ||
) | ||
b = DiffTestObjInvariant( | ||
a=3, | ||
b=4, | ||
) | ||
o1 = DiffTestObjInvariant( | ||
a=a, | ||
b=b, | ||
) | ||
o2 = o1.transform( | ||
['a'], | ||
lambda o: o.evolver().set('a', 2).set('b', 1).persistent() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "transform the object referenced by the a attribute of o1 by setting that object's a attribute to 2 and its b attribute to 1"? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I guess I could simplify that. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. |
||
) | ||
diff = create_diff(o1, o2) | ||
|
||
self.assertEqual( | ||
o2, | ||
diff.apply(o1) | ||
) | ||
|
||
@capture_logging(assertHasMessage, DIFF_COMMIT_ERROR) | ||
def test_error_logging(self, logger): | ||
""" | ||
Failures while applying a diff emit a log message containing the full | ||
diff. | ||
""" | ||
o1 = DiffTestObjInvariant( | ||
a=1, | ||
b=2, | ||
) | ||
DiffTestObjInvariant._perform_invariant_check = False | ||
o2 = o1.set('b', 1) | ||
DiffTestObjInvariant._perform_invariant_check = True | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I did try that but I think we're using testtools There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ahh, I forgot about testtools. Yea, maybe right. In that case, at least try/finally to minimize the chance state gets left corrupted. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. |
||
diff = create_diff(o1, o2) | ||
self.assertRaises( | ||
InvariantException, | ||
diff.apply, | ||
o1, | ||
) | ||
|
||
def test_application_add(self): | ||
""" | ||
A diff on a Node, which *adds* and application with a volume *and* the | ||
manifestation for the volume, can be applied without triggering an | ||
invariant error on the Node. | ||
""" | ||
node2 = node_strategy( | ||
min_number_of_applications=1, | ||
stateful_applications=True, | ||
).example() | ||
application = node2.applications.values()[0] | ||
node1 = node2.transform( | ||
['applications'], | ||
lambda o: o.remove(application.name) | ||
).transform( | ||
['manifestations'], | ||
lambda o: o.remove(application.volume.manifestation.dataset_id) | ||
) | ||
diff = create_diff(node1, node2) | ||
self.assertEqual( | ||
node2, | ||
diff.apply(node1), | ||
) | ||
|
||
def test_application_modify(self): | ||
""" | ||
A diff on a Node, which adds a volume to an *existing* application | ||
volume *and* the manifestation for the volume, can be applied without | ||
triggering an invariant error on the Node. | ||
""" | ||
node2 = node_strategy( | ||
min_number_of_applications=1, | ||
stateful_applications=True, | ||
).example() | ||
application = node2.applications.values()[0] | ||
volume = application.volume | ||
node1 = node2.transform( | ||
['applications', application.name], | ||
lambda o: o.set('volume', None) | ||
).transform( | ||
['manifestations'], | ||
lambda o: o.remove(volume.manifestation.dataset_id) | ||
) | ||
diff = create_diff(node1, node2) | ||
self.assertEqual( | ||
node2, | ||
diff.apply(node1), | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks private. If so, file an issue with upstream to expose this functionality publicly, link to it from here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think this should be public, but instead I've suggested that the ability to perform multiple transformations before checking invariants should be provided by Pyrsistent: tobgu/pyrsistent#89
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.