feat: /scan 採 stale-while-revalidate,閒置時零呼叫
- /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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user