diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f782a26d3d..c188aa5b70 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -10,6 +10,10 @@ services: JWT_SECRET: BOOKKEEPING-DEV GRPC_INTERNAL_ORIGIN: '[::]:4001' GRPC_AUTHENTICATED_ORIGIN: '[::]:4002' + GAQ_ENABLE_RECALCULATION: "True" + GAQ_RECALCULATION_PERIOD: 10000 + GAQ_RECALCULATION_MIN_BATCH_SIZE: 5 + GAQ_RECALCULATION_MAX_BATCH_SIZE: 50 ports: - "4000:4000" - "4001:4001" diff --git a/docker-compose.test-parallel-base.yml b/docker-compose.test-parallel-base.yml index 7147451bb6..444a40b762 100644 --- a/docker-compose.test-parallel-base.yml +++ b/docker-compose.test-parallel-base.yml @@ -16,6 +16,10 @@ services: FLP_INFOLOGGER_URL: "${FLP_INFOLOGGER_URL:-http://localhost:8081}" QC_GUI_URL: "${QC_GUI_URL:-http://localhost:8082}" ALI_FLP_INDEX_URL: "${ALI_FLP_INDEX_URL:-http://localhost:80}" + GAQ_ENABLE_RECALCULATION: "True" + GAQ_RECALCULATION_PERIOD: 1000 + GAQ_RECALCULATION_MIN_BATCH_SIZE: 1 + GAQ_RECALCULATION_MAX_BATCH_SIZE: 1 links: - test_db restart: "no" diff --git a/docker-compose.test.yml b/docker-compose.test.yml index b43a5c8ef9..a505b1022e 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -10,6 +10,10 @@ services: JWT_SECRET: BOOKKEEPING-TEST-SUITE PAGE_ITEMS_LIMIT: 100 CCDB_SYNCHRONIZATION_PERIOD: 3153600000000 # 100y in milliseconds, to be sure all the runs are included when testing sync + GAQ_ENABLE_RECALCULATION: "True" + GAQ_RECALCULATION_PERIOD: 1000 + GAQ_RECALCULATION_MIN_BATCH_SIZE: 1 + GAQ_RECALCULATION_MAX_BATCH_SIZE: 1 restart: "no" database: diff --git a/lib/application.js b/lib/application.js index 70a4ac6839..ff39c2ed25 100644 --- a/lib/application.js +++ b/lib/application.js @@ -15,7 +15,7 @@ const database = require('./database'); const { webUiServer } = require('./server'); const { GRPCConfig, ServicesConfig } = require('./config'); -const { userCertificate, monalisa: monalisaConfig, ccdb: ccdbConfig, enableHousekeeping } = ServicesConfig; +const { userCertificate, monalisa: monalisaConfig, ccdb: ccdbConfig, gaq: gaqConfig, enableHousekeeping } = ServicesConfig; const { handleLostRunsAndEnvironments } = require('./server/services/housekeeping/handleLostRunsAndEnvironments.js'); const { isInTestMode } = require('./utilities/env-utils.js'); const { ScheduledProcessesManager } = require('./server/services/ScheduledProcessesManager.js'); @@ -27,6 +27,7 @@ const { AliEcsSynchronizer } = require('./server/kafka/AliEcsSynchronizer.js'); const { environmentService } = require('./server/services/environment/EnvironmentService.js'); const { runService } = require('./server/services/run/RunService.js'); const { CcdbSynchronizer } = require('./server/externalServicesSynchronization/ccdb/CcdbSynchronizer.js'); +const { gaqWorker } = require('./server/services/gaq/GaqWorker.js'); const { promises: fs } = require('fs'); const { MonAlisaClient } = require('./server/externalServicesSynchronization/monalisa/MonAlisaClient.js'); const https = require('https'); @@ -131,6 +132,16 @@ class BookkeepingApplication { }, ); } + + if (gaqConfig.enableRecalculation) { + this.scheduledProcessesManager.schedule( + () => gaqWorker.recalculateGaqSummaries(gaqConfig.minBatchSize, gaqConfig.maxBatchSize), + { + wait: 10 * 1000, + every: gaqConfig.recalculationPeriod, + }, + ); + } } catch (error) { this._logger.errorMessage(`Error while starting: ${error}`); return this.stop(); diff --git a/lib/config/services.js b/lib/config/services.js index 9e535881a9..2cf56c9d64 100644 --- a/lib/config/services.js +++ b/lib/config/services.js @@ -68,4 +68,11 @@ exports.services = { synchronizationPeriod: Number(CCDB_SYNCHRONIZATION_PERIOD) || 24 * 60 * 60 * 1000, // 1d in milliseconds runInfoUrl: CCDB_RUN_INFO_URL, }, + + gaq: { + enableRecalculation: process.env?.GAQ_ENABLE_RECALCULATION?.toLowerCase() === 'true', + recalculationPeriod: Number(process.env?.GAQ_RECALCULATION_PERIOD) || 30 * 1000, // 30s default + minBatchSize: Number(process.env?.GAQ_RECALCULATION_MIN_BATCH_SIZE) || 1, + maxBatchSize: Number(process.env?.GAQ_RECALCULATION_MAX_BATCH_SIZE) || 100, + }, }; diff --git a/lib/database/adapters/GaqSummaryAdapter.js b/lib/database/adapters/GaqSummaryAdapter.js new file mode 100644 index 0000000000..53b6baf61a --- /dev/null +++ b/lib/database/adapters/GaqSummaryAdapter.js @@ -0,0 +1,62 @@ +/** + * @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. + */ + +/** + * GaqSummaryAdapter + */ +class GaqSummaryAdapter { + /** + * Constructor + */ + constructor() { + this.toEntity = this.toEntity.bind(this); + } + + /** + * Converts the given database object to an entity object. + * + * @param {SequelizeGaqSummary} databaseObject Object to convert. + * @returns {GaqSummary} Converted entity object. + */ + toEntity(databaseObject) { + const { + dataPassId, + runNumber, + badRunCoverage, + explicitlyNotBadRunCoverage, + mcReproducibleCoverage, + missingVerificationsCount, + undefinedQualityPeriodsCount, + notComputable, + invalidatedAt, + createdAt, + updatedAt, + } = databaseObject; + + return { + dataPassId, + runNumber, + badRunCoverage, + explicitlyNotBadRunCoverage, + mcReproducibleCoverage, + missingVerificationsCount, + undefinedQualityPeriodsCount, + notComputable, + invalidatedAt, + createdAt, + updatedAt, + }; + } +} + +module.exports = { GaqSummaryAdapter }; diff --git a/lib/database/adapters/index.js b/lib/database/adapters/index.js index 5ff6404b3d..9c858e1bc2 100644 --- a/lib/database/adapters/index.js +++ b/lib/database/adapters/index.js @@ -27,6 +27,7 @@ const EorReasonAdapter = require('./EorReasonAdapter'); const FlpRoleAdapter = require('./FlpRoleAdapter'); const { HostAdapter } = require('./HostAdapter.js'); const { GaqDetectorAdapter } = require('./GaqDetectorAdapter.js'); +const { GaqSummaryAdapter } = require('./GaqSummaryAdapter.js'); const { LhcFillAdapter } = require('./LhcFillAdapter.js'); const { LhcFillStatisticsAdapter } = require('./LhcFillStatisticsAdapter.js'); const LhcPeriodAdapter = require('./LhcPeriodAdapter'); @@ -63,6 +64,7 @@ const environmentHistoryItemAdapter = new EnvironmentHistoryItemAdapter(); const eorReasonAdapter = new EorReasonAdapter(); const flpRoleAdapter = new FlpRoleAdapter(); const gaqDetectorAdapter = new GaqDetectorAdapter(); +const gaqSummaryAdapter = new GaqSummaryAdapter(); const hostAdapter = new HostAdapter(); const lhcFillAdapter = new LhcFillAdapter(); const lhcFillStatisticsAdapter = new LhcFillStatisticsAdapter(); @@ -159,6 +161,7 @@ module.exports = { eorReasonAdapter, flpRoleAdapter, gaqDetectorAdapter, + gaqSummaryAdapter, hostAdapter, lhcFillAdapter, lhcFillStatisticsAdapter, diff --git a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js new file mode 100644 index 0000000000..639db07c5c --- /dev/null +++ b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js @@ -0,0 +1,71 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + up: async (queryInterface, Sequelize) => queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.createTable('gaq_summaries', { + data_pass_id: { + type: Sequelize.INTEGER, + primaryKey: true, + allowNull: false, + references: { + model: 'data_passes', + key: 'id', + }, + }, + run_number: { + type: Sequelize.INTEGER, + primaryKey: true, + allowNull: false, + references: { + model: 'runs', + key: 'run_number', + }, + }, + bad_run_coverage: { + type: Sequelize.FLOAT, + }, + explicitly_not_bad_run_coverage: { + type: Sequelize.FLOAT, + }, + mc_reproducible_coverage: { + type: Sequelize.FLOAT, + }, + missing_verifications_count: { + type: Sequelize.INTEGER, + }, + undefined_quality_periods_count: { + type: Sequelize.INTEGER, + }, + not_computable: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + invalidated_at: { + type: Sequelize.DATE(3), + allowNull: true, + defaultValue: null, + }, + created_at: { + type: Sequelize.DATE(3), + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP(3)'), + }, + updated_at: { + type: Sequelize.DATE(3), + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'), + }, + }, { transaction }); + + await queryInterface.addIndex('gaq_summaries', { + name: 'gaq_summaries_invalidated_at_idx', + fields: ['invalidated_at'], + }, { transaction }); + }), + + down: async (queryInterface) => queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.dropTable('gaq_summaries', { transaction }); + }), +}; diff --git a/lib/database/models/gaqSummary.js b/lib/database/models/gaqSummary.js new file mode 100644 index 0000000000..ca2a1f69b8 --- /dev/null +++ b/lib/database/models/gaqSummary.js @@ -0,0 +1,59 @@ +/** + * @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. + */ + +module.exports = (sequelize) => { + const Sequelize = require('sequelize'); + + const GaqSummary = sequelize.define('GaqSummary', { + dataPassId: { + type: Sequelize.INTEGER, + primaryKey: true, + }, + runNumber: { + type: Sequelize.INTEGER, + primaryKey: true, + }, + badRunCoverage: { + type: Sequelize.FLOAT, + }, + explicitlyNotBadRunCoverage: { + type: Sequelize.FLOAT, + }, + mcReproducibleCoverage: { + type: Sequelize.FLOAT, + }, + missingVerificationsCount: { + type: Sequelize.INTEGER, + }, + undefinedQualityPeriodsCount: { + type: Sequelize.INTEGER, + }, + notComputable: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + invalidatedAt: { + type: Sequelize.DATE(3), + }, + }, { tableName: 'gaq_summaries' }); + + GaqSummary.removeAttribute('id'); + + GaqSummary.associate = (models) => { + GaqSummary.belongsTo(models.Run, { foreignKey: 'runNumber', as: 'run' }); + GaqSummary.belongsTo(models.DataPass, { foreignKey: 'dataPassId', as: 'dataPass' }); + }; + + return GaqSummary; +}; diff --git a/lib/database/models/index.js b/lib/database/models/index.js index 87d793fac3..2549209c5b 100644 --- a/lib/database/models/index.js +++ b/lib/database/models/index.js @@ -27,6 +27,7 @@ const EorReason = require('./eorreason'); const EpnRoleSession = require('./epnrolesession'); const FlpRole = require('./flprole'); const GaqDetector = require('./gaqDetector.js'); +const GaqSummary = require('./gaqSummary.js'); const Host = require('./host.js'); const LhcFill = require('./lhcFill'); const LhcFillStatistics = require('./lhcFillStatistics.js'); @@ -66,6 +67,7 @@ module.exports = (sequelize) => { EpnRoleSessionkey: EpnRoleSession(sequelize), FlpRole: FlpRole(sequelize), GaqDetector: GaqDetector(sequelize), + GaqSummary: GaqSummary(sequelize), Host: Host(sequelize), LhcFill: LhcFill(sequelize), LhcFillStatistics: LhcFillStatistics(sequelize), diff --git a/lib/database/repositories/GaqSummaryRepository.js b/lib/database/repositories/GaqSummaryRepository.js new file mode 100644 index 0000000000..81463d31c3 --- /dev/null +++ b/lib/database/repositories/GaqSummaryRepository.js @@ -0,0 +1,55 @@ +/** + * @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. + */ + +const { + models: { + GaqSummary, + }, +} = require('..'); +const Repository = require('./Repository'); + +/** + * GaqSummary repository + */ +class GaqSummaryRepository extends Repository { + /** + * Creates a new `GaqSummaryRepository` instance. + */ + constructor() { + super(GaqSummary); + } + + /** + * Mark the summary for a given (dataPassId, runNumber) as invalidated, creating the row if it does not yet exist + * + * @param {number} dataPassId data pass id + * @param {number} runNumber run number + * @return {Promise} resolves once the summary is invalidated + */ + async invalidate(dataPassId, runNumber) { + await this.upsert({ dataPassId, runNumber, invalidatedAt: new Date() }); + } + + /** + * Mark a list of summaries as invalidated in parallel + * + * @param {{ dataPassId: number, runNumber: number }[]} pairs the (dataPassId, runNumber) pairs to invalidate + * @return {Promise} resolves once all summaries are invalidated + */ + async invalidateMany(pairs) { + const invalidatedAt = new Date(); + await Promise.all(pairs.map(({ dataPassId, runNumber }) => this.upsert({ dataPassId, runNumber, invalidatedAt }))); + } +} + +module.exports = new GaqSummaryRepository(); diff --git a/lib/database/repositories/Repository.js b/lib/database/repositories/Repository.js index 05a8e6c39c..45f3a3f42a 100644 --- a/lib/database/repositories/Repository.js +++ b/lib/database/repositories/Repository.js @@ -118,10 +118,11 @@ class Repository { * Insert multiple entities * * @param {*[]} entities list of entities to be inserted + * @param {Object} [options] options to be passed to bulkCreate * @return {Promise<*[]>} promise */ - async insertAll(entities) { - return this.model.bulkCreate(entities); + async insertAll(entities, options = {}) { + return this.model.bulkCreate(entities, options); } /** diff --git a/lib/database/repositories/index.js b/lib/database/repositories/index.js index 0c79279752..4be7600145 100644 --- a/lib/database/repositories/index.js +++ b/lib/database/repositories/index.js @@ -27,6 +27,7 @@ const EnvironmentRepository = require('./EnvironmentRepository'); const EorReasonRepository = require('./EorReasonRepository'); const FlpRoleRepository = require('./FlpRoleRepository'); const GaqDetectorRepository = require('./GaqDetectorRepository.js'); +const GaqSummaryRepository = require('./GaqSummaryRepository.js'); const HostRepository = require('./HostRepository.js'); const LhcFillRepository = require('./LhcFillRepository'); const LhcFillStatisticsRepository = require('./LhcFillStatisticsRepository.js'); @@ -70,6 +71,7 @@ module.exports = { EorReasonRepository, FlpRoleRepository, GaqDetectorRepository, + GaqSummaryRepository, HostRepository, LhcFillRepository, LhcFillStatisticsRepository, diff --git a/lib/database/seeders/20260223120000-gaq-summaries.js b/lib/database/seeders/20260223120000-gaq-summaries.js new file mode 100644 index 0000000000..61d0052e6d --- /dev/null +++ b/lib/database/seeders/20260223120000-gaq-summaries.js @@ -0,0 +1,84 @@ +/** + * @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. + */ + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + up: async (queryInterface) => queryInterface.bulkInsert('gaq_summaries', [ + { + data_pass_id: 1, + run_number: 106, + bad_run_coverage: 0, + mc_reproducible_coverage: 0, + missing_verifications_count: 3, + undefined_quality_periods_count: 11, + not_computable: 0, + invalidated_at: null, + created_at: '2026-06-29 13:09:09.382', + updated_at: '2026-06-29 13:09:09.382', + }, + { + data_pass_id: 1, + run_number: 107, + bad_run_coverage: 0, + explicitly_not_bad_run_coverage: 0.759654, + mc_reproducible_coverage: 0.240346, + missing_verifications_count: 3, + undefined_quality_periods_count: 0, + not_computable: 0, + invalidated_at: null, + created_at: '2026-06-29 13:09:09.382', + updated_at: '2026-06-29 13:09:09.382', + }, + { + data_pass_id: 2, + run_number: 1, + bad_run_coverage: null, + explicitly_not_bad_run_coverage: null, + mc_reproducible_coverage: null, + missing_verifications_count: null, + undefined_quality_periods_count: null, + not_computable: 1, + invalidated_at: null, + created_at: '2026-06-29 13:09:09.382', + updated_at: '2026-06-29 13:09:09.382', + }, + { + data_pass_id: 4, + run_number: 100, + bad_run_coverage: null, + explicitly_not_bad_run_coverage: null, + mc_reproducible_coverage: null, + missing_verifications_count: null, + undefined_quality_periods_count: null, + not_computable: 1, + invalidated_at: null, + created_at: '2026-06-29 13:09:09.382', + updated_at: '2026-06-29 13:09:09.382', + + }, + { + data_pass_id: 4, + run_number: 105, + bad_run_coverage: null, + explicitly_not_bad_run_coverage: null, + mc_reproducible_coverage: null, + missing_verifications_count: null, + undefined_quality_periods_count: null, + not_computable: 1, + invalidated_at: null, + created_at: '2026-06-29 13:09:09.382', + updated_at: '2026-06-29 13:09:09.382', + }, + ]), + down: async (queryInterface) => queryInterface.bulkDelete('gaq_summaries', null, {}), +}; diff --git a/lib/domain/enums/QcSummaryProperties.js b/lib/domain/enums/QcSummaryProperties.js index 741b102a8e..a4ffd457a2 100644 --- a/lib/domain/enums/QcSummaryProperties.js +++ b/lib/domain/enums/QcSummaryProperties.js @@ -18,4 +18,6 @@ exports.QcSummarProperties = { MC_REPRODUCIBLE: 'mcReproducible', MINIFIED_FLAGS: 'minifiedFlags', UNDEFINED_QUALITY_PERIODS_COUNT: 'undefinedQualityPeriodsCount', + NOT_COMPUTABLE: 'notComputable', + INVALIDATED_AT: 'invalidatedAt', }; diff --git a/lib/public/utilities/fetch/RemoteDataSource.js b/lib/public/utilities/fetch/RemoteDataSource.js index b1cd89fd86..332eb5ee4e 100644 --- a/lib/public/utilities/fetch/RemoteDataSource.js +++ b/lib/public/utilities/fetch/RemoteDataSource.js @@ -43,9 +43,10 @@ export class RemoteDataSource { * Fetch the given endpoint to fill current data * * @param {string} endpoint the endpoint to fetch + * @param {Object} [options] additional fetch options (e.g. headers) * @return {Promise} resolves once the data fetching has ended */ - async fetch(endpoint) { + async fetch(endpoint, options) { if (!this._observableData) { return null; } @@ -58,7 +59,7 @@ export class RemoteDataSource { this._abortController.abort(); } this._abortController = abortController; - const data = await this.getRemoteData(endpoint); + const data = await this.getRemoteData(endpoint, options); this._observableData.setCurrent(RemoteData.success(data)); } catch (error) { // Use local variable because the class member (this._abortController) may already have been override in another call @@ -72,9 +73,10 @@ export class RemoteDataSource { * Fetch the endpoint and return the result * * @param {string} endpoint the endpoint to fetch + * @param {Object} [options] additional fetch options (e.g. headers) * @return {Promise<*>} the result */ - async getRemoteData(endpoint) { - return getRemoteData(endpoint, { signal: this._abortController.signal }); + async getRemoteData(endpoint, options) { + return getRemoteData(endpoint, { ...options, signal: this._abortController.signal }); } } diff --git a/lib/public/utilities/fetch/getRemoteData.js b/lib/public/utilities/fetch/getRemoteData.js index f29e021544..bb2e21e57c 100644 --- a/lib/public/utilities/fetch/getRemoteData.js +++ b/lib/public/utilities/fetch/getRemoteData.js @@ -19,6 +19,7 @@ import { jsonFetch } from './jsonFetch.js'; * @param {string} endpoint the remote endpoint to send request to * @param {Object} options eventual options for the request * @param {AbortSignal} [options.signal] optionally, an abort signal to abort the request + * @param {Object} [options.headers] optionally, headers to include in the request * @return {Promise<*>} resolve with the result of the request or reject with the list of errors if any error occurred */ -export const getRemoteData = (endpoint, { signal } = {}) => jsonFetch(endpoint, { method: 'GET', signal }); +export const getRemoteData = (endpoint, { signal, headers } = {}) => jsonFetch(endpoint, { method: 'GET', signal, headers }); diff --git a/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js b/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js new file mode 100644 index 0000000000..985292ab80 --- /dev/null +++ b/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js @@ -0,0 +1,142 @@ +/** + * @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 { + h, + iconWarning, + iconReload, + iconClock, + sessionService, +} from '/js/src/index.js'; +import { tooltip } from '../../../components/common/popover/tooltip.js'; +import { getQcSummaryDisplay } from './getQcSummaryDisplay.js'; +import { BkpRoles } from '../../../domain/enums/BkpRoles.js'; +import spinner from '../../../components/common/spinner.js'; +import { frontLink } from '../../../components/common/navigation/frontLink.js'; + +/** + * Render an invalidated status indicator with tooltip + * + * @return {Component} invalidated display + */ +const invalidatedDisplay = () => h( + '.d-inline-block.va-t-bottom', + tooltip(h( + '.f7', + { id: 'clock-icon' }, + h('span', { style: 'color: var(--color-orange)' }, iconClock()), + ), 'Summary is invalid. New summary will be calculated shortly. Please wait and refresh the page.'), +); + +/** + * Render a failure warning indicator with tooltip + * + * @return {Component} failure warning display + */ +const failureWarningDisplay = () => h( + '.d-inline-block.va-t-bottom', + tooltip(h('.f7', { id: 'warning-icon' }, iconWarning()), 'GAQ Summary failed, please click to view GAQ flags'), +); + +/** + * Render a recalculate button for GAQ summary + * + * @param {() => void} onRecalculate callback for recalculating GAQ summary + * @return {Component} reload button + */ +const recalculateButton = (onRecalculate) => h( + 'button.btn.btn-group-item.last-item', + { + title: 'Recalculate GAQ for this run', + onclick: (e) => { + e.preventDefault(); + onRecalculate(); + }, + }, + iconReload(), +); + +/** + * Render the GAQ display for a successfully loaded summary + * + * @param {RunGaqSummary} gaqSummary the GAQ summary data + * @param {boolean} isFirstInGroup whether this is the first button in a button group (affects styling) + * @return {Component} the display element + */ +const getSuccessGaqDisplay = (gaqSummary, isFirstInGroup) => { + const { undefinedQualityPeriodsCount, invalidatedAt } = gaqSummary; + + // No summary exists at all (no QC flags configured for GAQ detectors) + if (Object.keys(gaqSummary).length === 0) { + return h(`button.btn.btn-primary.w-100${isFirstInGroup ? '.first-item' : ''}`, [ + 'GAQ', + h( + '.d-inline-block.va-t-bottom', + tooltip( + h('.f7', { id: 'warning-icon' }, iconWarning()), + 'GAQ summary has not yet been calculated', + ), + ), + ]); + } + + if (undefinedQualityPeriodsCount === 0 && !invalidatedAt) { + return getQcSummaryDisplay(gaqSummary, isFirstInGroup ? { classes: 'first-item' } : {}); + } + + return h(`button.btn.btn-primary.w-100${isFirstInGroup ? '.first-item' : ''}`, [ + 'GAQ', + invalidatedAt ? invalidatedDisplay() : null, + ]); +}; + +/** + * Render a loading indicator for GAQ summary + * + * @return {Component} loading spinner with tooltip + */ +const loadingDisplay = () => tooltip( + h('.flex-row.items-center.justify-center.black', spinner({ size: 2, absolute: false })), + 'Loading GAQ summary...', +); + +/** + * Render display for GAQ summaries + * + * @param {RemoteData} remoteSummary the remote GAQ summary data + * @param {number} dataPassId the data pass ID + * @param {number} runNumber the run number to display GAQ for + * @param {() => void} onRecalculate callback for recalculating GAQ summary + * @return {Component} display + */ +export const getGAQSummaryDisplay = (remoteSummary, dataPassId, runNumber, onRecalculate) => { + const hasRecalculateAccess = sessionService.hasAccess([BkpRoles.DPG_ASYNC_QC_ADMIN]); + + const gaqFlagLink = (content) => frontLink(content, 'gaq-flags', { dataPassId, runNumber }, { style: 'flex: 1' }); + + return remoteSummary.match({ + Success: (gaqSummary) => h( + `.flex-row.w-100${hasRecalculateAccess ? '.btn-group' : ''}`, + [ + gaqFlagLink(getSuccessGaqDisplay(gaqSummary, hasRecalculateAccess)), + hasRecalculateAccess ? recalculateButton(onRecalculate) : null, + ], + ), + Loading: loadingDisplay, + NotAsked: loadingDisplay, + Failure: () => gaqFlagLink(h( + 'button.btn.btn-primary.w-100', + ['GAQ', failureWarningDisplay()], + )), + }); +}; diff --git a/lib/public/views/Runs/ActiveColumns/getQcSummaryDisplay.js b/lib/public/views/Runs/ActiveColumns/getQcSummaryDisplay.js index 60ea52b338..3678964965 100644 --- a/lib/public/views/Runs/ActiveColumns/getQcSummaryDisplay.js +++ b/lib/public/views/Runs/ActiveColumns/getQcSummaryDisplay.js @@ -15,6 +15,7 @@ import { h, iconWarning, iconBolt, + iconClock, PopoverAnchors, PopoverTriggerPreConfiguration, } from '/js/src/index.js'; @@ -31,15 +32,18 @@ const FULL_COVERAGE = 1; * Render display common for QC and GAQ summaries * * @param {RunDetectorQcSummary|RunGaqSummary} summary summary + * @param {Object} [options] display options + * @param {string} [options.classes] additional CSS classes to add to the display element * @return {Component} display */ -export const getQcSummaryDisplay = (summary) => { +export const getQcSummaryDisplay = (summary, { classes } = {}) => { const { badEffectiveRunCoverage, explicitlyNotBadEffectiveRunCoverage, missingVerificationsCount, mcReproducible, minifiedFlags = [], + invalidated = false, } = summary; const missingVerificationDisplay = missingVerificationsCount @@ -49,6 +53,13 @@ export const getQcSummaryDisplay = (summary) => { ) : null; + const invalidatedDisplay = invalidated + ? h( + '.d-inline-block.va-t-bottom', + tooltip(h('.f7', iconClock()), 'Summary is invalid. New summary will be calculated shortly. Please wait and refresh the page.'), + ) + : null; + const notBadPercentageFormat = h('span', formatFloat((1 - badEffectiveRunCoverage) * 100, { precision: 0 })); const nonMcReproducibleQcSummaryContent = [ @@ -65,7 +76,7 @@ export const getQcSummaryDisplay = (summary) => { * @return {Component} QC summary display */ const getQcSummaryDisplayWithColor = (content, { color }) => h( - '.btn.w-100', + `.btn.w-100${classes ? `.${classes}` : ''}`, { style: { backgroundColor: color, color: getContrastColor(color) } }, content, ); @@ -79,12 +90,16 @@ export const getQcSummaryDisplay = (summary) => { 'Run start or stop is missing and time-based flag was assigned, coverage cannot be calculated', )), missingVerificationDisplay, + invalidatedDisplay, ], { color: QcSummaryColors.INCALCULABLE_COVERAGE }, ); } else if (!badEffectiveRunCoverage) { qcSummaryDisplay = getQcSummaryDisplayWithColor( - nonMcReproducibleQcSummaryContent, + [ + ...nonMcReproducibleQcSummaryContent, + invalidatedDisplay, + ], { color: explicitlyNotBadEffectiveRunCoverage === FULL_COVERAGE ? QcSummaryColors.ALL_GOOD @@ -97,12 +112,17 @@ export const getQcSummaryDisplay = (summary) => { notBadPercentageFormat, h('em.d-inline-block.va-top.f7', 'MC.R'), missingVerificationDisplay, + invalidatedDisplay, + ], { color: QcSummaryColors.LIMITED_ACCEPTANCE_MC_REPRODUCIBLE }, ); } else { qcSummaryDisplay = getQcSummaryDisplayWithColor( - nonMcReproducibleQcSummaryContent, + [ + ...nonMcReproducibleQcSummaryContent, + invalidatedDisplay, + ], { color: badEffectiveRunCoverage === FULL_COVERAGE ? QcSummaryColors.ALL_BAD diff --git a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js index 5bceeeeeb4..363dedffd2 100644 --- a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js +++ b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js @@ -71,6 +71,9 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo this._freezeOrUnfreezeActionState$ = new ObservableData(RemoteData.notAsked()); this._freezeOrUnfreezeActionState$.bubbleTo(this); + this._recalculateGaqActionState$ = new ObservableData(RemoteData.notAsked()); + this._recalculateGaqActionState$.bubbleTo(this); + this._discardAllQcFlagsActionState$ = new ObservableData(RemoteData.notAsked()); this._discardAllQcFlagsActionState$.bubbleTo(this); @@ -200,6 +203,49 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo return this._freezeOrUnfreezeActionState$.getCurrent(); } + /** + * Recalculate GAQ summary for current data pass and optionally given run numbers + * + * @param {number[]} [runNumbers] optional run numbers to recalculate GAQ summary for + * if not provided, GAQ summary will be recalculated for all runs of the data pass + * @return {Promise} resolves once request is handled + */ + async recalculateGaqSummary(runNumbers) { + this._recalculateGaqActionState$.setCurrent(RemoteData.loading()); + try { + const queryParams = { dataPassId: this._dataPassId, onlyInvalidated: false }; + if (runNumbers?.length) { + queryParams.runNumbers = runNumbers.join(','); + } + await jsonFetch( + buildUrl('/api/qcFlags/summary/gaq/recalculate', queryParams), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, + ); + this._recalculateGaqActionState$.setCurrent(RemoteData.success(null)); + if (runNumbers?.length) { + for (const runNumber of runNumbers) { + this._fetchGaqSummary(runNumber); + } + } else { + this._fetchGaqSummaryForCurrentRuns(); + } + } catch (error) { + this._recalculateGaqActionState$.setCurrent(RemoteData.failure(error)); + } + } + + /** + * Return the state of the recalculate GAQ action + * + * @return {RemoteData} the recalculate GAQ action state + */ + get recalculateGaqActionState() { + return this._recalculateGaqActionState$.getCurrent(); + } + /** * Discard all the QC for the data-pass * @@ -323,9 +369,17 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo } } + /** + * When view is removed callback + * @return {void} + */ + dispose() { + this._abortGaqFetches(); + } + /** * Cancel all ongoing and future GAQ summary fetches - * @return {void} promise + * @return {void} */ _abortGaqFetches() { // Aborts the overall sequence fetch, i.e. stops further individual run fetches @@ -359,7 +413,7 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo mcReproducibleAsNotBad: this._mcReproducibleAsNotBad.isToggled, runNumber: runNumber, }); - await this._gaqSummarySources[runNumber].fetch(url); + await this._gaqSummarySources[runNumber].fetch(url, { headers: { 'Cache-Control': 'no-cache' } }); } /** @@ -379,10 +433,7 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo const runNumbers = runs.map((run) => run.runNumber); // Prepare GAQ summary object with NotAsked RemoteData state for all runs - let gaqSummary = {}; - for (const runNumber of runNumbers) { - gaqSummary = { ...gaqSummary, [runNumber]: RemoteData.notAsked() }; - } + const gaqSummary = Object.fromEntries(runNumbers.map((rn) => [rn, RemoteData.notAsked()])); this._gaqSummary$.setCurrent(gaqSummary); // Trigger GAQ summary fetch for each run @@ -407,7 +458,8 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo async _fetchSkimmableRuns() { this._skimmableRuns$.setCurrent(RemoteData.loading()); try { - const { data: skimmableRuns } = await getRemoteData(buildUrl('/api/dataPasses/skimming/runs', { dataPassId: this._dataPassId })); + const url = buildUrl('/api/dataPasses/skimming/runs', { dataPassId: this._dataPassId }); + const { data: skimmableRuns } = await getRemoteData(url, { headers: { 'Cache-Control': 'no-cache' } }); const runToReadyForSkimmingFlag = Object.fromEntries(skimmableRuns .map(({ runNumber, readyForSkimming }) => [runNumber, readyForSkimming])); this._skimmableRuns$.setCurrent(RemoteData.success(runToReadyForSkimmingFlag)); diff --git a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js index fd847389f5..605f343632 100644 --- a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js +++ b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js @@ -24,7 +24,6 @@ import { filtersPanelPopover } from '../../../components/Filters/common/filtersP import { qcSummaryLegendTooltip } from '../../../components/qcFlags/qcSummaryLegendTooltip.js'; import { isRunNotSubjectToQc } from '../../../components/qcFlags/isRunNotSubjectToQc.js'; import { frontLink } from '../../../components/common/navigation/frontLink.js'; -import { getQcSummaryDisplay } from '../ActiveColumns/getQcSummaryDisplay.js'; import errorAlert from '../../../components/common/errorAlert.js'; import { switchInput } from '../../../components/common/form/switchInput.js'; import { PdpBeamType } from '../../../domain/enums/PdpBeamType.js'; @@ -40,6 +39,7 @@ import { exportTriggerAndModal } from '../../../components/common/dataExport/exp import { textInputFilter } from '../../../components/Filters/common/filters/textInputFilter.js'; import { toggleFilter } from '../../../components/Filters/common/filters/toggleFilter.js'; import { warningComponent } from '../../../components/common/messages/warningComponent.js'; +import { getGAQSummaryDisplay } from '../ActiveColumns/getGAQSummaryDisplay.js'; const TABLEROW_HEIGHT = 59; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -113,6 +113,7 @@ export const RunsPerDataPassOverviewPage = ({ markAsSkimmableRequestResult, skimmableRuns: remoteSkimmableRuns, freezeOrUnfreezeActionState, + recalculateGaqActionState, discardAllQcFlagsActionState, } = perDataPassOverviewModel; @@ -143,38 +144,12 @@ export const RunsPerDataPassOverviewPage = ({ ), visible: true, - format: (_, { runNumber }) => { - const runGaqSummary = remoteGaqSummary[runNumber]; - const spinnerEl = h('.flex-row.items-center.justify-center.black', spinner({ size: 2, absolute: false })); - - return runGaqSummary.match({ - Success: (gaqSummary) => { - const gaqDisplay = - gaqSummary?.undefinedQualityPeriodsCount === 0 - ? getQcSummaryDisplay(gaqSummary) - : h('button.btn.btn-primary.w-100', 'GAQ'); - - return frontLink(gaqDisplay, 'gaq-flags', { dataPassId, runNumber }); - }, - Loading: () => tooltip(spinnerEl), - NotAsked: () => tooltip(spinnerEl), - Failure: () => - frontLink( - h('button.btn.btn-primary.w-100', [ - 'GAQ', - h( - '.d-inline-block.va-t-bottom', - tooltip( - h('.f7', iconWarning()), - 'GAQ Summary failed, please click to view GAQ flags', - ), - ), - ]), - 'gaq-flags', - { dataPassId, runNumber }, - ), - }); - }, + format: (_, { runNumber }) => getGAQSummaryDisplay( + remoteGaqSummary[runNumber], + dataPassId, + runNumber, + () => perDataPassOverviewModel.recalculateGaqSummary([runNumber]), + ), filter: ({ filteringModel }) => numericalComparisonFilter(filteringModel.get('gaq').notBadFraction, { step: 0.1, selectorPrefix: 'gaqNotBadFraction' }), filterTooltip: 'not-bad fraction expressed as a percentage', @@ -259,6 +234,25 @@ export const RunsPerDataPassOverviewPage = ({ }, ), sessionService.hasAccess([BkpRoles.DPG_ASYNC_QC_ADMIN]) && [ + h( + 'button.btn.btn-primary.w-100.h2', + { + ...recalculateGaqActionState.match({ + Loading: () => ({ + disabled: true, + title: 'Loading', + }), + Other: () => ({}), + }), + onclick: () => { + if (confirm('Recalculate the GAQ summary for the entire data pass?')) { + perDataPassOverviewModel.recalculateGaqSummary(); + } + }, + id: 'recalculate-gaq-summary-trigger', + }, + 'Recalculate GAQ summary', + ), h( 'button.btn.btn-danger', { @@ -314,6 +308,10 @@ export const RunsPerDataPassOverviewPage = ({ Failure: (errors) => errorAlert(errors), Other: () => null, }), + recalculateGaqActionState.match({ + Failure: (errors) => errorAlert(errors), + Other: () => null, + }), discardAllQcFlagsActionState.match({ Failure: (errors) => errorAlert(errors), Other: () => null, diff --git a/lib/server/controllers/qcFlag.controller.js b/lib/server/controllers/qcFlag.controller.js index 7a088a3eb3..535755a19c 100644 --- a/lib/server/controllers/qcFlag.controller.js +++ b/lib/server/controllers/qcFlag.controller.js @@ -21,8 +21,9 @@ const { PaginationDto } = require('../../domain/dtos'); const { ApiConfig } = require('../../config'); const { countedItemsToHttpView } = require('../utilities/countedItemsToHttpView'); const { qcFlagService } = require('../services/qualityControlFlag/QcFlagService.js'); -const { gaqService } = require('../services/qualityControlFlag/GaqService.js'); +const { gaqService } = require('../services/gaq/GaqService.js'); const { qcFlagSummaryService } = require('../services/qualityControlFlag/QcFlagSummaryService.js'); +const { validateRange, RANGE_INVALID } = require('../../utilities/rangeUtils.js'); const qcFlagFilterDTO = Joi.object({ createdBy: Joi.object({ @@ -384,7 +385,7 @@ const getGaqSummaryHandler = async (request, response) => { DtoFactory.queryOnly(Joi.object({ dataPassId: Joi.number().positive().required(), mcReproducibleAsNotBad: Joi.boolean().optional(), - runNumber: Joi.number().positive().required(), + runNumber: Joi.number().positive().optional(), })), request, response, @@ -394,6 +395,34 @@ const getGaqSummaryHandler = async (request, response) => { const { dataPassId, mcReproducibleAsNotBad = false, runNumber } = validatedDTO.query; const data = await gaqService.getSummary(dataPassId, { mcReproducibleAsNotBad, runNumber }); + response.json({ data: data ?? {} }); + } catch (error) { + updateExpressResponseFromNativeError(response, error); + } + } +}; + +/** + * Recalculate GAQ summary for given data pass and optionally run number + */ +const recalculateGaqSummaryHandler = async (request, response) => { + const validatedDTO = await dtoValidator( + DtoFactory.queryOnly(Joi.object({ + dataPassId: Joi.number().positive().required(), + runNumbers: Joi.string().trim().custom(validateRange).messages({ + [RANGE_INVALID]: '{{#message}}', + 'string.base': 'Run numbers must be comma-separated numbers or ranges (e.g. 12,15-18)', + }), + onlyInvalidated: Joi.boolean(), + })), + request, + response, + ); + if (validatedDTO) { + try { + const { dataPassId, runNumbers, onlyInvalidated } = validatedDTO.query; + + const data = await gaqService.recalculateSummaries(dataPassId, runNumbers, onlyInvalidated); response.json({ data }); } catch (error) { updateExpressResponseFromNativeError(response, error); @@ -413,4 +442,5 @@ exports.QcFlagController = { getQcFlagsSummaryHandler, getGaqQcFlagsHandler, getGaqSummaryHandler, + recalculateGaqSummaryHandler, }; diff --git a/lib/server/routers/qcFlag.router.js b/lib/server/routers/qcFlag.router.js index f97a565c86..dec896a56b 100644 --- a/lib/server/routers/qcFlag.router.js +++ b/lib/server/routers/qcFlag.router.js @@ -35,6 +35,14 @@ exports.qcFlagsRouter = { path: 'gaq', method: 'get', controller: QcFlagController.getGaqSummaryHandler, + + children: [ + { + path: 'recalculate', + method: 'post', + controller: [rbacMiddleware([BkpRoles.DPG_ASYNC_QC_ADMIN]), QcFlagController.recalculateGaqSummaryHandler], + }, + ], }, ], }, diff --git a/lib/server/services/gaq/GaqDetectorsService.js b/lib/server/services/gaq/GaqDetectorsService.js index 1ac2a0dc7e..4f6d466503 100644 --- a/lib/server/services/gaq/GaqDetectorsService.js +++ b/lib/server/services/gaq/GaqDetectorsService.js @@ -12,7 +12,7 @@ */ const { gaqDetectorAdapter, detectorAdapter } = require('../../../database/adapters'); -const { GaqDetectorRepository, RunRepository, DetectorRepository } = require('../../../database/repositories'); +const { GaqDetectorRepository, RunRepository, DetectorRepository, GaqSummaryRepository } = require('../../../database/repositories'); const { BadParameterError } = require('../../errors/BadParameterError'); const { dataSource } = require('../../../database/DataSource.js'); const { Op } = require('sequelize'); @@ -57,6 +57,9 @@ class GaqDetectorService { .flatMap((runNumber) => detectorIds .map((detectorId) => ({ dataPassId, runNumber, detectorId }))); const createdEntries = await GaqDetectorRepository.insertAll(gaqEntries); + + await GaqSummaryRepository.invalidateMany(runNumbers.map((runNumber) => ({ dataPassId, runNumber }))); + return createdEntries.map(gaqDetectorAdapter.toEntity); }); } @@ -101,6 +104,9 @@ class GaqDetectorService { .flatMap(({ runNumber, detectors }) => detectors .map(({ id: detectorId }) => ({ dataPassId, runNumber, detectorId }))); const createdEntries = await GaqDetectorRepository.insertAll(gaqEntries); + + await GaqSummaryRepository.invalidateMany(runNumbers.map((runNumber) => ({ dataPassId, runNumber }))); + return createdEntries.map(gaqDetectorAdapter.toEntity); }, { transaction }); } diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js new file mode 100644 index 0000000000..5917eead79 --- /dev/null +++ b/lib/server/services/gaq/GaqService.js @@ -0,0 +1,265 @@ +/** + * @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. + */ + +/** + * @typedef GaqFlags + * + * @property {number} from + * @property {number} to + * @property {QcFlag[]} contributingFlags + */ + +/** + * @typedef RunGaqSummary + * @property {number} badEffectiveRunCoverage - fraction of run's data, which aggregated quality is bad + * @property {number} explicitlyNotBadEffectiveRunCoverage - fraction of run's data, which aggregated quality is explicitly good + * @property {number} missingVerificationsCount - number of not verified QC flags which are not discarded + * @property {boolean} mcReproducible - states whether some aggregation of QC flags is Limited Acceptance MC Reproducible + * @property {number} undefinedQualityPeriodsCount - number of GAQ periods with no contributing QC flag + */ + +const { getOneDataPassOrFail } = require('../dataPasses/getOneDataPassOrFail.js'); +const { QcFlagRepository, GaqSummaryRepository, DataPassRunRepository } = require('../../../database/repositories/index.js'); +const { qcFlagAdapter } = require('../../../database/adapters/index.js'); +const { Op } = require('sequelize'); +const { QcSummarProperties } = require('../../../domain/enums/QcSummaryProperties.js'); +const { LogManager } = require('@aliceo2/web-ui'); +const { unpackNumberRange } = require('../../../utilities/rangeUtils.js'); +const { splitStringToStringsTrimmed } = require('../../../utilities/stringUtils.js'); + +/** + * Globally aggregated quality (QC flags aggregated for a predefined list of detectors per runs) service + */ +class GaqService { + /** + * Constructor + */ + constructor() { + this._logger = LogManager.getLogger('GAQ_SERVICE'); + } + + /** + * Get GAQ summary + * + * @param {number} dataPassId id of data pass id + * @param {object} [options] additional options + * @param {boolean} [options.mcReproducibleAsNotBad = false] if set to true, + * `Limited Acceptance MC Reproducible` flag type is treated as good one + * @param {number} [options.runNumber] Optional run number to filter by + * @return {Promise|RunGaqSummary|null>} when runNumber is given, the run's summary or + * null if no row exists; otherwise a map of runNumber to summary. Rows with no coverage values yet (notComputable, + * or invalidated without ever having been computed) are returned with all coverage fields set to null. + */ + async getSummary(dataPassId, { mcReproducibleAsNotBad = false, runNumber } = {}) { + await getOneDataPassOrFail({ id: dataPassId }); + + const where = { dataPassId }; + if (runNumber !== undefined) { + where.runNumber = runNumber; + const row = await GaqSummaryRepository.findOne({ where }); + return row ? this._formatSummary(row, mcReproducibleAsNotBad) : null; + } + + const rows = await GaqSummaryRepository.findAll({ where }); + return Object.fromEntries(rows.map((row) => [row.runNumber, this._formatSummary(row, mcReproducibleAsNotBad)])); + } + + /** + * Format a raw GAQ summary record into its API representation. Rows without coverage values (notComputable, or + * invalidated before any compute) return null coverage fields so callers can distinguish "no data" from real 0%. + * @param {object} summary raw summary from the database + * @param {boolean} mcReproducibleAsNotBad whether MC reproducible coverage counts as not-bad + * @return {RunGaqSummary} formatted summary + */ + _formatSummary(summary, mcReproducibleAsNotBad) { + if (summary.badRunCoverage === null) { + return { + [QcSummarProperties.BAD_EFFECTIVE_RUN_COVERAGE]: null, + [QcSummarProperties.EXPLICITELY_NOT_BAD_EFFECTIVE_RUN_COVERAGE]: null, + [QcSummarProperties.MC_REPRODUCIBLE]: false, + [QcSummarProperties.MISSING_VERIFICATIONS]: null, + [QcSummarProperties.UNDEFINED_QUALITY_PERIODS_COUNT]: null, + [QcSummarProperties.NOT_COMPUTABLE]: summary.notComputable, + [QcSummarProperties.INVALIDATED_AT]: summary.invalidatedAt, + }; + } + return { + [QcSummarProperties.BAD_EFFECTIVE_RUN_COVERAGE]: + summary.badRunCoverage + (mcReproducibleAsNotBad ? 0 : summary.mcReproducibleCoverage), + [QcSummarProperties.EXPLICITELY_NOT_BAD_EFFECTIVE_RUN_COVERAGE]: + summary.explicitlyNotBadRunCoverage + (mcReproducibleAsNotBad ? summary.mcReproducibleCoverage : 0), + [QcSummarProperties.MC_REPRODUCIBLE]: summary.mcReproducibleCoverage > 0, + [QcSummarProperties.MISSING_VERIFICATIONS]: summary.missingVerificationsCount, + [QcSummarProperties.UNDEFINED_QUALITY_PERIODS_COUNT]: summary.undefinedQualityPeriodsCount, + [QcSummarProperties.NOT_COMPUTABLE]: summary.notComputable, + [QcSummarProperties.INVALIDATED_AT]: summary.invalidatedAt, + }; + } + + /** + * Find GAQ summary for given data pass and run. Returns null if no summary can be computed (e.g. no QC flags in GAQ periods) + * @param {number} dataPassId id of data pass + * @param {number} runNumber run number + * @return {Promise} promise of GAQ summary or null if it can't be computed + */ + async _computeSummary(dataPassId, runNumber) { + const gaqCoverages = await QcFlagRepository.getGaqCoverages(dataPassId, runNumber); + const entry = gaqCoverages[runNumber]; + if (!entry) { + return null; + } + + const { + badCoverage, + mcReproducibleCoverage, + goodCoverage, + flagsIds, + verifiedFlagsIds, + undefinedQualityPeriodsCount, + } = entry; + + return { + badRunCoverage: badCoverage, + explicitlyNotBadRunCoverage: goodCoverage, + mcReproducibleCoverage, + missingVerificationsCount: flagsIds.length - verifiedFlagsIds.length, + undefinedQualityPeriodsCount, + }; + } + + /** + * Find QC flags in GAQ effective periods for given data pass and run + * + * @param {number} dataPassId id of data pass + * @param {number} runNumber run number + * @return {Promise} promise of aggregated QC flags + */ + async getFlagsForDataPassAndRun(dataPassId, runNumber) { + const gaqPeriods = await QcFlagRepository.findGaqPeriods(dataPassId, runNumber); + const qcFlags = (await QcFlagRepository.findAll({ + where: { id: { [Op.in]: gaqPeriods.flatMap(({ contributingFlagIds }) => contributingFlagIds) } }, + include: [ + { association: 'flagType' }, + { association: 'createdBy' }, + { association: 'verifications', include: [{ association: 'createdBy' }] }, + ], + })).map(qcFlagAdapter.toEntity); + + const idToFlag = Object.fromEntries(qcFlags.map((flag) => [flag.id, flag])); + + return gaqPeriods.map(({ + contributingFlagIds, + from, + to, + }) => ({ + from, + to, + contributingFlags: contributingFlagIds.map((id) => idToFlag[id]), + })); + } + + /** + * Calculate and store GAQ summary for given data pass and run + * @param {number} dataPassId id of data pass + * @param {number} runNumber run number + * @param {object} [options] additional options + * @param {Date} [options.expectedInvalidatedAt] if provided, invalidatedAt will only be cleared if it is equal to the provided value + * @return {Promise} promise + */ + async calculateAndStoreGaqSummary(dataPassId, runNumber, { expectedInvalidatedAt } = {}) { + const summary = await this._computeSummary(dataPassId, runNumber); + + const fields = { + dataPassId, + runNumber, + badRunCoverage: summary?.badRunCoverage ?? null, + explicitlyNotBadRunCoverage: summary?.explicitlyNotBadRunCoverage ?? null, + mcReproducibleCoverage: summary?.mcReproducibleCoverage ?? null, + missingVerificationsCount: summary?.missingVerificationsCount ?? null, + undefinedQualityPeriodsCount: summary?.undefinedQualityPeriodsCount ?? null, + notComputable: summary === null, + }; + + if (expectedInvalidatedAt === undefined) { + // No expected invalidation time provided, just upsert the summary + await GaqSummaryRepository.upsert({ dataPassId, runNumber, ...fields, invalidatedAt: null }); + return; + }; + + // Only clear invalidatedAt if it hasn't been changed during compute + const [rows] = await GaqSummaryRepository.updateAll( + { ...fields, invalidatedAt: null }, + { where: { dataPassId, runNumber, invalidatedAt: expectedInvalidatedAt } }, + ); + + if (rows === 0) { + // Write fresh summary fields but leave invalidatedAt unchanged + await GaqSummaryRepository.updateAll( + { ...fields }, + { where: { dataPassId, runNumber } }, + ); + } + } + + /** + * Remove invalid GAQ summaries and recalculate them + * @param {number} batchSize maximum number of invalid summaries to process + * @return {Promise} promise + */ + async popNInvalidSummaryAndRecalculate(batchSize = 1) { + const { rows, count } = await GaqSummaryRepository.findAndCountAll({ + where: { invalidatedAt: { [Op.not]: null } }, + order: [['invalidatedAt', 'ASC']], + limit: batchSize, + }); + + await Promise.all(rows.map(({ dataPassId, runNumber, invalidatedAt }) => + this.calculateAndStoreGaqSummary(dataPassId, runNumber, { expectedInvalidatedAt: invalidatedAt }))); + + return { processedCount: rows.length, totalInvalidCount: count }; + } + + /** + * Recalculate summaries for given data pass and runs + * @param {number} dataPassId data pass id + * @param {number[]} [runNumbers] optional list of run numbers to recalculate. If not set, all runs will be considered + * @return {Promise} number of recalculated summaries + */ + async recalculateSummaries(dataPassId, runNumbers) { + const where = { dataPassId }; + let finalRunNumberList; + + if (runNumbers) { + const runNumberCriteria = splitStringToStringsTrimmed(runNumbers, ','); + finalRunNumberList = Array.from(unpackNumberRange(runNumberCriteria)); + where.runNumber = { [Op.in]: finalRunNumberList }; + } + + const summariesToRecalculate = await DataPassRunRepository.findAll({ + where, + attributes: ['dataPassId', 'runNumber'], + }); + + const now = new Date(); + await GaqSummaryRepository.insertAll( + summariesToRecalculate.map(({ dataPassId, runNumber }) => ({ dataPassId, runNumber, invalidatedAt: now })), + { updateOnDuplicate: ['invalidatedAt'] }, + ); + + return { summariesToRecalculate: summariesToRecalculate.length }; + } +} + +exports.GaqService = GaqService; + +exports.gaqService = new GaqService(); diff --git a/lib/server/services/gaq/GaqWorker.js b/lib/server/services/gaq/GaqWorker.js new file mode 100644 index 0000000000..949f1a9a2f --- /dev/null +++ b/lib/server/services/gaq/GaqWorker.js @@ -0,0 +1,136 @@ +const { gaqService } = require('../../services/gaq/GaqService.js'); +const { LogManager } = require('@aliceo2/web-ui'); + +// Tolerate brief blips before warning so transient overshoots don't spam logs +const OVERFLOW_THRESHOLD_TICKS = 5; + +// While the overflow persists, re-warn at this cadence so operators see "still bad" without log floods +const OVERFLOW_REMINDER_EVERY_TICKS = 30; + +/** + * Worker responsible for processing pending GAQ summary invalidations + */ +class GaqWorker { + /** + * Constructor + */ + constructor() { + this._logger = LogManager.getLogger(GaqWorker.name); + this._isPaused = false; + this._currentRun = null; + + // Adaptive batch size for the next tick. Null on first tick → falls back to the passed-in min. + this._nextBatchSize = null; + + this._overflowConsecutiveTicks = 0; + } + + /** + * Whether the worker is currently paused + * @return {boolean} true if paused, false otherwise + */ + get isPaused() { + return this._isPaused; + } + + /** + * Pause the worker so it skips future scheduled calls, and await any in-flight call to finish + * so callers can safely mutate shared state (e.g. drop tables in tests) once this resolves + * @return {Promise} resolves once the worker is idle and paused + */ + async pause() { + if (!this._isPaused) { + this._logger.infoMessage('Worker paused'); + } + this._isPaused = true; + if (this._currentRun) { + try { + await this._currentRun; + } catch { + // Already logged inside _doRecalculate + } + } + } + + /** + * Resume the worker after a pause + * @return {void} + */ + resume() { + if (this._isPaused) { + this._logger.infoMessage('Worker resumed'); + } + this._isPaused = false; + } + + /** + * Process pending GAQ summary invalidations. Skips if a previous call is still in progress or if paused. + * The batch size for this tick is clamped between min and max and adapts to the observed backlog. + * @param {number} minBatchSize lower bound on rows to fetch per tick + * @param {number} maxBatchSize upper bound on rows to fetch per tick + * @return {Promise} promise + */ + async recalculateGaqSummaries(minBatchSize, maxBatchSize) { + if (this._isPaused || this._currentRun) { + return; + } + this._currentRun = this._doRecalculate(minBatchSize, maxBatchSize); + try { + await this._currentRun; + } finally { + this._currentRun = null; + } + } + + /** + * Run a single recalculation pass; errors are logged but not rethrown + * @param {number} minBatchSize lower bound on rows to fetch per tick + * @param {number} maxBatchSize upper bound on rows to fetch per tick + * @return {Promise} promise + */ + async _doRecalculate(minBatchSize, maxBatchSize) { + const clamp = (n) => Math.min(maxBatchSize, Math.max(minBatchSize, n)); + const batchSize = clamp(this._nextBatchSize ?? minBatchSize); + + try { + const start = Date.now(); + const { processedCount, totalInvalidCount } = await gaqService.popNInvalidSummaryAndRecalculate(batchSize); + + // Adapt next tick's batch size to the observed backlog (clamped to the bounds) + this._nextBatchSize = clamp(totalInvalidCount); + + if (processedCount > 0) { + this._logger.infoMessage(`Processed ${processedCount} out of ${totalInvalidCount} ` + + `invalidated GAQ summaries (batch size: ${batchSize}) in ${Date.now() - start}ms`); + } + + // Overflow: backlog still exceeds the max even at the largest batch we'll fetch + if (totalInvalidCount > maxBatchSize) { + this._overflowConsecutiveTicks += 1; + + const ticks = this._overflowConsecutiveTicks; + const firstWarning = ticks === OVERFLOW_THRESHOLD_TICKS; + const reminderDue = ticks > OVERFLOW_THRESHOLD_TICKS + && (ticks - OVERFLOW_THRESHOLD_TICKS) % OVERFLOW_REMINDER_EVERY_TICKS === 0; + + if (firstWarning || reminderDue) { + this._logger.warnMessage(`Invalidated GAQ summary backlog (${totalInvalidCount}) has exceeded ` + + `the max batch size (${maxBatchSize}) for ${ticks} consecutive ticks. ` + + 'Consider raising GAQ_RECALCULATION_MAX_BATCH_SIZE or shortening GAQ_RECALCULATION_PERIOD.'); + } + } else { + if (this._overflowConsecutiveTicks >= OVERFLOW_THRESHOLD_TICKS) { + this._logger.infoMessage(`GAQ summary backlog recovered after ${this._overflowConsecutiveTicks} ` + + `consecutive overflow ticks (current backlog: ${totalInvalidCount})`); + } + this._overflowConsecutiveTicks = 0; + } + } catch (error) { + this._logger.errorMessage(`Error recalculating GAQ summaries: ${error.message}\n${error.stack}`); + } + } +} + +const gaqWorker = new GaqWorker(); + +exports.gaqWorker = gaqWorker; diff --git a/lib/server/services/qualityControlFlag/GaqService.js b/lib/server/services/qualityControlFlag/GaqService.js deleted file mode 100644 index 422e48bcac..0000000000 --- a/lib/server/services/qualityControlFlag/GaqService.js +++ /dev/null @@ -1,120 +0,0 @@ -/** - * @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. - */ - -/** - * @typedef GaqFlags - * - * @property {number} from - * @property {number} to - * @property {QcFlag[]} contributingFlags - */ - -/** - * @typedef RunGaqSummary - * @property {number} badEffectiveRunCoverage - fraction of run's data, which aggregated quality is bad - * @property {number} explicitlyNotBadEffectiveRunCoverage - fraction of run's data, which aggregated quality is explicitly good - * @property {number} missingVerificationsCount - number of not verified QC flags which are not discarded - * @property {boolean} mcReproducible - states whether some aggregation of QC flags is Limited Acceptance MC Reproducible - */ - -const { getOneDataPassOrFail } = require('../dataPasses/getOneDataPassOrFail.js'); -const { QcFlagRepository } = require('../../../database/repositories/index.js'); -const { qcFlagAdapter } = require('../../../database/adapters/index.js'); -const { Op } = require('sequelize'); -const { QcSummarProperties } = require('../../../domain/enums/QcSummaryProperties.js'); - -/** - * Globally aggregated quality (QC flags aggregated for a predefined list of detectors per runs) service - */ -class GaqService { - /** - * Get GAQ summary - * - * @param {number} dataPassId id of data pass id - * @param {object} [options] additional options - * @param {boolean} [options.mcReproducibleAsNotBad = false] if set to true, - * `Limited Acceptance MC Reproducible` flag type is treated as good one - * @param {number} [options.runNumber] Optional run number to filter by - * @return {Promise} Resolves with the GAQ Summary - */ - async getSummary(dataPassId, { mcReproducibleAsNotBad = false, runNumber } = {}) { - await getOneDataPassOrFail({ id: dataPassId }); - const gaqCoverages = await QcFlagRepository.getGaqCoverages(dataPassId, runNumber); - const gaqSummary = Object.entries(gaqCoverages).map(([ - runNumberMapped, - { - badCoverage, - mcReproducibleCoverage, - goodCoverage, - flagsIds, - verifiedFlagsIds, - undefinedQualityPeriodsCount, - }, - ]) => [ - runNumberMapped, - { - [QcSummarProperties.BAD_EFFECTIVE_RUN_COVERAGE]: badCoverage + (mcReproducibleAsNotBad ? 0 : mcReproducibleCoverage), - [QcSummarProperties.EXPLICITELY_NOT_BAD_EFFECTIVE_RUN_COVERAGE]: - goodCoverage + (mcReproducibleAsNotBad ? mcReproducibleCoverage : 0), - [QcSummarProperties.MC_REPRODUCIBLE]: mcReproducibleCoverage > 0, - [QcSummarProperties.MISSING_VERIFICATIONS]: flagsIds.length - verifiedFlagsIds.length, - [QcSummarProperties.UNDEFINED_QUALITY_PERIODS_COUNT]: undefinedQualityPeriodsCount, - }, - ]); - - /** - * If runNumber is specified, only one summary is returned but the getGaqCoverages - * returns still with runNumber as key, so we extract the single value from the array. - */ - if (runNumber && gaqSummary.length === 1) { - return Object.fromEntries(gaqSummary)[runNumber]; - } - - return Object.fromEntries(gaqSummary); - } - - /** - * Find QC flags in GAQ effective periods for given data pass and run - * - * @param {number} dataPassId id od data pass - * @param {number} runNumber run number - * @return {Promise} promise of aggregated QC flags - */ - async getFlagsForDataPassAndRun(dataPassId, runNumber) { - const gaqPeriods = await QcFlagRepository.findGaqPeriods(dataPassId, runNumber); - const qcFlags = (await QcFlagRepository.findAll({ - where: { id: { [Op.in]: gaqPeriods.flatMap(({ contributingFlagIds }) => contributingFlagIds) } }, - include: [ - { association: 'flagType' }, - { association: 'createdBy' }, - { association: 'verifications', include: [{ association: 'createdBy' }] }, - ], - })).map(qcFlagAdapter.toEntity); - - const idToFlag = Object.fromEntries(qcFlags.map((flag) => [flag.id, flag])); - - return gaqPeriods.map(({ - contributingFlagIds, - from, - to, - }) => ({ - from, - to, - contributingFlags: contributingFlagIds.map((id) => idToFlag[id]), - })); - } -} - -exports.GaqService = GaqService; - -exports.gaqService = new GaqService(); diff --git a/lib/server/services/qualityControlFlag/QcFlagService.js b/lib/server/services/qualityControlFlag/QcFlagService.js index 3d34e1446b..e389375453 100644 --- a/lib/server/services/qualityControlFlag/QcFlagService.js +++ b/lib/server/services/qualityControlFlag/QcFlagService.js @@ -20,6 +20,7 @@ const { RunRepository, QcFlagVerificationRepository, QcFlagEffectivePeriodRepository, + GaqSummaryRepository, }, } = require('../../../database/index.js'); const { dataSource } = require('../../../database/DataSource.js'); @@ -209,6 +210,14 @@ class QcFlagService { ], }); + /** + * Invalidate GAQ summary for the dataPass and runNumber of the created flag. + * Skip when `verify` is true: verifyFlag() above already invalidated the same (dataPassId, runNumber). + */ + if (dataPass && !verify) { + await GaqSummaryRepository.invalidate(dataPass.id, runNumber); + } + createdFlags.push(qcFlagAdapter.toEntity(createdFlag)); } catch (error) { this._logger.warnMessage(`Failed to create QC flag with properties: ${JSON.stringify(qcFlag)}. Error: ${error}`); @@ -284,6 +293,12 @@ class QcFlagService { { const { id, from, to, origin, createdById, runNumber, dplDetectorId, flagTypeId, createdAt } = qcFlag; + + if (dataPassId) { + // Invalidate GAQ summary for the dataPass and runNumber of the deleted flag + await GaqSummaryRepository.invalidate(dataPassId, runNumber); + } + const qcFlagPropertiesToLog = { id, from, @@ -338,8 +353,9 @@ class QcFlagService { await QcFlagEffectivePeriodRepository.removeAll({ where: { id: { [Op.in]: effectivePeriodIds } } }); // Sequelize update requires a where and can't work only using association - const qcFlagIds = (await QcFlagRepository.findAll({ - attributes: ['id'], + const qcFlagIdsToRunNumbers = (await QcFlagRepository.findAll({ + attributes: ['id', 'runNumber'], + where: { deleted: false }, include: { association: 'dataPasses', attributes: [], @@ -349,13 +365,19 @@ class QcFlagService { }, }, raw: true, - })).map(({ id }) => id); + })).map(({ id, runNumber }) => ({ id, runNumber })); + + const qcFlagIds = qcFlagIdsToRunNumbers.map(({ id }) => id); + const runNumbers = new Set(qcFlagIdsToRunNumbers.map(({ runNumber }) => runNumber)); await QcFlagRepository.updateAll( { deleted: true }, { where: { id: qcFlagIds } }, ); + // Invalidate GAQ summary for the dataPass and all runNumbers of the deleted flags + await GaqSummaryRepository.invalidateMany(Array.from(runNumbers, (runNumber) => ({ dataPassId, runNumber }))); + return qcFlagIds.length; }); } @@ -389,7 +411,18 @@ class QcFlagService { createdById: user.id, }); - return await this.getOneOrFail(flagId); + const updatedQcFlag = await this.getOneOrFail(flagId); + + // Get data pass id for the flag (if exists) to invalidate only related GAQ summaries + const dataPassQcFlag = await DataPassQcFlagRepository.findOne({ where: { qualityControlFlagId: flagId } }); + const dataPassId = dataPassQcFlag?.dataPassId; + + // Invalidate GAQ summary if it's the first verification + if (dataPassId && (!qcFlag.verifications || qcFlag.verifications.length === 0)) { + await GaqSummaryRepository.invalidate(dataPassId, updatedQcFlag.runNumber); + } + + return updatedQcFlag; }, { transaction }); } diff --git a/lib/server/services/run/updateRun.js b/lib/server/services/run/updateRun.js index 2e8b50048c..f4b9f0e69a 100644 --- a/lib/server/services/run/updateRun.js +++ b/lib/server/services/run/updateRun.js @@ -12,6 +12,8 @@ */ const RunRepository = require('../../../database/repositories/RunRepository.js'); +const GaqSummaryRepository = require('../../../database/repositories/GaqSummaryRepository.js'); +const DataPassRunRepository = require('../../../database/repositories/DataPassRunRepository.js'); const { getRunOrFail } = require('./getRunOrFail.js'); const { utilities: { TransactionHelper } } = require('../../../database'); const { checkLhcFill } = require('../../../usecases/lhcFill/checkLhcFill.js'); @@ -35,6 +37,20 @@ const { logSpecificRunTag } = require('./logEntriesCreation/logSpecificRunTag.js */ const TAGS_TO_LOG = ['Not for physics']; +/** + * Run patch fields whose values feed the qc_time_start / qc_time_end virtual columns + * + * @type {string[]} + */ +const QC_TIME_SOURCE_FIELDS = [ + 'firstTfTimestamp', + 'lastTfTimestamp', + 'timeTrgStart', + 'timeTrgEnd', + 'timeO2Start', + 'timeO2End', +]; + /** * Update the given run * @@ -82,7 +98,12 @@ exports.updateRun = async (identifier, payload, transaction) => { } // Store the run quality to create a log if it changed - const previousRun = { runQuality: runModel?.runQuality, calibrationStatus: runModel?.calibrationStatus }; + const previousRun = { + runQuality: runModel?.runQuality, + calibrationStatus: runModel?.calibrationStatus, + qcTimeStart: runModel?.qcTimeStart, + qcTimeEnd: runModel?.qcTimeEnd, + }; runPatch.definition = runPatch.definition ?? getRunDefinition({ ...runModel.dataValues, @@ -204,6 +225,20 @@ exports.updateRun = async (identifier, payload, transaction) => { } } + // Only check for invalidation when the patch could have changed them + if (QC_TIME_SOURCE_FIELDS.some((field) => field in runPatch)) { + // Reload because qcTimeStart/qcTimeEnd are virtual columns not in the patch and stay stale after update + await runModel.reload(); + if (previousRun.qcTimeStart?.getTime() !== runModel.qcTimeStart?.getTime() + || previousRun.qcTimeEnd?.getTime() !== runModel.qcTimeEnd?.getTime()) { + const dataPassRuns = await DataPassRunRepository.findAll({ + attributes: ['dataPassId'], + where: { runNumber: runModel.runNumber }, + }); + await GaqSummaryRepository.invalidateMany(dataPassRuns.map(({ dataPassId }) => ({ dataPassId, runNumber: runModel.runNumber }))); + } + } + return runModel; }, { transaction }); }; diff --git a/lib/usecases/run/GetAllRunsUseCase.js b/lib/usecases/run/GetAllRunsUseCase.js index ae0b14d071..8db461be2b 100644 --- a/lib/usecases/run/GetAllRunsUseCase.js +++ b/lib/usecases/run/GetAllRunsUseCase.js @@ -20,7 +20,7 @@ const sequelize = require('sequelize'); const { EorReasonRepository } = require('../../database/repositories'); const { PhysicalConstant } = require('../../domain/enums/PhysicalConstant'); const { BadParameterError } = require('../../server/errors/BadParameterError'); -const { gaqService } = require('../../server/services/qualityControlFlag/GaqService.js'); +const { gaqService } = require('../../server/services/gaq/GaqService.js'); const { qcFlagSummaryService } = require('../../server/services/qualityControlFlag/QcFlagSummaryService.js'); const { DetectorType } = require('../../domain/enums/DetectorTypes.js'); const { unpackNumberRange } = require('../../utilities/rangeUtils.js'); diff --git a/test/api/gaqSummary.test.js b/test/api/gaqSummary.test.js new file mode 100644 index 0000000000..b6479522e8 --- /dev/null +++ b/test/api/gaqSummary.test.js @@ -0,0 +1,240 @@ +/** + * @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. + */ + +const { expect } = require('chai'); +const request = require('supertest'); +const { server } = require('../../lib/application'); +const { resetDatabaseContent } = require('../utilities/resetDatabaseContent.js'); +const { BkpRoles } = require('../../lib/domain/enums/BkpRoles'); +const { repositories: { GaqSummaryRepository } } = require('../../lib/database'); +const { Op } = require('sequelize'); + +/** + * Check whether an invalidation entry exists for a given data pass and run + * + * @param {number} expectedDataPassId + * @param {number} expectedRunNumber + * @param {boolean} toBePresent whether the invalidation is expected to be present + * + * @return {Promise} + */ +const expectInvalidation = async (expectedDataPassId, expectedRunNumber, toBePresent = true) => { + const invalidation = await GaqSummaryRepository.findOne({ + where: { dataPassId: expectedDataPassId, runNumber: expectedRunNumber, invalidatedAt: { [Op.not]: null } }, + }); + if (!toBePresent) { + expect(invalidation, `Expected no invalidation for dataPassId=${expectedDataPassId} runNumber=${expectedRunNumber}`).to.be.null; + } else { + expect(invalidation, `Expected invalidation for dataPassId=${expectedDataPassId} runNumber=${expectedRunNumber}`).to.not.be.null; + } +}; + +module.exports = () => { + before(resetDatabaseContent); + + describe('POST /api/qcFlags/summary/gaq/recalculate', () => { + it('should fail to recalculate the summaries because of insufficient permission', async () => { + const response = await request(server).post(`/api/qcFlags/summary/gaq/recalculate?dataPassId=1&token=${BkpRoles.GUEST}`); + + expect(response.status).to.equal(403); + const { errors } = response.body; + expect(errors.find(({ title }) => title === 'Access denied')).to.not.be.null; + }); + + it('should return 400 when dataPassId parameter is missing', async () => { + const response = await request(server) + .post(`/api/qcFlags/summary/gaq/recalculate?runNumbers=107&token=${BkpRoles.DPG_ASYNC_QC_ADMIN}`); + + expect(response.status).to.equal(400); + const { errors } = response.body; + const dataPassIdError = errors.find((error) => error.source.pointer === '/data/attributes/query/dataPassId'); + expect(dataPassIdError.detail).to.equal('"query.dataPassId" is required'); + }); + + it('should return 400 if dataPassId is not positive', async () => { + const response = await request(server) + .post(`/api/qcFlags/summary/gaq/recalculate?dataPassId=-1&token=${BkpRoles.DPG_ASYNC_QC_ADMIN}`); + + expect(response.status).to.equal(400); + expect(response.body.errors[0].detail).to.equal('"query.dataPassId" must be a positive number'); + }); + + it('should return 400 if dataPassId is not a number', async () => { + const response = await request(server) + .post(`/api/qcFlags/summary/gaq/recalculate?dataPassId=abc&token=${BkpRoles.DPG_ASYNC_QC_ADMIN}`); + + expect(response.status).to.equal(400); + expect(response.body.errors[0].detail).to.equal('"query.dataPassId" must be a number'); + }); + + it('should return 200 when runNumbers is a comma-separated list', async () => { + const response = await request(server) + .post(`/api/qcFlags/summary/gaq/recalculate?dataPassId=1&runNumbers=106,107&token=${BkpRoles.DPG_ASYNC_QC_ADMIN}`); + + expect(response.status).to.equal(200); + expect(response.body.data).to.deep.equal({ summariesToRecalculate: 2 }); + }); + + it('should return 400 if runNumbers contains a negative value', async () => { + const response = await request(server) + .post(`/api/qcFlags/summary/gaq/recalculate?dataPassId=1&runNumbers=-1&token=${BkpRoles.DPG_ASYNC_QC_ADMIN}`); + + expect(response.status).to.equal(400); + expect(response.body.errors[0].detail).to.equal('Invalid range: -1'); + }); + + it('should return 400 if runNumbers contains an invalid range (start > end)', async () => { + const response = await request(server) + .post(`/api/qcFlags/summary/gaq/recalculate?dataPassId=1&runNumbers=200-100&token=${BkpRoles.DPG_ASYNC_QC_ADMIN}`); + + expect(response.status).to.equal(400); + expect(response.body.errors[0].detail).to.equal('Invalid range: 200-100'); + }); + + it('should return 400 if runNumbers contains a range exceeding the max size of 100', async () => { + const response = await request(server) + .post(`/api/qcFlags/summary/gaq/recalculate?dataPassId=1&runNumbers=1-200&token=${BkpRoles.DPG_ASYNC_QC_ADMIN}`); + + expect(response.status).to.equal(400); + expect(response.body.errors[0].detail).to.equal('Given range exceeds max size of 100 range: 1-200'); + }); + + it('should return 200 with the number of summaries that will be recalculated', async () => { + const response = await request(server).post(`/api/qcFlags/summary/gaq/recalculate?dataPassId=1&runNumbers=107&token=${BkpRoles.DPG_ASYNC_QC_ADMIN}`); + + expect(response.status).to.equal(200); + const { data } = response.body; + expect(data).to.deep.equal({ summariesToRecalculate: 1 }); + + await expectInvalidation(1, 107, true); + }); + }); + + describe('GET /api/qcFlags/summary/gaq', () => { + + before(async () => { + await resetDatabaseContent(); + + await GaqSummaryRepository.upsert({ + dataPassId: 1, + runNumber: 107, + badRunCoverage: 0, + explicitlyNotBadRunCoverage: 0.759654, + mcReproducibleCoverage: 0.240346, + missingVerificationsCount: 3, + undefinedQualityPeriodsCount: 0, + notComputable: false, + invalidatedAt: null, + }); + }); + + it('should return 200 with the correct GAQ summary with mcReproducibleAsNotBad=false', async () => { + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=1&mcReproducibleAsNotBad=false&runNumber=107'); + + expect(response.status).to.equal(200); + const { data } = response.body; + expect(data).to.deep.equal({ + badEffectiveRunCoverage: 0.240346, + explicitlyNotBadEffectiveRunCoverage: 0.759654, + mcReproducible: true, + missingVerificationsCount: 3, + undefinedQualityPeriodsCount: 0, + notComputable: false, + invalidatedAt: null, + }); + }); + + it('should return 200 with the correct GAQ summary with mcReproducibleAsNotBad=true', async () => { + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=1&mcReproducibleAsNotBad=true&runNumber=107'); + + expect(response.status).to.equal(200); + const { data } = response.body; + expect(data).to.deep.equal({ + badEffectiveRunCoverage: 0, + explicitlyNotBadEffectiveRunCoverage: 1, + mcReproducible: true, + missingVerificationsCount: 3, + undefinedQualityPeriodsCount: 0, + notComputable: false, + invalidatedAt: null, + }); + }); + + it('should return 200 with a calculated summary that is invalidated', async () => { + await GaqSummaryRepository.upsert({ dataPassId: 1, runNumber: 107, invalidatedAt: new Date() }); + + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=1&mcReproducibleAsNotBad=false&runNumber=107'); + + expect(response.status).to.equal(200); + const { data } = response.body; + expect(data.invalidatedAt).to.not.be.null; + delete data.invalidatedAt; + expect(data).to.deep.equal({ + badEffectiveRunCoverage: 0.240346, + explicitlyNotBadEffectiveRunCoverage: 0.759654, + mcReproducible: true, + missingVerificationsCount: 3, + undefinedQualityPeriodsCount: 0, + notComputable: false, + }); + }); + + it('should return 200 with a not-calculated summary that is invalidated', async () => { + await GaqSummaryRepository.upsert({ dataPassId: 1, runNumber: 106, invalidatedAt: new Date() }); + + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=1&mcReproducibleAsNotBad=false&runNumber=106'); + + expect(response.status).to.equal(200); + const { data } = response.body; + expect(data.invalidatedAt).to.not.be.null; + }); + + it('should return 400 if dataPassId is not positive', async () => { + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=-1&runNumber=107'); + + expect(response.status).to.equal(400); + expect(response.body.errors[0].detail).to.equal('"query.dataPassId" must be a positive number'); + }); + + it('should return 400 if runNumber is not positive', async () => { + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=1&runNumber=-1'); + + expect(response.status).to.equal(400); + expect(response.body.errors[0].detail).to.equal('"query.runNumber" must be a positive number'); + }); + + it('should return 400 if dataPassId is not a number', async () => { + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=abc&runNumber=107'); + + expect(response.status).to.equal(400); + expect(response.body.errors[0].detail).to.equal('"query.dataPassId" must be a number'); + }); + + it('should return 400 if runNumber is not a number', async () => { + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=1&runNumber=abc'); + + expect(response.status).to.equal(400); + expect(response.body.errors[0].detail).to.equal('"query.runNumber" must be a number'); + }); + + it('should return 400 when dataPassId parameter is missing', async () => { + const response = await request(server).get('/api/qcFlags/summary/gaq?runNumber=107'); + + expect(response.status).to.equal(400); + const { errors } = response.body; + const dataPassIdError = errors.find((error) => error.source.pointer === '/data/attributes/query/dataPassId'); + expect(dataPassIdError.detail).to.equal('"query.dataPassId" is required'); + }); + }); + +}; diff --git a/test/api/index.js b/test/api/index.js index e368ca5e61..98661ee237 100644 --- a/test/api/index.js +++ b/test/api/index.js @@ -35,6 +35,7 @@ const DplDetectorsSuite = require('./dplDetectors.test.js'); const QcFlagsSuite = require('./qcFlags.test.js'); const CtpTriggerCountersSuite = require('./ctpTriggerCounters.test'); const GaqDetectorsSuite = require('./gaqDetectors.test.js'); +const GaqSummarySuite = require('./gaqSummary.test.js'); module.exports = () => { describe('Attachments API', AttachmentsSuite); @@ -61,4 +62,5 @@ module.exports = () => { describe('QcFlagTypes API', QcFlagTypesSuite); describe('QcFlags API', QcFlagsSuite); describe('CtpTriggerCounters API', CtpTriggerCountersSuite); + describe('GaqSummary API', GaqSummarySuite); }; diff --git a/test/api/qcFlags.test.js b/test/api/qcFlags.test.js index ece5d54c54..b159eb1633 100644 --- a/test/api/qcFlags.test.js +++ b/test/api/qcFlags.test.js @@ -16,6 +16,7 @@ const request = require('supertest'); const { server } = require('../../lib/application'); const { resetDatabaseContent } = require('../utilities/resetDatabaseContent.js'); const { qcFlagService } = require('../../lib/server/services/qualityControlFlag/QcFlagService'); +const { gaqService } = require('../../lib/server/services/gaq/GaqService.js'); const { BkpRoles } = require('../../lib/domain/enums/BkpRoles.js'); const { dataPassService } = require('../../lib/server/services/dataPasses/DataPassService.js'); const { expectSuccessStatus } = require('./utils.js'); @@ -556,6 +557,9 @@ module.exports = () => { relations, ); + // getSummary now reads from the summary table, so compute first + await gaqService.calculateAndStoreGaqSummary(3, 54); + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=3&runNumber=54'); expect(response.status).to.be.equal(200); const { body: { data } } = response; @@ -569,11 +573,11 @@ module.exports = () => { ); }); - it('should return empty GAQ summary if no data exists for given dataPassId & runNumber combination', async () => { + it('should return null GAQ summary if no data exists for given dataPassId & runNumber combination', async () => { const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=3&runNumber=999'); expect(response.status).to.equal(200); const { body: { data } } = response; - expect(data).to.eql({}); + expect(data).to.be.null; }); it('should return 400 if dataPassId is not positive', async () => { @@ -602,14 +606,6 @@ module.exports = () => { expect(errors[0].detail).to.equal('"query.runNumber" must be a number'); }); - it('should return 400 when runNumber parameter is missing', async () => { - const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=3'); - expect(response.status).to.be.equal(400); - const { errors } = response.body; - const titleError = errors.find((err) => err.source.pointer === '/data/attributes/query/runNumber'); - expect(titleError.detail).to.equal('"query.runNumber" is required'); - }); - it('should return 400 when dataPassId parameter is missing', async () => { const response = await request(server).get('/api/qcFlags/summary/gaq?runNumber=54'); expect(response.status).to.be.equal(400); @@ -1110,7 +1106,7 @@ module.exports = () => { .delete(`/api/qcFlags/perDataPass?dataPassId=${dataPassId}&token=${BkpRoles.DPG_ASYNC_QC_ADMIN}`); expect(response.status).to.be.equal(200); - expect(response.body.data.deletedCount).to.equal(11); // 9 from seeders, 2 created in POST requests previously in this test + expect(response.body.data.deletedCount).to.equal(10); // 9 from seeders, 2 created in POST requests previously in this test, 1 already soft-deleted }); }); diff --git a/test/lib/server/services/gaq/GaqService.test.js b/test/lib/server/services/gaq/GaqService.test.js new file mode 100644 index 0000000000..d40ea7763a --- /dev/null +++ b/test/lib/server/services/gaq/GaqService.test.js @@ -0,0 +1,314 @@ +/** + * @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. + */ + +const { expect } = require('chai'); +const sinon = require('sinon'); +const { resetDatabaseContent } = require('../../../../utilities/resetDatabaseContent.js'); +const { repositories: { GaqSummaryRepository} } = require('../../../../../lib/database'); +const { gaqService } = require('../../../../../lib/server/services/gaq/GaqService.js'); +const { gaqWorker } = require('../../../../../lib/server/services/gaq/GaqWorker.js'); +const { Op } = require('sequelize'); + +/** + * Find the GAQ summary row for a given data pass and run + * @param {number} dataPassId data pass id + * @param {number} runNumber run number + * @return {Promise} + */ +const findSummary = (dataPassId, runNumber) => GaqSummaryRepository.findOne({ where: { dataPassId, runNumber } }); + +/** + * Insert an invalidation entry + * @param {number} dataPassId data pass id + * @param {number} runNumber run number + * @param {string} createdAt ISO timestamp string + * @return {Promise} + */ +const insertInvalidation = (dataPassId, runNumber, createdAt) => + GaqSummaryRepository.upsert({ dataPassId, runNumber, invalidatedAt: new Date(createdAt) }); + +// Tests for GaqService are split between QcFlagService.test.js and GaqSummary.test.js +// GaqService.test.js (this file) focuses on the summary recalculation and invalidation processing logic + +module.exports = () => { + before(resetDatabaseContent); + + // Data pass 1 (LHC22b_apass1), run 107 has GAQ detectors CPV (1) and ACO (2) seeded + // and has seeded QC flags, so a summary can always be computed + const dataPassId = 1; + const runNumber = 107; + + describe('calculateAndStoreGaqSummary', () => { + afterEach(async () => { + await GaqSummaryRepository.removeAll({ where: { dataPassId, runNumber } }); + }); + + it('should compute and store a summary row with correct values', async () => { + await gaqService.calculateAndStoreGaqSummary(dataPassId, runNumber); + + const summary = await findSummary(dataPassId, runNumber); + expect(summary).to.not.be.null; + expect(summary.dataPassId).to.equal(dataPassId); + expect(summary.runNumber).to.equal(runNumber); + expect(summary.badRunCoverage).to.equal(0); + expect(summary.explicitlyNotBadRunCoverage).to.equal(0.759654); + expect(summary.mcReproducibleCoverage).to.equal(0.240346); + expect(summary.missingVerificationsCount).to.equal(3); + expect(summary.undefinedQualityPeriodsCount).to.equal(0); + }); + + it('should upsert when a summary already exists', async () => { + await gaqService.calculateAndStoreGaqSummary(dataPassId, runNumber); + await gaqService.calculateAndStoreGaqSummary(dataPassId, runNumber); + + const rows = await GaqSummaryRepository.findAll({ where: { dataPassId, runNumber } }); + expect(rows).to.have.lengthOf(1); + }); + + it('should store a summary with notComputable set to true when there is no coverage data for the run', async () => { + // Run 49 has no QC flags seeded for data pass 1 + await gaqService.calculateAndStoreGaqSummary(dataPassId, 49); + + const summary = await findSummary(dataPassId, 49); + expect(summary).to.not.be.null; + expect(summary.notComputable).to.be.true; + + await GaqSummaryRepository.removeAll({ where: { dataPassId, runNumber: 49 } }); + }); + + it('should clear stale coverage fields when a previously-computable row becomes notComputable', async () => { + // Seed a row that has values but will become notComputable after recalculation due to missing QC flags + const staleRunNumber = 49; + await GaqSummaryRepository.upsert({ + dataPassId, + runNumber: staleRunNumber, + badRunCoverage: 0.5, + explicitlyNotBadRunCoverage: 0.4, + mcReproducibleCoverage: 0.1, + missingVerificationsCount: 2, + undefinedQualityPeriodsCount: 1, + notComputable: false, + }); + + // Run 49 has no QC flags seeded for data pass 1, so _computeSummary returns null + await gaqService.calculateAndStoreGaqSummary(dataPassId, staleRunNumber); + + const summary = await findSummary(dataPassId, staleRunNumber); + expect(summary).to.not.be.null; + expect(summary.notComputable).to.be.true; + expect(summary.badRunCoverage).to.be.null; + expect(summary.explicitlyNotBadRunCoverage).to.be.null; + expect(summary.mcReproducibleCoverage).to.be.null; + expect(summary.missingVerificationsCount).to.be.null; + expect(summary.undefinedQualityPeriodsCount).to.be.null; + + await GaqSummaryRepository.removeAll({ where: { dataPassId, runNumber: staleRunNumber } }); + }); + + it('should not clear invalidatedAt if the row was re-invalidated during compute', async () => { + // Seed an initial invalidation so the row has a known invalidatedAt + await GaqSummaryRepository.invalidate(dataPassId, runNumber); + const initial = await findSummary(dataPassId, runNumber); + expect(initial.invalidatedAt).to.not.be.null; + + // Simulate a concurrent invalidate by mocking _computeSummary to invalidate the row again but still return a valid summary + sinon.stub(gaqService, '_computeSummary').callsFake(async () => { + await GaqSummaryRepository.invalidate(dataPassId, runNumber); + return { + badRunCoverage: 0, + explicitlyNotBadRunCoverage: 1, + mcReproducibleCoverage: 0, + missingVerificationsCount: 0, + undefinedQualityPeriodsCount: 0, + }; + }); + + try { + await gaqService.calculateAndStoreGaqSummary( + dataPassId, + runNumber, + { expectedInvalidatedAt: initial.invalidatedAt }, + ); + } finally { + sinon.restore(); + } + + const after = await findSummary(dataPassId, runNumber); + // InvalidatedAt should not have been cleared because the row was re-invalidated during compute + expect(after.invalidatedAt).to.not.be.null; + expect(after.invalidatedAt).to.be.greaterThan(initial.invalidatedAt); + }); + }); + + describe('getSummary', () => { + before(() => gaqWorker.pause()); + after(() => gaqWorker.resume()); + + afterEach(async () => { + await GaqSummaryRepository.removeAll({ where: { dataPassId } }); + }); + + it('should return the summary for a single run when runNumber is given', async () => { + await gaqService.calculateAndStoreGaqSummary(dataPassId, runNumber); + + const result = await gaqService.getSummary(dataPassId, { runNumber }); + expect(result).to.be.an('object'); + expect(result).to.have.all.keys( + 'badEffectiveRunCoverage', + 'explicitlyNotBadEffectiveRunCoverage', + 'invalidatedAt', + 'mcReproducible', + 'missingVerificationsCount', + 'notComputable', + 'undefinedQualityPeriodsCount', + ); + expect(result.missingVerificationsCount).to.equal(3); + expect(result.mcReproducible).to.be.true; + }); + + it('should return null when runNumber is given and no row exists', async () => { + const result = await gaqService.getSummary(dataPassId, { runNumber: 999999 }); + expect(result).to.be.null; + }); + + it('should return a summary with null coverage fields when the row is notComputable', async () => { + // Run 49 has no QC flags in data pass 1, so calculateAndStoreGaqSummary writes a notComputable row + await gaqService.calculateAndStoreGaqSummary(dataPassId, 49); + + const result = await gaqService.getSummary(dataPassId, { runNumber: 49 }); + expect(result).to.deep.equal({ + badEffectiveRunCoverage: null, + explicitlyNotBadEffectiveRunCoverage: null, + mcReproducible: false, + missingVerificationsCount: null, + undefinedQualityPeriodsCount: null, + notComputable: true, + invalidatedAt: result.invalidatedAt, + }); + }); + + it('should return a summary with null coverage fields when the row is invalidated but never computed', async () => { + await GaqSummaryRepository.invalidate(dataPassId, runNumber); + + const result = await gaqService.getSummary(dataPassId, { runNumber }); + expect(result).to.deep.equal({ + badEffectiveRunCoverage: null, + explicitlyNotBadEffectiveRunCoverage: null, + mcReproducible: false, + missingVerificationsCount: null, + undefinedQualityPeriodsCount: null, + notComputable: false, + invalidatedAt: result.invalidatedAt, + }); + }); + + it('should include every existing row in the map, with null fields for those without coverage values', async () => { + await gaqService.calculateAndStoreGaqSummary(dataPassId, runNumber); + await gaqService.calculateAndStoreGaqSummary(dataPassId, 49); // notComputable + await GaqSummaryRepository.invalidate(dataPassId, 106); // invalidated, never computed + + const result = await gaqService.getSummary(dataPassId); + expect(Object.keys(result).map(Number).sort()).to.deep.equal([49, 106, runNumber].sort()); + expect(result[runNumber].badEffectiveRunCoverage).to.not.be.null; + expect(result[49].badEffectiveRunCoverage).to.be.null; + expect(result[106].badEffectiveRunCoverage).to.be.null; + }); + + it('should return the previously-computed values for a row that has been invalidated but not yet recomputed', async () => { + await gaqService.calculateAndStoreGaqSummary(dataPassId, runNumber); + const expected = await gaqService.getSummary(dataPassId, { runNumber }); + + await GaqSummaryRepository.invalidate(dataPassId, runNumber); + + const result = await gaqService.getSummary(dataPassId, { runNumber }); + expect(result).to.deep.equal({ ...expected, invalidatedAt: result.invalidatedAt }); + expect(result.invalidatedAt).to.not.be.null; + }); + + it('should not leak rows from other data passes into the result', async () => { + const otherDataPassId = 2; + await gaqService.calculateAndStoreGaqSummary(dataPassId, runNumber); + await GaqSummaryRepository.upsert({ + dataPassId: otherDataPassId, + runNumber, + badRunCoverage: 0.5, + explicitlyNotBadRunCoverage: 0.5, + mcReproducibleCoverage: 0, + missingVerificationsCount: 0, + undefinedQualityPeriodsCount: 0, + notComputable: false, + }); + + try { + const result = await gaqService.getSummary(dataPassId); + expect(Object.keys(result).map(Number)).to.have.members([runNumber]); + } finally { + await GaqSummaryRepository.removeAll({ where: { dataPassId: otherDataPassId, runNumber } }); + } + }); + + it('should return an empty map when no summary rows exist for the data pass', async () => { + const result = await gaqService.getSummary(dataPassId); + expect(result).to.deep.equal({}); + }); + + it('should treat mcReproducible coverage as not-bad when mcReproducibleAsNotBad is true', async () => { + await gaqService.calculateAndStoreGaqSummary(dataPassId, runNumber); + + const defaultResult = await gaqService.getSummary(dataPassId, { runNumber }); + const asNotBadResult = await gaqService.getSummary(dataPassId, { runNumber, mcReproducibleAsNotBad: true }); + + expect(asNotBadResult.badEffectiveRunCoverage).to.be.lessThan(defaultResult.badEffectiveRunCoverage + 1e-9); + expect(asNotBadResult.explicitlyNotBadEffectiveRunCoverage) + .to.be.greaterThan(defaultResult.explicitlyNotBadEffectiveRunCoverage - 1e-9); + const defaultTotal = defaultResult.badEffectiveRunCoverage + defaultResult.explicitlyNotBadEffectiveRunCoverage; + const asNotBadTotal = asNotBadResult.badEffectiveRunCoverage + asNotBadResult.explicitlyNotBadEffectiveRunCoverage; + expect(asNotBadTotal).to.be.closeTo(defaultTotal, 1e-9); + }); + }); + + describe('popNInvalidSummaryAndRecalculate', () => { + beforeEach(async () => { + await GaqSummaryRepository.updateAll({ invalidatedAt: null }, { where: {} }); + }); + + it('should do nothing when the invalidation table is empty', async () => { + await gaqService.popNInvalidSummaryAndRecalculate(5); + // No error thrown and nothing written + const summary = await findSummary(dataPassId, runNumber); + expect(summary).to.be.null; + }); + + it('should process exactly N invalidations ordered by createdAt', async () => { + // Insert invalidations for runs 106 and 107 in data pass 1 with different timestamps + await insertInvalidation(dataPassId, 106, '2024-01-01 10:00:00'); + await insertInvalidation(dataPassId, 107, '2024-01-01 11:00:00'); + + // Process only 1 — should pick run 106 (oldest) + await gaqService.popNInvalidSummaryAndRecalculate(1); + + const remaining = await GaqSummaryRepository.findOne({ where: { dataPassId, runNumber: 107, invalidatedAt: { [Op.not]: null } } }); + expect(remaining).to.not.be.null; + }); + + it('should process all invalidations when batchSize covers them all', async () => { + await insertInvalidation(dataPassId, 106, '2024-01-01 10:00:00'); + await insertInvalidation(dataPassId, 107, '2024-01-01 11:00:00'); + + await gaqService.popNInvalidSummaryAndRecalculate(10); + + const count = await GaqSummaryRepository.count({ where: { dataPassId, invalidatedAt: { [Op.not]: null } } }); + expect(count).to.equal(0); + }); + }); +}; diff --git a/test/lib/server/services/gaq/GaqSummary.test.js b/test/lib/server/services/gaq/GaqSummary.test.js new file mode 100644 index 0000000000..3ade1a9fe6 --- /dev/null +++ b/test/lib/server/services/gaq/GaqSummary.test.js @@ -0,0 +1,260 @@ +/** + * @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. + */ + +const { expect } = require('chai'); +const sinon = require('sinon'); +const { resetDatabaseContent } = require('../../../../utilities/resetDatabaseContent.js'); +const { repositories: { GaqSummaryRepository } } = require('../../../../../lib/database'); +const { qcFlagService } = require('../../../../../lib/server/services/qualityControlFlag/QcFlagService.js'); +const { gaqDetectorService } = require('../../../../../lib/server/services/gaq/GaqDetectorsService.js'); +const { updateRun } = require('../../../../../lib/server/services/run/updateRun.js'); +const { gaqWorker } = require('../../../../../lib/server/services/gaq/GaqWorker.js'); +const { gaqService } = require('../../../../../lib/server/services/gaq/GaqService.js'); + +/** + * Wait for a given number of milliseconds + * @param {number} ms milliseconds to wait + * @return {Promise} + */ +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Check whether an invalidation entry exists for a given data pass and run + * + * @param {number} expectedDataPassId + * @param {number} expectedRunNumber + * @param {boolean} toBeNull + * + * @return {Promise} + */ +const expectInvalidation = async (expectedDataPassId, expectedRunNumber, toBeNull = false) => { + const summary = await GaqSummaryRepository.findOne({ + where: { dataPassId: expectedDataPassId, runNumber: expectedRunNumber }, + }); + if (toBeNull) { + expect(summary?.invalidatedAt, `Expected no invalidation for dataPassId=${expectedDataPassId} runNumber=${expectedRunNumber}`).to.be.null; + } else { + expect(summary?.invalidatedAt, `Expected invalidation for dataPassId=${expectedDataPassId} runNumber=${expectedRunNumber}`).to.not.be.null; + } +}; + +module.exports = () => { + before(async () => { + await resetDatabaseContent(); + }); + + const relations = { user: { roles: ['admin'], externalUserId: 1 } }; + const dataPassId = 4; // LHC22a_apass2 + const runNumber = 56; + + describe("GAQ Summary Invalidation", async () => { + before(() => gaqWorker.pause()); + after(() => gaqWorker.resume()); + + // Resetting the invalidated column between each case + afterEach(async () => { + await GaqSummaryRepository.updateAll({ invalidatedAt: null }, { where: {} }); + }); + + it('should invalidate GAQ summary when a QC flag is created for a data pass', async () => { + await qcFlagService.create( + [{ from: null, to: null, flagTypeId: 3 }], + { runNumber, detectorIdentifier: { detectorId: 7 }, dataPassIdentifier: { id: dataPassId } }, + relations, + ); + + await expectInvalidation(dataPassId, runNumber); + }); + + it('should invalidate GAQ summary when a QC flag is verified for the first time for a data pass', async () => { + const flagId = 8; // Seeded flag in data pass 4, run 100, with no verifications + + await qcFlagService.verifyFlag({ flagId }, relations); + + await expectInvalidation(dataPassId, 100); + + // Clear invalidation + await GaqSummaryRepository.updateAll({ invalidatedAt: null }, { where: { dataPassId, runNumber: 100 } }); + + // Verify again to check that no new invalidation is created when the flag is already verified + await qcFlagService.verifyFlag({ flagId }, relations); + await expectInvalidation(dataPassId, 100, true); + }); + + it('should invalidate GAQ summary when a QC flag is deleted for a data pass', async () => { + const flagId = 9; // Seeded flag in data pass 4, run 105, with no verifications (so deletion is allowed) + await qcFlagService.delete(flagId); + + await expectInvalidation(dataPassId, 105); + }); + + it('should invalidate GAQ summary for all runs when all QC flags are deleted for a data pass', async () => { + await qcFlagService.deleteAllForDataPass(dataPassId); + + await expectInvalidation(dataPassId, 100); + }); + + it('should invalidate GAQ summary when GAQ detectors are explicitly set for a data pass and run', async () => { + const gaqDataPassId = 3; // LHC22a_apass1 (has run 56 and detectors set up in GaqDetectorService tests) + const detectorIds = [4, 7]; + + await gaqDetectorService.setGaqDetectors(gaqDataPassId, [runNumber], detectorIds); + + await expectInvalidation(gaqDataPassId, runNumber); + }); + + it('should invalidate GAQ summary when default GAQ detectors are used for a data pass and run', async () => { + const gaqDataPassId = 3; + + await gaqDetectorService.useDefaultGaqDetectors(gaqDataPassId, [runNumber]); + + await expectInvalidation(gaqDataPassId, runNumber); + }); + + it('should invalidate GAQ summary when the QC time of a run changes', async () => { + await updateRun( + { runNumber }, + { runPatch: { timeTrgStart: new Date('2019-08-08 20:30:00') } }, + ); + + await expectInvalidation(dataPassId, runNumber); + }); + + it('should NOT invalidate GAQ summary when updateRun patch does not touch QC time source fields', async () => { + await updateRun( + { runNumber }, + { runPatch: { definition: 'PHYSICS' } }, + ); + + await expectInvalidation(dataPassId, runNumber, true); + }); + + it('should NOT invalidate GAQ summary when a QC time source field is patched but qcTimeStart/qcTimeEnd do not change', async () => { + // Run 56 has a non-null time_trg_start, so qc_time_start resolves to time_trg_start regardless of time_o2_start. + // Patching only time_o2_start moves a source field but does not change the virtual qcTimeStart / qcTimeEnd, so no invalidation should fire. + await updateRun( + { runNumber }, + { runPatch: { timeO2Start: new Date('2019-08-08 18:00:00') } }, + ); + + await expectInvalidation(dataPassId, runNumber, true); + }); + }); + + describe('GAQ Worker', () => { + beforeEach(async () => { + await resetDatabaseContent(); + }); + + after(() => gaqWorker.pause()); + + it('should process invalidations and update the summary', async () => { + const workerDataPassId = 1; + const workerRunNumber = 107; + + await qcFlagService.create( + [{ from: null, to: null, flagTypeId: 3 }], + { runNumber: workerRunNumber, detectorIdentifier: { detectorId: 1 }, dataPassIdentifier: { id: workerDataPassId } }, + relations, + ); + + // confirm that the invalidation is made + await expectInvalidation(workerDataPassId, workerRunNumber); + + // wait at least 2s, recalculation period is 1s in test env, for the worker to process the invalidation + await sleep(2000); + + await expectInvalidation(workerDataPassId, workerRunNumber, true); + const summary = await GaqSummaryRepository.findOne({ where: { dataPassId: workerDataPassId, runNumber: workerRunNumber } }); + expect(summary.badRunCoverage).to.not.be.null; + }); + + it('should only upsert an existing summary row rather than creating a duplicate', async () => { + const workerDataPassId = 1; + const workerRunNumber = 107; + + await gaqService.calculateAndStoreGaqSummary(workerDataPassId, workerRunNumber); + const firstSummary = await GaqSummaryRepository.findOne({ where: { dataPassId: workerDataPassId, runNumber: workerRunNumber } }); + expect(firstSummary).to.not.be.null; + + // Trigger an invalidation + await qcFlagService.create( + [{ from: null, to: null, flagTypeId: 3 }], + { runNumber: workerRunNumber, detectorIdentifier: { detectorId: 1 }, dataPassIdentifier: { id: workerDataPassId } }, + relations, + ); + await expectInvalidation(workerDataPassId, workerRunNumber); + + await sleep(3000); + + await expectInvalidation(workerDataPassId, workerRunNumber, true); + + // confirm only one summary row exists (upsert, not duplicate) + const summaries = await GaqSummaryRepository.findAll({ where: { dataPassId: workerDataPassId, runNumber: workerRunNumber } }); + expect(summaries).to.have.lengthOf(1); + }); + + it('should process multiple invalidations in a single batch', async () => { + // Create invalidations for two different runs in data pass 1 + await qcFlagService.create( + [{ from: null, to: null, flagTypeId: 3 }], + { runNumber: 106, detectorIdentifier: { detectorId: 1 }, dataPassIdentifier: { id: 1 } }, + relations, + ); + await qcFlagService.create( + [{ from: null, to: null, flagTypeId: 3 }], + { runNumber: 107, detectorIdentifier: { detectorId: 1 }, dataPassIdentifier: { id: 1 } }, + relations, + ); + + await expectInvalidation(1, 106); + await expectInvalidation(1, 107); + + // Manually call the worker with min/max batchSize=2 to process both in one go + await gaqWorker.recalculateGaqSummaries(2, 2); + + await expectInvalidation(1, 106, true); + await expectInvalidation(1, 107, true); + + const summary106 = await GaqSummaryRepository.findOne({ where: { dataPassId: 1, runNumber: 106 } }); + const summary107 = await GaqSummaryRepository.findOne({ where: { dataPassId: 1, runNumber: 107 } }); + expect(summary106).to.not.be.null; + expect(summary107).to.not.be.null; + }); + + it('should skip processing if a previous call is still in progress', async () => { + // Stub gaqService to be slow so the first call blocks + let resolveFirst; + const slowPromise = new Promise((resolve) => { resolveFirst = resolve; }); + const stub = sinon.stub(gaqService, 'popNInvalidSummaryAndRecalculate').returns(slowPromise); + + try { + // First call — will be held open by the slow stub + const firstCall = gaqWorker.recalculateGaqSummaries(1, 1); + + // Second call — should be skipped because a previous run is still in flight + await gaqWorker.recalculateGaqSummaries(1, 1); + + // Stub should only have been called once + expect(stub.callCount).to.equal(1); + + // Release the first call with the shape popNInvalidSummaryAndRecalculate normally returns + resolveFirst({ processedCount: 0, totalInvalidCount: 0 }); + await firstCall; + } finally { + sinon.restore(); + } + }); + + }); +}; diff --git a/test/lib/server/services/gaq/index.js b/test/lib/server/services/gaq/index.js index 985f80fe84..32200b17d0 100644 --- a/test/lib/server/services/gaq/index.js +++ b/test/lib/server/services/gaq/index.js @@ -12,7 +12,11 @@ */ const GaqDetectorServiceSuite = require('./GaqDetectorService.test.js'); +const GaqServiceSuite = require('./GaqService.test.js'); +const GaqSummarySuite = require('./GaqSummary.test.js'); module.exports = () => { describe('GaqDetectorService', GaqDetectorServiceSuite); + describe('GaqService', GaqServiceSuite); + describe('GaqSummary', GaqSummarySuite); }; diff --git a/test/lib/server/services/index.js b/test/lib/server/services/index.js index 01df33fcaf..38aabbf6dd 100644 --- a/test/lib/server/services/index.js +++ b/test/lib/server/services/index.js @@ -32,7 +32,7 @@ const UserSuite = require('./user/index.js'); const SimulationPassesSuite = require('./simulationPasses/index.js'); const QcFlagsSuite = require('./qualityControlFlag/index.js'); const CtpTriggerCountersSuite = require('./ctpTriggerCounters/index.js'); -const GaqDetectorSuite = require('./gaq'); +const GaqSuite = require('./gaq'); module.exports = () => { before(resetDatabaseContent); @@ -46,7 +46,7 @@ module.exports = () => { describe('Environment history item', EnvironmentHistoryItemSuite); describe('EOS report', EosReportSuite); describe('Flp role', FlpRoleSuite); - describe('GaqDetector', GaqDetectorSuite); + describe('GAQ', GaqSuite); describe('LHC fill suite', LhcFillSuite); describe('Logs', LogSuite); describe('RunType', RunTypeSuite); diff --git a/test/lib/server/services/qualityControlFlag/QcFlagService.test.js b/test/lib/server/services/qualityControlFlag/QcFlagService.test.js index 3aa4300ab4..1a266f2a62 100644 --- a/test/lib/server/services/qualityControlFlag/QcFlagService.test.js +++ b/test/lib/server/services/qualityControlFlag/QcFlagService.test.js @@ -21,7 +21,7 @@ const { Op } = require('sequelize'); const { qcFlagAdapter } = require('../../../../../lib/database/adapters'); const { runService } = require('../../../../../lib/server/services/run/RunService'); const { gaqDetectorService } = require('../../../../../lib/server/services/gaq/GaqDetectorsService'); -const { gaqService } = require('../../../../../lib/server/services/qualityControlFlag/GaqService.js'); +const { gaqService } = require('../../../../../lib/server/services/gaq/GaqService.js'); const { qcFlagSummaryService } = require('../../../../../lib/server/services/qualityControlFlag/QcFlagSummaryService.js'); const { dataPassService } = require('../../../../../lib/server/services/dataPasses/DataPassService.js'); @@ -2074,6 +2074,9 @@ module.exports = () => { expectedGaqSummary.missingVerificationsCount = 11; expectedGaqSummary.undefinedQualityPeriodsCount = 8; + // getSummary now reads from the summary table, so compute first + await gaqService.calculateAndStoreGaqSummary(dataPassId, runNumber); + const { [runNumber]: runGaqSummary } = await gaqService.getSummary(dataPassId); expect(runGaqSummary).to.be.eql(expectedGaqSummary); @@ -2103,7 +2106,13 @@ module.exports = () => { relations, ); - const gaqSummary = await gaqService.getSummary(dataPassId); + await gaqService.calculateAndStoreGaqSummary(dataPassId, 56); + await gaqService.calculateAndStoreGaqSummary(dataPassId, 54); + + const allGaqSummaries = await gaqService.getSummary(dataPassId); + const gaqSummary = Object.fromEntries( + [runNumber, 56, 54].map((n) => [n, allGaqSummaries[n]]), + ); expect(gaqSummary).to.be.eql({ [runNumber]: expectedGaqSummary, 56: { diff --git a/test/public/defaults.js b/test/public/defaults.js index 9dac35f2bb..0eca0e82d7 100644 --- a/test/public/defaults.js +++ b/test/public/defaults.js @@ -751,30 +751,38 @@ module.exports.expectUrlParams = (page, expectedUrlParameters, ignoreParams = [] * * @return {Promise} resolve once column values were checked */ -module.exports.expectColumnValues = async (page, columnId, expectedInnerTextValues) => { +module.exports.expectColumnValues = async (page, columnId, expectedInnerTextValues, { orderMatters = true } = {}) => { try { await page.waitForFunction( - (columnId, expectedInnerTextValues) => { + (columnId, expectedInnerTextValues, orderMatters) => { const cells = document.querySelectorAll(`table tbody .column-${columnId}`); if (cells.length !== expectedInnerTextValues.length) { return false; } - for (const rowIndex in expectedInnerTextValues) { - if (cells[rowIndex].innerText !== expectedInnerTextValues[rowIndex]) { - return false; - } + const actualValues = [...cells].map((cell) => cell.innerText); + + if (orderMatters) { + return actualValues.every((value, index) => value === expectedInnerTextValues[index]); } - return true; + const sortedActual = [...actualValues].sort(); + const sortedExpected = [...expectedInnerTextValues].sort(); + return sortedActual.every((value, index) => value === sortedExpected[index]); }, {}, columnId, expectedInnerTextValues, + orderMatters, ); } catch (_) { // Use expect to have explicit error message - expect(await getColumnCellsInnerTexts(page, columnId)).to.deep.equal(expectedInnerTextValues); + const actual = await getColumnCellsInnerTexts(page, columnId); + if (orderMatters) { + expect(actual).to.deep.equal(expectedInnerTextValues); + } else { + expect(actual).to.have.members(expectedInnerTextValues); + } } }; diff --git a/test/public/runs/runsPerDataPass.overview.test.js b/test/public/runs/runsPerDataPass.overview.test.js index 4d1edbb4d6..c172ad0dc8 100644 --- a/test/public/runs/runsPerDataPass.overview.test.js +++ b/test/public/runs/runsPerDataPass.overview.test.js @@ -32,18 +32,32 @@ const { getPopoverSelector, getInnerText, getPopoverInnerText, + getColumnCellsInnerTexts, testTableSortingByColumn, setConfirmationDialogToBeAccepted, unsetConfirmationDialogActions, checkPopoverInnerText, } = require('../defaults.js'); const { resetDatabaseContent } = require('../../utilities/resetDatabaseContent.js'); +const { repositories: { GaqSummaryRepository } } = require('../../../lib/database'); const DataPassRepository = require('../../../lib/database/repositories/DataPassRepository.js'); const { BkpRoles } = require('../../../lib/domain/enums/BkpRoles.js'); const { navigateToRunsPerDataPass } = require('./navigationUtils.js'); +const { invalid } = require('joi'); +const { gaqWorker } = require('../../../lib/server/services/gaq/GaqWorker.js'); +const { Op } = require('sequelize'); + const { expect } = chai; +/** + * Wait for a given number of milliseconds + * @param {number} ms milliseconds to wait + * @return {Promise} + */ +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + const DETECTORS = [ 'CPV', 'EMC', @@ -74,6 +88,27 @@ module.exports = () => { deviceScaleFactor: 1, }); await resetDatabaseContent(); + + await GaqSummaryRepository.upsert({ + dataPassId: 1, + runNumber: 106, + badRunCoverage: 0, + explicitlyNotBadRunCoverage: 0, + mcReproducibleCoverage: 0, + missingVerificationsCount: 3, + undefinedQualityPeriodsCount: 11, + calculationFailed: false, + }); + await GaqSummaryRepository.upsert({ + dataPassId: 1, + runNumber: 107, + badRunCoverage: 0, + explicitlyNotBadRunCoverage: 0.759654, + mcReproducibleCoverage: 0.240346, + missingVerificationsCount: 3, + undefinedQualityPeriodsCount: 0, + calculationFailed: false, + }); }); after(async () => { @@ -471,10 +506,12 @@ module.exports = () => { await fillInput(page, '#duration-operand', '10', ['change']); await waitForTableLength(page, 2); - await expectColumnValues(page, 'runNumber', ['55', '1']); + // Made order-independent assertion as sometimes the run with 55 minutes duration is loaded before the one with 1 minute duration and sometimes the opposite happens, which causes test to fail if exact order is asserted + await expectColumnValues(page, 'runNumber', ['55', '1'], { orderMatters: false }); await pressElement(page, '#reset-filters'); - await expectColumnValues(page, 'runNumber', ['55', '2', '1']); + await waitForTableLength(page, 3); + await expectColumnValues(page, 'runNumber', ['55', '2', '1'], { orderMatters: false }); }); it('should successfully apply alice currents filters', async () => { @@ -598,8 +635,8 @@ module.exports = () => { await pressElement(page, '#actions-dropdown-button .popover-trigger', true); const popoverSelector = await getPopoverSelector(await page.waitForSelector('#actions-dropdown-button .popover-trigger')); - await expectInnerText(page, `${popoverSelector} button:nth-child(3)`, 'Freeze the data pass'); - await pressElement(page, `${popoverSelector} button:nth-child(3)`, true); + await expectInnerText(page, `${popoverSelector} button:nth-child(4)`, 'Freeze the data pass'); + await pressElement(page, `${popoverSelector} button:nth-child(4)`, true); }); it('should successfully disable QC flag creation when data pass is frozen', async () => { @@ -612,8 +649,8 @@ module.exports = () => { it('should successfully un-freeze a given data pass', async () => { const popoverSelector = await getPopoverSelector(await page.waitForSelector('#actions-dropdown-button .popover-trigger')); - await expectInnerText(page, `${popoverSelector} button:nth-child(3)`, 'Unfreeze the data pass'); - await pressElement(page, `${popoverSelector} button:nth-child(3)`); + await expectInnerText(page, `${popoverSelector} button:nth-child(4)`, 'Unfreeze the data pass'); + await pressElement(page, `${popoverSelector} button:nth-child(4)`); }); it('should successfully enable QC flag creation when data pass is un-frozen', async () => { @@ -627,7 +664,7 @@ module.exports = () => { it('should successfully not display button to discard all QC flags for the data pass', async () => { await pressElement(page, '#actions-dropdown-button .popover-trigger', true); const popoverSelector = await getPopoverSelector(await page.waitForSelector('#actions-dropdown-button .popover-trigger')); - await page.waitForSelector(`${popoverSelector} button:nth-child(4)`, { hidden: true }); + await page.waitForSelector(`${popoverSelector} button:nth-child(5)`, { hidden: true }); await pressElement(page, '#actions-dropdown-button .popover-trigger', true); }); @@ -643,6 +680,7 @@ module.exports = () => { // Press again actions dropdown to re-trigger render await pressElement(page, '#actions-dropdown-button .popover-trigger', true); setConfirmationDialogToBeAccepted(page); + await pressElement(page, `${popoverSelector} button:nth-child(5)`, true); const oldTable = await page.waitForSelector('table').then((table) => table.evaluate((t) => t.innerHTML)); await pressElement(page, `${popoverSelector} button:nth-child(4)`, true); await pressElement(page, '#actions-dropdown-button .popover-trigger', true); @@ -671,5 +709,148 @@ module.exports = () => { await page.waitForSelector('#MUD'); }); + describe('GAQ summary button', async () => { + before(async () => { + await page.evaluate((role) => { + // eslint-disable-next-line no-undef + const session = sessionService.get(); + session.access = session.access.filter((r) => r !== role); + }, BkpRoles.DPG_ASYNC_QC_ADMIN); + }); + + it('should not display the recalculate GAQ summary button for non-admin users', async () => { + await navigateToRunsPerDataPass(page, 2, 1, 3); + await pressElement(page, '#actions-dropdown-button .popover-trigger', true); + await page.waitForSelector('#recalculate-gaq-summary-trigger', { hidden: true }); + await pressElement(page, '#actions-dropdown-button .popover-trigger', true); + await page.waitForSelector('[title="Recalculate GAQ for this run"]', { hidden: true }); + }); + + it('should display a secondary GAQ button for runs without a calculated summary', async () => { + await navigateToRunsPerDataPass(page, 2, 1, 3); + + // Run 108 has no seeded GAQ summary + await page.waitForSelector('#row108-globalAggregatedQuality button'); + expect(await getPopoverInnerText(await page.waitForSelector('#row108-globalAggregatedQuality .popover-trigger'))) + .to.be.equal('GAQ summary has not yet been calculated'); + }); + + describe('with DPG_ASYNC_QC_ADMIN role', async () => { + before(async () => { + await page.evaluate((role) => { + // eslint-disable-next-line no-undef + sessionService.get().token = role; + // eslint-disable-next-line no-undef + sessionService.get().access.push(role); + }, BkpRoles.DPG_ASYNC_QC_ADMIN); + + gaqWorker.pause(); + }); + + after(async () => { + gaqWorker.resume(); + + await page.evaluate((role) => { + // eslint-disable-next-line no-undef + const session = sessionService.get(); + session.access = session.access.filter((r) => r !== role); + }, BkpRoles.DPG_ASYNC_QC_ADMIN); + }); + + it('should display the recalculate GAQ summary button for admin users', async () => { + await pressElement(page, '#actions-dropdown-button .popover-trigger', true); + await expectInnerText(page, '#recalculate-gaq-summary-trigger', 'Recalculate GAQ summary'); + await pressElement(page, '#actions-dropdown-button .popover-trigger', true); + }); + + it('should display a per-row recalculate GAQ button for each run with a GAQ summary', async () => { + await page.waitForSelector('#row106 [title="Recalculate GAQ for this run"]'); + await page.waitForSelector('#row107 [title="Recalculate GAQ for this run"]'); + }); + + describe('GAQ summary invalidation and recalculation', async () => { + + beforeEach(async () => { + await resetDatabaseContent(); + await navigateToRunsPerDataPass(page, 2, 1, 3); + }); + + it('should create GAQ summary invalidation entries when the specific run recalculate button is clicked', async () => { + await expectInnerText(page, '#row107-globalAggregatedQuality', '76'); + + await pressElement(page, '#row107 [title="Recalculate GAQ for this run"]'); + + // After clicking recalculate, the cell should show a clock icon indicating the summary has been fetched again and we can check db entries + await page.waitForSelector('#row107-globalAggregatedQuality #clock-icon'); + + const allInvalidations = await GaqSummaryRepository.findAll({ + where: { invalidatedAt: { [Op.not]: null } }, + }); + expect(allInvalidations).to.have.lengthOf(1); + const invalidatedRunNumbers = allInvalidations.map((i) => i.runNumber); + expect(invalidatedRunNumbers).to.be.eql([107]); + }); + + it('should create GAQ summary invalidation entries when the recalculate for the whole dataPass button is clicked', async () => { + await expectInnerText(page, '#row107-globalAggregatedQuality', '76'); + + await pressElement(page, '#actions-dropdown-button .popover-trigger', true); + setConfirmationDialogToBeAccepted(page); + await pressElement(page, '#recalculate-gaq-summary-trigger', true); + unsetConfirmationDialogActions(page); + await page.waitForSelector('#row107-globalAggregatedQuality #clock-icon'); + const allInvalidations = await GaqSummaryRepository.findAll({ + where: { invalidatedAt: { [Op.not]: null } }, + }); + expect(allInvalidations).to.have.lengthOf(3); + const invalidatedRunNumbers = allInvalidations.map((i) => i.runNumber); + expect(invalidatedRunNumbers).to.have.members([106, 107, 108]); + }); + + it('should update the GAQ cell appropriately after each step in the GAQ summary lifecycle', async () => { + // remove it from the db to simulate a run without a GAQ summary as it gets seeded in resetDatabaseContent + await GaqSummaryRepository.removeAll({ where: { runNumber: 107 } }); + await navigateToRunsPerDataPass(page, 2, 1, 3); + + await page.waitForSelector('#row107-globalAggregatedQuality #warning-icon'); + await expectInnerText(page, '#row107-globalAggregatedQuality', 'GAQ'); + + const notYetCalculatedIconPopoverContent = await getPopoverContent(await page.waitForSelector('#row107-globalAggregatedQuality .popover-trigger')); + expect(notYetCalculatedIconPopoverContent).to.equal('GAQ summary has not yet been calculated'); + + await pressElement(page, '#row107 [title="Recalculate GAQ for this run"]'); + + await page.waitForSelector('#row107-globalAggregatedQuality #clock-icon'); + + // Normal popover content executes too fast and grabs the old popover content thus the waitForFunction + await page.waitForFunction( + (triggerSelector, expectedText) => { + const trigger = document.querySelector(triggerSelector); + const key = trigger.dataset.popoverKey; + + const popover = document.querySelector(`.popover[data-popover-key="${key}"]`); + return popover?.innerText === expectedText; + }, + { timeout: 5000 }, + '#row107-globalAggregatedQuality .popover-trigger', + 'Summary is invalid. New summary will be calculated shortly. Please wait and refresh the page.', + ); + + const invalidatedIconPopoverContent = await getPopoverContent(await page.waitForSelector('#row107-globalAggregatedQuality .popover-trigger')); + expect(invalidatedIconPopoverContent).to.equal('Summary is invalid. New summary will be calculated shortly. Please wait and refresh the page.'); + + await gaqWorker.resume(); + await gaqWorker.recalculateGaqSummaries(1, 1); + + await navigateToRunsPerDataPass(page, 2, 1, 3); + + await page.waitForSelector('#row107-globalAggregatedQuality #clock-icon', { hidden: true }); + await expectInnerText(page, '#row107-globalAggregatedQuality', '76'); + }); + + }); + }); + }); + }; diff --git a/test/utilities/resetDatabaseContent.js b/test/utilities/resetDatabaseContent.js index 9e04ebddab..690b17c7b0 100644 --- a/test/utilities/resetDatabaseContent.js +++ b/test/utilities/resetDatabaseContent.js @@ -12,9 +12,20 @@ */ const { database } = require('../../lib/application.js'); +const { gaqWorker } = require('../../lib/server/services/gaq/GaqWorker.js'); exports.resetDatabaseContent = async () => { + /* + * Pause GAQ worker and await any in-flight call before dropping tables, otherwise a tick + * already past the guard would hit dropped tables and log a spurious ERROR. Restore the + * prior paused state on the way out so callers that paused first stay paused. + */ + const wasPaused = gaqWorker.isPaused; + await gaqWorker.pause(); await database.dropAllTables(); await database.migrate(); await database.seed(); + if (!wasPaused) { + gaqWorker.resume(); + } };