@clairejs/server 3.15.1 → 3.16.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.
Files changed (86) hide show
  1. package/.mocharc.json +3 -0
  2. package/README.md +1 -1
  3. package/dist/common/AbstractController.js +3 -0
  4. package/dist/common/ControllerMetadata.js +1 -0
  5. package/dist/common/FileOperation.js +6 -0
  6. package/dist/common/ServerModelMetadata.js +1 -0
  7. package/dist/common/Transactionable.js +17 -0
  8. package/dist/common/auth/AbstractPrincipalResolver.js +2 -0
  9. package/dist/common/auth/IPrincipal.js +1 -0
  10. package/dist/common/constants.js +7 -0
  11. package/dist/common/decorator.d.ts +2 -2
  12. package/dist/common/decorator.js +6 -0
  13. package/dist/common/request/EndpointMetadata.js +1 -0
  14. package/dist/common/request/HttpData.js +1 -0
  15. package/dist/common/request/HttpEndpoint.js +1 -0
  16. package/dist/common/request/JobData.js +1 -0
  17. package/dist/common/request/MountedEndpointInfo.js +1 -0
  18. package/dist/common/request/RequestOptions.js +1 -0
  19. package/dist/common/request/SocketData.js +1 -0
  20. package/dist/common/request/types.d.ts +1 -1
  21. package/dist/common/request/types.js +1 -0
  22. package/dist/controllers/FileManageController.js +90 -0
  23. package/dist/controllers/FileUploadController.js +64 -0
  24. package/dist/controllers/dto/system.js +14 -0
  25. package/dist/controllers/dto/upload.js +205 -0
  26. package/dist/http/auth/AbstractHttpAuthorizer.js +2 -0
  27. package/dist/http/common/HttpRequest.js +72 -0
  28. package/dist/http/common/HttpResponse.js +62 -0
  29. package/dist/http/controller/AbstractHttpController.js +21 -0
  30. package/dist/http/controller/AbstractHttpMiddleware.js +2 -0
  31. package/dist/http/controller/AbstractHttpRequestHandler.js +69 -0
  32. package/dist/http/controller/CrudHttpController.js +302 -0
  33. package/dist/http/controller/DefaultHttpRequestHandler.js +143 -0
  34. package/dist/http/decorators.d.ts +1 -1
  35. package/dist/http/decorators.js +86 -0
  36. package/dist/http/file-upload/AbstractFileUploadHandler.js +2 -0
  37. package/dist/http/file-upload/FileUploadHandler.js +41 -0
  38. package/dist/http/file-upload/types.d.ts +1 -1
  39. package/dist/http/file-upload/types.js +1 -0
  40. package/dist/http/repository/AbstractRepository.js +26 -0
  41. package/dist/http/repository/DtoRepository.d.ts +3 -3
  42. package/dist/http/repository/DtoRepository.js +204 -0
  43. package/dist/http/repository/ICrudRepository.js +1 -0
  44. package/dist/http/repository/ModelRepository.js +696 -0
  45. package/dist/http/security/AbstractAccessCondition.js +2 -0
  46. package/dist/http/security/access-conditions/FilterModelFieldAccessCondition.js +30 -0
  47. package/dist/http/security/access-conditions/MaximumQueryLimit.js +31 -0
  48. package/dist/http/security/cors.js +1 -0
  49. package/dist/http/utils.js +32 -0
  50. package/dist/index.js +75 -1
  51. package/dist/job/AbstractJobController.js +9 -0
  52. package/dist/job/AbstractJobRepository.js +2 -0
  53. package/dist/job/AbstractJobScheduler.js +48 -0
  54. package/dist/job/AwsJobScheduler.js +405 -0
  55. package/dist/job/LocalJobScheduler.js +273 -0
  56. package/dist/job/decorators.js +57 -0
  57. package/dist/job/interfaces.js +10 -0
  58. package/dist/logging/FileLogMedium.js +44 -0
  59. package/dist/services/AbstractFileService.js +28 -0
  60. package/dist/services/AbstractMailService.js +2 -0
  61. package/dist/services/AbstractService.js +3 -0
  62. package/dist/services/AbstractSmsService.js +2 -0
  63. package/dist/services/implementations/LocalFileService.js +42 -0
  64. package/dist/services/implementations/LocalMailService.js +27 -0
  65. package/dist/services/implementations/LocalSmsService.js +17 -0
  66. package/dist/services/implementations/S3FileService.js +107 -0
  67. package/dist/services/implementations/SesMailService.js +64 -0
  68. package/dist/socket/AbstractServerSocket.js +44 -0
  69. package/dist/socket/AbstractServerSocketManager.d.ts +1 -1
  70. package/dist/socket/AbstractServerSocketManager.js +348 -0
  71. package/dist/socket/AbstractSocketConnectionHandler.js +2 -0
  72. package/dist/socket/AbstractSocketController.d.ts +3 -3
  73. package/dist/socket/AbstractSocketController.js +12 -0
  74. package/dist/socket/AwsSocketManager.d.ts +2 -2
  75. package/dist/socket/AwsSocketManager.js +160 -0
  76. package/dist/socket/IServerSocket.js +1 -0
  77. package/dist/socket/LocalSocketManager.js +292 -0
  78. package/dist/system/ClaireServer.js +78 -0
  79. package/dist/system/ExpressWrapper.js +122 -0
  80. package/dist/system/LambdaWrapper.js +151 -0
  81. package/dist/system/ServerGlobalStore.js +1 -0
  82. package/dist/system/lamba-request-mapper.js +49 -0
  83. package/dist/system/locale/LocaleEntry.js +13 -0
  84. package/dist/system/locale/LocaleTranslation.js +47 -0
  85. package/dist/system/locale/decorators.js +14 -0
  86. package/package.json +13 -20
@@ -0,0 +1,405 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ import { AbstractLogger, Errors, LogContext } from "@clairejs/core";
11
+ import aws from "aws-sdk";
12
+ import Redis from "ioredis";
13
+ import { AbstractJobScheduler } from "./AbstractJobScheduler";
14
+ import { JobInterval } from "./interfaces";
15
+ const gcdBetween = (a, b) => {
16
+ const greater = a > b ? a : b;
17
+ const smaller = a > b ? b : a;
18
+ if (greater <= 0 || smaller <= 0) {
19
+ return 1;
20
+ }
21
+ else if (greater % smaller === 0) {
22
+ return smaller;
23
+ }
24
+ else {
25
+ const result = Math.trunc(greater / smaller);
26
+ return gcdBetween(smaller, greater - smaller * result);
27
+ }
28
+ };
29
+ const gcdOf = (val, arr) => {
30
+ if (!arr.length) {
31
+ return val;
32
+ }
33
+ else {
34
+ return gcdOf(gcdBetween(val, arr[0]), arr.slice(1));
35
+ }
36
+ };
37
+ const getWaitExpression = (interval) => `Wait ${interval} seconds`;
38
+ const oneMinuteFunctionFactory = (lambdaFunctionARN, intervals) => {
39
+ //-- find most greatest common divisor of intervals
40
+ let gcd = JobInterval.EVERY_30S;
41
+ if (intervals.length) {
42
+ //-- re-calculate gcd
43
+ gcd = gcdOf(intervals[0], intervals.slice(1));
44
+ if (gcd < JobInterval.EVERY_5S) {
45
+ gcd = JobInterval.EVERY_5S;
46
+ }
47
+ }
48
+ //-- create loop array
49
+ const itemCount = Math.trunc(60 / gcd);
50
+ const itemArray = [];
51
+ for (let i = 0; i < itemCount; i++) {
52
+ itemArray.push(i * gcd);
53
+ }
54
+ const definition = `
55
+ {
56
+ "StartAt": "Create items",
57
+ "States": {
58
+ "Create items": {
59
+ "Type": "Pass",
60
+ "Next": "Loop",
61
+ "Result": ${JSON.stringify(itemArray)}
62
+ },
63
+ "Loop": {
64
+ "Type": "Map",
65
+ "End": true,
66
+ "Iterator": {
67
+ "StartAt": "${getWaitExpression(gcd)}",
68
+ "States": {
69
+ "${getWaitExpression(gcd)}": {
70
+ "Type": "Wait",
71
+ "Seconds": ${gcd},
72
+ "Next": "Convert time to request object"
73
+ },
74
+ "Convert time to request object": {
75
+ "Type": "Pass",
76
+ "Next": "Lambda Invoke",
77
+ "Result": {
78
+ "requestContext": {
79
+ "intervalScheduler": {
80
+ "time": "$"
81
+ }
82
+ }
83
+ }
84
+ },
85
+ "Lambda Invoke": {
86
+ "Type": "Task",
87
+ "Resource": "arn:aws:states:::lambda:invoke",
88
+ "Parameters": {
89
+ "Payload.$": "$",
90
+ "FunctionName": "${lambdaFunctionARN}:$LATEST"
91
+ },
92
+ "End": true,
93
+ "OutputPath": "$.Payload"
94
+ }
95
+ }
96
+ },
97
+ "MaxConcurrency": 1
98
+ }
99
+ },
100
+ "Comment": "One minute loop to trigger lambda function"
101
+ }
102
+ `;
103
+ return [gcd, definition];
104
+ };
105
+ let AwsJobScheduler = class AwsJobScheduler extends AbstractJobScheduler {
106
+ logger;
107
+ redisClient;
108
+ uniqueIdKey;
109
+ apiLambdaFunctionArn;
110
+ stepFunctionName;
111
+ iamRoleArn;
112
+ eventBusName;
113
+ jobNamePrefix;
114
+ oneMinuteRule;
115
+ eventbridge = new aws.EventBridge();
116
+ stepfunctions = new aws.StepFunctions({ apiVersion: "2016-11-23" });
117
+ constructor(logger, redisClient, uniqueIdKey, apiLambdaFunctionArn, stepFunctionName,
118
+ /**
119
+ * This IAM role must have following permissions:
120
+ * - trigger any state machine (for one minute rule - as we don't now the state machine ARN until auto creation)
121
+ * - trigger lambda function specified in apiLambdaFunctionArn (so state machine can trigger API)
122
+ *
123
+ * In addition, the role which this API server assumes must have following permissions:
124
+ * - create event bridge rules, describe rules, remove rule, list and create rule targets
125
+ * - create state machines, list state machines
126
+ */
127
+ iamRoleArn, eventBusName = "default", jobNamePrefix = "claire-aws-job-", oneMinuteRule = "one-minute-step-function-trigger") {
128
+ super(logger);
129
+ this.logger = logger;
130
+ this.redisClient = redisClient;
131
+ this.uniqueIdKey = uniqueIdKey;
132
+ this.apiLambdaFunctionArn = apiLambdaFunctionArn;
133
+ this.stepFunctionName = stepFunctionName;
134
+ this.iamRoleArn = iamRoleArn;
135
+ this.eventBusName = eventBusName;
136
+ this.jobNamePrefix = jobNamePrefix;
137
+ this.oneMinuteRule = oneMinuteRule;
138
+ }
139
+ async handleInterval(interval) {
140
+ this.logger.debug(`Handle interval`, interval, typeof interval);
141
+ const allJobs = await this.getAvailableJobInfo();
142
+ const timedJobs = allJobs.filter((job) => job.interval && interval % job.interval === 0);
143
+ await Promise.all(timedJobs.map((job) => this.executeJob({ ...job, jobId: job.jobName })));
144
+ }
145
+ async handleCron(jobInfo) {
146
+ this.logger.debug(`Handle cron`, jobInfo);
147
+ await this.executeJob(jobInfo);
148
+ }
149
+ generateCronFromTimestamp(at) {
150
+ const date = new Date(at);
151
+ return [
152
+ date.getUTCMinutes(),
153
+ date.getUTCHours(),
154
+ date.getUTCDate(),
155
+ date.getUTCMonth(),
156
+ "?",
157
+ date.getUTCFullYear(),
158
+ ].join(" ");
159
+ }
160
+ isActiveScheduler() {
161
+ return false;
162
+ }
163
+ async getAllScheduledJobs() {
164
+ const availableJobs = await this.getAvailableJobInfo();
165
+ const allRules = await this.eventbridge
166
+ .listRules({
167
+ EventBusName: this.eventBusName,
168
+ NamePrefix: this.jobNamePrefix,
169
+ })
170
+ .promise();
171
+ const allJobs = await Promise.all((allRules.Rules || []).map(async (rule) => {
172
+ const ruleName = rule.Name;
173
+ const targets = await this.eventbridge
174
+ .listTargetsByRule({
175
+ EventBusName: this.eventBusName,
176
+ Rule: ruleName,
177
+ })
178
+ .promise();
179
+ const lambdaTarget = targets.Targets?.find((t) => t.Id === ruleName);
180
+ if (!lambdaTarget || !lambdaTarget.Input) {
181
+ return undefined;
182
+ }
183
+ const jobInfo = JSON.parse(lambdaTarget.Input).requestContext.cronScheduler.data;
184
+ return jobInfo;
185
+ }));
186
+ //-- concat with interval jobs because we don't register them
187
+ return allJobs
188
+ .filter((job) => !!job)
189
+ .concat(availableJobs.filter((j) => j.interval).map((j) => ({ ...j, jobId: j.jobName })));
190
+ }
191
+ async scheduleJob(jobInfo) {
192
+ this.logger.debug("Scheduling job: ", jobInfo);
193
+ if (jobInfo.cron || jobInfo.at) {
194
+ const uniqueId = await this.redisClient.incr(this.uniqueIdKey);
195
+ const jobId = `${this.jobNamePrefix}${uniqueId}`;
196
+ //-- generate pattern from cron (add * at the end for year) / at timestamp
197
+ const cronExpression = jobInfo.cron ? `${jobInfo.cron} *` : this.generateCronFromTimestamp(jobInfo.at);
198
+ this.logger.debug("Cron expression", cronExpression);
199
+ await this.eventbridge
200
+ .putRule({
201
+ Name: jobId,
202
+ Description: `${jobInfo.jobName} - ${jobInfo.cron || jobInfo.at}`,
203
+ EventBusName: this.eventBusName,
204
+ ScheduleExpression: `cron(${cronExpression})`,
205
+ State: "ENABLED",
206
+ })
207
+ .promise();
208
+ //-- this rule has only one target, which is the api lambda function, so id of target can be the same as id of rule
209
+ await this.eventbridge
210
+ .putTargets({
211
+ Rule: jobId,
212
+ Targets: [
213
+ {
214
+ Arn: this.apiLambdaFunctionArn,
215
+ Id: jobId,
216
+ //-- this json will be passed to lambda function as event input
217
+ Input: JSON.stringify({
218
+ requestContext: {
219
+ cronScheduler: {
220
+ data: { ...jobInfo, jobId },
221
+ },
222
+ },
223
+ }),
224
+ },
225
+ ],
226
+ })
227
+ .promise();
228
+ return jobId;
229
+ }
230
+ else if (jobInfo.interval) {
231
+ //-- interval job does not need to persist, the step function will take care of it
232
+ return jobInfo.jobName;
233
+ }
234
+ else {
235
+ throw Errors.SYSTEM_ERROR(`Job does not have time config: ${jobInfo.jobName}`);
236
+ }
237
+ }
238
+ async syncJobs() {
239
+ //-- check and init step function: step function must trigger lambda every 5s
240
+ this.logger.debug("Checking interval scheduler");
241
+ await this.checkIntervalScheduler();
242
+ //-- check jobs
243
+ const scheduledJobs = await this.getAllScheduledJobs();
244
+ const allJobs = await this.getAvailableJobInfo();
245
+ //-- remove job that no more exist
246
+ const nomoreExistJobs = scheduledJobs.filter((job) => !allJobs.find((j) => j.jobName === job.jobName));
247
+ for (const job of nomoreExistJobs) {
248
+ this.logger.info(`Removing stale job: ${job.jobName} of id: ${job.jobId}`);
249
+ await this.removeJob(job.jobId);
250
+ }
251
+ if (nomoreExistJobs.length) {
252
+ this.logger.info(`Cleaned up: ${nomoreExistJobs.length} stale jobs`);
253
+ }
254
+ //-- remove scheduled cron jobs
255
+ this.logger.debug("Remove scheduled cron jobs");
256
+ const scheduledCronJobs = scheduledJobs.filter((j) => j.cron);
257
+ await Promise.all(scheduledCronJobs.map((j) => this.removeJob(j.jobId)));
258
+ //-- reschedule cron & interval jobs because we might have updated the cron expression / interval value
259
+ const cronJobs = allJobs.filter((job) => job.cron || job.interval);
260
+ this.logger.debug("Scheduling cron & interval jobs");
261
+ for (const job of cronJobs) {
262
+ await this.scheduleJob({
263
+ jobName: job.jobName,
264
+ cron: job.cron,
265
+ interval: job.interval,
266
+ });
267
+ }
268
+ //-- keep at timestamp jobs as is
269
+ }
270
+ async removeJob(jobId) {
271
+ await this.eventbridge
272
+ .removeTargets({
273
+ EventBusName: this.eventBusName,
274
+ Rule: jobId,
275
+ Ids: [jobId],
276
+ })
277
+ .promise();
278
+ await this.eventbridge
279
+ .deleteRule({
280
+ EventBusName: this.eventBusName,
281
+ Name: jobId,
282
+ })
283
+ .promise();
284
+ }
285
+ async checkIntervalScheduler() {
286
+ //-- check and create the step function
287
+ const allJobs = await this.getAvailableJobInfo();
288
+ const intervalJobs = allJobs.filter((j) => j.interval);
289
+ const allIntervals = intervalJobs.map((j) => j.interval);
290
+ const [interval, oneMinuteFunctionDefinition] = oneMinuteFunctionFactory(this.apiLambdaFunctionArn, allIntervals);
291
+ this.logger.debug("Listing all state machines");
292
+ const allMachines = await this.stepfunctions.listStateMachines({}).promise();
293
+ let oldStateMachineArn = allMachines.stateMachines.find((s) => s.name.includes(this.stepFunctionName))?.stateMachineArn;
294
+ let newStateMachineArn = oldStateMachineArn;
295
+ if (oldStateMachineArn) {
296
+ //-- check definition, if different then clear newStateMachineArn to create new one
297
+ const describeResult = await this.stepfunctions
298
+ .describeStateMachine({
299
+ stateMachineArn: oldStateMachineArn,
300
+ })
301
+ .promise();
302
+ //-- check the interval
303
+ if (!describeResult.definition.includes(getWaitExpression(interval))) {
304
+ this.logger.debug("Step function definition changed, create new state machine");
305
+ newStateMachineArn = "";
306
+ }
307
+ }
308
+ const awsStateMachineName = `${this.stepFunctionName}-${interval}`;
309
+ if (!newStateMachineArn) {
310
+ this.logger.debug(`Create new step function with interval ${interval} seconds`);
311
+ const result = await this.stepfunctions
312
+ .createStateMachine({
313
+ definition: oneMinuteFunctionDefinition,
314
+ name: awsStateMachineName,
315
+ roleArn: this.iamRoleArn,
316
+ type: "EXPRESS",
317
+ })
318
+ .promise();
319
+ newStateMachineArn = result.stateMachineArn;
320
+ }
321
+ this.logger.debug("Step function ARNs old / new", oldStateMachineArn, newStateMachineArn);
322
+ //-- check and create the one-minute-rule that trigger step function
323
+ this.logger.debug("Getting one minute rule");
324
+ const matchedRules = await this.eventbridge
325
+ .listRules({
326
+ EventBusName: this.eventBusName,
327
+ NamePrefix: this.oneMinuteRule,
328
+ })
329
+ .promise();
330
+ const oneMinuteRule = matchedRules.Rules?.find((r) => r.Name == this.oneMinuteRule);
331
+ if (!oneMinuteRule) {
332
+ this.logger.debug("Create new one minute rule");
333
+ //-- one minute rule does not exist, create new
334
+ await this.eventbridge
335
+ .putRule({
336
+ EventBusName: this.eventBusName,
337
+ Name: this.oneMinuteRule,
338
+ Description: "One minute trigger function for step function",
339
+ ScheduleExpression: "rate(1 minute)",
340
+ State: "ENABLED",
341
+ })
342
+ .promise();
343
+ }
344
+ this.logger.debug("Adding step function state machine as target");
345
+ if (newStateMachineArn !== oldStateMachineArn) {
346
+ //-- if there is old target then remove it
347
+ if (oldStateMachineArn) {
348
+ this.logger.debug("Removing old target", oldStateMachineArn);
349
+ //-- remove old target
350
+ await this.eventbridge
351
+ .removeTargets({
352
+ EventBusName: this.eventBusName,
353
+ Rule: this.oneMinuteRule,
354
+ Ids: [this.stepFunctionName],
355
+ })
356
+ .promise();
357
+ this.logger.debug("Removing old state machine function");
358
+ await this.stepfunctions
359
+ .deleteStateMachine({
360
+ stateMachineArn: oldStateMachineArn,
361
+ })
362
+ .promise();
363
+ }
364
+ //-- add the step function as call target
365
+ this.logger.debug("Adding new target", newStateMachineArn);
366
+ await this.eventbridge
367
+ .putTargets({
368
+ Rule: this.oneMinuteRule,
369
+ Targets: [
370
+ {
371
+ Arn: newStateMachineArn,
372
+ Id: this.stepFunctionName,
373
+ RoleArn: this.iamRoleArn,
374
+ },
375
+ ],
376
+ })
377
+ .promise();
378
+ }
379
+ //-- if we don't have any interval job then disable the one minuter
380
+ if (!intervalJobs.length && oneMinuteRule?.State) {
381
+ this.logger.info("No interval job found, disable one minute rule");
382
+ await this.eventbridge
383
+ .disableRule({
384
+ EventBusName: this.eventBusName,
385
+ Name: this.oneMinuteRule,
386
+ })
387
+ .promise();
388
+ }
389
+ else {
390
+ this.logger.info("Interval job found, enable one minute rule");
391
+ await this.eventbridge
392
+ .enableRule({
393
+ EventBusName: this.eventBusName,
394
+ Name: this.oneMinuteRule,
395
+ })
396
+ .promise();
397
+ }
398
+ }
399
+ };
400
+ AwsJobScheduler = __decorate([
401
+ LogContext(),
402
+ __metadata("design:paramtypes", [AbstractLogger,
403
+ Redis, String, String, String, String, Object, Object, Object])
404
+ ], AwsJobScheduler);
405
+ export { AwsJobScheduler };
@@ -0,0 +1,273 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ import { AbstractLogger, Errors, LogContext } from "@clairejs/core";
11
+ import Redis from "ioredis";
12
+ import Redlock from "redlock";
13
+ import scheduler from "node-schedule";
14
+ import { AbstractJobScheduler } from "./AbstractJobScheduler";
15
+ import { AbstractJobRepository } from "./AbstractJobRepository";
16
+ import { clearInterval, clearTimeout } from "timers";
17
+ var CommunicationMessage;
18
+ (function (CommunicationMessage) {
19
+ CommunicationMessage["SCHEDULE_JOB"] = "SCHEDULE_JOB";
20
+ CommunicationMessage["REMOVE_JOB"] = "REMOVE_JOB";
21
+ CommunicationMessage["SYNC_JOB"] = "SYNC_JOB";
22
+ CommunicationMessage["NOTIFY"] = "NOTIFY";
23
+ })(CommunicationMessage || (CommunicationMessage = {}));
24
+ let LocalJobScheduler = class LocalJobScheduler extends AbstractJobScheduler {
25
+ logger;
26
+ redisClient;
27
+ subscribeClient;
28
+ jobRepo;
29
+ lockMutexKey;
30
+ holdMutexKey;
31
+ uniqueIdKey;
32
+ multiClientChannel;
33
+ keyRetentionDurationSecond;
34
+ intervals = [];
35
+ mutexHoldInterval;
36
+ isActive = false;
37
+ notifyResolver = {};
38
+ jobHolder = {};
39
+ constructor(logger, redisClient, subscribeClient, jobRepo,
40
+ /**
41
+ * Redis lock key to select active scheduler
42
+ */
43
+ lockMutexKey,
44
+ /**
45
+ * Redis key to hold active scheduler role
46
+ */
47
+ holdMutexKey,
48
+ /**
49
+ * Redis key to get unique incremental id
50
+ */
51
+ uniqueIdKey,
52
+ /**
53
+ * The channel for communication between passive and active schedulers
54
+ */
55
+ multiClientChannel,
56
+ /**
57
+ * The time to lock active scheduler
58
+ */
59
+ keyRetentionDurationSecond = 30) {
60
+ super(logger);
61
+ this.logger = logger;
62
+ this.redisClient = redisClient;
63
+ this.subscribeClient = subscribeClient;
64
+ this.jobRepo = jobRepo;
65
+ this.lockMutexKey = lockMutexKey;
66
+ this.holdMutexKey = holdMutexKey;
67
+ this.uniqueIdKey = uniqueIdKey;
68
+ this.multiClientChannel = multiClientChannel;
69
+ this.keyRetentionDurationSecond = keyRetentionDurationSecond;
70
+ }
71
+ sendJob(type, messageId, data) {
72
+ this.subscribeClient.publish(this.multiClientChannel, JSON.stringify({ type, messageId, data }));
73
+ }
74
+ async processMessage(type, messageId, data) {
75
+ switch (type) {
76
+ case CommunicationMessage.SYNC_JOB:
77
+ await this.syncJobs();
78
+ this.sendJob(CommunicationMessage.NOTIFY, messageId);
79
+ break;
80
+ case CommunicationMessage.NOTIFY:
81
+ const resolver = this.notifyResolver[messageId];
82
+ if (!resolver) {
83
+ //-- resolver not found, ignore
84
+ return;
85
+ }
86
+ resolver(data);
87
+ break;
88
+ case CommunicationMessage.SCHEDULE_JOB:
89
+ if (!this.isActive) {
90
+ //-- not active scheduler, ignore
91
+ return;
92
+ }
93
+ const jobInfo = data;
94
+ const scheduledId = await this.scheduleJobAt(jobInfo);
95
+ this.sendJob(CommunicationMessage.NOTIFY, messageId, scheduledId);
96
+ break;
97
+ case CommunicationMessage.REMOVE_JOB:
98
+ if (!this.isActive) {
99
+ //-- not active scheduler, ignore
100
+ return;
101
+ }
102
+ const jobId = data;
103
+ await this.removeJob(jobId);
104
+ this.sendJob(CommunicationMessage.NOTIFY, messageId);
105
+ break;
106
+ default:
107
+ this.logger.error(`Not recognize message type ${type}`);
108
+ break;
109
+ }
110
+ }
111
+ async extendMutexKey() {
112
+ //-- set expire the mutex key
113
+ if (!this.redisClient) {
114
+ return;
115
+ }
116
+ await this.redisClient.setex(this.holdMutexKey, this.keyRetentionDurationSecond, 1);
117
+ this.logger.debug("Scheduler extends mutex key");
118
+ }
119
+ async init() {
120
+ this.logger.debug("LocalJobScheduler init");
121
+ //-- subscribe to multi client channel
122
+ this.logger.debug("Listening on multi client channel");
123
+ this.redisClient.on("message", (channel, message) => {
124
+ if (channel === this.multiClientChannel) {
125
+ //-- process message
126
+ const payload = JSON.parse(message);
127
+ this.processMessage(payload.type, payload.messageId, payload.data).catch((err) => this.logger.error(`Fail to process message, ${payload}`, err));
128
+ }
129
+ });
130
+ await this.subscribeClient.subscribe(this.multiClientChannel);
131
+ this.logger.debug("Try to claim active scheduler");
132
+ //-- try to claim active scheduler
133
+ const locker = new Redlock([this.redisClient]);
134
+ try {
135
+ const lock = await locker.acquire([this.lockMutexKey], this.keyRetentionDurationSecond);
136
+ this.isActive = true;
137
+ await this.extendMutexKey();
138
+ await lock.release();
139
+ this.logger.debug("Being active scheduler");
140
+ //-- actively hold the mutex key
141
+ this.mutexHoldInterval = setInterval(() => {
142
+ this.extendMutexKey();
143
+ }, Math.trunc((this.keyRetentionDurationSecond * 1000) / 2) + 1);
144
+ }
145
+ catch (err) {
146
+ this.logger.info("Failed to lock mutex key, ignore", err);
147
+ }
148
+ }
149
+ exit() {
150
+ if (this.mutexHoldInterval) {
151
+ clearInterval(this.mutexHoldInterval);
152
+ }
153
+ this.redisClient.quit();
154
+ this.subscribeClient.quit();
155
+ for (const interval of this.intervals) {
156
+ clearInterval(interval);
157
+ }
158
+ this.logger.debug("LocalJobScheduler exit");
159
+ }
160
+ isActiveScheduler() {
161
+ return this.isActive;
162
+ }
163
+ async getAllScheduledJobs() {
164
+ return Object.values(this.jobHolder)
165
+ .filter((j) => !!j?.jobInfo)
166
+ .map((info) => info?.jobInfo);
167
+ }
168
+ async syncJobs() {
169
+ if (this.isActive) {
170
+ //-- schedule all cron & interval jobs
171
+ const allJobs = await this.getAvailableJobInfo();
172
+ for (const job of allJobs) {
173
+ if (job.cron || job.interval) {
174
+ await this.scheduleJob(job);
175
+ }
176
+ }
177
+ //-- re-schedule jobs that are stored in repo
178
+ const allPersistedJobs = await this.jobRepo.getJobs();
179
+ //-- run job anyway, expired job will be removed then
180
+ for (const job of allPersistedJobs) {
181
+ await this.scheduleJob(job);
182
+ }
183
+ }
184
+ else {
185
+ const uniqueMessageId = await this.redisClient.incr(this.uniqueIdKey);
186
+ return new Promise((resolve) => {
187
+ this.notifyResolver[uniqueMessageId] = resolve;
188
+ this.sendJob(CommunicationMessage.SYNC_JOB, uniqueMessageId);
189
+ });
190
+ }
191
+ return;
192
+ }
193
+ async scheduleJob(jobInfo) {
194
+ if (this.isActive) {
195
+ //-- case each job type
196
+ if (jobInfo.at) {
197
+ //-- create new schedule job
198
+ const jobId = await this.jobRepo.saveJob({
199
+ jobName: jobInfo.jobName,
200
+ params: jobInfo.params,
201
+ at: jobInfo.at,
202
+ });
203
+ //-- use the lib
204
+ const scheduledJob = { ...jobInfo, jobId };
205
+ const timeout = setTimeout(() => {
206
+ this.executeJob(scheduledJob).catch((err) => this.logger.error(`Error execute job ${scheduledJob.jobName} with id: ${scheduledJob.jobId}`, err));
207
+ }, new Date(jobInfo.at).getTime() - Date.now());
208
+ this.jobHolder[jobId] = { jobCanceler: () => clearTimeout(timeout), jobInfo: { ...jobInfo, jobId } };
209
+ return jobId;
210
+ }
211
+ else if (jobInfo.interval) {
212
+ const jobId = jobInfo.jobName;
213
+ //-- set interval and does not need to persist
214
+ const scheduledJob = { ...jobInfo, jobId };
215
+ const interval = setInterval(() => {
216
+ this.executeJob(scheduledJob).catch((err) => this.logger.error(`Error execute job ${scheduledJob.jobName} with id: ${scheduledJob.jobId}`, err));
217
+ }, jobInfo.interval);
218
+ this.jobHolder[jobId] = { jobCanceler: () => clearInterval(interval), jobInfo: { ...jobInfo, jobId } };
219
+ return jobId;
220
+ }
221
+ else if (jobInfo.cron) {
222
+ const jobId = jobInfo.jobName;
223
+ //-- set cron and does not need to persist
224
+ const scheduledJob = { ...jobInfo, jobId };
225
+ const job = scheduler.scheduleJob(jobInfo.cron, () => {
226
+ this.executeJob(scheduledJob).catch((err) => this.logger.error(`Error execute job ${scheduledJob.jobName} with id: ${scheduledJob.jobId}`, err));
227
+ });
228
+ this.jobHolder[jobId] = { jobCanceler: () => job.cancel(), jobInfo: { ...jobInfo, jobId } };
229
+ return jobId;
230
+ }
231
+ else {
232
+ throw Errors.SYSTEM_ERROR(`Job does not have time config: ${jobInfo.jobName}`);
233
+ }
234
+ }
235
+ else {
236
+ //-- get unique message id
237
+ const uniqueMessageId = await this.redisClient.incr(this.uniqueIdKey);
238
+ return new Promise((resolve) => {
239
+ this.notifyResolver[uniqueMessageId] = resolve;
240
+ this.sendJob(CommunicationMessage.SCHEDULE_JOB, uniqueMessageId, jobInfo);
241
+ });
242
+ }
243
+ }
244
+ async removeJob(jobId) {
245
+ if (this.isActive) {
246
+ //-- remove from holder
247
+ const job = this.jobHolder[jobId];
248
+ if (job) {
249
+ job.jobCanceler();
250
+ this.jobHolder[jobId] = undefined;
251
+ }
252
+ //-- remove from persistence
253
+ await this.jobRepo.removeJobById(jobId);
254
+ return;
255
+ }
256
+ else {
257
+ //-- get unique message id
258
+ const uniqueMessageId = await this.redisClient.incr(this.uniqueIdKey);
259
+ return new Promise((resolve) => {
260
+ this.notifyResolver[uniqueMessageId] = resolve;
261
+ this.sendJob(CommunicationMessage.REMOVE_JOB, uniqueMessageId, jobId);
262
+ });
263
+ }
264
+ }
265
+ };
266
+ LocalJobScheduler = __decorate([
267
+ LogContext(),
268
+ __metadata("design:paramtypes", [AbstractLogger,
269
+ Redis,
270
+ Redis,
271
+ AbstractJobRepository, String, String, String, String, Number])
272
+ ], LocalJobScheduler);
273
+ export { LocalJobScheduler };