Skip to content
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

Merged
merged 26 commits into from
Jul 11, 2016
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ff5f016
A failing test
wallrj Jun 20, 2016
835627a
Add a proxy to collect and apply adjacent set operations to an evolve…
wallrj Jun 21, 2016
2e00493
Add logging so I can see what diffs are failing.
wallrj Jun 22, 2016
962daa5
Tests for the diffs which modify a Node applications and manifestatio…
wallrj Jun 23, 2016
adcf738
A more flexible approach....not quite complete
wallrj Jun 23, 2016
0bec10c
Work in progress
wallrj Jun 23, 2016
a9ce128
Handle add and remove of Pyrsistent objects
wallrj Jun 24, 2016
5502b7b
Handle the freezing of map like objects and sets differently.
wallrj Jun 24, 2016
ae06a41
remove unused import
wallrj Jun 24, 2016
19d0d86
oops, I removed too much
wallrj Jun 24, 2016
ac9e6cb
When removing an item attempt to remove it from the evolver too.
wallrj Jun 24, 2016
68018e1
Merge remote-tracking branch 'origin/master' into invariant-error-FLO…
wallrj Jun 24, 2016
a982d77
docstrings
wallrj Jun 24, 2016
beac6b7
Merge remote-tracking branch 'origin/cancel-timer-FLOC-4450' into inv…
wallrj Jun 24, 2016
5352c86
Merge remote-tracking branch 'origin/master' into invariant-error-FLO…
wallrj Jun 24, 2016
c4e2815
A clearer set up for the expected test object.
wallrj Jul 5, 2016
f8cfdd6
Use twisted MonkeyPatcher directly....maybe overkill?!
wallrj Jul 5, 2016
1e512d5
Explain the purpose of the DiffTestObjInvariant class.
wallrj Jul 5, 2016
b0549a4
Merge remote-tracking branch 'origin/master' into invariant-error-FLO…
wallrj Jul 7, 2016
ff7730f
A note about why we're importing a private function from pyrsistent
wallrj Jul 7, 2016
d452558
Add a attribute to the _Set operation and a new _Replace operation f…
wallrj Jul 7, 2016
a24841f
Use Zope Interface to mark classes that support Pyrsistent evolver me…
wallrj Jul 7, 2016
14e2af6
Give clearer errors when the object supplied to _EvolverProxy.transfo…
wallrj Jul 8, 2016
574cf92
docstring
wallrj Jul 8, 2016
7ec0238
Refactor things...a lot...to get rid of hasattr checks and to have se…
wallrj Jul 8, 2016
7abd8a8
Add missing docstrings
wallrj Jul 8, 2016
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 168 additions & 3 deletions flocker/control/_diffing.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
flocker configuration or the flocker state.
"""

from eliot import MessageType, Field

from pyrsistent import (
PClass,
PMap,
Expand All @@ -15,6 +17,7 @@
pvector,
pvector_field,
)
from pyrsistent._transformations import _get
Copy link
Contributor

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?

Copy link
Contributor Author

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.


from zope.interface import Interface, implementer

Expand Down Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be nice to have docs on the interface expected of obj and self.path. They look pyrsistent-y but this change seems kind of subtle so I'm not sure.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)
Expand All @@ -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'):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasattr can mask errors. It's safer in general to use getattr(obj, attr, sentinel) and compare against sentinel.

Copy link
Contributor

Choose a reason for hiding this comment

The 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, isinstance(item, (all the pyrsistent types)) instead of hasattr() (or getattr).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've gone down the classImplements route and it works quite well I think.

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):
"""
Expand All @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if not isinstance(c, _Set): raise TypeError(...)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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:
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • _diffing import wire_encode from
  • _persistence imports SERIALIZABLE_CLASSES from
  • _model imports DIFF_SERIALIZABLE_CLASSES from
  • _diffing

I'll try and sort that out.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.
For now, I'm going to leave it.

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):
Expand Down
144 changes: 141 additions & 3 deletions flocker/control/test/test_diffing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down Expand Up @@ -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)
Expand All @@ -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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe noteworthy that the invariant spans multiple fields?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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()
Copy link
Contributor

Choose a reason for hiding this comment

The 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"?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I guess I could simplify that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe patch() here. You can undo a patch early by saving the result and calling methods on it, http://twistedmatrix.com/documents/current/api/twisted.trial.unittest.SynchronousTestCase.html#patch

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I did try that but I think we're using testtools patch which didn't seem to return anything...I didn't investigate though. I'll double check.

Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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),
)
Loading