Skip to content

Commit 5d71818

Browse files
committed
feat: add cron task management features including enabling/disabling tasks and immediate execution, along with log rotation configuration and UI enhancements for better user interaction
1 parent b458012 commit 5d71818

File tree

7 files changed

+374
-21
lines changed

7 files changed

+374
-21
lines changed

Dockerfile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ ENV ALLOW_RUNTIME_BUILD=true
2222
ENV DO_NOT_TRACK=1
2323
ENV PYTHONWARNINGS=ignore::UserWarning
2424
ENV TZ=Europe/Paris
25+
ENV SESAME_CRON_LOG_ROTATE_MAX_SIZE_BYTES=10485760
26+
ENV SESAME_CRON_LOG_ROTATE_MAX_FILES=5
2527

2628
WORKDIR /data
2729

@@ -41,7 +43,8 @@ RUN apk add --no-cache \
4143
tzdata \
4244
bash \
4345
nano && \
44-
mkdir -p /var/log/supervisor
46+
mkdir -p /var/log/supervisor /data/apps/api/logs/handlers && \
47+
chmod -R 0777 /data/apps/api/logs
4548

4649
RUN ARCH=$(uname -m) && \
4750
if [ "$ARCH" = "x86_64" ]; then \

apps/api/src/_common/functions/handler-logger.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,54 @@ function stripAnsiCodes(str: string): string {
2121
return str.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
2222
}
2323

24+
function rotateFile(logFile: string, maxFiles: number) {
25+
for (let index = maxFiles - 1; index >= 1; index--) {
26+
const source = `${logFile}.${index}`
27+
const destination = `${logFile}.${index + 1}`
28+
29+
if (fs.existsSync(source)) {
30+
fs.renameSync(source, destination)
31+
}
32+
}
33+
34+
fs.renameSync(logFile, `${logFile}.1`)
35+
}
36+
37+
function rotateHandlerLogIfNeeded(logFile: string, maxSizeBytes: number, maxFiles: number) {
38+
if (!fs.existsSync(logFile)) {
39+
return
40+
}
41+
42+
const stats = fs.statSync(logFile)
43+
if (stats.size < maxSizeBytes) {
44+
return
45+
}
46+
47+
try {
48+
const oldestArchive = `${logFile}.${maxFiles}`
49+
if (fs.existsSync(oldestArchive)) {
50+
fs.unlinkSync(oldestArchive)
51+
}
52+
53+
rotateFile(logFile, maxFiles)
54+
} catch (err) {
55+
Logger.error(`Could not rotate handler log file ${logFile}: ${(err as Error).message}`, rotateHandlerLogIfNeeded.name)
56+
}
57+
}
58+
2459
export function createHandlerLogger(config: ConfigService, handler: string) {
2560
const safeHandler = toSafeHandlerName(handler)
2661
const logDir = config.get('cron.logDirectory') || path.join(process.cwd(), 'logs', 'handlers')
2762
const logFile = path.join(logDir, `${safeHandler}.log`)
63+
const logRotateMaxSizeBytes = config.get<number>('cron.logRotateMaxSizeBytes') || 10 * 1024 * 1024
64+
const logRotateMaxFiles = config.get<number>('cron.logRotateMaxFiles') || 5
2865

2966
let stream: fs.WriteStream | null = null
3067
let streamError: Error | null = null
3168

3269
try {
3370
ensureDir(logDir)
71+
rotateHandlerLogIfNeeded(logFile, logRotateMaxSizeBytes, logRotateMaxFiles)
3472
stream = fs.createWriteStream(logFile, { flags: 'a' })
3573

3674
stream.on('error', (err) => {

apps/api/src/config.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,18 @@ export const validationSchema = Joi.object({
158158
.string()
159159
.default(path.join(process.cwd(), 'logs', 'handlers')),
160160

161+
SESAME_CRON_LOG_ROTATE_MAX_SIZE_BYTES: Joi
162+
.number()
163+
.integer()
164+
.min(1)
165+
.default(10 * 1024 * 1024),
166+
167+
SESAME_CRON_LOG_ROTATE_MAX_FILES: Joi
168+
.number()
169+
.integer()
170+
.min(1)
171+
.default(5),
172+
161173
SESAME_IDENTITY_DOUBLON_SEARCH_ATTRIBUTES: Joi
162174
.string()
163175
.default(''),
@@ -214,6 +226,8 @@ export interface ConfigInstance {
214226
cron: {
215227
handlerExpression: string
216228
logDirectory: string
229+
logRotateMaxSizeBytes: number
230+
logRotateMaxFiles: number
217231
}
218232
factorydrive: {
219233
options:
@@ -331,6 +345,8 @@ export default (): ConfigInstance => ({
331345
cron: {
332346
handlerExpression: process.env['SESAME_CRON_HANDLER_EXPRESSION'] || CronExpression.EVERY_HOUR,
333347
logDirectory: process.env['SESAME_CRON_LOG_DIRECTORY'] || path.join(process.cwd(), 'logs', 'handlers'),
348+
logRotateMaxSizeBytes: parseInt(process.env['SESAME_CRON_LOG_ROTATE_MAX_SIZE_BYTES'], 10) || 10 * 1024 * 1024,
349+
logRotateMaxFiles: parseInt(process.env['SESAME_CRON_LOG_ROTATE_MAX_FILES'], 10) || 5,
334350
},
335351
factorydrive: {
336352
options: {

apps/api/src/core/cron/cron-hooks.service.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,34 @@ export class CronHooksService {
9898
this.logger.log('CronHooksService (bootstrap) initialized')
9999
}
100100

101+
public async syncCronJobs(): Promise<void> {
102+
await this.handleCron()
103+
}
104+
105+
public async runTaskNow(name: string): Promise<boolean> {
106+
let currentTask = this.cronTasks.find((task) => task.name === name)
107+
if (!currentTask) {
108+
await this.refreshCronTasksFileCache()
109+
currentTask = this.cronTasks.find((task) => task.name === name)
110+
}
111+
112+
if (!currentTask) {
113+
return false
114+
}
115+
116+
this.logger.warn(`Running cron task manually: ${currentTask.name}`)
117+
// Fire-and-forget: the HTTP caller should get an immediate response.
118+
void this.executeHandlerCommand(currentTask.name, currentTask.handler, currentTask.options)
119+
.then(() => {
120+
this.logger.log(`Manual cron task <${currentTask.name}> finished`)
121+
})
122+
.catch((error) => {
123+
this.logger.error(`Manual cron task <${currentTask.name}> failed`, error?.message || error)
124+
})
125+
126+
return true
127+
}
128+
101129
private async handleCron(): Promise<void> {
102130
this.logger.verbose('Syncing cron tasks jobs from configs/cron/*.yml')
103131

apps/api/src/core/cron/cron.controller.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11

2-
import { Controller, Get, Res, HttpStatus, Query, Param, DefaultValuePipe, ParseIntPipe, NotFoundException } from '@nestjs/common'
2+
import { Body, Controller, DefaultValuePipe, Get, HttpStatus, NotFoundException, Param, ParseIntPipe, Patch, Post, Query, Res } from '@nestjs/common'
33
import { ApiTags } from '@nestjs/swagger'
44
import { CronService } from './cron.service'
55
import { Response } from 'express'
@@ -10,6 +10,12 @@ import { PickProjectionHelper } from '~/_common/helpers/pick-projection.helper'
1010
import { CronDto } from './_dto/cron.dto'
1111
import { PartialProjectionType } from '~/_common/types/partial-projection.type'
1212
import { ApiReadResponseDecorator } from '~/_common/decorators/api-read-response.decorator'
13+
import { IsBoolean } from 'class-validator'
14+
15+
class UpdateCronEnabledBody {
16+
@IsBoolean()
17+
enabled: boolean
18+
}
1319

1420
/**
1521
* Contrôleur Cron - Endpoints pour les tâches planifiées.
@@ -101,4 +107,51 @@ export class CronController {
101107
data,
102108
})
103109
}
110+
111+
@Patch(':name/enabled')
112+
@UseRoles({
113+
resource: '/core/cron',
114+
action: AC_ACTIONS.UPDATE,
115+
possession: AC_DEFAULT_POSSESSION,
116+
})
117+
public async updateEnabled(
118+
@Param('name') name: string,
119+
@Body() body: UpdateCronEnabledBody,
120+
@Res() res: Response,
121+
): Promise<Response> {
122+
const data = await this.cronService.setEnabled(name, body.enabled)
123+
if (!data) {
124+
throw new NotFoundException(`Cron task <${name}> not found`)
125+
}
126+
127+
return res.json({
128+
statusCode: HttpStatus.OK,
129+
data,
130+
})
131+
}
132+
133+
@Post(':name/run-immediately')
134+
@UseRoles({
135+
resource: '/core/cron',
136+
action: AC_ACTIONS.UPDATE,
137+
possession: AC_DEFAULT_POSSESSION,
138+
})
139+
public async runImmediately(
140+
@Param('name') name: string,
141+
@Res() res: Response,
142+
): Promise<Response> {
143+
const launched = await this.cronService.runImmediately(name)
144+
if (!launched) {
145+
throw new NotFoundException(`Cron task <${name}> not found`)
146+
}
147+
148+
return res.json({
149+
statusCode: HttpStatus.OK,
150+
data: {
151+
name,
152+
launched: true,
153+
status: 'in_progress',
154+
},
155+
})
156+
}
104157
}

apps/api/src/core/cron/cron.service.ts

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import { Injectable, Logger } from '@nestjs/common'
33
import { SchedulerRegistry } from '@nestjs/schedule'
44
import { CronHooksService } from './cron-hooks.service'
55
import { pick } from 'radash'
6-
import { CronTaskDTO } from './_dto/config-task.dto'
6+
import { ConfigTaskDTO, CronTaskDTO } from './_dto/config-task.dto'
77
import { CronJob } from 'cron'
88
import path from 'node:path'
9-
import { existsSync, readFileSync, statSync } from 'node:fs'
9+
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync, writeFileSync } from 'node:fs'
1010
import { ConfigService } from '@nestjs/config'
1111
import { toSafeHandlerName } from '~/_common/functions/handler-logger'
12+
import { parse, stringify } from 'yaml'
1213

1314
@Injectable()
1415
export class CronService {
@@ -118,6 +119,29 @@ export class CronService {
118119
}
119120
}
120121

122+
public async setEnabled(name: string, enabled: boolean): Promise<CronTaskDTO & { _job: Partial<CronJob> } | null> {
123+
const updated = this.updateTaskInConfig(name, (task) => {
124+
task.enabled = enabled
125+
})
126+
127+
if (!updated) {
128+
return null
129+
}
130+
131+
await this.cronHooksService.syncCronJobs()
132+
return this.read(name)
133+
}
134+
135+
public async runImmediately(name: string): Promise<boolean> {
136+
const task = await this.read(name)
137+
if (!task) {
138+
return false
139+
}
140+
141+
await this.cronHooksService.runTaskNow(name)
142+
return true
143+
}
144+
121145
public async readLogs(name: string, tail = 500): Promise<{
122146
name: string
123147
exists: boolean
@@ -140,10 +164,46 @@ export class CronService {
140164
}
141165

142166
const stats = statSync(logFile)
143-
const fullContent = readFileSync(logFile, 'utf-8')
144-
const boundedTail = Math.min(Math.max(tail || 200, 1), 2_000)
167+
const boundedTail = Math.max(tail || 200, 1)
145168
const maxLineChars = 4_000
146-
const maxContentChars = 200_000
169+
const maxContentChars = this.configService.get<number>('cron.logRotateMaxSizeBytes') || 10 * 1024 * 1024
170+
const maxReadableBytes = Math.max(maxContentChars, 1)
171+
const chunkSize = 64 * 1024
172+
173+
// Read the file backwards in chunks to avoid loading everything in memory.
174+
const fileDescriptor = openSync(logFile, 'r')
175+
let filePosition = stats.size
176+
let bytesReadTotal = 0
177+
let lineBreakCount = 0
178+
const chunks: Buffer[] = []
179+
180+
try {
181+
while (filePosition > 0 && bytesReadTotal < maxReadableBytes && lineBreakCount <= boundedTail) {
182+
const remainingBudget = maxReadableBytes - bytesReadTotal
183+
const readSize = Math.min(chunkSize, filePosition, remainingBudget)
184+
filePosition -= readSize
185+
186+
const chunkBuffer = Buffer.allocUnsafe(readSize)
187+
const readBytes = readSync(fileDescriptor, chunkBuffer, 0, readSize, filePosition)
188+
if (readBytes <= 0) {
189+
break
190+
}
191+
192+
const chunk = readBytes === readSize ? chunkBuffer : chunkBuffer.subarray(0, readBytes)
193+
chunks.unshift(chunk)
194+
bytesReadTotal += readBytes
195+
196+
for (let index = 0; index < chunk.length; index++) {
197+
if (chunk[index] === 0x0a) {
198+
lineBreakCount++
199+
}
200+
}
201+
}
202+
} finally {
203+
closeSync(fileDescriptor)
204+
}
205+
206+
const fullContent = Buffer.concat(chunks).toString('utf-8')
147207

148208
const tailedLines = fullContent
149209
.split('\n')
@@ -168,4 +228,35 @@ export class CronService {
168228
content,
169229
}
170230
}
231+
232+
private updateTaskInConfig(name: string, updater: (task: CronTaskDTO) => void): boolean {
233+
const configDir = path.join(process.cwd(), 'configs', 'cron')
234+
if (!existsSync(configDir)) {
235+
return false
236+
}
237+
238+
const files = readdirSync(configDir).filter((file) => file.endsWith('.yml') || file.endsWith('.yaml'))
239+
240+
for (const file of files) {
241+
const filePath = path.join(configDir, file)
242+
const raw = readFileSync(filePath, 'utf-8')
243+
const parsed = parse(raw) as ConfigTaskDTO
244+
const tasks = parsed?.tasks
245+
246+
if (!tasks || !Array.isArray(tasks)) {
247+
continue
248+
}
249+
250+
const targetTask = tasks.find((task) => task.name === name)
251+
if (!targetTask) {
252+
continue
253+
}
254+
255+
updater(targetTask)
256+
writeFileSync(filePath, stringify(parsed))
257+
return true
258+
}
259+
260+
return false
261+
}
171262
}

0 commit comments

Comments
 (0)