@clairejs/server 3.26.2 → 3.27.1

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,9 @@
1
1
  ## Change Log
2
2
 
3
+ #### 3.27.1
4
+
5
+ - use aws scheduler for jobs
6
+
3
7
  #### 3.26.2
4
8
 
5
9
  - clean up jobs after run
@@ -1,24 +1,20 @@
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;
22
18
  protected cleanupJob(job: AbstractJob): Promise<void>;
23
19
  protected getScheduledJobs(): Promise<AbstractJob[]>;
24
20
  protected _scheduleJob(jobInfo: AbstractJob): Promise<void>;
@@ -9,151 +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
+ const parts = cron.split(" ");
46
+ if (parts.length === 5) {
47
+ // Standard cron: minute hour day month day-of-week
48
+ // AWS Scheduler: minute hour day month day-of-week year
49
+ parts.push("*");
50
+ }
51
+ if (parts.length !== 6) {
52
+ throw Errors.SYSTEM_ERROR(`Invalid cron expression: ${cron}`);
53
+ }
54
+ if (parts[4] === "*") {
55
+ parts[4] = "?";
56
+ }
57
+ return parts.join(" ");
51
58
  }
52
59
  async cleanupJob(job) {
53
- if (job.cron) {
54
- //-- do nothing
55
- }
56
- else {
60
+ if (job.at) {
57
61
  await this.cancelJob(job.id);
58
62
  }
59
63
  }
60
64
  async getScheduledJobs() {
61
- const allRules = await this.eventbridge
62
- .listRules({
63
- EventBusName: this.eventBusName,
64
- NamePrefix: this.jobNamespace,
65
- })
66
- .promise();
67
- const allJobs = await Promise.all((allRules.Rules || []).map(async (rule) => {
68
- const ruleName = rule.Name;
69
- const targets = await this.eventbridge
70
- .listTargetsByRule({
71
- EventBusName: this.eventBusName,
72
- Rule: ruleName,
73
- })
74
- .promise();
75
- const lambdaTarget = targets.Targets?.find((t) => t.Id === ruleName);
76
- if (!lambdaTarget || !lambdaTarget.Input) {
77
- return undefined;
78
- }
79
- const jobInfo = JSON.parse(lambdaTarget.Input).requestContext.cronScheduler.data;
80
- return jobInfo;
81
- }));
82
- //-- concat with interval jobs because we don't register them
83
- 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
+ }
84
96
  }
85
97
  async _scheduleJob(jobInfo) {
86
98
  this.logger.debug("Scheduling job: ", jobInfo);
87
99
  if (jobInfo.cron || jobInfo.at) {
88
100
  const jobId = `${this.jobNamespace}${jobInfo.id}`;
89
- let cronExpression;
101
+ let scheduleExpression;
102
+ let flexibleTimeWindow;
90
103
  if (jobInfo.at) {
91
- 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
+ };
92
111
  }
93
112
  else if (jobInfo.cron) {
94
- let cron = jobInfo.cron;
95
- if (cron.endsWith(" *")) {
96
- cron = `${cron.slice(0, -2)} ?`;
97
- }
98
- 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}`);
99
124
  }
100
- this.logger.debug("Cron expression", cronExpression);
101
- assert.ok(cronExpression);
102
- await this.eventbridge
103
- .putRule({
125
+ this.logger.debug("Schedule expression", scheduleExpression);
126
+ const scheduleParams = {
104
127
  Name: jobId,
105
- Description: `${jobInfo.jobName} - ${cronExpression || jobInfo.at}`,
106
- EventBusName: this.eventBusName,
107
- ScheduleExpression: `cron(${cronExpression})`,
108
- State: "ENABLED",
109
- })
110
- .promise();
111
- //-- this rule has only one target, which is the api lambda function, so id of target can be the same as id of rule
112
- await this.eventbridge
113
- .putTargets({
114
- Rule: jobId,
115
- Targets: [
116
- {
117
- Arn: this.apiLambdaFunctionArn,
118
- Id: jobId,
119
- //-- this json will be passed to lambda function as event input
120
- Input: JSON.stringify({
121
- requestContext: {
122
- cronScheduler: {
123
- data: { ...jobInfo, jobId },
124
- },
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 },
125
139
  },
126
- }),
127
- },
128
- ],
129
- })
130
- .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
+ }
131
154
  }
132
155
  else {
133
156
  throw Errors.SYSTEM_ERROR(`Job does not have time config: ${jobInfo.jobName}`);
134
157
  }
135
158
  }
136
159
  async cancelJob(jobId) {
137
- const ruleId = `${this.jobNamespace}${jobId}`;
138
- await this.eventbridge
139
- .removeTargets({
140
- EventBusName: this.eventBusName,
141
- Rule: ruleId,
142
- Ids: [ruleId],
143
- })
144
- .promise();
145
- await this.eventbridge
146
- .deleteRule({
147
- EventBusName: this.eventBusName,
148
- Name: ruleId,
149
- })
150
- .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
+ }
151
173
  }
152
174
  };
153
175
  AwsJobScheduler = __decorate([
154
176
  LogContext(),
155
177
  __metadata("design:paramtypes", [AbstractLogger,
156
178
  AbstractDbAdapter,
157
- AbstractJobRepository, String, String, Object])
179
+ AbstractJobRepository, String, String, String])
158
180
  ], AwsJobScheduler);
159
181
  export { AwsJobScheduler };
@@ -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.2",
3
+ "version": "3.27.1",
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",