Actor Model trong TypeScript với XState v5: Xây dựng Pipeline Xử lý Dữ liệu Phân tán

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

So sánh Tiếp cận: Điều phối Logic Bất đồng bộ Phức tạp

Quản lý state bất đồng bộ trên các backend microservice hoặc phiên trình duyệt tải cao thường buộc chúng ta phải lựa chọn giữa hai mô hình: Event-Driven Pub/Sub và Actor Model.

Kiến trúc Pub/Sub tiêu chuẩn—chẳng hạn như Redis Pub/Sub, Apache Kafka hoặc Node.js EventEmitter—broadcast event tới các channel chung. Các listener độc lập sẽ consume message theo tốc độ riêng. Sự tách rời (decoupling) này giúp việc scale ngang trở nên dễ dàng, nhưng lại rất dễ dẫn đến tình trạng trôi dạt trạng thái (state drift).

Khi năm listener phân tán cùng sửa đổi một bản ghi PostgreSQL tại một thời điểm, race condition sẽ xảy ra. Việc xử lý lỗi bị phân mảnh rải rác trên nhiều callback khác nhau. Để tái dựng lại vòng đời chính xác của một thực thể, bạn thường phải tổng hợp log từ hàng chục microservice.

Actor Model giải quyết bài toán này bằng cách đặt chung state, behavior và một message queue riêng biệt vào trong các đơn vị cô lập gọi là actor. Một actor chỉ được phép mutate state cục bộ của chính nó. Nó tương tác với thế giới bên ngoài hoàn toàn thông qua các direct message bất đồng bộ. XState v5 đã tái cấu trúc toàn bộ các primitive cốt lõi xoay quanh tiêu chuẩn này. State machine, promise, observable và callback giờ đây đều là các first-class actor. Chúng có thể spawn, supervise (giám sát) và trao đổi message với các child actor một cách trực tiếp mà không cần cấu hình phức tạp.

Ưu và Nhược điểm khi Dùng Actor Model với XState v5

Việc sử dụng XState v5 làm actor runtime đem lại những đánh đổi kiến trúc rõ ràng mà bạn cần cân nhắc kỹ trước khi viết lại pipeline trên production.

Ưu điểm

  • Triệt tiêu Hoàn toàn Race Condition Nội bộ (In-Process): Mỗi actor xử lý message tuần tự từ mailbox của riêng mình. Bạn loại bỏ hoàn toàn mutex lock, semaphore và lỗi xung đột read-after-write trong các tác vụ async phức tạp.
  • Cơ chế Giám sát Phân tầng (Hierarchical Supervision): Actor cha có thể spawn các child actor riêng biệt cho từng tác vụ độc lập như upload từng chunk dữ liệu hoặc gửi socket heartbeat. Nếu một child actor gặp sự cố (crash), supervisor có thể restart, throttle hoặc terminate nó một cách an toàn mà không làm sập toàn bộ process.
  • Type Safety ở Thời điểm Compile: XState v5 tận dụng triệt để template literal types và contextual typing của TypeScript. Event schema, context property và input của child actor đều được kiểm tra kiểu đầu-cuối (end-to-end) mà không cần boilerplate rườm rà.
  • Khả năng Trực quan hóa Quy trình (Visual Traceability): Workflow được định nghĩa theo phong cách declarative. Bạn có thể export machine trực tiếp sang công cụ trực quan hóa XState Stately để kiểm tra các edge case hoặc mô phỏng kịch bản lỗi trước khi deploy lên production.

Cons

  • Độ dốc Học tập Cao (Steep Learning Curve): Chuyển từ lối tư duy viết script tuần tự dạng async/await mệnh lệnh sang tư duy message-driven với mailbox đòi hỏi thời gian làm quen. Team cần rèn luyện cách tư duy theo event stream và chuyển trạng thái (state transition).
  • Dung lượng Bộ nhớ (Memory Footprint): Một instance actor của XState tiêu tốn khoảng 2.5 KB đến 5 KB bộ nhớ heap. Mặc dù chạy 10,000 actor đồng thời chỉ tốn khoảng ~35 MB RAM, nhưng việc scale lên tới 1,000,000 actor hoạt động bền bỉ (persistent) trong một Node.js runtime duy nhất sẽ cần đến memory profiling. Các async closure thuần túy sẽ ít tốn overhead hơn.
  • Chi phí Quản lý Lưu trữ Trạng thái (Persistence Overhead): Các workflow chạy dài xuyên suốt qua những lần restart server đòi hỏi adapter lưu trữ tùy biến. Bạn phải chụp snapshot context của actor vào storage như Redis hoặc DynamoDB và rehydrate lại state khi hệ thống khởi động lại.

Cấu trúc Khuyên dùng: Khởi tạo Dự án và Kiến trúc

Khi mở rộng quy mô vượt ra ngoài các thao tác CRUD cơ bản để tiếp nhận hàng nghìn event mỗi giây, việc circular import giữa supervisor và child actor có thể nhanh chóng làm tắc nghẽn quá trình phát triển. Tách riêng các machine definition thành các module độc lập sẽ giúp loại bỏ sự phụ thuộc vòng lẫn nhau.

Khởi tạo một dự án TypeScript hiện đại với cấu hình ESM nghiêm ngặt:

mkdir xstate-actor-pipeline
cd xstate-actor-pipeline
npm init -y
npm install xstate@^5.18.0
npm install -D typescript @types/node tsx
npx tsc --init

Cấu hình file tsconfig.json để bắt buộc module resolution hiện đại cùng các kiểm tra an toàn nghiêm ngặt:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

Hướng dẫn Triển khai: Xây dựng Data Ingestion Pipeline Chịu lỗi Cao

Ví dụ dưới đây xây dựng một ingestion pipeline có khả năng chịu lỗi cao. Một pipelineSupervisor gốc sẽ điều phối quá trình xử lý theo batch bằng cách giới hạn concurrency tối đa là hai worker hoạt động đồng thời, sau đó phân phối từng chunk cho các child actor dataWorker.

1. Định nghĩa Worker Actor

Worker chịu trách nhiệm ingest một batch đơn lẻ. Nó mô phỏng network I/O với tỷ lệ lỗi tạm thời (transient failure) là 20%, tự quản lý logic retry tối đa ba lần và gửi kết quả ngược về cho supervisor.

import { setup, fromPromise, sendParent, assign } from 'xstate';

export interface WorkerInput {
  batchId: string;
  items: string[];
}

export type WorkerEvents =
  | { type: 'PROCESS' }
  | { type: 'RETRY' };

const uploadBatchLogic = fromPromise(async ({ input }: { input: { batchId: string; items: string[] } }) => {
  // Mô phỏng network I/O với tỷ lệ lỗi tạm thời 20%
  if (Math.random() < 0.2) {
    throw new Error(`Lỗi kết nối mạng tạm thời trên batch: ${input.batchId}`);
  }
  return { batchId: input.batchId, processedCount: input.items.length };
});

export const workerMachine = setup({
  types: {
    context: {} as { batchId: string; items: string[]; attempts: number },
    input: {} as WorkerInput,
    events: {} as WorkerEvents,
  },
  actors: {
    uploadBatch: uploadBatchLogic,
  },
}).createMachine({
  id: 'dataWorker',
  initial: 'idle',
  context: ({ input }) => ({
    batchId: input.batchId,
    items: input.items,
    attempts: 0,
  }),
  states: {
    idle: {
      on: {
        PROCESS: 'processing',
      },
    },
    processing: {
      entry: assign({
        attempts: ({ context }) => context.attempts + 1,
      }),
      invoke: {
        id: 'uploadTask',
        src: 'uploadBatch',
        input: ({ context }) => ({ batchId: context.batchId, items: context.items }),
        onDone: {
          target: 'completed',
          actions: sendParent(({ event }) => ({
            type: 'WORKER_SUCCESS',
            batchId: event.output.batchId,
            processedCount: event.output.processedCount,
          })),
        },
        onError: {
          target: 'failed',
        },
      },
    },
    failed: {
      always: [
        {
          guard: ({ context }) => context.attempts < 3,
          target: 'processing',
        },
        {
          target: 'aborted',
          actions: sendParent(({ context }) => ({
            type: 'WORKER_FAILED',
            batchId: context.batchId,
          })),
        },
      ],
    },
    completed: { type: 'final' },
    aborted: { type: 'final' },
  },
});

2. Triển khai Supervisor Actor

Supervisor chịu trách nhiệm quản lý hàng đợi. Nó spawn worker theo nhu cầu nhưng luôn giới hạn số lượng worker hoạt động đồng thời ở mức hai, đồng thời cập nhật registry nội bộ khi worker hoàn tất hoặc thất bại.

import { setup, createActor, assign, type ActorRefFrom } from 'xstate';
import { workerMachine } from './worker.js';

type WorkerRef = ActorRefFrom<typeof workerMachine>;

interface SupervisorContext {
  queue: Array<{ batchId: string; items: string[] }>;
  activeWorkers: Record<string, WorkerRef>;
  completedBatches: string[];
  failedBatches: string[];
}

type SupervisorEvents =
  | { type: 'QUEUE_BATCH'; batchId: string; items: string[] }
  | { type: 'WORKER_SUCCESS'; batchId: string; processedCount: number }
  | { type: 'WORKER_FAILED'; batchId: string };

const MAX_CONCURRENT_WORKERS = 2;

export const supervisorMachine = setup({
  types: {
    context: {} as SupervisorContext,
    events: {} as SupervisorEvents,
  },
  actions: {
    spawnNextWorker: assign(({ context, spawn }) => {
      const activeCount = Object.keys(context.activeWorkers).length;
      if (activeCount >= MAX_CONCURRENT_WORKERS || context.queue.length === 0) {
        return {};
      }

      const [nextBatch, ...remainingQueue] = context.queue;
      const child = spawn(workerMachine, {
        id: `worker-${nextBatch.batchId}`,
        input: nextBatch,
      });

      child.send({ type: 'PROCESS' });

      return {
        queue: remainingQueue,
        activeWorkers: {
          ...context.activeWorkers,
          [nextBatch.batchId]: child,
        },
      };
    }),
  },
}).createMachine({
  id: 'supervisor',
  initial: 'active',
  context: {
    queue: [],
    activeWorkers: {},
    completedBatches: [],
    failedBatches: [],
  },
  states: {
    active: {
      on: {
        QUEUE_BATCH: {
          actions: [
            assign({
              queue: ({ context, event }) => [
                ...context.queue,
                { batchId: event.batchId, items: event.items },
              ],
            }),
            'spawnNextWorker',
          ],
        },
        WORKER_SUCCESS: {
          actions: [
            assign({
              completedBatches: ({ context, event }) => [
                ...context.completedBatches,
                event.batchId,
              ],
              activeWorkers: ({ context, event }) => {
                const updated = { ...context.activeWorkers };
                delete updated[event.batchId];
                return updated;
              },
            }),
            'spawnNextWorker',
          ],
        },
        WORKER_FAILED: {
          actions: [
            assign({
              failedBatches: ({ context, event }) => [
                ...context.failedBatches,
                event.batchId,
              ],
              activeWorkers: ({ context, event }) => {
                const updated = { ...context.activeWorkers };
                delete updated[event.batchId];
                return updated;
              },
            }),
            'spawnNextWorker',
          ],
        },
      },
    },
  },
});

3. Khởi chạy và Giám sát Hệ thống

Khởi tạo supervisor actor gốc và dispatch bốn payload thử nghiệm:

import { createActor } from 'xstate';
import { supervisorMachine } from './supervisor.js';

const supervisor = createActor(supervisorMachine);

supervisor.subscribe((snapshot) => {
  const { activeWorkers, completedBatches, failedBatches, queue } = snapshot.context;
  console.log(
    `[Trạng thái] Đang chạy: ${Object.keys(activeWorkers).length} | ` +
    `Đang chờ: ${queue.length} | ` +
    `Hoàn tất: ${completedBatches.length} | ` +
    `Thất bại: ${failedBatches.length}`
  );
});

supervisor.start();

supervisor.send({ type: 'QUEUE_BATCH', batchId: 'b-101', items: ['payloadA', 'payloadB'] });
supervisor.send({ type: 'QUEUE_BATCH', batchId: 'b-102', items: ['payloadC'] });
supervisor.send({ type: 'QUEUE_BATCH', batchId: 'b-103', items: ['payloadD', 'payloadE'] });
supervisor.send({ type: 'QUEUE_BATCH', batchId: 'b-104', items: ['payloadF'] });

Quan sát log hiển thị ở console. Bạn sẽ thấy supervisor tuân thủ nghiêm ngặt giới hạn concurrency đã thiết lập. Hai batch b-101 và b-102 được khởi chạy ngay lập tức, trong khi b-103 và b-104 nằm chờ trong queue. Mỗi worker tự cô lập và xử lý sự cố mạng của riêng mình. Khi một worker hoàn thành, supervisor tự động rút tiếp batch từ queue để xử lý—toàn bộ quy trình diễn ra trơn tru mà không cần dùng đến bất kỳ mutex hay biến toàn cục (global variable) nào.

Share: