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

UI: Gracefully handle stat errors #4833

Merged
merged 6 commits into from
Nov 9, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 1 addition & 5 deletions ui/app/services/system.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import Service, { inject as service } from '@ember/service';
import { computed } from '@ember/object';
import { copy } from '@ember/object/internals';
import PromiseObject from '../utils/classes/promise-object';
import PromiseArray from '../utils/classes/promise-array';
import { namespace } from '../adapters/application';

// When the request isn't ok (e.g., forbidden) handle gracefully
const jsonWithDefault = defaultResponse => res =>
res.ok ? res.json() : copy(defaultResponse, true);
import jsonWithDefault from '../utils/json-with-default';

export default Service.extend({
token: service(),
Expand Down
36 changes: 32 additions & 4 deletions ui/app/utils/classes/abstract-stats-tracker.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import Ember from 'ember';
import Mixin from '@ember/object/mixin';
import { assert } from '@ember/debug';
import { task, timeout } from 'ember-concurrency';
import jsonWithDefault from 'nomad-ui/utils/json-with-default';

export default Mixin.create({
url: '',

// The max number of data points tracked. Once the max is reached,
// data points at the head of the list are removed in favor of new
// data appended at the tail
bufferSize: 500,

// The number of consecutive request failures that can occur before an
// empty frame is appended
maxFrameMisses: 5,

fetch() {
assert('StatsTrackers need a fetch method, which should have an interface like window.fetch');
},
Expand All @@ -23,6 +32,25 @@ export default Mixin.create({
);
},

frameMisses: 0,

handleResponse(frame) {
if (frame.error) {
this.incrementProperty('frameMisses');
if (this.get('frameMisses') >= this.get('maxFrameMisses')) {
// Missing enough data consecutively is effectively a pause
this.pause();
this.set('frameMisses', 0);
}
return;
} else {
this.set('frameMisses', 0);

// Only append non-error frames
this.append(frame);
}
},

// Uses EC as a form of debounce to prevent multiple
// references to the same tracker from flooding the tracker,
// but also avoiding the issue where different places where the
Expand All @@ -36,18 +64,18 @@ export default Mixin.create({
assert('Url must be defined', url);

yield this.get('fetch')(url)
.then(res => res.json())
.then(frame => this.append(frame));
.then(jsonWithDefault({ error: true }))
.then(frame => this.handleResponse(frame));
} catch (error) {
Copy link
Contributor

Choose a reason for hiding this comment

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

More ember-data adapter like things that normally you might use ember-data for (see my comment from earlier to do on another PR). Actually now I've seen this again, did you say somewhere there was a decision made not to use ember-data for this? Does the same go for the non-ember-data usage in system?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, I wrote out the rationale on the PR that introduced stat trackers.

This may sound a lot like what Ember Data does, but this isn't using Ember Data. This data isn't stateful (no writes), and it isn't necessarily globally persistent (created and deleted on demand). Although fetching and normalizing the data does overlap in behavior with adapters and serializers, given the nature of client and allocation stats, I think it makes more sense to keep this data out of the Ember Data store.

And yes, similar rationale for things like leader and regions. They're one-off calls that aren't going to change, have no relationships, have no computed properties, aren't CRUD, etc. If that changes some day, I'll convert them into ED models.

throw new Error(error);
}

yield timeout(2000);
yield timeout(Ember.testing ? 0 : 2000);
}).drop(),

signalPause: task(function*() {
// wait 2 seconds
yield timeout(2000);
yield timeout(Ember.testing ? 0 : 2000);
// if no poll called in 2 seconds, pause
this.pause();
}).drop(),
Expand Down
10 changes: 10 additions & 0 deletions ui/app/utils/json-with-default.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { copy } from '@ember/object/internals';

// Used with fetch.
// Fetch only goes into the promise catch if there is a network error.
// This means that handling a 4xx or 5xx error is the responsibility
// of the developer.
const jsonWithDefault = defaultResponse => res =>
res.ok ? res.json() : copy(defaultResponse, true);

Copy link
Contributor

Choose a reason for hiding this comment

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

This is nice, I just realized it depends on the res having an ok property. So, tiny super nit here, but would naming it differently or putting it in a subfolder to make it clearer that the res needs an ok property and json method.

Isn't that a Response?

https://developer.mozilla.org/en-US/docs/Web/API/Response/ok

So maybe responseWithDefault or maybe put it in utils/http/response/... or similar?

The commenting here helps, but if I'm in a file that uses this I might think that this just requires a json blob, whereas it needs a Response

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 considered this interface gotcha, but decided ultimately that I was probably overthinking it, and it isn't as big of a footgun as it seems under a microscope.

If it comes up again, I'll considering moving it.

export default jsonWithDefault;
12 changes: 11 additions & 1 deletion ui/mirage/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,17 @@ export default function() {
this.get('/client/fs/logs/:allocation_id', clientAllocationLog);

this.get('/client/stats', function({ clientStats }, { queryParams }) {
return this.serialize(clientStats.find(queryParams.node_id));
const seed = Math.random();
if (seed > 0.8) {
const stats = clientStats.find(queryParams.node_id);
stats.update({
timestamp: Date.now() * 1000000,
CPUTicksConsumed: stats.CPUTicksConsumed + (Math.random() * 20 - 10),
});
return this.serialize(stats);
} else {
return new Response(500, {}, null);
}
});

// TODO: in the future, this hack may be replaceable with dynamic host name
Expand Down
4 changes: 2 additions & 2 deletions ui/mirage/factories/client-stats.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default Factory.extend({
})),
],

CPUTicksConsumed: 1000000,
CPUTicksConsumed: faker.random.number({ min: 100, max: 1000 }),

diskStats: () => [
Array(faker.random.number({ min: 1, max: 5 }))
Expand All @@ -46,6 +46,6 @@ export default Factory.extend({
Used: 10000000000,
}),

timestamp: 149000000000,
timestamp: Date.now() * 1000000,
uptime: 193838,
});
4 changes: 3 additions & 1 deletion ui/mirage/factories/node.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Factory, faker, trait } from 'ember-cli-mirage';
import { provide } from '../utils';
import { DATACENTERS, HOSTS } from '../common';
import { DATACENTERS, HOSTS, generateResources } from '../common';
import moment from 'moment';

const UUIDS = provide(100, faker.random.uuid.bind(faker.random));
Expand Down Expand Up @@ -65,6 +65,8 @@ export default Factory.extend({

drivers: makeDrivers,

resources: generateResources,

attributes() {
// TODO add variability to these
return {
Expand Down
3 changes: 3 additions & 0 deletions ui/tests/unit/adapters/node-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ moduleForAdapter('node', 'Unit | Adapter | Node', {
'model:node-driver',
'model:node-event',
'model:evaluation',
'model:resources',
'model:network',
'model:job',
'serializer:application',
'serializer:node',
Expand All @@ -21,6 +23,7 @@ moduleForAdapter('node', 'Unit | Adapter | Node', {
'service:watchList',
'transform:fragment',
'transform:fragment-array',
'transform:array',
],
beforeEach() {
this.server = startMirage();
Expand Down
25 changes: 25 additions & 0 deletions ui/tests/unit/utils/allocation-stats-tracker-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import sinon from 'sinon';
import Pretender from 'pretender';
import AllocationStatsTracker, { stats } from 'nomad-ui/utils/classes/allocation-stats-tracker';
import fetch from 'nomad-ui/utils/fetch';
import statsTrackerFrameMissingBehavior from './behaviors/stats-tracker-frame-missing';

module('Unit | Util | AllocationStatsTracker');

Expand Down Expand Up @@ -453,3 +454,27 @@ test('changing the value of the allocationProp constructs a new AllocationStatsT
'Changing the value of alloc results in creating a new AllocationStatsTracker instance'
);
});

statsTrackerFrameMissingBehavior({
resourceName: 'allocation',
ResourceConstructor: MockAllocation,
TrackerConstructor: AllocationStatsTracker,
mockFrame,
compileResources(frame) {
const timestamp = makeDate(frame.Timestamp);
const cpu = frame.ResourceUsage.CpuStats.TotalTicks;
const memory = frame.ResourceUsage.MemoryStats.RSS;
return [
{
timestamp,
used: cpu,
percent: cpu / 200,
},
{
timestamp,
used: memory,
percent: memory / 1024 / 1024 / 512,
},
];
},
});
97 changes: 97 additions & 0 deletions ui/tests/unit/utils/behaviors/stats-tracker-frame-missing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { resolve } from 'rsvp';
import wait from 'ember-test-helpers/wait';
import { test } from 'ember-qunit';
import sinon from 'sinon';

const MockResponse = json => ({
ok: true,
json() {
return resolve(json);
},
});

export default function statsTrackerFrameMissing({
resourceName,
TrackerConstructor,
ResourceConstructor,
mockFrame,
compileResources,
}) {
test('a bad response from a fetch request is handled gracefully', function(assert) {
const frame = mockFrame(1);
const [compiledCPU, compiledMemory] = compileResources(frame);

Copy link
Contributor

Choose a reason for hiding this comment

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

I know you generally don't like lingering comments, so thought I'd check you'd noticed this, not a biggie for me especially as its in tests, maybe it helps to help folks understand what's happening 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.

Oh yep! This should be deleted. This code is now expected of the caller of the behavior.

let shouldFail = false;
const fetch = () => {
return resolve(shouldFail ? { ok: false } : new MockResponse(frame));
};

const resource = ResourceConstructor();
const tracker = TrackerConstructor.create({ fetch, [resourceName]: resource });

tracker.get('poll').perform();

return wait()
.then(() => {
assert.deepEqual(tracker.get('cpu'), [compiledCPU], 'One frame of cpu');
assert.deepEqual(tracker.get('memory'), [compiledMemory], 'One frame of memory');

shouldFail = true;
tracker.get('poll').perform();
return wait();
})
.then(() => {
assert.deepEqual(tracker.get('cpu'), [compiledCPU], 'Still one frame of cpu');
assert.deepEqual(tracker.get('memory'), [compiledMemory], 'Still one frame of memory');
assert.equal(tracker.get('frameMisses'), 1, 'Frame miss is tracked');

shouldFail = false;
tracker.get('poll').perform();
return wait();
})
.then(() => {
assert.deepEqual(tracker.get('cpu'), [compiledCPU, compiledCPU], 'Still one frame of cpu');
assert.deepEqual(
tracker.get('memory'),
[compiledMemory, compiledMemory],
'Still one frame of memory'
);
assert.equal(tracker.get('frameMisses'), 0, 'Frame misses is reset');
});
});

test('enough bad responses from fetch consecutively (as set by maxFrameMisses) results in a pause', function(assert) {
const fetch = () => {
return resolve({ ok: false });
};

const resource = ResourceConstructor();
const tracker = TrackerConstructor.create({
fetch,
[resourceName]: resource,
maxFrameMisses: 3,
pause: sinon.spy(),
});

tracker.get('poll').perform();
return wait()
.then(() => {
assert.equal(tracker.get('frameMisses'), 1, 'Tick misses');
assert.notOk(tracker.pause.called, 'Pause not called yet');

tracker.get('poll').perform();
return wait();
})
.then(() => {
assert.equal(tracker.get('frameMisses'), 2, 'Tick misses');
assert.notOk(tracker.pause.called, 'Pause still not called yet');

tracker.get('poll').perform();
return wait();
})
.then(() => {
assert.equal(tracker.get('frameMisses'), 0, 'Misses reset');
assert.ok(tracker.pause.called, 'Pause called now that frameMisses == maxFrameMisses');
});
});
}
23 changes: 23 additions & 0 deletions ui/tests/unit/utils/node-stats-tracker-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import sinon from 'sinon';
import Pretender from 'pretender';
import NodeStatsTracker, { stats } from 'nomad-ui/utils/classes/node-stats-tracker';
import fetch from 'nomad-ui/utils/fetch';
import statsTrackerFrameMissingBehavior from './behaviors/stats-tracker-frame-missing';

module('Unit | Util | NodeStatsTracker');

Expand Down Expand Up @@ -221,3 +222,25 @@ test('changing the value of the nodeProp constructs a new NodeStatsTracker', fun
'Changing the value of the node results in creating a new NodeStatsTracker instance'
);
});

statsTrackerFrameMissingBehavior({
resourceName: 'node',
ResourceConstructor: MockNode,
TrackerConstructor: NodeStatsTracker,
mockFrame,
compileResources(frame) {
const timestamp = makeDate(frame.Timestamp);
return [
{
timestamp,
used: frame.CPUTicksConsumed,
percent: frame.CPUTicksConsumed / 2000,
},
{
timestamp,
used: frame.Memory.Used,
percent: frame.Memory.Used / 1024 / 1024 / 4096,
},
];
},
});