iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Build on Google AI

LOCAL:30 天打造 LINE × Google AI 地方服務 Agent系列 第 11 篇

Day 11|服務重啟了,剛才交代的事還在嗎?重送、背景工作與重啟恢復

  • 分享至 

  • xImage
  •  

走讀前,你把「花壇場次在哪裡集合?」交給 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.

先做一個對照:收下詢問的人換了,原單還在嗎?

LOCAL Day 11 跨行程查回對照實測結果
圖 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 模擬器的完整實測在第四節。

一、Day 10 的 SQLite 還在,究竟少了什麼?

先替 SQLite 說句話:一般應用行程結束,不會讓已提交的資料庫檔案跟著消失。Day 10 尚未完整移出記憶體的,是原確認、任務參照與控制器進度。即使磁碟還有一張單,新行程也需要知道這個使用者剛才正在處理哪件事。

這像服務窗口換班。收件盒還在,接班的人卻需要知道「這位客人剛才交的是哪張單、現在做到哪一步」,才不用請他從頭說明。

到了 Cloud Run,還多一個條件:容器可寫檔案系統不是可跨實例保留的長期儲存,實例結束時內容也會消失。因此我把接續資料放進外部資料庫,為下一篇的雲端整合預作準備。[1]

所謂的 Stateless(無狀態) 在這裡指:服務接續所需的業務事實,交給外部儲存,而不是綁死在某個行程的記憶體。程式照樣可以有變數、快取,只是不能靠它們當唯一紀錄。

這次要保存四件事:

資料責任 集合 新行程想知道什麼?
原操作綁定 bindings 哪個人、哪段對話,送的是哪份內容與哪把鍵?
使用者確認 confirmations 當時同意什麼,何時到期?
請求與回條 requests 後端收成哪張單,留下哪些內容?
工作進度 jobs 做過幾次,接下來要查回、重送還是停止?

另用 sessions 保存「這段邏輯對話目前處理哪筆任務」。這是 任務參照 ,不是全部聊天歷史;保存原操作,也不是讓模型重新猜一遍使用者的意思。

二、讓同一把送出鍵,在 Firestore 找到同一個位置

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)實測對照如下:

  • 記憶體控制組 :行程 A(PID 92639)結束後,行程 B(PID 92640)回傳 session_not_found,找不到原任務。
  • 持久化測試路線 :行程 A(PID 92644)寫入工單(單號 req-20260924-2801b50c24a43c84)後退出;行程 B(PID 92645,記憶體全空)依原操作鍵讀回相同單號,資料庫維持 1 筆。

真正將紀錄交給 Firestore:本機模擬器建置與實測

如果我們想在本機跑真正的 Firestore SDK 該怎麼做?這不是單純安裝 Python 套件就能解決,因為 Google 的 Firestore 模擬器本質上是 Java 執行的本機服務。

我在 macOS 上的實戰安裝與啟動流程如下,讀者可以直接參考:

  1. 安裝 Java 21 :Firestore 模擬器需要 Java 執行環境。透過 Homebrew 安裝 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。
  2. 準備獨立 Python 虛擬環境 :建立環境並安裝 Day 11 及其 SDK 相依套件:
python3 -m venv examples/day11/.venv
examples/day11/.venv/bin/pip install -r examples/day11/requirements.txt -r examples/day11/requirements-sdk.txt
  1. 終端機 A 啟動模擬器 :利用 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

LOCAL Day 11 本機啟動 Google Firestore 模擬器就緒畫面
圖 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 模擬器上的實測結果如下:

  • 記憶體控制組 vs Firestore 模擬器 :
    • 記憶體控制組 :行程 A(PID 48844)結束後,行程 B(PID 48845)回傳 session_not_found,記憶體全空且資料筆數歸 0。
    • Firestore 模擬器組 :行程 A(PID 48848)寫入工單後退出;接手的行程 B(PID 48849)讀回原單號 req-20260925-5e791db9a2462b4f,Firestore 集合中的文件維持 1 筆。
  • 中途崩潰強制中斷(after_commit) :
    • 行程 A(PID 48888)在提交後,呼叫 os._exit(73) 強制退出(73 只是我選的非零退出碼,用來跳過正常清理流程,證明沒有靠 finally 偷偷存檔)。
    • 接續的行程 B(PID 48890)直接由 Firestore 的集合讀回原操作四參數(送出鍵、確認單號、需求文字與事件編號)與單號(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 直接讀取文件留存。

我的判讀很單純:

  1. 記憶體控制組換了行程就查無資料,業務接續狀態不能只放在行程記憶體。
  2. 不論是輕量的 SQLite 測試 adapter 還是真正的 Firestore 模擬器,新行程都能依原鍵讀回同一單號,沒有重複建單,也沒有讓使用者重新確認一次。
  3. 模擬器自己停止後的資料保存與正式雲端條件各有不同,這次重啟的是應用行程;真正的正式雲端驗證,留待下一篇與 Cloud Run 一併完成。[4]

五、工作能接續,也要知道什麼時候停

資料存在,還需要知道由誰接下一步。我用 租約(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 接回任務,驗證也跟著分層

新的 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 之後,使用者是否真的少了一次重填。

七、今天保住原任務,下一篇讓它回到 LINE

這次先保存任務、原確認、回條與有限工作進度。登入入口、正式 Cloud Run 部署、真人接手與完整聊天歷史,仍各有自己的整合工作。

我想留下的能力很具體: 服務換了一個行程,使用者原本交代的那件事,還能被認出來。

下一篇把已驗證的持久化與正常服務流程接向 Cloud Run,讓讀者從 LINE 入口,看見前面的資料、確認與請求如何合成同一段服務。

程式與參考資料

前篇:Day 10|逾時後到底有沒有送出?。


上一篇
Day 10|逾時後到底有沒有送出?
下一篇
Day 12|換了雲端行程,剛才交代的事還在嗎?第一個 Cloud Run 版本
系列文
LOCAL:30 天打造 LINE × Google AI 地方服務 Agent 共 12 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言