forked from elastic/kibana
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
make updates buffered in task manager
- Loading branch information
Showing
7 changed files
with
344 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License; | ||
* you may not use this file except in compliance with the Elastic License. | ||
*/ | ||
|
||
import { TaskStore } from './task_store'; | ||
import { ConcreteTaskInstance } from './task'; | ||
import { Updatable } from './task_runner'; | ||
import { createBuffer, Operation } from './lib/bulk_operation_buffer'; | ||
import { unwrapPromise, mapErr } from './lib/result_type'; | ||
|
||
export class BufferedTaskStore implements Updatable { | ||
private bufferedUpdate: Operation<ConcreteTaskInstance, Error>; | ||
constructor(private readonly taskStore: TaskStore) { | ||
this.bufferedUpdate = createBuffer<ConcreteTaskInstance, Error>(async (docs) => { | ||
return (await taskStore.bulkUpdate(docs)).map((entityOrError, index) => | ||
mapErr( | ||
(error: Error) => ({ | ||
entity: docs[index], | ||
error, | ||
}), | ||
entityOrError | ||
) | ||
); | ||
}); | ||
} | ||
|
||
public get maxAttempts(): number { | ||
return this.taskStore.maxAttempts; | ||
} | ||
|
||
public async update(doc: ConcreteTaskInstance): Promise<ConcreteTaskInstance> { | ||
return unwrapPromise(this.bufferedUpdate(doc)); | ||
} | ||
|
||
public async remove(id: string): Promise<void> { | ||
return this.taskStore.remove(id); | ||
} | ||
} |
167 changes: 167 additions & 0 deletions
167
x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,167 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License; | ||
* you may not use this file except in compliance with the Elastic License. | ||
*/ | ||
|
||
import { createBuffer, Entity, OperationError, BulkOperation } from './bulk_operation_buffer'; | ||
import { mapErr, asOk, asErr, Ok, Err } from './result_type'; | ||
|
||
interface TaskInstance extends Entity { | ||
attempts: number; | ||
} | ||
|
||
const createTask = (function (): () => TaskInstance { | ||
let counter = 0; | ||
return () => ({ | ||
id: `task ${++counter}`, | ||
attempts: 1, | ||
}); | ||
})(); | ||
|
||
function incrementAttempts(task: TaskInstance): Ok<TaskInstance> { | ||
return asOk({ | ||
...task, | ||
attempts: task.attempts + 1, | ||
}); | ||
} | ||
|
||
function errorAttempts(task: TaskInstance): Err<OperationError<TaskInstance, Error>> { | ||
return asErr({ | ||
entity: incrementAttempts(task).value, | ||
error: { name: '', message: 'Oh no, something went terribly wrong', statusCode: 500 }, | ||
}); | ||
} | ||
|
||
describe('Task Store Buffer', () => { | ||
describe('createBuffer()', () => { | ||
test('batches up multiple Operation calls', async () => { | ||
const bulkUpdate: jest.Mocked<BulkOperation<TaskInstance, Error>> = jest.fn( | ||
([task1, task2]) => { | ||
return Promise.resolve([incrementAttempts(task1), incrementAttempts(task2)]); | ||
} | ||
); | ||
|
||
const bufferedUpdate = createBuffer(bulkUpdate); | ||
|
||
const task1 = createTask(); | ||
const task2 = createTask(); | ||
|
||
expect(await Promise.all([bufferedUpdate(task1), bufferedUpdate(task2)])).toMatchObject([ | ||
incrementAttempts(task1), | ||
incrementAttempts(task2), | ||
]); | ||
expect(bulkUpdate).toHaveBeenCalledWith([task1, task2]); | ||
}); | ||
|
||
test('batch updates are executed at most by the next Event Loop tick', async () => { | ||
const bulkUpdate: jest.Mocked<BulkOperation<TaskInstance, Error>> = jest.fn((tasks) => { | ||
return Promise.resolve(tasks.map(incrementAttempts)); | ||
}); | ||
|
||
const bufferedUpdate = createBuffer(bulkUpdate); | ||
|
||
const task1 = createTask(); | ||
const task2 = createTask(); | ||
const task3 = createTask(); | ||
const task4 = createTask(); | ||
const task5 = createTask(); | ||
const task6 = createTask(); | ||
|
||
return new Promise((resolve) => { | ||
Promise.all([bufferedUpdate(task1), bufferedUpdate(task2)]).then((_) => { | ||
expect(bulkUpdate).toHaveBeenCalledTimes(1); | ||
expect(bulkUpdate).toHaveBeenCalledWith([task1, task2]); | ||
expect(bulkUpdate).not.toHaveBeenCalledWith([task3, task4]); | ||
}); | ||
|
||
setTimeout(() => { | ||
// on next tick | ||
setTimeout(() => { | ||
// on next tick | ||
expect(bulkUpdate).toHaveBeenCalledTimes(2); | ||
Promise.all([bufferedUpdate(task5), bufferedUpdate(task6)]).then((_) => { | ||
expect(bulkUpdate).toHaveBeenCalledTimes(3); | ||
expect(bulkUpdate).toHaveBeenCalledWith([task5, task6]); | ||
resolve(); | ||
}); | ||
}, 0); | ||
|
||
expect(bulkUpdate).toHaveBeenCalledTimes(1); | ||
Promise.all([bufferedUpdate(task3), bufferedUpdate(task4)]).then((_) => { | ||
expect(bulkUpdate).toHaveBeenCalledTimes(2); | ||
expect(bulkUpdate).toHaveBeenCalledWith([task3, task4]); | ||
}); | ||
}, 0); | ||
}); | ||
}); | ||
|
||
test('handles both resolutions and rejections at individual task level', async (done) => { | ||
const bulkUpdate: jest.Mocked<BulkOperation<TaskInstance, Error>> = jest.fn( | ||
([task1, task2, task3]) => { | ||
return Promise.resolve([ | ||
incrementAttempts(task1), | ||
errorAttempts(task2), | ||
incrementAttempts(task3), | ||
]); | ||
} | ||
); | ||
|
||
const bufferedUpdate = createBuffer(bulkUpdate); | ||
|
||
const task1 = createTask(); | ||
const task2 = createTask(); | ||
const task3 = createTask(); | ||
|
||
return Promise.all([ | ||
expect(bufferedUpdate(task1)).resolves.toMatchObject(incrementAttempts(task1)), | ||
expect(bufferedUpdate(task2)).rejects.toMatchObject( | ||
mapErr( | ||
(err: OperationError<TaskInstance, Error>) => asErr(err.error), | ||
errorAttempts(task2) | ||
) | ||
), | ||
expect(bufferedUpdate(task3)).resolves.toMatchObject(incrementAttempts(task3)), | ||
]).then(() => { | ||
expect(bulkUpdate).toHaveBeenCalledTimes(1); | ||
done(); | ||
}); | ||
}); | ||
|
||
test('handles bulkUpdate failure', async (done) => { | ||
const bulkUpdate: jest.Mocked<BulkOperation<TaskInstance, Error>> = jest.fn(() => { | ||
return Promise.reject(new Error('bulkUpdate is an illusion')); | ||
}); | ||
|
||
const bufferedUpdate = createBuffer(bulkUpdate); | ||
|
||
const task1 = createTask(); | ||
const task2 = createTask(); | ||
const task3 = createTask(); | ||
|
||
return Promise.all([ | ||
expect(bufferedUpdate(task1)).rejects.toMatchInlineSnapshot(` | ||
Object { | ||
"error": [Error: bulkUpdate is an illusion], | ||
"tag": "err", | ||
} | ||
`), | ||
expect(bufferedUpdate(task2)).rejects.toMatchInlineSnapshot(` | ||
Object { | ||
"error": [Error: bulkUpdate is an illusion], | ||
"tag": "err", | ||
} | ||
`), | ||
expect(bufferedUpdate(task3)).rejects.toMatchInlineSnapshot(` | ||
Object { | ||
"error": [Error: bulkUpdate is an illusion], | ||
"tag": "err", | ||
} | ||
`), | ||
]).then(() => { | ||
expect(bulkUpdate).toHaveBeenCalledTimes(1); | ||
done(); | ||
}); | ||
}); | ||
}); | ||
}); |
69 changes: 69 additions & 0 deletions
69
x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License; | ||
* you may not use this file except in compliance with the Elastic License. | ||
*/ | ||
|
||
import { keyBy, map } from 'lodash'; | ||
import { Subject } from 'rxjs'; | ||
import { bufferWhen, filter } from 'rxjs/operators'; | ||
import { either, Result, asOk, asErr, Ok, Err } from './result_type'; | ||
|
||
export interface Entity { | ||
id: string; | ||
} | ||
|
||
export interface OperationError<H, E> { | ||
entity: H; | ||
error: E; | ||
} | ||
|
||
export type OperationResult<H, E> = Result<H, OperationError<H, E>>; | ||
|
||
export type Operation<H, E> = (entity: H) => Promise<Result<H, E>>; | ||
export type BulkOperation<H, E> = (entities: H[]) => Promise<Array<OperationResult<H, E>>>; | ||
|
||
export function createBuffer<H extends Entity, E>( | ||
bulkOperation: BulkOperation<H, E> | ||
): Operation<H, E> { | ||
const flushBuffer = new Subject<void>(); | ||
const storeUpdateBuffer = new Subject<{ | ||
entity: H; | ||
onSuccess: (entity: Ok<H>) => void; | ||
onFailure: (error: Err<E>) => void; | ||
}>(); | ||
|
||
storeUpdateBuffer | ||
.pipe( | ||
bufferWhen(() => flushBuffer), | ||
filter((tasks) => tasks.length > 0) | ||
) | ||
.subscribe((entities) => { | ||
const entityById = keyBy(entities, ({ entity: { id } }) => id); | ||
bulkOperation(map(entities, 'entity')) | ||
.then((results) => { | ||
results.forEach((result) => | ||
either( | ||
result, | ||
(entity) => { | ||
entityById[entity.id].onSuccess(asOk(entity)); | ||
}, | ||
({ entity, error }: OperationError<H, E>) => { | ||
entityById[entity.id].onFailure(asErr(error)); | ||
} | ||
) | ||
); | ||
}) | ||
.catch((ex) => { | ||
entities.forEach(({ onFailure }) => onFailure(asErr(ex))); | ||
}); | ||
}); | ||
|
||
return async function (entity: H) { | ||
return new Promise((resolve, reject) => { | ||
// ensure we flush by the end of the "current" event loop tick | ||
setImmediate(() => flushBuffer.next()); | ||
storeUpdateBuffer.next({ entity, onSuccess: resolve, onFailure: reject }); | ||
}); | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.