diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.test.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.test.ts index f6fd06509cb0b..0d480b953d7a8 100644 --- a/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.test.ts +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.test.ts @@ -27,10 +27,11 @@ import { CreatedAtSearchResponse, transformSingleResponse, } from './scheduled_query'; -import { ReportingCore } from '../../..'; -import { ScheduledReportType } from '../../../types'; -import { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; +import type { ReportingCore } from '../../..'; +import type { ScheduledReportType } from '../../../types'; +import { TaskStatus, type TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; import { omit } from 'lodash'; +import type { BulkGetResult } from '@kbn/task-manager-plugin/server/task_store'; const fakeRawRequest = { headers: { @@ -60,23 +61,11 @@ const savedObjects: Array> = [ createdBy: 'elastic', enabled: true, jobType: 'printable_pdf_v2', - meta: { - isDeprecated: false, - layout: 'preserve_layout', - objectType: 'dashboard', - }, + meta: { isDeprecated: false, layout: 'preserve_layout', objectType: 'dashboard' }, migrationVersion: '9.1.0', title: '[Logs] Web Traffic', payload, - schedule: { - rrule: { - freq: 3, - interval: 3, - byhour: [12], - byminute: [0], - tzid: 'UTC', - }, - }, + schedule: { rrule: { freq: 3, interval: 3, byhour: [12], byminute: [0], tzid: 'UTC' } }, }, references: [], managed: false, @@ -95,27 +84,13 @@ const savedObjects: Array> = [ createdBy: 'not-elastic', enabled: true, jobType: 'PNGV2', - meta: { - isDeprecated: false, - layout: 'preserve_layout', - objectType: 'dashboard', - }, + meta: { isDeprecated: false, layout: 'preserve_layout', objectType: 'dashboard' }, migrationVersion: '9.1.0', - notification: { - email: { - to: ['user@elastic.co'], - }, - }, + notification: { email: { to: ['user@elastic.co'] } }, title: 'Another cool dashboard', payload: '{"browserTimezone":"America/New_York","layout":{"dimensions":{"height":2220,"width":1364},"id":"preserve_layout"},"objectType":"dashboard","title":"[Logs] Web Traffic","version":"9.1.0","locatorParams":[{"id":"DASHBOARD_APP_LOCATOR","params":{"dashboardId":"edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b","preserveSavedFilters":true,"timeRange":{"from":"now-7d/d","to":"now"},"useHash":false,"viewMode":"view"}}],"isDeprecated":false}', - schedule: { - rrule: { - freq: 1, - interval: 3, - tzid: 'UTC', - }, - }, + schedule: { rrule: { freq: 1, interval: 3, tzid: 'UTC' } }, }, references: [], managed: false, @@ -145,30 +120,107 @@ const lastRunResponse: CreatedAtSearchResponse = { _index: '.ds-.kibana-reporting-2025.05.06-000001', _id: '7c14d3e0-5d3f-4374-87f8-1758d2aaa10b', _score: null, - _source: { - created_at: '2025-05-06T21:12:07.198Z', - }, - fields: { - scheduled_report_id: ['2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], - }, + _source: { created_at: '2025-05-06T21:12:07.198Z' }, + fields: { scheduled_report_id: ['2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'] }, sort: [1746565930198], }, { _index: '.ds-.kibana-reporting-2025.05.06-000001', _id: '895f9620-cf3c-4e9e-9bf2-3750360ebd81', _score: null, - _source: { - created_at: '2025-05-06T12:00:00.500Z', - }, - fields: { - scheduled_report_id: ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca'], - }, + _source: { created_at: '2025-05-06T12:00:00.500Z' }, + fields: { scheduled_report_id: ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca'] }, sort: [1746565930198], }, ], }, }; +const nextRunResponse: BulkGetResult = [ + { + tag: 'ok', + value: { + taskType: 'report:execute-scheduled', + state: {}, + ownerId: '', + params: { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + spaceId: 'default', + jobtype: 'printable_pdf_v2', + }, + schedule: { + rrule: { + dtstart: '2025-09-03T16:49:00.000Z', + tzid: 'America/New_York', + byhour: [12], + byminute: [49], + freq: 3, + interval: 1, + byweekday: ['WE'], + }, + }, + traceparent: '', + enabled: true, + attempts: 0, + scheduledAt: new Date('2025-09-03T16:49:17.952Z'), + startedAt: null, + retryAt: null, + runAt: new Date('2025-09-10T16:49:00.000Z'), + status: TaskStatus.Idle, + partition: 146, + userScope: { + apiKeyId: 'ueR7EJkB2N4RCOVIjhec', + spaceId: 'default', + apiKeyCreatedByUser: false, + }, + apiKey: 'dWVSN0VKa0IyTjRSQ09WSWpoZWM6OEUyZUYtWDlSNndKckdMY0hPTElpZw==', + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + version: 'WzYzMSwxXQ==', + }, + }, + { + tag: 'ok', + value: { + taskType: 'report:execute-scheduled', + state: {}, + ownerId: '', + params: { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + spaceId: 'default', + jobtype: 'printable_pdf_v2', + }, + schedule: { + rrule: { + dtstart: '2025-09-03T16:49:00.000Z', + tzid: 'America/New_York', + byhour: [12], + byminute: [49], + freq: 3, + interval: 1, + byweekday: ['WE'], + }, + }, + traceparent: '', + enabled: true, + attempts: 0, + scheduledAt: new Date('2025-09-03T16:49:17.952Z'), + startedAt: null, + retryAt: null, + runAt: new Date('2025-09-12T08:30:00.000Z'), + status: TaskStatus.Idle, + partition: 146, + userScope: { + apiKeyId: 'ueR7EJkB2N4RCOVIjhec', + spaceId: 'default', + apiKeyCreatedByUser: false, + }, + apiKey: 'dWVSN0VKa0IyTjRSQ09WSWpoZWM6OEUyZUYtWDlSNndKckdMY0hPTElpZw==', + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + version: 'WzYzMSwxXQ==', + }, + }, +]; + const mockLogger = loggingSystemMock.createLogger(); describe('scheduledQueryFactory', () => { @@ -209,6 +261,7 @@ describe('scheduledQueryFactory', () => { tasks: savedObjects.map((so) => ({ id: so.id })), errors: [], })); + taskManager.bulkGet = jest.fn().mockResolvedValue(nextRunResponse); scheduledQuery = scheduledQueryFactory(core); jest.spyOn(core, 'canManageReportingForSpace').mockResolvedValue(true); @@ -257,6 +310,11 @@ describe('scheduledQueryFactory', () => { size: 10, sort: [{ created_at: { order: 'desc' } }], }); + expect(taskManager.bulkGet).toHaveBeenCalledTimes(1); + expect(taskManager.bulkGet).toHaveBeenCalledWith([ + 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + ]); expect(auditLogger.log).toHaveBeenCalledTimes(2); expect(auditLogger.log).toHaveBeenNthCalledWith(1, { @@ -307,7 +365,7 @@ describe('scheduledQueryFactory', () => { enabled: true, jobtype: 'printable_pdf_v2', last_run: '2025-05-06T12:00:00.500Z', - next_run: expect.any(String), + next_run: '2025-09-10T16:49:00.000Z', payload: jsonPayload, schedule: { rrule: { @@ -328,7 +386,7 @@ describe('scheduledQueryFactory', () => { enabled: true, jobtype: 'PNGV2', last_run: '2025-05-06T21:12:07.198Z', - next_run: expect.any(String), + next_run: '2025-09-12T08:30:00.000Z', notification: { email: { to: ['user@elastic.co'], @@ -413,6 +471,7 @@ describe('scheduledQueryFactory', () => { perPage: 10, }); expect(client.search).not.toHaveBeenCalled(); + expect(taskManager.bulkGet).not.toHaveBeenCalled(); expect(result).toEqual({ page: 1, per_page: 10, total: 0, data: [] }); }); @@ -436,6 +495,9 @@ describe('scheduledQueryFactory', () => { "statusCode": 500, } `); + + expect(client.search).not.toHaveBeenCalled(); + expect(taskManager.bulkGet).not.toHaveBeenCalled(); }); it('should gracefully handle esClient.search errors', async () => { @@ -452,6 +514,12 @@ describe('scheduledQueryFactory', () => { 10 ); + expect(taskManager.bulkGet).toHaveBeenCalledTimes(1); + expect(taskManager.bulkGet).toHaveBeenCalledWith([ + 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + ]); + expect(result).toEqual({ page: 1, per_page: 10, @@ -463,7 +531,7 @@ describe('scheduledQueryFactory', () => { created_by: 'elastic', enabled: true, jobtype: 'printable_pdf_v2', - next_run: expect.any(String), + next_run: '2025-09-10T16:49:00.000Z', payload: jsonPayload, schedule: { rrule: { @@ -483,7 +551,7 @@ describe('scheduledQueryFactory', () => { created_by: 'not-elastic', enabled: true, jobtype: 'PNGV2', - next_run: expect.any(String), + next_run: '2025-09-12T08:30:00.000Z', notification: { email: { to: ['user@elastic.co'], @@ -507,6 +575,152 @@ describe('scheduledQueryFactory', () => { `Error getting last run for scheduled reports: Some other error` ); }); + + it('should gracefully handle taskManager.bulkGet errors', async () => { + taskManager.bulkGet = jest.fn().mockImplementationOnce(() => { + throw new Error('task manager error'); + }); + const result = await scheduledQuery.list( + mockLogger, + fakeRawRequest, + mockResponseFactory, + { username: 'somebody' }, + 1, + 10 + ); + + expect(soClient.find).toHaveBeenCalledTimes(1); + expect(client.search).toHaveBeenCalledTimes(1); + expect(taskManager.bulkGet).toHaveBeenCalledTimes(1); + + expect(auditLogger.log).toHaveBeenCalledTimes(2); + + expect(result).toEqual({ + page: 1, + per_page: 10, + total: 2, + data: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + created_at: '2025-05-06T21:10:17.137Z', + created_by: 'elastic', + enabled: true, + jobtype: 'printable_pdf_v2', + last_run: '2025-05-06T12:00:00.500Z', + next_run: expect.any(String), + payload: jsonPayload, + schedule: { + rrule: { + freq: 3, + interval: 3, + byhour: [12], + byminute: [0], + tzid: 'UTC', + }, + }, + space_id: 'a-space', + title: '[Logs] Web Traffic', + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + created_at: '2025-05-06T21:12:06.584Z', + created_by: 'not-elastic', + enabled: true, + jobtype: 'PNGV2', + last_run: '2025-05-06T21:12:07.198Z', + next_run: expect.any(String), + notification: { + email: { + to: ['user@elastic.co'], + }, + }, + payload: jsonPayload, + space_id: 'a-space', + title: 'Another cool dashboard', + schedule: { + rrule: { + freq: 1, + interval: 3, + tzid: 'UTC', + }, + }, + }, + ], + }); + }); + + it('should gracefully handle errors in taskManager.bulkGet result', async () => { + taskManager.bulkGet = jest.fn().mockImplementationOnce(() => { + return [nextRunResponse[0], { tag: 'error', error: new Error('not found') }]; + }); + const result = await scheduledQuery.list( + mockLogger, + fakeRawRequest, + mockResponseFactory, + { username: 'somebody' }, + 1, + 10 + ); + + expect(soClient.find).toHaveBeenCalledTimes(1); + expect(client.search).toHaveBeenCalledTimes(1); + expect(taskManager.bulkGet).toHaveBeenCalledTimes(1); + + expect(auditLogger.log).toHaveBeenCalledTimes(2); + + expect(result).toEqual({ + page: 1, + per_page: 10, + total: 2, + data: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + created_at: '2025-05-06T21:10:17.137Z', + created_by: 'elastic', + enabled: true, + jobtype: 'printable_pdf_v2', + last_run: '2025-05-06T12:00:00.500Z', + next_run: '2025-09-10T16:49:00.000Z', + payload: jsonPayload, + schedule: { + rrule: { + freq: 3, + interval: 3, + byhour: [12], + byminute: [0], + tzid: 'UTC', + }, + }, + space_id: 'a-space', + title: '[Logs] Web Traffic', + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + created_at: '2025-05-06T21:12:06.584Z', + created_by: 'not-elastic', + enabled: true, + jobtype: 'PNGV2', + last_run: '2025-05-06T21:12:07.198Z', + next_run: expect.any(String), + notification: { + email: { + to: ['user@elastic.co'], + }, + }, + payload: jsonPayload, + space_id: 'a-space', + title: 'Another cool dashboard', + schedule: { + rrule: { + freq: 1, + interval: 3, + tzid: 'UTC', + }, + }, + }, + ], + }); + }); }); describe('bulkDisable', () => { @@ -1084,6 +1298,61 @@ describe('transformResponse', () => { jest.clearAllMocks(); }); it('should correctly transform the responses', () => { + expect(transformResponse(mockLogger, soResponse, lastRunResponse, nextRunResponse)).toEqual({ + page: 1, + per_page: 10, + total: 2, + data: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + created_at: '2025-05-06T21:10:17.137Z', + created_by: 'elastic', + enabled: true, + jobtype: 'printable_pdf_v2', + last_run: '2025-05-06T12:00:00.500Z', + next_run: '2025-09-10T16:49:00.000Z', + payload: jsonPayload, + schedule: { + rrule: { + freq: 3, + interval: 3, + byhour: [12], + byminute: [0], + tzid: 'UTC', + }, + }, + space_id: 'a-space', + title: '[Logs] Web Traffic', + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + created_at: '2025-05-06T21:12:06.584Z', + created_by: 'not-elastic', + enabled: true, + jobtype: 'PNGV2', + last_run: '2025-05-06T21:12:07.198Z', + next_run: '2025-09-12T08:30:00.000Z', + notification: { + email: { + to: ['user@elastic.co'], + }, + }, + payload: jsonPayload, + title: 'Another cool dashboard', + schedule: { + rrule: { + freq: 1, + interval: 3, + tzid: 'UTC', + }, + }, + space_id: 'a-space', + }, + ], + }); + }); + + it('should still calculate the next_run date when nextRunResponse is undefined', () => { expect(transformResponse(mockLogger, soResponse, lastRunResponse)).toEqual({ page: 1, per_page: 10, diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.ts index 05028a1794818..3504a29e2f261 100644 --- a/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.ts +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.ts @@ -17,7 +17,9 @@ import { REPORTING_DATA_STREAM_WILDCARD_WITH_LEGACY } from '@kbn/reporting-serve import { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import { RRule } from '@kbn/rrule'; import { DEFAULT_SPACE_ID } from '@kbn/spaces-utils'; -import { ReportApiJSON } from '@kbn/reporting-common/types'; +import type { ReportApiJSON } from '@kbn/reporting-common/types'; +import type { BulkGetResult } from '@kbn/task-manager-plugin/server/task_store'; +import { isOk } from '@kbn/task-manager-plugin/server/lib/result_type'; import type { ReportingCore } from '../../..'; import type { ListScheduledReportApiJSON, @@ -65,33 +67,44 @@ export type CreatedAtSearchResponse = SearchResponse<{ created_at: string }>; export function transformSingleResponse( logger: Logger, so: SavedObjectsFindResult, - lastResponse?: CreatedAtSearchResponse + lastResponse?: CreatedAtSearchResponse, + nextRunResponse?: BulkGetResult ) { const id = so.id; const lastRunForId = (lastResponse?.hits.hits ?? []).find( (hit) => hit.fields?.[SCHEDULED_REPORT_ID_FIELD]?.[0] === id ); + const nextRunForId = (nextRunResponse ?? []).find( + (taskOrError) => isOk(taskOrError) && taskOrError.value.id === id + ); + + let nextRun: string | undefined; + if (!nextRunForId) { + // try to calculate dynamically if we were not able to get from the task + const schedule = so.attributes.schedule; - const schedule = so.attributes.schedule; - - // get start date - let dtstart = new Date(); - const rruleStart = schedule.rrule.dtstart; - if (rruleStart) { - try { - // if start date is provided and in the future, use it, otherwise use current time - const startDateValue = new Date(rruleStart).valueOf(); - const now = Date.now(); - if (startDateValue > now) { - dtstart = new Date(startDateValue + 60000); // add 1 minute to ensure it's in the future + // get start date + let dtstart = new Date(); + const rruleStart = schedule.rrule.dtstart; + if (rruleStart) { + try { + // if start date is provided and in the future, use it, otherwise use current time + const startDateValue = new Date(rruleStart).valueOf(); + const now = Date.now(); + if (startDateValue > now) { + dtstart = new Date(startDateValue + 60000); // add 1 minute to ensure it's in the future + } + } catch (e) { + logger.debug( + `Failed to parse rrule.dtstart for scheduled report next run calculation - default to now ${id}: ${e.message}` + ); } - } catch (e) { - logger.debug( - `Failed to parse rrule.dtstart for scheduled report next run calculation - default to now ${id}: ${e.message}` - ); } + const _rrule = new RRule({ ...schedule.rrule, dtstart }); + nextRun = _rrule.after(new Date())?.toISOString(); + } else { + nextRun = isOk(nextRunForId) ? nextRunForId.value.runAt.toISOString() : undefined; } - const _rrule = new RRule({ ...schedule.rrule, dtstart }); let payload: ReportApiJSON['payload'] | undefined; try { @@ -107,7 +120,7 @@ export function transformSingleResponse( enabled: so.attributes.enabled, jobtype: so.attributes.jobType, last_run: lastRunForId?._source?.[CREATED_AT_FIELD], - next_run: _rrule.after(new Date())?.toISOString(), + next_run: nextRun, notification: so.attributes.notification, payload, schedule: so.attributes.schedule, @@ -119,13 +132,16 @@ export function transformSingleResponse( export function transformResponse( logger: Logger, result: SavedObjectsFindResponse, - lastResponse?: CreatedAtSearchResponse + lastResponse?: CreatedAtSearchResponse, + nextRunResponse?: BulkGetResult ): ApiResponse { return { page: result.page, per_page: result.per_page, total: result.total, - data: result.saved_objects.map((so) => transformSingleResponse(logger, so, lastResponse)), + data: result.saved_objects.map((so) => + transformSingleResponse(logger, so, lastResponse, nextRunResponse) + ), }; } @@ -154,6 +170,7 @@ export function scheduledQueryFactory(reportingCore: ReportingCore): ScheduledQu const esClient = await reportingCore.getEsClient(); const auditLogger = await reportingCore.getAuditLogger(req); const savedObjectsClient = await reportingCore.getScopedSoClient(req); + const taskManager = await reportingCore.getTaskManager(); const username = getUsername(user); // if user has Manage Reporting privileges, we can list @@ -196,6 +213,8 @@ export function scheduledQueryFactory(reportingCore: ReportingCore): ScheduledQu ) ); + const scheduledReportIds = scheduledReportIdsAndName.map(({ id }) => id); + let lastRunResponse; try { lastRunResponse = (await esClient.asInternalUser.search({ @@ -208,7 +227,7 @@ export function scheduledQueryFactory(reportingCore: ReportingCore): ScheduledQu filter: [ { terms: { - [SCHEDULED_REPORT_ID_FIELD]: scheduledReportIdsAndName.map(({ id }) => id), + [SCHEDULED_REPORT_ID_FIELD]: scheduledReportIds, }, }, ], @@ -222,7 +241,15 @@ export function scheduledQueryFactory(reportingCore: ReportingCore): ScheduledQu logger.warn(`Error getting last run for scheduled reports: ${error.message}`); } - return transformResponse(logger, response, lastRunResponse); + let nextRunResponse; + try { + nextRunResponse = await taskManager.bulkGet(scheduledReportIds); + } catch (error) { + // swallow this error + logger.warn(`Error getting next run for scheduled reports: ${error.message}`); + } + + return transformResponse(logger, response, lastRunResponse, nextRunResponse); } catch (error) { throw res.customError({ statusCode: 500, diff --git a/x-pack/platform/plugins/private/reporting/server/test_helpers/create_mock_reportingplugin.ts b/x-pack/platform/plugins/private/reporting/server/test_helpers/create_mock_reportingplugin.ts index 2a6d49b61e7f6..83d10699cd10a 100644 --- a/x-pack/platform/plugins/private/reporting/server/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/platform/plugins/private/reporting/server/test_helpers/create_mock_reportingplugin.ts @@ -99,6 +99,7 @@ export const createMockPluginStart = async ( taskManager: { schedule: jest.fn().mockImplementation(() => ({ id: 'taskId' })), ensureScheduled: jest.fn(), + bulkGet: jest.fn(), }, licensing: { ...licensingMock.createStart(), diff --git a/x-pack/platform/plugins/shared/task_manager/server/mocks.ts b/x-pack/platform/plugins/shared/task_manager/server/mocks.ts index b800cb2c9af61..3bfe9c3467bc1 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/mocks.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/mocks.ts @@ -24,6 +24,7 @@ const createStartMock = () => { const mock: jest.Mocked = { fetch: jest.fn(), get: jest.fn(), + bulkGet: jest.fn(), aggregate: jest.fn(), remove: jest.fn(), bulkRemove: jest.fn(), diff --git a/x-pack/platform/plugins/shared/task_manager/server/plugin.ts b/x-pack/platform/plugins/shared/task_manager/server/plugin.ts index 2e09c55fe15b1..719010d655603 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/plugin.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/plugin.ts @@ -90,7 +90,7 @@ export type TaskManagerStartContract = Pick< | 'bulkSchedule' | 'bulkUpdateState' > & - Pick & { + Pick & { removeIfExists: TaskStore['remove']; } & { supportsEphemeralTasks: () => boolean; @@ -438,6 +438,7 @@ export class TaskManagerPlugin aggregate: (opts: AggregationOpts): Promise> => taskStore.aggregate(opts), get: (id: string) => taskStore.get(id), + bulkGet: (...args) => taskStore.bulkGet(...args), remove: (id: string) => taskStore.remove(id), bulkRemove: (ids: string[]) => taskStore.bulkRemove(ids), removeIfExists: (id: string) => removeIfExists(taskStore, id),