走讀前,你把「花壇場次在哪裡集合?」交給 LOCAL,服務卻剛好重新啟動。回來查時,還找得到原詢問嗎?今天把任務需要的紀錄交給 Firestore,讓新的行程有辦法接著處理。原本那件事,為什麼不必重填一次?
Day 11 moves an operation’s binding, confirmation, receipt, and recovery progress into a persistent store. A memory-only control shows what a fresh process loses; the Firestore path recovers the original request through a stored task reference, verified against the local Firestore emulator. We separate application restarts from database restarts, and test storage adapters, actual ADK integration, and Firestore independently.

圖 1:SQLite 測試 adapter 的六種跨行程接續情境。每一列的 A/B 都是不同的行程;可對照原單查回、寫入前到期,以及查回受阻時的結果。記憶體控制組的比較另見下方實測數據。
Day 10 已能用原送出鍵查回。今天先不製造逾時,只讓行程 A 正常收下一份詢問、結束,再啟動行程 B。兩組都做同一個工作:一組把任務全放在記憶體,另一組把必要紀錄存入持久化儲存(圖 1 先以無需額外相依的 SQLite 測試 adapter 示範原理,第四節則以真實 Firestore 模擬器實測)。
下表是這個實驗要核對的結果;實際 PID、單號與筆數由各次執行報告提供。
| 要比較什麼? | 記憶體控制組 | 持久化路線(驗收目標) |
|---|---|---|
| A 收下詢問後 | 有一份任務與請求 | 有一份任務與請求 |
| A 結束,B 啟動 | B 沒有 A 的任務參照 | B 從資料庫讀回原參照 |
| B 是否知道原本送什麼? | session_not_found |
找到原內容與送出鍵 |
| B 查回的請求 | 沒有單號可交代 | 與 A 同一單號,仍一筆 |
每一組各自建立自己的請求。我們比較的是「同一組前後是否認得原單」,不是要求兩組產生相同單號。
還沒準備 Firestore 環境,也可以先用標準函式庫的 SQLite 測試 adapter 跑這個接續流程(如圖 1);真正將紀錄交給 Firestore 模擬器的完整實測在第四節。
先替 SQLite 說句話:一般應用行程結束,不會讓已提交的資料庫檔案跟著消失。Day 10 尚未完整移出記憶體的,是原確認、任務參照與控制器進度。即使磁碟還有一張單,新行程也需要知道這個使用者剛才正在處理哪件事。
這像服務窗口換班。收件盒還在,接班的人卻需要知道「這位客人剛才交的是哪張單、現在做到哪一步」,才不用請他從頭說明。
到了 Cloud Run,還多一個條件:容器可寫檔案系統不是可跨實例保留的長期儲存,實例結束時內容也會消失。因此我把接續資料放進外部資料庫,為下一篇的雲端整合預作準備。[1]
所謂的 Stateless(無狀態) 在這裡指:服務接續所需的業務事實,交給外部儲存,而不是綁死在某個行程的記憶體。程式照樣可以有變數、快取,只是不能靠它們當唯一紀錄。
這次要保存四件事:
| 資料責任 | 集合 | 新行程想知道什麼? |
|---|---|---|
| 原操作綁定 | bindings |
哪個人、哪段對話,送的是哪份內容與哪把鍵? |
| 使用者確認 | confirmations |
當時同意什麼,何時到期? |
| 請求與回條 | requests |
後端收成哪張單,留下哪些內容? |
| 工作進度 | jobs |
做過幾次,接下來要查回、重送還是停止? |
另用 sessions 保存「這段邏輯對話目前處理哪筆任務」。這是 任務參照 ,不是全部聊天歷史;保存原操作,也不是讓模型重新猜一遍使用者的意思。
Firestore 是 Google 的 NoSQL 文件資料庫。這裡用一份 Document(文件) 保存一筆結構化紀錄,同類文件放在一個 Collection(集合) 裡;資料不使用關聯式資料表,也仍需要我們自己訂清楚欄位與關係。[2]
資料沿用 Day 9 的花壇場次快照與「請問花壇場次的集合地點在哪裡?」。快照日期維持原來的 2026-09-19,作為教學資料;今天沒有把它改寫成仍可報名的新活動。
對外仍有原送出鍵與請求單號。底下的文件識別,則由服務單位、使用者與送出鍵共同決定:
服務單位+使用者+原送出鍵 → 固定操作文件 → 原請求回條。
同鍵換了問題,應該得到衝突,而不是另外找個位置再存一次;真的要處理另一件事,才建立新的確認與操作。Session 也要核對,避免把原單帶到別段對話。
我把資料存取放在小型 Storage Adapter 後面:SQLiteTestStore 讓核心規則容易離線重跑,FirestoreStore 則使用真正 Firestore SDK。每次只選一條路線。Firestore 路線以 Firestore 的文件為準,不先寫 SQLite 再做第二次同步。
Firestore 交易讓相關讀寫一起提交;讀過的資料被其他操作改動時,交易函式可能重新執行。因此資料核對留在交易裡,Gemini 呼叫、LINE 訊息等外部動作放在外面。[3]
firestore_store.py 的介接核心如下:
@self.api.transactional
def apply(transaction):
return fn(FirestoreTx(self.root, transaction, read_only=read_only))
return apply(self.client.transaction(max_attempts=3, read_only=read_only))
service.py 先讀權限、原操作、既有請求與必要確認,再決定讀回或建立。新的單號候選值在交易外產生;若原位置已有請求,就交回原單,而不是因交易重跑另開一筆。
Day 8 的 execution_allowed: false 繼續保留:確認只是記錄使用者同意內容,不是執行通行證。今天能不能新增,仍要由後端核對原期限與當前權限。
初次準備操作時,可信測試入口呼叫原 ConfirmationStore.issue()/decide(),保存確認內容與期限。原類別沒有公開匯入方法,所以這篇新增 confirmation_gate.py 檢查持久化快照,並用固定案例對照前篇判斷;沒有把資料直接塞進舊類別的私人欄位,再假裝整個記憶體服務已經搬好了。
兩條路仍分開:
尚未建立 → 檢查原確認是否有效,再判斷能不能新增。
請求已存在 → 核對當前權限、本人、Session、鍵與內容,再讀回原回條。
所以,確認期限過了,可能阻止第一次寫入;已經建立的請求則不會因此被當成從未發生。重啟也不會把確認期限重新算一次。
請求的 pending_human_review 仍是待真人處理的資料標記,本篇只保存請求,沒有發出真人通知。
從含有前篇程式的 Repo 根目錄,先執行不需要額外套件的對照:
python3 examples/day11/demo.py --compare-memory --backend sqlite-test
開啟命令印出的 REPORT.html。先看兩組的 B 結果,再對 PID A/B。記憶體組應找不到原任務,SQLite 測試路線應讀回原單。它是理解資料責任的入口,不是 Firestore 的測試捷徑。
我的本機(macOS、Python 3.13.5)實測對照如下:
session_not_found,找不到原任務。req-20260924-2801b50c24a43c84)後退出;行程 B(PID 92645,記憶體全空)依原操作鍵讀回相同單號,資料庫維持 1 筆。如果我們想在本機跑真正的 Firestore SDK 該怎麼做?這不是單純安裝 Python 套件就能解決,因為 Google 的 Firestore 模擬器本質上是 Java 執行的本機服務。
我在 macOS 上的實戰安裝與啟動流程如下,讀者可以直接參考:
brew install openjdk@21,並在當前終端機設定 PATH(export PATH="/opt/homebrew/opt/openjdk@21/bin:$PATH",或依你的 Homebrew 前綴設定 export PATH="$(brew --prefix openjdk@21)/bin:$PATH")。這裡只在目前終端機指定 Java 路徑,不需使用 sudo。python3 -m venv examples/day11/.venv
examples/day11/.venv/bin/pip install -r examples/day11/requirements.txt -r examples/day11/requirements-sdk.txt
npx 免全域安裝 Firebase CLI,自動下載 cloud-firestore-emulator.jar 並在 127.0.0.1:8080 啟動服務:npx firebase-tools emulators:start --only firestore --project demo-local-day11 --config examples/day11/firebase.json

圖 2:本機終端機成功啟動 Google Firestore 模擬器。看到「All emulators ready」與 127.0.0.1:8080,代表本機模擬環境就緒,提供後續測試與接續演練所需的本機端點(模擬器不同於正式雲端環境)。
保持模擬器運作,在另一個終端機設定環境變數並執行實測:
export FIRESTORE_EMULATOR_HOST=127.0.0.1:8080
PY=examples/day11/.venv/bin/python
# 驗證模擬器套件與連線契約
$PY examples/day11/verify.py --group emulator
# 執行記憶體控制組 vs Firestore 模擬器對照
$PY examples/day11/demo.py --compare-memory --backend emulator
# 執行中途崩潰案例
$PY examples/day11/demo.py --backend emulator --case after_commit
這次本機在真實 Firestore 模擬器上的實測結果如下:
session_not_found,記憶體全空且資料筆數歸 0。req-20260925-5e791db9a2462b4f,Firestore 集合中的文件維持 1 筆。os._exit(73) 強制退出(73 只是我選的非零退出碼,用來跳過正常清理流程,證明沒有靠 finally 偷偷存檔)。req-20260925-3ec438b28a5a52c7),文件數始終為 1 筆,且沒有重複觸發確認流程。在模擬器上執行的完整六案例對照(demo.py --backend emulator)記錄如下:
| 案例 | PID A/B | A 後筆數 | B 後筆數 | B 結果 | 回傳單號 |
|---|---|---|---|---|---|
| normal | [48884, 48885] | 1 | 1 | already_created | req-20260925-3cf28f6eb32df50b |
| after_commit | [48888, 48890] | 1 | 1 | already_created | req-20260925-3ec438b28a5a52c7 |
| before_write | [48892, 48893] | 0 | 1 | request_created | req-20260925-71e4630083c21554 |
| expired_before_write | [48894, 48895] | 0 | 0 | expired | None |
| expired_after_commit | [48897, 48898] | 1 | 1 | already_created | req-20260925-8732c71bfb0a11cc |
| lookup_unavailable | [48901, 48904] | 1 | 1 | pending_verification | None |
報告要對到三件事: A/B 是不同 PID、B 回條對得上原文件、接續時沒有再取得一次同意。 先看每列的 PID A/B 不同,再看 expired_before_write 留 0 筆(寫入前到期被擋下)、expired_after_commit 仍讀回原單(已建立的不會被當成沒發生)——這正是第三節說明的那兩條路。SQLite 測試路線留真正的資料庫快照;Firestore 模擬器路線則由 SDK 直接讀取文件留存。
我的判讀很單純:
資料存在,還需要知道由誰接下一步。我用 租約(lease) 保存這一步暫時由哪個工作者處理、何時到期。工作者先用交易記錄嘗試次數與接續位置,再進行實際操作。
jobs.py 裡,這幾行決定了重新啟動也不能重算預算:
counter = 'lookups' if phase == 'lookup' else 'writes'
limit = 1 if counter == 'lookups' else 2
if job[counter] >= limit:
return {'result': pending('recovery_budget_exhausted')}
job[counter] += 1
job['phase'] = 'paused' if phase == 'lookup' else 'lookup'
每筆操作最多預留兩次寫入嘗試、一次自動查回;每次啟動最多三步。寫入中斷時,新工作者先查回;查回本身又中斷,就保留待查證,停止這次自動流程。這是有界的恢復方法,不是永遠重試到成功。
完成工作時還要核對租約 token,避免較晚回來的舊工作者蓋掉新進度。租約負責協調,資料防重複仍靠原鍵與交易。
測試用可控的假時鐘(邏輯時鐘)前移,演練租約到期,不必浪費時間 sleep 等待系統時間。這支 worker 有明確入口與次數上限,沒有假設 HTTP 回覆後的背景程式必定繼續執行。
新的 ADK Runner 先透過可信入口找回業務任務,再由工具接續。程式仍建立 InMemorySessionService;ADK Session 的舊對話事件沒有全部匯入,所以不要把這次稱為完整聊天記憶恢復。[5]
安裝 SDK 相依套件後,可以這樣分組驗證:
$PY examples/day11/verify.py --group core
$PY examples/day11/verify.py --sdk
$PY examples/day11/verify.py --group emulator
$PY examples/day11/demo.py --backend emulator --adk --case after_commit
核心測試沿用 Day 10 的驗收入口;ADK 群組用真正 Runner 與固定腳本模型;模擬器群組用真正 Firestore SDK 與本機模擬器。在我的本機上:離線核心回歸共 241 項全數通過 (前篇 144 項+本篇 97 項);加上真正 Google ADK Runner 搭配固定腳本模型的 32 項整合測試 (前篇 19 項+本篇 13 項),兩者合計 273 項全數通過 (即 verify.py --sdk 涵蓋範圍);在終端機啟動模擬器後,模擬器群組另有 59 項執行全數通過 (同一套契約檢查改在真正 Firestore SDK 與模擬器上重跑 57 項,加 2 項跨行程實測),三大群組累計 332 項執行全數通過 。缺少相依套件或服務不可用時,驗證會以非零狀態結束,不改用替身湊通過。
本章保留前篇 ci.yml,新增 day11.yml 跑新增的核心與 ADK 群組。推送後要看同一 commit 的兩份工作流,才知道前篇與本篇的哪些檢查真正執行過;Firestore 模擬器與正式雲端另列。
敏捷與 CI 的用法延續 Day 10:這次增加的是「原任務換行程也找得回」,後續再觀察接回 LINE 之後,使用者是否真的少了一次重填。
這次先保存任務、原確認、回條與有限工作進度。登入入口、正式 Cloud Run 部署、真人接手與完整聊天歷史,仍各有自己的整合工作。
我想留下的能力很具體: 服務換了一個行程,使用者原本交代的那件事,還能被認出來。
下一篇把已驗證的持久化與正常服務流程接向 Cloud Run,讓讀者從 LINE 入口,看見前面的資料、確認與請求如何合成同一段服務。
本篇程式目錄:examples/day11/
最小操作與排錯:README.md
驗收矩陣與證據讀法:ACCEPTANCE.md
資料與行為契約:CONTRACT.md
[1] Cloud Run 容器執行契約。
[2] Firestore 資料模型。
[3] Firestore 交易與批次寫入。
[5] Google ADK:Session。