@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.
- package/.mocharc.json +3 -0
- package/README.md +1 -1
- package/dist/common/AbstractController.js +3 -0
- package/dist/common/ControllerMetadata.js +1 -0
- package/dist/common/FileOperation.js +6 -0
- package/dist/common/ServerModelMetadata.js +1 -0
- package/dist/common/Transactionable.js +17 -0
- package/dist/common/auth/AbstractPrincipalResolver.js +2 -0
- package/dist/common/auth/IPrincipal.js +1 -0
- package/dist/common/constants.js +7 -0
- package/dist/common/decorator.d.ts +2 -2
- package/dist/common/decorator.js +6 -0
- package/dist/common/request/EndpointMetadata.js +1 -0
- package/dist/common/request/HttpData.js +1 -0
- package/dist/common/request/HttpEndpoint.js +1 -0
- package/dist/common/request/JobData.js +1 -0
- package/dist/common/request/MountedEndpointInfo.js +1 -0
- package/dist/common/request/RequestOptions.js +1 -0
- package/dist/common/request/SocketData.js +1 -0
- package/dist/common/request/types.d.ts +1 -1
- package/dist/common/request/types.js +1 -0
- package/dist/controllers/FileManageController.js +90 -0
- package/dist/controllers/FileUploadController.js +64 -0
- package/dist/controllers/dto/system.js +14 -0
- package/dist/controllers/dto/upload.js +205 -0
- package/dist/http/auth/AbstractHttpAuthorizer.js +2 -0
- package/dist/http/common/HttpRequest.js +72 -0
- package/dist/http/common/HttpResponse.js +62 -0
- package/dist/http/controller/AbstractHttpController.js +21 -0
- package/dist/http/controller/AbstractHttpMiddleware.js +2 -0
- package/dist/http/controller/AbstractHttpRequestHandler.js +69 -0
- package/dist/http/controller/CrudHttpController.js +302 -0
- package/dist/http/controller/DefaultHttpRequestHandler.js +143 -0
- package/dist/http/decorators.d.ts +1 -1
- package/dist/http/decorators.js +86 -0
- package/dist/http/file-upload/AbstractFileUploadHandler.js +2 -0
- package/dist/http/file-upload/FileUploadHandler.js +41 -0
- package/dist/http/file-upload/types.d.ts +1 -1
- package/dist/http/file-upload/types.js +1 -0
- package/dist/http/repository/AbstractRepository.js +26 -0
- package/dist/http/repository/DtoRepository.d.ts +3 -3
- package/dist/http/repository/DtoRepository.js +204 -0
- package/dist/http/repository/ICrudRepository.js +1 -0
- package/dist/http/repository/ModelRepository.js +696 -0
- package/dist/http/security/AbstractAccessCondition.js +2 -0
- package/dist/http/security/access-conditions/FilterModelFieldAccessCondition.js +30 -0
- package/dist/http/security/access-conditions/MaximumQueryLimit.js +31 -0
- package/dist/http/security/cors.js +1 -0
- package/dist/http/utils.js +32 -0
- package/dist/index.js +75 -1
- package/dist/job/AbstractJobController.js +9 -0
- package/dist/job/AbstractJobRepository.js +2 -0
- package/dist/job/AbstractJobScheduler.js +48 -0
- package/dist/job/AwsJobScheduler.js +405 -0
- package/dist/job/LocalJobScheduler.js +273 -0
- package/dist/job/decorators.js +57 -0
- package/dist/job/interfaces.js +10 -0
- package/dist/logging/FileLogMedium.js +44 -0
- package/dist/services/AbstractFileService.js +28 -0
- package/dist/services/AbstractMailService.js +2 -0
- package/dist/services/AbstractService.js +3 -0
- package/dist/services/AbstractSmsService.js +2 -0
- package/dist/services/implementations/LocalFileService.js +42 -0
- package/dist/services/implementations/LocalMailService.js +27 -0
- package/dist/services/implementations/LocalSmsService.js +17 -0
- package/dist/services/implementations/S3FileService.js +107 -0
- package/dist/services/implementations/SesMailService.js +64 -0
- package/dist/socket/AbstractServerSocket.js +44 -0
- package/dist/socket/AbstractServerSocketManager.d.ts +1 -1
- package/dist/socket/AbstractServerSocketManager.js +348 -0
- package/dist/socket/AbstractSocketConnectionHandler.js +2 -0
- package/dist/socket/AbstractSocketController.d.ts +3 -3
- package/dist/socket/AbstractSocketController.js +12 -0
- package/dist/socket/AwsSocketManager.d.ts +2 -2
- package/dist/socket/AwsSocketManager.js +160 -0
- package/dist/socket/IServerSocket.js +1 -0
- package/dist/socket/LocalSocketManager.js +292 -0
- package/dist/system/ClaireServer.js +78 -0
- package/dist/system/ExpressWrapper.js +122 -0
- package/dist/system/LambdaWrapper.js +151 -0
- package/dist/system/ServerGlobalStore.js +1 -0
- package/dist/system/lamba-request-mapper.js +49 -0
- package/dist/system/locale/LocaleEntry.js +13 -0
- package/dist/system/locale/LocaleTranslation.js +47 -0
- package/dist/system/locale/decorators.js +14 -0
- 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 };
|