throttle loading outcomes on student grades page

fixes TALLY-228

test plan:
 * Smoke test student grades page with many outcomes
 * Jenkins oughtta be enough

Change-Id: Idc66f8b2cccea29da3a5641eb9ba0776ea623a2f
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/216867
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Tested-by: Jenkins
QA-Review: Gary Mei <gmei@instructure.com>
Product-Review: Spencer Olson <solson@instructure.com>
Reviewed-by: Gary Mei <gmei@instructure.com>
Reviewed-by: Spencer Olson <solson@instructure.com>
This commit is contained in:
Jeremy Neander 2019-11-12 11:39:27 -06:00
parent ba650e5b96
commit 17cb63df31
4 changed files with 215 additions and 29 deletions

View File

@ -0,0 +1,89 @@
/*
* Copyright (C) 2019 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const ACTIVE_REQUEST_LIMIT = 12 // naive limit
export default class NaiveFetchDispatch {
constructor(options = {}) {
this.options = {
activeRequestLimit: ACTIVE_REQUEST_LIMIT,
...options
}
this.requests = []
}
get activeRequestCount() {
// Return the count of active requests currently in the queue.
return this.requests.filter(request => request.active).length
}
get nextPendingRequest() {
// Return the first request in the queue which has not been started.
return this.requests.find(request => !request.active)
}
addRequest(request) {
this.requests.push(request)
this.fillQueue()
}
clearRequest(request) {
this.requests = this.requests.filter(r => r !== request)
this.fillQueue()
}
fillQueue() {
let nextRequest = this.nextPendingRequest
while (nextRequest != null && this.activeRequestCount < this.options.activeRequestLimit) {
nextRequest.start()
nextRequest = this.nextPendingRequest
}
}
fetch(...args) {
const request = {
active: false
}
request.promise = new Promise((resolve, reject) => {
request.resolve = resolve
request.reject = reject
})
request.start = () => {
/*
* Update the request as "active" so that it is counted as an active
* request in the queue and is not restarted when filling the queue.
*/
request.active = true
/* eslint-disable promise/catch-or-return */
fetch(...args)
.then(request.resolve)
.catch(request.reject)
.finally(() => {
this.clearRequest(request)
})
/* eslint-enable promise/catch-or-return */
}
this.addRequest(request)
return request.promise
}
}

View File

@ -0,0 +1,80 @@
/*
* Copyright (C) 2019 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import fetchMock from 'fetch-mock'
import NaiveFetchDispatch from '../NaiveFetchDispatch'
describe('Outcomes > IndividualStudentMastery > NaiveFetchDispatch', () => {
const URL = 'http://localhost/example'
let dispatch
beforeEach(() => {
dispatch = new NaiveFetchDispatch({activeRequestLimit: 2})
})
describe('#fetch()', () => {
let exampleData
function resourceUrl(resourceIndex) {
return `${URL}/?index=${resourceIndex}`
}
function stageRequests(resourceCount) {
for (let resourceIndex = 1; resourceIndex <= resourceCount; resourceIndex++) {
exampleData[resourceIndex] = {resourceIndex}
fetchMock.mock(resourceUrl(resourceIndex), exampleData[resourceIndex])
}
}
function fetch(resourceIndex) {
return new Promise((resolve, reject) => {
dispatch
.fetch(resourceUrl(resourceIndex))
.then(resolve)
.catch(reject)
})
}
beforeEach(() => {
exampleData = {}
stageRequests(4)
})
afterEach(() => {
fetchMock.restore()
})
it('sends a request for the resource', async () => {
await fetch(1)
expect(fetchMock.calls(url => url.match(URL))).toHaveLength(1)
})
it('resolves with the data from the request', async () => {
const response = await fetch(1)
expect(response.body).toEqual(JSON.stringify(exampleData[1]))
})
it('resolves when flooded with requests', async () => {
const requests = [1, 2, 3, 4].map(fetch)
await Promise.all(requests)
expect(fetchMock.calls(url => url.match(URL))).toHaveLength(4) // 4 resources
})
})
})

View File

@ -18,6 +18,7 @@
import fetchMock from 'fetch-mock' import fetchMock from 'fetch-mock'
import fetchOutcomes, {fetchUrl} from '../fetchOutcomes' import fetchOutcomes, {fetchUrl} from '../fetchOutcomes'
import NaiveFetchDispatch from '../NaiveFetchDispatch'
describe('fetchOutcomes', () => { describe('fetchOutcomes', () => {
afterEach(() => { afterEach(() => {
@ -206,6 +207,12 @@ describe('fetchOutcomes', () => {
}) })
describe('fetchUrl', () => { describe('fetchUrl', () => {
let dispatch
beforeEach(() => {
dispatch = new NaiveFetchDispatch({activeRequestLimit: 2})
})
const mockRequests = (first, second, third) => { const mockRequests = (first, second, third) => {
fetchMock.mock('/first', { fetchMock.mock('/first', {
body: first, body: first,
@ -233,40 +240,36 @@ describe('fetchOutcomes', () => {
} }
} }
it('combines result arrays', done => { it('combines result arrays', () => {
mockRequests([1, 'hello world', {foo: 'bar'}], [2, 'goodbye', {baz: 'bat'}]) mockRequests([1, 'hello world', {foo: 'bar'}], [2, 'goodbye', {baz: 'bat'}])
fetchUrl('/first').then(resp => { return fetchUrl('/first', dispatch).then(resp => {
expect(resp).toEqual([1, 'hello world', {foo: 'bar'}, 2, 'goodbye', {baz: 'bat'}]) expect(resp).toEqual([1, 'hello world', {foo: 'bar'}, 2, 'goodbye', {baz: 'bat'}])
done()
}) })
}) })
it('combines result objects', done => { it('combines result objects', () => {
mockRequests({a: 'b', c: ['d', 'e', 'f']}, {g: 'h', c: ['i', 'j', 'k']}) mockRequests({a: 'b', c: ['d', 'e', 'f']}, {g: 'h', c: ['i', 'j', 'k']})
fetchUrl('/first').then(resp => { return fetchUrl('/first', dispatch).then(resp => {
expect(resp).toEqual({a: 'b', c: ['d', 'e', 'f', 'i', 'j', 'k'], g: 'h'}) expect(resp).toEqual({a: 'b', c: ['d', 'e', 'f', 'i', 'j', 'k'], g: 'h'})
done()
}) })
}) })
it('handles three requests', done => { it('handles three requests', () => {
mockRequests({a: 'b', c: ['d', 'e', 'f']}, {g: 'h', c: ['i', 'j', 'k']}, {a: 'x'}) mockRequests({a: 'b', c: ['d', 'e', 'f']}, {g: 'h', c: ['i', 'j', 'k']}, {a: 'x'})
fetchUrl('/first').then(resp => { return fetchUrl('/first', dispatch).then(resp => {
expect(resp).toEqual({a: 'x', c: ['d', 'e', 'f', 'i', 'j', 'k'], g: 'h'}) expect(resp).toEqual({a: 'x', c: ['d', 'e', 'f', 'i', 'j', 'k'], g: 'h'})
done()
}) })
}) })
it('handles deeply nested objects', done => { it('handles deeply nested objects', () => {
mockRequests( mockRequests(
{a: {b: {c: {d: ['e', 'f'], g: ['h', 'i'], j: 'k'}}}}, {a: {b: {c: {d: ['e', 'f'], g: ['h', 'i'], j: 'k'}}}},
{a: {b: {c: {d: ['e2', 'f2'], g: ['h2', 'i2'], j: 'k2'}}}} {a: {b: {c: {d: ['e2', 'f2'], g: ['h2', 'i2'], j: 'k2'}}}}
) )
fetchUrl('/first').then(resp => { return fetchUrl('/first', dispatch).then(resp => {
expect(resp).toEqual({ expect(resp).toEqual({
a: {b: {c: {d: ['e', 'f', 'e2', 'f2'], g: ['h', 'i', 'h2', 'i2'], j: 'k2'}}} a: {b: {c: {d: ['e', 'f', 'e2', 'f2'], g: ['h', 'i', 'h2', 'i2'], j: 'k2'}}}
}) })
done()
}) })
}) })
}) })

View File

@ -19,6 +19,7 @@
import _ from 'lodash' import _ from 'lodash'
import uuid from 'uuid' import uuid from 'uuid'
import parseLinkHeader from 'parse-link-header' import parseLinkHeader from 'parse-link-header'
import NaiveFetchDispatch from './NaiveFetchDispatch'
const deepMerge = (lhs, rhs) => { const deepMerge = (lhs, rhs) => {
if (lhs === undefined || lhs === null) { if (lhs === undefined || lhs === null) {
@ -37,20 +38,25 @@ const combine = (promiseOfJson1, promiseOfJson2) =>
const parse = response => response.text().then(text => JSON.parse(text.replace('while(1);', ''))) const parse = response => response.text().then(text => JSON.parse(text.replace('while(1);', '')))
export const fetchUrl = url => export function fetchUrl(url, dispatch) {
fetch(url, { return dispatch
credentials: 'include' .fetch(url, {
}).then(response => { credentials: 'include'
const linkHeader = response.headers.get('link') })
const next = linkHeader ? parseLinkHeader(linkHeader).next : null .then(response => {
if (next) { const linkHeader = response.headers.get('link')
return combine(parse(response), fetchUrl(next.url)) const next = linkHeader ? parseLinkHeader(linkHeader).next : null
} else { if (next) {
return parse(response) return combine(parse(response), fetchUrl(next.url, dispatch))
} } else {
}) return parse(response)
}
})
}
const fetchOutcomes = (courseId, studentId) => { const fetchOutcomes = (courseId, studentId) => {
const dispatch = new NaiveFetchDispatch()
let outcomeGroups let outcomeGroups
let outcomeLinks let outcomeLinks
let outcomeRollups let outcomeRollups
@ -58,11 +64,19 @@ const fetchOutcomes = (courseId, studentId) => {
let outcomeResultsByOutcomeId let outcomeResultsByOutcomeId
let assignmentsByAssignmentId let assignmentsByAssignmentId
function fetchWithDispatch(url) {
return fetchUrl(url, dispatch)
}
return Promise.all([ return Promise.all([
fetchUrl(`/api/v1/courses/${courseId}/outcome_groups?per_page=100`), fetchWithDispatch(`/api/v1/courses/${courseId}/outcome_groups?per_page=100`),
fetchUrl(`/api/v1/courses/${courseId}/outcome_group_links?outcome_style=full&per_page=100`), fetchWithDispatch(
fetchUrl(`/api/v1/courses/${courseId}/outcome_rollups?user_ids[]=${studentId}&per_page=100`), `/api/v1/courses/${courseId}/outcome_group_links?outcome_style=full&per_page=100`
fetchUrl(`/api/v1/courses/${courseId}/outcome_alignments?student_id=${studentId}`) ),
fetchWithDispatch(
`/api/v1/courses/${courseId}/outcome_rollups?user_ids[]=${studentId}&per_page=100`
),
fetchWithDispatch(`/api/v1/courses/${courseId}/outcome_alignments?student_id=${studentId}`)
]) ])
.then(([groups, links, rollups, alignments]) => { .then(([groups, links, rollups, alignments]) => {
outcomeGroups = groups outcomeGroups = groups
@ -73,7 +87,7 @@ const fetchOutcomes = (courseId, studentId) => {
.then(() => .then(() =>
Promise.all( Promise.all(
outcomeLinks.map(outcomeLink => outcomeLinks.map(outcomeLink =>
fetchUrl( fetchWithDispatch(
`/api/v1/courses/${courseId}/outcome_results?user_ids[]=${studentId}&outcome_ids[]=${outcomeLink.outcome.id}&include[]=assignments&per_page=100` `/api/v1/courses/${courseId}/outcome_results?user_ids[]=${studentId}&outcome_ids[]=${outcomeLink.outcome.id}&include[]=assignments&per_page=100`
) )
) )