@clairejs/server 3.26.1 → 3.27.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/README.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## Change Log
2
2
 
3
+ #### 3.27.0
4
+
5
+ - use aws scheduler for jobs
6
+
7
+ #### 3.26.2
8
+
9
+ - clean up jobs after run
10
+
3
11
  #### 3.26.1
4
12
 
5
13
  - remove the resolve url function in file upload handler
@@ -29,6 +29,11 @@ export declare abstract class AbstractJobScheduler {
29
29
  * @param id The job id returned from scheduleJobAt function
30
30
  */
31
31
  protected abstract cancelJob(id: string): Promise<void>;
32
+ /**
33
+ * Clean up the job after it has been executed
34
+ * @param job The job to clean up
35
+ */
36
+ protected abstract cleanupJob(job: AbstractJob): Promise<void>;
32
37
  /**
33
38
  * Remove the scheduled job and prevent if from running in the future
34
39
  * @param id The job id returned from scheduleJobAt function
@@ -129,6 +129,8 @@ export class AbstractJobScheduler {
129
129
  await jobHandler.handlerFn(job);
130
130
  //-- job run success, update
131
131
  update.lastSuccessAt = Date.now();
132
+ //-- clean up
133
+ await this.cleanupJob(job);
132
134
  //-- reset retry count if this is cron job
133
135
  if (job.cron) {
134
136
  if (job.retryCount) {
@@ -1,24 +1,21 @@
1
1
  import { AbstractLogger } from "@clairejs/core";
2
2
  import { AbstractDbAdapter } from "@clairejs/orm";
3
- import aws from "aws-sdk";
3
+ import { SchedulerClient } from "@aws-sdk/client-scheduler";
4
4
  import { AbstractJob } from "./interfaces";
5
5
  import { AbstractJobScheduler } from "./AbstractJobScheduler";
6
6
  import { AbstractJobRepository } from "./AbstractJobRepository";
7
- /**
8
- * Following EventBrige permissions is required: listRules, putRule, deleteRule, listTargetsByRule, putTargets, removeTargets.
9
- * Also update Lambda function to alow function invocation from events.amazonaws.com.
10
- */
11
7
  export declare class AwsJobScheduler extends AbstractJobScheduler {
12
8
  protected readonly logger: AbstractLogger;
13
9
  protected readonly db: AbstractDbAdapter;
14
10
  protected readonly jobRepo: AbstractJobRepository;
15
11
  protected readonly apiLambdaFunctionArn: string;
12
+ protected readonly apiLambdaFunctionRoleArn: string;
16
13
  protected readonly jobNamespace: string;
17
- protected readonly eventBusName: string;
18
- protected readonly eventbridge: aws.EventBridge;
19
- constructor(logger: AbstractLogger, db: AbstractDbAdapter, jobRepo: AbstractJobRepository, apiLambdaFunctionArn: string, jobNamespace: string, eventBusName?: string);
14
+ protected readonly scheduler: SchedulerClient;
15
+ constructor(logger: AbstractLogger, db: AbstractDbAdapter, jobRepo: AbstractJobRepository, apiLambdaFunctionArn: string, apiLambdaFunctionRoleArn: string, jobNamespace: string);
20
16
  handleCron(jobInfo: AbstractJob): Promise<void>;
21
- private generateCronFromTimestamp;
17
+ private convertCronToSchedulerExpression;
18
+ protected cleanupJob(job: AbstractJob): Promise<void>;
22
19
  protected getScheduledJobs(): Promise<AbstractJob[]>;
23
20
  protected _scheduleJob(jobInfo: AbstractJob): Promise<void>;
24
21
  cancelJob(jobId: string): Promise<void>;
@@ -9,143 +9,173 @@ var __metadata = (this && this.__metadata) || function (k, v) {
9
9
  };
10
10
  import { AbstractLogger, Errors, LogContext } from "@clairejs/core";
11
11
  import { AbstractDbAdapter } from "@clairejs/orm";
12
- import assert from "assert";
13
- import aws from "aws-sdk";
12
+ import { SchedulerClient, CreateScheduleCommand, DeleteScheduleCommand, GetScheduleCommand, ListSchedulesCommand, ScheduleState } from "@aws-sdk/client-scheduler";
14
13
  import { AbstractJobScheduler } from "./AbstractJobScheduler";
15
14
  import { AbstractJobRepository } from "./AbstractJobRepository";
16
15
  /**
17
- * Following EventBrige permissions is required: listRules, putRule, deleteRule, listTargetsByRule, putTargets, removeTargets.
18
- * Also update Lambda function to alow function invocation from events.amazonaws.com.
16
+ * AWS Scheduler API permissions required:
17
+ * scheduler:CreateSchedule, scheduler:DeleteSchedule, scheduler:GetSchedule,
18
+ * scheduler:ListSchedules, scheduler:UpdateSchedule
19
+ * Also update Lambda function to allow function invocation from scheduler.amazonaws.com.
19
20
  */
21
+ const schedulerGroupName = "default";
20
22
  let AwsJobScheduler = class AwsJobScheduler extends AbstractJobScheduler {
21
23
  logger;
22
24
  db;
23
25
  jobRepo;
24
26
  apiLambdaFunctionArn;
27
+ apiLambdaFunctionRoleArn;
25
28
  jobNamespace;
26
- eventBusName;
27
- eventbridge = new aws.EventBridge();
28
- constructor(logger, db, jobRepo, apiLambdaFunctionArn, jobNamespace, eventBusName = "default") {
29
+ scheduler = new SchedulerClient();
30
+ constructor(logger, db, jobRepo, apiLambdaFunctionArn, apiLambdaFunctionRoleArn, jobNamespace) {
29
31
  super(logger, db, jobRepo);
30
32
  this.logger = logger;
31
33
  this.db = db;
32
34
  this.jobRepo = jobRepo;
33
35
  this.apiLambdaFunctionArn = apiLambdaFunctionArn;
36
+ this.apiLambdaFunctionRoleArn = apiLambdaFunctionRoleArn;
34
37
  this.jobNamespace = jobNamespace;
35
- this.eventBusName = eventBusName;
36
38
  }
37
39
  async handleCron(jobInfo) {
38
40
  this.logger.debug(`Handle cron`, jobInfo);
39
41
  await this.executeJob(jobInfo);
40
42
  }
41
- generateCronFromTimestamp(at) {
42
- const date = new Date(at);
43
- return [
44
- date.getUTCMinutes(),
45
- date.getUTCHours(),
46
- date.getUTCDate(),
47
- date.getUTCMonth() + 1,
48
- "?",
49
- date.getUTCFullYear(),
50
- ].join(" ");
43
+ convertCronToSchedulerExpression(cron) {
44
+ // AWS Scheduler uses a different cron format than standard cron
45
+ // Standard cron: minute hour day month day-of-week
46
+ // AWS Scheduler: minute hour day month day-of-week year
47
+ const parts = cron.split(" ");
48
+ if (parts.length === 5) {
49
+ // Add year field if not present
50
+ return `${cron} *`;
51
+ }
52
+ else if (parts.length === 6) {
53
+ return cron;
54
+ }
55
+ else {
56
+ throw Errors.SYSTEM_ERROR(`Invalid cron expression: ${cron}`);
57
+ }
58
+ }
59
+ async cleanupJob(job) {
60
+ if (job.at) {
61
+ await this.cancelJob(job.id);
62
+ }
51
63
  }
52
64
  async getScheduledJobs() {
53
- const allRules = await this.eventbridge
54
- .listRules({
55
- EventBusName: this.eventBusName,
56
- NamePrefix: this.jobNamespace,
57
- })
58
- .promise();
59
- const allJobs = await Promise.all((allRules.Rules || []).map(async (rule) => {
60
- const ruleName = rule.Name;
61
- const targets = await this.eventbridge
62
- .listTargetsByRule({
63
- EventBusName: this.eventBusName,
64
- Rule: ruleName,
65
- })
66
- .promise();
67
- const lambdaTarget = targets.Targets?.find((t) => t.Id === ruleName);
68
- if (!lambdaTarget || !lambdaTarget.Input) {
69
- return undefined;
70
- }
71
- const jobInfo = JSON.parse(lambdaTarget.Input).requestContext.cronScheduler.data;
72
- return jobInfo;
73
- }));
74
- //-- concat with interval jobs because we don't register them
75
- return allJobs.flatMap((job) => (job ? [job] : []));
65
+ try {
66
+ const listCommand = new ListSchedulesCommand({
67
+ GroupName: schedulerGroupName,
68
+ NamePrefix: this.jobNamespace,
69
+ });
70
+ const allSchedules = await this.scheduler.send(listCommand);
71
+ const allJobs = await Promise.all((allSchedules.Schedules || []).map(async (schedule) => {
72
+ const scheduleName = schedule.Name;
73
+ try {
74
+ const getCommand = new GetScheduleCommand({
75
+ Name: scheduleName,
76
+ GroupName: schedulerGroupName,
77
+ });
78
+ const scheduleDetails = await this.scheduler.send(getCommand);
79
+ if (!scheduleDetails.Target?.Arn || !scheduleDetails.Target?.Input) {
80
+ return undefined;
81
+ }
82
+ const jobInfo = JSON.parse(scheduleDetails.Target.Input).requestContext.cronScheduler.data;
83
+ return jobInfo;
84
+ }
85
+ catch (error) {
86
+ this.logger.warn(`Failed to get schedule details for ${scheduleName}`, error);
87
+ return undefined;
88
+ }
89
+ }));
90
+ return allJobs.flatMap((job) => (job ? [job] : []));
91
+ }
92
+ catch (error) {
93
+ this.logger.error("Failed to get scheduled jobs", error);
94
+ return [];
95
+ }
76
96
  }
77
97
  async _scheduleJob(jobInfo) {
78
98
  this.logger.debug("Scheduling job: ", jobInfo);
79
99
  if (jobInfo.cron || jobInfo.at) {
80
100
  const jobId = `${this.jobNamespace}${jobInfo.id}`;
81
- let cronExpression;
101
+ let scheduleExpression;
102
+ let flexibleTimeWindow;
82
103
  if (jobInfo.at) {
83
- cronExpression = this.generateCronFromTimestamp(jobInfo.at);
104
+ // For one-time jobs, use at() expression
105
+ const date = new Date(jobInfo.at);
106
+ scheduleExpression = `at(${date.toISOString()})`;
107
+ // Add flexible time window for one-time jobs to handle slight delays
108
+ flexibleTimeWindow = {
109
+ Mode: "OFF",
110
+ };
84
111
  }
85
112
  else if (jobInfo.cron) {
86
- let cron = jobInfo.cron;
87
- if (cron.endsWith(" *")) {
88
- cron = `${cron.slice(0, -2)} ?`;
89
- }
90
- cronExpression = `${cron} *`;
113
+ // For recurring jobs, use cron() expression
114
+ const schedulerCron = this.convertCronToSchedulerExpression(jobInfo.cron);
115
+ scheduleExpression = `cron(${schedulerCron})`;
116
+ // Add flexible time window for recurring jobs
117
+ flexibleTimeWindow = {
118
+ Mode: "FLEXIBLE",
119
+ MaximumWindowInMinutes: 5, // Allow 5-minute window for execution
120
+ };
121
+ }
122
+ else {
123
+ throw Errors.SYSTEM_ERROR(`Job does not have time config: ${jobInfo.jobName}`);
91
124
  }
92
- this.logger.debug("Cron expression", cronExpression);
93
- assert.ok(cronExpression);
94
- await this.eventbridge
95
- .putRule({
125
+ this.logger.debug("Schedule expression", scheduleExpression);
126
+ const scheduleParams = {
96
127
  Name: jobId,
97
- Description: `${jobInfo.jobName} - ${cronExpression || jobInfo.at}`,
98
- EventBusName: this.eventBusName,
99
- ScheduleExpression: `cron(${cronExpression})`,
100
- State: "ENABLED",
101
- })
102
- .promise();
103
- //-- this rule has only one target, which is the api lambda function, so id of target can be the same as id of rule
104
- await this.eventbridge
105
- .putTargets({
106
- Rule: jobId,
107
- Targets: [
108
- {
109
- Arn: this.apiLambdaFunctionArn,
110
- Id: jobId,
111
- //-- this json will be passed to lambda function as event input
112
- Input: JSON.stringify({
113
- requestContext: {
114
- cronScheduler: {
115
- data: { ...jobInfo, jobId },
116
- },
128
+ GroupName: schedulerGroupName,
129
+ Description: `${jobInfo.jobName} - ${scheduleExpression}`,
130
+ ScheduleExpression: scheduleExpression,
131
+ FlexibleTimeWindow: flexibleTimeWindow,
132
+ Target: {
133
+ Arn: this.apiLambdaFunctionArn,
134
+ RoleArn: this.apiLambdaFunctionRoleArn,
135
+ Input: JSON.stringify({
136
+ requestContext: {
137
+ cronScheduler: {
138
+ data: { ...jobInfo, jobId },
117
139
  },
118
- }),
119
- },
120
- ],
121
- })
122
- .promise();
140
+ },
141
+ }),
142
+ },
143
+ State: ScheduleState.ENABLED,
144
+ };
145
+ try {
146
+ const createCommand = new CreateScheduleCommand(scheduleParams);
147
+ await this.scheduler.send(createCommand);
148
+ this.logger.debug(`Successfully created schedule: ${jobId}`);
149
+ }
150
+ catch (error) {
151
+ this.logger.error(`Failed to create schedule: ${jobId}`, error);
152
+ throw error;
153
+ }
123
154
  }
124
155
  else {
125
156
  throw Errors.SYSTEM_ERROR(`Job does not have time config: ${jobInfo.jobName}`);
126
157
  }
127
158
  }
128
159
  async cancelJob(jobId) {
129
- const ruleId = `${this.jobNamespace}${jobId}`;
130
- await this.eventbridge
131
- .removeTargets({
132
- EventBusName: this.eventBusName,
133
- Rule: ruleId,
134
- Ids: [ruleId],
135
- })
136
- .promise();
137
- await this.eventbridge
138
- .deleteRule({
139
- EventBusName: this.eventBusName,
140
- Name: ruleId,
141
- })
142
- .promise();
160
+ const scheduleName = `${this.jobNamespace}${jobId}`;
161
+ try {
162
+ const deleteCommand = new DeleteScheduleCommand({
163
+ Name: scheduleName,
164
+ GroupName: schedulerGroupName,
165
+ });
166
+ await this.scheduler.send(deleteCommand);
167
+ this.logger.debug(`Successfully deleted schedule: ${scheduleName}`);
168
+ }
169
+ catch (error) {
170
+ this.logger.error(`Failed to delete schedule: ${scheduleName}`, error);
171
+ throw error;
172
+ }
143
173
  }
144
174
  };
145
175
  AwsJobScheduler = __decorate([
146
176
  LogContext(),
147
177
  __metadata("design:paramtypes", [AbstractLogger,
148
178
  AbstractDbAdapter,
149
- AbstractJobRepository, String, String, Object])
179
+ AbstractJobRepository, String, String, String])
150
180
  ], AwsJobScheduler);
151
181
  export { AwsJobScheduler };
@@ -50,6 +50,7 @@ export declare class LocalJobScheduler extends AbstractJobScheduler implements I
50
50
  private sendJob;
51
51
  private processMessage;
52
52
  private extendMutexKey;
53
+ protected cleanupJob(): Promise<void>;
53
54
  init(): Promise<void>;
54
55
  exit(): void;
55
56
  protected getScheduledJobs(): Promise<AbstractJob[]>;
@@ -103,6 +103,9 @@ let LocalJobScheduler = class LocalJobScheduler extends AbstractJobScheduler {
103
103
  await this.redisClient.setex(this.holdMutexKey, this.keyRetentionDurationSeconds, 1);
104
104
  this.logger.debug("Scheduler extends mutex key");
105
105
  }
106
+ async cleanupJob() {
107
+ //-- do nothing
108
+ }
106
109
  async init() {
107
110
  this.logger.debug("LocalJobScheduler init");
108
111
  //-- subscribe to multi client channel
@@ -106,6 +106,7 @@ export class LambdaWrapper {
106
106
  return toApiGatewayFormat({ code: 200, headers: corsHeaders, cookies: {} });
107
107
  }
108
108
  if (requestOptions.method === CRON_REQUEST_METHOD) {
109
+ console.log("handle cron", requestOptions.body);
109
110
  await this.jobScheduler?.handleCron(requestOptions.body);
110
111
  return toApiGatewayFormat({ code: 200, headers: corsHeaders, cookies: {} });
111
112
  }
@@ -11,6 +11,7 @@ const parseCookie = (cookieArray) => {
11
11
  return result;
12
12
  };
13
13
  export const lambdaRequestMapper = (event) => {
14
+ console.log("lambda event", event);
14
15
  if (event.requestContext.eventType) {
15
16
  //-- socket
16
17
  const method = event.requestContext.eventType.toLowerCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clairejs/server",
3
- "version": "3.26.1",
3
+ "version": "3.27.0",
4
4
  "description": "Claire server NodeJs framework written in Typescript.",
5
5
  "types": "dist/index.d.ts",
6
6
  "main": "dist/index.js",
@@ -15,6 +15,7 @@
15
15
  "author": "immort",
16
16
  "license": "ISC",
17
17
  "dependencies": {
18
+ "@aws-sdk/client-scheduler": "^3.848.0",
18
19
  "aws-sdk": "^2.841.0",
19
20
  "axios": "^1.6.2",
20
21
  "cookie-parser": "^1.4.6",