launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #26389
[Merge] ~cjwatson/launchpad:fix-stormbase-eq-hash into launchpad:master
Colin Watson has proposed merging ~cjwatson/launchpad:fix-stormbase-eq-hash into launchpad:master.
Commit message:
Fix handling of removed objects in StormBase
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
Related bugs:
Bug #1916522 in Launchpad itself: "buildd-manager regression: 'NoneType' object has no attribute 'flush'"
https://bugs.launchpad.net/launchpad/+bug/1916522
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/398498
Objects that have been removed don't have `primary_vars`, and `Store.of` returns None for them. This caused `StormBase.__eq__` (and, less importantly, `StormBase.__hash__`) to crash if given such an object. In most cases we don't keep removed objects around, but there is at least one exception: the cache of the current `BuildQueue` associated with the builder managed by a `SlaveScanner`, which is checked for equality with the `BuildQueue` found from the builder's vitals.
To avoid this problem, use `IStore(obj.__class__).flush()` instead, and in the `__eq__` case explicitly return False if this still doesn't cause `primary_vars` to appear. (`__hash__` will still crash if used on a removed object, but that seems to be OK.) This is a better match for the details of the behaviour of `SQLBase`.
Fixes a regression introduced by https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/398087.
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:fix-stormbase-eq-hash into launchpad:master.
diff --git a/lib/lp/buildmaster/tests/test_manager.py b/lib/lp/buildmaster/tests/test_manager.py
index 99b9e52..6b8c8d5 100644
--- a/lib/lp/buildmaster/tests/test_manager.py
+++ b/lib/lp/buildmaster/tests/test_manager.py
@@ -2,7 +2,7 @@
# NOTE: The first line above must stay first; do not move the copyright
# notice to the top. See http://www.python.org/dev/peps/pep-0263/.
#
-# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Tests for the renovated slave scanner aka BuilddManager."""
@@ -680,12 +680,17 @@ class TestSlaveScannerWithLibrarian(TestCaseWithFactory):
@defer.inlineCallbacks
def test_end_to_end(self):
# Test that SlaveScanner.scan() successfully finds, dispatches,
- # collects and cleans a build.
+ # collects and cleans a build, and then makes a reasonable start on
+ # a second build.
build = self.factory.makeBinaryPackageBuild()
build.distro_arch_series.addOrUpdateChroot(
self.factory.makeLibraryFileAlias(db_only=True))
bq = build.queueBuild()
bq.manualScore(9000)
+ build2 = self.factory.makeBinaryPackageBuild(
+ distroarchseries=build.distro_arch_series)
+ bq2 = build2.queueBuild()
+ bq2.manualScore(8900)
builder = self.factory.makeBuilder(
processors=[bq.processor], manual=False, vm_host='VMHOST')
@@ -765,6 +770,24 @@ class TestSlaveScannerWithLibrarian(TestCaseWithFactory):
self.assertIs(None, builder.currentjob)
self.assertEqual(BuilderCleanStatus.CLEAN, builder.clean_status)
+ # Now we can go round the loop again with a second build. (We only
+ # go far enough to ensure that the lost-job check works on the
+ # second iteration.)
+ get_slave.result = OkSlave()
+ yield scanner.scan()
+ self.assertEqual(
+ ['status', 'ensurepresent', 'build'],
+ get_slave.result.method_log)
+ self.assertEqual(bq2, builder.currentjob)
+ self.assertEqual(BuildQueueStatus.RUNNING, bq2.status)
+ self.assertEqual(BuildStatus.BUILDING, build2.status)
+ self.assertEqual(BuilderCleanStatus.DIRTY, builder.clean_status)
+
+ get_slave.result = BuildingSlave(build2.build_cookie)
+ yield scanner.scan()
+ yield scanner.manager.flushLogTails()
+ self.assertEqual("This is a build log: 0", bq2.logtail)
+
class TestPrefetchedBuilderFactory(TestCaseWithFactory):
diff --git a/lib/lp/services/database/stormbase.py b/lib/lp/services/database/stormbase.py
index 4d99064..29888ed 100644
--- a/lib/lp/services/database/stormbase.py
+++ b/lib/lp/services/database/stormbase.py
@@ -7,12 +7,10 @@ __all__ = [
]
from storm.info import get_obj_info
-from storm.locals import (
- Store,
- Storm,
- )
+from storm.locals import Storm
from zope.security.proxy import removeSecurityProxy
+from lp.services.database.interfaces import IStore
from lp.services.propertycache import clear_property_cache
@@ -25,12 +23,16 @@ class StormBase(Storm):
def __eq__(self, other):
"""Equality operator.
- Objects compare equal if they have the same class and id, and the id
- is not None.
+ Objects compare equal if they have the same class and primary key,
+ and the primary key is not None.
This rule allows objects retrieved from different stores to compare
- equal. Newly-created objects may not yet have an id; in such cases
- we flush the store so that we can find out their id.
+ equal. Newly-created objects may not yet have an primary key; in
+ such cases we flush the store so that we can find out their primary
+ key. Objects that have been removed from the store will have no
+ primary key even after flushing the store, and compare unequal to
+ anything (although keeping objects around after they have been
+ removed is not normally a good idea).
"""
other = removeSecurityProxy(other)
if self.__class__ != other.__class__:
@@ -38,9 +40,13 @@ class StormBase(Storm):
self_obj_info = get_obj_info(self)
other_obj_info = get_obj_info(other)
if "primary_vars" not in self_obj_info:
- Store.of(self).flush()
+ IStore(self.__class__).flush()
+ if "primary_vars" not in self_obj_info:
+ return False
if "primary_vars" not in other_obj_info:
- Store.of(other).flush()
+ IStore(other.__class__).flush()
+ if "primary_vars" not in other_obj_info:
+ return False
self_primary = [var.get() for var in self_obj_info["primary_vars"]]
other_primary = [var.get() for var in other_obj_info["primary_vars"]]
return self_primary == other_primary
@@ -60,7 +66,7 @@ class StormBase(Storm):
"""
obj_info = get_obj_info(self)
if "primary_vars" not in obj_info:
- Store.of(self).flush()
+ IStore(self.__class__).flush()
primary = [var.get() for var in obj_info["primary_vars"]]
return hash((self.__class__,) + tuple(primary))
diff --git a/lib/lp/services/database/tests/test_stormbase.py b/lib/lp/services/database/tests/test_stormbase.py
new file mode 100644
index 0000000..6abfa1f
--- /dev/null
+++ b/lib/lp/services/database/tests/test_stormbase.py
@@ -0,0 +1,64 @@
+# Copyright 2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""StormBase tests."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from storm.locals import Int
+import transaction
+from zope.component import getUtility
+
+from lp.services.database.interfaces import (
+ DEFAULT_FLAVOR,
+ IStore,
+ IStoreSelector,
+ MAIN_STORE,
+ )
+from lp.services.database.stormbase import StormBase
+from lp.testing import TestCase
+from lp.testing.layers import ZopelessDatabaseLayer
+
+
+class StormExample(StormBase):
+
+ __storm_table__ = "StormExample"
+
+ id = Int(primary=True)
+
+ @classmethod
+ def new(cls):
+ example = cls()
+ IStore(cls).add(example)
+ return example
+
+
+class TestStormBase(TestCase):
+
+ layer = ZopelessDatabaseLayer
+
+ def setUp(self):
+ super(TestStormBase, self).setUp()
+ self.store = getUtility(IStoreSelector).get(MAIN_STORE, DEFAULT_FLAVOR)
+ self.store.execute("CREATE TABLE StormExample (id serial PRIMARY KEY)")
+
+ def test_eq_ne(self):
+ examples = [StormExample.new() for _ in range(3)]
+ transaction.commit()
+ for i in range(len(examples)):
+ for j in range(len(examples)):
+ if i == j:
+ self.assertEqual(examples[i], examples[j])
+ else:
+ self.assertNotEqual(examples[i], examples[j])
+
+ def test_ne_removed(self):
+ # A removed object is not equal to a newly-created object, even
+ # though we no longer know the removed object's primary key.
+ example = StormExample.new()
+ self.store.remove(example)
+ self.store.flush()
+ new_example = StormExample.new()
+ self.assertNotEqual(example, new_example)