Merge lp:~blake-rouse/maas/fix-1627362 into lp:~maas-committers/maas/trunk

Proposed by Blake Rouse
Status: Merged
Approved by: Blake Rouse
Approved revision: no longer in the source branch.
Merged at revision: 5448
Proposed branch: lp:~blake-rouse/maas/fix-1627362
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 296 lines (+87/-40)
7 files modified
src/maasserver/static/js/angular/controllers/tests/test_dashboard.js (+4/-4)
src/maasserver/static/js/angular/factories/discoveries.js (+2/-1)
src/maasserver/static/js/angular/factories/tests/test_discoveries.js (+2/-1)
src/maasserver/static/partials/dashboard.html (+7/-7)
src/maasserver/testing/sampledata.py (+21/-18)
src/maasserver/websockets/handlers/discovery.py (+25/-8)
src/maasserver/websockets/handlers/tests/test_discovery.py (+26/-1)
To merge this branch: bzr merge lp:~blake-rouse/maas/fix-1627362
Reviewer Review Type Date Requested Status
Mike Pontillo (community) Abstain
Andres Rodriguez (community) Approve
Review via email: mp+307728@code.launchpad.net

Commit message

Fix discovery websocket and factory to use first_seen as the batch key. Convert first_key to a timestamp with microseconds to ensure always unique.

To post a comment you must log in.
Revision history for this message
Andres Rodriguez (andreserl) wrote :

lgtm! But maybe mike should review prior to landing.

review: Approve
Revision history for this message
Mike Pontillo (mpontillo) wrote :

There is a subtle problem with using the first_seen value as a primary key. (If we were going to do that, it would have been just as easy to use the .id field, which is backed by the auto-incrementing primary key field in the maasserver_neighbour table.)

The discovery_id was intended to uniquely identify a (ip, mac) pair. Importantly, only one (IP, MAC) pair (the most recently seen, over the set of all rack interfaces) will ever appear in the maasserver_discovery view at a time.

Then, the subtle flaw in using the first_seen value (or the id, for that matter, which will also always be unique) is that over time, the discovery view will change, especially in an environment with multiple rack controllers observing the same subnet(s). So the discovery_id was intended as a composite key to identify the most recent information for the observed (ip, mac). The view only ever selects one unique (ip, mac) at a time, so the discovery_id will be unique, in that every time you select from the view, you'll never see a duplicate. (though it might have different data, such as which rack observed it, what time it was last observed, etc.)

So, as an example, here's the problem with using first_seen (or id) as the primary key:

00:00:00 - Web UI opens, user views dashboard
00:01:00 - Rack A discovers (192.168.0.1, 01:02:03:04:05:06)
00:02:00 - Rack B discovers (192.168.0.1, 01:02:03:04:05:06)

So at first when the user launches the Web UI, the discovered devices list will be empty.

One minute later, when Rack A comes online and makes its discovery, the user will see the discovered IP address in the dashboard.

After another minute, Rack B comes online and reports its own discovery of the same (ip, mac). The discovery view will now prefer to present Rack B's view of the world, since it was seen more recently than Rack A's.

The intended behavior was that the Web UI would see this as an "update" based on seeing the same discovery_id and update the last_seen time to 00:02:00. What will happen with this branch is, the user will see two discoveries in the list, for the same (ip, mac). But only if they leave UI sitting on the dashboard for a long period of time. (Discoveries are generally throttled to one every ten minutes, unless the discovery represents a new or changed device, which could appear as duplicates almost immediately, depending on timing.)

All that said, I'm +1 on this branch since the behavior is much better than crashing. ;-) And I trust your judgement when it comes to the websocket/js code. But I'm also -1 because I would have preferred a simpler solution that doesn't involve changing the intended primary key for the view. So, IMHO, we still have work to do.

review: Abstain
Revision history for this message
Blake Rouse (blake-rouse) wrote :

The user will not actually see two discoveries, what you are describing is not an issue. The discovery_id should be distinct in the database view. With that then you will not see duplicates as the view removes the duplicates, hope that is the case as the API would be affect by this as well. Also since this is polling based the array is only replaced once all the items have been reloaded again. So in this case there will only ever be one discovery_id in the array at a time, even if the primary key is first_seen.

Revision history for this message
Mike Pontillo (mpontillo) wrote :

Ah, I didn't realize we don't do the "smart replace" with the polling mode. That being the case, I'm more comfortable with this branch. But I think it could still be an issue, if someone is editing a discovery that is subsequently replaced by another rack's observation.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/static/js/angular/controllers/tests/test_dashboard.js'
2--- src/maasserver/static/js/angular/controllers/tests/test_dashboard.js 2016-09-27 14:24:19 +0000
3+++ src/maasserver/static/js/angular/controllers/tests/test_dashboard.js 2016-10-05 15:19:04 +0000
4@@ -195,7 +195,7 @@
5 };
6 DomainsManager._items = [defaultDomain];
7 var discovered = {
8- discovery_id: id,
9+ first_seen: id,
10 hostname: makeName("hostname"),
11 subnet: makeInteger(0, 100)
12 };
13@@ -226,7 +226,7 @@
14 };
15 DomainsManager._items = [defaultDomain];
16 var discovered = {
17- discovery_id: id,
18+ first_seen: id,
19 hostname: makeName("hostname"),
20 subnet: null
21 };
22@@ -259,7 +259,7 @@
23 };
24 DomainsManager._items = [defaultDomain];
25 var discovered = {
26- discovery_id: id,
27+ first_seen: id,
28 hostname: makeName("hostname"),
29 subnet: makeInteger(0, 100),
30 mac_address: makeName("mac"),
31@@ -301,7 +301,7 @@
32 };
33 DomainsManager._items = [defaultDomain];
34 var discovered = {
35- discovery_id: id,
36+ first_seen: id,
37 hostname: makeName("hostname"),
38 subnet: makeInteger(0, 100),
39 mac_address: makeName("mac"),
40
41=== modified file 'src/maasserver/static/js/angular/factories/discoveries.js'
42--- src/maasserver/static/js/angular/factories/discoveries.js 2016-09-22 15:05:38 +0000
43+++ src/maasserver/static/js/angular/factories/discoveries.js 2016-10-05 15:19:04 +0000
44@@ -21,7 +21,8 @@
45 function DiscoveriesManager() {
46 PollingManager.call(this);
47
48- this._pk = "discovery_id";
49+ this._pk = "first_seen";
50+ this._batchKey = "first_seen";
51 this._handler = "discovery";
52
53 // Poll every 10 seconds when its empty as its okay for
54
55=== modified file 'src/maasserver/static/js/angular/factories/tests/test_discoveries.js'
56--- src/maasserver/static/js/angular/factories/tests/test_discoveries.js 2016-09-22 15:05:38 +0000
57+++ src/maasserver/static/js/angular/factories/tests/test_discoveries.js 2016-10-05 15:19:04 +0000
58@@ -17,7 +17,8 @@
59 }));
60
61 it("set requires attributes", function() {
62- expect(DiscoveriesManager._pk).toBe("discovery_id");
63+ expect(DiscoveriesManager._pk).toBe("first_seen");
64+ expect(DiscoveriesManager._batchKey).toBe("first_seen");
65 expect(DiscoveriesManager._handler).toBe("discovery");
66 expect(DiscoveriesManager._pollEmptyTimeout).toBe(5000);
67 });
68
69=== modified file 'src/maasserver/static/partials/dashboard.html'
70--- src/maasserver/static/partials/dashboard.html 2016-09-30 19:07:37 +0000
71+++ src/maasserver/static/partials/dashboard.html 2016-10-05 15:19:04 +0000
72@@ -68,10 +68,10 @@
73 </div>
74 </div>
75 <div class="table__row"
76- data-ng-repeat="discovery in discoveredDevices track by discovery.discovery_id"
77- data-ng-class="{'is-active' : discovery.discovery_id === selectedDevice}">
78- <div data-ng-if="discovery.discovery_id !== selectedDevice"
79- data-ng-dblclick="toggleSelected(discovery.discovery_id)">
80+ data-ng-repeat="discovery in discoveredDevices | orderBy:'-last_seen' track by discovery.first_seen"
81+ data-ng-class="{'is-active' : discovery.first_seen === selectedDevice}">
82+ <div data-ng-if="discovery.first_seen !== selectedDevice"
83+ data-ng-dblclick="toggleSelected(discovery.first_seen)">
84 <div class="table__data table-col--20">
85 {$ getDiscoveryName(discovery) $}
86 <i data-ng-show="discovery.is_external_dhcp === true" class="icon icon--info tooltip u-margin--left-tiny ng-hide" aria-label="This device is providing DHCP."></i>
87@@ -93,10 +93,10 @@
88 </div>
89 <div class="table__data table-col--3">
90 <i class="icon icon--open tooltip" aria-label="Open"
91- data-ng-click="toggleSelected(discovery.discovery_id)">Open</i>
92+ data-ng-click="toggleSelected(discovery.first_seen)">Open</i>
93 </div>
94 </div>
95- <maas-obj-form data-ng-if="discovery.discovery_id === selectedDevice"
96+ <maas-obj-form data-ng-if="discovery.first_seen === selectedDevice"
97 obj="convertTo" manager="proxyManager" pre-process="preProcess"
98 after-save="afterSave" table-form="true" save-on-blur="false">
99 <div class="table__data table-col--20">
100@@ -106,7 +106,7 @@
101 <div class="table__data table-col--77"></div>
102 <div class="table__data table-col--3">
103 <i class="icon icon--close tooltip" aria-label="Close"
104- data-ng-click="toggleSelected(discovery.discovery_id)">Close</i>
105+ data-ng-click="toggleSelected(discovery.first_seen)">Close</i>
106 </div>
107 <div class="table__dropdown">
108 <div class="table__row is-active">
109
110=== modified file 'src/maasserver/testing/sampledata.py'
111--- src/maasserver/testing/sampledata.py 2016-09-23 18:48:54 +0000
112+++ src/maasserver/testing/sampledata.py 2016-10-05 15:19:04 +0000
113@@ -22,6 +22,7 @@
114 Domain,
115 Fabric,
116 Node,
117+ RackController,
118 VersionedTextFile,
119 )
120 from maasserver.storage_layouts import STORAGE_LAYOUTS
121@@ -131,7 +132,6 @@
122 interface=interface)
123
124
125-@transactional
126 def populate(seed="sampledata"):
127 """Populate the database with example data.
128
129@@ -158,7 +158,26 @@
130
131 """
132 random.seed(seed)
133-
134+ populate_main()
135+ for _ in range(120):
136+ make_discovery()
137+
138+
139+@transactional
140+def make_discovery():
141+ """Make a discovery in its own transaction so each last_seen time
142+ is different."""
143+ random_rack = random.choice(RackController.objects.all())
144+ random_interface = random.choice(random_rack.interface_set.all())
145+ random_subnet = random.choice(random_interface.vlan.subnet_set.all())
146+ factory.make_Discovery(
147+ interface=random_interface,
148+ ip=factory.pick_ip_in_Subnet(random_subnet))
149+
150+
151+@transactional
152+def populate_main():
153+ """Populate the main data all in one transaction."""
154 admin = factory.make_admin(
155 username="admin", password="test", completed_intro=False) # noqa
156 user1, _ = factory.make_user_with_keys(
157@@ -281,14 +300,6 @@
158 factory.make_StaticIPAddress(
159 alloc_type=IPADDRESS_TYPE.STICKY, ip="172.16.3.2",
160 subnet=subnet_3, interface=bond0_10)
161- # Add some discovery devices for rack interfaces
162- for _ in range(3):
163- factory.make_Discovery(
164- interface=eth0, ip=factory.pick_ip_in_Subnet(subnet_1))
165- factory.make_Discovery(
166- interface=eth1, ip=factory.pick_ip_in_Subnet(subnet_1))
167- factory.make_Discovery(
168- interface=eth2, ip=factory.pick_ip_in_Subnet(subnet_2))
169
170 # Rack controller (happy-rack)
171 # eth0 - fabric 0 - untagged
172@@ -323,14 +334,6 @@
173 factory.make_StaticIPAddress(
174 alloc_type=IPADDRESS_TYPE.STICKY, ip="172.16.3.3",
175 subnet=subnet_3, interface=bond0_10)
176- # Add some discovery devices for rack interfaces
177- for _ in range(3):
178- factory.make_Discovery(
179- interface=eth0, ip=factory.pick_ip_in_Subnet(subnet_1))
180- factory.make_Discovery(
181- interface=eth1, ip=factory.pick_ip_in_Subnet(subnet_1))
182- factory.make_Discovery(
183- interface=eth2, ip=factory.pick_ip_in_Subnet(subnet_2))
184
185 # Region controller (happy-region)
186 # eth0 - fabric 0 - untagged
187
188=== modified file 'src/maasserver/websockets/handlers/discovery.py'
189--- src/maasserver/websockets/handlers/discovery.py 2016-09-06 20:31:47 +0000
190+++ src/maasserver/websockets/handlers/discovery.py 2016-10-05 15:19:04 +0000
191@@ -7,6 +7,9 @@
192 "DiscoveryHandler",
193 ]
194
195+from datetime import datetime
196+import time
197+
198 from maasserver.models import Discovery
199 from maasserver.websockets.base import dehydrate_datetime
200 from maasserver.websockets.handlers.viewmodel import ViewModelHandler
201@@ -22,10 +25,6 @@
202 queryset = (
203 Discovery.objects.by_unknown_ip_and_mac()
204 )
205- # This batch key isn't guaranteed to be stable, since newly-discovered
206- # items can come in as the new first-items in the query. But that's why
207- # we're also going to poll. But using row_number() seems to be a good
208- # compromise for now.
209 batch_key = 'first_seen'
210 pk = 'discovery_id'
211 allowed_methods = [
212@@ -33,13 +32,31 @@
213 'get',
214 ]
215
216+ def list(self, params):
217+ """List objects.
218+
219+ :param start: A value of the `batch_key` column and NOT `pk`. They are
220+ often the same but that is not a certainty. Make sure the client
221+ also understands this distinction.
222+ :param offset: Offset into the queryset to return.
223+ :param limit: Maximum number of objects to return.
224+ """
225+ if "start" in params:
226+ params["start"] = datetime.fromtimestamp(float(params['start']))
227+ return super(DiscoveryHandler, self).list(params)
228+
229 def dehydrate(self, obj, data, for_list=False):
230 """Add extra fields to `data`."""
231 data["mac_organization"] = obj.mac_organization
232 return data
233
234- def dehydrate_last_seen(self, datetime):
235- return dehydrate_datetime(datetime)
236+ def dehydrate_last_seen(self, obj):
237+ return dehydrate_datetime(obj)
238
239- def dehydrate_first_seen(self, datetime):
240- return dehydrate_datetime(datetime)
241+ def dehydrate_first_seen(self, obj):
242+ # This is rendered all they way to microseconds so its always
243+ # unique. This is because each discovery item is always created in
244+ # is own transaction. If this changes then the barch key needs to
245+ # be changed to something that is ordered and unique.
246+ return str(
247+ time.mktime(obj.timetuple()) + obj.microsecond / 1e6)
248
249=== modified file 'src/maasserver/websockets/handlers/tests/test_discovery.py'
250--- src/maasserver/websockets/handlers/tests/test_discovery.py 2016-09-30 18:07:10 +0000
251+++ src/maasserver/websockets/handlers/tests/test_discovery.py 2016-10-05 15:19:04 +0000
252@@ -9,6 +9,7 @@
253 datetime,
254 timedelta,
255 )
256+import time
257
258 from maasserver.dbviews import register_view
259 from maasserver.models.discovery import Discovery
260@@ -46,7 +47,9 @@
261 "subnet_cidr": discovery.subnet_cidr,
262 "vid": discovery.vid,
263 "vlan": discovery.vlan_id,
264- "first_seen": dehydrate_datetime(discovery.first_seen),
265+ "first_seen": str(
266+ time.mktime(discovery.first_seen.timetuple()) +
267+ discovery.first_seen.microsecond / 1e6),
268 "last_seen": dehydrate_datetime(discovery.last_seen)
269 }
270 return data
271@@ -90,3 +93,25 @@
272 self.assertEquals(
273 expected_discoveries,
274 handler.list({}))
275+
276+ def test_list_starts_after_first_seen(self):
277+ user = factory.make_User()
278+ handler = DiscoveryHandler(user, {})
279+ now = datetime.now()
280+ factory.make_Discovery(created=now)
281+ d4 = factory.make_Discovery(created=(now + timedelta(days=4)))
282+ d3 = factory.make_Discovery(created=(now + timedelta(days=3)))
283+ factory.make_Discovery(created=(now + timedelta(days=1)))
284+ factory.make_Discovery(created=(now + timedelta(days=2)))
285+ first_seen = now + timedelta(days=2)
286+ first_seen = str(
287+ time.mktime(first_seen.timetuple()) + first_seen.microsecond / 1e6)
288+ # Test for the expected order independent of how the database
289+ # decided to sort.
290+ expected_discoveries = [
291+ self.dehydrate_discovery(discovery, for_list=True)
292+ for discovery in [d3, d4]
293+ ]
294+ self.assertEquals(
295+ expected_discoveries,
296+ handler.list({"start": first_seen}))