1M 列批次建立改用瀏覽器內 SQLite(souffle !1199 + cupcake !1777)
研究這組前後端 MR 的完整筆記,抽出的原子筆記見文末。 MR 連結
- 前端 souffle !1199 —
feat/013-bulk-create-raw-material-1m-sqlite→ main,作者 YCLin,reviewer ray.hsu,97 檔案,open https://gitlab.cedarsdigital.io/carbonx/souffle/-/merge_requests/1199- 後端 cupcake !1777 —
feat/bulk-create-raw-material-1m-sqlite→ main,同作者,reviewer stone.wei,27 commits/111 檔案/+6491 −998,open https://gitlab.cedarsdigital.io/carbonx/cupcake/-/merge_requests/1777- 過程中產出的評估 HTML artifact:https://claude.ai/code/artifact/aa455d98-f5dd-413c-9a89-5dacd379babd(只涵蓋前端)
一、原本的瓶頸
舊 validate 回傳 { rawMaterials, activityData, validateResults } 整包 JSON,前端 transformApiResponseToForm 一次把全部列轉成 form row 灌進 zustand。瓶頸是複合的:
- HTTP:100 萬列的 JSON 是 GB 級,ingress 有 proxy-body-size 上限,根本傳不動
- 記憶體:每列被展開成含
{label, value}的 form object,實際 footprint 遠大於原始資料 - CPU:transform 在主執行緒同步跑,整段期間 UI 凍住
- 同步 request:validate 要等後端跑完整包才回應,長連線容易 timeout
- 另外
useSorting是全陣列排序再切片;tableColumns的 memo deps 含sortedIndices / pageIndex / pageSize,翻頁就重建所有 column def → 50×30 個 cell 全重渲染
二、解法:SQLite artifact 當傳輸格式與儲存層
把「資料的儲存與查詢」整個搬出 JS heap 與主執行緒:
- 後端產
.sqlite(better-sqlite3)→ gzip → 上傳 GCS → 前端下載 - 前端在 Worker 裡用 sql.js(SQLite 編成 WASM)打開,
ORDER BY/LIKE/LIMIT OFFSET/COUNT全部下推成 SQL,主執行緒只持有當前這一頁 50 列 errors表由 Worker 在queryPage裡 join 回來,不再另外回一個validateResults陣列- validate / submit 改成非同步 workflow:202 +
workflowId→ 輪詢 status → 下載 artifact externalQueryoptional prop 而非改寫BulkInputTable:有傳就走 Worker 分頁,沒傳走原路徑,其他 consumer 零影響- 舊流程整份保留為
*Legacy,可回退
前端的心智模型:SQLite 是唯一真相,zustand 降級成單頁的顯示快取。
- 讀:完全走 SQL,
paginatedData直接return { rows: externalQuery.pageRows } - 寫:write-through — store 先更新讓 UI 不卡,SQLite 隨後同步;真正被 export 送給後端的是 SQLite 的內容,不是 store
- 顯示:cell 渲染、欄位設定、驗證、i18n 還是原本的 React 那套
- 驗證分兩層:編輯當下送單列 JSON,提交時才送整份 artifact(見下)
flowchart LR C["CellWrapper 編輯"] --> ST["zustand store 更新<br/>畫面立刻反應"] ST --> MSG["UPDATE_CELL 訊息"] MSG --> WK["Worker"] WK --> SQL["SQL UPDATE"] WK --> INV["invalidateCounts<br/>搜尋條件下的 totalCount 可能變了"]
BulkInputTable.tsx 的註解把分界講得很準:externalQuery: store.rows IS the page window——所以 absoluteRowIndex 的語意在兩種模式下不同,非 externalQuery 要透過 sortedIndices[pageOffset + row.index] 換算,externalQuery 下 row.index 直接就是答案。
編輯迴圈:單列 revalidate,整包只在提交時送一次
改一格不會重送整份 artifact。usePreviewRawMaterial.ts:357-360 打的是原本那支同步 endpoint,body 是長度 1 的陣列:
const response = await postRawMaterialBulkRevalidate({
query: { enterpriseId, projectId },
body: { rawMaterials: [rawMaterial], activityData: [] },
});revalidate 沒有跟著改成非同步——validate 改成 202 + workflowId 了,revalidate 維持原樣直接回 validateResults,不走 workflow 輪詢。DTO 註解寫明這是刻意的。
觸發條件也不是「每改一格就送」:
- cell-blur 才觸發,打字過程不送
- 欄位要在
validatableFields白名單裡 - 同一列 300ms debounce——連續改同一列三個欄位只送一次
- 停用的列跳過
原本還有第五個條件「只有已經是 error 狀態的列才重驗」,被 revalidateAllEditedRows(D28)關掉了,所以現在每個編輯過的列都會重驗。驗完呼叫 clearDirty('rm', rowNo) 清掉 Worker 裡那列的 dirty 標記——註解說這是唯一清 dirty 的地方,而且只在成功時清。(這解釋了 rm_rows / ad_rows 為什麼要為 dirty 建索引。)
提交時才是整包:
flowchart LR EX["db.export 完整 sqlite bytes"] --> GZ["compressGzip"] GZ --> POST["POST submit<br/>Content-Encoding gzip"] POST --> BE["後端 gunzip 讀 artifact<br/>完整重跑驗證 寫進資料庫"]
兩層驗證的分工
| 時機 | 送什麼 | 目的 | |
|---|---|---|---|
revalidate | cell blur + 300ms debounce | 單列 JSON | 即時回饋,當下就看到錯 |
submit | 按下提交 | 整份 artifact binary | 最終把關,前端的驗證結果一律不採信 |
後端在 submit 時完整重跑一次驗證,不管前端說什麼;任何一列有錯就擋掉整批寫入,產一份 errors-only artifact 讓前端 merge 回去修。所以前端的即時驗證純粹是 UX,不是 gate。
整份 artifact 的往返只發生在提交那一刻——但一旦提交失敗要重試,就得整份重來(db.export() 的 2.5–3× 峰值、重新 gzip、重新上傳、後端重驗全部列),而且是全新的 workflowId(retry.maximumAttempts: 1,沒有伺服器端續傳)。
三、瀏覽器裡的 SQLite 怎麼運作
sql.js 是把 SQLite 的 C 原始碼用 Emscripten 編成 WebAssembly,跑的是真正的 SQLite engine,query planner、B-tree 都是原版。
export const openDatabase = (SQL, bytes?) => new SQL.Database(bytes);flowchart TD BE["後端產 .sqlite<br/>better-sqlite3"] --> GZ["gzip<br/>~30MB"] GZ --> DL["HTTP download<br/>responseType: blob"] DL --> AB["arrayBuffer 加 gunzip<br/>ArrayBuffer ~300MB<br/>主執行緒"] AB -->|"postMessage transfer<br/>零複製 來源 detached"| W["Worker 收到<br/>new Uint8Array 只是 view"] W -->|"複製進 WASM heap"| DB["new SQL.Database bytes<br/>SQLite 在 WASM linear memory"] DB --> Q["QUERY_PAGE<br/>只回傳當前 50 列"]
沒有真正的 file handle:瀏覽器不能像 server 那樣 fopen 再只讀需要的 page,sql.js 預設 VFS 是純記憶體的,整份 DB 一定完整存在 WASM heap。所以 1M 列的記憶體成本沒有消失,只是從 JS heap(被 form object 放大過)搬到 WASM heap(原始 SQLite page 格式,緊湊很多)——這才是真正的節省來源。
WASM heap = WebAssembly 的線性記憶體:一塊連續 bytes,從 JS 看就是巨大的 ArrayBuffer(Module.HEAPU8)。C 裡的「位址 0x1000」就是 HEAPU8[0x1000],指標其實只是索引。
| JS heap | WASM heap | |
|---|---|---|
| 誰管 | V8 GC 自動回收 | C 程式碼自己 malloc / free |
| 結構 | 物件圖,有 header、hidden class | 一整塊連續 bytes |
| 怎麼變小 | GC 跑過就回收 | 只有 free() 會還,整塊通常不縮回去 |
所以 worker.ts 在 OPEN 前先 db?.close() 是必要的——沒這行,重複上傳每次疊加一份 300MB,穩定洩漏。
記憶體歸屬:Worker 跟分頁在同一個 renderer process,共用同一份預算。
flowchart TD subgraph P["OS process renderer 一個分頁進程"] subgraph MT["主執行緒"] I1["V8 isolate 1<br/>React DOM zustand"] end subgraph WT["Worker 執行緒"] I2["V8 isolate 2<br/>worker 的 JS"] LM["WASM linear memory<br/>SQLite 那 460MB"] end end
「另外開一塊」這個直覺對了一半:Worker 確實有自己的 V8 isolate——獨立的 heap、獨立的 GC、獨立的 hidden class 表,兩個 isolate 之間不能直接共享物件參照(這正是 postMessage 只能傳可序列化資料或 transferable 的原因)。所以 Worker 的 GC 停頓不會卡住主執行緒的畫面。
但 isolate 不是 process:兩個 isolate 加上那塊 WASM 記憶體都在同一個 OS process 的位址空間裡。可驗證的三個推論:分頁被 OOM 砍掉時 Worker 一起死/工作管理員的數字以 process 為單位、包含 Worker/開 Worker 不會多拿到記憶體,只繞開執行緒阻塞。
真正在不同 process 的只有 Service Worker 與 cross-origin iframe,這專案兩個都沒用到。
硬限制:WASM linear memory 4GB(wasm32 位址空間,規格層級)/單一 ArrayBuffer Chrome ~2GB/iOS Safari 每 process 數百 MB,超過直接被系統砍掉,連 error event 都沒有。
四、記憶體的空間複雜度(線性放大,差別只在常數)
這一節的 V8 / WASM 機制屬於背景知識,不是 MR 本身寫的;帶
(推論)的才是對這份程式碼的推測。
結論:這次改動沒有把空間複雜度從 O(N) 降下來,降的是常數與「同時存在幾份」。
| 量 | 舊做法 | 新做法 |
|---|---|---|
| 後端產出物 | JSON 字串 O(N) | .sqlite O(N) |
| 前端常駐(資料本體) | JS heap O(N),被 form object 放大 | WASM heap O(N),SQLite page 格式 |
| 前端渲染 working set | O(N)(全陣列排序、切片、column def 重建) | O(pageSize)=O(1) relative to N |
| 峰值倍率 | 難以界定 | OPEN ≈ 2×、EXPORT ≈ 2.5–3× |
真正變成 O(1) 的只有第三列。第二列仍然是 O(N),因為 sql.js 的 VFS 是純記憶體的。
「放大」是什麼意思
SQLite 那側,一列就是 page 裡一段連續 bytes,額外開銷只有一個 record header:
flowchart LR subgraph SQLiteRow["SQLite 一列 約 460 bytes"] H["record header<br/>各欄位型別與長度"] --- V1["值1 bytes"] --- V2["值2 bytes"] --- V3["...值33 bytes"] end
同一列進到 JS 變成一張物件圖:
flowchart TD ROW["form row 物件<br/>V8 object header<br/>map pointer 加 properties 加 elements"] ROW --> S1["字串物件 name<br/>header 加 字元"] ROW --> CAT["物件 category"] CAT --> L1["字串 label 金屬"] CAT --> V1["字串 value metal"] ROW --> SUP["物件 supplier"] SUP --> L2["字串 label 台灣鋼鐵"] SUP --> V2["字串 value sup_001"] ROW --> MORE["...另外 30 個欄位"] ROW --> UI["rowStatus dirty errors<br/>原始資料沒有的 UI 狀態"]
放大來自五件事疊加:
- 每個字串都是一個 heap object — V8 的字串帶 header(map pointer、length、hash),短字串的 header 可能比字元本身還大;含非 Latin-1 字元(中文)會整條升級成 two-byte 表示
- 物件本身的固定成本 — map pointer(指向 hidden class)、properties backing store、elements pointer,光是存在就要幾十 bytes
{label, value}把一個值變成三個東西 — 一個物件加兩個字串- 多出原始資料沒有的欄位 —
rowStatus、errors、dirty;SQLite 那側 errors 在獨立的表 - 指標間接性造成的碎片 — 物件散落 heap 各處,實際 RSS 高於理論加總
對照:"台灣" 在 100 萬列裡重複 100 萬次,SQLite 也是老實存 100 萬份 bytes,差距不在資料本身,全在包裝。
常數是多少
以 bulk-create-common.config.ts 的註解為準:
500k 列 → 解壓後 ~245MB
1M 列 → 解壓後 ~460MB → c_sqlite ≈ 460 bytes / row(rm + ad 兩張表合計)
gzip 後 ~30MB vs ~300MB → 壓縮比約 10×舊做法那側沒有實測數字,只能從物件圖推:單列 footprint 大致落在數 KB等級,c_form / c_sqlite 保守估 5–10×。(推論)
峰值倍率是什麼
峰值倍率 = 那條路上同一瞬間最多同時存在幾份完整資料,以穩定狀態大小 S 為單位。
OOM 不是發生在穩定狀態,是發生在峰值。 常駐 460MB 聽起來安全,但如果 OPEN 的瞬間需要 920MB,裝置就得撐得住 920MB。
令 S = 解壓後 artifact 大小(1M 列 ≈ 460MB),C = gzip 後(≈ S/10)。
前端 OPEN
flowchart LR subgraph T1["解壓後"] A1["ArrayBuffer S"] end subgraph T2["複製進 WASM 的瞬間 峰值 2S"] A2["來源 bytes S<br/>複製期間必須完整有效"] B2["WASM heap S"] end subgraph T3["GC 跑過之後"] B3["WASM heap S"] end T1 --> T2 --> T3
關鍵在 new SQL.Database(bytes) 是複製不是搬移:_malloc 一塊 WASM 空間再 HEAPU8.set(bytes) 寫進去,複製進行到一半時來源必須完整有效,所以兩份必然並存。複製完成後來源才變成垃圾,而回收時機由 GC 決定——大 buffer 直接進 large object space,要等 major GC 才處理,所以 = null 之後 devtools 數字不會立刻降,這不是洩漏。
峰值 ≈ 2S + C(≈ 2.1S),穩定後降回 S。gzip 那段用 pull-based 分塊,理論上不需要再多一份完整副本;但解壓結果最終要組成單一 ArrayBuffer,組裝瞬間分塊與成品是否並存,程式碼沒明說。(推論)
前端 EXPORT(峰值最高,發生在按下提交之後——最不能失敗的時刻)
flowchart LR W["WASM heap S<br/>原本就在 且是資料來源"] --> E["db.export 第二份 S"] E --> G["compressGzip 輸出 C"] W -.->|"三者同時存在"| G
理論峰值 ≈ 2S + C ≈ 2.1S。session 當時估 2.5–3×,差額大概來自 WASM heap 只增不減加上壓縮的中間 buffer。兩個數字沒對齊,實測前不要當定論。 (推論)
後端 writer(風險最高的一處)
flowchart TD M["mapper 完整展開<br/>RmRow 加 AdRow JS 陣列 A"] --> D["memory SQLite DB S"] D --> B["db.serialize 的 Buffer S"] B --> G["gzip 輸出 C"] M -.->|"單一 Node process 內全部並存"| G
峰值 ≈ A + 2S + C。A 是 JS 物件陣列,量級接近舊前端那個被放大的常數,整體很可能落在 4–5S——1M 列就是好幾 GB,同步跑完、event loop 全程停住。(推論:A 的量級是從 mapper 結構推的)
倍率是相對值:460MB 的 artifact 峰值 920MB,1GB 的峰值 2GB。
WASM heap 為什麼不會自己縮回去
WebAssembly.Memory 只有 grow() 沒有 shrink()。SQLite 內部 free() 掉的空間會被它自己重複利用,但整塊配額不縮。所以 devtools 看到居高不下是正常的;db?.close() 是唯一釋放手段;JS 的 GC 完全管不到這塊,它只會回收那個指向 WASM memory 的包裝物件。
上限怎麼換算
WASM 4GB 是 wasm32 的 2^32 定址上限(規格層級,非瀏覽器政策)。以 460 bytes/row 反推理論上限在千萬量級——但沒有意義:峰值 2× 先砍半/單一 ArrayBuffer Chrome 約 2GB 先撞牆/iOS Safari 每 process 數百 MB,460MB 已在被系統砍掉的區間。
所以 SQLITE_ROW_CAP = 1_000_000 是產品決定的,不是從記憶體推導出來的;它在桌機成立、行動裝置不成立。
五、Worker 怎麼「啟動」(沒有註冊這回事)
- Web Worker 不用註冊,
new Worker(url)就直接啟動;要register()的是 Service Worker(它得在沒有分頁時也能被喚醒,所以要登記在 origin 層級),這專案沒用到 - webpack 那段不是註冊,是打包指令:
module.parser.javascript.worker: ['CorsWorker from @/utils/CorsWorker', '...']告訴 webpack「new CorsWorker(new URL(...))也算 worker entry」,它才會獨立打包成 chunk 並把new URL(...)改寫成帶 hash 的真實網址('...'是保留預設偵測) - runtime 是 module-scoped lazy singleton:
getWorker()第一次呼叫才建立,之後全 app 共用——這就是 validate / preview / edit / submit 能共享同一份開著的 DB 的原因 - 這一行必須維持確切的 AST 形狀:把 URL 抽成變數會讓 webpack 偵測失效,吐出原始
.tsasset,沒有 build error,只有 runtime 才炸 - CorsWorker 處理 Module Federation 的跨源問題:同源時直接
new Worker(url);跨源時包一層 Blob(self.__webpack_public_path__ = "http://localhost:3002/"+importScripts(...))——blob URL 算同源所以能建 Worker,classic worker 的importScripts允許跨源 - worker 檔案底部用
typeof window === 'undefined' && typeof self !== 'undefined'守衛掛 handler,讓測試 import 純函式時不會炸 - 搭配的
CopyWebpackPlugin把sql-wasm-browser.wasm複製到dist/,sql.js 的 browser build 會在 runtime 用locateFile抓這個確切檔名
六、後端(cupcake !1777)
SQLite artifact 層:rm_rows(33 欄)/ ad_rows(22 欄)/ errors(id, sheet, rowNo, rowId, field, code)/ meta(只放 schemaVersion)四張表,11 個索引。reader 開檔時跑 PRAGMA integrity_check + table/index 白名單 + assertSchemaVersion,逐列重跑長度上限檢查(不信任 writer 已經擋過),SELECT 明確列欄位而非 SELECT *。errors-only artifact 由獨立的 SqliteArtifactErrorsWriter 產,只有 errors 表沒有 meta,reader 用白名單自動辨識並跳過版本檢查。
Temporal:validate / submit / export 是三個獨立 workflow,每個只包一個 activity。
- artifact 不走 Temporal payload(預設 ~2MB 上限),activity 的輸入輸出只有
fileId/gcsArtifactPath/ id / count 等小字串,.sqlite與.xlsx全走 GCS - progress 用
Context.current().heartbeat({percent})傳出,status endpoint 從pendingActivities[0].heartbeatDetails解回 - 只有 validate 的 45→90 是真的(按已寫入列數線性內插,每 5000 列更新);submit 最長的 DB 寫入那段被
setInterval(30_000)釘死在 70%,純粹為了餵飽heartbeatTimeout: '2 minutes' - 新開專屬 task queue
PCF_BULK_HEAVY(預設併發 3),註解明講就是不讓 1M 這條 head-of-line block 掉既有 50k JSON 流程 startToCloseTimeout: '1 hour'、retry.maximumAttempts: 1- submit 列級驗證失敗是「部分成功但被擋」:errors-only artifact 先上傳 GCS,再丟
BulkSubmitRowValidationFailedError,activity 把gcsArtifactPath塞進ApplicationFailure.details;前端拿到{status: FAILED, failedRowCount}後打 download,後端沿 failure/cause chain 解出路徑串回那份 errors-only artifact——這就是前端MERGE_ERRORS_ARTIFACT的來源。submit 成功時failedRowCount寫死 0,全有全無
API 契約:五個 endpoint 全部對得上。submit / export 是雙模 endpoint(application/octet-stream 走新路徑,application/json 仍走舊 smart-bulk-create 同步路徑),這解釋了前端為何保留 submitLegacy.ts。
main.ts 有一段只針對 bulk_create 的 middleware:Content-Type 是 octet-stream 時主動拆掉進來的 Content-Encoding: gzip header,不讓 Express body-parser 自動解壓——註解寫明原因是自動解壓會把 65MB 變 245MB,server 還要再壓回去傳 GCS,白做一輪。controller 拿到的是還壓著的 raw Buffer,直接丟 GCS,解壓延後到 activity。limit 為 SQLITE_ARTIFACT_MAX_FILE_SIZE_BYTES(預設 500MB)。
授權模型:download 沒有 project/enterprise guard,enterpriseId / projectId 只是形式一致而收,不用於授權;實際存取控制全靠 Temporal workflow 的 memo.userId + timingSafeEqual,fail-closed 丟 62097/403。enterpriseId 在 service 層是資料範圍過濾,不是成員身分檢查。
新錯誤碼:62097(ownership 不符 / 403)、62099(artifact 完整性或欄位長度超限 / 400)、62100(meta.schemaVersion 不是 1.1.0 / 400)、40400(artifact not found,沿用既有數字碼但覆寫 getStatus() 成 404;GCS 物件有 7 天生命週期,過期也走這條)。
七、風險清單
🔴 最嚴重:GET /bulk/workflow/status 的 ownership guard 是無條件的
assertOwnership 在所有分支之前呼叫,1M 前綴判斷在它之後、只決定回應欄位。而全 repo 只有 bulk-create-raw-material-workflow.service.ts 三處寫 memo: { userId, projectId };其他所有 workflow 走 tasks/adapters/temporal.adapter.ts,memo 只放 projectId / enterpriseId,從來沒有 userId。這個 endpoint 是共用的(bulk-delete、org bulk-create、DC bulk input、bulk-edit、pipeline 都在打),deploy 後這些輪詢一律拿到 403。common.service.spec.ts 沒抓到,是因為測試連 legacy workflow id 都手動 stub 了 memo: { userId: TEST_USER_ID },跟實際產出不一致。
🔴 部署順序:後端不能先於前端部署
export / submit 都按 Content-Type 分流保留 legacy 行為,但 validate 沒有這個分支——被就地改成無條件 202 回 {workflowId},舊前端打過去只會拿到 workflowId、沒有任何驗證結果。兩個 MR 必須同時上;前端的 *Legacy 檔案是 code 層面回退用的,不是 runtime feature flag。
🟠 前端記憶體完全沒有防線
沒有 artifact 大小檢查、沒有 OOM 處理(RangeError 只會走 onerror → 顯示一句原始英文訊息)、iOS 分頁被砍時連 error event 都沒有。上傳端只有 maxSize={50MB}(Excel),但 50MB xlsx 解壓成 artifact 可以是好幾百 MB——入口檔案大小無法推導 artifact 記憶體用量。
🟠 後端 writer 全程 in-memory + 單一超大 transaction
new SqliteArtifactWriter() 沒傳 filePath → :memory:,最後 db.serialize() 再吐一份完整 Buffer,上面還疊著已完整展開的 RmRow[] / AdRow[]。writeRmRows / writeAdRows 把整個陣列包在一個 db.transaction() 裡跑同步 for loop,這段時間 Node event loop 是停的。(Temporal 那層有以 5000 列為一批寫入的路徑,兩份 agent 報告在這點描述不一致,見矛盾點。)
🟠 CompressionStream 沒有 fallback 也沒有 feature detection
gzip.ts 檔頭承認沒有 fallback,理由是「supported-browser matrix 一定有」,但專案裡沒有 browserslist / babel targets 把這個約定固定下來。不支援的瀏覽器會當場丟 ReferenceError。詳見第九節。
🟠 測試涵蓋度
叫做 1M 的功能,最大 fixture:sqlite writer spec 200 列、payload-store 5001 列、pipeline 1000 列。沒有任何測試跑真實 Temporal worker(@temporalio/* 整個被 mock,沒有 TestWorkflowEnvironment),e2e 驗的是 HTTP 契約而非資料正確性。Worker + WASM + Streams API 在 jsdom 下也測不到真實行為。
🟡 單列 revalidate 目前送不出 activityData
revalidateRow 的註解:activityData for this RM is not yet sourced from the Worker here — the payload's activityData is empty pending that wiring. 需要看該原料底下活動數據才判斷得出來的規則,這條路驗不到,要等 submit 的完整重驗才會浮現。標記為 Phase 4 待處理。
🟡 其他脆弱點
module-scoped 全域狀態(sharedWorker / pendingRequests / isDbOpenGlobal / dbLifecycleListeners)生命週期不跟 React 綁,useSqliteWorkerQuery 裡「hook 晚掛載要 replay open 狀態」的 workaround 就是直接後果,MFE 反覆掛載風險更高/CorsWorker 依賴 webpack 內部 AST 偵測,升級容易靜默壞掉/多處 render-time ref 寫入配 eslint-disable react-hooks/refs/*Legacy 雙軌長期是雙倍維護面,應該要有明確刪除時點/buildSearchClause 對 33 個欄位各做一次 LIKE '%…%',前置萬用字元用不到索引,1M 列就是 33 次全表掃描。
🟢 build 沒問題
better-sqlite3@^11.8.1 已加進 Dockerfile:66 既有的 npm rebuild canvas 那行,跑在 builder stage;Node 固定 node:20-slim 兩 stage 一致,ABI 對得上。但 runtime image 完全沒有 build toolchain,那行 rebuild 若被誤刪會在啟動時直接 crash。沒有 DB migration,所有新 config 都有安全預設值。
八、IndexedDB / OPFS 這條路
直接用 IndexedDB 取代 SQLite 不會是好選擇——它是 key-value store 加預先宣告的索引,任意欄位排序要 33 個索引、LIKE '%x%' 完全沒有、COUNT(*) WHERE 要自己 cursor 數、沒有 join、沒有運算式索引,等於要在 JS 裡重寫一個查詢引擎;而且後端已經產出 .sqlite,改用 IndexedDB 還得先把 100 萬列一筆筆寫進去。
真正的方案是換掉 SQLite 的 VFS:
| VFS | 儲存在哪 | 記憶體 |
|---|---|---|
| sql.js 現在用的 | WASM linear memory | 整個 DB 常駐,受 4GB 與分頁配額限制 |
| absurd-sql | IndexedDB | 只有 page cache 常駐 |
| 官方 sqlite-wasm 的 OPFS VFS | Origin Private File System | 只有 page cache 常駐 |
OPFS sync access handle 是什麼,以及 SQLite 為什麼非它不可
OPFS(Origin Private File System) 是給網頁用的私有檔案系統,每個 origin 一份,使用者看不到、也不會出現在下載資料夾。它跟 IndexedDB 的根本差別是它真的是檔案——可以 seek 到任意位置、讀某個區間、寫某個區間。
const root = await navigator.storage.getDirectory();
const file = await root.getFileHandle('db.sqlite', { create: true });
const handle = await file.createSyncAccessHandle();
handle.read(buffer, { at: 4096 }); // 同步,沒有 await
handle.write(buffer, { at: 8192 }); // 同步那兩行沒有 await。這是整個瀏覽器儲存生態裡少數的同步 I/O,也是它存在的唯一理由。代價是只能在 Worker 裡用——主執行緒禁止。
SQLite 的核心是同步的,VFS 介面長這樣:
int xRead(sqlite3_file*, void* buf, int amt, sqlite3_int64 offset);「從 offset 讀 amt 個 byte 到 buf」——回傳時資料就必須在 buf 裡了,沒有 callback、沒有 promise 的位置。所有非同步儲存 API(IndexedDB、fetch、File API)都接不上。這就是為什麼過去在瀏覽器跑 SQLite 只能整個載進記憶體——因為記憶體存取是同步的。
有了它,SQLite 才能只讀用得到的 page,300MB 的 DB 常駐可能只剩幾 MB 的 page cache。
這個 MR 沒這樣做的阻力
要換成 @sqlite.org/sqlite-wasm(worker 層重寫)/官方 OPFS VFS 主要模式依賴 SharedArrayBuffer + Atomics.wait,需要 COOP/COEP header,在 Module Federation 下會連帶影響 host 載入的所有其他 remote/Worker 從 blob URL 建立,blob 繼承的是建立它的 document 的 origin(host 的 :3000,不是 souffle 的 :3002),OPFS 會落在 host origin 底下/資料實際寫到使用者硬碟可能有法遵問題/OPFS sync access handle 是 Safari 16.4(2023)才有。
結論:以「先讓功能能動」來說目前選擇合理,但有天花板——桌機撐得住 460MB,iPad 撐不住。若要支援行動裝置或列數再往上長,OPFS 是唯一出路,而且越晚換成本越高(現在 worker 協定還乾淨,就七個訊息類型)。
九、瀏覽器相依:卡點不在 WASM
WASM 本身不是問題——2017 年就全面落地(Chrome 57 / Firefox 52 / Safari 11 / Edge 16 同一年)。真正不支援的只有 IE11,而它 2022 年已停止支援。
| 能力 | Chrome | Safari | Firefox | 用在哪 |
|---|---|---|---|---|
| WebAssembly | 57(2017) | 11(2017) | 52(2017) | sql.js |
| Web Worker | 遠古 | 遠古 | 遠古 | — |
| CompressionStream | 80(2020) | 16.4(2023-03) | 113(2023-05) | gzip.ts |
| OPFS sync handle | 108 | 16.4 | 111 | 未使用,未來換 VFS 會需要 |
卡住的是 CompressionStream / DecompressionStream,不是 WASM。
瀏覽器內建的 gzip 壓縮器,Streams API 的一員,實作在 C++ 層(zlib),比 pako 這類 JS 函式庫快很多且不佔 bundle:
readableStream
.pipeThrough(new CompressionStream('gzip'))
.pipeThrough(new DecompressionStream('gzip'));這個 MR 怎麼用它:gzip.ts 的 chunkedSource 把已經在記憶體裡的 buffer 切成 4MB 一塊餵進去。為什麼要自己切塊——為了進度回報:ReadableStream 的 pull() 只有在下游真的消化完前一塊時才會被呼叫,所以 onProgress 反映的是實際壓縮進度,而不是「已經丟進 buffer 的量」。
解壓那邊多一層 magic-byte 檢查(0x1f 0x8b):如果 server 回應帶了 Content-Encoding: gzip,瀏覽器會自己先解壓,這時再跑一次 DecompressionStream 會丟 incorrect header check。
風險排序:這四個能力裡先擋住使用者的不會是 WASM 也不會是 CompressionStream,而是記憶體。Safari 16.4 已是三年前的版本;但 iPad 的 process 限制是現在式,失敗方式也更難處理。
反覆出現的關鍵字
- artifact 當傳輸格式 —
.sqlite二進位本身就是 HTTP body,貫穿前後端與 GCS,是整個設計的軸心 - WASM heap / linear memory — 「為什麼省記憶體」與「為什麼有天花板」的同一個答案
- 峰值 2×–3× — OPEN、EXPORT、後端 writer 三處,是最一致的風險形狀
- transfer 而非 clone —
postMessage的 transfer、detached ArrayBuffer db?.close()/ explicit free — WASM heap 不會自己縮回去,是 GC 管不到的邊界- workflowId + 輪詢 — validate/submit/export 全部 202 + workflowId
memo.userId+ fail-closed — ownership guard 的實作方式,也是最大風險的來源- legacy 雙軌 /
Content-Type分流 — 前端*Legacy、後端雙模 endpoint - 確切的 AST 形狀(webpack) — 靜默失敗的依賴
- 「不 head-of-line block 既有流程」 — task queue 隔離、
externalQueryoptional prop、雙模 endpoint
矛盾點
session 內的自我訂正(結論以後者為準)
ARTIFACT_SCHEMA_VERSION是不是死常數 — 訂正後:SCHEMA_VERSION = '1.1.0'(schema.ts)才是唯一生效的,ARTIFACT_SCHEMA_VERSION = '1'(config.ts)存在但沒被 sqlite 層引用。兩者並存且值不同,是容易誤用的陷阱。- 記憶體「與列數脫鉤」 — 早期評估把記憶體列在「好處」,後來承認只講了常駐、沒講峰值。
rowId為 null 會不會破壞前端 merge — 查證後不成立:前端 group key 是${sheet}:${rowNo},rowId不參與比對。- 「編輯要重送整包」 — 錯的。編輯走單列 JSON 的
revalidate,整份 artifact 只在提交時往返一次;dirty索引就是給這條迴圈用的。
兩份 agent 報告互相打架(尚未收斂)
- 後端 writer 是否分批寫入 — SQLite artifact 層那份說「單一未分塊 transaction」,Temporal 層那份說「以 5000 列為一批(
SQLITE_ARTIFACT_WRITE_BATCH_SIZE)」。很可能是前者只讀 writer 檔案、沒看到呼叫端的分批迴圈。要自己回去確認。(推論) CUSTOM_FACTOR_CHUNK_SIZE = 5000是否被引用 — 可能與SQLITE_ARTIFACT_WRITE_BATCH_SIZE = 5000被混為一談。(推論)
程式碼 / 註解 / 文件之間的不一致
- whitelist 檢查的強度 — 檔頭註解宣稱比對
sqlite_master的sql文字,但matchesWhitelist只比type+name,同名表被改欄位定義驗不出來。實際防護靠明確列欄位的SELECT加逐列長度檢查。 downloadLink是否被移除 — 前端說「被移除」,後端 DTO 裡沒有移除,只是 1M 這族從不填它。createdRawMaterialCount/createdActivityDataCount同理永遠undefined。- download 的「三種變體」 — DTO 註解說可用 Content-Type / Content-Encoding 分辨三種,後端實際只有兩種組合(validate 與 submit errors-only 完全相同)。
ARTIFACT_MAX_ROW_LIMIT沒被強制 — 實際擋列數的是 Temporal 那層的SQLITE_ROW_CAP。- 前端 MR 自陳「validate artifact errors 表為空、rowStatus 全 OK」 — 錯誤顯示這條主要路徑端到端沒驗證過。
- progress 的語意 — COMPLETED 時
progress: 100是硬寫的;submit 的 70% 不是真進度。前端MonotonicSegmentedProgress某種程度上是在補這個洞。(推論)
抽出的原子筆記
- 瀏覽器內 SQLite:把查詢下推到 Worker 的 WASM heap
- JS 物件比原始資料胖好幾倍
- WASM linear memory 是 GC 管不到的記憶體
- Worker 與分頁共用同一份記憶體配額
- 記憶體峰值倍率:OOM 發生在轉換的瞬間
- Web Worker 不需要註冊,Service Worker 才需要
- 打包器靠 AST 形狀認出 worker,改寫法會靜默失效
- CompressionStream:瀏覽器內建的 gzip
- OPFS sync access handle 讓 SQLite 能在瀏覽器只讀用得到的 page
- 用二進位 artifact 當 API 的傳輸格式
- 長時間工作改成 202 加 workflowId 輪詢
- 前端的即時驗證是 UX,後端重驗才是 gate
- 失敗時只回錯誤集合,讓使用者就地修正
- 在共用 endpoint 加無條件檢查會打爛其他呼叫者
- 契約 breaking 又沒有分流時,前後端不能分開部署
- 規模型功能要有規模型測試