Bỏ qua, tới nội dung
Kỹ thuật
8 phút đọc

Lưu không bao giờ phải chờ LLM: AI tagging bất đồng bộ và extension MV3

Nút lưu một chạm cho creator: worker BullMQ gắn tag ở nền có retry, cùng extension Manifest V3 với adapter theo trang và hàng đợi lưu offline.

Dao Van Thuong
Mobile & Fullstack Engineer
Read in English
Ảnh bìa: nút lưu trả về ngay trong khi job AI tagging chạy ở nền
Trong bài này
  1. Đường lưu: commit trước, enqueue sau
  2. Worker
  3. Trạng thái nói thật
  4. Khi model trả lời không đúng thứ bạn hỏi
  5. Extension: Manifest V3 có lịch riêng của nó
  6. Adapter, và những trang tự khai sai về mình
  7. Hàng đợi offline
  8. Đăng nhập
  9. Lần sau mình vẫn sẽ làm

Wavesgroup CIP là công cụ nghiên cứu nội dung cho creator. Thấy một video, một bài post hay một bài viết đáng giữ, bạn bấm một cái, và nó nằm trong thư viện, gắn tag sẵn. Mình làm một mình cả ba phần: browser extension, web app và API.

Cả sản phẩm sống nhờ cảm giác cú bấm đó là tức thì. Nhưng phần gắn tag lại do một LLM làm, thường mất vài giây, thỉnh thoảng lỗi hoặc trả về thứ gì đó kỳ quặc. Nhét hai sự thật đó vào cùng một request thì cú bấm gánh luôn mọi lần LLM chậm và mọi lần LLM lỗi. Nên quy tắc đầu tiên được ghi vào kiến trúc là: lưu không bao giờ chờ model. Lưu thì commit, trả về, còn việc gắn tag diễn ra sau đó, ở một chỗ được phép chậm, được phép lỗi, và được thử lại.

Bài này kể cách chia đôi đó vận hành, những gì đã hỏng bên trong nó, và phía browser extension, nơi Manifest V3 có ý kiến riêng về việc khi nào code của bạn được chạy.

Đường lưu: commit trước, enqueue sau

Đường lưu: extension tới Hasura action tới NestJS, một transaction ghi item và job tagging, event sau commit mới enqueue job BullMQ
Cú bấm kết thúc ở commit. Mọi thứ sau đó là việc của chỗ khác.

Extension gọi mutation saveItem. Hasura chuyển nó dưới dạng action đồng bộ, timeout 10 giây, tới một command handler NestJS. Handler làm ba việc theo đúng thứ tự:

  1. Validate trước. Trang capture được kiểm tra trước khi ghi bất cứ thứ gì, nên một lần capture không hợp lệ không để lại side effect nào.
  2. Một transaction. Nó insert item đã lưu và một dòng trong ai_tagging_jobs cùng lúc. Lưu cùng một trang hai lần không tạo bản trùng: một partial unique index trên (user_id, url) cho các trang còn sống biến lệnh insert thành ON CONFLICT trả về dòng đã có.
  3. Publish sau commit. Chỉ khi transaction đã commit thì nó mới publish event ItemSaved. Comment trong code giải thích lý do: một worker nhanh không bao giờ được thấy id của item trong queue trước khi dòng đó được commit chắc chắn.

Event handler là chỗ duy nhất đụng tới queue:

ts
@EventsHandler(ItemSavedEvent)
class EnqueueAiTagging {
  async handle({ itemId, isNewInsert }: ItemSavedEvent) {
    if (!isNewInsert) return;              // lưu lại không gắn tag lại
    try {
      await this.queue.add('ai-tag', { itemId });
    } catch (err) {
      this.log.error('enqueue failed', err); // không bao giờ làm hỏng lần lưu
    }
  }
}

Redis chết thì lưu vẫn thành công. Dòng job tagging vẫn tồn tại với trạng thái PENDING, và user có thể retry sau. Đó là cái giá mà ADR chấp nhận khi chọn BullMQ thay vì Hasura event trigger: đúng convention của team và kiểm soát tốt hơn, đổi lại Redis thành phụ thuộc bắt buộc. Giữ state trong một bảng Postgres chứ không phải trong queue là thứ làm cái giá đó chịu được. Queue chuyển việc đi, còn bảng cho biết việc đang ở đâu.

Worker

Queue ai-tag chạy với ba lần thử, exponential backoff bắt đầu từ 5 giây, giữ lại 100 job xong và 500 job lỗi gần nhất để soi, và timeout 20 giây cho lời gọi model. Mỗi job chạy một pipeline sáu bước:

  1. Load item.
  2. Load bộ tag sẵn có của user.
  3. Hỏi model xin tag.
  4. Chuẩn hoá chúng theo cách tất định.
  5. So từng tag với tag của user: trước hết khớp chính xác trên một key đã chuẩn hoá, sau đó độ tương đồng pg_trgm từ 0,75 trở lên, và chỉ khi không khớp mới tạo tag mới.
  6. Ghi xuống, bỏ qua những tag mà user từng xoá khỏi item đó.

Bước 5 là thứ giữ cho thư viện dùng được. Không có nó thì "UI design", "ui-design" và "Thiết kế UI" thành ba tag khác nhau.

Bước 6 sinh ra từ một bug. Retry một job làm sống lại những tag user đã cố tình xoá, vì worker không nhớ gì về lần xoá đó. Giờ nó kiểm tra audit log xem tag nào đã bị gỡ khỏi item và bỏ chúng ra.

Idempotency nằm trong database, không nằm ở job id của BullMQ. ai_tagging_jobs.item_id là unique, nên mỗi item chỉ có một dòng job, còn liên kết item-tag có khoá chính kép với ON CONFLICT DO NOTHING. Một job chạy hai lần thì cũng chỉ ghi đúng một bộ tag.

Trạng thái nói thật

Dòng job đi qua PENDING, PROCESSING, SUCCEEDED và FAILED. Chỗ tinh tế là lúc nào thì ghi FAILED:

ts
} catch (err) {
  const final = job.attemptsMade + 1 >= (job.opts.attempts ?? 1);
  await this.jobs.update(itemId, {
    status: final ? 'FAILED' : 'PENDING',
    lastError: String(err).slice(0, 1000),
    attemptCount: () => 'attempt_count + 1',
  });
  throw err; // để BullMQ lên lịch retry
}

Ghi FAILED ngay lần lỗi đầu tiên thì user sẽ thấy nút retry cho một việc vẫn đang chạy. Nên chỉ lần thử cuối mới đánh dấu job thất bại. Action retry thủ công kiểm tra quyền sở hữu, từ chối mọi thứ không phải FAILED, đặt lại về PENDING rồi enqueue lần nữa.

Khi model trả lời không đúng thứ bạn hỏi

Tài liệu kiến trúc lên kế hoạch dùng generateObject có kiểu cùng một schema Zod. Thứ thực sự được ship lại gọi một workflow runner nội bộ mà team khác đang vận hành, giải quyết luôn câu hỏi chọn provider bằng cách dùng lại cái đã có. Cái giá là shape của response là bất cứ thứ gì workflow trả về, và thực tế nó trả về bốn shape khác nhau: object có tag, object có label, một mảng trần, và một mảng chuỗi thường đi kèm một mảng confidence riêng.

Parser đầu tiên chỉ hiểu một trong số đó. Ba trên sáu lần thử trả về dạng chuỗi thường, và mấy lần đó cho ra không tag nào, im lặng. Job thành công mà chẳng có gì để xem. Parser giờ đọc được cả bốn shape, và theo một quy tắc quan trọng hơn bất kỳ shape nào:

  • Response không đọc được thì throw. Một response kiểu {} sẽ được retry. Nó không bao giờ được ghi nhận là thành công rỗng.
  • Một danh sách đọc được nhưng rỗng là kết quả hợp lệ. Có những trang thật sự chẳng có gì để gắn tag.
  • Hơn năm tag thì cắt còn năm, và confidence bị thiếu thì lấy mức sàn 0,45.

Sự khác nhau giữa "model không nói gì" và "mình không hiểu model nói gì" chính là toàn bộ vấn đề. Gộp hai cái đó thành một mảng rỗng là cách ship một tính năng trông như đang chạy.

Thêm hai bài học từ cùng khu vực này. Có một thời gian queue chỉ có producer: API enqueue job nhưng không có worker nào tiêu thụ, nên mọi job nằm ở PENDING. Và có lần thiếu đăng ký một TypeORM entity làm worker thất bại đủ ba lần thử với mọi job. Nhìn từ ngoài, cả hai đều trông như "AI tagging chậm". Chính một cột trạng thái query được đã biến chúng thành những bug tìm ra được.

Extension: Manifest V3 có lịch riêng của nó

Extension: capture qua adapter, lưu online hoặc xếp hàng offline, flush bằng alarm một phút
Alarm mới là đường chịu lực; event online chỉ là phần thêm

Extension được build bằng WXT trên Manifest V3. Một lần lưu có thể bắt đầu từ icon trên thanh công cụ, Ctrl/Cmd+Shift+S, context menu hoặc popup.

Adapter, và những trang tự khai sai về mình

Capture đi qua một chuỗi dự phòng: adapter theo nền tảng, rồi adapter Open Graph chung, rồi một bản ghi tối thiểu gồm URL và tiêu đề tài liệu. URL không bao giờ null, nên lần lưu nào cũng ra được thứ gì đó. Trang xem video YouTube có adapter riêng với selector được khoanh phạm vi. Các trang khác đi qua adapter chung, và một bảng hostname gắn nhãn chúng là một trong bảy nền tảng (YouTube, TikTok, Reddit, Pinterest, Facebook, Instagram, LinkedIn) hoặc web thường. Với Facebook, Instagram và LinkedIn, nơi metadata rất nghèo, background còn chụp màn hình bằng captureVisibleTab. Việc này phải làm sớm, khi vẫn còn nằm trong cửa sổ user gesture.

Bug capture khó chịu nhất lại không dính tới nền tảng nào. Lưu video thứ hai sau khi bấm sang nó trong cùng một tab, bạn nhận được URL mới nhưng tiêu đề và thumbnail của video đầu tiên. location.href được History API cập nhật liên tục, còn og:title và og:image được server render vào HTML đầu tiên và không router SPA nào viết lại chúng. Cách sửa là phát hiện trực tiếp việc điều hướng trong trang:

ts
function hasNavigatedSinceLoad(): boolean {
  const [nav] = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];
  return !!nav && nav.name !== location.href;
}

Nếu trang đã điều hướng kể từ lúc load, adapter chung bỏ qua og:* và dùng document.title, thứ mà router vẫn cập nhật. Adapter YouTube thì thôi đọc og:* hẳn, lấy thumbnail từ video id trong URL hiện tại, nên hai thứ không thể lệch nhau nữa.

Hàng đợi offline

Không có token hoặc browser đang offline thì bản capture vào một hàng đợi trong browser.storage.local. Ba chi tiết làm nó đáng tin:

  • Một lock bọc mọi chu trình read-modify-write. Hai lần lưu liên tiếp ở hai tab, hay một lần enqueue đua với một lần flush, sẽ cùng đọc một queue và lần ghi sau đè mất của lần trước. Một bài test lúc review tái hiện việc mất item trong 48 trên 50 lần chạy. Giờ mọi thao tác với queue đi qua một lock dạng promise chain duy nhất.
  • Flush từ cũ nhất, lưu phần còn lại sau mỗi lần thành công, dừng ở lần lỗi đầu tiên. Crash giữa lúc flush không làm mất gì, tệ nhất là gửi lại đúng một item đang bay.
  • Một alarm, không chỉ mỗi event online. Trong MV3, service worker bị dừng khi rảnh, và event online không đánh thức được nó. Một timer chrome.alarms mỗi phút mới là đường mà việc flush thực sự dựa vào. Listener online, một message flush và một lần flush tranh thủ trước mỗi lần lưu online chỉ là phần thêm.

Không có dedupe phía client. ON CONFLICT trên (user, url) ở server đã lo việc đó. Và một lần lưu thất bại trong lúc đang online và đã đăng nhập thì không được xếp hàng. Nó hiện lỗi, vì xếp hàng sẽ giấu một vấn đề thật sau cái toast "đã lưu".

Hai bài học MV3 nhỏ hơn: context menu tồn tại qua các lần service worker khởi động lại, nên code gọi removeAll() trước create() thay vì đăng ký lại cùng các id mỗi lần restart. Và permission scripting bị bỏ sau một lần Chrome Web Store từ chối; danh sách permission giờ dừng đúng ở những gì tính năng cần.

Đăng nhập

Auth là Keycloak SSO qua launchWebAuthFlow với PKCE. Keycloak xoay vòng refresh token, nên hai lần refresh đồng thời sẽ vô hiệu hoá lẫn nhau. Refresh chạy 15 giây trước khi hết hạn, dưới một lock tuần tự. Một key trong manifest ghim cố định extension id, để redirect URI không đổi qua các bản build.

Lần sau mình vẫn sẽ làm

  • Commit, rồi publish, rồi enqueue. Worker không bao giờ được thấy một id mà database chưa commit.
  • Giữ state của job trong bảng, không phải trong queue. Nhờ vậy mà Redis chết, thiếu consumer hay thất bại im lặng đều lộ ra được.
  • Enqueue lỗi thì không được làm hỏng lần lưu. Lưu là sản phẩm; tagging thì retry được.
  • Chỉ đánh FAILED ở lần thử cuối, và cho retry từ chối mọi trạng thái khác.
  • Đặt idempotency vào unique constraint. Retry khi đó chẳng tốn gì.
  • Parse output của model dễ dãi, nhưng không đọc được thì throw. Kết quả rỗng và kết quả không parse được là hai chuyện khác nhau.
  • Nhớ những gì user đã xoá, không thì retry sẽ phá công sức của họ.
  • Với MV3, dựa vào alarm, khoá các lần read-modify-write vào storage, và đừng tin og:* sau khi trang đã điều hướng tại chỗ.

Phần còn lại của nền tảng, gồm công cụ nghiên cứu YouTube và bộ sưu tập Smart Space, nằm trong case study Wavesgroup CIP.

  • #Wavesgroup CIP
  • #BullMQ
  • #Browser Extension
  • #LLM
  • #NestJS
Chia sẻXLinkedInFacebook
Dao Van Thuong

Kỹ sư mobile và fullstack ở TP. Hồ Chí Minh. Mình tự xây và phát hành các app iOS indie — Lockboxy, Linkeeper, Minivid, Ringsy, Talkzy, Baton và Stampzy.