@cleverbrush/scheduler 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,419 @@
1
+ import fs from 'fs';
2
+ import { EventEmitter } from 'events';
3
+ import { Readable, PassThrough } from 'stream';
4
+ import { access as fsAccess } from 'fs/promises';
5
+ import { join as pathJoin } from 'path';
6
+ import { Worker } from 'worker_threads';
7
+
8
+ import {
9
+ schemaRegistry,
10
+ JobSchedulerProps,
11
+ CreateJobRequest,
12
+ SchedulerStatus,
13
+ Schedule,
14
+ Job,
15
+ JobInstance,
16
+ JobInstanceStatus,
17
+ Schemas
18
+ } from './types.js';
19
+
20
+ import { ScheduleCalculator } from './ScheduleCalculator.js';
21
+
22
+ import { IJobRepository, InMemoryJobRepository } from './jobRepository.js';
23
+
24
+ export { ScheduleCalculator, Schedule as TaskSchedule, Schemas };
25
+
26
+ type WorkerResult = {
27
+ status: JobInstanceStatus;
28
+ exitCode: number;
29
+ };
30
+
31
+ const CHECK_INTERVAL = 1000 * 10; // every 10 seconds
32
+ // const SCHEDULE_JOB_SPAN = 1000 * 60 * 60; // 1 hour
33
+ const SCHEDULE_JOB_SPAN = 1000 * 60; // 1 hour
34
+ const DEFAULT_JOB_TIMEOUT = 1000 * 20; // 20 seconds
35
+ const DEFAULT_MAX_CONSEQUENT_FAILS = 3;
36
+
37
+ type JobStartItem = {
38
+ jobId: string;
39
+ instanceId: number;
40
+ stdout: Readable;
41
+ stderr: Readable;
42
+ startDate: Date;
43
+ };
44
+
45
+ type JobEndItem = JobStartItem & {
46
+ endDate: Date;
47
+ };
48
+
49
+ type Events = {
50
+ 'job:start': (job: JobStartItem) => any;
51
+ 'job:end': (job: JobEndItem) => any;
52
+ };
53
+
54
+ interface IJobScheduler {
55
+ on<T extends keyof Events>(name: T, callback: Events[T]): this;
56
+ }
57
+
58
+ export class JobScheduler extends EventEmitter implements IJobScheduler {
59
+ protected _rootFolder: string;
60
+ protected _status: SchedulerStatus = 'stopped';
61
+ protected _defaultTimezone: string;
62
+
63
+ protected _checkTimer;
64
+
65
+ protected _jobsRepository: IJobRepository = new InMemoryJobRepository();
66
+
67
+ protected _jobProps: Map<string, any> = new Map<string, any>();
68
+
69
+ public get status() {
70
+ return this._status;
71
+ }
72
+
73
+ protected set status(val: SchedulerStatus) {
74
+ if (val === this._status) return;
75
+ this._status = val;
76
+ }
77
+
78
+ private scheduleCalculatorCache = new Map<string, ScheduleCalculator>();
79
+
80
+ protected async getJobSchedule(job: Job) {
81
+ if (this.scheduleCalculatorCache.has(job.id)) {
82
+ return this.scheduleCalculatorCache.get(job.id);
83
+ }
84
+
85
+ const schedule = {
86
+ ...job.schedule
87
+ };
88
+
89
+ if (typeof job.firstInstanceEndedAt !== 'undefined') {
90
+ schedule.startsOn = job.firstInstanceEndedAt;
91
+ }
92
+
93
+ if (typeof job.successfullTimesRunned === 'number') {
94
+ schedule.startingFromIndex = job.successfullTimesRunned + 1;
95
+ }
96
+
97
+ const res = new ScheduleCalculator(schedule);
98
+ this.scheduleCalculatorCache.set(job.id, res);
99
+
100
+ return res;
101
+ }
102
+
103
+ protected runWorkerWithTimeout(file: string, props: any, timeout: number) {
104
+ const worker = new Worker(file, {
105
+ workerData: props,
106
+ execArgv: ['--unhandled-rejections=strict'],
107
+ stderr: true,
108
+ stdout: true
109
+ });
110
+ const promise = new Promise<WorkerResult>((resolve) => {
111
+ let timedOut = false;
112
+ let isFinished = false;
113
+
114
+ const timeoutTimer = setTimeout(() => {
115
+ if (isFinished) return;
116
+ timedOut = true;
117
+ worker.terminate();
118
+ resolve({
119
+ exitCode: 1,
120
+ status: 'timedout'
121
+ });
122
+ }, timeout);
123
+
124
+ worker.on('error', (e) => e);
125
+
126
+ worker.on('exit', (exitCode) => {
127
+ if (isFinished) return;
128
+ if (timedOut) return;
129
+ clearTimeout(timeoutTimer);
130
+ isFinished = true;
131
+ resolve({
132
+ status: exitCode === 0 ? 'succeeded' : 'errored',
133
+ exitCode
134
+ });
135
+ });
136
+ });
137
+
138
+ return {
139
+ promise,
140
+ stderr: worker.stderr,
141
+ stdout: worker.stdout
142
+ };
143
+ }
144
+
145
+ private readToEnd(source: Readable): Promise<Buffer> {
146
+ return new Promise<Buffer>((res, rej) => {
147
+ const chunks = [];
148
+ source.on('data', (chunk) => chunks.push(chunk));
149
+ source.on('end', () => res(Buffer.concat(chunks)));
150
+ source.on('error', (err) => rej(err));
151
+ });
152
+ }
153
+
154
+ protected async startJobInstance(instance: JobInstance): Promise<void> {
155
+ const startDate = new Date();
156
+ instance = await this._jobsRepository.saveInstance({
157
+ ...instance,
158
+ status: 'running',
159
+ startDate
160
+ });
161
+
162
+ let status: JobInstanceStatus, exitCode: number;
163
+
164
+ try {
165
+ const job = await this._jobsRepository.getJobById(instance.jobId);
166
+
167
+ const fileName = pathJoin(this._rootFolder, job.path);
168
+
169
+ const props = this._jobProps.get(job.id);
170
+
171
+ let finalProps = typeof props === 'function' ? props() : props;
172
+ if (finalProps instanceof Promise) {
173
+ finalProps = await finalProps;
174
+ }
175
+
176
+ instance = await this._jobsRepository.saveInstance({
177
+ ...instance,
178
+ status: 'running'
179
+ });
180
+
181
+ const { promise, stderr, stdout } = this.runWorkerWithTimeout(
182
+ fileName,
183
+ finalProps,
184
+ job.timeout
185
+ );
186
+
187
+ const stdOutPass = new PassThrough();
188
+ stdout.pipe(stdOutPass);
189
+ const stdErrPass = new PassThrough();
190
+ stderr.pipe(stdErrPass);
191
+
192
+ const stdOutForJobStart = stdout.pipe(new PassThrough());
193
+ const stdErrForJobStart = stderr.pipe(new PassThrough());
194
+
195
+ const stdOutForJobEnd = stdout.pipe(new PassThrough());
196
+ const stdErrForJobEnd = stderr.pipe(new PassThrough());
197
+
198
+ this.emit('job:start', {
199
+ instanceId: instance.id,
200
+ jobId: job.id,
201
+ stderr: stdErrForJobStart,
202
+ stdout: stdOutForJobStart,
203
+ startDate
204
+ } as JobStartItem);
205
+
206
+ const stdOutStr = (await this.readToEnd(stdOutPass)).toString();
207
+ const stdErrStr = (await this.readToEnd(stdErrPass)).toString();
208
+
209
+ const result = await promise;
210
+ status = result.status;
211
+ exitCode = result.exitCode;
212
+
213
+ const endDate = new Date();
214
+
215
+ this.emit('job:end', {
216
+ instanceId: instance.id,
217
+ jobId: job.id,
218
+ stderr: stdErrForJobEnd,
219
+ stdout: stdOutForJobEnd,
220
+ startDate,
221
+ endDate
222
+ } as JobStartItem);
223
+
224
+ instance = await this._jobsRepository.saveInstance({
225
+ ...instance,
226
+ status,
227
+ exitCode,
228
+ stdErr: stdErrStr,
229
+ stdOut: stdOutStr,
230
+ endDate
231
+ });
232
+ } finally {
233
+ const job = {
234
+ ...(await this._jobsRepository.getJobById(instance.jobId))
235
+ };
236
+
237
+ job.timesRunned++;
238
+
239
+ let shouldRetry = false;
240
+
241
+ const schedule = await this.getJobSchedule(job);
242
+
243
+ if (status !== 'succeeded') {
244
+ if (job.consequentFailsCount + 1 >= job.maxConsequentFails) {
245
+ job.status = 'disabled';
246
+ } else {
247
+ job.consequentFailsCount += 1;
248
+ shouldRetry = true;
249
+ }
250
+ } else {
251
+ job.successfullTimesRunned++;
252
+ job.consequentFailsCount = 0;
253
+ if (!schedule.hasNext()) {
254
+ job.status = 'finished';
255
+ }
256
+ }
257
+
258
+ await this._jobsRepository.saveJob(job);
259
+
260
+ if (shouldRetry) {
261
+ await this.startJobInstance(instance);
262
+ }
263
+ }
264
+ }
265
+
266
+ protected async scheduleJobTo(
267
+ job: Job,
268
+ date: Date,
269
+ index: number
270
+ ): Promise<JobInstance | null> {
271
+ let timer;
272
+
273
+ try {
274
+ const now = new Date();
275
+ let interval = date.getTime() - now.getTime();
276
+ if (interval < 0) {
277
+ interval = 0;
278
+ }
279
+
280
+ const instance = await this._jobsRepository.addInstance(job.id, {
281
+ scheduledTo: date,
282
+ status: 'scheduled',
283
+ timeout: job.timeout,
284
+ index
285
+ });
286
+
287
+ timer = setTimeout(async () => {
288
+ const actualJob = await this._jobsRepository.getJobById(job.id);
289
+ if (actualJob.status !== 'active') {
290
+ instance.status = 'canceled';
291
+ await this._jobsRepository.saveInstance(instance);
292
+ return;
293
+ }
294
+ await this.startJobInstance(instance);
295
+ }, interval);
296
+
297
+ return instance;
298
+ } catch (e) {
299
+ clearTimeout(timer);
300
+ // console.log(e);
301
+ // console.log('task failed!');
302
+ return null;
303
+ }
304
+ }
305
+
306
+ protected async checkForUpcomingJobs(): Promise<void> {
307
+ const jobs = await this._jobsRepository.getJobs();
308
+ for (let i = 0; i < jobs.length; i++) {
309
+ if (jobs[i].status !== 'active') continue;
310
+
311
+ const schedule = await this.getJobSchedule(jobs[i]);
312
+
313
+ if (schedule.hasNext()) {
314
+ const scheduledInstances =
315
+ await this._jobsRepository.getInstancesWithStatus(
316
+ jobs[i].id,
317
+ 'scheduled'
318
+ );
319
+
320
+ while (schedule.hasNext(SCHEDULE_JOB_SPAN)) {
321
+ const { date: nextRun, index } = schedule.next();
322
+ const alreadyScheduled = scheduledInstances.find(
323
+ (i) => i.index === index
324
+ );
325
+ if (alreadyScheduled) continue;
326
+
327
+ await this.scheduleJobTo(jobs[i], nextRun, index);
328
+ }
329
+ } else {
330
+ jobs[i].status = 'finished';
331
+ await this._jobsRepository.saveJob(jobs[i]);
332
+ }
333
+ }
334
+ }
335
+
336
+ public async start() {
337
+ if (this._status === 'started') {
338
+ throw new Error('Scheduler is already started');
339
+ }
340
+
341
+ this.status = 'started';
342
+
343
+ this._checkTimer = setInterval(
344
+ this.checkForUpcomingJobs.bind(this),
345
+ CHECK_INTERVAL
346
+ );
347
+
348
+ // TODO: add logic
349
+ }
350
+
351
+ public stop() {
352
+ if (this._status === 'stopped') {
353
+ throw new Error('Scheduler is already stopped');
354
+ }
355
+
356
+ clearInterval(this._checkTimer);
357
+
358
+ this.status = 'stopped';
359
+ // TODO: add logic
360
+ }
361
+
362
+ public async addJob(job: CreateJobRequest) {
363
+ const validationResult =
364
+ await schemaRegistry.schemas.Models.CreateJobRequest.validate(job);
365
+ if (!validationResult.valid) {
366
+ throw new Error(
367
+ `Invalid CreateJobRequest: ${validationResult.errors?.join(
368
+ '; '
369
+ )}`
370
+ );
371
+ }
372
+
373
+ const path = pathJoin(this._rootFolder, job.path);
374
+ await fsAccess(path, fs.constants.R_OK);
375
+
376
+ this._jobProps.set(job.id, job.props);
377
+
378
+ await this._jobsRepository.createJob({
379
+ id: job.id,
380
+ createdAt: new Date(),
381
+ schedule: job.schedule,
382
+ timeout: job.timeout || DEFAULT_JOB_TIMEOUT,
383
+ path: job.path,
384
+ consequentFailsCount: 0,
385
+ timesRunned: 0,
386
+ successfullTimesRunned: 0,
387
+ maxConsequentFails:
388
+ typeof job.maxConsequentFails === 'number'
389
+ ? job.maxConsequentFails
390
+ : DEFAULT_MAX_CONSEQUENT_FAILS
391
+ });
392
+ }
393
+
394
+ constructor(props: JobSchedulerProps) {
395
+ super();
396
+ if (typeof props.rootFolder !== 'string') {
397
+ throw new Error('rootFolder must be a string');
398
+ }
399
+ if (typeof props.defaultTimeZone === 'string') {
400
+ this._defaultTimezone = props.defaultTimeZone;
401
+ }
402
+
403
+ if (typeof props.persistRepository === 'object') {
404
+ this._jobsRepository = props.persistRepository;
405
+ }
406
+
407
+ this._rootFolder = props.rootFolder;
408
+
409
+ setInterval(() => {
410
+ this._jobsRepository.dumpJobs();
411
+ this._jobsRepository.dumpInstances();
412
+ }, 10 * 1000);
413
+ }
414
+
415
+ public on<T extends keyof Events>(name: T, callback: Events[T]): this {
416
+ super.on(name, callback);
417
+ return this;
418
+ }
419
+ }
@@ -0,0 +1,135 @@
1
+ import { Job, JobInstance, JobInstanceStatus, JobStatus } from './types.js';
2
+
3
+ type AddJobRequest = Omit<Job, 'status'>;
4
+ type AddJobInstanceRequest = Omit<JobInstance, 'id' | 'jobId'>;
5
+
6
+ export interface IJobRepository {
7
+ getJobs(): Promise<Job[]>;
8
+ getJobById(jobId: string): Promise<Job>;
9
+ createJob(item: AddJobRequest): Promise<Job>;
10
+
11
+ saveJob(job: Job): Promise<Job>;
12
+
13
+ getInstancesWithStatus(
14
+ jobId: string,
15
+ status: JobInstanceStatus
16
+ ): Promise<JobInstance[]>;
17
+
18
+ addInstance(
19
+ jobId: string,
20
+ instance: AddJobInstanceRequest
21
+ ): Promise<JobInstance>;
22
+
23
+ saveInstance(instance: JobInstance): Promise<JobInstance>;
24
+
25
+ // TODO: remove
26
+ dumpJobs(): void;
27
+ dumpInstances(): void;
28
+ }
29
+
30
+ export class InMemoryJobRepository implements IJobRepository {
31
+ private _jobs: Array<Job> = [];
32
+ private _jobInstances: Array<JobInstance> = [];
33
+ private _instanceId = 1;
34
+
35
+ async getJobs(): Promise<Job[]> {
36
+ return this._jobs;
37
+ }
38
+
39
+ async createJob(item: AddJobRequest): Promise<Job> {
40
+ const job: Job = {
41
+ ...item,
42
+ status: 'active'
43
+ };
44
+ this._jobs.push(job);
45
+ return job;
46
+ }
47
+
48
+ async getInstances(jobId: string): Promise<JobInstance[]> {
49
+ return this._jobInstances.filter((ji) => ji.jobId === jobId);
50
+ }
51
+
52
+ async addInstance(
53
+ jobId: string,
54
+ instance: AddJobInstanceRequest
55
+ ): Promise<JobInstance> {
56
+ const newInstance = {
57
+ ...instance,
58
+ id: this._instanceId++,
59
+ jobId
60
+ };
61
+
62
+ this._jobInstances.push(newInstance);
63
+
64
+ return newInstance;
65
+ }
66
+
67
+ async getJobById(jobId: string): Promise<Job> {
68
+ return this._jobs.find((j) => j.id === jobId);
69
+ }
70
+
71
+ async setJobStatus(jobId: string, status: JobStatus): Promise<Job> {
72
+ const job = await this.getJobById(jobId);
73
+ if (!job) return null;
74
+ job.status = status;
75
+ return job;
76
+ }
77
+
78
+ async saveJob(job: Job): Promise<Job> {
79
+ const index = this._jobs.findIndex((j) => j.id === job.id);
80
+ if (index !== -1) {
81
+ this._jobs[index] = {
82
+ ...job
83
+ };
84
+ return this._jobs[index];
85
+ }
86
+
87
+ const result = {
88
+ ...job
89
+ };
90
+
91
+ this._jobs.push(result);
92
+ return result;
93
+ }
94
+
95
+ async getInstancesWithStatus(
96
+ jobId: string,
97
+ status: JobInstanceStatus
98
+ ): Promise<JobInstance[]> {
99
+ return (await this.getInstances(jobId)).filter(
100
+ (i) => i.status === status
101
+ );
102
+ }
103
+
104
+ async getInstanceById(id: number): Promise<JobInstance> {
105
+ return this._jobInstances.find((ji) => ji.id === id);
106
+ }
107
+
108
+ async saveInstance(instance: JobInstance): Promise<JobInstance> {
109
+ const oldIndex = this._jobInstances.findIndex(
110
+ (ji) => ji.id === instance.id
111
+ );
112
+ if (oldIndex !== -1) {
113
+ this._jobInstances[oldIndex] = {
114
+ ...instance
115
+ };
116
+ return this._jobInstances[oldIndex];
117
+ }
118
+
119
+ const result = {
120
+ ...instance,
121
+ id: this._instanceId++
122
+ };
123
+
124
+ this._jobInstances.push(result);
125
+ return result;
126
+ }
127
+
128
+ dumpJobs(): void {
129
+ // console.table(this._jobs);
130
+ }
131
+
132
+ dumpInstances(): void {
133
+ // console.table(this._jobInstances);
134
+ }
135
+ }