Refactor into broker

This commit is contained in:
Johan Haals
2021-01-21 15:53:01 +01:00
parent 21e18aced4
commit 5e06a9f45b
8 changed files with 272 additions and 94 deletions
+2
View File
@@ -53,7 +53,9 @@
"helmet": "^4.0.0",
"isomorphic-git": "^1.8.0",
"jsonschema": "^1.2.6",
"luxon": "^1.25.0",
"morgan": "^1.10.0",
"p-queue": "^6.3.0",
"uuid": "^8.2.0",
"winston": "^3.2.1",
"yaml": "^1.10.0"
@@ -13,23 +13,79 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Task, Status } from './types';
import { DbTaskRow, TaskSpec } from './types';
import { v4 as uuid } from 'uuid';
import { DateTime } from 'luxon';
export class Database {
private readonly store = new Map<string, Task>();
export interface Database {
get(taskId: string): Promise<DbTaskRow>;
// updateTask(task: Task): Promise<DbTaskRow>;
createTask(task: TaskSpec): Promise<DbTaskRow>;
claimTask(): Promise<DbTaskRow>;
heartBeat(runId: string): Promise<void>;
}
get(taskId: string) {
return this.store.get(taskId);
export class InMemoryDatabase implements Database {
private readonly store = new Map<string, DbTaskRow>();
async heartBeat(runId: string): Promise<void> {
let task: DbTaskRow | undefined;
for (const t of this.store.values()) {
if (t.runId === runId) {
task = t;
}
}
if (!task) {
throw new Error('No task with matching runId found');
}
this.store.set(task.taskId, {
...task,
lastHeartbeat: DateTime.local().toString(),
});
}
write(task: Task) {
return task;
async claimTask(): Promise<DbTaskRow> {
for (const t of this.store.values()) {
if (t.status === 'OPEN') {
const task: DbTaskRow = {
...t,
status: 'PROCESSING',
runId: uuid(),
};
this.store.set(t.taskId, task);
return task;
}
}
throw new Error('No task found');
}
writeStatus(taskId: string, status: Status) {
async createTask(spec: TaskSpec): Promise<DbTaskRow> {
return {
taskId: uuid(),
spec,
status: 'OPEN',
retryCount: 0,
createdAt: new Date().toISOString(),
};
}
async get(taskId: string): Promise<DbTaskRow> {
const task = this.store.get(taskId);
if (task) {
this.store.set(taskId, { ...task, status });
return task;
}
throw new Error(`could not found task ${taskId}`);
}
// async updateTask(task: Task): Promise<Task> {
// if (!task.taskId) {
// throw new Error('Task must contain id');
// }
// this.store.set(task.taskId, task);
// return task;
// }
}
@@ -0,0 +1,63 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { MemoryTaskBroker, TaskAgent } from './TaskBroker';
describe('MemoryTaskBroker', () => {
it('should claim a dispatched work item', async () => {
const broker = new MemoryTaskBroker();
await broker.dispatch({});
await expect(broker.claim()).resolves.toEqual(expect.any(TaskAgent));
});
it('should wait for a dispatched work item', async () => {
const broker = new MemoryTaskBroker();
const promise = broker.claim();
await expect(Promise.race([promise, 'waiting'])).resolves.toBe('waiting');
await broker.dispatch({});
await expect(promise).resolves.toEqual(expect.any(TaskAgent));
});
it('should dispatch multiple items and claim them in order', async () => {
const broker = new MemoryTaskBroker();
await broker.dispatch({ name: 'a' });
await broker.dispatch({ name: 'b' });
await broker.dispatch({ name: 'c' });
const taskA = await broker.claim();
const taskB = await broker.claim();
const taskC = await broker.claim();
await expect(taskA).toEqual(expect.any(TaskAgent));
await expect(taskB).toEqual(expect.any(TaskAgent));
await expect(taskC).toEqual(expect.any(TaskAgent));
await expect(taskA.spec.name).toBe('a');
await expect(taskB.spec.name).toBe('b');
await expect(taskC.spec.name).toBe('c');
});
it('should complete a task', async () => {
const broker = new MemoryTaskBroker();
await broker.dispatch({});
const task = await broker.claim();
await task.complete('COMPLETED');
});
});
@@ -0,0 +1,109 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
CompletedTaskState,
Task,
TaskSpec,
TaskBroker,
Status,
} from './types';
import { v4 as uuid } from 'uuid';
import { InMemoryDatabase } from './database';
export class TaskAgent implements Task {
private heartbeartInterval?: ReturnType<typeof setInterval>;
static create(state: TaskState, db: InMemoryDatabase) {
const agent = new TaskAgent(state, db);
agent.start();
return agent;
}
// Runs heartbeat internally
private constructor(
private readonly state: TaskState,
private readonly db: InMemoryDatabase,
) {}
get spec() {
return this.state.spec;
}
async emitLog(message: string): Promise<void> {
throw new Error('Method not implemented.');
}
async complete(result: CompletedTaskState): Promise<void> {
this.state.status = result === 'FAILED' ? 'COMPLETED' : 'FAILED';
}
private start() {
this.heartbeartInterval = setInterval(() => {
const runId = 'iiiid';
this.db.heartBeat(runId);
}, 4269);
}
}
interface TaskState {
spec: TaskSpec;
status: Status;
runId: string | undefined;
}
function defer() {
let resolve = () => {};
const promise = new Promise<void>(_resolve => {
resolve = _resolve;
});
return { promise, resolve };
}
export class MemoryTaskBroker implements TaskBroker {
private readonly db = new InMemoryDatabase();
private readonly tasks = new Array<TaskState>();
private deferredDispatch = defer();
async claim(): Promise<Task> {
for (;;) {
const pendingTask = await this.db.claimTask();
if (pendingTask) {
return TaskAgent.create(pendingTask, this.db);
}
await this.waitForDispatch();
}
}
async dispatch(spec: TaskSpec): Promise<void> {
this.tasks.push({
spec,
status: 'OPEN',
runId: undefined,
});
this.signalDispatch();
}
private waitForDispatch() {
return this.deferredDispatch.promise;
}
private signalDispatch() {
this.deferredDispatch.resolve();
this.deferredDispatch = defer();
}
}
@@ -1,15 +0,0 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@@ -1,34 +0,0 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Status, ClaimResponse } from './types';
import { Database } from './Database';
export class TaskWorker {
static async fromConfig() {}
constructor(private readonly database: Database) {}
claim(): Promise<ClaimResponse> {
return Promise.resolve(undefined);
}
setStatus(taskId: string, status: Status) {
this.database.writeStatus(taskId, status);
}
heartbeat(runId: number) {}
}
@@ -1,29 +0,0 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Task } from './types';
import { Database } from './Database';
export class taskDispatcher {
static async fromConfig(config: { database: Database }) {}
constructor(private readonly db: Database) {}
dispatch(task: Task): Promise<string> {
this.db.write(task);
return Promise.resolve('uuid');
}
}
@@ -14,17 +14,43 @@
* limitations under the License.
*/
export type Status = 'OPEN' | 'PROCESSING' | 'FAILED' | 'CANCELLED';
export type Task = {
export type Status =
| 'OPEN'
| 'PROCESSING'
| 'FAILED'
| 'CANCELLED'
| 'COMPLETED';
export type CompletedTaskState = 'FAILED' | 'COMPLETED';
export type DbTaskRow = {
taskId: string;
metadata: string;
spec: TaskSpec;
status: Status;
lastHeartbeat: string;
lastHeartbeat?: string;
retryCount: number;
createdAt: string;
runId?: string;
};
export type DbTaskEventRow = {
id: number;
runId?: number;
stageName: string;
createdAt: string;
};
export type ClaimResponse = {
runId: number;
task: Task;
export type TaskSpec = {
metadata: string;
};
export interface Task {
spec: TaskSpec;
emitLog(message: string): Promise<void>;
complete(result: CompletedTaskState): Promise<void>;
}
export interface TaskBroker {
claim(): Promise<Task>;
dispatch(spec: TaskSpec): Promise<void>;
}