Skip to content
Open
24 changes: 23 additions & 1 deletion InfoLogger/lib/controller/QueryController.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
*/

const { LogManager, updateAndSendExpressResponseFromNativeError } = require('@aliceo2/web-ui');
const { AbortError, throwIfQueryAborted } = require('../utils/queryCancellation');

/**
* Gateway for all calls that are to query InfoLogger database
Expand All @@ -37,15 +38,36 @@ class QueryController {
* @returns {void}
*/
async getLogs(req, res) {
const abortController = new AbortController();
const { signal } = abortController;
try {
const { body: { criterias, options } } = req;
if (!criterias || Object.keys(criterias).length === 0) {
res.status(400).json({ error: 'Invalid query parameters provided' });
return;
}
const logs = await this._queryService.queryFromFilters(criterias, options);

let responseInProgress = true;

res.on('finish', () => {
responseInProgress = false;
});

res.on('close', () => {
if (responseInProgress) {
abortController.abort();
}
});

const logs = await this._queryService.queryFromFilters(criterias, options, signal);
throwIfQueryAborted(signal);

res.status(200).json(logs);
} catch (error) {
if (signal.aborted || error instanceof AbortError) {
this._logger.infoMessage('Query was cancelled by the client');
return;
}
this._logger.errorMessage(error.toString());
updateAndSendExpressResponseFromNativeError(res, error);
}
Expand Down
32 changes: 25 additions & 7 deletions InfoLogger/lib/services/QueryService.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const mariadb = require('mariadb');
const { LogManager, InvalidInputError } = require('@aliceo2/web-ui');
const { fromSqlToNativeError } = require('../utils/fromSqlToNativeError');
const { processPreparedSQLStatement } = require('../utils/preparedStatementParser');
const { throwIfQueryAborted, attachAbortDestroyHandler } = require('../utils/queryCancellation');

class QueryService {
/**
Expand Down Expand Up @@ -92,32 +93,49 @@ class QueryService {
* @param {object} filters - criteria like MongoDB
* @param {object} options - specific options for the query
* @param {number} options.limit - how many rows to get
* @param {AbortSignal} [signal] - optional signal to cancel the query; when aborted, the DB connection is destroyed
* @returns {Promise.<object>} - {total, more, limit, rows, count, time}
*/
async queryFromFilters(filters, options) {
async queryFromFilters(filters, options, signal = null) {
const { limit = 100000 } = options;
const { criteria, values } = this._filtersToSqlConditions(filters);
const criteriaString = this._getCriteriaAsString(criteria);

const requestRows = `SELECT * FROM \`messages\` ${criteriaString} ORDER BY \`TIMESTAMP\` LIMIT ?;`;
const queryValues = [...values, limit];
const startTime = Date.now(); // ms

this._logger.debugMessage(`SQL to execute: ${processPreparedSQLStatement(requestRows, values, limit)}`);

let rows = [];
let connection = null;
let connectionDestroyed = false;
try {
if (!this._pool) {
throw new Error('No database connection available');
}
rows = await this._pool.query(
{
sql: requestRows,
timeout: this._timeout,
connection = await this._pool.getConnection();
throwIfQueryAborted(signal);

const detachAbortHandler = attachAbortDestroyHandler(
signal,
connection,
() => {
connectionDestroyed = true;
},
[...values, limit],
);

try {
rows = await connection.query({ sql: requestRows, timeout: this._timeout }, queryValues);
} finally {
detachAbortHandler();
}
} catch (error) {
throwIfQueryAborted(signal);
fromSqlToNativeError(error);
} finally {
if (connection && !connectionDestroyed) {
connection.release();
}
}

const totalTime = Date.now() - startTime; // ms
Expand Down
67 changes: 67 additions & 0 deletions InfoLogger/lib/utils/queryCancellation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* @license
* Copyright 2019-2020 CERN and copyright holders of ALICE O2.
* See http://alice-o2.web.cern.ch/copyright for details of the copyright holders.
* All rights not expressly granted are reserved.
*
* This software is distributed under the terms of the GNU General Public
* License v3 (GPL Version 3), copied verbatim in the file "COPYING".
*
* In applying this license CERN does not waive the privileges and immunities
* granted to it by virtue of its status as an Intergovernmental Organization
* or submit itself to any jurisdiction.
*/

/**
* Custom error class for query cancellation
*/
class AbortError extends Error {
/**
* Create an AbortError
* @param {string} message - error message
*/
constructor(message = 'Query cancelled by client') {
super(message);
this.name = 'AbortError';
this.code = 'QUERY_CANCELLED';
}
}

/**
* Throw an error if the given signal is already aborted.
* @param {AbortSignal|null} signal - optional abort signal
* @throws {AbortError} if signal is aborted
*/
const throwIfQueryAborted = (signal) => {
if (signal?.aborted) {
throw new AbortError();
}
};

/**
* Attach a one-time abort handler to the signal that destroys the connection.
* Return a cleanup function to remove the listener
* @param {AbortSignal|null} signal - optional abort signal
* @param {object} connection - mariadb connection-like object
* @param {function(): void} onDestroyed - callback called just before destroying connection
* @returns {function(): void} cleanup callback to remove the listener
*/
const attachAbortDestroyHandler = (signal, connection, onDestroyed) => {
if (!signal) {
return () => {};
}

const abortHandler = () => {
onDestroyed();
connection.destroy();
};

signal.addEventListener('abort', abortHandler, { once: true });
return () => signal.removeEventListener('abort', abortHandler);
};

module.exports = {
AbortError,
throwIfQueryAborted,
attachAbortDestroyHandler,
};
1 change: 1 addition & 0 deletions InfoLogger/public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
--row-height: 0.91rem; /* default, overridden by JS zoom */
}
.logs-content { border-top: 1px solid #aaa; }
.bold { font-weight: bold; }

/* logs tables */
.table-logs-header { width: 100%; border-collapse: collapse; }
Expand Down
54 changes: 54 additions & 0 deletions InfoLogger/public/common/jsonFetch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* @license
* Copyright CERN and copyright holders of ALICE O2. This software is
* distributed under the terms of the GNU General Public License v3 (GPL
* Version 3), copied verbatim in the file "COPYING".
*
* See http://alice-o2.web.cern.ch/license for full licensing information.
*
* In applying this license CERN does not waive the privileges and immunities
* granted to it by virtue of its status as an Intergovernmental Organization
* or submit itself to any jurisdiction.
*/

import { fetchClient } from '/js/src/index.js';

/**
* Send a request to an endpoint, and extract the response. If errors occurred, return an error containing a message.
*
* The endpoint is expected to follow some conventions:
* - If request is valid but no data was sent as response, it must return a 204
* - If an error occurred on the backend:
* - request can be status ok with {message: string} body describing the error
* - or request can be status error with or without body that contains a message field describing the error
* - If request is valid and data is sent as response, it must return a json with the expected data
* @param {string} endpoint - the remote endpoint to send request to
* @param {RequestInit} options - the request options, see {@link fetch } native function
* (method, headers, body, abort.signal, etc.)
* @returns {Promise<Resolve<object>.Error<{message: string}>>} resolve with the result or reject with the error message
*/
export const jsonFetch = async (endpoint, options) => {
try {
const response = await fetchClient(endpoint, options);
if (response.status === 204) {
return null;
}

const result = await response.json();

if (response.ok) {
return result;
}

const serverMessage = result && typeof result.message === 'string'
? result.message
: null;

throw new Error(serverMessage || `Request failed with status ${response.status}`);
} catch (error) {
if (error && typeof error.message === 'string') {
throw error;
}
throw new Error('Parsing result from server failed', { cause: error });
}
};
40 changes: 40 additions & 0 deletions InfoLogger/public/common/jsonPost.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* @license
* Copyright CERN and copyright holders of ALICE O2. This software is
* distributed under the terms of the GNU General Public License v3 (GPL
* Version 3), copied verbatim in the file "COPYING".
*
* See http://alice-o2.web.cern.ch/license for full licensing information.
*
* In applying this license CERN does not waive the privileges and immunities
* granted to it by virtue of its status as an Intergovernmental Organization
* or submit itself to any jurisdiction.
*/

import { jsonFetch } from './jsonFetch.js';

/**
* Build and send a POST request to a remote endpoint, and extract the response.
* @param {string} endpoint - the remote endpoint to send request to
* @param {RequestInit} options - the request options, see {@link fetch } native function
* (method, headers, body, abort.signal, etc.)
* @returns {Promise<Resolve<object>.Error<{message: string}>>} resolve with the result or reject with the error
*/
export const jsonPost = async (endpoint, options = {}) => {
if (options.body && typeof options.body === 'object') {
options.body = JSON.stringify(options.body);
}
try {
const result = await jsonFetch(endpoint, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
...options,
});
return result;
} catch (error) {
return Promise.reject({ message: error.message || error });
}
};
Loading
Loading