Web Totals

Học từ Facebook #88: Store optimistic, rollback rồi áp lại

Relay đặt update optimistic trên một lớp phủ copy-on-write. Có dữ liệu server mới thì bỏ lớp phủ, ghi vào base, rồi áp lại mọi update còn sống.

hunghg255hunghg25527 min read
#facebook#relay#optimistic-ui#state-management#graphql
Học từ Facebook #88: Store optimistic, rollback rồi áp lại

Bấm "Thích" thì trái tim đỏ ngay, số like tăng ngay, request đi sau. Đó là optimistic update (cập nhật lạc quan). Cái khó không nằm ở lúc áp mà ở lúc gỡ: request thất bại thì phải quay lại trạng thái cũ. Nhưng "trạng thái cũ" là lúc nào? Trong 800 ms chờ server, có thể đã có ba người khác like (qua push realtime), một bình luận mới tới, và chính bạn đã bấm thêm hai thao tác optimistic khác.

Cách phổ biến là chụp lại state trước khi áp rồi khôi phục khi lỗi. Cách này làm mất mọi thứ xảy ra sau lúc chụp. Relay dùng một cách khác: dữ liệu server và dữ liệu optimistic nằm ở hai lớp riêng. Bài này đọc ba module trong store của Relay và viết lại cơ chế đó.

Tip

Tóm tắt

  • Dữ liệu server nằm ở base. Khi có update optimistic, Relay đặt một lớp phủ (overlay) lên trên. Mọi update optimistic chỉ ghi vào lớp phủ. Xoá một record ở lớp phủ thì ghi một sentinel để che bản ở dưới.
  • Updater không ghi thẳng vào store mà vào một sổ nháp (sink): đọc thì xem sổ nháp trước, rồi tới nguồn. Lần đầu ghi một record thì tạo bản sao chỉ chứa field bị ghi (copy-on-write).
  • Có payload server, hoặc vừa gỡ một update đã áp: bỏ cả lớp phủ, ghi payload vào base, rồi áp lại mọi update còn sống (rebase). Không ai phải viết hàm "hoàn tác".
  • Quy tắc mang về: updater optimistic phải là hàm thuần của state hiện tại (likes + 1, không phải likes = 11), vì nó sẽ được chạy lại nhiều lần.

#Facebook làm thế nào?

Ba module trong 99 ChatComposer/all.js (cũng có trong 31.js, giống hệt): relay-runtime/store/RelayOptimisticRecordSource, relay-runtime/mutations/RelayRecordSourceMutator và relay-runtime/store/RelayPublishQueue. Relay là mã nguồn mở (MIT). Đoạn trích dưới đây đã đổi tên biến cho dễ đọc.

#Lớp phủ và sentinel xoá

const UNPUBLISH_RECORD_SENTINEL = Object.freeze({ __UNPUBLISH_RECORD_SENTINEL: true });

class RelayOptimisticRecordSource {
  constructor(base) { this._base = base; this._sink = RelayRecordSource.create(); }
  get(id) {
    if (this._sink.has(id)) {
      const r = this._sink.get(id);
      return r === UNPUBLISH_RECORD_SENTINEL ? undefined : r;
    }
    return this._base.get(id);
  }
  remove(id) { this._sink.set(id, UNPUBLISH_RECORD_SENTINEL); } // che bản ở base
  delete(id) { this._sink.delete(id); }                         // bỏ lớp phủ, lộ lại base
}
js

Có ba trạng thái khác nhau cho một ID trong lớp phủ: không có mục nào (đọc xuống base), có record (đè lên base), và sentinel (record coi như chưa từng có, trạng thái UNKNOWN). Thêm vào đó, giá trị null trong record source của Relay nghĩa là "biết chắc không tồn tại" (NONEXISTENT). Phân biệt remove và delete là điểm tinh tế: một cái thêm thông tin "đã xoá", cái kia gỡ thông tin.

RelayModernStore.snapshot() tạo lớp phủ, restore() vứt nó đi:

snapshot() { /* … */ this._optimisticSource = RelayOptimisticRecordSource.create(this.getSource()); }
restore()  { /* … */ this._optimisticSource = null; }
publish(source) {
  const target = this._optimisticSource ?? this._recordSource; // có lớp phủ thì ghi vào lớp phủ
  updateTargetFromSource(target, source, /* … */);
}
js

#Sổ nháp copy-on-write

RelayRecordSourceMutator nhận hai nguồn: base (chỉ đọc) và sink (sổ nháp). Đọc một field thì duyệt [sink, base]:

getValue(id, field) {
  for (const source of [this._sink, this._base]) {
    const record = source.get(id);
    if (record) {
      const v = RelayModernRecord.getValue(record, field);
      if (v !== undefined) return v;
    } else if (record === null) return null; // null ở lớp trên chặn lớp dưới
  }
}
setValue(id, field, value) {
  const draft = this._getSinkRecord(id); // lần đầu: tạo record rỗng cùng kiểu trong sink
  RelayModernRecord.setValue(draft, field, value);
}
js

_getSinkRecord (tên gốc $3) chỉ tạo một record rỗng với cùng ID và kiểu. Không chép field nào cả. Khi publish, record trong sink được trộn vào record ở đích. Kết quả: một updater chỉ đổi likes thì sổ nháp chỉ chứa {likes}, và mọi record khác không bị đụng tới.

#Hàng đợi publish và rebase

RelayPublishQueue giữ ba tập: pendingData (payload server và commitUpdate chờ ghi), pendingOptimisticUpdates (update optimistic mới), appliedOptimisticUpdates (đang sống trên lớp phủ). Thêm một cờ pendingBackupRebase. Hàm run():

run(sourceOperation) {
  if (this._pendingBackupRebase && this._hasStoreSnapshot) {
    this._store.restore();                // 1. bỏ lớp phủ
    this._hasStoreSnapshot = false;
  }
  const invalidated = this._commitData(); // 2. ghi dữ liệu server vào base
  if (this._pendingOptimisticUpdates.size ||
      (this._pendingBackupRebase && this._appliedOptimisticUpdates.size)) {
    if (!this._hasStoreSnapshot) { this._store.snapshot(); this._hasStoreSnapshot = true; }
    this._applyUpdates();                 // 3. áp lại update cũ + update mới lên lớp phủ mới
  }
  this._pendingBackupRebase = false;
  if (this._appliedOptimisticUpdates.size > 0) {
    this._gcHold ??= this._store.holdGC(); // còn optimistic thì không cho GC dọn record
  } else { this._gcHold?.dispose(); this._gcHold = null; }
  return this._store.notify(sourceOperation, invalidated);
}
js

Cờ pendingBackupRebase bật khi commitPayload, commitUpdate, commitSource, revertAll, hoặc khi revertUpdate gỡ một update đã áp. Gỡ một update chưa áp thì chỉ cần bỏ khỏi pendingOptimisticUpdates, không cần rebase.

Không có hàm "hoàn tác" nào. Muốn gỡ update A trong ba update A, B, C: vứt lớp phủ, dựng lớp phủ mới, áp B và C. Đó là ý tưởng giống git rebase: base thay đổi thì phát lại các commit của mình lên trên.

Hai chi tiết nữa:

  • holdGC(): khi còn update optimistic, store không được thu gom record. Có lẽ vì update optimistic có thể trỏ tới record mà không query nào đang giữ, và GC dọn mất thì lần áp lại sẽ thiếu dữ liệu.
  • ErrorUtils.applyWithGuard: mỗi updater (cả optimistic lẫn commitUpdate) chạy trong guard. Updater ném lỗi thì lỗi được báo lên, hàng đợi vẫn chạy tiếp.

#Một điểm cần biết: updater lỗi để lại nửa chừng

Đọc code _applyUpdates (tên gốc $12) thấy mọi update optimistic dùng chung một sổ nháp và chung một RelayRecordSourceProxy:

const sink = RelayRecordSource.create();
const mutator = new RelayRecordSourceMutator(this._store.getSource(), sink);
const proxy = new RelayRecordSourceProxy(mutator, /* … */);
this._appliedOptimisticUpdates.forEach((u) => applyWithGuard(u.storeUpdater, null, [proxy], /* … */));
this._store.publish(sink);
js

Guard bắt lỗi nhưng không gỡ những gì updater đã ghi vào sink trước khi ném. Updater ghi likes = 9999 rồi ném lỗi thì 9999 vẫn được publish. Mình chưa dựng lại được toàn bộ store của Relay để chạy thử ca này, nên đây là kết luận từ việc đọc code.

#Bên trong: dòng thời gian của một lần rebase

sequenceDiagram
  participant U as Người dùng
  participant Q as PublishQueue
  participant O as Lớp phủ
  participant B as Base
  U->>Q: applyUpdate "thích": likes + 1
  Q->>O: snapshot, áp "thích" ⇒ hiện 11
  Note over B: base vẫn là 10
  B-->>Q: push từ server: likes = 13
  Q->>O: restore, bỏ lớp phủ
  Q->>B: ghi likes = 13
  Q->>O: snapshot mới, áp lại "thích" ⇒ hiện 14
  U->>Q: server từ chối "thích"
  Q->>O: restore, không còn update nào
  Note over B: hiện 13, đúng bằng server

So với cách chụp state: khi server từ chối, bản chụp có likes = 10 (trước khi push tới), và khôi phục bản chụp làm UI hiện 10 trong khi server đang là 13. Đó chính là ca demo ở trên: bấm "Thích", "Server push", rồi "Server lỗi" ở dòng "thích".

#Viết lại

File src/lib/optimisticStore.ts. Lớp phủ với sentinel là một Symbol thay vì object đóng băng:

src/lib/optimisticStore.ts
export class OverlaySource implements ReadableSource {
  readonly layer = new Map<string, StoreRecord | null | typeof REMOVED>();
  constructor(readonly base: ReadableSource) {}
  get(id: string): RecordValue {
    if (this.layer.has(id)) {
      const v = this.layer.get(id)!;
      return v === REMOVED ? undefined : v;
    }
    return this.base.get(id);
  }
  set(id: string, record: StoreRecord | null) {
    this.layer.set(id, record);
  }
  /** Xoá record ở lớp phủ: đặt sentinel để che bản ở base. */
  remove(id: string) {
    this.layer.set(id, REMOVED);
  }
  ids() {
    const out = new Set(this.base.ids());
    this.layer.forEach((v, id) => (v === REMOVED ? out.delete(id) : out.add(id)));
    return [...out];
  }
}
ts

Sổ nháp: đọc qua [sink, source], ghi lần đầu thì tạo bản nháp chỉ có id:

src/lib/optimisticStore.ts
/** Sổ nháp của một updater: chỉ chứa record bị sửa, đọc thì xem sổ nháp trước rồi tới nguồn. */
export class Mutator {
  readonly sink = new Map<string, Record<string, unknown> | null>();
  constructor(private source: ReadableSource) {}

  get(id: string, field: string): unknown {
    for (const layer of [this.sink, this.source]) {
      const rec = layer.get(id);
      if (rec === null) return null; // record bị xoá ở lớp trên che lớp dưới
      if (rec && field in rec) return rec[field];
    }
    return undefined;
  }
  exists(id: string) {
    const s = this.sink.get(id);
    if (s !== undefined) return s !== null;
    return this.source.get(id) != null;
  }
  set(id: string, field: string, value: unknown) {
    let draft = this.sink.get(id);
    if (draft == null) {
      if (draft === null || this.source.get(id) == null)
        throw new Error(`Record ${id} không tồn tại`);
      draft = { id }; // copy-on-write: chỉ chép field nào bị ghi
      this.sink.set(id, draft);
    }
    draft[field] = value;
  }
  create(id: string, fields: Record<string, unknown> = {}) {
    if (this.exists(id)) throw new Error(`Record ${id} đã tồn tại`);
    this.sink.set(id, { ...fields, id });
  }
  remove(id: string) {
    this.sink.set(id, null);
  }
}
ts

Hàng đợi: ba bước của run(), mỗi updater có sổ nháp riêng:

src/lib/optimisticStore.ts
  run(): RunStats {
    const stats: RunStats = { committed: 0, reapplied: 0, applied: 0, rebased: false, errors: [] };
    const guard = (label: string, fn: Updater, source: ReadableSource) => {
      const m = new Mutator(source);
      try {
        fn(m);
        return m.sink;
      } catch (e) {
        stats.errors.push(label);
        this.onError(label, e);
        return null; // updater lỗi: bỏ cả sổ nháp, không để ghi dở
      }
    };

    // 1. Có dữ liệu server mới hoặc vừa gỡ một update đã áp: bỏ lớp phủ
    if (this.needsRebase && this.store.overlay) {
      this.store.restore();
      stats.rebased = true;
    }
    // 2. Ghi dữ liệu server vào base
    for (const updater of this.pendingData) {
      const sink = guard('commit', updater, this.store.base);
      if (sink) this.store.publish(sink);
      stats.committed++;
    }
    this.pendingData = [];
    // 3. Dựng lại lớp phủ: áp lại update cũ (nếu vừa bỏ lớp phủ) rồi update mới
    if (this.pendingOptimistic.size || (stats.rebased && this.applied.size)) {
      if (!this.store.overlay) this.store.snapshot();
      if (stats.rebased) {
        this.applied.forEach((u) => {
          const sink = guard(u.label, u.apply, this.store.source());
          if (sink) this.store.publish(sink);
          stats.reapplied++;
        });
      }
      this.pendingOptimistic.forEach((u) => {
        const sink = guard(u.label, u.apply, this.store.source());
        if (sink) this.store.publish(sink);
        this.applied.add(u);
        stats.applied++;
      });
      this.pendingOptimistic.clear();
    }
    this.needsRebase = false;
    this.store.notify();
    return stats;
  }
ts
Xem toàn bộ src/lib/optimisticStore.ts
src/lib/optimisticStore.ts
/**
 * Store optimistic theo lớp, ý tưởng từ RelayOptimisticRecordSource + RelayRecordSourceMutator +
 * RelayPublishQueue (viết lại từ đầu, rút gọn):
 * - dữ liệu server nằm ở `base`; khi có update optimistic, một lớp phủ (overlay) được đặt lên trên,
 * - update ghi vào một "sổ nháp" (sink) theo kiểu copy-on-write rồi mới publish vào lớp đang hoạt động,
 * - có dữ liệu server mới: bỏ lớp phủ, ghi vào base, rồi áp lại mọi update optimistic còn sống (rebase).
 */

export type StoreRecord = { readonly id: string; readonly [field: string]: unknown };
/** null = biết chắc record không tồn tại; undefined = không biết. */
export type RecordValue = StoreRecord | null | undefined;

export interface ReadableSource {
  get(id: string): RecordValue;
  ids(): string[];
}

/** Đánh dấu "record đã bị xoá ở lớp này", khác với "lớp này không đụng tới record". */
export const REMOVED: unique symbol = Symbol('removed');

export class BaseSource implements ReadableSource {
  private map = new Map<string, StoreRecord | null>();
  constructor(initial: StoreRecord[] = []) {
    initial.forEach((r) => this.map.set(r.id, Object.freeze({ ...r })));
  }
  get(id: string): RecordValue {
    return this.map.get(id);
  }
  set(id: string, record: StoreRecord | null) {
    this.map.set(id, record);
  }
  ids() {
    return [...this.map.keys()];
  }
}

export class OverlaySource implements ReadableSource {
  readonly layer = new Map<string, StoreRecord | null | typeof REMOVED>();
  constructor(readonly base: ReadableSource) {}
  get(id: string): RecordValue {
    if (this.layer.has(id)) {
      const v = this.layer.get(id)!;
      return v === REMOVED ? undefined : v;
    }
    return this.base.get(id);
  }
  set(id: string, record: StoreRecord | null) {
    this.layer.set(id, record);
  }
  /** Xoá record ở lớp phủ: đặt sentinel để che bản ở base. */
  remove(id: string) {
    this.layer.set(id, REMOVED);
  }
  ids() {
    const out = new Set(this.base.ids());
    this.layer.forEach((v, id) => (v === REMOVED ? out.delete(id) : out.add(id)));
    return [...out];
  }
}

/** Sổ nháp của một updater: chỉ chứa record bị sửa, đọc thì xem sổ nháp trước rồi tới nguồn. */
export class Mutator {
  readonly sink = new Map<string, Record<string, unknown> | null>();
  constructor(private source: ReadableSource) {}

  get(id: string, field: string): unknown {
    for (const layer of [this.sink, this.source]) {
      const rec = layer.get(id);
      if (rec === null) return null; // record bị xoá ở lớp trên che lớp dưới
      if (rec && field in rec) return rec[field];
    }
    return undefined;
  }
  exists(id: string) {
    const s = this.sink.get(id);
    if (s !== undefined) return s !== null;
    return this.source.get(id) != null;
  }
  set(id: string, field: string, value: unknown) {
    let draft = this.sink.get(id);
    if (draft == null) {
      if (draft === null || this.source.get(id) == null)
        throw new Error(`Record ${id} không tồn tại`);
      draft = { id }; // copy-on-write: chỉ chép field nào bị ghi
      this.sink.set(id, draft);
    }
    draft[field] = value;
  }
  create(id: string, fields: Record<string, unknown> = {}) {
    if (this.exists(id)) throw new Error(`Record ${id} đã tồn tại`);
    this.sink.set(id, { ...fields, id });
  }
  remove(id: string) {
    this.sink.set(id, null);
  }
}

export type Updater = (m: Mutator) => void;
export interface OptimisticUpdate {
  readonly label: string;
  readonly apply: Updater;
}

export class LayeredStore {
  readonly base: BaseSource;
  overlay: OverlaySource | null = null;
  private listeners = new Set<() => void>();
  version = 0;

  constructor(initial: StoreRecord[]) {
    this.base = new BaseSource(initial);
  }
  source(): ReadableSource {
    return this.overlay ?? this.base;
  }
  snapshot() {
    if (this.overlay) throw new Error('Đã có snapshot');
    this.overlay = new OverlaySource(this.base);
  }
  restore() {
    this.overlay = null;
  }
  /** Ghi sổ nháp vào lớp đang hoạt động; mỗi record bị sửa là một object mới (bất biến). */
  publish(sink: Mutator['sink']) {
    const target = this.overlay ?? this.base;
    sink.forEach((draft, id) => {
      if (draft === null) {
        if (target instanceof OverlaySource) target.remove(id);
        else target.set(id, null);
        return;
      }
      const current = target.get(id);
      target.set(id, Object.freeze({ ...(current ?? {}), ...draft, id }));
    });
  }
  subscribe(fn: () => void) {
    this.listeners.add(fn);
    return () => void this.listeners.delete(fn);
  }
  notify() {
    this.version++;
    this.listeners.forEach((fn) => fn());
  }
}

export interface RunStats {
  committed: number;
  reapplied: number;
  applied: number;
  rebased: boolean;
  errors: string[];
}

export class PublishQueue {
  private pendingData: Updater[] = [];
  private pendingOptimistic = new Set<OptimisticUpdate>();
  private applied = new Set<OptimisticUpdate>();
  private needsRebase = false;
  onError: (label: string, error: unknown) => void = () => {};

  constructor(private store: LayeredStore) {}

  get appliedUpdates(): OptimisticUpdate[] {
    return [...this.applied, ...this.pendingOptimistic];
  }

  applyUpdate(update: OptimisticUpdate) {
    if (this.applied.has(update) || this.pendingOptimistic.has(update))
      throw new Error('Update đã được áp');
    this.pendingOptimistic.add(update);
  }
  revertUpdate(update: OptimisticUpdate) {
    if (this.pendingOptimistic.delete(update)) return;
    if (this.applied.delete(update)) this.needsRebase = true;
  }
  revertAll() {
    this.pendingOptimistic.clear();
    if (this.applied.size) this.needsRebase = true;
    this.applied.clear();
  }
  /** Dữ liệu thật từ server (hoặc commitUpdate): luôn ghi vào base, nên phải rebase. */
  commit(updater: Updater) {
    this.pendingData.push(updater);
    this.needsRebase = true;
  }

  run(): RunStats {
    const stats: RunStats = { committed: 0, reapplied: 0, applied: 0, rebased: false, errors: [] };
    const guard = (label: string, fn: Updater, source: ReadableSource) => {
      const m = new Mutator(source);
      try {
        fn(m);
        return m.sink;
      } catch (e) {
        stats.errors.push(label);
        this.onError(label, e);
        return null; // updater lỗi: bỏ cả sổ nháp, không để ghi dở
      }
    };

    // 1. Có dữ liệu server mới hoặc vừa gỡ một update đã áp: bỏ lớp phủ
    if (this.needsRebase && this.store.overlay) {
      this.store.restore();
      stats.rebased = true;
    }
    // 2. Ghi dữ liệu server vào base
    for (const updater of this.pendingData) {
      const sink = guard('commit', updater, this.store.base);
      if (sink) this.store.publish(sink);
      stats.committed++;
    }
    this.pendingData = [];
    // 3. Dựng lại lớp phủ: áp lại update cũ (nếu vừa bỏ lớp phủ) rồi update mới
    if (this.pendingOptimistic.size || (stats.rebased && this.applied.size)) {
      if (!this.store.overlay) this.store.snapshot();
      if (stats.rebased) {
        this.applied.forEach((u) => {
          const sink = guard(u.label, u.apply, this.store.source());
          if (sink) this.store.publish(sink);
          stats.reapplied++;
        });
      }
      this.pendingOptimistic.forEach((u) => {
        const sink = guard(u.label, u.apply, this.store.source());
        if (sink) this.store.publish(sink);
        this.applied.add(u);
        stats.applied++;
      });
      this.pendingOptimistic.clear();
    }
    this.needsRebase = false;
    this.store.notify();
    return stats;
  }
}
ts

Khác bản gốc:

  • Mỗi updater có sổ nháp riêng, chỉ được publish khi updater chạy xong không lỗi. Updater ném lỗi thì bỏ cả sổ nháp, không còn ghi nửa chừng.
  • Record trong store là object đóng băng. publish tạo object mới cho record bị sửa, nên tham chiếu cũ không bao giờ đổi dưới chân component (kết hợp tốt với bài #87).
  • Không có GC, không có fieldPayloads/handler, không có selector proxy. Updater nhận một Mutator với get/set/create/remove.
  • run() trả về số payload đã ghi, số update áp lại, có rebase hay không, để demo hiển thị.

#Đi qua code

  • OverlaySource.get: có mục trong layer thì dùng mục đó (sentinel đổi thành undefined), không thì đọc base. ids() cộng ID của base với lớp phủ rồi trừ các ID mang sentinel.
  • Mutator.get duyệt hai lớp. Gặp null (record bị xoá ở lớp trên) thì trả null ngay, không đọc xuống lớp dưới. Bản nháp chỉ có vài field, nên field in rec sai thì đi xuống lớp dưới đọc tiếp.
  • Mutator.set kiểm tra record có tồn tại trước khi tạo bản nháp. Ghi vào record đã bị remove trong cùng updater là lỗi.
  • LayeredStore.publish ghi vào lớp phủ nếu có, không thì vào base. Trộn {...current, ...draft} rồi Object.freeze.
  • PublishQueue.run: bước 1 chỉ chạy khi có lớp phủ, vì không có lớp phủ thì không có gì để bỏ. Bước 3 dựng lại lớp phủ khi có update mới, hoặc khi vừa bỏ lớp phủ mà vẫn còn update đang sống.
  • revertUpdate với update còn ở pendingOptimistic (chưa run) chỉ xoá nó khỏi tập, không đánh dấu rebase.

#Tại sao không làm cách khác?

  • Chụp state rồi khôi phục: mất dữ liệu đến sau lúc chụp, như demo cho thấy. Muốn đúng phải tự "trộn" bản chụp với dữ liệu mới, mà việc đó chính là rebase.
  • Viết hàm hoàn tác cho từng thao tác (like thì unlike): mỗi thao tác cần hai hàm, và hàm hoàn tác chạy trên state đã đổi nên dễ sai (trừ 1 like khi server đã đặt lại số like). Với thao tác thêm/xoá phần tử ở giữa danh sách, hoàn tác đúng rất khó.
  • Giữ danh sách update rồi tính lại toàn bộ state từ base mỗi lần render (kiểu useOptimistic của React): đúng về ngữ nghĩa và đơn giản. Relay làm cùng việc nhưng chỉ tính lại khi có thay đổi, và kết quả nằm trong store chung để mọi component đọc.
  • Ghi optimistic thẳng vào base và đánh dấu record "chưa xác nhận": đơn giản khi chỉ có một update mỗi record. Hai update chồng lên cùng một record thì không biết gỡ cái nào.

#Edge case và cạm bẫy

  • Updater phải thuần: likes = likes + 1 đúng, likes = 11 sai, vì lần áp lại base đã là 13. Updater cũng không được gọi API hay đọc Date.now() nếu kết quả phải ổn định giữa các lần áp lại.
  • Update optimistic sau khi server đã xác nhận: payload thật thường đã chứa thay đổi. Trong demo, updater "thích" kiểm tra liked trước, nên áp lại trên base đã liked = true không cộng thêm lần nữa. Relay làm việc này bằng cách gỡ update optimistic và commit payload thật trong cùng một run() (trong RelayModernQueryExecutor, hàm xử lý payload gọi revertUpdate cho mọi update optimistic của operation rồi mới commitPayload).
  • Chi phí rebase: mỗi payload server làm mọi update đang sống chạy lại. Có 50 tin nhắn đang gửi trong một cuộc chat chậm thì mỗi tin nhắn đến làm 50 updater chạy lại. Thường vẫn rẻ, nhưng đừng để updater làm việc nặng.
  • ID tạm: record tạo optimistic dùng ID client:.... Server trả về ID thật thì record tạm biến mất cùng lớp phủ, và component dùng key={id} sẽ unmount rồi mount lại. Muốn mượt thì giữ một khoá ổn định riêng (Messenger dùng offline_threading_id sinh ở client, xem bài #39).
  • Updater lỗi được giữ lại: update có updater ném lỗi vẫn nằm trong tập đang sống, và mỗi lần rebase lại ném lỗi. Nên gỡ nó khi request tương ứng kết thúc.

#Dùng trong dự án

import { LayeredStore, PublishQueue } from './lib/optimisticStore';

const store = new LayeredStore(initialRecords);
const queue = new PublishQueue(store);

async function like(postId: string) {
  const update = {
    label: 'like',
    apply: (m) => {
      if (m.get(postId, 'liked')) return;
      m.set(postId, 'liked', true);
      m.set(postId, 'likes', (m.get(postId, 'likes') as number) + 1);
    },
  };
  queue.applyUpdate(update);
  queue.run();
  try {
    const real = await api.like(postId);
    queue.revertUpdate(update);
    queue.commit((m) => m.set(postId, 'likes', real.likes)); // gỡ optimistic + ghi thật trong cùng một run
    queue.commit((m) => m.set(postId, 'liked', true));
  } catch {
    queue.revertUpdate(update);
  }
  queue.run();
}

// realtime push ở bất kỳ lúc nào
socket.on('post_likes', ({ id, likes }) => {
  queue.commit((m) => m.set(id, 'likes', likes));
  queue.run();
});
ts

Đọc trong React bằng useSyncExternalStore(store.subscribe, () => store.version) rồi lấy dữ liệu qua store.source().get(id).

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it, vi } from 'vitest';
import { LayeredStore, PublishQueue, type Mutator } from '../src/lib/optimisticStore';

const setup = () => {
  const store = new LayeredStore([
    { id: 'p', likes: 10, liked: false, commentIds: ['c1'] },
    { id: 'c1', text: 'hi' },
  ]);
  return { store, queue: new PublishQueue(store) };
};
const like = {
  label: 'like',
  apply: (m: Mutator) => {
    m.set('p', 'liked', true);
    m.set('p', 'likes', (m.get('p', 'likes') as number) + 1);
  },
};

describe('PublishQueue', () => {
  it('update optimistic chỉ nằm ở lớp phủ', () => {
    const { store, queue } = setup();
    queue.applyUpdate(like);
    queue.run();
    expect(store.source().get('p')!.likes).toBe(11);
    expect(store.base.get('p')!.likes).toBe(10);
  });

  it('dữ liệu server mới thì rebase update đang sống', () => {
    const { store, queue } = setup();
    queue.applyUpdate(like);
    queue.run();
    queue.commit((m) => m.set('p', 'likes', 15));
    const stats = queue.run();
    expect(stats).toMatchObject({ rebased: true, reapplied: 1 });
    expect(store.source().get('p')!.likes).toBe(16);
  });

  it('gỡ update thì giữ dữ liệu server đến sau', () => {
    const { store, queue } = setup();
    queue.applyUpdate(like);
    queue.run();
    queue.commit((m) => m.set('p', 'likes', 13));
    queue.run();
    queue.revertUpdate(like);
    queue.run();
    expect(store.source().get('p')!.likes).toBe(13);
    expect(store.overlay).toBeNull();
  });

  it('xoá ở lớp phủ dùng sentinel, base không đổi', () => {
    const { store, queue } = setup();
    queue.applyUpdate({ label: 'del', apply: (m) => m.remove('c1') });
    queue.run();
    expect(store.source().get('c1')).toBeUndefined();
    expect(store.source().ids()).not.toContain('c1');
    expect(store.base.get('c1')).toBeDefined();
  });

  it('updater lỗi không để lại ghi dở', () => {
    const { store, queue } = setup();
    const onError = vi.fn();
    queue.onError = onError;
    queue.applyUpdate({
      label: 'bad',
      apply: (m) => {
        m.set('p', 'likes', 9999);
        throw new Error('x');
      },
    });
    expect(queue.run().errors).toEqual(['bad']);
    expect(store.source().get('p')!.likes).toBe(10);
    expect(onError).toHaveBeenCalledOnce();
  });

  it('record bị sửa là object mới, record khác giữ tham chiếu', () => {
    const { store, queue } = setup();
    const c1 = store.source().get('c1');
    queue.commit((m) => m.set('p', 'likes', 11));
    queue.run();
    expect(store.source().get('c1')).toBe(c1);
  });
});
ts

#So với thư viện và chuẩn web

  • React useOptimistic (React 19): giữ danh sách action optimistic trong một transition và tính lại state từ giá trị thật mỗi lần render. Khi transition kết thúc, mọi action optimistic của nó biến mất. Cùng tinh thần "áp lại lên base mới", nhưng phạm vi là một component.
  • Apollo Client đặt kết quả optimistic của mỗi mutation vào một lớp riêng trong InMemoryCache (các Layer xếp chồng lên root). Gỡ một lớp thì cache được tính lại từ các lớp còn lại, gần giống Relay.
  • TanStack Query: onMutate chụp dữ liệu cũ, onError khôi phục. Đây là cách "chụp rồi khôi phục" với đúng nhược điểm ở trên, và tài liệu của họ khuyên gọi cancelQueries trước để tránh refetch chen vào. Bản v5 có thêm cách hiển thị optimistic qua variables của mutation đang chạy, không đụng cache.
  • Replicache, Zero, các thư viện local-first: dùng đúng mô hình rebase: mutation chờ xác nhận được phát lại trên snapshot mới nhất từ server.
  • Bài #48 (snapshot và push) nói về chuyện ngược lại: push đến trước snapshot.

Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).

Xem nhanh danh sách bài trong series

Phần 1

Phần 2

Phần 3

Phần 4–6

Phần 7–10

Phần 11–15

Code + demo: examples/facebook-patterns · Mở trên StackBlitz