mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-20 00:08:02 +01:00
feat: allow schedulers to run in a single node (#6794)
## About the changes
This PR provides a service that allows a scheduled function to run in a
single instance. It's currently not in use but tests show how to wrap a
function to make it single-instance:
65b7080e05/src/lib/features/scheduler/job-service.test.ts (L26-L32)
The key `'test'` is used to identify the group and most likely should
have the same name as the scheduled job.
---------
Co-authored-by: Christopher Kolstad <chriswk@getunleash.io>
This commit is contained in:
parent
00d3490764
commit
0a2d40fb8b
68
src/lib/features/scheduler/job-service.test.ts
Normal file
68
src/lib/features/scheduler/job-service.test.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { createTestConfig } from '../../../test/config/test-config';
|
||||||
|
import { JobStore } from './job-store';
|
||||||
|
import { JobService } from './job-service';
|
||||||
|
import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init';
|
||||||
|
|
||||||
|
let db: ITestDb;
|
||||||
|
let store: JobStore;
|
||||||
|
const config = createTestConfig();
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = await dbInit('job_service_serial', config.getLogger);
|
||||||
|
store = new JobStore(db.rawDatabase, config);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await store.deleteAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// note: this might be flaky if the test runs exactly at 59 minutes and 59 seconds of an hour and 999 milliseconds but should be unlikely
|
||||||
|
test('Only executes job once within time period', async () => {
|
||||||
|
let counter = 0;
|
||||||
|
const service = new JobService(store, config.getLogger);
|
||||||
|
const job = service.singleInstance(
|
||||||
|
'test',
|
||||||
|
async () => {
|
||||||
|
counter++;
|
||||||
|
},
|
||||||
|
60,
|
||||||
|
);
|
||||||
|
await job();
|
||||||
|
await job();
|
||||||
|
expect(counter).toBe(1);
|
||||||
|
const jobs = await store.getAll();
|
||||||
|
expect(jobs).toHaveLength(1);
|
||||||
|
expect(jobs.every((j) => j.finishedAt !== null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Will execute jobs with different keys', async () => {
|
||||||
|
let counter = 0;
|
||||||
|
let otherCounter = 0;
|
||||||
|
const service = new JobService(store, config.getLogger);
|
||||||
|
const incrementCounter = service.singleInstance(
|
||||||
|
'one',
|
||||||
|
async () => {
|
||||||
|
counter++;
|
||||||
|
},
|
||||||
|
60,
|
||||||
|
);
|
||||||
|
const incrementOtherCounter = service.singleInstance(
|
||||||
|
'two',
|
||||||
|
async () => {
|
||||||
|
otherCounter++;
|
||||||
|
},
|
||||||
|
60,
|
||||||
|
);
|
||||||
|
await incrementCounter();
|
||||||
|
await incrementCounter();
|
||||||
|
await incrementOtherCounter();
|
||||||
|
await incrementOtherCounter();
|
||||||
|
expect(counter).toBe(1);
|
||||||
|
expect(otherCounter).toBe(1);
|
||||||
|
const jobs = await store.getAll();
|
||||||
|
expect(jobs).toHaveLength(2);
|
||||||
|
expect(jobs.every((j) => j.finishedAt !== null)).toBe(true);
|
||||||
|
});
|
58
src/lib/features/scheduler/job-service.ts
Normal file
58
src/lib/features/scheduler/job-service.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import type { Logger } from '../../server-impl';
|
||||||
|
import type { JobStore } from './job-store';
|
||||||
|
import type { LogProvider } from '../../logger';
|
||||||
|
import { subMinutes } from 'date-fns';
|
||||||
|
|
||||||
|
export class JobService {
|
||||||
|
private jobStore: JobStore;
|
||||||
|
private logger: Logger;
|
||||||
|
constructor(jobStore: JobStore, logProvider: LogProvider) {
|
||||||
|
this.jobStore = jobStore;
|
||||||
|
this.logger = logProvider('/services/job-service');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a function in a job that will guarantee the function is executed
|
||||||
|
* in a mutually exclusive way in a single instance of the cluster, at most
|
||||||
|
* once every {@param bucketSizeInMinutes}.
|
||||||
|
*
|
||||||
|
* The key identifies the job group: only one job in the group will execute
|
||||||
|
* at any given time.
|
||||||
|
*
|
||||||
|
* Note: buckets begin at the start of the time span
|
||||||
|
*/
|
||||||
|
public singleInstance(
|
||||||
|
key: string,
|
||||||
|
fn: (range?: { from: Date; to: Date }) => Promise<unknown>,
|
||||||
|
bucketSizeInMinutes = 5,
|
||||||
|
): () => Promise<unknown> {
|
||||||
|
return async () => {
|
||||||
|
const acquired = await this.jobStore.acquireBucket(
|
||||||
|
key,
|
||||||
|
bucketSizeInMinutes,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (acquired) {
|
||||||
|
const { name, bucket } = acquired;
|
||||||
|
this.logger.info(
|
||||||
|
`Acquired job lock for ${name} from >= ${subMinutes(
|
||||||
|
bucket,
|
||||||
|
bucketSizeInMinutes,
|
||||||
|
)} to < ${bucket}`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const range = {
|
||||||
|
from: subMinutes(bucket, bucketSizeInMinutes),
|
||||||
|
to: bucket,
|
||||||
|
};
|
||||||
|
return fn(range);
|
||||||
|
} finally {
|
||||||
|
await this.jobStore.update(name, bucket, {
|
||||||
|
stage: 'completed',
|
||||||
|
finishedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
30
src/lib/features/scheduler/job-store.test.ts
Normal file
30
src/lib/features/scheduler/job-store.test.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { createTestConfig } from '../../../test/config/test-config';
|
||||||
|
import { JobStore } from './job-store';
|
||||||
|
import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init';
|
||||||
|
|
||||||
|
let db: ITestDb;
|
||||||
|
const config = createTestConfig();
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = await dbInit('job_store_serial', config.getLogger);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot acquireBucket twice', async () => {
|
||||||
|
const store = new JobStore(db.rawDatabase, config);
|
||||||
|
// note: this might be flaky if the test runs exactly at 59 minutes and 59 seconds of an hour and 999 milliseconds but should be unlikely
|
||||||
|
const bucket = await store.acquireBucket('test', 60);
|
||||||
|
expect(bucket).toBeDefined();
|
||||||
|
const bucket2 = await store.acquireBucket('test', 60);
|
||||||
|
expect(bucket2).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Can acquire bucket for two different key names within the same period', async () => {
|
||||||
|
const store = new JobStore(db.rawDatabase, config);
|
||||||
|
const firstBucket = await store.acquireBucket('first', 60);
|
||||||
|
const secondBucket = await store.acquireBucket('second', 60);
|
||||||
|
expect(firstBucket).toBeDefined();
|
||||||
|
expect(secondBucket).toBeDefined();
|
||||||
|
});
|
110
src/lib/features/scheduler/job-store.ts
Normal file
110
src/lib/features/scheduler/job-store.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import type { Store } from '../../types/stores/store';
|
||||||
|
import type { Db, IUnleashConfig, Logger } from '../../server-impl';
|
||||||
|
import metricsHelper from '../../util/metrics-helper';
|
||||||
|
import { DB_TIME } from '../../metric-events';
|
||||||
|
import type { Row } from '../../db/crud/row-type';
|
||||||
|
import { defaultToRow } from '../../db/crud/default-mappings';
|
||||||
|
|
||||||
|
export type JobModel = {
|
||||||
|
name: string;
|
||||||
|
bucket: Date;
|
||||||
|
stage: 'started' | 'completed' | 'failed';
|
||||||
|
finishedAt?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TABLE = 'jobs';
|
||||||
|
const toRow = (data: Partial<JobModel>) =>
|
||||||
|
defaultToRow<JobModel, Row<JobModel>>(data);
|
||||||
|
|
||||||
|
export class JobStore
|
||||||
|
implements Store<JobModel, { name: string; bucket: Date }>
|
||||||
|
{
|
||||||
|
private logger: Logger;
|
||||||
|
protected readonly timer: (action: string) => Function;
|
||||||
|
private db: Db;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
db: Db,
|
||||||
|
config: Pick<IUnleashConfig, 'eventBus' | 'getLogger'>,
|
||||||
|
) {
|
||||||
|
this.db = db;
|
||||||
|
this.logger = config.getLogger('job-store');
|
||||||
|
this.timer = (action: string) =>
|
||||||
|
metricsHelper.wrapTimer(config.eventBus, DB_TIME, {
|
||||||
|
store: TABLE,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async acquireBucket(
|
||||||
|
key: string,
|
||||||
|
bucketLengthInMinutes: number,
|
||||||
|
): Promise<{ name: string; bucket: Date } | undefined> {
|
||||||
|
const endTimer = this.timer('acquireBucket');
|
||||||
|
|
||||||
|
const bucket = await this.db<Row<JobModel>>(TABLE)
|
||||||
|
.insert({
|
||||||
|
name: key,
|
||||||
|
// note: date_floor_round is a custom function defined in the DB
|
||||||
|
bucket: this.db.raw(
|
||||||
|
`date_floor_round(now(), '${bucketLengthInMinutes} minutes')`,
|
||||||
|
),
|
||||||
|
stage: 'started',
|
||||||
|
})
|
||||||
|
.onConflict(['name', 'bucket'])
|
||||||
|
.ignore()
|
||||||
|
.returning(['name', 'bucket']);
|
||||||
|
|
||||||
|
endTimer();
|
||||||
|
return bucket[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
name: string,
|
||||||
|
bucket: Date,
|
||||||
|
data: Partial<Omit<JobModel, 'name' | 'bucket'>>,
|
||||||
|
): Promise<JobModel> {
|
||||||
|
const rows = await this.db<Row<JobModel>>(TABLE)
|
||||||
|
.update(toRow(data))
|
||||||
|
.where({ name, bucket })
|
||||||
|
.returning('*');
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(pk: { name: string; bucket: Date }): Promise<JobModel> {
|
||||||
|
const rows = await this.db(TABLE).where(pk);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll(query?: Object | undefined): Promise<JobModel[]> {
|
||||||
|
if (query) {
|
||||||
|
return this.db(TABLE).where(query);
|
||||||
|
}
|
||||||
|
return this.db(TABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(key: { name: string; bucket: Date }): Promise<boolean> {
|
||||||
|
const result = await this.db.raw(
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM ${TABLE} WHERE name = ? AND bucket = ?) AS present`,
|
||||||
|
[key.name, key.bucket],
|
||||||
|
);
|
||||||
|
const { present } = result.rows[0];
|
||||||
|
return present;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: { name: string; bucket: Date }): Promise<void> {
|
||||||
|
await this.db(TABLE).where(key).delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAll(): Promise<void> {
|
||||||
|
return this.db(TABLE).delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {}
|
||||||
|
|
||||||
|
async count(): Promise<number> {
|
||||||
|
return this.db(TABLE)
|
||||||
|
.count()
|
||||||
|
.then((res) => Number(res[0].count));
|
||||||
|
}
|
||||||
|
}
|
@ -133,17 +133,13 @@ export const scheduleServices = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
schedulerService.schedule(
|
schedulerService.schedule(
|
||||||
() => {
|
() => clientMetricsServiceV2.bulkAdd().catch(console.error),
|
||||||
clientMetricsServiceV2.bulkAdd().catch(console.error);
|
|
||||||
},
|
|
||||||
secondsToMilliseconds(5),
|
secondsToMilliseconds(5),
|
||||||
'bulkAddMetrics',
|
'bulkAddMetrics',
|
||||||
);
|
);
|
||||||
|
|
||||||
schedulerService.schedule(
|
schedulerService.schedule(
|
||||||
() => {
|
() => clientMetricsServiceV2.clearMetrics(48).catch(console.error),
|
||||||
clientMetricsServiceV2.clearMetrics(48).catch(console.error);
|
|
||||||
},
|
|
||||||
hoursToMilliseconds(12),
|
hoursToMilliseconds(12),
|
||||||
'clearMetrics',
|
'clearMetrics',
|
||||||
);
|
);
|
||||||
|
@ -38,7 +38,7 @@ export class SchedulerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async schedule(
|
async schedule(
|
||||||
scheduledFunction: () => void,
|
scheduledFunction: () => Promise<unknown>,
|
||||||
timeMs: number,
|
timeMs: number,
|
||||||
id: string,
|
id: string,
|
||||||
jitter = randomJitter(2 * 1000, 30 * 1000, timeMs),
|
jitter = randomJitter(2 * 1000, 30 * 1000, timeMs),
|
||||||
|
@ -664,7 +664,12 @@ export default class MetricsMonitor {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await schedulerService.schedule(
|
await schedulerService.schedule(
|
||||||
this.registerPoolMetrics.bind(this, db.client.pool, eventBus),
|
async () =>
|
||||||
|
this.registerPoolMetrics.bind(
|
||||||
|
this,
|
||||||
|
db.client.pool,
|
||||||
|
eventBus,
|
||||||
|
),
|
||||||
minutesToMilliseconds(1),
|
minutesToMilliseconds(1),
|
||||||
'registerPoolMetrics',
|
'registerPoolMetrics',
|
||||||
0, // no jitter
|
0, // no jitter
|
||||||
|
@ -125,6 +125,8 @@ import {
|
|||||||
createFakeProjectInsightsService,
|
createFakeProjectInsightsService,
|
||||||
createProjectInsightsService,
|
createProjectInsightsService,
|
||||||
} from '../features/project-insights/createProjectInsightsService';
|
} from '../features/project-insights/createProjectInsightsService';
|
||||||
|
import { JobService } from '../features/scheduler/job-service';
|
||||||
|
import { JobStore } from '../features/scheduler/job-store';
|
||||||
import { FeatureLifecycleService } from '../features/feature-lifecycle/feature-lifecycle-service';
|
import { FeatureLifecycleService } from '../features/feature-lifecycle/feature-lifecycle-service';
|
||||||
import { createFakeFeatureLifecycleService } from '../features/feature-lifecycle/createFeatureLifecycle';
|
import { createFakeFeatureLifecycleService } from '../features/feature-lifecycle/createFeatureLifecycle';
|
||||||
|
|
||||||
@ -350,6 +352,12 @@ export const createServices = (
|
|||||||
const inactiveUsersService = new InactiveUsersService(stores, config, {
|
const inactiveUsersService = new InactiveUsersService(stores, config, {
|
||||||
userService,
|
userService,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const jobService = new JobService(
|
||||||
|
new JobStore(db!, config),
|
||||||
|
config.getLogger,
|
||||||
|
);
|
||||||
|
|
||||||
const { featureLifecycleService } = db
|
const { featureLifecycleService } = db
|
||||||
? createFeatureLifecycleService(db, config)
|
? createFeatureLifecycleService(db, config)
|
||||||
: createFakeFeatureLifecycleService(config);
|
: createFakeFeatureLifecycleService(config);
|
||||||
@ -413,6 +421,7 @@ export const createServices = (
|
|||||||
featureSearchService,
|
featureSearchService,
|
||||||
inactiveUsersService,
|
inactiveUsersService,
|
||||||
projectInsightsService,
|
projectInsightsService,
|
||||||
|
jobService,
|
||||||
featureLifecycleService,
|
featureLifecycleService,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -461,5 +470,6 @@ export {
|
|||||||
ClientFeatureToggleService,
|
ClientFeatureToggleService,
|
||||||
FeatureSearchService,
|
FeatureSearchService,
|
||||||
ProjectInsightsService,
|
ProjectInsightsService,
|
||||||
|
JobService,
|
||||||
FeatureLifecycleService,
|
FeatureLifecycleService,
|
||||||
};
|
};
|
||||||
|
@ -53,6 +53,7 @@ import type { ClientFeatureToggleService } from '../features/client-feature-togg
|
|||||||
import type { FeatureSearchService } from '../features/feature-search/feature-search-service';
|
import type { FeatureSearchService } from '../features/feature-search/feature-search-service';
|
||||||
import type { InactiveUsersService } from '../users/inactive/inactive-users-service';
|
import type { InactiveUsersService } from '../users/inactive/inactive-users-service';
|
||||||
import type { ProjectInsightsService } from '../features/project-insights/project-insights-service';
|
import type { ProjectInsightsService } from '../features/project-insights/project-insights-service';
|
||||||
|
import type { JobService } from '../features/scheduler/job-service';
|
||||||
import type { FeatureLifecycleService } from '../features/feature-lifecycle/feature-lifecycle-service';
|
import type { FeatureLifecycleService } from '../features/feature-lifecycle/feature-lifecycle-service';
|
||||||
|
|
||||||
export interface IUnleashServices {
|
export interface IUnleashServices {
|
||||||
@ -116,5 +117,6 @@ export interface IUnleashServices {
|
|||||||
featureSearchService: FeatureSearchService;
|
featureSearchService: FeatureSearchService;
|
||||||
inactiveUsersService: InactiveUsersService;
|
inactiveUsersService: InactiveUsersService;
|
||||||
projectInsightsService: ProjectInsightsService;
|
projectInsightsService: ProjectInsightsService;
|
||||||
|
jobService: JobService;
|
||||||
featureLifecycleService: FeatureLifecycleService;
|
featureLifecycleService: FeatureLifecycleService;
|
||||||
}
|
}
|
||||||
|
37
src/migrations/20240405174629-jobs.js
Normal file
37
src/migrations/20240405174629-jobs.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
exports.up = function (db, cb) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
-- this function rounds a date to the nearest interval
|
||||||
|
CREATE OR REPLACE FUNCTION date_floor_round(base_date timestamptz, round_interval interval) RETURNS timestamptz AS $BODY$
|
||||||
|
SELECT to_timestamp(
|
||||||
|
(EXTRACT(epoch FROM $1)::integer / EXTRACT(epoch FROM $2)::integer)
|
||||||
|
* EXTRACT(epoch FROM $2)::integer
|
||||||
|
)
|
||||||
|
$BODY$ LANGUAGE SQL STABLE;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
bucket TIMESTAMPTZ NOT NULL,
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
finished_at TIMESTAMPTZ,
|
||||||
|
PRIMARY KEY (name, bucket)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_job_finished ON jobs(finished_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_job_stage ON jobs(stage);
|
||||||
|
`,
|
||||||
|
cb,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (db, cb) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
DROP INDEX IF EXISTS idx_job_finished;
|
||||||
|
DROP INDEX IF EXISTS idx_job_stage;
|
||||||
|
DROP TABLE IF EXISTS jobs;
|
||||||
|
|
||||||
|
`,
|
||||||
|
cb,
|
||||||
|
);
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user