Skip to content

Commit e89dc46

Browse files
committed
feat: add one time task scheduler
1 parent 7e3765e commit e89dc46

File tree

5 files changed

+202
-1
lines changed

5 files changed

+202
-1
lines changed

backend/src/cronjobs/ghostmode-reminder.cron.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { OfflineryNotification } from "@/types/notification-message.types";
1818
import { EDateMode } from "@/types/user.types";
1919
import { MailerService } from "@nestjs-modules/mailer";
2020
import { Injectable, Logger } from "@nestjs/common";
21+
import { Cron, CronExpression } from "@nestjs/schedule";
2122
import { InjectRepository } from "@nestjs/typeorm";
2223
import { differenceInHours } from "date-fns";
2324
import { I18nService } from "nestjs-i18n";
@@ -37,7 +38,7 @@ export class GhostModeReminderCronJob extends BaseCronJob {
3738
super(ECronJobType.GHOST_MODE_REMINDER, mailService, i18n);
3839
}
3940

40-
//TODO @Cron(CronExpression.EVERY_DAY_AT_NOON)
41+
@Cron(CronExpression.EVERY_DAY_AT_NOON)
4142
async checkGhostModeUsers(): Promise<void> {
4243
this.logger.debug(`Starting checkGhostModeUsers cron job..`);
4344
const usersToNotify = await this.findOfflineUsers();
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { UserModule } from "@/entities/user/user.module";
2+
import { forwardRef, Module } from "@nestjs/common";
3+
import { TaskService } from "./task.service";
4+
5+
@Module({
6+
imports: [forwardRef(() => UserModule)],
7+
controllers: [],
8+
providers: [TaskService],
9+
exports: [TaskService],
10+
})
11+
export class TaskModule {}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Injectable, Logger } from "@nestjs/common";
2+
import { SchedulerRegistry } from "@nestjs/schedule";
3+
4+
@Injectable()
5+
export class TaskService {
6+
private readonly logger = new Logger(TaskService.name);
7+
8+
constructor(private schedulerRegistry: SchedulerRegistry) {}
9+
10+
public async createOneTimeTask(
11+
taskId: string,
12+
task: () => Promise<void>,
13+
runInMs: number,
14+
) {
15+
if (runInMs <= 0) {
16+
throw new Error(
17+
`Invalid value for runInMs, must be greater than 0: ${runInMs}`,
18+
);
19+
}
20+
const timeout = setTimeout(async () => {
21+
try {
22+
await task();
23+
} catch (error) {
24+
this.logger.error(
25+
`Could not execute one time task with id ${taskId} because of: ${error?.message ?? JSON.stringify(error)}`,
26+
);
27+
}
28+
}, runInMs);
29+
this.schedulerRegistry.addTimeout(taskId, timeout);
30+
}
31+
32+
/** @dev Abort one time task.
33+
* @returns boolean: true = task aborted, false = error or no task with taskId found. */
34+
public async abortOneTimeTask(taskId: string): Promise<boolean> {
35+
try {
36+
this.schedulerRegistry.deleteTimeout(taskId);
37+
this.logger.debug(`Aborting task with id ${taskId}`);
38+
return true;
39+
} catch (error) {
40+
// Handle case where timeout doesn't exist
41+
this.logger.error(`No task found to cancel with taskId ${taskId}`);
42+
return false;
43+
}
44+
}
45+
}

backend/test/_src/modules/integration-test.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { UserRepository } from "@/entities/user/user.repository";
1919
import { ClusteringModule } from "@/transient-services/clustering/clustering.module";
2020
import { MatchingModule } from "@/transient-services/matching/matching.module";
2121
import { NotificationModule } from "@/transient-services/notification/notification.module";
22+
import { TaskModule } from "@/transient-services/task/task.module";
2223
import { TYPED_ENV } from "@/utils/env.utils";
2324
import { MailerModule } from "@nestjs-modules/mailer";
2425
import { HandlebarsAdapter } from "@nestjs-modules/mailer/dist/adapters/handlebars.adapter";
@@ -79,6 +80,7 @@ export const getIntegrationTestModule = async (): Promise<TestModuleSetup> => {
7980
MockAuthModule,
8081
UserReportModule,
8182
MapModule,
83+
TaskModule,
8284
forwardRef(() => AppStatsModule),
8385
forwardRef(() => NotificationModule),
8486
MockMatchingModule,
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { TaskService } from "@/transient-services/task/task.service";
2+
import { TestingModule } from "@nestjs/testing";
3+
import { DataSource } from "typeorm";
4+
import { getIntegrationTestModule } from "../../_src/modules/integration-test.module";
5+
import { clearDatabase } from "../../_src/utils/utils";
6+
7+
describe("Task Service Integration Tests ", () => {
8+
let taskService: TaskService;
9+
let testingModule: TestingModule;
10+
let testingDataSource: DataSource;
11+
12+
beforeAll(async () => {
13+
const { module, dataSource } = await getIntegrationTestModule();
14+
testingModule = module;
15+
testingDataSource = dataSource;
16+
17+
taskService = module.get(TaskService);
18+
});
19+
20+
afterAll(async () => {
21+
await testingModule.close();
22+
});
23+
24+
beforeEach(async () => {
25+
await clearDatabase(testingDataSource);
26+
});
27+
28+
describe("createOneTimeTask", () => {
29+
it("should create and execute a one-time task successfully", async () => {
30+
// Arrange
31+
const taskId = "test-task-1";
32+
let taskExecuted = false;
33+
const task = async () => {
34+
taskExecuted = true;
35+
};
36+
const runInMs = 100;
37+
38+
// Act
39+
await taskService.createOneTimeTask(taskId, task, runInMs);
40+
41+
// Assert
42+
expect(taskExecuted).toBeFalsy(); // Task shouldn't execute immediately
43+
44+
// Wait for task execution
45+
await new Promise((resolve) => setTimeout(resolve, runInMs + 50));
46+
expect(taskExecuted).toBeTruthy();
47+
48+
taskExecuted = false; // reset
49+
await new Promise((resolve) => setTimeout(resolve, runInMs + 101));
50+
expect(taskExecuted).toBeFalsy(); // should not have been executed again
51+
});
52+
53+
it("should throw error when runInMs is less than or equal to 0", async () => {
54+
// Arrange
55+
const taskId = "test-task-2";
56+
const task = async () => {};
57+
const runInMs = 0;
58+
59+
// Act & Assert
60+
await expect(
61+
taskService.createOneTimeTask(taskId, task, runInMs),
62+
).rejects.toThrow(
63+
"Invalid value for runInMs, must be greater than 0: 0",
64+
);
65+
});
66+
67+
it("should handle task execution errors gracefully", async () => {
68+
// Arrange
69+
const taskId = "test-task-3";
70+
const task = async () => {
71+
throw new Error("Task execution failed");
72+
};
73+
const runInMs = 100;
74+
75+
// Act
76+
await taskService.createOneTimeTask(taskId, task, runInMs);
77+
78+
// Assert - should not throw error
79+
await new Promise((resolve) => setTimeout(resolve, runInMs + 50));
80+
});
81+
});
82+
83+
describe("abortOneTimeTask", () => {
84+
it("should successfully abort a scheduled task", async () => {
85+
// Arrange
86+
const taskId = "test-task-4";
87+
let taskExecuted = false;
88+
const task = async () => {
89+
taskExecuted = true;
90+
};
91+
const runInMs = 200;
92+
93+
// Act
94+
await taskService.createOneTimeTask(taskId, task, runInMs);
95+
96+
// Wait a bit but not enough for task execution
97+
await new Promise((resolve) => setTimeout(resolve, 50));
98+
99+
const abortResult = await taskService.abortOneTimeTask(taskId);
100+
101+
// Wait to ensure task doesn't execute
102+
await new Promise((resolve) => setTimeout(resolve, runInMs + 50));
103+
104+
// Assert
105+
expect(abortResult).toBeTruthy();
106+
expect(taskExecuted).toBeFalsy();
107+
});
108+
109+
it("should return false when trying to abort non-existent task", async () => {
110+
// Arrange
111+
const nonExistentTaskId = "non-existent-task";
112+
113+
// Act
114+
const result =
115+
await taskService.abortOneTimeTask(nonExistentTaskId);
116+
117+
// Assert
118+
expect(result).toBeFalsy();
119+
});
120+
121+
it("should allow creating new task after aborting previous one with same id", async () => {
122+
// Arrange
123+
const taskId = "test-task-5";
124+
let taskExecutionCount = 0;
125+
const task = async () => {
126+
taskExecutionCount++;
127+
};
128+
const runInMs = 200;
129+
130+
// Act
131+
await taskService.createOneTimeTask(taskId, task, runInMs);
132+
await taskService.abortOneTimeTask(taskId);
133+
await taskService.createOneTimeTask(taskId, task, 100);
134+
135+
// Wait for second task execution
136+
await new Promise((resolve) => setTimeout(resolve, 150));
137+
138+
// Assert
139+
expect(taskExecutionCount).toBe(1);
140+
});
141+
});
142+
});

0 commit comments

Comments
 (0)