lakutata 3.0.0 → 3.1.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 +50 -30
- package/README.zh-CN.md +130 -0
- package/dist/cjs/exports/QueueComponent.d.ts +1 -0
- package/dist/cjs/exports/QueueComponent.js +20 -0
- package/dist/esm/exports/QueueComponent.js +1 -0
- package/dist/types/exports/QueueComponent.d.ts +1 -0
- package/doc/en/Application.md +107 -0
- package/doc/en/CLI.md +274 -0
- package/doc/en/Cache.md +379 -0
- package/doc/en/Command.md +110 -0
- package/doc/en/ContainerdIntegration.md +17 -16
- package/doc/en/Controllers.md +256 -0
- package/doc/en/DBus.md +291 -0
- package/doc/en/DTOValidation.md +72 -43
- package/doc/en/Database.md +188 -0
- package/doc/en/DatabaseDrivers.md +287 -0
- package/doc/en/Decorator.md +52 -40
- package/doc/en/DockerIntegration.md +7 -6
- package/doc/en/Entities.md +240 -0
- package/doc/en/Entrypoint.md +216 -0
- package/doc/en/Exceptions.md +174 -0
- package/doc/en/GRPC.md +251 -0
- package/doc/en/GettingStarted.md +165 -0
- package/doc/en/HTTP.md +507 -0
- package/doc/en/HelperFunctions.md +11 -11
- package/doc/en/I18n.md +119 -115
- package/doc/en/Injection.md +144 -0
- package/doc/en/Installers.md +280 -0
- package/doc/en/Logger.md +206 -0
- package/doc/en/MCP.md +293 -0
- package/doc/en/Middlewares.md +213 -0
- package/doc/en/Migrations.md +202 -0
- package/doc/en/MongoEmbedded.md +149 -0
- package/doc/en/Monitor.md +164 -0
- package/doc/en/Mutex.md +27 -25
- package/doc/en/NATS.md +246 -0
- package/doc/en/NativeAddons.md +212 -0
- package/doc/en/Objects.md +143 -0
- package/doc/en/Overview.md +62 -85
- package/doc/en/Packaging.md +193 -0
- package/doc/en/ProjectStructure.md +124 -0
- package/doc/en/Queue.md +269 -0
- package/doc/en/Schedules.md +217 -0
- package/doc/en/Security.md +10 -9
- package/doc/en/ServiceCalls.md +504 -0
- package/doc/en/Time.md +19 -20
- package/doc/en/UpgradingFrom1.md +435 -0
- package/doc/en/UpgradingFrom2.md +315 -0
- package/doc/en/Workspaces.md +189 -0
- package/doc/en/examples/BackgroundJobs.md +677 -0
- package/doc/en/examples/CommandLineTool.md +664 -0
- package/doc/en/examples/DesktopApp.md +809 -0
- package/doc/en/examples/DeviceService.md +607 -0
- package/doc/en/examples/MCPServer.md +653 -0
- package/doc/en/examples/README.md +16 -0
- package/doc/en/examples/RestAPI.md +490 -0
- package/doc/en/examples/ServiceCalls.md +829 -0
- package/doc/en/examples/WebApp.md +568 -0
- package/doc/zh/Application.md +75 -0
- package/doc/zh/CLI.md +250 -0
- package/doc/zh/Cache.md +328 -0
- package/doc/zh/Command.md +78 -0
- package/doc/zh/ContainerdIntegration.md +9 -9
- package/doc/zh/Controllers.md +228 -0
- package/doc/zh/DBus.md +257 -0
- package/doc/zh/DTOValidation.md +49 -19
- package/doc/zh/Database.md +175 -0
- package/doc/zh/DatabaseDrivers.md +261 -0
- package/doc/zh/Decorator.md +25 -18
- package/doc/zh/DockerIntegration.md +6 -6
- package/doc/zh/Entities.md +224 -0
- package/doc/zh/Entrypoint.md +193 -0
- package/doc/zh/Exceptions.md +158 -0
- package/doc/zh/GRPC.md +226 -0
- package/doc/zh/GettingStarted.md +148 -0
- package/doc/zh/HTTP.md +445 -0
- package/doc/zh/HelperFunctions.md +2 -2
- package/doc/zh/I18n.md +26 -26
- package/doc/zh/Injection.md +129 -0
- package/doc/zh/Installers.md +232 -0
- package/doc/zh/Logger.md +188 -0
- package/doc/zh/MCP.md +265 -0
- package/doc/zh/Middlewares.md +198 -0
- package/doc/zh/Migrations.md +178 -0
- package/doc/zh/MongoEmbedded.md +118 -0
- package/doc/zh/Monitor.md +145 -0
- package/doc/zh/Mutex.md +10 -10
- package/doc/zh/NATS.md +207 -0
- package/doc/zh/NativeAddons.md +180 -0
- package/doc/zh/Objects.md +119 -0
- package/doc/zh/Overview.md +46 -71
- package/doc/zh/Packaging.md +154 -0
- package/doc/zh/ProjectStructure.md +102 -0
- package/doc/zh/Queue.md +214 -0
- package/doc/zh/Schedules.md +193 -0
- package/doc/zh/Security.md +6 -6
- package/doc/zh/ServiceCalls.md +459 -0
- package/doc/zh/Time.md +2 -2
- package/doc/zh/UpgradingFrom1.md +347 -0
- package/doc/zh/UpgradingFrom2.md +230 -0
- package/doc/zh/Workspaces.md +150 -0
- package/doc/zh/examples/BackgroundJobs.md +618 -0
- package/doc/zh/examples/CommandLineTool.md +618 -0
- package/doc/zh/examples/DesktopApp.md +748 -0
- package/doc/zh/examples/DeviceService.md +546 -0
- package/doc/zh/examples/MCPServer.md +604 -0
- package/doc/zh/examples/README.md +14 -0
- package/doc/zh/examples/RestAPI.md +458 -0
- package/doc/zh/examples/ServiceCalls.md +765 -0
- package/doc/zh/examples/WebApp.md +520 -0
- package/package.json +46 -17
- package/doc/en/ActionPattern.md +0 -408
- package/doc/en/Core.md +0 -312
- package/doc/en/ORMIntegration.md +0 -341
- package/doc/zh/ActionPattern.md +0 -334
- package/doc/zh/Core.md +0 -256
- package/doc/zh/ORMIntegration.md +0 -319
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
# Background jobs
|
|
2
|
+
|
|
3
|
+
This example builds a service that makes reports in the background: a request queues a report and gets its id at once,
|
|
4
|
+
a queue action builds the report, and the client asks for its state until it is done. A scheduled job deletes the old
|
|
5
|
+
reports, in one instance of the service at a time. It starts from the `service` template and shows the queue component
|
|
6
|
+
and its actions, the retries and the dead letter queue, a schedule, a lock shared by the instances, and the tests. No
|
|
7
|
+
server is needed: the queue is in memory, and the reports and the locks are files.
|
|
8
|
+
|
|
9
|
+
| Action | Name | What it does |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `POST` | `/reports` | Queues the report of a month, `{"month": "2026-09"}`, and answers with its id |
|
|
12
|
+
| `GET` | `/reports/:id` | Gives the state of a report (`queued`, `running`, `done`, `failed`) and its result |
|
|
13
|
+
| Queue | `report.build` | Builds a report: 2 at a time, 3 attempts |
|
|
14
|
+
| Queue | `report.build.dead` | Marks the reports that used up their attempts as failed |
|
|
15
|
+
| Schedule | `*/5 * * * *` | Deletes the reports finished more than a day ago, in one instance |
|
|
16
|
+
|
|
17
|
+
## 1. Create the project
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx @lakutata/cli create --name reports --template service --yes
|
|
21
|
+
cd reports
|
|
22
|
+
rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
|
|
23
|
+
echo 'data/' >> .gitignore
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The project is a workspace whose service is `packages/app` ([ProjectStructure](../ProjectStructure.md)). The queues
|
|
27
|
+
(`lakutata/com/queue`) and the locks (`lakutata/com/mutex`) come with the `lakutata` package, and their memory and file
|
|
28
|
+
drivers need no other package, so nothing is installed. The example controller of the template is removed, and the
|
|
29
|
+
`data` directory, which holds the reports and the locks, is kept out of git.
|
|
30
|
+
|
|
31
|
+
## 2. The input
|
|
32
|
+
|
|
33
|
+
**`packages/app/src/dtos/ReportDTO.ts`**
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import {DTO} from 'lakutata'
|
|
37
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A new report: the sales of a month
|
|
41
|
+
*/
|
|
42
|
+
export class CreateReportDTO extends DTO {
|
|
43
|
+
@Expect(DTO.String().pattern(/^\d{4}-(0[1-9]|1[0-2])$/, 'YYYY-MM').required())
|
|
44
|
+
public month: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A report by its id
|
|
49
|
+
*/
|
|
50
|
+
export class ReportIdDTO extends DTO {
|
|
51
|
+
@Expect(DTO.String().uuid().required())
|
|
52
|
+
public id: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The message of a job: the report to build
|
|
57
|
+
*/
|
|
58
|
+
export class BuildReportDTO extends CreateReportDTO {
|
|
59
|
+
@Expect(DTO.String().uuid().required())
|
|
60
|
+
public id: string
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- `CreateReportDTO` is the body of `POST /reports`. The second argument of `pattern()` names the pattern in the error
|
|
65
|
+
message: `"month" with value "2026-13" fails to match the YYYY-MM pattern`.
|
|
66
|
+
- `BuildReportDTO` is the message that the queue carries: the id of the report and its month. A queue action validates
|
|
67
|
+
its message with its DTO, as any action validates its input ([DTOValidation](../DTOValidation.md)).
|
|
68
|
+
|
|
69
|
+
## 3. The reports
|
|
70
|
+
|
|
71
|
+
**`packages/app/src/components/Reports.ts`**
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
import {randomUUID} from 'node:crypto'
|
|
75
|
+
import {mkdir, readdir, readFile, rename, rm, writeFile} from 'node:fs/promises'
|
|
76
|
+
import path from 'node:path'
|
|
77
|
+
import {Component, DTO} from 'lakutata'
|
|
78
|
+
import {Configurable} from 'lakutata/decorator/di'
|
|
79
|
+
import {type SalesSummary} from './Sales.js'
|
|
80
|
+
import {ReportNotFoundException} from '../exceptions/ReportNotFoundException.js'
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A report, from its request to its result
|
|
84
|
+
*/
|
|
85
|
+
export interface Report {
|
|
86
|
+
id: string
|
|
87
|
+
month: string
|
|
88
|
+
state: 'queued' | 'running' | 'done' | 'failed'
|
|
89
|
+
attempts: number
|
|
90
|
+
createdAt: string
|
|
91
|
+
finishedAt?: string
|
|
92
|
+
result?: SalesSummary
|
|
93
|
+
error?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The reports: a JSON file each, in a directory that the instances of the service on a machine share
|
|
98
|
+
*/
|
|
99
|
+
export class Reports extends Component {
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The directory of the reports
|
|
103
|
+
*/
|
|
104
|
+
@Configurable(DTO.String().required())
|
|
105
|
+
protected readonly directory: string
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* How long a finished report is kept, in milliseconds
|
|
109
|
+
*/
|
|
110
|
+
@Configurable(DTO.Number().positive().required())
|
|
111
|
+
protected readonly keep: number
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Create the directory when it is missing
|
|
115
|
+
*/
|
|
116
|
+
protected async init(): Promise<void> {
|
|
117
|
+
await mkdir(this.directory, {recursive: true})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The file of a report
|
|
122
|
+
* @param id
|
|
123
|
+
*/
|
|
124
|
+
protected file(id: string): string {
|
|
125
|
+
return path.join(this.directory, `${id}.json`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Write a report to a temporary file, then rename the file, so that a reader never gets half a report
|
|
130
|
+
* @param report
|
|
131
|
+
*/
|
|
132
|
+
protected async save(report: Report): Promise<Report> {
|
|
133
|
+
const temporary: string = `${this.file(report.id)}.${process.pid}.tmp`
|
|
134
|
+
await writeFile(temporary, JSON.stringify(report))
|
|
135
|
+
await rename(temporary, this.file(report.id))
|
|
136
|
+
return report
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A new report, queued
|
|
141
|
+
* @param month
|
|
142
|
+
*/
|
|
143
|
+
public async create(month: string): Promise<Report> {
|
|
144
|
+
return await this.save({id: randomUUID(), month: month, state: 'queued', attempts: 0, createdAt: new Date().toISOString()})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A report, or a 404 error
|
|
149
|
+
* @param id
|
|
150
|
+
*/
|
|
151
|
+
public async get(id: string): Promise<Report> {
|
|
152
|
+
try {
|
|
153
|
+
return JSON.parse(await readFile(this.file(id), 'utf8')) as Report
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new ReportNotFoundException(`The report ${id} does not exist`)
|
|
156
|
+
throw error
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Change a report
|
|
162
|
+
* @param id
|
|
163
|
+
* @param changes
|
|
164
|
+
*/
|
|
165
|
+
public async update(id: string, changes: Partial<Report>): Promise<Report> {
|
|
166
|
+
return await this.save({...await this.get(id), ...changes})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Delete the reports that finished longer ago than the time they are kept
|
|
171
|
+
* @param now the time of the cleaning
|
|
172
|
+
* @returns the number of reports deleted
|
|
173
|
+
*/
|
|
174
|
+
public async clean(now: Date = new Date()): Promise<number> {
|
|
175
|
+
let deleted: number = 0
|
|
176
|
+
for (const name of await readdir(this.directory)) {
|
|
177
|
+
if (!name.endsWith('.json')) continue
|
|
178
|
+
const report: Report = await this.get(path.basename(name, '.json'))
|
|
179
|
+
if (report.finishedAt && now.getTime() - Date.parse(report.finishedAt) > this.keep) {
|
|
180
|
+
await rm(this.file(report.id), {force: true})
|
|
181
|
+
deleted++
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return deleted
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**`packages/app/src/exceptions/ReportNotFoundException.ts`**
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
import {Exception} from 'lakutata'
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The report of the request does not exist
|
|
196
|
+
*/
|
|
197
|
+
export class ReportNotFoundException extends Exception {
|
|
198
|
+
public errno: string = 'E_REPORT_NOT_FOUND'
|
|
199
|
+
|
|
200
|
+
public readonly statusCode: number = 404
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
A report is `queued`, then `running`, and ends `done` with its result or `failed` with its error. The `Reports`
|
|
205
|
+
component keeps each report in a JSON file:
|
|
206
|
+
|
|
207
|
+
- A component is created once and shared by the whole service: the HTTP actions, the queue actions and the schedule
|
|
208
|
+
use the same instance ([Objects](../Objects.md)). Its `@Configurable` properties are its options, given in
|
|
209
|
+
`Config.ts`, and its `init()` creates the directory.
|
|
210
|
+
- A report is written to a temporary file, then renamed, so a request never reads half a report.
|
|
211
|
+
- `clean()` deletes the reports that finished more than `keep` ago; it is what the schedule runs. It takes the current
|
|
212
|
+
time as an argument, so that the test can run it as if it were tomorrow.
|
|
213
|
+
- The instances of the service on one machine share the directory. In a service with a database, the reports would be
|
|
214
|
+
a table, behind the same methods.
|
|
215
|
+
|
|
216
|
+
## 4. The sales
|
|
217
|
+
|
|
218
|
+
**`packages/app/src/components/Sales.ts`**
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
import {setTimeout} from 'node:timers/promises'
|
|
222
|
+
import {Component} from 'lakutata'
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The sales of a month
|
|
226
|
+
*/
|
|
227
|
+
export interface SalesSummary {
|
|
228
|
+
orders: number
|
|
229
|
+
revenue: number
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The sales of the shop. Made up in this example; in a real service, queries to its database, the slow part of a report
|
|
234
|
+
*/
|
|
235
|
+
export class Sales extends Component {
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The orders and the revenue of a month
|
|
239
|
+
* @param month 2026-09
|
|
240
|
+
* @param signal aborted when the job is stopped: at its time limit, or when the service stops
|
|
241
|
+
*/
|
|
242
|
+
public async summarize(month: string, signal?: AbortSignal): Promise<SalesSummary> {
|
|
243
|
+
//The slow part: half a second here
|
|
244
|
+
await setTimeout(500, undefined, {signal: signal})
|
|
245
|
+
//Made-up figures, always the same for a given month
|
|
246
|
+
const orders: number = Number(month.replace('-', '')) % 500 + 100
|
|
247
|
+
return {orders: orders, revenue: orders * 37.5}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
A report counts the orders of a month and their revenue. Here the figures are made up, after a wait of half a second
|
|
253
|
+
that stands for the slow queries of a real service. `summarize()` takes the job's signal, so that the wait stops when
|
|
254
|
+
the job is aborted. Because `Sales` is a separate component, the tests can replace its `summarize()` to make a job fail.
|
|
255
|
+
|
|
256
|
+
## 5. The controller
|
|
257
|
+
|
|
258
|
+
**`packages/app/src/controllers/ReportController.ts`**
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
import {Controller, Result, type StatusResult} from 'lakutata/com/entrypoint'
|
|
262
|
+
import {type Logger} from 'lakutata/com/logger'
|
|
263
|
+
import {type Queue, QueueAction, type QueueContext, type QueueDeadLetter} from 'lakutata/com/queue'
|
|
264
|
+
import {GET, POST, Prefix, Schedule} from 'lakutata/decorator/ctrl'
|
|
265
|
+
import {Inject} from 'lakutata/decorator/di'
|
|
266
|
+
import {type Report, type Reports} from '../components/Reports.js'
|
|
267
|
+
import {type Sales, type SalesSummary} from '../components/Sales.js'
|
|
268
|
+
import {BuildReportDTO, CreateReportDTO, ReportIdDTO} from '../dtos/ReportDTO.js'
|
|
269
|
+
|
|
270
|
+
@Prefix({route: '/reports'})
|
|
271
|
+
export class ReportController extends Controller {
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The queues of the service (the component "queue" of its options)
|
|
275
|
+
*/
|
|
276
|
+
@Inject('queue')
|
|
277
|
+
protected readonly queue: Queue
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The reports (the component "reports")
|
|
281
|
+
*/
|
|
282
|
+
@Inject('reports')
|
|
283
|
+
protected readonly reports: Reports
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The sales of the shop (the component "sales")
|
|
287
|
+
*/
|
|
288
|
+
@Inject('sales')
|
|
289
|
+
protected readonly sales: Sales
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The log of the service
|
|
293
|
+
*/
|
|
294
|
+
@Inject('log')
|
|
295
|
+
protected readonly log: Logger
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* POST /reports {"month": "2026-09"}: the report is queued, and its id returned at once
|
|
299
|
+
* @param input
|
|
300
|
+
*/
|
|
301
|
+
@POST('/', CreateReportDTO)
|
|
302
|
+
public async create(input: CreateReportDTO): Promise<StatusResult> {
|
|
303
|
+
const report: Report = await this.reports.create(input.month)
|
|
304
|
+
await this.queue.add('report.build', {id: report.id, month: report.month}, {jobId: report.id})
|
|
305
|
+
return Result.status(202, report, {Location: `/reports/${report.id}`})
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* GET /reports/:id: its state, and its result once done
|
|
310
|
+
* @param input
|
|
311
|
+
*/
|
|
312
|
+
@GET('/:id', ReportIdDTO)
|
|
313
|
+
public async get(input: ReportIdDTO): Promise<Report> {
|
|
314
|
+
return await this.reports.get(input.id)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Builds the reports of the queue report.build: 2 at a time, 3 attempts 1 s then 2 s apart, 1 minute each
|
|
319
|
+
* @param input
|
|
320
|
+
*/
|
|
321
|
+
@QueueAction('report.build', BuildReportDTO, {concurrency: 2, attempts: 3, backoff: {type: 'exponential', delay: '1s'}, timeout: '1m'})
|
|
322
|
+
public async build(input: BuildReportDTO): Promise<SalesSummary> {
|
|
323
|
+
const context: QueueContext = this.context as QueueContext
|
|
324
|
+
await this.reports.update(input.id, {state: 'running', attempts: context.attempt})
|
|
325
|
+
try {
|
|
326
|
+
const result: SalesSummary = await this.sales.summarize(input.month, this.signal)
|
|
327
|
+
await this.reports.update(input.id, {state: 'done', result: result, error: undefined, finishedAt: new Date().toISOString()})
|
|
328
|
+
return result
|
|
329
|
+
} catch (error) {
|
|
330
|
+
//Back in the queue: delivered again after the backoff while attempts remain, then sent to report.build.dead
|
|
331
|
+
await this.reports.update(input.id, {state: 'queued', error: (error as Error).message})
|
|
332
|
+
throw error
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The jobs of report.build that used up their attempts
|
|
338
|
+
* @param input
|
|
339
|
+
*/
|
|
340
|
+
@QueueAction('report.build.dead')
|
|
341
|
+
public async failed(input: QueueDeadLetter): Promise<void> {
|
|
342
|
+
const job: BuildReportDTO = input.input as BuildReportDTO
|
|
343
|
+
await this.reports.update(job.id, {state: 'failed', error: input.error.message, finishedAt: input.failedAt})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Every 5 minutes, in one instance at a time: delete the old reports
|
|
348
|
+
*/
|
|
349
|
+
@Schedule({cron: '*/5 * * * *', exclusive: true})
|
|
350
|
+
public async clean(): Promise<void> {
|
|
351
|
+
const deleted: number = await this.reports.clean()
|
|
352
|
+
this.log.info('%d old reports deleted', deleted)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
One controller holds the actions of three transports: HTTP, the queue and the schedule. Each request, message or run
|
|
358
|
+
gets a new instance of the controller, with the components injected ([Controllers](../Controllers.md)).
|
|
359
|
+
|
|
360
|
+
- `create()` saves the report, adds a message to the queue `report.build` with `queue.add()`, and answers without
|
|
361
|
+
waiting for the build: `Result.status()` sends the status 202 (Accepted), the report, and a `Location` header that
|
|
362
|
+
points to it ([HTTP](../HTTP.md)). The `jobId` of the message is the id of the report.
|
|
363
|
+
- `@QueueAction` makes `build()` the consumer of the queue `report.build` ([Queue](../Queue.md)). It processes 2
|
|
364
|
+
messages at a time (`concurrency`), each for at most 1 minute (`timeout`: after that, `this.signal` is aborted and
|
|
365
|
+
the message is retried). Its context, a `QueueContext`, gives the number of the current `attempt` (1 on the first
|
|
366
|
+
delivery) and the `attempts` allowed.
|
|
367
|
+
- A message is acknowledged when its action returns, and the value the action returns becomes the result of the
|
|
368
|
+
message, which the queue keeps. When the action throws, `build()` puts the report back in the `queued` state with
|
|
369
|
+
the error, and the message is delivered again after the `backoff` (1 s, then 2 s), up to 3 attempts. After the third
|
|
370
|
+
failed attempt, it goes to the dead letter queue `report.build.dead`, a queue like the others: `failed()` consumes
|
|
371
|
+
it and marks the report as failed.
|
|
372
|
+
- A message is delivered at least once: after a crash, a server-backed driver delivers it again, and `build()` builds
|
|
373
|
+
the same report a second time, which does no harm.
|
|
374
|
+
- `@Schedule` runs `clean()` at the times of the cron expression, every 5 minutes ([Schedules](../Schedules.md)). With
|
|
375
|
+
`exclusive: true`, each run first tries to get the lock `schedule:ReportController:clean` from the `mutex` component;
|
|
376
|
+
the instances that do not get it skip the run ([Mutex](../Mutex.md)).
|
|
377
|
+
|
|
378
|
+
## 6. The options of the service
|
|
379
|
+
|
|
380
|
+
**`packages/app/src/config/Config.ts`**
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
import path from 'node:path'
|
|
384
|
+
import type {ApplicationOptions} from 'lakutata'
|
|
385
|
+
import {buildEntrypoints, scheduleEntrypoint} from 'lakutata/com/entrypoint'
|
|
386
|
+
import {Mutex} from 'lakutata/com/mutex'
|
|
387
|
+
import {buildQueueOptions, queueEntrypoint} from 'lakutata/com/queue'
|
|
388
|
+
import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
|
|
389
|
+
import compression from 'compression'
|
|
390
|
+
import {authenticate} from '../middlewares/Authenticate.js'
|
|
391
|
+
import {HealthController} from '../controllers/HealthController.js'
|
|
392
|
+
import {ReportController} from '../controllers/ReportController.js'
|
|
393
|
+
import {Reports} from '../components/Reports.js'
|
|
394
|
+
import {Sales} from '../components/Sales.js'
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The options of the service
|
|
398
|
+
* @param port the port of the HTTP server, the PORT environment variable or 8080 by default
|
|
399
|
+
* @param data the directory of the reports and of the locks, the DATA environment variable or data by default
|
|
400
|
+
*/
|
|
401
|
+
export function config(port: number = Number(process.env.PORT ?? 8080), data: string = process.env.DATA ?? 'data'): ApplicationOptions {
|
|
402
|
+
return {
|
|
403
|
+
id: 'reports',
|
|
404
|
+
name: 'reports',
|
|
405
|
+
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
|
|
406
|
+
components: {
|
|
407
|
+
//The messages are kept in the memory of the process, and their results for an hour
|
|
408
|
+
queue: buildQueueOptions({results: {keep: '1h'}}),
|
|
409
|
+
//The locks: files that the instances of the service on a machine share
|
|
410
|
+
mutex: {class: Mutex, driver: {type: 'file', directory: path.join(data, 'locks')}},
|
|
411
|
+
//The finished reports are kept for a day
|
|
412
|
+
reports: {class: Reports, directory: path.join(data, 'reports'), keep: 24 * 60 * 60 * 1000},
|
|
413
|
+
sales: {class: Sales},
|
|
414
|
+
entrypoint: buildEntrypoints({
|
|
415
|
+
controllers: [HealthController, ReportController],
|
|
416
|
+
http: expressHTTPEntrypoint({
|
|
417
|
+
port: port,
|
|
418
|
+
//The results and the errors are {errno, errMsg, data}; the errors keep their status (400, 404…)
|
|
419
|
+
respond: jsonEnvelope(),
|
|
420
|
+
body: {limit: '100mb'},
|
|
421
|
+
server: {keepAliveTimeout: 60000, headersTimeout: 65000},
|
|
422
|
+
middlewares: [authenticate],
|
|
423
|
+
before: (app): void => void app.use(compression())
|
|
424
|
+
}),
|
|
425
|
+
//Delivers the messages of the queues to the @QueueAction actions
|
|
426
|
+
queue: queueEntrypoint('queue'),
|
|
427
|
+
//Runs the @Schedule actions
|
|
428
|
+
schedule: scheduleEntrypoint()
|
|
429
|
+
})
|
|
430
|
+
},
|
|
431
|
+
bootstrap: ['entrypoint']
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
This is the `Config.ts` of the template, with:
|
|
437
|
+
|
|
438
|
+
- the `queue` component, made by `buildQueueOptions()`: without a `driver`, the messages are kept in the memory of the
|
|
439
|
+
process. The queue keeps the results of the messages for an hour (a day by default): the test reads one of them,
|
|
440
|
+
while the service itself reads the reports from their files;
|
|
441
|
+
- the `mutex` component, with the `file` driver: a lock is a file in `data/locks`, shared by the processes on the
|
|
442
|
+
machine that use this directory;
|
|
443
|
+
- the `reports` and `sales` components of the example, with their options;
|
|
444
|
+
- the `ReportController` instead of the example controller;
|
|
445
|
+
- two more entrypoint adapters: `queueEntrypoint('queue')` delivers the messages of the `queue` component to the
|
|
446
|
+
`@QueueAction` actions, and `scheduleEntrypoint()` runs the `@Schedule` actions ([Entrypoint](../Entrypoint.md)).
|
|
447
|
+
Without its adapter, a decorator has no effect;
|
|
448
|
+
- `jsonEnvelope()` without `errorStatus`, so that the errors keep their status (400, 404), as REST clients expect.
|
|
449
|
+
|
|
450
|
+
`bootstrap: ['entrypoint']` creates the entrypoint at launch, and with it the queue: the actions start consuming once
|
|
451
|
+
the service has launched. The template's middleware `Authenticate.ts` stays: it is a middleware of the HTTP adapter, so
|
|
452
|
+
the queue messages and the scheduled runs do not go through it.
|
|
453
|
+
|
|
454
|
+
## 7. Run it
|
|
455
|
+
|
|
456
|
+
Run two instances of the service on this machine; they share the `data` directory:
|
|
457
|
+
|
|
458
|
+
```bash
|
|
459
|
+
npm run dev
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
In a second terminal, once the first instance has started:
|
|
463
|
+
|
|
464
|
+
```bash
|
|
465
|
+
PORT=8081 npm start
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
In a third terminal, queue a report on the first instance and read it on the second one:
|
|
469
|
+
|
|
470
|
+
```bash
|
|
471
|
+
curl -i -X POST localhost:8080/reports -H 'Content-Type: application/json' -d '{"month": "2026-09"}'
|
|
472
|
+
# HTTP/1.1 202 Accepted
|
|
473
|
+
# Location: /reports/eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9
|
|
474
|
+
# {"errno":null,"errMsg":null,"data":{"id":"eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9","month":"2026-09","state":"queued","attempts":0,"createdAt":"2026-10-17T15:00:46.797Z"}}
|
|
475
|
+
|
|
476
|
+
curl localhost:8081/reports/eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9
|
|
477
|
+
# {"errno":null,"errMsg":null,"data":{"id":"eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9","month":"2026-09","state":"running","attempts":1,"createdAt":"2026-10-17T15:00:46.797Z"}}
|
|
478
|
+
|
|
479
|
+
curl localhost:8081/reports/eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9
|
|
480
|
+
# {"errno":null,"errMsg":null,"data":{"id":"eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9","month":"2026-09","state":"done","attempts":1,"createdAt":"2026-10-17T15:00:46.797Z","result":{"orders":209,"revenue":7837.5},"finishedAt":"2026-10-17T15:00:47.303Z"}}
|
|
481
|
+
|
|
482
|
+
curl -i -X POST localhost:8080/reports -H 'Content-Type: application/json' -d '{"month": "2026-13"}'
|
|
483
|
+
# HTTP/1.1 400 Bad Request
|
|
484
|
+
# {"errno":"E_INVALID_VALUE","errMsg":"\"month\" with value \"2026-13\" fails to match the YYYY-MM pattern","data":null}
|
|
485
|
+
|
|
486
|
+
curl -i localhost:8081/reports/5b0f8a52-2f4e-4a3c-9a55-0b1c3c4d5e6f
|
|
487
|
+
# HTTP/1.1 404 Not Found
|
|
488
|
+
# {"errno":"E_REPORT_NOT_FOUND","errMsg":"The report 5b0f8a52-2f4e-4a3c-9a55-0b1c3c4d5e6f does not exist","data":null}
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
The first instance, which received the request, builds the report in half a second; the second one reads its file.
|
|
492
|
+
|
|
493
|
+
Every 5 minutes, at :00, :05, :10…, one of the two instances deletes the old reports. This time it was the first one,
|
|
494
|
+
which wrote in its terminal:
|
|
495
|
+
|
|
496
|
+
```
|
|
497
|
+
app │ [23:05:00.009] INFO (reports/97338): 0 old reports deleted
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
The second instance did not get the lock and skipped the run, without writing anything to its log. No report was
|
|
501
|
+
deleted: the reports are kept for a day.
|
|
502
|
+
|
|
503
|
+
## 8. Test it
|
|
504
|
+
|
|
505
|
+
The test of the template is replaced by a test of the reports. It starts the service with its reports and its locks in
|
|
506
|
+
a temporary directory:
|
|
507
|
+
|
|
508
|
+
**`packages/app/src/tests/App.spec.ts`**
|
|
509
|
+
|
|
510
|
+
```typescript
|
|
511
|
+
import {after, before, describe, it, type TestContext} from 'node:test'
|
|
512
|
+
import assert from 'node:assert'
|
|
513
|
+
import {mkdtemp, rm} from 'node:fs/promises'
|
|
514
|
+
import os from 'node:os'
|
|
515
|
+
import path from 'node:path'
|
|
516
|
+
import {setTimeout} from 'node:timers/promises'
|
|
517
|
+
import {Application} from 'lakutata'
|
|
518
|
+
import {type Queue} from 'lakutata/com/queue'
|
|
519
|
+
import {config} from '../config/Config.js'
|
|
520
|
+
import {type Report, type Reports} from '../components/Reports.js'
|
|
521
|
+
import {type Sales} from '../components/Sales.js'
|
|
522
|
+
|
|
523
|
+
const PORT: number = Number(process.env.TEST_PORT ?? 18087)
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* A request to the service: its status and its {errno, errMsg, data}
|
|
527
|
+
* @param method
|
|
528
|
+
* @param route
|
|
529
|
+
* @param body
|
|
530
|
+
*/
|
|
531
|
+
async function request(method: string, route: string, body?: unknown): Promise<{ status: number, errno: string | null, data: any }> {
|
|
532
|
+
const response: Response = await fetch(`http://127.0.0.1:${PORT}${route}`, {
|
|
533
|
+
method: method,
|
|
534
|
+
headers: {'Authorization': 'Bearer test-token', 'Content-Type': 'application/json'},
|
|
535
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
536
|
+
})
|
|
537
|
+
const envelope = await response.json() as { errno: string | null, data: any }
|
|
538
|
+
return {status: response.status, errno: envelope.errno, data: envelope.data}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Read a report until its job has ended (done or failed)
|
|
543
|
+
* @param id
|
|
544
|
+
*/
|
|
545
|
+
async function finished(id: string): Promise<Report> {
|
|
546
|
+
for (; ;) {
|
|
547
|
+
const report: Report = (await request('GET', `/reports/${id}`)).data
|
|
548
|
+
if (report.state === 'done' || report.state === 'failed') return report
|
|
549
|
+
await setTimeout(100)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
describe('reports', (): void => {
|
|
554
|
+
|
|
555
|
+
let app: Application
|
|
556
|
+
let data: string
|
|
557
|
+
|
|
558
|
+
before(async (): Promise<void> => {
|
|
559
|
+
process.env.API_TOKEN = 'test-token'
|
|
560
|
+
//The reports and the locks in a temporary directory
|
|
561
|
+
data = await mkdtemp(path.join(os.tmpdir(), 'reports-'))
|
|
562
|
+
app = await new Promise<Application>((resolve: (app: Application) => void): void => {
|
|
563
|
+
Application.run(config(PORT, data)).onLaunched((launched: Application): void => resolve(launched))
|
|
564
|
+
})
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
after(async (): Promise<void> => {
|
|
568
|
+
await app?.dispose()
|
|
569
|
+
await rm(data, {recursive: true, force: true})
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
it('builds a report in the background', async (): Promise<void> => {
|
|
573
|
+
const created = await request('POST', '/reports', {month: '2026-09'})
|
|
574
|
+
assert.strictEqual(created.status, 202)
|
|
575
|
+
assert.strictEqual(created.data.state, 'queued')
|
|
576
|
+
//The result of the job, kept by the queue
|
|
577
|
+
const queue: Queue = await app.getObject<Queue>('queue')
|
|
578
|
+
assert.deepStrictEqual(await queue.result('report.build', created.data.id), {orders: 209, revenue: 7837.5})
|
|
579
|
+
const report = await request('GET', `/reports/${created.data.id}`)
|
|
580
|
+
assert.strictEqual(report.data.state, 'done')
|
|
581
|
+
assert.deepStrictEqual(report.data.result, {orders: 209, revenue: 7837.5})
|
|
582
|
+
assert.strictEqual(report.data.attempts, 1)
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
it('retries a job that failed', async (t: TestContext): Promise<void> => {
|
|
586
|
+
const sales: Sales = await app.getObject<Sales>('sales')
|
|
587
|
+
//The first call fails, the next ones run the real code
|
|
588
|
+
const summarize = t.mock.method(sales, 'summarize')
|
|
589
|
+
summarize.mock.mockImplementationOnce(async (): Promise<never> => {
|
|
590
|
+
throw new Error('The database did not answer')
|
|
591
|
+
})
|
|
592
|
+
const created = await request('POST', '/reports', {month: '2026-08'})
|
|
593
|
+
const report: Report = await finished(created.data.id)
|
|
594
|
+
assert.strictEqual(report.state, 'done')
|
|
595
|
+
assert.strictEqual(report.attempts, 2)
|
|
596
|
+
assert.strictEqual(summarize.mock.callCount(), 2)
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
it('fails a job after its attempts', async (t: TestContext): Promise<void> => {
|
|
600
|
+
const sales: Sales = await app.getObject<Sales>('sales')
|
|
601
|
+
t.mock.method(sales, 'summarize', async (): Promise<never> => {
|
|
602
|
+
throw new Error('The database did not answer')
|
|
603
|
+
})
|
|
604
|
+
const created = await request('POST', '/reports', {month: '2026-07'})
|
|
605
|
+
const report: Report = await finished(created.data.id)
|
|
606
|
+
assert.strictEqual(report.state, 'failed')
|
|
607
|
+
assert.strictEqual(report.attempts, 3)
|
|
608
|
+
assert.strictEqual(report.error, 'The database did not answer')
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
it('refuses an invalid month and an unknown report', async (): Promise<void> => {
|
|
612
|
+
const invalid = await request('POST', '/reports', {month: '2026-13'})
|
|
613
|
+
assert.strictEqual(invalid.status, 400)
|
|
614
|
+
assert.strictEqual(invalid.errno, 'E_INVALID_VALUE')
|
|
615
|
+
const unknown = await request('GET', '/reports/5b0f8a52-2f4e-4a3c-9a55-0b1c3c4d5e6f')
|
|
616
|
+
assert.strictEqual(unknown.status, 404)
|
|
617
|
+
assert.strictEqual(unknown.errno, 'E_REPORT_NOT_FOUND')
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
it('deletes the reports finished for more than a day', async (): Promise<void> => {
|
|
621
|
+
//What the schedule runs, called directly: now, then as if it were tomorrow
|
|
622
|
+
const reports: Reports = await app.getObject<Reports>('reports')
|
|
623
|
+
assert.strictEqual(await reports.clean(new Date()), 0)
|
|
624
|
+
assert.strictEqual(await reports.clean(new Date(Date.now() + 25 * 60 * 60 * 1000)), 3)
|
|
625
|
+
})
|
|
626
|
+
})
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
- The first test reads the result of the job with `queue.result()`, which waits until the message has been processed,
|
|
630
|
+
then reads the report.
|
|
631
|
+
- To make a job fail, the tests replace the `summarize()` method of the `sales` component with `t.mock.method()` from
|
|
632
|
+
`node:test`: for one call, to see the job succeed on its second attempt, then for every call, to see it fail after
|
|
633
|
+
its 3 attempts, which takes 3 seconds (the backoff). The component is shared, so the action calls the replaced
|
|
634
|
+
method, which is restored at the end of the test.
|
|
635
|
+
- The last test calls `clean()` directly, as if it were tomorrow, rather than waiting for the cron expression.
|
|
636
|
+
|
|
637
|
+
```bash
|
|
638
|
+
npm test
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
```
|
|
642
|
+
app │ ✔ builds a report in the background
|
|
643
|
+
app │ ✔ retries a job that failed
|
|
644
|
+
app │ ✔ fails a job after its attempts
|
|
645
|
+
app │ ✔ refuses an invalid month and an unknown report
|
|
646
|
+
app │ ✔ deletes the reports finished for more than a day
|
|
647
|
+
app │ ℹ pass 5
|
|
648
|
+
✔ test ran in app
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
The service logs each failed attempt, with its error: `The queue action ReportController:build failed on the message
|
|
652
|
+
<id> of report.build (attempt 1 of 3)`. `queueEntrypoint('queue', {onError})` replaces this log.
|
|
653
|
+
|
|
654
|
+
## 9. With several machines
|
|
655
|
+
|
|
656
|
+
The example runs its instances on one machine. On several machines, each part needs a driver that the machines share,
|
|
657
|
+
and the code of the actions does not change:
|
|
658
|
+
|
|
659
|
+
- The queue: the memory driver keeps the messages in one process. An instance builds only the reports it was asked
|
|
660
|
+
for, and the messages still waiting when it stops are lost: their reports stay `queued`, and the schedule never
|
|
661
|
+
deletes them. With Redis (`driver: {type: 'redis', url: process.env.REDIS_URL}`, and
|
|
662
|
+
`npm install bullmq ioredis -w app`), the messages survive restarts, any instance can process them, and the results
|
|
663
|
+
are shared ([Queue](../Queue.md)).
|
|
664
|
+
- The locks: the file driver shares them only between the processes of one machine. Redis
|
|
665
|
+
(`driver: {type: 'redis', url}`, and `npm install redis -w app`) or PostgreSQL share them between machines
|
|
666
|
+
([Mutex](../Mutex.md)).
|
|
667
|
+
- The reports: a database table ([A REST API with a database](RestAPI.md)), or a volume that every instance mounts as
|
|
668
|
+
`DATA`.
|
|
669
|
+
|
|
670
|
+
`npm start` runs the built service, and `npm run docker:build` makes its image (the template's `Dockerfile`).
|
|
671
|
+
|
|
672
|
+
<!--
|
|
673
|
+
check:
|
|
674
|
+
create: reports service
|
|
675
|
+
remove: packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
|
|
676
|
+
run: npm test
|
|
677
|
+
-->
|