Skip to content

Commit a8e1f62

Browse files
committed
[Alerting] Handle when an Alerting Task fails due to its Alert object being deleted mid flight (elastic#63093)
Detects if a task run failed due to the task SO being deleted mid flight and if so writes debug logs instead of warnings. Detects if an Alerting task run failed due to the alert SO being deleted mid flight of the task and if so ensures the task doesn't reschedule itself (as it usually would with other types of tasks). Ensures that the operation of deleting or disabling an Alert won't fail if it fails to delete an already deleted task (a task might preemptively self delete if its underlying alert object was deleted, even if the overall delete operation wasn't deleted).
1 parent 103ef92 commit a8e1f62

File tree

12 files changed

+240
-21
lines changed

12 files changed

+240
-21
lines changed

x-pack/plugins/alerting/server/alerts_client.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
import { EncryptedSavedObjectsPluginStart } from '../../../plugins/encrypted_saved_objects/server';
3434
import { TaskManagerStartContract } from '../../../plugins/task_manager/server';
3535
import { taskInstanceToAlertTaskInstance } from './task_runner/alert_task_instance';
36+
import { deleteTaskIfItExists } from './lib/delete_task_if_it_exists';
3637

3738
type NormalizedAlertAction = Omit<AlertAction, 'actionTypeId'>;
3839
export type CreateAPIKeyResult =
@@ -263,7 +264,7 @@ export class AlertsClient {
263264
const removeResult = await this.savedObjectsClient.delete('alert', id);
264265

265266
await Promise.all([
266-
taskIdToRemove ? this.taskManager.remove(taskIdToRemove) : null,
267+
taskIdToRemove ? deleteTaskIfItExists(this.taskManager, taskIdToRemove) : null,
267268
apiKeyToInvalidate ? this.invalidateApiKey({ apiKey: apiKeyToInvalidate }) : null,
268269
]);
269270

@@ -505,7 +506,9 @@ export class AlertsClient {
505506
);
506507

507508
await Promise.all([
508-
attributes.scheduledTaskId ? this.taskManager.remove(attributes.scheduledTaskId) : null,
509+
attributes.scheduledTaskId
510+
? deleteTaskIfItExists(this.taskManager, attributes.scheduledTaskId)
511+
: null,
509512
apiKeyToInvalidate ? this.invalidateApiKey({ apiKey: apiKeyToInvalidate }) : null,
510513
]);
511514
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import uuid from 'uuid';
8+
import { taskManagerMock } from '../../../task_manager/server/mocks';
9+
import { SavedObjectsErrorHelpers } from '../../../../../src/core/server';
10+
import { deleteTaskIfItExists } from './delete_task_if_it_exists';
11+
12+
describe('deleteTaskIfItExists', () => {
13+
test('removes the task by its ID', async () => {
14+
const tm = taskManagerMock.createStart();
15+
const id = uuid.v4();
16+
17+
expect(await deleteTaskIfItExists(tm, id)).toBe(undefined);
18+
19+
expect(tm.remove).toHaveBeenCalledWith(id);
20+
});
21+
22+
test('handles 404 errors caused by the task not existing', async () => {
23+
const tm = taskManagerMock.createStart();
24+
const id = uuid.v4();
25+
26+
tm.remove.mockRejectedValue(SavedObjectsErrorHelpers.createGenericNotFoundError('task', id));
27+
28+
expect(await deleteTaskIfItExists(tm, id)).toBe(undefined);
29+
30+
expect(tm.remove).toHaveBeenCalledWith(id);
31+
});
32+
33+
test('throws if any other errro is caused by task removal', async () => {
34+
const tm = taskManagerMock.createStart();
35+
const id = uuid.v4();
36+
37+
const error = SavedObjectsErrorHelpers.createInvalidVersionError(uuid.v4());
38+
tm.remove.mockRejectedValue(error);
39+
40+
expect(deleteTaskIfItExists(tm, id)).rejects.toBe(error);
41+
42+
expect(tm.remove).toHaveBeenCalledWith(id);
43+
});
44+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
import { TaskManagerStartContract } from '../../../task_manager/server';
7+
import { SavedObjectsErrorHelpers } from '../../../../../src/core/server';
8+
9+
export async function deleteTaskIfItExists(taskManager: TaskManagerStartContract, taskId: string) {
10+
try {
11+
await taskManager.remove(taskId);
12+
} catch (err) {
13+
if (!SavedObjectsErrorHelpers.isNotFoundError(err)) {
14+
throw err;
15+
}
16+
}
17+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { isAlertSavedObjectNotFoundError } from './is_alert_not_found_error';
8+
import { SavedObjectsErrorHelpers } from '../../../../../src/core/server';
9+
import uuid from 'uuid';
10+
11+
describe('isAlertSavedObjectNotFoundError', () => {
12+
test('identifies SavedObjects Not Found errors', () => {
13+
const id = uuid.v4();
14+
// ensure the error created by SO parses as a string with the format we expect
15+
expect(
16+
`${SavedObjectsErrorHelpers.createGenericNotFoundError('alert', id)}`.includes(`alert/${id}`)
17+
).toBe(true);
18+
19+
const errorBySavedObjectsHelper = SavedObjectsErrorHelpers.createGenericNotFoundError(
20+
'alert',
21+
id
22+
);
23+
24+
expect(isAlertSavedObjectNotFoundError(errorBySavedObjectsHelper, id)).toBe(true);
25+
});
26+
27+
test('identifies generic errors', () => {
28+
const id = uuid.v4();
29+
expect(isAlertSavedObjectNotFoundError(new Error(`not found`), id)).toBe(false);
30+
});
31+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { SavedObjectsErrorHelpers } from '../../../../../src/core/server';
8+
9+
export function isAlertSavedObjectNotFoundError(err: Error, alertId: string) {
10+
return SavedObjectsErrorHelpers.isNotFoundError(err) && `${err}`.includes(alertId);
11+
}

x-pack/plugins/alerting/server/task_runner/task_runner.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { encryptedSavedObjectsMock } from '../../../../plugins/encrypted_saved_o
1414
import { savedObjectsClientMock, loggingServiceMock } from '../../../../../src/core/server/mocks';
1515
import { PluginStartContract as ActionsPluginStart } from '../../../actions/server';
1616
import { actionsMock } from '../../../actions/server/mocks';
17+
import { SavedObjectsErrorHelpers } from '../../../../../src/core/server';
1718

1819
const alertType = {
1920
id: 'test',
@@ -501,4 +502,36 @@ describe('Task Runner', () => {
501502
}
502503
`);
503504
});
505+
506+
test('avoids rescheduling a failed Alert Task Runner when it throws due to failing to fetch the alert', async () => {
507+
savedObjectsClient.get.mockImplementation(() => {
508+
throw SavedObjectsErrorHelpers.createGenericNotFoundError('task', '1');
509+
});
510+
511+
const taskRunner = new TaskRunner(
512+
alertType,
513+
mockedTaskInstance,
514+
taskRunnerFactoryInitializerParams
515+
);
516+
517+
encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({
518+
id: '1',
519+
type: 'alert',
520+
attributes: {
521+
apiKey: Buffer.from('123:abc').toString('base64'),
522+
},
523+
references: [],
524+
});
525+
526+
const runnerResult = await taskRunner.run();
527+
528+
expect(runnerResult).toMatchInlineSnapshot(`
529+
Object {
530+
"runAt": undefined,
531+
"state": Object {
532+
"previousStartedAt": 1970-01-01T00:00:00.000Z,
533+
},
534+
}
535+
`);
536+
});
504537
});

x-pack/plugins/alerting/server/task_runner/task_runner.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,13 @@ import {
2424
import { promiseResult, map, Resultable, asOk, asErr, resolveErr } from '../lib/result_type';
2525
import { taskInstanceToAlertTaskInstance } from './alert_task_instance';
2626
import { AlertInstances } from '../alert_instance/alert_instance';
27+
import { isAlertSavedObjectNotFoundError } from '../lib/is_alert_not_found_error';
2728

2829
const FALLBACK_RETRY_INTERVAL: IntervalSchedule = { interval: '5m' };
2930

3031
interface AlertTaskRunResult {
3132
state: AlertTaskState;
32-
runAt: Date;
33+
runAt: Date | undefined;
3334
}
3435

3536
interface AlertTaskInstance extends ConcreteTaskInstance {
@@ -293,22 +294,29 @@ export class TaskRunner {
293294
};
294295
},
295296
(err: Error) => {
296-
this.logger.error(`Executing Alert "${alertId}" has resulted in Error: ${err.message}`);
297+
const message = `Executing Alert "${alertId}" has resulted in Error: ${err.message}`;
298+
if (isAlertSavedObjectNotFoundError(err, alertId)) {
299+
this.logger.debug(message);
300+
} else {
301+
this.logger.error(message);
302+
}
297303
return {
298304
...originalState,
299305
previousStartedAt,
300306
};
301307
}
302308
),
303-
runAt: resolveErr<Date, Error>(runAt, () =>
304-
getNextRunAt(
305-
new Date(),
306-
// if we fail at this point we wish to recover but don't have access to the Alert's
307-
// attributes, so we'll use a default interval to prevent the underlying task from
308-
// falling into a failed state
309-
FALLBACK_RETRY_INTERVAL
310-
)
311-
),
309+
runAt: resolveErr<Date | undefined, Error>(runAt, err => {
310+
return isAlertSavedObjectNotFoundError(err, alertId)
311+
? undefined
312+
: getNextRunAt(
313+
new Date(),
314+
// if we fail at this point we wish to recover but don't have access to the Alert's
315+
// attributes, so we'll use a default interval to prevent the underlying task from
316+
// falling into a failed state
317+
FALLBACK_RETRY_INTERVAL
318+
);
319+
}),
312320
};
313321
}
314322
}

x-pack/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.test.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
* you may not use this file except in compliance with the Elastic License.
55
*/
66

7-
import moment from 'moment';
87
import {
98
getMockCallWithInternal,
109
getMockConfig,
@@ -13,6 +12,7 @@ import {
1312
} from '../../../test_utils';
1413
import { visualizationsTaskRunner } from './task_runner';
1514
import { TaskInstance } from '../../../../../task_manager/server';
15+
import { getNextMidnight } from '../../get_next_midnight';
1616

1717
describe('visualizationsTaskRunner', () => {
1818
let mockTaskInstance: TaskInstance;
@@ -41,12 +41,6 @@ describe('visualizationsTaskRunner', () => {
4141
});
4242

4343
test('Summarizes visualization response data', async () => {
44-
const getNextMidnight = () =>
45-
moment()
46-
.add(1, 'days')
47-
.startOf('day')
48-
.toDate();
49-
5044
const runner = visualizationsTaskRunner(mockTaskInstance, getMockConfig(), getMockEs());
5145
const result = await runner();
5246

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { isTaskSavedObjectNotFoundError } from './is_task_not_found_error';
8+
import { SavedObjectsErrorHelpers } from '../../../../../src/core/server';
9+
import uuid from 'uuid';
10+
11+
describe('isTaskSavedObjectNotFoundError', () => {
12+
test('identifies SavedObjects Not Found errors', () => {
13+
const id = uuid.v4();
14+
// ensure the error created by SO parses as a string with the format we expect
15+
expect(
16+
`${SavedObjectsErrorHelpers.createGenericNotFoundError('task', id)}`.includes(`task/${id}`)
17+
).toBe(true);
18+
19+
const errorBySavedObjectsHelper = SavedObjectsErrorHelpers.createGenericNotFoundError(
20+
'task',
21+
id
22+
);
23+
24+
expect(isTaskSavedObjectNotFoundError(errorBySavedObjectsHelper, id)).toBe(true);
25+
});
26+
27+
test('identifies generic errors', () => {
28+
const id = uuid.v4();
29+
expect(isTaskSavedObjectNotFoundError(new Error(`not found`), id)).toBe(false);
30+
});
31+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { SavedObjectsErrorHelpers } from '../../../../../src/core/server';
8+
9+
export function isTaskSavedObjectNotFoundError(err: Error, taskId: string) {
10+
return SavedObjectsErrorHelpers.isNotFoundError(err) && `${err}`.includes(taskId);
11+
}

0 commit comments

Comments
 (0)