Hết Part 2, video đã nằm an toàn trên đĩa máy chủ và có thể phát lại được — nhưng vẫn nguyên định dạng gốc. Người dùng tải lên một file quay bằng điện thoại 4K nặng 1,8 GB, và bất kỳ ai muốn xem cũng phải tải về đúng 1,8 GB đó.
Part này làm phần việc nặng nhất của cả hệ thống: chuyển mã ra nhiều độ phân giải. Và ngay khi chạm vào nó, Node lộ ra giới hạn cốt lõi của mình — một luồng. Toàn bộ phần còn lại của bài là các cách khác nhau để vượt qua giới hạn đó, mỗi cách hợp với một loại công việc.
child_process và đọc được tiến độ thật của nó.
Một hồ worker_threads làm việc nặng CPU thuần trong Node. Nhiều instance ứng dụng chạy song
song sau nginx. Một hàng đợi có thử lại và có nơi chứa job hỏng. Và tiến độ chuyển mã chạy về trình
duyệt theo thời gian thực, đúng tab của đúng người dùng, kể cả khi worker và WebSocket nằm ở hai tiến
trình khác nhau.
1. Vì sao không thể chuyển mã ngay trong handler
Cách viết thẳng thắn nhất là gọi ffmpeg ngay sau khi nhận xong file. Nó sai theo hai tầng, và tầng thứ hai mới là tầng chết người.
Tầng thứ nhất: chuyển mã một video 10 phút mất vài phút. Giữ một kết nối HTTP mở suốt vài phút thì gần như
chắc chắn có ai đó ở giữa cắt nó — nginx với proxy_read_timeout, hoặc chính trình duyệt, hoặc
mạng di động của người dùng. Công việc vẫn chạy tiếp trong khi người dùng đã thấy lỗi.
Tầng thứ hai: nếu bạn dùng một thư viện chuyển mã viết bằng JavaScript, hoặc thậm chí chỉ ngồi tính hash của file trong handler, thì toàn bộ ứng dụng đứng hình. Không phải chậm — đứng. Event loop của Node là một luồng; trong lúc nó chạy vòng lặp tính toán của bạn thì không request nào khác được phục vụ, không callback nào chạy, health check cũng không trả lời nổi.
@Get('block')
block(): string {
const end = Date.now() + 5000;
while (Date.now() < end) {
// Nam giay tinh toan thuan tuy — dai dien cho bat ky viec nang CPU nao.
}
return 'xong';
}
@Get('ping')
ping(): string {
return 'pong';
}
Gọi /block ở một tab, rồi gọi /ping ở tab khác. /ping — một hàm trả
về hằng số — sẽ chờ đủ năm giây. Đó là toàn bộ vấn đề, gói trong mười dòng.
2. child_process: gọi ffmpeg cho đúng
ffmpeg là một chương trình viết bằng C, chạy như một tiến trình riêng của hệ điều hành. Nó có bộ nhớ riêng, có luồng riêng, và quan trọng nhất: nó bận bao nhiêu cũng không ảnh hưởng gì tới event loop của Node. Node chỉ khởi động nó rồi ngồi nghe.
2.1. spawn chứ không phải exec
Bốn hàm trong node:child_process hay bị dùng lẫn lộn, và chỉ một cái đúng cho việc này.
exec chay qua shell, gom TOAN BO output vao bo nho roi moi tra ve
-> ffmpeg in hang nghin dong tien do => phinh bo nho
-> chuoi lenh di qua shell => lo hong chen lenh
execFile khong qua shell (tot hon), nhung van gom het output
fork tao mot tien trinh NODE moi, co san kenh IPC
-> dung cho worker Node, khong dung cho ffmpeg
spawn tra ve STREAM stdout/stderr, khong gom vao bo nho
-> dung cho tien trinh chay lau va noi nhieu. Chinh la ffmpeg.
exec với chuỗi ghép là lỗ hổng chèn lệnhexec(`ffmpeg -i ${path} out.mp4`) đưa cả chuỗi cho shell diễn giải. Người dùng đặt tên file
là a.mp4; rm -rf /var/media thì shell hiểu đó là hai lệnh và chạy cả hai. spawn nhận đối số dưới dạng mảng, và không có shell nào ở giữa. Mỗi phần
tử tới thẳng ffmpeg như một đối số nguyên vẹn, dấu chấm phẩy trong tên file chỉ là một ký tự trong tên
file. Đây là lý do đủ để chọn spawn ngay cả khi bỏ qua mọi lý do về bộ nhớ.
2.2. Đọc tiến độ thật từ ffmpeg
ffmpeg không có API báo tiến độ. Nó in trạng thái ra stderr — vâng, stderr, dù
đó không phải lỗi — theo từng dòng dạng khoá bằng giá trị nếu bạn thêm cờ -progress pipe:2.
Việc của ta là đọc dòng đó và chia cho tổng thời lượng.
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
export interface TranscodeOptions {
readonly input: string;
readonly output: string;
readonly height: 480 | 720 | 1080;
readonly durationSeconds: number;
readonly onProgress: (percent: number) => void;
readonly signal: AbortSignal;
}
export async function transcode(options: TranscodeOptions): Promise<void> {
const args = [
'-hide_banner',
'-nostdin', // KHONG doi input tu ban phim. Thieu co nay ffmpeg co the treo im lang.
'-i',
options.input,
'-vf',
`scale=-2:${options.height}`, // -2 = tu tinh chieu rong sao cho chia het cho 2
'-c:v',
'libx264',
'-preset',
'veryfast',
'-crf',
'23',
'-c:a',
'aac',
'-movflags',
'+faststart', // day metadata len dau file => trinh duyet phat duoc ngay
'-progress',
'pipe:2',
'-y',
options.output,
];
const child = spawn('ffmpeg', args, { signal: options.signal });
// Doc theo DONG. Doc theo chunk thi mot dong co the bi cat lam doi.
const lines = createInterface({ input: child.stderr });
for await (const line of lines) {
// ffmpeg in "out_time_ms=12345678" — don vi thuc te la MICRO giay,
// bat ke ten bien noi gi.
const match = /^out_time_ms=(\d+)$/.exec(line);
if (match?.[1] !== undefined) {
const seconds = Number(match[1]) / 1_000_000;
const percent = Math.min(99, Math.round((seconds / options.durationSeconds) * 100));
options.onProgress(percent);
}
}
const code = await new Promise<number>((resolve) => child.on('close', resolve));
if (code !== 0) {
throw new Error(`ffmpeg thoat voi ma ${code}`);
}
}
Đọc bằng readline chứ không bắt sự kiện 'data' là chi tiết dễ bỏ qua. Stream
chia dữ liệu thành các mẩu theo kích thước bộ đệm, hoàn toàn không quan tâm tới dấu xuống dòng — nên một
mẩu có thể kết thúc giữa chữ out_ti và mẩu sau bắt đầu bằng me_ms=.... Biểu thức
chính quy trượt, tiến độ đứng im ở những chỗ ngẫu nhiên, và bạn mất một buổi chiều để tìm ra tại sao.
100% chỉ được đặt sau khi
close trả về mã 0. Nguyên tắc chung: phần trăm là ước lượng, mã
thoát là sự thật.
2.3. Huỷ, hết giờ, và tiến trình mồ côi
Một job chuyển mã có thể chạy mãi không dứt: file hỏng làm ffmpeg quay vòng, hoặc người dùng tải lên thứ gì đó khiến nó chờ input. Phải có giới hạn cứng, và phải chắc rằng khi bỏ cuộc thì tiến trình con thật sự chết.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30 * 60 * 1000);
try {
await transcode({ ...options, signal: controller.signal });
} finally {
clearTimeout(timer); // Quen dong nay thi tien trinh song them 30 phut vo ich.
}
signal gửi SIGTERM. Phần lớn tiến trình dừng ngay, nhưng ffmpeg đang ở giữa một
thao tác ghi đĩa dài có thể chưa xử lý tín hiệu. Nếu quá trình dừng cũng bị treo, cần một tầng nữa:
function stop(child: ChildProcess): void {
child.kill('SIGTERM'); // Xin dung, cho don dep
const force = setTimeout(() => {
// Van con song sau 10 giay -> SIGKILL. Khong the bat, khong the bo qua.
if (child.exitCode === null) child.kill('SIGKILL');
}, 10_000);
child.on('close', () => clearTimeout(force));
}
SIGKILL — do container hết bộ nhớ chẳng hạn — thì ffmpeg
không chết theo. Nó thành mồ côi và tiếp tục ăn CPU cho tới khi xong một việc mà không
còn ai quan tâm. Khởi động lại vài lần là máy chủ đầy ffmpeg ma. Trong Docker, cách chắc nhất là chạy container với
--init: một tiến trình khởi tạo tối giản
đứng ở PID 1, nhận nuôi tiến trình mồ côi và dọn chúng đi. Đây cũng chính là lý do
docker-compose.yml của dự án đặt init: true cho dịch vụ worker.
3. worker_threads: khi việc nặng nằm trong chính JavaScript
ffmpeg giải quyết được phần chuyển mã vì nó là chương trình có sẵn. Nhưng có những việc bạn phải tự viết bằng JavaScript và chúng vẫn nặng CPU: tính hash SHA-256 của file 2 GB để phát hiện trùng lặp, dựng dải ảnh thu nhỏ cho thanh tua, so khớp vân tay nội dung.
Đẻ hẳn một tiến trình Node mới cho mỗi việc như vậy thì tốn: mỗi tiến trình là một bản V8 riêng, khoảng
30–40 MB và mất hàng chục mili giây để khởi động. worker_threads nhẹ hơn nhiều — cùng
tiến trình, nhưng mỗi luồng có event loop riêng, nên vòng lặp tính toán của nó không đụng gì tới luồng
chính.
import { parentPort, workerData } from 'node:worker_threads';
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
interface HashTask {
readonly filePath: string;
}
const task = workerData as HashTask;
const hash = createHash('sha256');
createReadStream(task.filePath)
.on('data', (chunk) => hash.update(chunk))
.on('end', () => parentPort?.postMessage({ digest: hash.digest('hex') }))
.on('error', (error: Error) => parentPort?.postMessage({ error: error.message }));
Tạo một worker cho mỗi file thì lại rơi vào đúng cái bẫy chi phí khởi động vừa nói, chỉ nhỏ hơn. Cách làm đúng là một hồ worker dựng sẵn, cỡ hồ bằng số nhân CPU:
import { Worker } from 'node:worker_threads';
import { availableParallelism } from 'node:os';
interface Pending<T> {
readonly data: unknown;
readonly resolve: (value: T) => void;
readonly reject: (error: Error) => void;
}
export class WorkerPool<T> {
private readonly idle: Worker[] = [];
private readonly queue: Pending<T>[] = [];
private spawned = 0;
// availableParallelism ton trong gioi han CPU cua container.
// os.cpus().length dem nhan cua CA MAY CHU — trong Docker la con so sai.
constructor(
private readonly script: string,
private readonly size = availableParallelism(),
) {}
private create(): Worker {
this.spawned += 1;
return new Worker(this.script);
}
run(data: unknown): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.queue.push({ data, resolve, reject });
this.pump();
});
}
private pump(): void {
const job = this.queue.shift();
if (job === undefined) return;
const worker = this.idle.pop() ?? (this.spawned < this.size ? this.create() : undefined);
if (worker === undefined) {
this.queue.unshift(job); // Het worker ranh — xep lai hang, cho luot sau.
return;
}
worker.once('message', (result: { digest?: string; error?: string }) => {
if (result.error !== undefined) job.reject(new Error(result.error));
else job.resolve(result as T);
this.idle.push(worker);
this.pump(); // Tra worker ve ho roi keo viec tiep theo.
});
worker.postMessage(job.data);
}
}
3.1. Chọn cái nào
child_process worker_threads
Bo nho rieng hoan toan chung, chia se duoc buffer
Khoi dong 30-50ms 3-5ms
Truyen du lieu serialize qua IPC SharedArrayBuffer: 0 chi phi
postMessage: van phai copy
Su co mot tien trinh chet mot luong chet co the keo
khong anh huong ai ca tien trinh theo
Chay duoc gi BAT KY chuong trinh nao chi JavaScript
Dung khi goi ffmpeg, imagemagick hash, nen anh, tinh toan
bat cu binary nao thuan JS trong Node
Quy tắc rút gọn:
chương trình bên ngoài thì child_process, JavaScript của chính bạn thì
worker_threads. Dự án này dùng cả hai vì nó có cả hai loại việc — ffmpeg là chương trình bên ngoài, còn hàm băm là
JavaScript.
postMessage mặc định sao chép dữ liệu — gửi một buffer 500 MB sang worker là
tốn thêm 500 MB và một khoảng lặng đáng kể. Có hai cách tránh: SharedArrayBuffer để
hai luồng cùng nhìn một vùng nhớ, hoặc danh sách transfer để
chuyển quyền sở hữu: worker.postMessage(buffer, [buffer]);
Sau dòng này, buffer ở luồng gửi trở thành rỗng — nó đã sang bên kia, không có bản sao nào được tạo. Kiểu dữ liệu không cảnh báo bạn về điều đó, nên đây là một trong số ít chỗ phải nhớ bằng đầu.
4. Hàng đợi: tách công việc khỏi request
Giờ ghép lại. Endpoint chuyển mã không chạy ffmpeg — nó ghi một dòng vào bảng jobs, đẩy một
thông điệp vào hàng đợi, rồi trả về ngay.
import { Controller, Get, HttpCode, HttpStatus, Param, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RateLimitGuard } from '../common/rate-limit/rate-limit.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { JwtPayload } from '../auth/jwt-payload';
import { JobService, type JobState } from './job.service';
@Controller()
@UseGuards(JwtAuthGuard, RateLimitGuard)
export class JobController {
constructor(private readonly jobs: JobService) {}
@Post('media/:videoId/transcode')
@HttpCode(HttpStatus.ACCEPTED) // 202: da nhan, chua lam xong
async transcode(
@Param('videoId') videoId: string,
@CurrentUser() user: JwtPayload,
): Promise<{ jobId: string }> {
const job = await this.jobs.enqueue(videoId, user.sub);
return { jobId: job.id };
}
// Nguon su that goi lai luc connect/reconnect — xem muc 6-7.
@Get('jobs/active')
async active(@CurrentUser() user: JwtPayload): Promise<JobState[]> {
return this.jobs.findActiveForUser(user.sub);
}
}
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Job } from './job.entity';
import { JobQueue } from './job.queue';
export interface JobState {
readonly id: string;
readonly videoTitle: string;
readonly status: 'queued' | 'processing' | 'completed' | 'failed';
readonly percent: number;
}
@Injectable()
export class JobService {
constructor(
@InjectRepository(Job) private readonly jobs: Repository<Job>,
private readonly queue: JobQueue,
) {}
async enqueue(videoId: string, userId: string, height: 480 | 720 | 1080 = 720): Promise<Job> {
const job = await this.jobs.save(this.jobs.create({ videoId }));
await this.queue.push({ id: job.id, videoId, userId, height, attempt: 0 });
return job;
}
async markFailed(jobId: string, error: string): Promise<void> {
await this.jobs.update(jobId, { status: 'failed', error, finishedAt: new Date() });
}
// Nguon su that cho FE khi connect/reconnect (muc 6) — WebSocket chi tang
// toc, bang jobs moi la thu luon dung. "Active" nghia la job chua xong
// (queued/processing) hoac vua xong/hong trong 24 gio gan nhat, de UI con
// thay duoc buoc chuyen trang thai cuoi cung sau khi reconnect.
async findActiveForUser(userId: string): Promise<JobState[]> {
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
const jobs = await this.jobs
.createQueryBuilder('job')
.innerJoinAndSelect('job.video', 'video')
.where('video.user_id = :userId', { userId })
.andWhere("(job.status IN ('queued', 'processing') OR job.updated_at >= :cutoff)", { cutoff })
.orderBy('job.created_at', 'DESC')
.getMany();
return jobs.map((job) => ({
id: job.id,
videoTitle: job.video.originalName ?? job.videoId,
status: job.status,
// Phan tram song chi ton tai qua Redis pub/sub trong luc dang chay —
// khong luu vao Postgres. WebSocket se day gia tri that ngay sau khi
// connect (xem useJobProgress.ts o muc 7).
percent: job.status === 'completed' ? 100 : 0,
}));
}
}
JobController và JobService chưa từng xuất hiện
this.jobs.enqueue(...) ở trên giả định một service đã tồn tại sẵn, nhưng
JobService — nơi thật sự ghi dòng vào bảng jobs rồi đẩy vào
JobQueue — chưa từng được viết ra ở đâu trong cả bốn phần. Khai báo đầy đủ ở trên, cùng với
việc bọc endpoint vào một JobController thật — kèm luôn GET /jobs/active mà
mục 6-7 phía dưới dùng để đồng bộ lại sau khi mất kết nối, cũng chưa từng có route nào phục vụ nó.
202 Accepted chứ không phải 200 OK. Khác biệt không chỉ nằm ở con số: nó nói với
client rằng kết quả chưa có, và client phải theo dõi bằng cách khác — chính là WebSocket ở mục 6.
Hàng đợi ở đây dùng Redis Streams thay vì danh sách. Lý do là XACK: một thông điệp lấy ra
nhưng chưa được xác nhận vẫn nằm trong danh sách chờ, nên worker chết giữa chừng thì job không biến mất.
Với LPOP thì thông điệp rời hàng đợi ngay lúc đọc, worker chết là mất luôn.
import { Injectable, OnModuleInit } from '@nestjs/common';
import { RedisService } from '../redis/redis.service';
export interface TranscodeJob {
readonly id: string; // trung voi Job.id trong Postgres
readonly videoId: string;
readonly userId: string;
readonly height: 480 | 720 | 1080;
readonly attempt: number;
// Gan boi pull() sau khi doc tu Redis Streams — push() khong co gia tri nay.
readonly messageId?: string;
// Chi co khi day sang hang doi chet (muc 4.1).
readonly lastError?: string;
}
const STREAM = 'jobs:transcode';
const GROUP = 'workers';
function parseMessage(fields: string[]): TranscodeJob {
// xadd luu 'data' <json> — fields la mang phang [key, value, key, value, ...].
const raw = fields[fields.indexOf('data') + 1] as string;
return JSON.parse(raw) as TranscodeJob;
}
@Injectable()
export class JobQueue implements OnModuleInit {
constructor(private readonly redis: RedisService) {}
async onModuleInit(): Promise<void> {
// MKSTREAM: tao stream neu chua co. '$' = bo qua nhung message co tu
// truoc, chi doc nhung gi den sau khi group nay duoc tao.
try {
await this.redis.xgroup('CREATE', STREAM, GROUP, '$', 'MKSTREAM');
} catch (err) {
// BUSYGROUP: group da ton tai tu lan khoi dong truoc — khong phai loi that.
if (!String(err).includes('BUSYGROUP')) throw err;
}
}
async push(payload: TranscodeJob): Promise<string> {
const id = await this.redis.xadd(STREAM, '*', 'data', JSON.stringify(payload));
if (id === null) throw new Error('XADD khong tra ve id.');
return id;
}
async pull(consumerName: string): Promise<TranscodeJob | null> {
// BLOCK 5000: cho toi 5 giay neu hang doi rong, khong quay vong hoi lien tuc.
// Chu thich kieu tra ve bang tay: chu ky bien the nay cua xreadgroup khong
// duoc ioredis suy ra dung, TypeScript thay "unknown" neu khong ep kieu.
const result = (await this.redis.xreadgroup(
'GROUP', GROUP, consumerName,
'COUNT', 1,
'BLOCK', 5000,
'STREAMS', STREAM, '>',
)) as [string, [string, string[]][]][] | null;
if (result === null || result.length === 0) return null;
const [messageId, fields] = result[0]![1][0]!;
return { ...parseMessage(fields), messageId };
}
async ack(messageId: string): Promise<void> {
await this.redis.xack(STREAM, GROUP, messageId);
}
}
TranscodeJob, parseMessage và lớp bao quanh không có sẵn
this.redis nên phải nằm trong một class thật, và
TranscodeJob — kiểu dữ liệu xuất hiện trong cả ba chữ ký hàm — chưa từng được định nghĩa ở
đâu trước đó. Khai báo đầy đủ ở trên để phần còn lại của mục này biên dịch được.
4.1. Thử lại, và nơi chứa job không bao giờ chạy được
Job thất bại có hai loại rất khác nhau. Loại thứ nhất là tạm thời: hết chỗ trống trên đĩa, mạng chớp tắt, database bận. Thử lại là chữa được. Loại thứ hai là vĩnh viễn: file không phải video, codec không hỗ trợ. Thử lại một nghìn lần vẫn hỏng — và nếu bạn cứ thử, một job hỏng sẽ chiếm worker mãi mãi và chặn tất cả các job phía sau.
Hai loại thất bại cần hai nơi khác nhau để đợi: một hàng đợi trễ cho lần thử lại kế tiếp, một hàng đợi
chết cho thứ không thử lại nữa. Thêm hai method vào JobQueue đã viết ở mục 4:
// them vao class JobQueue o muc 4
private readonly DELAYED_KEY = 'jobs:delayed'; // sorted set: score = thoi diem (ms) duoc phep chay
private readonly DEAD_LETTER_STREAM = 'jobs:dead';
// Khong day thang vao stream chinh — xep vao sorted-set voi diem la thoi
// diem duoc phep chay. promoteDelayed() (goi dinh ky) day nhung job da toi
// han sang stream chinh.
async pushDelayed(payload: TranscodeJob, delayMs: number): Promise<void> {
await this.redis.zadd(this.DELAYED_KEY, Date.now() + delayMs, JSON.stringify(payload));
}
async promoteDelayed(): Promise<number> {
const due = await this.redis.zrangebyscore(this.DELAYED_KEY, 0, Date.now());
for (const raw of due) {
// ZREM truoc: hai worker cung quet dung luc thi chi mot ben con xoa
// duoc, ben kia nhan 0 — khong day trung.
const removed = await this.redis.zrem(this.DELAYED_KEY, raw);
if (removed > 0) await this.push(JSON.parse(raw) as TranscodeJob);
}
return due.length;
}
// Khong dung consumer group — day la noi con nguoi doc lai bang XRANGE,
// khong ai XACK ca.
async pushDeadLetter(payload: TranscodeJob): Promise<string> {
const id = await this.redis.xadd(this.DEAD_LETTER_STREAM, '*', 'data', JSON.stringify(payload));
if (id === null) throw new Error('XADD khong tra ve id.');
return id;
}
promoteDelayed() cần một nơi gọi định kỳ — giống hệt OutboxRelay ở Part 4, chỉ
khác nó chạy trong tiến trình worker:
import { Injectable } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { JobQueue } from './job.queue';
@Injectable()
export class DelayedJobPromoter {
constructor(private readonly queue: JobQueue) {}
@Interval(1000)
async flush(): Promise<void> {
await this.queue.promoteDelayed();
}
}
Giờ mới tới vòng lặp chính — một method riêng của JobRunner ở mục 4.4, không phải hàm rời:
const MAX_ATTEMPTS = 3;
// Method cua JobRunner, KHONG PHAI mot ham doc lap — mot function() thuong
// khong co `this` gan san, trong khi than ham ben duoi dung this.queue,
// this.jobs. Xem class JobRunner day du o muc 4.4.
private async processOne(job: TranscodeJob): Promise<void> {
try {
await this.runTranscode(job);
await this.queue.ack(job.messageId!);
} catch (error) {
const attempt = job.attempt + 1;
if (attempt >= MAX_ATTEMPTS) {
// Het luot: day sang hang doi chet. KHONG xoa — con nguoi can xem lai.
await this.queue.pushDeadLetter({ ...job, lastError: String(error) });
await this.queue.ack(job.messageId!);
await this.jobs.markFailed(job.id, String(error));
return;
}
// Cho lau dan: 2s, 4s, 8s. Thu lai ngay lap tuc chi lam nang them
// dung cai he thong dang gap su co.
const delayMs = 2 ** attempt * 1000;
await this.queue.pushDelayed({ ...job, attempt }, delayMs);
await this.queue.ack(job.messageId!);
}
}
Việc tối thiểu cần làm: đặt cảnh báo khi hàng đợi chết có phần tử, và giữ đủ ngữ cảnh (id video, thông báo lỗi cuối) để đẩy lại được sau khi vá.
4.2. Tính bình thản khi thử lại
Có một cái bẫy tinh vi. Worker chuyển mã xong, trừ credit của người dùng, rồi chết trước khi kịp
XACK. Redis thấy thông điệp chưa được xác nhận nên giao lại cho worker khác — và người dùng
bị trừ tiền lần thứ hai cho cùng một video.
Đây lại đúng là gia đình của lỗi ở Part 1, chỉ khác là lần này thủ phạm không phải hai request đồng thời mà là một thông điệp được xử lý hai lần. Lời giải là làm cho thao tác bình thản: chạy hai lần cho kết quả giống hệt chạy một lần.
export interface ChargeForJobResult {
readonly charged: boolean;
readonly balance: number;
}
async chargeForJob(jobId: string, userId: string, amount: number): Promise<ChargeForJobResult> {
return this.dataSource.transaction(async (manager) => {
// So cai append-only tu Part 1, gio them mot rang buoc duy nhat tren job_id.
// Lan chay thu hai va vao rang buoc nay va bi tu choi.
const result = await manager
.createQueryBuilder()
.insert()
.into(CreditEntry)
.values({ userId, jobId, delta: -amount, reason: 'transcode' })
.orIgnore() // ON CONFLICT DO NOTHING
.execute();
// Bi bo qua vi trung job_id thi RETURNING khong tra dong nao — result.raw rong.
const charged = result.raw.length > 0;
const row = await manager
.createQueryBuilder(CreditEntry, 'e')
.select('COALESCE(SUM(e.delta), 0)', 'balance')
.where('e.user_id = :userId', { userId })
.getRawOne<{ balance: string }>();
return { charged, balance: Number(row?.balance ?? 0) };
});
}
Một chỉ mục duy nhất trên job_id và một mệnh đề ON CONFLICT DO NOTHING — thế là
xong. Không cần cờ, không cần bảng phụ theo dõi "đã xử lý chưa", không có cửa sổ đua nào. Database giữ
ràng buộc, và nó giữ đúng kể cả khi hai worker chạy cùng lúc trên hai máy khác nhau.
result.identifiers nói dối khi bị ON CONFLICT DO NOTHING bỏ qua
result.identifiers.length > 0 để biết dòng có được chèn hay
không. Đo thật trên Postgres cho thấy điều ngược lại: khi bị xung đột bỏ qua,
identifiers không phải mảng rỗng — nó là mảng
có đúng một phần tử, mang giá trị null ([null]), nên
.length > 0 vẫn đúng ở cả hai lần gọi và luôn báo charged: true. Tín hiệu
đáng tin là result.raw: rỗng khi bị bỏ qua, có một dòng khi chèn thành công, vì nó phản ánh
đúng những gì mệnh đề RETURNING thực sự trả về.
4.3. Worker là một tiến trình như thế nào
Từ đầu part này worker được nhắc tới như một thứ có sẵn. Nó không có sẵn — phải tự dựng, và điểm hay là nó dùng lại toàn bộ module của ứng dụng, chỉ bỏ đi phần HTTP.
import { NestFactory } from '@nestjs/core';
import { WorkerModule } from './worker.module';
import { JobRunner } from './job.runner';
async function bootstrap(): Promise<void> {
// createApplicationContext, KHONG phai create(). Co DI, co ConfigService,
// co TypeORM — nhung khong mo cong nao, khong co controller nao.
const app = await NestFactory.createApplicationContext(WorkerModule);
app.enableShutdownHooks();
const runner = app.get(JobRunner);
await runner.loop();
}
void bootstrap();
Đây là chỗ trả cổ tức cho quyết định ở Part 1: chia thư mục theo miền nghiệp vụ chứ không theo loại file.
WorkerModule nhập BillingModule và JobModule y như
AppModule làm, nên hàm trừ credit mà worker gọi là chính xác hàm mà API gọi — không
có bản sao nào để trôi khỏi nhau.
{
"scripts": {
"start:dev": "nest start --watch",
"start:worker": "node dist/worker/main.js",
"start:worker:dev": "nest start --watch --entryFile worker/main"
}
}
services:
worker:
build: .
command: npm run start:worker
init: true # PID 1 tu te — don tien trinh ffmpeg mo coi, xem canh bao muc 2.3
environment:
DATABASE_URL: postgres://app:secret@postgres:5432/media
REDIS_URL: redis://redis:6379
volumes:
- media:/var/media # dung chung thu muc voi API
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
deploy:
replicas: 2 # hai worker chay song song, Redis Streams tu chia viec
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import { AppConfigModule } from '../config/config.module';
import { sharedOptions } from '../database/typeorm.options';
import type { AppConfig } from '../config/configuration';
import { RedisModule } from '../redis/redis.module';
import { BillingModule } from '../billing/billing.module';
import { JobModule } from '../job/job.module';
import { Video } from '../media/video.entity';
import { Job } from '../job/job.entity';
import { DelayedJobPromoter } from '../job/delayed-job-promoter';
import { JobRunner } from './job.runner';
// Dung lai toan bo module nghiep vu — chia thu muc theo mien nghiep vu
// tra co tuc dung o day (xem Part 1, muc 1).
@Module({
imports: [
AppConfigModule,
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService<AppConfig, true>) => ({
...sharedOptions,
url: config.get('DATABASE_URL', { infer: true }),
}),
}),
// Video CUNG Job — JobRunner tiem thang Repository<Job>, va JobModule
// khong export forFeature cua rieng no nen phai khai lai o day.
TypeOrmModule.forFeature([Video, Job]),
ScheduleModule.forRoot(), // bat buoc de @Interval o DelayedJobPromoter chay that
RedisModule,
BillingModule,
JobModule,
],
providers: [JobRunner, DelayedJobPromoter],
})
export class WorkerModule {}
ScheduleModule.forRoot() thì @Interval im lặng không chạy
@Interval (dùng ở DelayedJobPromoter trên, và ở OutboxRelay tại
Part 4) đến từ gói @nestjs/schedule — cần npm i @nestjs/schedule và đăng ký
ScheduleModule.forRoot() trong module gốc của mỗi tiến trình dùng nó. Thiếu bước này ứng
dụng vẫn khởi động bình thường, không báo lỗi gì cả — chỉ riêng hàm gắn @Interval không bao
giờ được gọi. Đây là kiểu lỗi khó phát hiện nhất: mọi thứ trông như chạy đúng cho tới khi có ai hỏi vì
sao hàng đợi trễ không bao giờ được đẩy lại.
4.4. Dừng worker mà không bỏ dở job
Triển khai bản mới nghĩa là gửi SIGTERM cho worker. Nếu nó chết ngay lập tức, job đang chuyển
mã dở dang bị bỏ lại — thông điệp chưa XACK nên rồi cũng được giao lại, nhưng công sức mã hoá
mấy phút vừa rồi mất trắng, và có một file tạm nằm lại trên đĩa.
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { hostname } from 'node:os';
import { dirname, join } from 'node:path';
import { Job } from '../job/job.entity';
import { JobQueue } from '../job/job.queue';
import { JobService } from '../job/job.service';
import type { TranscodeJob } from '../job/job.queue';
import { Video } from '../media/video.entity';
import { BillingService } from '../billing/billing.service';
import { RedisService } from '../redis/redis.service';
import { transcode } from './transcoder';
// Gia tren moi do phan giai — dung cho demo, thuc te se doc tu bang gia.
const PRICE_BY_HEIGHT: Record<TranscodeJob['height'], number> = { 480: 5, 720: 10, 1080: 20 };
// So lan thu toi da truoc khi day sang hang doi chet (muc 4.1).
const MAX_ATTEMPTS = 3;
@Injectable()
export class JobRunner implements OnApplicationShutdown {
private stopping = false;
private current: Promise<void> | null = null;
private readonly consumerName = `${hostname()}-${process.pid}`;
constructor(
private readonly queue: JobQueue,
private readonly jobs: JobService,
@InjectRepository(Job) private readonly jobRepo: Repository<Job>,
@InjectRepository(Video) private readonly videos: Repository<Video>,
private readonly billing: BillingService,
private readonly redis: RedisService,
) {}
async loop(): Promise<void> {
while (!this.stopping) {
const job = await this.queue.pull(this.consumerName);
if (job === null) continue; // het 5 giay cho ma khong co viec
this.current = this.processOne(job);
await this.current;
this.current = null;
}
}
// Nest goi ham nay khi nhan SIGTERM (nho enableShutdownHooks).
async onApplicationShutdown(): Promise<void> {
this.stopping = true; // khong nhan viec moi nua
await this.current; // nhung lam not viec dang do
}
// Method cua JobRunner, KHONG PHAI mot ham doc lap — mot function() thuong
// khong co `this` gan san, ma than ham duoi day dung this.queue, this.jobs.
private async processOne(job: TranscodeJob): Promise<void> {
try {
await this.runTranscode(job);
await this.queue.ack(job.messageId!);
} catch (error) {
const attempt = job.attempt + 1;
if (attempt >= MAX_ATTEMPTS) {
// Het luot: day sang hang doi chet. KHONG xoa — con nguoi can xem lai.
await this.queue.pushDeadLetter({ ...job, lastError: String(error) });
await this.queue.ack(job.messageId!);
await this.jobs.markFailed(job.id, String(error));
return;
}
// Cho lau dan: 2s, 4s, 8s. Thu lai ngay lap tuc chi lam nang them
// dung cai he thong dang gap su co.
const delayMs = 2 ** attempt * 1000;
await this.queue.pushDelayed({ ...job, attempt }, delayMs);
await this.queue.ack(job.messageId!);
}
}
private async runTranscode(job: TranscodeJob): Promise<void> {
const video = await this.videos.findOneOrFail({ where: { id: job.videoId } });
await this.jobRepo.update(job.id, { status: 'processing', startedAt: new Date() });
const controller = new AbortController();
const output = join(dirname(video.originalKey), `${video.id}-${job.height}p.mp4`);
await transcode({
input: video.originalKey,
output,
height: job.height,
durationSeconds: video.duration ?? 60,
signal: controller.signal,
onProgress: (percent) => {
// Worker khong biet gi ve WebSocket. No chi phat ra Redis (muc 6).
void this.redis.publish('progress', JSON.stringify({ userId: job.userId, jobId: job.id, percent }));
},
});
// Tru credit BINH THAN — an toan neu job nay bi xu ly lai (muc 4.2).
await this.billing.chargeForJob(job.id, job.userId, PRICE_BY_HEIGHT[job.height]);
await this.jobRepo.update(job.id, { status: 'completed', finishedAt: new Date() });
// Kenh RIENG voi 'progress' — client can biet job da xong HOAN TOAN,
// khong chi la mot moc phan tram nua trong hang dai cac moc trung gian.
void this.redis.publish('job:done', JSON.stringify({ userId: job.userId, jobId: job.id }));
}
}
job:done thì UI kẹt ở "đang xử lý" mãi mãiuseJobProgress.ts ở mục 7 nghe 'job:progress' và
'job:done' như hai sự kiện tách biệt — chỉ nhánh job:done mới chuyển
status sang 'completed'. Nếu worker chỉ phát 'progress' (kể cả
lúc 100%), phía client vẫn thấy thanh chạy đủ nhưng nhãn trạng thái đứng nguyên ở
'processing' — vì không nhánh nào trong useJobProgress.ts đọc
percent để tự suy ra job đã xong.
processOne được ghép vào từ mục 4.1loop() gọi this.processOne(job) — thân của nó đã viết riêng ở mục 4.1 khi bàn
về thử lại, và vừa được ghép vào ngay trên, ngay trước runTranscode. Cùng với
runTranscode — nơi ghép transcode() (mục 2.2),
RedisService.publish (mục 6), và BillingService.chargeForJob (mục 4.2) lại với
nhau — class JobRunner ở trên giờ đầy đủ.
SIGTERM và SIGKILL; Kubernetes cho 30 giây. Một job
chuyển mã cần vài phút — nên đoạn code trên sẽ bị cắt ngang bất kể nó lịch sự đến đâu. Phải nâng hạn:
stop_grace_period: 10m trong Compose, hoặc
terminationGracePeriodSeconds: 600 trong Kubernetes. Và vẫn phải chấp nhận rằng một job đủ
dài sẽ bị cắt — đó là lý do XACK đặt sau khi làm xong chứ không phải trước, và là
lý do thao tác trừ tiền phải bình thản như mục 4.2.
services:
worker:
# ... build, command, init, environment, volumes, depends_on da chot o muc 4.3 ...
stop_grace_period: 10m # doi toi 10 phut truoc khi Docker gui SIGKILL
deploy:
replicas: 2
5. cluster: dùng hết số nhân CPU
Worker lo phần nặng rồi, nhưng bản thân API vẫn chỉ chạy trên một luồng, tức là một nhân. Máy chủ 8 nhân thì 7 nhân ngồi không.
cluster nhân bản tiến trình: một tiến trình chính đẻ ra N tiến trình con, tất cả cùng nghe
trên một cổng, và hệ điều hành chia kết nối cho chúng.
Mỗi tiến trình con phải tự chạy được một bản NestJS đầy đủ — tức là gọi lại đúng đoạn
NestFactory.create(AppModule) và app.listen(...) đã thấy ở Part 1 và Part 2. Bọc
đoạn đó vào một hàm export function bootstrap() trong src/main.ts, và chỉ tự gọi
nó khi file chạy trực tiếp — để cluster.ts phía dưới import lại đúng hàm này mà không chạy nó
thêm một lần nữa lúc import:
export async function bootstrap(): Promise<void> {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.set('trust proxy', 1); // xem Part 2, muc 4.2
await app.listen(process.env.PORT ?? 3000);
}
// Chi tu chay khi file nay la entry point that su (npm run start:dev goi
// thang main.ts) — tranh chay bootstrap() hai lan khi cluster.ts import ham nay.
if (require.main === module) {
void bootstrap();
}
import { bootstrap } from './main';
import cluster from 'node:cluster';
import { availableParallelism } from 'node:os';
if (cluster.isPrimary) {
const count = availableParallelism();
for (let i = 0; i < count; i += 1) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
// Chet vi loi thi dung ngay mot cai moi. Con neu la dung co y
// (SIGTERM khi trien khai ban moi) thi de yen cho no thoat.
if (signal !== 'SIGTERM') {
console.error(`worker ${worker.process.pid} chet (${code}), dung lai`);
cluster.fork();
}
});
} else {
// bootstrap() CUA src/main.ts (tien trinh API, co app.listen()) — KHONG
// PHAI bootstrap() cua src/worker/main.ts (tien trinh worker, khong HTTP,
// xem muc 4.3). Hai file dinh nghia hai ham trung ten nhung khac nhau.
void bootstrap();
}
Nhưng khi có nhiều tiến trình, ba thứ trước đây đúng bỗng thành sai:
1. Trang thai trong bo nho
Cache bang Map trong tien trinh -> 8 ban khac nhau, khong dong bo.
=> Redis.
2. Ket noi WebSocket
Nguoi dung noi vao tien trinh 3. Tien do phat tu worker khong den duoc.
=> Redis pub/sub (muc 6).
3. Cong viec dinh ky
@Cron chay o CA 8 tien trinh -> gui email 8 lan.
=> Khoa trong Redis, hoac tach han ra mot tien trinh scheduler rieng.
cluster hay nhiều containerdocker compose --scale), cách thường dùng là
một tiến trình Node cho mỗi container và để bộ điều phối nhân bản container. Bạn được
kiểm tra sức khoẻ, triển khai cuốn chiếu và giới hạn tài nguyên từng bản — những thứ
cluster không có. cluster vẫn là lựa chọn hợp lý khi chạy trên một máy chủ đơn lẻ, hoặc khi muốn tận dụng hết
nhân của một container lớn. Điều đáng nói là
ba vấn đề ở trên giống hệt nhau trong cả hai cách — nên hiểu chúng ở đây thì lên
Kubernetes không gặp bất ngờ nào.
5.1. nginx cân tải nhiều instance
upstream app {
least_conn; # gui toi instance dang it ket noi nhat
server app1:3000 max_fails=3 fail_timeout=30s;
server app2:3000 max_fails=3 fail_timeout=30s;
server app3:3000 max_fails=3 fail_timeout=30s;
keepalive 32;
}
least_conn hợp hơn kiểu chia lượt đều cho hệ thống này, vì thời gian xử lý mỗi request rất
lệch nhau: một lượt đăng nhập mất mấy mili giây, một lượt upload mất mấy phút. Chia đều theo lượt thì
instance nào xui nhận liên tiếp mấy upload sẽ quá tải trong khi các instance khác rảnh.
max_fails=3 fail_timeout=30s nghĩa là ba lần lỗi liên tiếp thì nginx tạm loại instance đó
khỏi vòng quay 30 giây. Một instance đang khởi động lại không kéo theo lỗi cho người dùng.
6. Realtime: đẩy tiến độ về đúng người
Người dùng bấm chuyển mã và nhận về 202. Giờ họ cần thấy thanh tiến trình nhích. Cách thô sơ
là gọi hỏi liên tục mỗi giây — nhưng một nghìn người xem tiến độ là một nghìn request mỗi giây cho một con
số hầu như không đổi.
WebSocket đảo chiều: một kết nối mở, server chủ động đẩy khi có gì mới. Nhưng nó đặt ra một câu hỏi mà mục 5 đã báo trước — worker biết tiến độ, nhưng kết nối WebSocket nằm ở một tiến trình khác.
import { OnGatewayConnection, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import type { Server, Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import type { JwtPayload } from '../auth/jwt-payload';
@WebSocketGateway({ path: '/ws', cors: false })
export class ProgressGateway implements OnGatewayConnection {
@WebSocketServer() private readonly server!: Server;
constructor(private readonly jwt: JwtService) {}
async handleConnection(client: Socket): Promise<void> {
try {
// Xac thuc NGAY luc bat tay. Ket noi chua xac thuc thi dong luon —
// de no mo la de nguoi la giu tai nguyen cua ban.
const token = String(client.handshake.auth['token'] ?? '');
const payload = await this.jwt.verifyAsync<JwtPayload>(token);
// Moi nguoi dung mot phong rieng. Khong ai nghe duoc tien do cua ai.
await client.join(`user:${payload.sub}`);
} catch {
client.disconnect(true);
}
}
// Duoc goi khi co thong diep tu Redis pub/sub.
emitProgress(userId: string, jobId: string, percent: number): void {
this.server.to(`user:${userId}`).emit('job:progress', { jobId, percent });
}
// Su kien RIENG, khac 'job:progress' — bao cho client biet job da xong
// hoan toan (kem tru credit), khong chi la mot moc phan tram nua.
emitDone(userId: string, jobId: string): void {
this.server.to(`user:${userId}`).emit('job:done', { jobId });
}
}
redis ở đâyprogress thuộc về ProgressSubscriber ngay
bên dưới, không phải ProgressGateway — gateway chỉ cần JwtService để xác thực
bắt tay. Thêm tham số redis: RedisService vào constructor mà không dùng tới sẽ biên dịch
lỗi TS6138 dưới noUnusedLocals mà chính Part 1 khuyến nghị bật.
// Worker khong biet gi ve WebSocket. No chi phat ra Redis.
await this.redis.publish(
'progress',
JSON.stringify({ userId: job.userId, jobId: job.id, percent }),
);
import { Injectable, OnModuleInit } from '@nestjs/common';
import type { Redis } from 'ioredis';
import { RedisService } from '../redis/redis.service';
import { ProgressGateway } from './progress.gateway';
interface ProgressEvent {
readonly userId: string;
readonly jobId: string;
readonly percent: number;
}
interface DoneEvent {
readonly userId: string;
readonly jobId: string;
}
@Injectable()
export class ProgressSubscriber implements OnModuleInit {
private readonly subscriber: Redis;
constructor(
private readonly redis: RedisService,
private readonly gateway: ProgressGateway,
) {
// MOT ket noi Redis RIENG chi de dang ky nghe. Ket noi dang o che do
// subscribe khong chay duoc lenh thuong nao khac.
this.subscriber = this.redis.duplicate();
}
onModuleInit(): void {
// Dang ky ca hai kenh — tham so channel cua callback tu phan biet duoc
// thong diep nao thuoc kenh nao, khong can hai handler rieng.
this.subscriber.subscribe('progress', 'job:done');
this.subscriber.on('message', (channel: string, raw: string) => {
if (channel === 'job:done') {
const event = JSON.parse(raw) as DoneEvent;
this.gateway.emitDone(event.userId, event.jobId);
return;
}
const event = JSON.parse(raw) as ProgressEvent;
// Tien trinh nao dang giu ket noi cua nguoi do thi gui duoc.
// Cac tien trinh con lai goi to() vao mot phong rong — khong sao ca.
this.gateway.emitProgress(event.userId, event.jobId, event.percent);
});
}
}
redis.duplicate() — nơi kết nối riêng thật sự được tạothis.subscriber ở trên chưa từng được gán giá trị nếu chỉ nhìn đoạn
onModuleInit. RedisService kế thừa từ ioredis nên có sẵn
.duplicate() — tạo một kết nối mới cùng cấu hình, đúng thứ cần cho một kênh luôn ở chế độ
subscribe.
Cả tám tiến trình đều nhận được thông điệp, nhưng chỉ tiến trình đang thật sự giữ kết nối của người dùng đó mới gửi được gì. Bảy tiến trình còn lại phát vào một phòng trống — vô hại, và đó chính là điều làm cho mô hình này đơn giản đến mức đáng tin.
6.1. nginx và cái bắt tay nâng cấp giao thức
WebSocket bắt đầu bằng một request HTTP thường có header Upgrade. nginx mặc định dùng
HTTP/1.0 cho upstream và cắt bỏ các header liên quan đến kết nối — nên nếu không cấu hình, WebSocket sẽ
không bao giờ bắt tay xong.
location /ws {
proxy_pass http://app;
proxy_http_version 1.1; # bat buoc: 1.0 khong nang cap duoc
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
# Mac dinh 60s. Ket noi im lang qua 60s se bi cat — ma WebSocket thi
# im lang la binh thuong.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
least_conn, chúng bị rải ra khắp nơi
và kết nối thất bại một cách rất khó hiểu — thường biểu hiện là client cứ kết nối rồi rớt thành vòng
lặp. Thuật toán cân tải là thuộc tính của cả khối
upstream, không thể khác nhau theo từng
location dùng chung một khối. Hai cách chữa: tách một khối upstream riêng chỉ
để /ws dùng — upstream app_ws {
ip_hash;
server app1:3000;
server app2:3000;
server app3:3000;
}
— rồi đổi
proxy_pass trong location /ws sang http://app_ws (khối
app còn lại ở mục 5.1 giữ nguyên least_conn cho các request thường), hoặc ép
client dùng thẳng WebSocket: io(url, { transports: ['websocket'] }) — bỏ hẳn giai đoạn
polling và bỏ luôn vấn đề.
6.2. Mất kết nối và những gì bỏ lỡ
WebSocket sẽ rớt: wifi chuyển sang 4G, máy tính ngủ, nginx khởi động lại. Kết nối lại thì tự động, nhưng những sự kiện phát ra trong lúc mất kết nối thì đã trôi qua — pub/sub không lưu gì cả.
Cách chữa không nằm ở WebSocket mà ở chỗ khác: coi kênh realtime là thứ tăng tốc, không phải
nguồn sự thật. Trạng thái thật luôn nằm trong bảng jobs.
api() gọi dưới đây là hàm tự làm mới token đã viết ở Part 2, mục 8.1 — mọi request qua
GET /jobs/active đi kèm access token mới nhất mà không cần lặp lại logic refresh ở đây.
import { api } from './api';
socket.on('connect', async () => {
// Moi lan noi lai — ke ca lan dau — hoi mot phat trang thai hien tai.
// Sau do WebSocket lo phan cap nhat.
const res = await api('/jobs/active');
const jobs = (await res.json()) as JobState[];
setJobs(jobs);
});
socket.on('job:progress', (event: { jobId: string; percent: number }) => {
setJobs((prev) =>
prev.map((job) => (job.id === event.jobId ? { ...job, percent: event.percent } : job)),
);
});
Bốn dòng trong connect khiến toàn bộ bài toán "bỏ lỡ sự kiện" biến mất. Mất mạng bao lâu cũng
được: nối lại là đồng bộ lại từ đầu, rồi chạy tiếp bằng sự kiện.
7. Mốc #3: bảng tiến độ chạy theo thời gian thực
import { useEffect, useState } from 'react';
import { io, type Socket } from 'socket.io-client';
import { api } from '../lib/api';
export interface JobState {
readonly id: string;
readonly videoTitle: string;
readonly status: 'queued' | 'processing' | 'completed' | 'failed';
readonly percent: number;
}
export function useJobProgress(accessToken: string): readonly JobState[] {
const [jobs, setJobs] = useState<readonly JobState[]>([]);
useEffect(() => {
const socket: Socket = io('/', {
path: '/ws',
transports: ['websocket'], // bo qua long-polling, xem canh bao o muc 6.1
auth: { token: accessToken },
});
socket.on('connect', () => {
void api('/jobs/active')
.then((res) => res.json() as Promise<JobState[]>)
.then(setJobs);
});
socket.on('job:progress', ({ jobId, percent }: { jobId: string; percent: number }) => {
setJobs((prev) =>
prev.map((job) => (job.id === jobId ? { ...job, percent, status: 'processing' } : job)),
);
});
socket.on('job:done', ({ jobId }: { jobId: string }) => {
setJobs((prev) =>
prev.map((job) => (job.id === jobId ? { ...job, percent: 100, status: 'completed' } : job)),
);
});
// Dong ket noi khi component bi go bo. Quen dong nay thi moi lan
// dieu huong lai mo them mot socket nua.
return () => {
socket.disconnect();
};
}, [accessToken]);
return jobs;
}
export function JobList({ token }: { token: string }): JSX.Element {
const jobs = useJobProgress(token);
return (
<ul className="space-y-3">
{jobs.map((job) => (
<li key={job.id} className="rounded-lg border border-slate-200 p-4">
<div className="flex justify-between text-sm">
<span className="font-medium">{job.videoTitle}</span>
<span className="text-slate-500">{job.percent}%</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-slate-200">
<div
className="h-full bg-emerald-500 transition-[width] duration-300"
style={{ width: `${job.percent}%` }}
/>
</div>
</li>
))}
</ul>
);
}
transition-[width] duration-300 làm thanh tiến trình trượt mượt giữa hai lần cập nhật. Không
có nó, tiến độ nhảy giật từng nấc mỗi khi ffmpeg báo về, và cảm giác là hệ thống đang lag chứ không phải
đang chạy.
child_process — ở một tiến trình của hệ điều hành. worker_threads — ở một
luồng khác trong cùng tiến trình. cluster — ở một bản sao của chính ứng dụng. Hàng đợi — ở
một thời điểm khác, tách hẳn khỏi request. Chọn sai công cụ thì hoặc trả giá vô ích (đẻ tiến trình cho một việc 5 ms), hoặc chẳng giải quyết được gì (đưa việc nặng CPU sang một hàm
async — nó vẫn chạy trên chính luồng đó).
Part 4 tách nó ra thành các dịch vụ riêng nối với nhau bằng gRPC, và nhìn thẳng vào cái giá phải trả: transaction của Part 1 vỡ ngay khi hai thao tác rơi vào hai dịch vụ khác nhau. Chỗ đó là nơi outbox pattern lần đầu tiên có nghĩa thật, thay vì là một sơ đồ đẹp trong sách.
Bình luận