From 2f7285437b7abdd8d9533d0d8b109c0d3a3b5715 Mon Sep 17 00:00:00 2001 From: Timmy Date: Mon, 20 Apr 2026 09:30:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20/scan=20=E6=8E=A1=20stale-while-revalid?= =?UTF-8?q?ate=EF=BC=8C=E9=96=92=E7=BD=AE=E6=99=82=E9=9B=B6=E5=91=BC?= =?UTF-8?q?=E5=8F=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /scan 永遠秒回當前 Redis 快取,若 TTL < STALE_THRESHOLD 或 key 不存在,用 daemon thread 呼叫 radar_fetcher.main() 非同步更新 - 以 Redis SET NX EX 當互斥 lock(pokemon:radar:fetching,TTL 180s)避免並發 fetcher - 新增設定:STALE_THRESHOLD=120、FETCH_LOCK_KEY、FETCH_LOCK_TTL=180 - 原本需要 cron 定期跑 fetcher;現在 API 端會自動觸發,沒人打就不動 驗證: - cold start /scan: 26ms 回空、同時搶 lock - 背景 fetcher 跑完(~16s Function Server)後寫入 Redis 並釋放 lock - 下一次 /scan 13ms 回 96 筆、TTL 299s - 快取新鮮時重複打 /scan 不會重複觸發(refresh counter=1) Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 29 ++++++++++++++----------- pokemon_radar_api.py | 50 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 56e86cf..1b712b3 100644 --- a/README.md +++ b/README.md @@ -14,16 +14,7 @@ # 起本機 Redis(一次性) docker run -d --name pokemon-radar-redis --restart unless-stopped -p 6379:6379 redis:7-alpine -# 跑一次 fetcher,把資料抓進 Redis(TTL 300s) -uv run python radar_fetcher.py - -# 看 Redis 目前有什麼(rich 表格) -uv run python reader.py - -# 看 Redis raw JSON -uv run python check_redis.py - -# 起 API +# 起 API(日常只要這一個) ./run_pokemon_radar_api.sh # 內部執行:uv run uvicorn pokemon_radar_api:app --host 0.0.0.0 --port 8008 --log-config logging.ini @@ -33,7 +24,17 @@ curl -s -X POST http://127.0.0.1:8008/scan -H "Content-Type: application/json" \ -d '{"lat":25.0478,"lng":121.5170,"min_iv":90}' ``` -**生產環境 cron**:`./fetch_and_upload.sh` — 走的是**舊 pipeline**(`pokemon_location_fetcher.py` 直接輸出本地 JSON,`scp` 到 `192.168.42.104` 的 caddy),不經 Redis,不經 API。跟 fetcher/API 這條線沒關係、請不要混淆。 +**通常不用手動跑 fetcher**。API 採 stale-while-revalidate:`/scan` 永遠秒回當前快取,若快取剩餘 TTL 低於 `STALE_THRESHOLD`(預設 120s)或 key 不存在,會在背景 thread 觸發 `radar_fetcher.main()` 更新;同時用 Redis lock(`pokemon:radar:fetching`,TTL 180s)避免重複觸發。沒人打 `/scan` 時不會動,閒置零呼叫。 + +需要偵錯或手動拉資料時: + +```bash +uv run python radar_fetcher.py # 手動拉一次寫入 Redis +uv run python reader.py # rich 表格看目前快取 +uv run python check_redis.py # raw JSON dump +``` + +**生產環境 cron**:`./fetch_and_upload.sh` — 走的是**舊 pipeline**(`pokemon_location_fetcher.py` 直接輸出本地 JSON,`scp` 到 `192.168.42.104` 的 caddy),不經 Redis,不經 API。跟這條線沒關係、請不要混淆。 沒有 test / lint / build。 @@ -56,6 +57,9 @@ Secrets 放 `.env`(gitignore)。本機與生產共用同一份格式、只 | `REDIS_HOST` / `REDIS_PORT` / `REDIS_DB` / `REDIS_PASSWORD` | 全部 | 本機 `127.0.0.1` / 生產 `192.168.42.211` | 預設本機無密碼 | | `REDIS_TTL` | fetcher | `300` | 寫入 TTL | | `REDIS_KEY` | 全部 | `pokemon:radar:latest` | | +| `STALE_THRESHOLD` | API | `120` | 快取剩餘 TTL 低於此值(秒)就在背景觸發 fetcher | +| `FETCH_LOCK_KEY` | API | `pokemon:radar:fetching` | 背景刷新互斥 lock 的 Redis key | +| `FETCH_LOCK_TTL` | API | `180` | lock 自動過期時間,應 >= fetcher 最長執行時間 | --- @@ -128,7 +132,8 @@ pokemon_radar_api.py :8008 ### 非看不可的設計決策 -- **為什麼要 Redis**:v1 是每個 `/scan` 都打 Function Server,一次 browser automation 要 30-60s,多人同時打直接壞。拆成 writer(fetcher 定期 cron)+ reader(API 讀快取)後壓力從「每 request」降到「每 TTL」。 +- **為什麼要 Redis**:v1 是每個 `/scan` 都打 Function Server,一次 browser automation 要 30-60s,多人同時打直接壞。拆成 writer(fetcher)+ reader(API 讀快取)後壓力從「每 request」降到「每 TTL」。 +- **為什麼 stale-while-revalidate(v2.1)**:原本的 cron 方案「沒人用也一直打」,會被 twpkinfo 標記、也浪費 Function Server 資源。現在改為 API 端觸發:`/scan` 秒回當前 Redis 快取(即使快過期),若 TTL < `STALE_THRESHOLD` 或 key 不存在,用 `threading.Thread` 呼叫 `radar_fetcher.main()` 非同步更新;以 Redis `SET NX EX` 做互斥 lock 避免並發。閒置時完全靜止。 - **為什麼兩條 pipeline 並存**:caddy 前端走靜態 JSON(舊 CLI 的產物),沒理由為它引 Redis。所以 A 走 Redis 給 API,B 走本地檔給靜態前端,**兩條都還在跑**。 - **`twpk_radar.mjs` 靠 regex 注入參數**:Function Server 的契約是「你丟 JS 過來我跑」,所以參數化只能在字串層做。三個錨點別改形狀: - `const TARGET = { lat: ..., lng: ..., zoom: ... };` diff --git a/pokemon_radar_api.py b/pokemon_radar_api.py index 5a22701..2b3a9b1 100644 --- a/pokemon_radar_api.py +++ b/pokemon_radar_api.py @@ -1,28 +1,36 @@ import json import os import sys +import threading +import time from typing import Any, Dict, List, Optional import redis from dotenv import load_dotenv from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware +from loguru import logger from pydantic import BaseModel, Field -# 引用舊有的 Client 主要是為了使用它的 postprocess 篩選邏輯,不進行連線 -from pokemon_location_fetcher import PokemonRadarClient - -# 載入環境變數 +# 載入環境變數(要在 import radar_fetcher 之前,讓它 module-level 讀到) load_dotenv() +import radar_fetcher +from pokemon_location_fetcher import PokemonRadarClient + # ========= 設定 ========= -# Redis 連線資訊 (指向 192.168.42.211) REDIS_HOST = os.getenv("REDIS_HOST", "192.168.42.211") REDIS_PORT = int(os.getenv("REDIS_PORT", 6379)) REDIS_DB = int(os.getenv("REDIS_DB", 0)) REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", None) REDIS_KEY = "pokemon:radar:latest" +# Stale-while-revalidate:快取剩餘 TTL 低於此值時,在背景觸發 fetcher 更新 +STALE_THRESHOLD = int(os.getenv("STALE_THRESHOLD", 120)) +# 互斥 lock,避免同時多個 fetcher;FETCH_LOCK_TTL 要 >= fetcher 最長執行時間 +FETCH_LOCK_KEY = os.getenv("FETCH_LOCK_KEY", "pokemon:radar:fetching") +FETCH_LOCK_TTL = int(os.getenv("FETCH_LOCK_TTL", 180)) + # ========= FastAPI 初始化 ========= app = FastAPI(title="Pokemon Radar API (Redis Backed)", version="2.0.0") @@ -120,6 +128,34 @@ def get_data_from_redis(): _filter_helper = PokemonRadarClient(url="", token="") +# ========= Stale-while-revalidate 背景刷新 ========= +def _trigger_background_refresh() -> None: + """若沒有其他 fetcher 在跑,開一個 daemon thread 更新 Redis 快取。非阻塞。""" + try: + r = redis.Redis(connection_pool=redis_pool) + acquired = r.set(FETCH_LOCK_KEY, str(time.time()), nx=True, ex=FETCH_LOCK_TTL) + except redis.exceptions.RedisError as e: + logger.warning(f"refresh lock check failed: {e}") + return + if not acquired: + return # 有其他 fetcher 正在跑 + + def _run() -> None: + try: + logger.info("background refresh: start") + radar_fetcher.main() + logger.info("background refresh: done") + except Exception as e: + logger.exception(f"background refresh failed: {e}") + finally: + try: + redis.Redis(connection_pool=redis_pool).delete(FETCH_LOCK_KEY) + except redis.exceptions.RedisError: + pass + + threading.Thread(target=_run, daemon=True, name="radar-refresh").start() + + # ========= 路由 ========= @app.get("/ping") def ping(): @@ -131,6 +167,10 @@ def scan(req: ScanRequest) -> Any: # 1. 從 Redis 讀取資料 data, ttl, err = get_data_from_redis() + # Stale-while-revalidate:Redis 可達且(key 不存在 or TTL 快過期)時在背景觸發更新 + if err is None and (data is None or (0 < ttl < STALE_THRESHOLD)): + _trigger_background_refresh() + if not data: # Redis 沒資料或連不上,都回傳空結果而不是 500,避免前端炸掉 return {