← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wgrant/launchpad/xref-buglinks into lp:launchpad

 

William Grant has proposed merging lp:~wgrant/launchpad/xref-buglinks into lp:launchpad with lp:~wgrant/launchpad/xref-model as a prerequisite.

Commit message:
BugCve/QuestionBug/SpecificationBug -> XRef, part 1: write to both schemas, read from either.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~wgrant/launchpad/xref-buglinks/+merge/272591

BugCve/QuestionBug/SpecificationBug -> XRef, part 1: write to both schemas, read from either.

There's also a garbo job to backfill XRef, and a test-only feature flag to disable writing to the old schema.

There are a couple of XXXs about filling in XRef.creator, but the old schema doesn't store that data, and this branch is big enough as is. creator comes later.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wgrant/launchpad/xref-buglinks into lp:launchpad.
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2015-09-29 04:00:42 +0000
+++ database/schema/security.cfg	2015-09-29 04:00:42 +0000
@@ -654,6 +654,7 @@
 public.validpersoncache                 = SELECT
 public.validpersonorteamcache           = SELECT
 public.wikiname                         = SELECT, INSERT
+public.xref                             = SELECT, INSERT
 type=user
 
 [branchscanner]
@@ -945,6 +946,7 @@
 public.translationgroup                 = SELECT
 public.validpersoncache                 = SELECT
 public.validpersonorteamcache           = SELECT
+public.xref                             = SELECT, INSERT
 type=user
 
 [fiera]
@@ -1295,6 +1297,7 @@
 public.teamparticipation                = SELECT
 public.validpersoncache                 = SELECT
 public.validpersonorteamcache           = SELECT
+public.xref                             = SELECT
 type=user
 
 [expire_questions]
@@ -1436,6 +1439,7 @@
 public.validpersoncache                 = SELECT
 public.validpersonorteamcache           = SELECT
 public.wikiname                         = SELECT, INSERT
+public.xref                             = SELECT, INSERT
 type=group
 
 [queued]
@@ -1545,6 +1549,7 @@
 public.teamparticipation                = SELECT, INSERT
 public.validpersoncache                 = SELECT
 public.validpersonorteamcache           = SELECT
+public.xref                             = SELECT, INSERT
 type=user
 
 [process_accepted]
@@ -1625,6 +1630,7 @@
 public.teamparticipation                = SELECT
 public.validpersoncache                 = SELECT
 public.validpersonorteamcache           = SELECT
+public.xref                             = SELECT
 type=user
 
 [personnotification]
@@ -1835,6 +1841,7 @@
 public.teamparticipation                = SELECT
 public.validpersoncache                 = SELECT
 public.validpersonorteamcache           = SELECT
+public.xref                             = SELECT, INSERT
 type=user
 
 [mlist-sync]
@@ -2337,6 +2344,7 @@
 public.translationmessage               = SELECT, DELETE
 public.translationtemplateitem          = SELECT, DELETE
 public.webhookjob                       = SELECT, DELETE
+public.xref                             = SELECT, INSERT
 type=user
 
 [garbo_daily]

=== modified file 'lib/lp/answers/browser/tests/views.txt'
--- lib/lp/answers/browser/tests/views.txt	2014-04-24 02:53:05 +0000
+++ lib/lp/answers/browser/tests/views.txt	2015-09-29 04:00:42 +0000
@@ -334,8 +334,8 @@
     ...           'field.description': 'Bug description.'})
     >>> request.method = 'POST'
     >>> makebug = getMultiAdapter((question_three, request), name='+makebug')
-    >>> question_three.bugs.count() == 0
-    True
+    >>> question_three.bugs
+    []
 
     >>> makebug.initialize()
     >>> print question_three.bugs[0].title

=== modified file 'lib/lp/answers/model/question.py'
--- lib/lp/answers/model/question.py	2015-09-28 07:57:17 +0000
+++ lib/lp/answers/model/question.py	2015-09-29 04:00:42 +0000
@@ -94,12 +94,14 @@
     IProductSet,
     )
 from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet
+from lp.services.database import bulk
 from lp.services.database.constants import (
     DEFAULT,
     UTC_NOW,
     )
 from lp.services.database.datetimecol import UtcDateTimeCol
 from lp.services.database.enumcol import EnumCol
+from lp.services.database.interfaces import IStore
 from lp.services.database.nl_search import nl_phrase_search
 from lp.services.database.sqlbase import (
     cursor,
@@ -108,6 +110,7 @@
     sqlvalues,
     )
 from lp.services.database.stormexpr import rank_by_fti
+from lp.services.features import getFeatureFlag
 from lp.services.mail.notificationrecipientset import NotificationRecipientSet
 from lp.services.messages.interfaces.message import IMessage
 from lp.services.messages.model.message import (
@@ -119,6 +122,7 @@
 from lp.services.worlddata.helpers import is_english_variant
 from lp.services.worlddata.interfaces.language import ILanguage
 from lp.services.worlddata.model.language import Language
+from lp.services.xref.interfaces import IXRefSet
 
 
 class notify_question_modified:
@@ -210,8 +214,6 @@
     subscribers = SQLRelatedJoin('Person',
         joinColumn='question', otherColumn='person',
         intermediateTable='QuestionSubscription', orderBy='name')
-    bugs = SQLRelatedJoin('Bug', joinColumn='question', otherColumn='bug',
-        intermediateTable='QuestionBug', orderBy='id')
     messages = SQLMultipleJoin('QuestionMessage', joinColumn='question',
         prejoins=['message'], orderBy=['QuestionMessage.id'])
     reopenings = SQLMultipleJoin('QuestionReopening', orderBy='datecreated',
@@ -660,6 +662,20 @@
         self.status = new_status
         return tktmsg
 
+    @property
+    def bugs(self):
+        from lp.bugs.model.bug import Bug
+        if getFeatureFlag('bugs.xref_buglinks.query'):
+            bug_ids = [
+                int(id) for _, id in getUtility(IXRefSet).findFrom(
+                    (u'question', unicode(self.id)), types=[u'bug'])]
+        else:
+            bug_ids = list(IStore(QuestionBug).find(
+                QuestionBug,
+                QuestionBug.question == self).values(QuestionBug.bugID))
+        return list(sorted(
+            bulk.load(Bug, bug_ids), key=operator.attrgetter('id')))
+
     # IBugLinkTarget implementation
     def linkBug(self, bug, user=None):
         """See `IBugLinkTarget`."""
@@ -677,11 +693,18 @@
 
     def createBugLink(self, bug):
         """See BugLinkTargetMixin."""
-        QuestionBug(question=self, bug=bug)
+        if not getFeatureFlag('bugs.xref_buglinks.write_old.disabled'):
+            QuestionBug(question=self, bug=bug)
+        # XXX: Should set creator.
+        getUtility(IXRefSet).create(
+            {(u'question', unicode(self.id)): {(u'bug', unicode(bug.id)): {}}})
 
     def deleteBugLink(self, bug):
         """See BugLinkTargetMixin."""
-        Store.of(self).find(QuestionBug, question=self, bug=bug).remove()
+        if not getFeatureFlag('bugs.xref_buglinks.write_old.disabled'):
+            Store.of(self).find(QuestionBug, question=self, bug=bug).remove()
+        getUtility(IXRefSet).delete(
+            {(u'question', unicode(self.id)): [(u'bug', unicode(bug.id))]})
 
     def setCommentVisibility(self, user, comment_number, visible):
         """See `IQuestion`."""
@@ -707,24 +730,38 @@
         # This query joins to bugtasks that are not BugTaskStatus.INVALID
         # because there are many bugtasks to one question. A question is
         # included when BugTask.status IS NULL.
-        return Question.select("""
-            id in (SELECT Question.id
-                FROM Question
+        if getFeatureFlag('bugs.xref_buglinks.query'):
+            bugtask_join = """
+                    LEFT OUTER JOIN XRef ON (
+                        XRef.from_type = 'question'
+                        AND XRef.from_id_int = Question.id
+                        AND XRef.to_type = 'bug')
+                    LEFT OUTER JOIN BugTask ON (
+                        BugTask.bug = XRef.to_id_int
+                        AND BugTask.status != %s)
+                """
+        else:
+            bugtask_join = """
                     LEFT OUTER JOIN QuestionBug
                         ON Question.id = QuestionBug.question
-                    LEFT OUTER JOIN BugTask
-                        ON QuestionBug.bug = BugTask.bug
-                            AND BugTask.status != %s
+                    LEFT OUTER JOIN BugTask ON (
+                        BugTask.bug = QuestionBug.bug
+                        AND BugTask.status != %s)
+                """
+        return Question.select(("""
+            id in (SELECT Question.id
+                FROM Question
+                    %s
                 WHERE
-                    Question.status IN (%s, %s)
+                    Question.status IN (%%s, %%s)
                     AND (Question.datelastresponse IS NULL
                          OR Question.datelastresponse < (CURRENT_TIMESTAMP
-                            AT TIME ZONE 'UTC' - interval '%s days'))
+                            AT TIME ZONE 'UTC' - interval '%%s days'))
                     AND Question.datelastquery < (CURRENT_TIMESTAMP
-                            AT TIME ZONE 'UTC' - interval '%s days')
+                            AT TIME ZONE 'UTC' - interval '%%s days')
                     AND Question.assignee IS NULL
                     AND BugTask.status IS NULL)
-            """ % sqlvalues(
+            """ % bugtask_join) % sqlvalues(
                 BugTaskStatus.INVALID,
                 QuestionStatus.OPEN, QuestionStatus.NEEDSINFO,
                 days_before_expiration, days_before_expiration))

=== modified file 'lib/lp/blueprints/model/specification.py'
--- lib/lp/blueprints/model/specification.py	2015-09-28 07:57:17 +0000
+++ lib/lp/blueprints/model/specification.py	2015-09-29 04:00:42 +0000
@@ -11,6 +11,8 @@
     'SpecificationSet',
     ]
 
+import operator
+
 from lazr.lifecycle.event import (
     ObjectCreatedEvent,
     ObjectModifiedEvent,
@@ -86,6 +88,7 @@
 from lp.registry.interfaces.person import validate_public_person
 from lp.registry.interfaces.product import IProduct
 from lp.registry.interfaces.productseries import IProductSeries
+from lp.services.database import bulk
 from lp.services.database.constants import (
     DEFAULT,
     UTC_NOW,
@@ -99,12 +102,14 @@
     SQLBase,
     sqlvalues,
     )
+from lp.services.features import getFeatureFlag
 from lp.services.mail.helpers import get_contact_email_addresses
 from lp.services.propertycache import (
     cachedproperty,
     get_property_cache,
     )
 from lp.services.webapp.interfaces import ILaunchBag
+from lp.services.xref.interfaces import IXRefSet
 
 
 def recursive_blocked_query(user):
@@ -239,9 +244,6 @@
     sprints = SQLRelatedJoin('Sprint', orderBy='name',
         joinColumn='specification', otherColumn='sprint',
         intermediateTable='SprintSpecification')
-    bugs = SQLRelatedJoin('Bug',
-        joinColumn='specification', otherColumn='bug',
-        intermediateTable='SpecificationBug', orderBy='id')
     spec_dependency_links = SQLMultipleJoin('SpecificationDependency',
         joinColumn='specification', orderBy='id')
 
@@ -791,14 +793,38 @@
 
         return bool(self.subscription(person))
 
+    @property
+    def bugs(self):
+        from lp.bugs.model.bug import Bug
+        if getFeatureFlag('bugs.xref_buglinks.query'):
+            bug_ids = [
+                int(id) for _, id in getUtility(IXRefSet).findFrom(
+                    (u'specification', unicode(self.id)), types=[u'bug'])]
+        else:
+            bug_ids = list(IStore(SpecificationBug).find(
+                SpecificationBug,
+                SpecificationBug.specification == self).values(
+                    SpecificationBug.bugID))
+        return list(sorted(
+            bulk.load(Bug, bug_ids), key=operator.attrgetter('id')))
+
     def createBugLink(self, bug):
         """See BugLinkTargetMixin."""
-        SpecificationBug(specification=self, bug=bug)
+        if not getFeatureFlag('bugs.xref_buglinks.write_old.disabled'):
+            SpecificationBug(specification=self, bug=bug)
+        # XXX: Should set creator.
+        getUtility(IXRefSet).create(
+            {(u'specification', unicode(self.id)):
+                {(u'bug', unicode(bug.id)): {}}})
 
     def deleteBugLink(self, bug):
         """See BugLinkTargetMixin."""
-        Store.of(self).find(
-            SpecificationBug, specification=self, bug=bug).remove()
+        if not getFeatureFlag('bugs.xref_buglinks.write_old.disabled'):
+            Store.of(self).find(
+                SpecificationBug, specification=self, bug=bug).remove()
+        getUtility(IXRefSet).delete(
+            {(u'specification', unicode(self.id)):
+                [(u'bug', unicode(bug.id))]})
 
     # sprint linking
     def linkSprint(self, sprint, user):

=== modified file 'lib/lp/blueprints/tests/test_specification.py'
--- lib/lp/blueprints/tests/test_specification.py	2015-09-28 07:39:28 +0000
+++ lib/lp/blueprints/tests/test_specification.py	2015-09-29 04:00:42 +0000
@@ -59,6 +59,7 @@
     EditSpecificationByRelatedPeople,
     ViewSpecification,
     )
+from lp.services.features.testing import FeatureFixture
 from lp.services.propertycache import get_property_cache
 from lp.services.webapp.authorization import check_permission
 from lp.services.webapp.interaction import ANONYMOUS
@@ -876,3 +877,19 @@
         self.assertContentEqual([bug1], spec2.bugs)
         self.assertContentEqual([spec2], bug1.specifications)
         self.assertContentEqual([], bug2.specifications)
+
+
+class TestBugLinksWithXRef(TestBugLinks):
+
+    def setUp(self):
+        super(TestBugLinksWithXRef, self).setUp()
+        self.useFixture(FeatureFixture({'bugs.xref_buglinks.query': 'true'}))
+
+
+class TestBugLinksWithXRefAndNoOld(TestBugLinks):
+
+    def setUp(self):
+        super(TestBugLinksWithXRefAndNoOld, self).setUp()
+        self.useFixture(FeatureFixture({
+            'bugs.xref_buglinks.query': 'true',
+            'bugs.xref_buglinks.write_old.disabled': 'true'}))

=== modified file 'lib/lp/bugs/browser/tests/test_bugtask.py'
--- lib/lp/bugs/browser/tests/test_bugtask.py	2015-06-30 01:10:06 +0000
+++ lib/lp/bugs/browser/tests/test_bugtask.py	2015-09-29 04:00:42 +0000
@@ -125,7 +125,7 @@
             0, 10, login_method=lambda: login(ADMIN_EMAIL))
         # This may seem large: it is; there is easily another 25% fat in
         # there.
-        self.assertThat(recorder1, HasQueryCount(LessThan(81)))
+        self.assertThat(recorder1, HasQueryCount(LessThan(83)))
         self.assertThat(recorder2, HasQueryCount(Equals(recorder1.count)))
 
     def test_rendered_query_counts_constant_with_attachments(self):
@@ -136,7 +136,7 @@
             lambda: self.getUserBrowser(url, person),
             lambda: self.factory.makeBugAttachment(bug=task.bug),
             1, 9, login_method=lambda: login(ADMIN_EMAIL))
-        self.assertThat(recorder1, HasQueryCount(LessThan(82)))
+        self.assertThat(recorder1, HasQueryCount(LessThan(84)))
         self.assertThat(recorder2, HasQueryCount(Equals(recorder1.count)))
 
     def makeLinkedBranchMergeProposal(self, sourcepackage, bug, owner):
@@ -171,7 +171,7 @@
         recorder1, recorder2 = record_two_runs(
             lambda: self.getUserBrowser(url, owner),
             make_merge_proposals, 0, 1)
-        self.assertThat(recorder1, HasQueryCount(LessThan(87)))
+        self.assertThat(recorder1, HasQueryCount(LessThan(89)))
         # Ideally this should be much fewer, but this tries to keep a win of
         # removing more than half of these.
         self.assertThat(
@@ -217,7 +217,7 @@
             lambda: self.getUserBrowser(url, person),
             lambda: add_activity("description", self.factory.makePerson()),
             1, 20, login_method=lambda: login(ADMIN_EMAIL))
-        self.assertThat(recorder1, HasQueryCount(LessThan(82)))
+        self.assertThat(recorder1, HasQueryCount(LessThan(84)))
         self.assertThat(recorder2, HasQueryCount(Equals(recorder1.count)))
 
     def test_rendered_query_counts_constant_with_milestones(self):
@@ -227,7 +227,7 @@
 
         with celebrity_logged_in('admin'):
             browses_under_limit = BrowsesWithQueryLimit(
-                82, self.factory.makePerson())
+                84, self.factory.makePerson())
 
         self.assertThat(bug, browses_under_limit)
 

=== modified file 'lib/lp/bugs/doc/cve.txt'
--- lib/lp/bugs/doc/cve.txt	2015-09-25 09:48:57 +0000
+++ lib/lp/bugs/doc/cve.txt	2015-09-29 04:00:42 +0000
@@ -75,16 +75,16 @@
 
 Let's add the new CVE:
 
-    >>> b.cves.count()
+    >>> len(b.cves)
     1
     >>> b.linkCVE(cve, no_priv)
-    >>> b.cves.count()
+    >>> len(b.cves)
     2
 
 Ah, but that was a bad idea. Let's unlink it.
 
     >>> b.unlinkCVE(cve, user=no_priv)
-    >>> b.cves.count()
+    >>> len(b.cves)
     1
 
 Alternatively, we can link CVEs to bugs by looking for CVEs in a

=== modified file 'lib/lp/bugs/model/bug.py'
--- lib/lp/bugs/model/bug.py	2015-09-25 10:23:48 +0000
+++ lib/lp/bugs/model/bug.py	2015-09-29 04:00:42 +0000
@@ -99,11 +99,6 @@
 from lp.app.interfaces.services import IService
 from lp.app.model.launchpad import InformationTypeMixin
 from lp.app.validators import LaunchpadValidationError
-from lp.blueprints.model.specification import Specification
-from lp.blueprints.model.specificationbug import SpecificationBug
-from lp.blueprints.model.specificationsearch import (
-    get_specification_privacy_filter,
-    )
 from lp.bugs.adapters.bug import convert_to_information_type
 from lp.bugs.adapters.bugchange import (
     BranchLinkedToBug,
@@ -198,6 +193,7 @@
 from lp.registry.model.pillar import pillar_sort_key
 from lp.registry.model.teammembership import TeamParticipation
 from lp.services.config import config
+from lp.services.database import bulk
 from lp.services.database.constants import UTC_NOW
 from lp.services.database.datetimecol import UtcDateTimeCol
 from lp.services.database.decoratedresultset import DecoratedResultSet
@@ -208,6 +204,7 @@
     sqlvalues,
     )
 from lp.services.database.stormbase import StormBase
+from lp.services.features import getFeatureFlag
 from lp.services.fields import DuplicateBug
 from lp.services.helpers import shortlist
 from lp.services.librarian.interfaces import ILibraryFileAliasSet
@@ -234,6 +231,7 @@
 from lp.services.webapp.publisher import (
     get_raw_form_value_from_current_request,
     )
+from lp.services.xref.interfaces import IXRefSet
 
 
 def snapshot_bug_params(bug_params):
@@ -363,15 +361,7 @@
         'BugMessage', joinColumn='bug', orderBy='index')
     watches = SQLMultipleJoin(
         'BugWatch', joinColumn='bug', orderBy=['bugtracker', 'remotebug'])
-    cves = SQLRelatedJoin('Cve', intermediateTable='BugCve',
-        orderBy='sequence', joinColumn='bug', otherColumn='cve')
     duplicates = SQLMultipleJoin('Bug', joinColumn='duplicateof', orderBy='id')
-    specifications = SQLRelatedJoin(
-        'Specification', joinColumn='bug', otherColumn='specification',
-        intermediateTable='SpecificationBug', orderBy='-datecreated')
-    questions = SQLRelatedJoin('Question', joinColumn='bug',
-        otherColumn='question', intermediateTable='QuestionBug',
-        orderBy='-datecreated')
     linked_branches = SQLMultipleJoin(
         'BugBranch', joinColumn='bug', orderBy='id')
     date_last_message = UtcDateTimeCol(default=None)
@@ -383,12 +373,63 @@
     heat_last_updated = UtcDateTimeCol(default=None)
     latest_patch_uploaded = UtcDateTimeCol(default=None)
 
+    @property
+    def cves(self):
+        from lp.bugs.model.bugcve import BugCve
+        from lp.bugs.model.cve import Cve
+        if getFeatureFlag('bugs.xref_buglinks.query'):
+            xref_cve_sequences = [
+                sequence for _, sequence in getUtility(IXRefSet).findFrom(
+                    (u'bug', unicode(self.id)), types=[u'cve'])]
+            expr = Cve.sequence.is_in(xref_cve_sequences)
+        else:
+            old_cve_ids = list(IStore(BugCve).find(
+                BugCve,
+                BugCve.bug == self).values(BugCve.cveID))
+            expr = Cve.id.is_in(old_cve_ids)
+        return list(sorted(
+            IStore(Cve).find(Cve, expr), key=operator.attrgetter('sequence')))
+
+    @property
+    def questions(self):
+        from lp.answers.model.question import Question
+        from lp.coop.answersbugs.model import QuestionBug
+        if getFeatureFlag('bugs.xref_buglinks.query'):
+            question_ids = [
+                int(id) for _, id in getUtility(IXRefSet).findFrom(
+                    (u'bug', unicode(self.id)), types=[u'question'])]
+        else:
+            question_ids = list(IStore(QuestionBug).find(
+                QuestionBug,
+                QuestionBug.bug == self).values(QuestionBug.questionID))
+        return list(sorted(
+            bulk.load(Question, question_ids), key=operator.attrgetter('id')))
+
+    @property
+    def specifications(self):
+        from lp.blueprints.model.specification import Specification
+        from lp.blueprints.model.specificationbug import SpecificationBug
+        if getFeatureFlag('bugs.xref_buglinks.query'):
+            spec_ids = [
+                int(id) for _, id in getUtility(IXRefSet).findFrom(
+                    (u'bug', unicode(self.id)), types=[u'specification'])]
+        else:
+            spec_ids = list(IStore(SpecificationBug).find(
+                SpecificationBug,
+                SpecificationBug.bug == self).values(
+                    SpecificationBug.specificationID))
+        return list(sorted(
+            bulk.load(Specification, spec_ids), key=operator.attrgetter('id')))
+
     def getSpecifications(self, user):
         """See `IBug`."""
-        return IStore(SpecificationBug).find(
+        from lp.blueprints.model.specification import Specification
+        from lp.blueprints.model.specificationsearch import (
+            get_specification_privacy_filter,
+            )
+        return IStore(Specification).find(
             Specification,
-            SpecificationBug.bugID == self.id,
-            SpecificationBug.specificationID == Specification.id,
+            Specification.id.is_in(spec.id for spec in self.specifications),
             *get_specification_privacy_filter(user))
 
     @property

=== modified file 'lib/lp/bugs/model/bugcve.py'
--- lib/lp/bugs/model/bugcve.py	2015-09-25 10:15:37 +0000
+++ lib/lp/bugs/model/bugcve.py	2015-09-29 04:00:42 +0000
@@ -6,6 +6,8 @@
 
 from sqlobject import ForeignKey
 
+from lp.services.database.constants import UTC_NOW
+from lp.services.database.datetimecol import UtcDateTimeCol
 from lp.services.database.sqlbase import SQLBase
 
 
@@ -17,3 +19,4 @@
     # db field names
     bug = ForeignKey(dbName='bug', foreignKey='Bug', notNull=True)
     cve = ForeignKey(dbName='cve', foreignKey='Cve', notNull=True)
+    date_created = UtcDateTimeCol(notNull=True, default=UTC_NOW)

=== modified file 'lib/lp/bugs/model/bugtask.py'
--- lib/lp/bugs/model/bugtask.py	2015-07-08 16:05:11 +0000
+++ lib/lp/bugs/model/bugtask.py	2015-09-29 04:00:42 +0000
@@ -139,10 +139,12 @@
     SQLBase,
     sqlvalues,
     )
+from lp.services.features import getFeatureFlag
 from lp.services.helpers import shortlist
 from lp.services.propertycache import get_property_cache
 from lp.services.searchbuilder import any
 from lp.services.webapp.interfaces import ILaunchBag
+from lp.services.xref.interfaces import IXRefSet
 
 
 def bugtask_sort_key(bugtask):
@@ -1388,9 +1390,15 @@
         from lp.bugs.model.bugbranch import BugBranch
 
         bug_ids = set(bugtask.bugID for bugtask in bugtasks)
-        bug_ids_with_specifications = set(IStore(SpecificationBug).find(
-            SpecificationBug.bugID,
-            SpecificationBug.bugID.is_in(bug_ids)))
+        if getFeatureFlag('bugs.xref_buglinks.query'):
+            bug_ids_with_specifications = set(
+                int(id) for _, id in getUtility(IXRefSet).findFromMany(
+                    [(u'bug', unicode(bug_id)) for bug_id in bug_ids],
+                    types=[u'specification']).keys())
+        else:
+            bug_ids_with_specifications = set(IStore(SpecificationBug).find(
+                SpecificationBug.bugID,
+                SpecificationBug.bugID.is_in(bug_ids)))
         bug_ids_with_branches = set(IStore(BugBranch).find(
                 BugBranch.bugID, BugBranch.bugID.is_in(bug_ids)))
         # Badging looks up milestones too : eager load into the storm cache.

=== modified file 'lib/lp/bugs/model/bugtasksearch.py'
--- lib/lp/bugs/model/bugtasksearch.py	2015-09-28 12:33:22 +0000
+++ lib/lp/bugs/model/bugtasksearch.py	2015-09-29 04:00:42 +0000
@@ -95,6 +95,7 @@
     rank_by_fti,
     Unnest,
     )
+from lp.services.features import getFeatureFlag
 from lp.services.propertycache import get_property_cache
 from lp.services.searchbuilder import (
     all,
@@ -103,6 +104,7 @@
     not_equals,
     NULL,
     )
+from lp.services.xref.model import XRef
 from lp.soyuz.enums import PackagePublishingStatus
 from lp.soyuz.model.publishing import SourcePackagePublishingHistory
 
@@ -421,9 +423,18 @@
             BugTaskFlat.productseries == None))
 
     if params.has_cve:
-        extra_clauses.append(
-            BugTaskFlat.bug_id.is_in(
-                Select(BugCve.bugID, tables=[BugCve])))
+        if getFeatureFlag('bugs.xref_buglinks.query'):
+            where = [
+                XRef.from_type == u'bug',
+                XRef.from_id_int == BugTaskFlat.bug_id,
+                XRef.to_type == u'cve',
+                ]
+            extra_clauses.append(Exists(Select(
+                1, tables=[XRef], where=And(*where))))
+        else:
+            extra_clauses.append(
+                BugTaskFlat.bug_id.is_in(
+                    Select(BugCve.bugID, tables=[BugCve])))
 
     if params.attachmenttype is not None:
         if params.attachmenttype == BugAttachmentType.PATCH:
@@ -1013,12 +1024,26 @@
     linked_blueprints = params.linked_blueprints
 
     def make_clause(blueprints=None):
-        where = [SpecificationBug.bugID == BugTaskFlat.bug_id]
-        if blueprints is not None:
-            where.append(
-                search_value_to_storm_where_condition(
-                    SpecificationBug.specificationID, blueprints))
-        return Exists(Select(1, tables=[SpecificationBug], where=And(*where)))
+        if getFeatureFlag('bugs.xref_buglinks.query'):
+            where = [
+                XRef.from_type == u'bug',
+                XRef.from_id_int == BugTaskFlat.bug_id,
+                XRef.to_type == u'specification',
+                ]
+            if blueprints is not None:
+                where.append(
+                    search_value_to_storm_where_condition(
+                        XRef.to_id_int, blueprints))
+            return Exists(Select(
+                1, tables=[XRef], where=And(*where)))
+        else:
+            where = [SpecificationBug.bugID == BugTaskFlat.bug_id]
+            if blueprints is not None:
+                where.append(
+                    search_value_to_storm_where_condition(
+                        SpecificationBug.specificationID, blueprints))
+            return Exists(Select(
+                1, tables=[SpecificationBug], where=And(*where)))
 
     if linked_blueprints is None:
         return None

=== modified file 'lib/lp/bugs/model/cve.py'
--- lib/lp/bugs/model/cve.py	2015-09-28 07:57:17 +0000
+++ lib/lp/bugs/model/cve.py	2015-09-29 04:00:42 +0000
@@ -8,16 +8,16 @@
     'CveSet',
     ]
 
-# SQL imports
+import operator
+
 from sqlobject import (
     SQLMultipleJoin,
     SQLObjectNotFound,
-    SQLRelatedJoin,
     StringCol,
     )
 from storm.expr import In
 from storm.store import Store
-# Zope
+from zope.component import getUtility
 from zope.interface import implementer
 
 from lp.app.validators.cve import (
@@ -34,12 +34,16 @@
 from lp.bugs.model.bugcve import BugCve
 from lp.bugs.model.buglinktarget import BugLinkTargetMixin
 from lp.bugs.model.cvereference import CveReference
-from lp.services.database.bulk import load_related
+from lp.services.database import bulk
 from lp.services.database.constants import UTC_NOW
 from lp.services.database.datetimecol import UtcDateTimeCol
 from lp.services.database.enumcol import EnumCol
+from lp.services.database.interfaces import IStore
 from lp.services.database.sqlbase import SQLBase
 from lp.services.database.stormexpr import fti_search
+from lp.services.features import getFeatureFlag
+from lp.services.xref.interfaces import IXRefSet
+from lp.services.xref.model import XRef
 
 
 @implementer(ICve, IBugLinkTarget)
@@ -54,10 +58,6 @@
     datecreated = UtcDateTimeCol(notNull=True, default=UTC_NOW)
     datemodified = UtcDateTimeCol(notNull=True, default=UTC_NOW)
 
-    # joins
-    bugs = SQLRelatedJoin('Bug', intermediateTable='BugCve',
-        joinColumn='cve', otherColumn='bug', orderBy='id')
-    bug_links = SQLMultipleJoin('BugCve', joinColumn='cve', orderBy='id')
     references = SQLMultipleJoin(
         'CveReference', joinColumn='cve', orderBy='id')
 
@@ -75,6 +75,19 @@
     def title(self):
         return 'CVE-%s (%s)' % (self.sequence, self.status.title)
 
+    @property
+    def bugs(self):
+        if getFeatureFlag('bugs.xref_buglinks.query'):
+            bug_ids = [
+                int(id) for _, id in getUtility(IXRefSet).findFrom(
+                    (u'cve', self.sequence), types=[u'bug'])]
+        else:
+            bug_ids = list(IStore(BugCve).find(
+                BugCve,
+                BugCve.cve == self).values(BugCve.bugID))
+        return list(sorted(
+            bulk.load(Bug, bug_ids), key=operator.attrgetter('id')))
+
     # CveReference's
     def createReference(self, source, content, url=None):
         """See ICveReference."""
@@ -87,11 +100,18 @@
 
     def createBugLink(self, bug):
         """See BugLinkTargetMixin."""
-        BugCve(cve=self, bug=bug)
+        if not getFeatureFlag('bugs.xref_buglinks.write_old.disabled'):
+            BugCve(cve=self, bug=bug)
+        # XXX: Should set creator.
+        getUtility(IXRefSet).create(
+            {(u'cve', self.sequence): {(u'bug', unicode(bug.id)): {}}})
 
     def deleteBugLink(self, bug):
         """See BugLinkTargetMixin."""
-        Store.of(self).find(BugCve, cve=self, bug=bug).remove()
+        if not getFeatureFlag('bugs.xref_buglinks.write_old.disabled'):
+            Store.of(self).find(BugCve, cve=self, bug=bug).remove()
+        getUtility(IXRefSet).delete(
+            {(u'cve', self.sequence): [(u'bug', unicode(bug.id))]})
 
 
 @implementer(ICveSet)
@@ -177,39 +197,52 @@
 
     def getBugCvesForBugTasks(self, bugtasks, cve_mapper=None):
         """See ICveSet."""
-        bugs = load_related(Bug, bugtasks, ('bugID', ))
+        bugs = bulk.load_related(Bug, bugtasks, ('bugID', ))
         if len(bugs) == 0:
             return []
-        bug_ids = [bug.id for bug in bugs]
-
-        # Do not use BugCve instances: Storm may need a very long time
-        # to look up the bugs and CVEs referenced by a BugCve instance
-        # when the +cve view of a distroseries is rendered: There may
-        # be a few thousand (bug, CVE) tuples, while the number of bugs
-        # and CVEs is in the order of hundred. It is much more efficient
-        # to retrieve just (bug_id, cve_id) from the BugCve table and
-        # to map this to (Bug, CVE) here, instead of letting Storm
-        # look up the CVE and bug for a BugCve instance, even if bugs
-        # and CVEs are bulk loaded.
         store = Store.of(bugtasks[0])
-        bugcve_ids = store.find(
-            (BugCve.bugID, BugCve.cveID), In(BugCve.bugID, bug_ids))
-        bugcve_ids.order_by(BugCve.bugID, BugCve.cveID)
-        bugcve_ids = list(bugcve_ids)
-
-        cve_ids = set(cve_id for bug_id, cve_id in bugcve_ids)
-        cves = store.find(Cve, In(Cve.id, list(cve_ids)))
+
+        if getFeatureFlag('bugs.xref_buglinks.query'):
+            xrefs = getUtility(IXRefSet).findFromMany(
+                [(u'bug', unicode(bug.id)) for bug in bugs], types=[u'cve'])
+            bugcve_ids = set()
+            for bug_key in xrefs:
+                for cve_key in xrefs[bug_key]:
+                    bugcve_ids.add((int(bug_key[1]), cve_key[1]))
+        else:
+            # Do not use BugCve instances: Storm may need a very long time
+            # to look up the bugs and CVEs referenced by a BugCve instance
+            # when the +cve view of a distroseries is rendered: There may
+            # be a few thousand (bug, CVE) tuples, while the number of bugs
+            # and CVEs is in the order of hundred. It is much more efficient
+            # to retrieve just (Bug.id, Cve.sequence) from the BugCve
+            # table and to map this to (Bug, CVE) here, instead of
+            # letting Storm look up the CVE and bug for a BugCve
+            # instance, even if bugs and CVEs are bulk loaded.
+            bug_ids = [bug.id for bug in bugs]
+            bugcve_ids = store.find(
+                (BugCve.bugID, Cve.sequence),
+                Cve.id == BugCve.cveID, In(BugCve.bugID, bug_ids))
+
+        bugcve_ids = list(sorted(bugcve_ids))
+
+        cves = store.find(
+            Cve, In(Cve.sequence, [seq for _, seq in bugcve_ids]))
 
         if cve_mapper is None:
-            cvemap = dict((cve.id, cve) for cve in cves)
+            cvemap = dict((cve.sequence, cve) for cve in cves)
         else:
-            cvemap = dict((cve.id, cve_mapper(cve)) for cve in cves)
+            cvemap = dict((cve.sequence, cve_mapper(cve)) for cve in cves)
         bugmap = dict((bug.id, bug) for bug in bugs)
         return [
-            (bugmap[bug_id], cvemap[cve_id])
-            for bug_id, cve_id in bugcve_ids
+            (bugmap[bug_id], cvemap[cve_sequence])
+            for bug_id, cve_sequence in bugcve_ids
             ]
 
     def getBugCveCount(self):
         """See ICveSet."""
-        return BugCve.select().count()
+        if getFeatureFlag('bugs.xref_buglinks.query'):
+            return IStore(XRef).find(
+                XRef, XRef.from_type == u'bug', XRef.to_type == u'cve').count()
+        else:
+            return BugCve.select().count()

=== modified file 'lib/lp/bugs/model/tests/test_bugtask.py'
--- lib/lp/bugs/model/tests/test_bugtask.py	2014-07-08 09:53:50 +0000
+++ lib/lp/bugs/model/tests/test_bugtask.py	2015-09-29 04:00:42 +0000
@@ -526,6 +526,22 @@
         ])
 
 
+class TestBugTaskBadgesWithXRef(TestBugTaskBadges):
+
+    def setUp(self):
+        super(TestBugTaskBadges, self).setUp()
+        self.useFixture(FeatureFixture({'bugs.xref_buglinks.query': 'true'}))
+
+
+class TestBugTaskBadgesWithXRefAndNoOld(TestBugTaskBadges):
+
+    def setUp(self):
+        super(TestBugTaskBadges, self).setUp()
+        self.useFixture(FeatureFixture({
+            'bugs.xref_buglinks.query': 'true',
+            'bugs.xref_buglinks.write_old.disabled': 'true'}))
+
+
 class TestBugTaskPrivacy(TestCaseWithFactory):
     """Verify that the bug is either private or public.
 

=== modified file 'lib/lp/bugs/model/tests/test_bugtasksearch.py'
--- lib/lp/bugs/model/tests/test_bugtasksearch.py	2015-09-28 12:33:22 +0000
+++ lib/lp/bugs/model/tests/test_bugtasksearch.py	2015-09-29 04:00:42 +0000
@@ -67,6 +67,7 @@
 from lp.registry.model.person import Person
 from lp.services.database.interfaces import IStore
 from lp.services.database.sqlbase import convert_storm_clause_to_string
+from lp.services.features.testing import FeatureFixture
 from lp.services.searchbuilder import (
     all,
     any,
@@ -419,6 +420,16 @@
                 BugBlueprintSearch.BUGS_WITHOUT_BLUEPRINTS))
         self.assertSearchFinds(params, self.bugtasks[1:])
 
+    def test_blueprints_linked_with_xref(self):
+        self.useFixture(FeatureFixture({'bugs.xref_buglinks.query': 'true'}))
+        self.test_blueprints_linked()
+
+    def test_blueprints_linked_with_xref_and_no_old(self):
+        self.useFixture(FeatureFixture({
+            'bugs.xref_buglinks.query': 'true',
+            'bugs.xref_buglinks.write_old.disabled': 'true'}))
+        self.test_blueprints_linked()
+
     def test_limit_search_to_one_bug(self):
         # Search results can be limited to a given bug.
         params = self.getBugTaskSearchParams(
@@ -492,6 +503,16 @@
         params = self.getBugTaskSearchParams(user=None, has_cve=True)
         self.assertSearchFinds(params, self.bugtasks[:1])
 
+    def test_has_cve_with_xref(self):
+        self.useFixture(FeatureFixture({'bugs.xref_buglinks.query': 'true'}))
+        self.test_has_cve()
+
+    def test_has_cve_with_xref_and_no_old(self):
+        self.useFixture(FeatureFixture({
+            'bugs.xref_buglinks.query': 'true',
+            'bugs.xref_buglinks.write_old.disabled': 'true'}))
+        self.test_has_cve()
+
     def test_sort_by_milestone_name(self):
         expected = self.setUpMilestoneSorting()
         params = self.getBugTaskSearchParams(

=== modified file 'lib/lp/bugs/tests/test_cve.py'
--- lib/lp/bugs/tests/test_cve.py	2015-09-28 07:39:28 +0000
+++ lib/lp/bugs/tests/test_cve.py	2015-09-29 04:00:42 +0000
@@ -7,6 +7,7 @@
 
 from lp.bugs.interfaces.bugtasksearch import BugTaskSearchParams
 from lp.bugs.interfaces.cve import ICveSet
+from lp.services.features.testing import FeatureFixture
 from lp.testing import (
     login_person,
     person_logged_in,
@@ -77,6 +78,42 @@
             u'CVE-2000-0004']
         self.assertEqual(expected, cve_data)
 
+    def test_getBugCveCount(self):
+        login_person(self.factory.makePerson())
+
+        base = getUtility(ICveSet).getBugCveCount()
+        bug1 = self.factory.makeBug()
+        bug2 = self.factory.makeBug()
+        cve1 = self.factory.makeCVE(sequence='2099-1234')
+        cve2 = self.factory.makeCVE(sequence='2099-2468')
+        self.assertEqual(base, getUtility(ICveSet).getBugCveCount())
+        cve1.linkBug(bug1)
+        self.assertEqual(base + 1, getUtility(ICveSet).getBugCveCount())
+        cve1.linkBug(bug2)
+        self.assertEqual(base + 2, getUtility(ICveSet).getBugCveCount())
+        cve2.linkBug(bug1)
+        self.assertEqual(base + 3, getUtility(ICveSet).getBugCveCount())
+        cve1.unlinkBug(bug1)
+        cve1.unlinkBug(bug2)
+        cve2.unlinkBug(bug1)
+        self.assertEqual(base, getUtility(ICveSet).getBugCveCount())
+
+
+class TestCveSetWithXRef(TestCveSet):
+
+    def setUp(self):
+        self.useFixture(FeatureFixture({'bugs.xref_buglinks.query': 'true'}))
+        super(TestCveSetWithXRef, self).setUp()
+
+
+class TestCveSetWithXRefAndNoOld(TestCveSet):
+
+    def setUp(self):
+        self.useFixture(FeatureFixture({
+            'bugs.xref_buglinks.query': 'true',
+            'bugs.xref_buglinks.write_old.disabled': 'true'}))
+        super(TestCveSetWithXRefAndNoOld, self).setUp()
+
 
 class TestBugLinks(TestCaseWithFactory):
 
@@ -113,3 +150,19 @@
         self.assertContentEqual([bug1], cve2.bugs)
         self.assertContentEqual([cve2], bug1.cves)
         self.assertContentEqual([], bug2.cves)
+
+
+class TestBugLinksWithXRef(TestBugLinks):
+
+    def setUp(self):
+        super(TestBugLinksWithXRef, self).setUp()
+        self.useFixture(FeatureFixture({'bugs.xref_buglinks.query': 'true'}))
+
+
+class TestBugLinksWithXRefAndNoOld(TestBugLinks):
+
+    def setUp(self):
+        super(TestBugLinksWithXRefAndNoOld, self).setUp()
+        self.useFixture(FeatureFixture({
+            'bugs.xref_buglinks.query': 'true',
+            'bugs.xref_buglinks.write_old.disabled': 'true'}))

=== modified file 'lib/lp/coop/answersbugs/model.py'
--- lib/lp/coop/answersbugs/model.py	2015-09-25 10:15:37 +0000
+++ lib/lp/coop/answersbugs/model.py	2015-09-29 04:00:42 +0000
@@ -9,6 +9,8 @@
 
 from sqlobject import ForeignKey
 
+from lp.services.database.constants import UTC_NOW
+from lp.services.database.datetimecol import UtcDateTimeCol
 from lp.services.database.sqlbase import SQLBase
 
 
@@ -20,3 +22,4 @@
     question = ForeignKey(
         dbName='question', foreignKey='Question', notNull=True)
     bug = ForeignKey(dbName='bug', foreignKey='Bug', notNull=True)
+    date_created = UtcDateTimeCol(notNull=True, default=UTC_NOW)

=== modified file 'lib/lp/coop/answersbugs/tests/test_questionbug.py'
--- lib/lp/coop/answersbugs/tests/test_questionbug.py	2015-09-28 07:39:28 +0000
+++ lib/lp/coop/answersbugs/tests/test_questionbug.py	2015-09-29 04:00:42 +0000
@@ -1,6 +1,7 @@
 # Copyright 2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
+from lp.services.features.testing import FeatureFixture
 from lp.testing import (
     login_person,
     TestCaseWithFactory,
@@ -43,3 +44,19 @@
         self.assertContentEqual([bug1], question2.bugs)
         self.assertContentEqual([question2], bug1.questions)
         self.assertContentEqual([], bug2.questions)
+
+
+class TestQuestionBugLinksWithXRef(TestQuestionBugLinks):
+
+    def setUp(self):
+        super(TestQuestionBugLinksWithXRef, self).setUp()
+        self.useFixture(FeatureFixture({'bugs.xref_buglinks.query': 'true'}))
+
+
+class TestQuestionBugLinksWithXRefAndNoOld(TestQuestionBugLinks):
+
+    def setUp(self):
+        super(TestQuestionBugLinksWithXRefAndNoOld, self).setUp()
+        self.useFixture(FeatureFixture({
+            'bugs.xref_buglinks.query': 'true',
+            'bugs.xref_buglinks.write_old.disabled': 'true'}))

=== modified file 'lib/lp/scripts/garbo.py'
--- lib/lp/scripts/garbo.py	2015-07-22 07:09:12 +0000
+++ lib/lp/scripts/garbo.py	2015-09-29 04:00:42 +0000
@@ -81,6 +81,7 @@
 from lp.services.database.bulk import (
     create,
     dbify_value,
+    load_related,
     )
 from lp.services.database.constants import UTC_NOW
 from lp.services.database.interfaces import IMasterStore
@@ -1678,6 +1679,66 @@
                 transaction.abort()
 
 
+class BugXRefMigrator(TunableLoop):
+    """Creates an XRef record for each former IBugLink."""
+
+    maximum_chunk_size = 5000
+
+    def __init__(self, log, abort_time=None):
+        super(BugXRefMigrator, self).__init__(log, abort_time)
+        self.start_at = 1
+        self.store = IMasterStore(Bug)
+
+    def findBugs(self):
+        if not getFeatureFlag('bugs.xref_buglinks.garbo.enabled'):
+            return EmptyResultSet()
+        return self.store.find(
+            Bug, Bug.id >= self.start_at).order_by(Bug.id)
+
+    def isDone(self):
+        return self.findBugs().is_empty()
+
+    def __call__(self, chunk_size):
+        # Grab a chunk of Bug IDs.
+        # Find all QuestionBugs, SpecificationBugs and BugCves for each
+        # of those bugs.
+        # Compose a list of link IDs that should exist.
+        # Perform a bulk XRef find for all of those.
+        # Delete any extras, create any missing.
+        from lp.blueprints.model.specificationbug import SpecificationBug
+        from lp.bugs.model.bugcve import BugCve
+        from lp.bugs.model.cve import Cve
+        from lp.coop.answersbugs.model import QuestionBug
+        from lp.services.xref.interfaces import IXRefSet
+        bug_ids = list(self.findBugs()[:chunk_size].values(Bug.id))
+        qbs = list(self.store.find(
+            QuestionBug, QuestionBug.bugID.is_in(bug_ids)))
+        sbs = list(self.store.find(
+            SpecificationBug, SpecificationBug.bugID.is_in(bug_ids)))
+        bcs = list(self.store.find(BugCve, BugCve.bugID.is_in(bug_ids)))
+        wanted = {(u'bug', unicode(bug_id)): {} for bug_id in bug_ids}
+        for qb in qbs:
+            wanted[(u'bug', unicode(qb.bugID))][
+                (u'question', unicode(qb.questionID))] = {
+                    'date_created': qb.date_created}
+        for sb in sbs:
+            wanted[(u'bug', unicode(sb.bugID))][
+                (u'specification', unicode(sb.specificationID))] = {}
+        load_related(Cve, bcs, ['cveID'])
+        for bc in bcs:
+            wanted[(u'bug', unicode(bc.bugID))][
+                (u'cve', unicode(bc.cve.sequence))] = {}
+        existing = getUtility(IXRefSet).findFromMany(wanted.keys())
+        needed = {
+            bug: {
+                other: meta for other, meta in others.iteritems()
+                if other not in existing.get(bug, {})}
+            for bug, others in wanted.iteritems() if others}
+        getUtility(IXRefSet).create(needed)
+        self.start_at = bug_ids[-1] + 1
+        transaction.commit()
+
+
 class FrequentDatabaseGarbageCollector(BaseDatabaseGarbageCollector):
     """Run every 5 minutes.
 
@@ -1714,6 +1775,7 @@
         DuplicateSessionPruner,
         RevisionCachePruner,
         UnusedSessionPruner,
+        BugXRefMigrator,
         ]
     experimental_tunable_loops = []
 

=== modified file 'lib/lp/scripts/tests/test_garbo.py'
--- lib/lp/scripts/tests/test_garbo.py	2015-07-22 07:09:12 +0000
+++ lib/lp/scripts/tests/test_garbo.py	2015-09-29 04:00:42 +0000
@@ -1433,6 +1433,94 @@
         for person in people_enf_true:
             _assert_enf_by_person(person, True)
 
+    def test_BugXRefMigrator(self):
+        from testtools.matchers import (
+            Equals,
+            Is,
+            MatchesDict,
+            Not,
+            )
+
+        from lp.bugs.model.bug import Bug
+        from lp.bugs.model.bugcve import BugCve
+        from lp.blueprints.model.specificationbug import SpecificationBug
+        from lp.coop.answersbugs.model import QuestionBug
+        from lp.services.database.interfaces import IStore
+        from lp.services.xref.interfaces import IXRefSet
+
+        switch_dbuser('testadmin')
+        self.useFixture(FeatureFixture(
+            {'bugs.xref_buglinks.garbo.enabled': 'on'}))
+        store = IStore(Bug)
+
+        # The first bug has a spec and a question.
+        bug1 = self.factory.makeBug()
+        spec1 = self.factory.makeSpecification()
+        sb1 = SpecificationBug(specification=spec1, bug=bug1)
+        store.add(sb1)
+        question1 = self.factory.makeQuestion()
+        qb1 = QuestionBug(question=question1, bug=bug1)
+        store.add(qb1)
+
+        # A second bug has a question and a CVE.
+        bug2 = self.factory.makeBug()
+        question2 = self.factory.makeQuestion()
+        qb2 = QuestionBug(question=question2, bug=bug2)
+        store.add(qb2)
+        cve2 = self.factory.makeCVE(sequence='2099-1234')
+        bc2 = BugCve(bug=bug2, cve=cve2)
+        store.add(bc2)
+
+        # Bug the third is all alone.
+        bug3 = self.factory.makeBug()
+
+        # Bug four has just a spec.
+        bug4 = self.factory.makeBug()
+        spec4 = self.factory.makeSpecification()
+        sb4 = SpecificationBug(specification=spec4, bug=bug4)
+        store.add(sb4)
+
+        # Initially the new XRef table has no links for the bugs.
+        self.assertEqual(
+            {},
+            getUtility(IXRefSet).findFromMany(
+                (u'bug', unicode(bug.id)) for bug in (bug1, bug2, bug3, bug4)))
+
+        # Garbo fills in links for each QuestionBug, SpecificationBug
+        # and BugCve.
+        self.runHourly()
+        matches_expected = MatchesDict({
+            (u'bug', unicode(bug1.id)): MatchesDict({
+                (u'specification', unicode(spec1.id)): MatchesDict({
+                    'metadata': Is(None), 'creator': Is(None),
+                    'date_created': Not(Is(None))}),
+                (u'question', unicode(question1.id)): MatchesDict({
+                    'metadata': Is(None), 'creator': Is(None),
+                    'date_created': Equals(qb1.date_created)}),
+                }),
+            (u'bug', unicode(bug2.id)): MatchesDict({
+                (u'question', unicode(question2.id)): MatchesDict({
+                    'metadata': Is(None), 'creator': Is(None),
+                    'date_created': Equals(qb2.date_created)}),
+                (u'cve', cve2.sequence): MatchesDict({
+                    'metadata': Is(None), 'creator': Is(None),
+                    'date_created': Not(Is(None))}),
+                }),
+            (u'bug', unicode(bug4.id)): MatchesDict({
+                (u'specification', unicode(spec4.id)): MatchesDict({
+                    'metadata': Is(None), 'creator': Is(None),
+                    'date_created': Not(Is(None))}),
+                }),
+            })
+        self.assertThat(
+            getUtility(IXRefSet).findFromMany(
+                (u'bug', unicode(bug.id)) for bug in (bug1, bug2, bug3, bug4)),
+            matches_expected)
+
+        # A second run is harmless.
+        self.runHourly()
+
+
 
 class TestGarboTasks(TestCaseWithFactory):
     layer = LaunchpadZopelessLayer


Follow ups