Skip to content

Commit 61467b1

Browse files
authored
[Alerting V2] Reorganize SO updates and task scheduling in rules_client. (elastic#281214)
Closes elastic/rna-program#779 ## Summary Fixes the ordering of SO update operations and task manager scheduling in the rules client: - Delete - `bulkDelete(soDescriptors)` first - `taskManager.bulkRemove(taskIds)` - Enable - `taskManager.bulkSchedule(...)` first - `bulkUpdate(itemsToUpdate)` - Disable - The ordering remains the same, but we do not fail silently anymore. The errors are now logged with `code: 'TASK_MANAGER_DRIFT'`.
1 parent ab45576 commit 61467b1

3 files changed

Lines changed: 348 additions & 72 deletions

File tree

x-pack/platform/plugins/shared/alerting_v2/server/lib/errors/error_codes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ export const ALERTING_V2_ERROR_CODES = {
5151
SCHEDULE_INTERVAL_TOO_SHORT: 'SCHEDULE_INTERVAL_TOO_SHORT',
5252
/** Scheduling the rule would exceed the configured maximum rule runs per minute. */
5353
MAX_SCHEDULES_PER_MINUTE_EXCEEDED: 'MAX_SCHEDULES_PER_MINUTE_EXCEEDED',
54+
/**
55+
* A bulk operation persisted the rule saved object, but the paired Task
56+
* Manager call failed, leaving the rule's task state diverged from its saved object.
57+
* The saved-object change already committed; this entry flags the drift so the client
58+
* can detect and (optionally) retry.
59+
*/
60+
TASK_MANAGER_DRIFT: 'TASK_MANAGER_DRIFT',
5461
/** A manual "run now" was requested for a disabled rule (it has no executor task to run). */
5562
RULE_DISABLED: 'RULE_DISABLED',
5663
/** A manual "run now" was requested for a rule whose executor task is already running. */

x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.test.ts

Lines changed: 207 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ describe('RulesClient', () => {
8787

8888
ensureRuleExecutorTaskScheduledMock.mockResolvedValue({ id: 'task-123' });
8989
getRuleExecutorTaskIdMock.mockReturnValue('task:fallback');
90+
91+
taskManager.bulkRemove.mockResolvedValue({ statuses: [] });
9092
});
9193

9294
afterAll(() => {
@@ -1367,7 +1369,44 @@ describe('RulesClient', () => {
13671369
]);
13681370
});
13691371

1370-
it('continues with deletion even if task removal fails', async () => {
1372+
it('deletes the saved objects before removing the tasks', async () => {
1373+
const client = createClient();
1374+
1375+
const callOrder: string[] = [];
1376+
rulesSavedObjectService.bulkDelete.mockImplementationOnce(async () => {
1377+
callOrder.push('bulkDelete');
1378+
return [{ id: 'rule-1', success: true }];
1379+
});
1380+
taskManager.bulkRemove.mockImplementationOnce(async () => {
1381+
callOrder.push('bulkRemove');
1382+
return { statuses: [] };
1383+
});
1384+
1385+
await client.bulkDeleteRules({ ids: ['rule-1'] });
1386+
1387+
expect(callOrder).toEqual(['bulkDelete', 'bulkRemove']);
1388+
});
1389+
1390+
it('only removes tasks for rules whose saved object was deleted', async () => {
1391+
const client = createClient();
1392+
1393+
getRuleExecutorTaskIdMock.mockReturnValueOnce('task:rule-1');
1394+
1395+
rulesSavedObjectService.bulkDelete.mockResolvedValueOnce([
1396+
{ id: 'rule-1', success: true },
1397+
{
1398+
id: 'rule-2',
1399+
success: false,
1400+
error: { error: 'Not Found', message: 'Rule not found', statusCode: 404 },
1401+
},
1402+
]);
1403+
1404+
await client.bulkDeleteRules({ ids: ['rule-1', 'rule-2'] });
1405+
1406+
expect(taskManager.bulkRemove).toHaveBeenCalledWith(['task:rule-1']);
1407+
});
1408+
1409+
it('surfaces TASK_MANAGER_DRIFT errors when task removal fails', async () => {
13711410
const client = createClient();
13721411

13731412
getRuleExecutorTaskIdMock
@@ -1383,7 +1422,70 @@ describe('RulesClient', () => {
13831422

13841423
const res = await client.bulkDeleteRules({ ids: ['rule-1', 'rule-2'] });
13851424

1386-
expect(res).toEqual({ affected_count: 2, errors: [] });
1425+
// The saved objects are gone (affected), but the orphan tasks are flagged.
1426+
expect(res.affected_count).toBe(2);
1427+
expect(res.errors).toEqual([
1428+
{ id: 'rule-1', error: expect.objectContaining({ code: 'TASK_MANAGER_DRIFT' }) },
1429+
{ id: 'rule-2', error: expect.objectContaining({ code: 'TASK_MANAGER_DRIFT' }) },
1430+
]);
1431+
expect(mockLogger.error).toHaveBeenCalledTimes(1);
1432+
});
1433+
1434+
it('surfaces TASK_MANAGER_DRIFT for per-task failures in the bulkRemove statuses (no throw)', async () => {
1435+
const client = createClient();
1436+
1437+
getRuleExecutorTaskIdMock
1438+
.mockReturnValueOnce('task:rule-1')
1439+
.mockReturnValueOnce('task:rule-2');
1440+
1441+
rulesSavedObjectService.bulkDelete.mockResolvedValueOnce([
1442+
{ id: 'rule-1', success: true },
1443+
{ id: 'rule-2', success: true },
1444+
]);
1445+
1446+
taskManager.bulkRemove.mockResolvedValueOnce({
1447+
statuses: [
1448+
{
1449+
id: 'task:rule-1',
1450+
type: 'task',
1451+
success: false,
1452+
error: { error: 'Internal', message: 'boom', statusCode: 500 },
1453+
},
1454+
{ id: 'task:rule-2', type: 'task', success: true },
1455+
],
1456+
});
1457+
1458+
const res = await client.bulkDeleteRules({ ids: ['rule-1', 'rule-2'] });
1459+
1460+
expect(res.affected_count).toBe(2);
1461+
expect(res.errors).toEqual([
1462+
{ id: 'rule-1', error: expect.objectContaining({ code: 'TASK_MANAGER_DRIFT' }) },
1463+
]);
1464+
expect(mockLogger.error).toHaveBeenCalledTimes(1);
1465+
});
1466+
1467+
it('ignores missing tasks (404) in the bulkRemove statuses — the task is already gone', async () => {
1468+
const client = createClient();
1469+
1470+
getRuleExecutorTaskIdMock.mockReturnValueOnce('task:rule-1');
1471+
1472+
rulesSavedObjectService.bulkDelete.mockResolvedValueOnce([{ id: 'rule-1', success: true }]);
1473+
1474+
taskManager.bulkRemove.mockResolvedValueOnce({
1475+
statuses: [
1476+
{
1477+
id: 'task:rule-1',
1478+
type: 'task',
1479+
success: false,
1480+
error: { error: 'Not Found', message: 'not found', statusCode: 404 },
1481+
},
1482+
],
1483+
});
1484+
1485+
const res = await client.bulkDeleteRules({ ids: ['rule-1'] });
1486+
1487+
expect(res).toEqual({ affected_count: 1, errors: [] });
1488+
expect(mockLogger.error).not.toHaveBeenCalled();
13871489
});
13881490

13891491
it('returns a zero-affected empty response when ids is an empty array', async () => {
@@ -1439,7 +1541,7 @@ describe('RulesClient', () => {
14391541
expect(res).toEqual({ affected_count: 1, errors: [] });
14401542
});
14411543

1442-
it('logs a warning when task scheduling fails but still counts the rule as affected', async () => {
1544+
it('schedules the tasks before persisting enabled=true', async () => {
14431545
const client = createClient();
14441546

14451547
const disabledAttrs = createRuleSoAttributes({
@@ -1451,17 +1553,107 @@ describe('RulesClient', () => {
14511553
{ id: 'rule-1', attributes: disabledAttrs, version: 'v1' },
14521554
]);
14531555

1454-
rulesSavedObjectService.bulkUpdate.mockResolvedValueOnce([{ id: 'rule-1', success: true }]);
1556+
const callOrder: string[] = [];
1557+
taskManager.bulkSchedule.mockImplementationOnce(async () => {
1558+
callOrder.push('bulkSchedule');
1559+
return [];
1560+
});
1561+
rulesSavedObjectService.bulkUpdate.mockImplementationOnce(async () => {
1562+
callOrder.push('bulkUpdate');
1563+
return [{ id: 'rule-1', success: true }];
1564+
});
1565+
1566+
await client.bulkEnableRules({ ids: ['rule-1'] });
1567+
1568+
expect(callOrder).toEqual(['bulkSchedule', 'bulkUpdate']);
1569+
});
1570+
1571+
it('leaves rules disabled and surfaces TASK_MANAGER_DRIFT when scheduling fails', async () => {
1572+
const client = createClient();
1573+
1574+
const disabledAttrs = createRuleSoAttributes({
1575+
metadata: { name: 'disabled-rule' },
1576+
enabled: false,
1577+
});
1578+
1579+
rulesSavedObjectService.bulkGetByIds.mockResolvedValueOnce([
1580+
{ id: 'rule-1', attributes: disabledAttrs, version: 'v1' },
1581+
]);
14551582

1583+
getRuleExecutorTaskIdMock.mockReturnValue('task:rule-1');
14561584
taskManager.bulkSchedule.mockRejectedValueOnce(new Error('Failed to grant UIAM API key'));
14571585

14581586
const res = await client.bulkEnableRules({ ids: ['rule-1'] });
14591587

1460-
expect(mockLogger.warn).toHaveBeenCalledTimes(1);
1461-
expect(mockLogger.warn).toHaveBeenCalledWith(
1462-
expect.stringContaining('Failed to grant UIAM API key')
1588+
// Scheduling failed first, so the saved object is never flipped to enabled.
1589+
expect(rulesSavedObjectService.bulkUpdate).not.toHaveBeenCalled();
1590+
expect(taskManager.bulkRemove).toHaveBeenCalledWith(['task:rule-1']);
1591+
expect(res.affected_count).toBe(0);
1592+
expect(res.errors).toEqual([
1593+
{ id: 'rule-1', error: expect.objectContaining({ code: 'TASK_MANAGER_DRIFT' }) },
1594+
]);
1595+
expect(mockLogger.error).toHaveBeenCalledTimes(1);
1596+
expect(ruleEventPublisher.emitRuleEnabled).not.toHaveBeenCalled();
1597+
});
1598+
1599+
it('rolls back partially scheduled tasks when bulkSchedule throws', async () => {
1600+
const client = createClient();
1601+
1602+
const disabledAttrs = createRuleSoAttributes({
1603+
metadata: { name: 'disabled-rule' },
1604+
enabled: false,
1605+
});
1606+
1607+
rulesSavedObjectService.bulkGetByIds.mockResolvedValueOnce([
1608+
{ id: 'rule-1', attributes: disabledAttrs, version: 'v1' },
1609+
{ id: 'rule-2', attributes: disabledAttrs, version: 'v1' },
1610+
]);
1611+
1612+
getRuleExecutorTaskIdMock.mockImplementation(
1613+
({ ruleId }: { ruleId: string }) => `task:${ruleId}`
14631614
);
1464-
expect(res).toEqual({ affected_count: 1, errors: [] });
1615+
taskManager.bulkSchedule.mockRejectedValueOnce(new Error('partial schedule failure'));
1616+
1617+
const res = await client.bulkEnableRules({ ids: ['rule-1', 'rule-2'] });
1618+
1619+
expect(rulesSavedObjectService.bulkUpdate).not.toHaveBeenCalled();
1620+
expect(taskManager.bulkRemove).toHaveBeenCalledWith(['task:rule-1', 'task:rule-2']);
1621+
expect(res.affected_count).toBe(0);
1622+
expect(res.errors).toEqual([
1623+
{ id: 'rule-1', error: expect.objectContaining({ code: 'TASK_MANAGER_DRIFT' }) },
1624+
{ id: 'rule-2', error: expect.objectContaining({ code: 'TASK_MANAGER_DRIFT' }) },
1625+
]);
1626+
});
1627+
1628+
it('cancels the just-scheduled task when the saved object update fails', async () => {
1629+
const client = createClient();
1630+
1631+
const disabledAttrs = createRuleSoAttributes({
1632+
metadata: { name: 'disabled-rule' },
1633+
enabled: false,
1634+
});
1635+
1636+
rulesSavedObjectService.bulkGetByIds.mockResolvedValueOnce([
1637+
{ id: 'rule-1', attributes: disabledAttrs, version: 'v1' },
1638+
]);
1639+
1640+
getRuleExecutorTaskIdMock.mockReturnValue('task:rule-1');
1641+
1642+
rulesSavedObjectService.bulkUpdate.mockResolvedValueOnce([
1643+
{
1644+
id: 'rule-1',
1645+
success: false,
1646+
error: { statusCode: 409, error: 'Conflict', message: 'Version conflict' },
1647+
},
1648+
]);
1649+
1650+
const res = await client.bulkEnableRules({ ids: ['rule-1'] });
1651+
1652+
expect(taskManager.bulkRemove).toHaveBeenCalledWith(['task:rule-1']);
1653+
expect(res.affected_count).toBe(0);
1654+
expect(res.errors).toEqual([
1655+
{ id: 'rule-1', error: { code: 'RULE_VERSION_CONFLICT', message: 'Version conflict' } },
1656+
]);
14651657
});
14661658

14671659
it('counts already-enabled rules as affected without updating them (idempotent)', async () => {
@@ -1620,7 +1812,7 @@ describe('RulesClient', () => {
16201812
]);
16211813
});
16221814

1623-
it('logs a warning and still counts the rule as affected when task removal fails', async () => {
1815+
it('surfaces TASK_MANAGER_DRIFT errors when the task removal fails', async () => {
16241816
const client = createClient();
16251817

16261818
const enabledAttrs = createRuleSoAttributes({
@@ -1639,9 +1831,12 @@ describe('RulesClient', () => {
16391831

16401832
const res = await client.bulkDisableRules({ ids: ['rule-1'] });
16411833

1642-
expect(mockLogger.warn).toHaveBeenCalledTimes(1);
1643-
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('task removal failed'));
1644-
expect(res).toEqual({ affected_count: 1, errors: [] });
1834+
// The saved object is disabled (affected), but the task drift is flagged.
1835+
expect(res.affected_count).toBe(1);
1836+
expect(res.errors).toEqual([
1837+
{ id: 'rule-1', error: expect.objectContaining({ code: 'TASK_MANAGER_DRIFT' }) },
1838+
]);
1839+
expect(mockLogger.error).toHaveBeenCalledTimes(1);
16451840
});
16461841

16471842
it('returns a zero-affected empty response when ids is an empty array', async () => {

0 commit comments

Comments
 (0)