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

Update recalc logic to be resumable #1005

Open
wants to merge 2 commits into
base: bugfix
Choose a base branch
from
Open
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
152 changes: 102 additions & 50 deletions qa-content/qa-admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,76 +19,128 @@
More about this license: http://www.question2answer.org/license.php
*/

var qa_recalc_running = 0;
const qa_recalcProcesses = new Map();

window.onbeforeunload = function(event)
{
if (qa_recalc_running > 0) {
event = event || window.event;
var message = qa_warning_recalc;
event.returnValue = message;
return message;
window.onbeforeunload = event => {
for (const [processKey, process] of qa_recalcProcesses.entries()) {
if (process.clientRunning) {
event.preventDefault();
event.returnValue = true;
}
}
};

function qa_recalc_click(state, elem, value, noteid)
/**
* @param {string} processKey
* @param {Object} options - Object used to configure the process
* @param {boolean} [options.forceRestart = false] - Whether the click has to trigger a restart of the process
* @param {boolean} [options.requiresServerTracking = true] - Whether the process is expected to be stopped and resumed
* @param {(process: object) => void} [options.callbackStart] - Callback run when the process is started
* @param {(process: object, hasFinished: boolean) => void} [options.callbackStop] - Callback run when the process is stopped
* @returns {boolean}
*/
function qa_recalc_click(processKey, options = {})
{
if (elem.qa_recalc_running) {
elem.qa_recalc_stopped = true;
options = {
forceRestart: false,
requiresServerTracking: true,
...options,
};

let process = qa_recalcProcesses.get(processKey) ?? {processKey: processKey};

const startButton = document.getElementById(processKey);
const continueButton = document.getElementById(processKey + '_continue');
const statusLabel = document.getElementById(processKey + '_status');

if (process.clientRunning) {
process.stopRequest = true;
} else {
elem.qa_recalc_running = true;
elem.qa_recalc_stopped = false;
qa_recalc_running++;
process = {
...process,
"startButton": startButton,
"continueButton": continueButton,
"statusLabel": statusLabel,
"clientRunning": true,
"stopRequest": false,
"startListeners": options.callbackStart ? [options.callbackStart] : [],
"stopListeners": options.callbackStop ? [options.callbackStop] : [],
"options": options
};

document.getElementById(noteid).innerHTML = '';
elem.qa_original_value = elem.value;
if (value)
elem.value = value;
qa_recalcProcesses.set(processKey, process);

qa_recalc_update(elem, state, noteid);
qa_conceal(process.continueButton);

statusLabel.innerHTML = qa_langs.please_wait;
startButton.value = qa_langs.process_stop;

process.startListeners.forEach(listener => listener(process));

qa_recalc_update(process);
}

return false;
}

function qa_recalc_update(elem, state, noteid)
function qa_recalc_update(process)
{
if (state) {
var recalcCode = elem.form.elements.code_recalc ? elem.form.elements.code_recalc.value : elem.form.elements.code.value;
qa_ajax_post(
'recalc',
{state: state, code: recalcCode},
function(lines) {
if (lines[0] == '1') {
if (lines[2])
document.getElementById(noteid).innerHTML = lines[2];

if (elem.qa_recalc_stopped)
qa_recalc_cleanup(elem);
else
qa_recalc_update(elem, lines[1], noteid);

} else if (lines[0] == '0') {
document.getElementById(noteid).innerHTML = lines[1];
qa_recalc_cleanup(elem);

} else {
const recalcCode = process.startButton.form.elements.code.value;

qa_ajax_post(
'recalc',
{
process: process.processKey,
forceRestart: process.options.forceRestart,
code: recalcCode
},
function (lines) {
const result = lines[0] ?? null;
const message = lines[1] ?? null;
const hasFinished = (lines[2] ?? '0') === '1';

switch (result) {
case '1':
if (message !== null) {
process.statusLabel.innerHTML = message;
}

process.serverProcessPending = process.options.requiresServerTracking ? !hasFinished : false;
if (hasFinished || process.stopRequest) {
qa_recalc_cleanup(process, hasFinished);
} else {
process.options.forceRestart = false;
qa_recalc_update(process);
}
break;
case '0':
process.statusLabel.innerHTML = message;
process.serverProcessPending = true;
qa_recalc_cleanup(process, false, message);
break;
default:
process.serverProcessPending = true;
qa_recalc_cleanup(process);
qa_ajax_error();
qa_recalc_cleanup(elem);
}
}
);
} else {
qa_recalc_cleanup(elem);
}
}
);
}

function qa_recalc_cleanup(elem)
function qa_recalc_cleanup(process, hasFinished = false, message = null)
{
elem.value = elem.qa_original_value;
elem.qa_recalc_running = null;
qa_recalc_running--;
process.clientRunning = false;

process.stopListeners.forEach(listener => listener(process, hasFinished));

if (process.options.requiresServerTracking && process.serverProcessPending) {
process.startButton.value = qa_langs.process_restart;
process.statusLabel.innerHTML = message ?? qa_langs.process_unfinished;
qa_reveal(process.continueButton);
} else {
process.startButton.value = qa_langs.process_start;
qa_conceal(process.continueButton);
}
}

function qa_mailing_start(noteid, pauseid)
Expand Down
123 changes: 123 additions & 0 deletions qa-include/Q2A/Admin/Recalc/AbstractProcessManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

/*
Question2Answer by Gideon Greenspan and contributors
http://www.question2answer.org/

This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.

This program 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 General Public License for more details.

More about this license: http://www.question2answer.org/license.php
*/

abstract class Q2A_Admin_Recalc_AbstractProcessManager
{
/** @var string */
protected $stateOption;

/** @var int */
protected $currentStepIndex = 0;

/** @var array */
protected $steps;

/**
* @return array Return step and process state data
*/
public function execute($forceRestart = false)
{
$newStep = false;

try {
if ($forceRestart) {
throw new Exception('Force exception');
}

$step = $this->loadState();

if ($step->isFinished()) {
$this->currentStepIndex++;

if ($this->currentStepIndex >= count($this->steps)) {
$this->clearState();

return [
'process_finished' => true,
'message' => qa_lang('admin/process_complete'),
];
}

$newStep = true;
$step = $this->getCurrentStepInstance();
}
} catch (Exception $e) {
$step = $this->getCurrentStepInstance();
$newStep = true;
}

if ($newStep) {
$step->setup();
} else {
$step->execute();
}

$result = [
'step_state' => $step->asArray(),
'step_index' => $this->currentStepIndex,
'process_finished' => false,
];
$this->saveState($result);

$result['message'] = $step->getMessage();

return $result;
}

/**
* @throws Exception
*/
private function loadState()
{
$state = qa_opt($this->stateOption);
$state = json_decode($state, true);

if (!isset($state['step_index'])) {
throw new Exception('Nothing to load');
}

$this->currentStepIndex = $state['step_index'];

$step = $this->getCurrentStepInstance();
$step->loadFromJson($state['step_state']);

return $step;
}

private function saveState($state)
{
qa_opt($this->stateOption, json_encode($state));
}

private function clearState()
{
qa_opt($this->stateOption, '');
}

/**
* @return Q2A_Admin_Recalc_AbstractStep
*/
protected function getCurrentStepInstance()
{
// Make sure the step index to instantiate is valid
$this->currentStepIndex = min(max(0, $this->currentStepIndex), count($this->steps) - 1);

return new $this->steps[$this->currentStepIndex];
}
}
84 changes: 84 additions & 0 deletions qa-include/Q2A/Admin/Recalc/AbstractStep.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

/*
Question2Answer by Gideon Greenspan and contributors
http://www.question2answer.org/

This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.

This program 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 General Public License for more details.

More about this license: http://www.question2answer.org/license.php
*/

abstract class Q2A_Admin_Recalc_AbstractStep
{
/** @var int|null */
protected $processedItems = 0;

/** @var int|null */
protected $totalItems;

/** @var mixed */
protected $nextItemId = 0;

/** @var mixed */
protected $lastProcessedItemId = 0;

/** @var bool */
protected $isFinished = false;

/** @var string */
protected $messageLangId = '';

abstract public function setup();

abstract public function execute();

/**
* @return array
*/
public function asArray()
{
return [
'is_finished' => $this->isFinished,
'processed_items' => $this->processedItems,
'total_items' => $this->totalItems,
'next_item_id' => $this->nextItemId,
'last_processed_item_id' => $this->lastProcessedItemId,
];
}

public function loadFromJson($state)
{
$this->isFinished = $state['is_finished'];
$this->processedItems = $state['processed_items'];
$this->totalItems = $state['total_items'];
$this->nextItemId = $state['next_item_id'];
$this->lastProcessedItemId = $state['last_processed_item_id'];
}

/**
* @return bool
*/
public function isFinished()
{
return $this->isFinished;
}

public function getMessage()
{
require_once QA_INCLUDE_DIR . 'app/format.php';

return strtr(qa_lang($this->messageLangId), array(
'^1' => qa_format_number($this->processedItems),
'^2' => qa_format_number($this->totalItems),
));
}
}
Loading