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

Encapsulate progress tracking logic in a composable to prevent content pages crossing their streams #8898

Merged
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
312 changes: 0 additions & 312 deletions kolibri/core/assets/src/state/modules/core/actions.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import debounce from 'lodash/debounce';
import isNumber from 'lodash/isNumber';
import isPlainObject from 'lodash/isPlainObject';
import isUndefined from 'lodash/isUndefined';
import pick from 'lodash/pick';
import client from 'kolibri.client';
import logger from 'kolibri.lib.logging';
Expand All @@ -26,9 +23,6 @@ import { browser, os } from '../../../utils/browserInfo';
import errorCodes from './../../../disconnectionErrorCodes.js';

const logging = logger.getLogger(__filename);
const intervalTime = 5000; // Frequency at which time logging is updated
const progressThreshold = 0.4; // Update logs if user has reached 40% more progress
const timeThreshold = 120; // Update logs if 120 seconds have passed since last update

/**
* Vuex State Mappers
Expand Down Expand Up @@ -270,312 +264,6 @@ export function getFacilityConfig(store, facilityId) {
});
}

let lastElapsedTimeCheck;
let timeCheckIntervalTimer;

function getNewTimeElapsed() {
// Timer has not been started
if (!lastElapsedTimeCheck) {
return 0;
}
const currentTime = new Date();
const timeElapsed = currentTime - lastElapsedTimeCheck;
lastElapsedTimeCheck = currentTime;
// Some browsers entirely suspend code execution in background tabs,
// which can lead to unreliable timing if a tab has been in the background
// if the time elasped is significantly longer than the interval that we are
// checking this at, we should discard the result.
if (timeElapsed > intervalTime * 10) {
return 0;
}
// Return a time in seconds, rather than milliseconds.
return timeElapsed / 1000;
}

function clearTrackingInterval() {
clearInterval(timeCheckIntervalTimer);
timeCheckIntervalTimer = null;
lastElapsedTimeCheck = null;
}

/**
* Initialize a content session for progress tracking
* To be called on page load for content renderers
*/
export function initContentSession(store, { nodeId, lessonId, quizId } = {}) {
const data = {};
if (!nodeId && !quizId) {
throw TypeError('Must define either nodeId or quizId');
}
if ((nodeId || lessonId) && quizId) {
throw TypeError('quizId must be the only defined parameter if defined');
}
let sessionStarted = false;

if (quizId) {
sessionStarted = store.state.logging.context && store.state.logging.context.quiz_id === quizId;
data.quiz_id = quizId;
}

if (nodeId) {
sessionStarted = store.state.logging.context && store.state.logging.context.node_id === nodeId;
data.node_id = nodeId;
if (lessonId) {
sessionStarted =
sessionStarted &&
store.state.logging.context &&
store.state.logging.context.lesson_id === lessonId;
data.lesson_id = lessonId;
}
}

if (sessionStarted) {
return;
}

// Always clear the logging state when we init the content session,
// to avoid state pollution.
store.commit('SET_EMPTY_LOGGING_STATE');
// Clear any previous interval tracking that has been started
clearTrackingInterval();

return client({
method: 'post',
url: urls['kolibri:core:trackprogress-list'](),
data: data,
}).then(response => {
store.commit('INITIALIZE_LOGGING_STATE', response.data);
});
}

function _zeroToOne(num) {
return Math.min(1, Math.max(num || 0, 0));
}

function makeSessionUpdateRequest(store, data) {
const wasComplete = store.state.logging.complete;
return client({
method: 'put',
url: urls['kolibri:core:trackprogress-detail'](store.state.logging.session_id),
data,
}).then(response => {
if (response.data.attempts) {
for (let attempt of response.data.attempts) {
store.commit('UPDATE_ATTEMPT', attempt);
}
}
if (response.data.complete) {
store.commit('SET_COMPLETE');
if (store.getters.isUserLoggedIn && !wasComplete) {
store.commit('INCREMENT_TOTAL_PROGRESS', 1);
}
}
return response.data;
});
}

const maxRetries = 5;

// Function to delay rejection to allow delayed retry behaviour
function rejectDelay(reason, retryDelay = 5000) {
return new Promise(function(resolve, reject) {
setTimeout(reject.bind(null, reason), retryDelay);
});
}

// Start the savingPromise as a resolved promise
// so we can always just chain from this promise for subsequent saves.
let savingPromise = Promise.resolve();

let updateContentSessionResolveRejectStack = [];
let updateContentSessionTimeout;

function immediatelyUpdateContentSession(store) {
// Once this timeout has been executed, we can reset the global timeout
// to null, as we are now actually invoking the debounced function.
updateContentSessionTimeout = null;

const progress_delta = store.state.logging.progress_delta;
const time_spent_delta = store.state.logging.time_spent_delta;
const extra_fields = store.state.logging.extra_fields;
const extra_fields_dirty_bit = store.state.logging.extra_fields_dirty_bit;
const progress = store.state.logging.progress;
const unsavedInteractions = store.state.logging.unsavedInteractions;

if (
// Always update if there's an attempt that hasn't got an id yet, or if there have been
// at least 3 additional interactions.
(unsavedInteractions.length &&
(unsavedInteractions.some(r => !r.id) || unsavedInteractions.length > 2)) ||
progress_delta >= progressThreshold ||
(progress_delta && progress >= 1) ||
time_spent_delta >= timeThreshold ||
extra_fields_dirty_bit
) {
// Clear the temporary state that we've
// picked up to save to the backend.
store.commit('LOGGING_SAVING');
const data = {};
if (progress_delta) {
data.progress_delta = progress_delta;
}
if (time_spent_delta) {
data.time_spent_delta = time_spent_delta;
}
if (unsavedInteractions.length) {
data.interactions = unsavedInteractions;
}
if (extra_fields_dirty_bit) {
data.extra_fields = extra_fields;
}
// Don't try to make a new save until the previous save
// has completed.
savingPromise = savingPromise.then(() => {
// Create an initial rejection so that we can chain consistently
// in the retry loop.
let attempt = Promise.reject({ response: { status: 503 } });
for (var i = 0; i < maxRetries; i++) {
// Catch any previous error and then try to make the session update again
attempt = attempt
.catch(err => {
if (err && err.response && err.response.status === 503) {
return makeSessionUpdateRequest(store, data);
}
return Promise.reject(err);
})
.catch(err => {
// Only try to handle 503 status codes here, as otherwise we might be continually
// retrying the server when it is rejecting the request for valid reasons.
if (err && err.response && err.response.status === 503) {
// Defer to the server's Retry-After header if it is set.
const retryAfter = (err.response.headers || {})['retry-after'];
// retry-after header is in seconds, we need a value in milliseconds.
return rejectDelay(err, retryAfter ? retryAfter * 1000 : retryAfter);
}
return Promise.reject(err);
});
}
return attempt.catch(err => store.dispatch('handleApiError', err));
});
}
return savingPromise
.then(result => {
// If it is successful call all of the resolve functions that we have stored
// from all the Promises that have been returned while this specific debounce
// has been active.
for (let [resolve] of updateContentSessionResolveRejectStack) {
resolve(result);
}
// Reset the stack for resolve/reject functions, so that future invocations
// do not call these now consumed functions.
updateContentSessionResolveRejectStack = [];
})
.catch(err => {
// If there is an error call reject for all previously returned promises.
for (let [, reject] of updateContentSessionResolveRejectStack) {
reject(err);
}
// Likewise reset the stack.
updateContentSessionResolveRejectStack = [];
});
}

// Set the debounce artificially short in tests to prevent slowdowns.
const updateContentSessionDebounceTime = process.env.NODE_ENV === 'test' ? 1 : 2000;

/**
* Update a content session for progress tracking
*/
export function updateContentSession(
store,
{ progressDelta, progress, contentState, interaction, immediate = false } = {}
) {
if (store.state.logging.session_id === null) {
throw ReferenceError('Cannot update a content session before one has been initialized');
}
if (!isUndefined(progressDelta) && !isUndefined(progress)) {
throw TypeError('Must only specify either progressDelta or progress');
}
if (!isUndefined(progress)) {
if (!isNumber(progress)) {
throw TypeError('progress must be a number');
}
progress = _zeroToOne(progress);
store.commit('SET_LOGGING_PROGRESS', progress);
}
if (!isUndefined(progressDelta)) {
if (!isNumber(progressDelta)) {
throw TypeError('progressDelta must be a number');
}
progressDelta = _zeroToOne(progressDelta);
store.commit('ADD_LOGGING_PROGRESS', progressDelta);
}
if (!isUndefined(contentState)) {
if (!isPlainObject(contentState)) {
throw TypeError('contentState must be an object');
}
store.commit('SET_LOGGING_CONTENT_STATE', contentState);
}
if (!isUndefined(interaction)) {
if (!isPlainObject(interaction)) {
throw TypeError('interaction must be an object');
}
store.commit('ADD_UNSAVED_INTERACTION', interaction);
store.commit('UPDATE_ATTEMPT', interaction);
}
// Reset the elapsed time in the timer
const elapsedTime = getNewTimeElapsed();
// Discard the time that has passed if the page is not visible.
if (store.state.pageVisible && elapsedTime) {
/* Update the logging state with new timing information */
store.commit('UPDATE_LOGGING_TIME', elapsedTime);
}

immediate = (!isUndefined(interaction) && !interaction.id) || immediate;
// Logic for promise returning debounce vendored and modified from:
// https://github.com/sindresorhus/p-debounce/blob/main/index.js
// Return a promise that will consistently resolve when this debounced
// function invocation is completed.
return new Promise((resolve, reject) => {
// Clear any current timeouts, so that this invocation takes precedence
// Any subsequent calls will then also revoke this timeout.
clearTimeout(updateContentSessionTimeout);
// Add the resolve and reject handlers for this promise to the stack here.
updateContentSessionResolveRejectStack.push([resolve, reject]);
if (immediate) {
// If immediate invocation is required immediately call the handler
// rather than using a timeout delay.
immediatelyUpdateContentSession(store);
} else {
// Otherwise update the timeout to this invocation.
updateContentSessionTimeout = setTimeout(
() => immediatelyUpdateContentSession(store),
updateContentSessionDebounceTime
);
}
});
}

/**
* Start interval timer and set start time
* @param {int} interval
*/
export function startTrackingProgress(store) {
timeCheckIntervalTimer = setInterval(() => {
updateContentSession(store);
}, intervalTime);
lastElapsedTimeCheck = new Date();
}

/**
* Stop interval timer and update latest times
* Must be called after startTrackingProgress
*/
export function stopTrackingProgress(store) {
clearTrackingInterval();
updateContentSession(store, { immediate: true });
}

export function setChannelInfo(store) {
return ChannelResource.fetchCollection({ getParams: { available: true } }).then(
channelsData => {
Expand Down
2 changes: 0 additions & 2 deletions kolibri/core/assets/src/state/modules/core/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import connectionModule from '../connection';
import loggingModule from '../logging';
import sessionModule from '../session';
import snackbarModule from '../snackbar';
import * as getters from './getters';
Expand Down Expand Up @@ -32,7 +31,6 @@ export default {
mutations,
modules: {
connection: connectionModule,
logging: loggingModule,
session: sessionModule,
snackbar: snackbarModule,
},
Expand Down
Loading