chundev
Created: 2026-08-24
日期:2026-08-24
環境:macOS、APFS、Node.js、SQLite WAL、加密 SQLite CLI
候選 snapshot 必須先完成 single-flight 同步、來源/副本 SHA-256、實際 SQLite 開庫與核心查詢驗證,再進入 active namespace。Freshness-sensitive API 在同步失敗時應保留 last-known-good snapshot 供調查,但一般資料查詢必須 fail closed,避免把 stale data 當成最新事實。
本篇延續第一篇的一致性模型,集中說明 Node.js 實作、狀態機、restart loader、測試矩陣、容量維運與安全邊界。
第一篇建立的 candidate protocol 只解決「如何判斷副本可接受」。完整服務還必須回答:多個 request 同時到達時誰負責同步、process restart 後如何找回 active snapshot、同步失敗能否回舊資料、錯誤如何暴露,以及 append-only snapshot 如何避免變成新的磁碟風險。
同步器通常已經有一份舊 snapshot。
當新同步失敗時,最直覺的行為是回舊資料。
這對一般 dashboard 可能合理。
對「幫 agent 讀最新訊息」則危險。
Agent 無法從正常的 200 OK 分辨:
同步失敗時:
lastError 顯示原因。FALLBACK
|
| first successful sync
v
SNAPSHOT_CURRENT
|
| source changed
v
SYNCING
| |
| success | failure
v v
SNAPSHOT_NEW STALE_BLOCKED
|
| next query retry
v
SYNCING
建議回傳:
{
"enabled": true,
"activeKind": "snapshot",
"snapshotName": "snapshot-<timestamp>-<suffix>",
"lastAttemptAt": "<ISO timestamp>",
"lastSyncedAt": "<ISO timestamp>",
"sourceSignature": "<opaque signature>",
"lastError": null
}
不要回傳:
同一時間可能有多個 API request。
如果每個 request 都各自同步:
使用 single-flight promise:
class Synchronizer {
#inFlight: Promise<string> | null = null;
async ensureFresh(): Promise<string> {
if (this.#inFlight !== null) {
return await this.#inFlight;
}
this.#inFlight = this.#refresh().finally(() => {
this.#inFlight = null;
});
return await this.#inFlight;
}
}
這保證同一 process 內:
多 process 部署則還需要跨 process coordination。
可選方案:
不要輕率實作 stale lock 自動刪除。
錯刪活 lock 會重新引入競態。
Process restart 時需要找回最近有效 snapshot。
Loader 規則:
snapshot- prefix。.incoming。snapshot.json 存在。ensureFresh()。Snapshot metadata 範例:
{
"createdAt": "2026-08-24T12:00:00.000Z",
"sourceSignature": "<opaque>",
"databaseSha256": "<sha256>",
"walSha256": "<sha256>"
}
Metadata 寫入使用 exclusive create。
正式目錄名稱也必須唯一。
不要把「目錄存在」當作成功標記。
步驟:
ensureFresh()。驗證:
lastError 是 null。步驟:
ensureFresh()。驗證:
在 injected copier 完成第一個 copy 後,修改 source WAL。
驗證:
S0 != S1。lastError 明確指出 copy race。在 destination 寫入不同 bytes。
驗證:
模擬:
驗證:
lastError 不包含 secret。同時送出多個 request。
驗證:
先建立兩份 snapshot,再建立新 synchronizer。
驗證:
.incoming 被忽略。一組去識別化實測數據:
| 指標 | Before | After |
|---|---|---|
| 主 DB | 約 70 MiB | 約 70 MiB clone |
| WAL | 約 1.8 MiB | 約 2.0 MiB |
| Snapshot count | 1 | 2 |
| 可見 message rows | 120,701 | 120,703 |
| Unit tests | 10 | 14 |
| Sync error | N/A | null |
| 原始主 DB hash | 基準值 | 相同 |
來源 signature 未變時連續查詢:
snapshots_before=1
snapshots_after=1
來源 WAL 增長後執行下一次 query:
snapshots_before=1
snapshots_after=2
active_kind=snapshot
last_error=null
API 成功讀到基準點以後新增的 row。
typecheck: exit 0
lint: exit 0
tests: 14/14
build: exit 0
format: exit 0
real snapshot smoke: exit 0
這些數字只能證明該環境與該 workload。
它們不能證明所有 SQLite WAL database 都能用同樣延遲同步。
WAL 不一定永遠 append。
Checkpoint 完成後可能從頭覆寫或 reset。
所以不能只比較 WAL size 是否增加。
必須把 inode、mtime 與 size 一起看。
最後一個 connection 關閉時,SQLite 可能 checkpoint 並移除 WAL/SHM。
同步器必須接受:
wal:present -> wal:none
這也是 source signature 變動。
相同路徑可能指向新 inode。
只比較 path 或 size 會漏掉。
SQLite 官方說明 WAL 需要同一 host 上的 shared memory coordination。
不要把本機 WAL 同步方案直接搬到 NFS/SMB。
SQLite URI 的 immutable=1 是強假設。
如果來源其實會改,錯用 immutable 可能忽略 WAL 或 locking reality。
copyFile() 非原子Node.js 文件明確指出 copy operation 沒有 atomicity 保證。
所以必須:
Failing candidate 若不刪除會占用空間。
自動刪除又是破壞性操作。
可選策略:
Append-only 的優點:
缺點:
低磁碟空間下常見假象:
同步前應先觀察 disk free space。
不要在沒有授權下自動刪除其他專案資料。
訊息 row 可能只有:
這不表示附件 bytes 已在本機。
API 應回:
metadata-only
cached
unavailable
不要把 metadata-only 呈現成已看見圖片或檔案。
| 欄位 | 用途 |
|---|---|
lastAttemptAt |
最近一次 freshness check |
lastSyncedAt |
最近一次成功切換 |
lastError |
是否 stale-blocked |
snapshotName |
active version |
sourceSignature |
是否觀察到來源變動 |
| snapshot count | 容量趨勢 |
| incoming count | race 或 validation failure 趨勢 |
| disk free | 避免磁碟壓力假錯 |
lastError 持續非 null。lastAttemptAt - lastSyncedAt 超過容許範圍。實作自動刪除前要回答:
在答案不明時,append-only 是較保守的預設。
推薦:
/v1/* 需要 bearer token。所有 API query 應是固定 SQL template。
使用者文字不要直接插入 SQL。
若 CLI 無 parameter binding,可把 UTF-8 轉成 hex blob literal:
function sqlText(value: string): string {
const hex = Buffer.from(value, "utf8").toString("hex");
return `CAST(X'${hex}' AS TEXT)`;
}
數值參數必須先經 integer schema validation。
仍需拒絕多 statement。
Commit 前執行:
exact database key matches in trackable files = 0
exact API token matches in trackable files = 0
不要輸出 secret 本身。
只輸出 match count。
不要記錄:
可以記錄:
copy database.db snapshot.db
❌ 問題:最近 commit 可能只在 WAL。
copy database.db snapshot.db
copy database.db-wal snapshot.db-wal
❌ 問題:兩次 copy 間 source 可能變動。
S0 = stat source
copy DB + WAL
S1 = stat source
accept if S0 == S1
⚠️ 改善:可偵測多數 copy race。
❌ 問題:仍未證明 destination bytes 等於 source。
S0
copy
S1
hash source + copy
S2
accept if signatures and hashes match
⚠️ 改善:建立內容等同性證據。
❌ 問題:副本仍可能無法被 SQLite 正確解讀。
validate sqlite_master
validate core table
validate business count
✅ 改善:候選必須能實際查詢。
.incoming/candidate
-> validate
-> rename snapshot-<version>
-> switch active
✅ 改善:不完整 candidate 永遠不可見。
sync failure
-> keep last good snapshot
-> block normal query
-> expose sanitized status
-> retry next query
✅ 改善:消除 silent stale success。
一個可宣稱完成的同步器至少要有:
只完成「copy command exit 0」不算完成。
只完成「可以打開 DB」也不算完成。
真正的完成是:
來源發生可觀察的新 commit,下一次 API query 自動建立並切換新 snapshot,讀到新增 row,且來源資料未被同步器改動。
資料同步的核心不是「把檔案搬過去」,而是定義可驗證的發布條件。來源觀察、候選建立、內容驗證、語意驗證與 active 切換,是五個不同的責任;把它們壓成一條 cp 指令,只是把一致性風險藏起來。
同一套模式也適用於其他 immutable artifact pipeline:下載模型、匯入索引、產生報表、更新規則庫。共同原則是 candidate 永遠不等於 release;只有通過內容與語意驗證的 candidate,才有資格被原子地發布給讀者。
對 Agent 系統尤其如此。Agent 會把 API 的正常回應視為事實。如果系統無法區分「真的沒有新資料」與「同步失敗所以只剩舊資料」,再好的推理也只是在錯誤前提上工作。Freshness 必須成為 API contract,而不是藏在維運文件裡的期望。
wal.c source — 官方 Git mirror 的 WAL frame、wal-index、SHM transient 性質與 reader end mark 實作註解。COPYFILE_FICLONE、fallback 行為,以及 copyFile() 不保證 atomicity。COPYFILE_CLONE 與 safe-save API 概覽。