Skip to main content
Asset Index thiết kế idempotent ở mọi tầng — cùng input đem đi process N lần ra cùng output, không tốn thêm LLM call.
Idempotent nghĩa là gì? Là tính chất “làm 1 lần hay 100 lần đều ra cùng kết quả”. Ví dụ ngoài đời:
  • Bấm nút gọi thang máy 1 lần và bấm 100 lần → kết quả như nhau (thang đến tầng đó). Đây là idempotent.
  • Bấm nút mua hàng 100 lần → trừ tiền 100 lần. Không idempotent.
Áp dụng cho Asset Index: cùng 1 file ảnh đem chạy index 100 lần, hệ thống chỉ gọi AI phân tích đúng 1 lần. 99 lần còn lại nó nhận ra “đã làm rồi” và trả lại kết quả cũ — không tốn quota OpenAI/Gemini, không thay đổi DB, không gì cả. Hữu ích khi watcher restart, khi bạn copy file qua nhiều folder, hay khi --scan-on-start quét lại toàn bộ.

Cơ chế

Trong router.process_file():
1

Compute SHA-256 của file

File được hash streaming (chunk 1MB) — hoạt động tốt với file 4GB+.
content_hash = sha256_file(abs_path)
2

Check DB: hash có trong assets chưa?

SELECT id, mtime FROM assets WHERE id = ?
Lấy ra row khớp content_hash.
3

Decision

  • Hash chưa có → analyze + embed + insert → status: ok.
  • Hash đã có + mtime khớp → skip (giả định không có gì đổi) → status: skipped.
  • Hash đã có + mtime khác → file metadata đổi (di chuyển, rename) → update file_path, file_name, mtime nhưng giữ nguyên embeddingstatus: skipped (no LLM call).
  • --force flag → bỏ qua cả 3 case trên, force re-analyze.

Tại sao dùng SHA-256 thay vì path?

Path đổi thường xuyên:
  • User rename file: old.jpgnew.jpg.
  • User di chuyển folder: raw_assets/images/test.jpgraw_assets/images/2026/test.jpg.
  • User copy file ra job: raw_assets/...jobs/.../input/raw_assets/....
SHA-256 chỉ phụ thuộc vào bytes, nên 1 lần phân tích cho mọi vị trí.
Cùng 1 file copy ra 5 chỗ → 5 row trong assets (vì file_path UNIQUE), nhưng cùng 1 id (= hash), cùng 1 row trong assets_vec, cùng 1 embedding.Lưu ý: vì primary key là id (hash), implementation chỉ giữ 1 row đầu. File copy thứ 2 sẽ update file_path của row có sẵn, không tạo row mới. Xem code store.upsert_asset() để biết logic chính xác.

Tương tác với watcher

Watcher đôi khi nhận nhiều event cho cùng 1 file (FSEvents/inotify quirk):
  • File copy lớn → nhiều on_modified event trong khi đang ghi.
  • Editor save → on_created + on_deleted + on_created lại (atomic save).
watcher.py xử lý bằng:
  1. Debounce: trễ 1.5s (mac) / 2.5s (win) sau event cuối cùng — đợi file ổn định.
  2. Size-stable check: đọc size 2 lần cách 100ms; nếu khác → file đang copy → đợi tiếp.
  3. Single-instance lock trong state.json — không cho 2 watcher chạy song song.
Sau debounce, watcher gọi process_file() — nếu file đã được index ở event trước, hash khớp → skip ngay.

Tương tác với scan-on-start

Khi khởi động với --scan-on-start, watcher walk recursive các root đang watch và gọi process_file() cho mỗi file. Nhờ idempotency:
  • File đã có trong DB → skip (no LLM call).
  • File mới drop khi watcher offline → index.
Nên thêm --scan-on-start vào service config — không tốn gì khi watcher đã đồng bộ.

Process log

Mọi lần process_file() chạy đều ghi vào process_log (xem File runtime). Audit trail hữu ích khi debug:
SELECT file_path, status, error, ran_at
FROM process_log
WHERE file_path LIKE '%test.jpg'
ORDER BY ran_at DESC
LIMIT 10;
Xem được:
  • Lần process đầu tiên thành công ở đâu.
  • Có bao nhiêu lần skip (idempotent).
  • Lỗi gần nhất.

Force re-index

Nếu Gemini đã update model hoặc bạn muốn refresh summary:
.venv/bin/python -m tools.asset_index.router \
  raw_assets/images/test.jpg \
  --force
Hoặc batch:
find raw_assets -type f \( -name "*.jpg" -o -name "*.png" \) | \
  xargs -I{} .venv/bin/python -m tools.asset_index.router {} --force
--force re-call Gemini + OpenAI cho từng file → tốn quota. Dùng có chủ ý.

Bước tiếp theo

Cross-platform

FSEvents fallback, cp65001, apsw.

Mở rộng

Thêm format, đổi embed model.