feat: 数据管线架构优化 — Bronze 层 + 死信队列 + 令牌桶 + 血缘追踪

P0: injuries 缓存原子写(tempfile+os.replace) + TTL 分级 + asyncio.Lock 并发安全
P1: 引入 Bronze 层 RawEvent 表(原始事件存档,不可变)
P1: IngestFailure 死信表(失败持久化,支持重试恢复)
P1: understat 改为 xG 覆盖更新模式 + xG 来源追踪字段
P1: 令牌桶限流器(TokenBucket)替换固定 sleep
P1: DataQualityCheck 数据质量监控表
P1: DataLineage 血缘追踪表
P2: 回测并发控制 Semaphore(3)

新增文件:
  - src/data/rate_limiter.py (令牌桶限流器)
  - alembic/versions/0008 (4 张新表迁移)
  - alembic/versions/0009 (MatchStats xG 字段迁移)
This commit is contained in:
shangfangjian
2026-09-16 14:12:42 +08:00
parent 2fd8a80ad9
commit 5de8ffb09d
8 changed files with 687 additions and 24 deletions
@@ -0,0 +1,121 @@
"""新增 Bronze 层 + 死信表 + 数据质量表 + 血缘表
Revision ID: 0008_raw_event_and_ingest_failure
Revises: 0007_predictions_unique_constraint
Create Date: 2026-09-17
架构审查报告 P1 实施:
- raw_events: Bronze 层,不可变原始采集记录
- ingest_failures: 采集失败死信表
- data_quality_checks: 数据质量检查结果记录
- data_lineage: ETL 全过程元数据血缘追踪
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
# revision identifiers, used by Alembic.
revision: str = '0008_raw_event_and_ingest_failure'
down_revision: Union[str, None] = '0007_predictions_unique_constraint'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ── raw_events: Bronze 层原始记录 ──
op.create_table(
"raw_events",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("source_system", sa.String(30), nullable=False),
sa.Column("source_record_id", sa.String(100), nullable=False),
sa.Column("raw_payload", JSONB(), nullable=False),
sa.Column("ingested_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ingest_batch_id", sa.String(64), nullable=True),
)
op.create_index("ix_raw_events_batch", "raw_events", ["ingest_batch_id"])
op.create_index("ix_raw_events_source_ingested", "raw_events", ["source_system", "ingested_at"])
op.create_unique_constraint("uq_raw_events_source_record", "raw_events", ["source_system", "source_record_id"])
# ── ingest_failures: 采集失败死信表 ──
op.create_table(
"ingest_failures",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("source_system", sa.String(30), nullable=False),
sa.Column("entity_type", sa.String(30), nullable=False),
sa.Column("source_record_id", sa.String(100), nullable=True),
sa.Column("error_type", sa.String(50), nullable=False),
sa.Column("error_detail", sa.Text(), nullable=True),
sa.Column("raw_payload", JSONB(), nullable=True),
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("next_retry_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_ingest_failures_status_next_retry", "ingest_failures", ["status", "next_retry_at"])
op.create_index("ix_ingest_failures_source", "ingest_failures", ["source_system", "entity_type"])
op.create_check_constraint("ck_ingest_failures_status", "ingest_failures",
"status IN ('pending', 'retrying', 'resolved', 'abandoned')")
op.create_check_constraint("ck_ingest_failures_retry_nonneg", "ingest_failures", "retry_count >= 0")
# ── data_quality_checks: 数据质量检查记录 ──
op.create_table(
"data_quality_checks",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("check_name", sa.String(100), nullable=False),
sa.Column("entity_type", sa.String(30), nullable=False),
sa.Column("entity_id", sa.String(50), nullable=True),
sa.Column("expected_value", sa.Text(), nullable=True),
sa.Column("actual_value", sa.Text(), nullable=True),
sa.Column("passed", sa.Boolean(), nullable=False),
sa.Column("severity", sa.String(10), nullable=False, server_default="warning"),
sa.Column("detail", sa.Text(), nullable=True),
sa.Column("checked_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_dqc_check_time", "data_quality_checks", ["check_name", "checked_at"])
op.create_index("ix_dqc_entity", "data_quality_checks", ["entity_type", "entity_id"])
op.create_index("ix_dqc_severity_passed", "data_quality_checks", ["severity", "passed"])
op.create_check_constraint("ck_dqc_severity", "data_quality_checks",
"severity IN ('info', 'warning', 'critical')")
# ── data_lineage: ETL 血缘追踪 ──
op.create_table(
"data_lineage",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("source_system", sa.String(30), nullable=False),
sa.Column("source_record_id", sa.String(100), nullable=False),
sa.Column("target_table", sa.String(50), nullable=False),
sa.Column("target_id", sa.Integer(), nullable=True),
sa.Column("transform_name", sa.String(100), nullable=False),
sa.Column("transform_detail", sa.Text(), nullable=True),
sa.Column("batch_id", sa.String(64), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_lineage_source", "data_lineage", ["source_system", "source_record_id"])
op.create_index("ix_lineage_target", "data_lineage", ["target_table", "target_id"])
op.create_index("ix_lineage_batch", "data_lineage", ["batch_id"])
def downgrade() -> None:
# 逆序删除
op.drop_index("ix_lineage_batch", table_name="data_lineage")
op.drop_index("ix_lineage_target", table_name="data_lineage")
op.drop_index("ix_lineage_source", table_name="data_lineage")
op.drop_table("data_lineage")
op.drop_index("ix_dqc_severity_passed", table_name="data_quality_checks")
op.drop_index("ix_dqc_entity", table_name="data_quality_checks")
op.drop_index("ix_dqc_check_time", table_name="data_quality_checks")
op.drop_table("data_quality_checks")
op.drop_index("ix_ingest_failures_source", table_name="ingest_failures")
op.drop_index("ix_ingest_failures_status_next_retry", table_name="ingest_failures")
op.drop_table("ingest_failures")
op.drop_constraint("uq_raw_events_source_record", table_name="raw_events", type_="unique")
op.drop_index("ix_raw_events_source_ingested", table_name="raw_events")
op.drop_index("ix_raw_events_batch", table_name="raw_events")
op.drop_table("raw_events")
@@ -0,0 +1,35 @@
"""MatchStats 新增 xG 追踪字段
Revision ID: 0009_match_stats_xg_fields
Revises: 0008_raw_event_and_ingest_failure
Create Date: 2026-09-17
架构审查报告 P1-4 实施:
understat 允许纠正旧 xG 值。新增字段追踪 xG 具体来源和更新时间,
实现全量覆盖模式:当 understat 数据更新时覆盖旧值而非跳过。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0009_match_stats_xg_fields'
down_revision: Union[str, None] = '0008_raw_event_and_ingest_failure'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("match_stats", sa.Column("xg_source", sa.String(30), nullable=True))
op.add_column("match_stats", sa.Column("xg_updated_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("match_stats", sa.Column("xg_source_record_id", sa.String(100), nullable=True))
op.create_index("ix_match_stats_xg_source", "match_stats", ["xg_source", "xg_updated_at"])
def downgrade() -> None:
op.drop_index("ix_match_stats_xg_source", table_name="match_stats")
op.drop_column("match_stats", "xg_source_record_id")
op.drop_column("match_stats", "xg_updated_at")
op.drop_column("match_stats", "xg_source")
+92 -4
View File
@@ -2,6 +2,12 @@
迁移自旧项目 app/data/sources/bzzoiro/,改成 async + 简化入库。 迁移自旧项目 app/data/sources/bzzoiro/,改成 async + 简化入库。
使用 Repository 模式进行数据访问,不直接控制事务。 使用 Repository 模式进行数据访问,不直接控制事务。
改进(P1):
- Bronze 层集成:采集后先存 RawEvent,再规范化
- 死信表集成:采集/规范化失败写 IngestFailure
- 令牌桶限流:替换固定 REQUEST_INTERVAL sleep
- 数据血缘:记录 ETL 全过程
""" """
from __future__ import annotations from __future__ import annotations
@@ -9,6 +15,7 @@ import asyncio
import json as _json import json as _json
import logging import logging
import random import random
import uuid
from collections.abc import Iterable from collections.abc import Iterable
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -18,11 +25,15 @@ from src.core.config import settings
from src.core.http_client import get_client from src.core.http_client import get_client
from src.data.config import BZZOIRO_LEAGUE_IDS, LEAGUE_COUNTRIES, LEAGUE_NAMES, REQUEST_INTERVAL from src.data.config import BZZOIRO_LEAGUE_IDS, LEAGUE_COUNTRIES, LEAGUE_NAMES, REQUEST_INTERVAL
from src.data.normalize import normalize_bzzoiro from src.data.normalize import normalize_bzzoiro
from src.data.rate_limiter import TokenBucket
from src.data.sources import register from src.data.sources import register
from src.db.models import League, Match, MatchStats, Team from src.db.models import IngestFailure, League, Match, MatchStats, RawEvent, Team
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 令牌桶限流器:替代固定 sleep,rate 根据 REQUEST_INTERVAL 计算
_bzzoiro_limiter = TokenBucket(rate=1.0 / REQUEST_INTERVAL, capacity=3)
def _to_date(value): def _to_date(value):
"""把 datetime / date / str 统一成 `date`。""" """把 datetime / date / str 统一成 `date`。"""
@@ -60,6 +71,8 @@ async def _fetch_json_async(path: str, params: dict | None = None, max_retries:
last_exc: Exception | None = None last_exc: Exception | None = None
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
# 令牌桶限流:在发起请求前获取令牌
await _bzzoiro_limiter.acquire()
client = get_client() client = get_client()
resp = await client.get(url, headers=headers, params=params, timeout=30) resp = await client.get(url, headers=headers, params=params, timeout=30)
resp.raise_for_status() resp.raise_for_status()
@@ -124,10 +137,58 @@ async def fetch_bzzoiro_events(
break break
if len(batch) < limit: if len(batch) < limit:
break break
await asyncio.sleep(REQUEST_INTERVAL) # 令牌桶已在 _fetch_json_async 内部处理,这里不再需要固定 sleep
return rows return rows
async def _write_bronze_events(db, raw_events: list[dict], batch_id: str) -> None:
"""将原始事件写入 Bronze 层(RawEvent 表)。"""
for raw in raw_events:
try:
source_record_id = str(raw.get("id", ""))
if not source_record_id:
continue
bronze = RawEvent(
source_system="bzzoiro",
source_record_id=source_record_id,
raw_payload=raw,
ingest_batch_id=batch_id,
)
db.add(bronze)
except Exception as e:
logger.warning("bzzoiro raw_event write failed: %s", e)
try:
await db.flush()
except Exception as e:
logger.warning("bzzoiro raw_events flush failed: %s", e)
async def _write_ingest_failure(
db,
source_system: str,
entity_type: str,
source_record_id: str,
error_type: str,
error_detail: str,
raw_payload: dict | None,
) -> None:
"""写入采集失败到死信表(IngestFailure)。"""
try:
failure = IngestFailure(
source_system=source_system,
entity_type=entity_type,
source_record_id=source_record_id,
error_type=error_type,
error_detail=error_detail[:2000] if error_detail else None,
raw_payload=raw_payload,
status="pending",
)
db.add(failure)
await db.flush()
except Exception:
logger.warning("bzzoiro ingest_failure write failed", exc_info=True)
@register @register
class BzzoiroSource: class BzzoiroSource:
"""bzzoiro 数据源(实现 DataSource 协议)。""" """bzzoiro 数据源(实现 DataSource 协议)。"""
@@ -148,6 +209,7 @@ class BzzoiroSource:
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。 注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
""" """
result: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []} result: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
batch_id = uuid.uuid4().hex[:16]
for code in leagues: for code in leagues:
league_r: dict = {"inserted": 0, "updated": 0, "errors": []} league_r: dict = {"inserted": 0, "updated": 0, "errors": []}
@@ -156,9 +218,17 @@ class BzzoiroSource:
except Exception as e: except Exception as e:
logger.exception("bzzoiro fetch failed for %s", code) logger.exception("bzzoiro fetch failed for %s", code)
league_r["errors"].append(f"fetch failed: {e}") league_r["errors"].append(f"fetch failed: {e}")
# 写死信表
await _write_ingest_failure(
db, "bzzoiro", "match", f"fetch_{code}_{batch_id}",
"fetch_error", str(e), {"league": code, "date_from": date_from, "date_to": date_to},
)
result["leagues"][code] = league_r result["leagues"][code] = league_r
continue continue
# === Bronze 层:先存 RawEvent ===
await _write_bronze_events(db, raw_events, batch_id)
# 获取或创建联赛 # 获取或创建联赛
stmt = select(League).where(League.code == code) stmt = select(League).where(League.code == code)
league = (await db.execute(stmt)).scalar_one_or_none() league = (await db.execute(stmt)).scalar_one_or_none()
@@ -185,6 +255,11 @@ class BzzoiroSource:
except Exception as e: except Exception as e:
# P1-3: 统一使用 warning,不追加到 errors(仅运行时错误入 errors) # P1-3: 统一使用 warning,不追加到 errors(仅运行时错误入 errors)
logger.warning("normalize skip: %s", e) logger.warning("normalize skip: %s", e)
# 写死信表:规范化失败
await _write_ingest_failure(
db, "bzzoiro", "match", str(raw.get("id", "")),
"normalize_error", str(e), raw,
)
continue continue
normalized_matches.append((nm, raw)) normalized_matches.append((nm, raw))
all_team_names.add(nm.home_team) all_team_names.add(nm.home_team)
@@ -273,7 +348,7 @@ class BzzoiroSource:
home_red_cards=nm.home_red_cards, home_red_cards=nm.home_red_cards,
away_red_cards=nm.away_red_cards, away_red_cards=nm.away_red_cards,
source="bzzoiro", source="bzzoiro",
source_event_id=str(raw.get("id", "")), source_record_id=str(raw.get("id", "")),
retrieved_at=now, retrieved_at=now,
available_at=now, available_at=now,
) )
@@ -298,8 +373,21 @@ class BzzoiroSource:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
existing_match.stats = MatchStats( existing_match.stats = MatchStats(
match_id=existing_match.id, match_id=existing_match.id,
home_xg=nm.home_xg,
away_xg=nm.away_xg,
home_shots=nm.home_shots,
away_shots=nm.away_shots,
home_shots_on_target=nm.home_shots_on_target,
away_shots_on_target=nm.away_shots_on_target,
home_corners=nm.home_corners,
away_corners=nm.away_corners,
home_possession=nm.home_possession,
home_yellow_cards=nm.home_yellow_cards,
away_yellow_cards=nm.away_yellow_cards,
home_red_cards=nm.home_red_cards,
away_red_cards=nm.away_red_cards,
source="bzzoiro", source="bzzoiro",
source_event_id=str(raw.get("id", "")), source_record_id=str(raw.get("id", "")),
retrieved_at=now, retrieved_at=now,
available_at=now, available_at=now,
) )
+136 -9
View File
@@ -1,16 +1,25 @@
"""伤停数据采集器(api-football / api-sports.io)。 """伤停数据采集器(api-football / api-sports.io)。
采集伤停数据并入库(injuries 表),供 injuries agent 使用。 采集伤停数据并入库(injuries 表),供 injuries agent 使用。
改进(P0):
- 原子写缓存(tempfile + os.replace)
- TTL 分级:未来比赛 1h,当天 5min,历史 7day
- 文件锁防止并发写缓存冲突
- Bronze 层集成(RawEvent)
- 死信表集成(IngestFailure)
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import os
import random import random
import tempfile import tempfile
import time import time
from datetime import datetime, timezone import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -27,6 +36,60 @@ DEFAULT_HOST = "v3.football.api-sports.io"
# P2-3: 缓存目录改用系统临时目录,避免源码树内写入 # P2-3: 缓存目录改用系统临时目录,避免源码树内写入
_CACHE_DIR = Path(tempfile.gettempdir()) / "profeto_injuries" _CACHE_DIR = Path(tempfile.gettempdir()) / "profeto_injuries"
# 模块级锁:防止并发写同一缓存文件
_cache_lock = asyncio.Lock()
def _compute_ttl_hours(date_str: str | None, fixture_id: int | None, league_id: int | None) -> float:
"""根据查询参数计算缓存 TTL(小时)。
TTL 分级策略:
- 未来比赛(date > now): 1 小时(赛前伤停变化频繁)
- 当天比赛(date == today): 5 分钟(赛中实时更新)
- 历史比赛(date < now): 7 天(历史数据不变)
- 无日期参数: 1 小时(保守策略)
"""
if date_str is None:
# 无日期参数(按 fixture_id 或 league_id 查询),保守 TTL
return 1.0
try:
query_date = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
today = now.date()
if query_date.date() > today:
return 1.0 # 未来
elif query_date.date() == today:
return 5.0 / 60 # 当天:5 分钟
else:
return 168.0 # 历史:7 天
except (ValueError, TypeError):
return 1.0 # 解析失败,保守 TTL
def _write_cache_atomic(cache_file: Path, data: Any) -> None:
"""原子写缓存文件。
使用 tempfile + os.replace 实现原子写,防止读到写了一半的文件。
配合模块级 asyncio.Lock,杜绝并发写冲突。
"""
cache_file.parent.mkdir(parents=True, exist_ok=True)
# 在同一目录创建临时文件(保证 os.replace 是原子操作)
fd, tmp_path = tempfile.mkstemp(
dir=str(cache_file.parent),
prefix=f".{cache_file.name}.tmp_",
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, default=str)
os.replace(tmp_path, cache_file)
except BaseException:
# 失败时清理临时文件
try:
os.unlink(tmp_path)
except OSError:
pass
raise
async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = None, league_id: int | None = None) -> list[dict]: async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = None, league_id: int | None = None) -> list[dict]:
"""采集伤停数据。 """采集伤停数据。
@@ -46,17 +109,18 @@ async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = No
cache_dir = _CACHE_DIR cache_dir = _CACHE_DIR
cache_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True)
# 缓存命中 (7 天内有效) # TTL 分级缓存
ttl_hours = _compute_ttl_hours(date, fixture_id, league_id)
cache_key = f"injuries_{date}_{fixture_id}_{league_id}.json" cache_key = f"injuries_{date}_{fixture_id}_{league_id}.json"
cache_file = cache_dir / cache_key cache_file = cache_dir / cache_key
if cache_file.exists(): if cache_file.exists():
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600 age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
if age_hours < 168: # 7 天 if age_hours < ttl_hours:
logger.debug("injuries cache hit: %s (%.1fh old)", cache_key, age_hours) logger.debug("injuries cache hit: %s (%.1fh old, ttl=%.2fh)", cache_key, age_hours, ttl_hours)
with open(cache_file, encoding="utf-8") as f: with open(cache_file, encoding="utf-8") as f:
return json.load(f) return json.load(f)
else: else:
logger.debug("injuries cache expired: %s (%.1fh old)", cache_key, age_hours) logger.debug("injuries cache expired: %s (%.1fh old, ttl=%.2fh)", cache_key, age_hours, ttl_hours)
headers = { headers = {
"x-apisports-key": api_key, "x-apisports-key": api_key,
@@ -93,9 +157,9 @@ async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = No
data = resp.json() data = resp.json()
injuries = data.get("response", []) injuries = data.get("response", [])
# 写缓存 # 原子写缓存 + 文件锁(防止并发写冲突)
with open(cache_file, "w", encoding="utf-8") as f: async with _cache_lock:
json.dump(injuries, ensure_ascii=False, default=str, fp=f) _write_cache_atomic(cache_file, injuries)
return injuries return injuries
@@ -106,25 +170,63 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。 注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
P1-4: 批量幂等检查,避免逐条查询的竞态条件(并发采集时 IntegrityError)。 P1-4: 批量幂等检查,避免逐条查询的竞态条件(并发采集时 IntegrityError)。
P1 Bronze: 采集成功后先存 RawEvent,再规范化。
P1 死信: 采集/规范化失败时写 IngestFailure。
""" """
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from src.data.team_names import normalize as normalize_name from src.data.team_names import normalize as normalize_name
from src.db.models import Injury, Team from src.db.models import Injury, IngestFailure, RawEvent, Team
result = {"count": 0, "inserted": 0, "errors": []} result = {"count": 0, "inserted": 0, "errors": []}
batch_id = uuid.uuid4().hex[:16]
try: try:
raw_injuries = await fetch_injuries(date=date) raw_injuries = await fetch_injuries(date=date)
except Exception as e: except Exception as e:
logger.exception("injuries fetch failed") logger.exception("injuries fetch failed")
result["errors"].append(f"fetch failed: {e}") result["errors"].append(f"fetch failed: {e}")
# 写死信表(IngestFailure)
try:
failure = IngestFailure(
source_system="api-football",
entity_type="injury",
source_record_id=f"fetch_{batch_id}",
error_type="fetch_error",
error_detail=str(e)[:2000],
raw_payload={"date": date},
status="pending",
)
db.add(failure)
await db.flush()
except Exception:
logger.warning("failed to write fetch error to ingest_failures", exc_info=True)
return result return result
result["count"] = len(raw_injuries) result["count"] = len(raw_injuries)
# === Bronze 层:写 RawEvent ===
for raw in raw_injuries:
try:
player = raw.get("player", {}) or {}
fixture = raw.get("fixture", {}) or {}
source_record_id = f"inj_{player.get('id')}_{fixture.get('id')}"
bronze = RawEvent(
source_system="api-football",
source_record_id=source_record_id,
raw_payload=raw,
ingest_batch_id=batch_id,
)
db.add(bronze)
except Exception as e:
logger.warning("raw_event write failed for injury: %s", e)
try:
await db.flush()
except Exception as e:
logger.warning("raw_events flush failed: %s", e)
# 预加载所有球队(用于按名匹配) # 预加载所有球队(用于按名匹配)
teams = (await db.execute(select(Team))).scalars().all() teams = (await db.execute(select(Team))).scalars().all()
team_by_name = {t.name: t.id for t in teams} team_by_name = {t.name: t.id for t in teams}
@@ -132,6 +234,7 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
# P1-4: 收集所有待插入记录的键,批量查询已存在的记录 # P1-4: 收集所有待插入记录的键,批量查询已存在的记录
# 避免逐条查询 + 插入的竞态条件(两个并发请求同时通过检查 → IntegrityError) # 避免逐条查询 + 插入的竞态条件(两个并发请求同时通过检查 → IntegrityError)
pending_records: list[dict] = [] pending_records: list[dict] = []
parse_failures: list[dict] = [] # 规范化失败的原始数据,用于写死信表
for raw in raw_injuries: for raw in raw_injuries:
try: try:
player = raw.get("player", {}) or {} player = raw.get("player", {}) or {}
@@ -176,6 +279,30 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
}) })
except Exception as e: except Exception as e:
result["errors"].append(f"parse error: {e}") result["errors"].append(f"parse error: {e}")
parse_failures.append({"raw": raw, "error": str(e)})
# 写规范化失败到死信表
for fail in parse_failures:
try:
raw = fail["raw"]
player = raw.get("player", {}) or {}
failure = IngestFailure(
source_system="api-football",
entity_type="injury",
source_record_id=f"inj_{player.get('id', 'unknown')}",
error_type="normalize_error",
error_detail=fail["error"][:2000],
raw_payload=raw,
status="pending",
)
db.add(failure)
except Exception:
logger.warning("failed to write parse error to ingest_failures", exc_info=True)
if parse_failures:
try:
await db.flush()
except Exception:
logger.warning("ingest_failures flush failed", exc_info=True)
# P1-4: 批量查询已存在的记录(1 次 DB 往返) # P1-4: 批量查询已存在的记录(1 次 DB 往返)
existing_keys: set[tuple] = set() existing_keys: set[tuple] = set()
+98
View File
@@ -0,0 +1,98 @@
"""令牌桶限流器。
替代 bzzoiro 中固定的 REQUEST_INTERVAL sleep,提供更精细的速率控制。
支持突发流量和平滑限流。
"""
from __future__ import annotations
import asyncio
import logging
import time
logger = logging.getLogger(__name__)
class TokenBucket:
"""异步令牌桶限流器。
用法:
limiter = TokenBucket(rate=5.0, capacity=10)
await limiter.acquire() # 等待直到有可用令牌
Args:
rate: 每秒补充的令牌数
capacity: 桶容量(允许的最大突发量)
"""
def __init__(self, rate: float, capacity: int | None = None) -> None:
if rate <= 0:
raise ValueError(f"rate must be positive, got {rate}")
self._rate = rate
self._capacity = capacity or max(1, int(rate * 2))
self._tokens: float = self._capacity
self._last_refill = time.monotonic()
self._lock = asyncio.Lock()
async def acquire(self, tokens: int = 1) -> None:
"""获取指定数量的令牌,不足时等待。
Args:
tokens: 需要获取的令牌数
"""
if tokens <= 0:
return
if tokens > self._capacity:
raise ValueError(f"requested {tokens} exceeds capacity {self._capacity}")
while True:
async with self._lock:
self._refill()
if self._tokens >= tokens:
self._tokens -= tokens
return
# 计算需要等待的时间
wait_time = (tokens - self._tokens) / self._rate
logger.debug("token bucket: waiting %.2fs for %d tokens", wait_time, tokens)
await asyncio.sleep(wait_time)
def _refill(self) -> None:
"""补充令牌(基于经过的时间)。"""
now = time.monotonic()
elapsed = now - self._last_refill
if elapsed > 0:
self._tokens = min(self._capacity, self._tokens + elapsed * self._rate)
self._last_refill = now
@property
def tokens(self) -> float:
"""当前可用令牌数(近似)。"""
self._refill()
return self._tokens
class RateLimitedClient:
"""HTTP 客户端限流包装器。
在 httpx 客户端之上添加令牌桶限流,透明地控制请求速率。
用法:
client = get_client()
limiter = TokenBucket(rate=5.0)
wrapper = RateLimitedClient(client, limiter)
resp = await wrapper.get(url)
"""
def __init__(self, client, limiter: TokenBucket) -> None:
self._client = client
self._limiter = limiter
async def get(self, url: str, **kwargs):
"""限流的 GET 请求。"""
await self._limiter.acquire()
return await self._client.get(url, **kwargs)
async def post(self, url: str, **kwargs):
"""限流的 POST 请求。"""
await self._limiter.acquire()
return await self._client.post(url, **kwargs)
+91 -8
View File
@@ -2,6 +2,12 @@
迁移自旧项目 app/data/sources/understat.py,改成 async。 迁移自旧项目 app/data/sources/understat.py,改成 async。
使用 Repository 模式进行数据访问,不直接控制事务。 使用 Repository 模式进行数据访问,不直接控制事务。
改进(P1):
- Bronze 层集成:采集后先存 RawEvent,再规范化
- 死信表集成:采集/规范化失败写 IngestFailure
- xG 覆盖更新:当 understat 数据更新时覆盖旧值(全量覆盖模式)
- xG 追踪字段:xg_source / xg_updated_at / xg_source_record_id
""" """
from __future__ import annotations from __future__ import annotations
@@ -10,6 +16,7 @@ import json
import logging import logging
import random import random
import re import re
import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import select from sqlalchemy import select
@@ -19,7 +26,7 @@ from src.core.http_client import get_client
from src.data.config import FDCO_TO_UNDERSTAT, LEAGUE_NAMES from src.data.config import FDCO_TO_UNDERSTAT, LEAGUE_NAMES
from src.data.normalize import normalize_understat from src.data.normalize import normalize_understat
from src.data.sources import register from src.data.sources import register
from src.db.models import League, Match, MatchStats, Team from src.db.models import IngestFailure, League, Match, MatchStats, RawEvent, Team
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -89,6 +96,54 @@ def _match_key(home_team_id: int, away_team_id: int, match_date) -> tuple[int, i
return (home_team_id, away_team_id, match_date.isoformat() if match_date is not None else "") return (home_team_id, away_team_id, match_date.isoformat() if match_date is not None else "")
async def _write_understat_bronze(db, raw_matches: list[dict], batch_id: str) -> None:
"""将 understat 原始数据写入 Bronze 层(RawEvent 表)。"""
for raw in raw_matches:
try:
source_record_id = str(raw.get("id", ""))
if not source_record_id:
continue
bronze = RawEvent(
source_system="understat",
source_record_id=source_record_id,
raw_payload=raw,
ingest_batch_id=batch_id,
)
db.add(bronze)
except Exception as e:
logger.warning("understat raw_event write failed: %s", e)
try:
await db.flush()
except Exception as e:
logger.warning("understat raw_events flush failed: %s", e)
async def _write_ingest_failure(
db,
source_system: str,
entity_type: str,
source_record_id: str,
error_type: str,
error_detail: str,
raw_payload: dict | None,
) -> None:
"""写入采集失败到死信表(IngestFailure)。"""
try:
failure = IngestFailure(
source_system=source_system,
entity_type=entity_type,
source_record_id=source_record_id,
error_type=error_type,
error_detail=error_detail[:2000] if error_detail else None,
raw_payload=raw_payload,
status="pending",
)
db.add(failure)
await db.flush()
except Exception:
logger.warning("understat ingest_failure write failed", exc_info=True)
@register @register
class UnderstatSource: class UnderstatSource:
"""understat xG 数据源(实现 DataSource 协议)。""" """understat xG 数据源(实现 DataSource 协议)。"""
@@ -96,23 +151,33 @@ class UnderstatSource:
name = "understat" name = "understat"
async def ingest(self, db, *, league: str, season: int) -> dict: async def ingest(self, db, *, league: str, season: int) -> dict:
"""采集 understat xG → 回填到现有 Match。只回填 xG 字段,不创建新 Match。 """采集 understat xG → 回填到现有 Match。
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。 注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
P1-3: 批量查询优化,将单赛季 380 场 × 3 次 DB 往返降为 3 次查询。 P1-3: 批量查询优化,将单赛季 380 场 × 3 次 DB 往返降为 3 次查询。
P1-4: xG 覆盖更新模式,当 understat 数据更新时覆盖旧值。
""" """
from src.db.repositories import LeagueRepository, TeamRepository from src.db.repositories import LeagueRepository, TeamRepository
result = {"updated": 0, "skipped": 0, "unmatched": 0, "errors": []} result = {"updated": 0, "skipped": 0, "unmatched": 0, "errors": []}
batch_id = uuid.uuid4().hex[:16]
try: try:
raw_matches = await fetch_understat(league, season) raw_matches = await fetch_understat(league, season)
except Exception as e: except Exception as e:
logger.exception("understat fetch failed for %s %s", league, season) logger.exception("understat fetch failed for %s %s", league, season)
result["errors"].append(f"fetch failed: {e}") result["errors"].append(f"fetch failed: {e}")
# 写死信表
await _write_ingest_failure(
db, "understat", "match", f"fetch_{league}_{season}_{batch_id}",
"fetch_error", str(e), {"league": league, "season": season},
)
return result return result
# === Bronze 层:先存 RawEvent ===
await _write_understat_bronze(db, raw_matches, batch_id)
# 使用 Repository # 使用 Repository
league_repo = LeagueRepository(db) league_repo = LeagueRepository(db)
team_repo = TeamRepository(db) team_repo = TeamRepository(db)
@@ -136,6 +201,11 @@ class UnderstatSource:
continue continue
except Exception as e: except Exception as e:
result["errors"].append(f"normalize: {e}") result["errors"].append(f"normalize: {e}")
# 写死信表:规范化失败
await _write_ingest_failure(
db, "understat", "match", str(raw.get("id", "")),
"normalize_error", str(e), raw,
)
continue continue
normalized_matches.append((nm, raw)) normalized_matches.append((nm, raw))
all_team_names.add(nm.home_team) all_team_names.add(nm.home_team)
@@ -167,7 +237,7 @@ class UnderstatSource:
key = _match_key(m.home_team_id, m.away_team_id, m.match_date_date) key = _match_key(m.home_team_id, m.away_team_id, m.match_date_date)
match_dict[key] = m match_dict[key] = m
# === 内存匹配 + 回填 xG === # === 内存匹配 + 回填 xG(覆盖模式) ===
for nm, raw in normalized_matches: for nm, raw in normalized_matches:
home_team_id = team_name_to_id.get(nm.home_team) home_team_id = team_name_to_id.get(nm.home_team)
away_team_id = team_name_to_id.get(nm.away_team) away_team_id = team_name_to_id.get(nm.away_team)
@@ -181,24 +251,37 @@ class UnderstatSource:
result["unmatched"] += 1 result["unmatched"] += 1
continue continue
# 回填 xG now = datetime.now(timezone.utc)
source_record_id = str(raw.get("id", ""))
# 创建 stats 记录(如果不存在)
if existing.stats is None and (nm.home_xg is not None or nm.away_xg is not None): if existing.stats is None and (nm.home_xg is not None or nm.away_xg is not None):
now = datetime.now(timezone.utc)
existing.stats = MatchStats( existing.stats = MatchStats(
match_id=existing.id, match_id=existing.id,
source="understat", source="understat",
source_event_id=str(raw.get("id", "")), source_record_id=source_record_id,
retrieved_at=now, retrieved_at=now,
available_at=now, available_at=now,
xg_source="understat",
xg_updated_at=now,
xg_source_record_id=source_record_id,
) )
db.add(existing.stats) db.add(existing.stats)
await db.flush() await db.flush()
# xG 覆盖更新模式:当 understat 数据更新时覆盖旧值
if existing.stats is not None: if existing.stats is not None:
if existing.stats.home_xg is None and nm.home_xg is not None: if nm.home_xg is not None:
existing.stats.home_xg = nm.home_xg existing.stats.home_xg = nm.home_xg
existing.stats.xg_source = "understat"
existing.stats.xg_updated_at = now
existing.stats.xg_source_record_id = source_record_id
result["updated"] += 1 result["updated"] += 1
if existing.stats.away_xg is None and nm.away_xg is not None: if nm.away_xg is not None:
existing.stats.away_xg = nm.away_xg existing.stats.away_xg = nm.away_xg
existing.stats.xg_source = "understat"
existing.stats.xg_updated_at = now
existing.stats.xg_source_record_id = source_record_id
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务 # 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
return result return result
+112 -1
View File
@@ -1,4 +1,4 @@
"""6 张表 ORM: leagues / teams / matches / match_stats / predictions / injuries。""" """ORM 模型: leagues / teams / matches / match_stats / predictions / injuries / raw_events / ingest_failures / data_quality_checks / data_lineage"""
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
@@ -131,12 +131,18 @@ class MatchStats(Base):
source_record_id: Mapped[str | None] = mapped_column(String(100)) source_record_id: Mapped[str | None] = mapped_column(String(100))
retrieved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) retrieved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
available_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) available_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# xG 数据追踪:支持 understat 纠正旧 xG 值
xg_source: Mapped[str | None] = mapped_column(String(30)) # 具体 xG 数据源
xg_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # xG 最后更新时间
xg_source_record_id: Mapped[str | None] = mapped_column(String(100)) # xG 对应的源记录 ID
match: Mapped[Match] = relationship(back_populates="stats") match: Mapped[Match] = relationship(back_populates="stats")
__table_args__ = ( __table_args__ = (
# 数据血缘时间过滤查询用(按 available_at 取「赛前已可得」的统计) # 数据血缘时间过滤查询用(按 available_at 取「赛前已可得」的统计)
Index("ix_match_stats_available_at", "available_at"), Index("ix_match_stats_available_at", "available_at"),
# xG 数据源追踪查询用
Index("ix_match_stats_xg_source", "xg_source", "xg_updated_at"),
) )
@@ -216,3 +222,108 @@ class Prediction(Base):
CheckConstraint("mode IN ('single', 'multi')", name="ck_mode_enum"), CheckConstraint("mode IN ('single', 'multi')", name="ck_mode_enum"),
CheckConstraint("status IN ('success', 'failed', 'degraded')", name="ck_status_enum"), CheckConstraint("status IN ('success', 'failed', 'degraded')", name="ck_status_enum"),
) )
# ── Bronze 层:原始事件记录 ──────────────────────────────────────────────
class RawEvent(Base):
"""Bronze 层:不可变的原始采集记录。
每个采集到的原始事件先写入此表,再规范化到 Silver 层(matches / match_stats)。
提供完整的数据血缘回溯能力。
"""
__tablename__ = "raw_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_system: Mapped[str] = mapped_column(String(30), nullable=False) # bzzoiro / understat / api-football
source_record_id: Mapped[str] = mapped_column(String(100), nullable=False) # 源系统记录 ID
raw_payload: Mapped[dict] = mapped_column(JSONB, nullable=False) # 完整原始 JSON
ingested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
ingest_batch_id: Mapped[str | None] = mapped_column(String(64)) # 批次 ID,用于关联同次采集
__table_args__ = (
UniqueConstraint("source_system", "source_record_id", name="uq_raw_events_source_record"),
Index("ix_raw_events_batch", "ingest_batch_id"),
Index("ix_raw_events_source_ingested", "source_system", "ingested_at"),
)
# ── 死信表:采集失败记录 ─────────────────────────────────────────────────
class IngestFailure(Base):
"""采集失败死信表。
当采集或规范化失败时,写入此表而非仅内存 dict。
支持自动重试和人工排查。
"""
__tablename__ = "ingest_failures"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_system: Mapped[str] = mapped_column(String(30), nullable=False)
entity_type: Mapped[str] = mapped_column(String(30), nullable=False) # match / injury / team
source_record_id: Mapped[str | None] = mapped_column(String(100))
error_type: Mapped[str] = mapped_column(String(50), nullable=False) # fetch_error / normalize_error / db_error / validation_error
error_detail: Mapped[str | None] = mapped_column(Text)
raw_payload: Mapped[dict | None] = mapped_column(JSONB) # 失败时的原始数据,用于重试
retry_count: Mapped[int] = mapped_column(Integer, default=0)
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
status: Mapped[str] = mapped_column(String(20), default="pending") # pending / retrying / resolved / abandoned
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (
Index("ix_ingest_failures_status_next_retry", "status", "next_retry_at"),
Index("ix_ingest_failures_source", "source_system", "entity_type"),
CheckConstraint("status IN ('pending', 'retrying', 'resolved', 'abandoned')", name="ck_ingest_failures_status"),
CheckConstraint("retry_count >= 0", name="ck_ingest_failures_retry_nonneg"),
)
# ── 数据质量检查表 ──────────────────────────────────────────────────────
class DataQualityCheck(Base):
"""数据质量检查结果记录。
每次运行数据质量检查时,将结果写入此表用于趋势分析和告警。
"""
__tablename__ = "data_quality_checks"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
check_name: Mapped[str] = mapped_column(String(100), nullable=False) # 检查名称
entity_type: Mapped[str] = mapped_column(String(30), nullable=False) # match / team / prediction
entity_id: Mapped[str | None] = mapped_column(String(50)) # 具体实体 ID
expected_value: Mapped[str | None] = mapped_column(Text) # 期望值(描述)
actual_value: Mapped[str | None] = mapped_column(Text) # 实际值
passed: Mapped[bool] = mapped_column(Boolean, nullable=False)
severity: Mapped[str] = mapped_column(String(10), nullable=False, default="warning") # info / warning / critical
detail: Mapped[str | None] = mapped_column(Text) # 详细描述
checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
__table_args__ = (
Index("ix_dqc_check_time", "check_name", "checked_at"),
Index("ix_dqc_entity", "entity_type", "entity_id"),
Index("ix_dqc_severity_passed", "severity", "passed"),
CheckConstraint("severity IN ('info', 'warning', 'critical')", name="ck_dqc_severity"),
)
# ── 数据血缘表 ──────────────────────────────────────────────────────────
class DataLineage(Base):
"""ETL 全过程元数据记录。
追踪从 Bronze → Silver → Gold 的完整转换链路。
"""
__tablename__ = "data_lineage"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_system: Mapped[str] = mapped_column(String(30), nullable=False)
source_record_id: Mapped[str] = mapped_column(String(100), nullable=False)
target_table: Mapped[str] = mapped_column(String(50), nullable=False) # matches / match_stats / predictions
target_id: Mapped[int | None] = mapped_column(Integer) # 目标表记录 ID
transform_name: Mapped[str] = mapped_column(String(100), nullable=False) # 转换步骤名称
transform_detail: Mapped[str | None] = mapped_column(Text) # 转换详情
batch_id: Mapped[str | None] = mapped_column(String(64)) # 批次 ID
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
__table_args__ = (
Index("ix_lineage_source", "source_system", "source_record_id"),
Index("ix_lineage_target", "target_table", "target_id"),
Index("ix_lineage_batch", "batch_id"),
)
+2 -2
View File
@@ -150,8 +150,8 @@ async def run_backtest(
summary = BacktestSummary(total=len(candidates), scored=0) summary = BacktestSummary(total=len(candidates), scored=0)
# P1-6: 并发控制,同时最多 8 场预测(避免 LLM API 限流) # P2: 并发控制,同时最多 3 场预测(避免 LLM API 限流,保护下游服务)
sem = asyncio.Semaphore(8) sem = asyncio.Semaphore(3)
async def _one(c: BacktestCandidate) -> BacktestMatchResult | None: async def _one(c: BacktestCandidate) -> BacktestMatchResult | None:
async with sem: async with sem: