Files
xiaoxia-saas/apps/worker/video_processing/render_engine_resolver.py
T
灵应 c96ebfb4ae feat(feature-flag): Redis Feature Flag 灰度发布基础设施
- 新增 FeatureFlagStore 抽象 + Redis/InMemory 双实现
  - 支持全局开关 + 白名单 + 百分比切流(MD5哈希一致性)
  - 本地缓存 + TTL,减少 Redis 调用
  - Redis 不可用时自动降级,不影响业务

- 新增 RenderEngineResolver 渲染引擎选择器
  - 环境变量默认 + Redis 运行时覆盖,支持热更新
  - 按 user_id 灰度(白名单 > 百分比 > 默认值)
  - 惰性刷新,30秒刷新间隔
  - 在途任务不受配置变更影响(任务开始时确定引擎)

- 新增 /api/v1/internal/feature-flags 管理接口
  - GET 列表/详情、PUT 修改、DELETE 删除
  - /{name}/check 端点验证指定标识符是否命中
  - 复用内部 API Key 鉴权
  - ALLOWED_FLAGS 白名单防误操作

- compose_video 任务接入 RenderEngineResolver
  - 从 job.created_by_user_id 获取用户标识
  - 替换原环境变量直接读取方式

- 30 个单元测试全绿
  - FeatureFlagConfig 判定逻辑(优先级、边界、一致性)
  - InMemoryStore CRUD
  - RenderEngineResolver(白名单、百分比、默认值、降级、热更新不影响在途任务)
  - Redis 异常降级
2026-07-13 00:29:58 +08:00

209 lines
7.0 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""渲染引擎 Feature Flag 解析器。
封装渲染引擎选择逻辑,支持:
- 环境变量作为默认值(RENDER_ENGINE=legacy/unified
- Redis Feature Flag 运行时覆盖(白名单 + 百分比 + 全局开关)
- 定时刷新,支持热更新不重启 worker
使用方式:
resolver = RenderEngineResolver(redis_url="redis://...", default_engine="legacy")
engine = resolver.get_engine(user_id="user123")
# engine: "legacy" 或 "unified"
"""
from __future__ import annotations
import logging
import threading
from typing import Optional
from packages.adapters.redis.feature_flag_store import (
FeatureFlagConfig,
FeatureFlagStore,
InMemoryFeatureFlagStore,
RedisFeatureFlagStore,
)
logger = logging.getLogger(__name__)
# Feature Flag 名称常量
FLAG_RENDER_ENGINE = "render_engine"
# 引擎常量
ENGINE_LEGACY = "legacy"
ENGINE_UNIFIED = "unified"
VALID_ENGINES = {ENGINE_LEGACY, ENGINE_UNIFIED}
class RenderEngineResolver:
"""渲染引擎选择器。
判定逻辑(从高到低):
1. Redis flag 白名单匹配 → unified
2. Redis flag 百分比命中 → unified
3. Redis flag 全局开启(100%)→ unified
4. 环境变量默认值 → legacy / unified
当 Redis 不可用时,自动降级到环境变量默认值,不影响业务。
"""
def __init__(
self,
default_engine: str = ENGINE_LEGACY,
redis_url: Optional[str] = None,
refresh_interval: float = 30.0,
store: Optional[FeatureFlagStore] = None,
) -> None:
"""
Args:
default_engine: 环境变量默认的引擎名(legacy / unified
redis_url: Redis 连接 URL,传 None 时使用内存实现(测试用)
refresh_interval: Redis flag 配置刷新间隔(秒)
store: 直接传入 store 实例(测试用,优先级高于 redis_url)
"""
self._default_engine = (
default_engine.lower() if default_engine else ENGINE_LEGACY
)
if self._default_engine not in VALID_ENGINES:
logger.warning(
"Invalid default engine '%s', fallback to '%s'",
self._default_engine,
ENGINE_LEGACY,
)
self._default_engine = ENGINE_LEGACY
if store is not None:
self._store = store
elif redis_url:
self._store = RedisFeatureFlagStore(redis_url=redis_url)
else:
self._store = InMemoryFeatureFlagStore()
logger.info("No Redis configured, using in-memory feature flag store")
self._refresh_interval = refresh_interval
self._lock = threading.Lock()
self._cached_config: Optional[FeatureFlagConfig] = None
self._last_refresh: float = 0.0
def _maybe_refresh(self) -> None:
"""惰性刷新配置,超过刷新间隔时从存储重新读取。"""
import time
now = time.time()
if now - self._last_refresh < self._refresh_interval:
return
try:
config = self._store.get(FLAG_RENDER_ENGINE)
with self._lock:
self._cached_config = config
self._last_refresh = now
except Exception as exc:
logger.warning("Failed to refresh render engine flag: %s", exc)
# 刷新失败时保留旧缓存,不中断业务
if self._cached_config is None:
# 首次就读失败,设一个默认值
with self._lock:
self._cached_config = FeatureFlagConfig(name=FLAG_RENDER_ENGINE)
self._last_refresh = now
def _get_config(self) -> FeatureFlagConfig:
"""获取当前 flag 配置(带缓存)。"""
if self._cached_config is None:
self._maybe_refresh()
else:
self._maybe_refresh()
return self._cached_config or FeatureFlagConfig(name=FLAG_RENDER_ENGINE)
def get_engine(self, user_id: Optional[str] = None) -> str:
"""获取当前应该使用的渲染引擎。
Args:
user_id: 用户ID,用于白名单匹配和百分比哈希。
传 None 时只看全局开关。
Returns:
"legacy" 或 "unified"
"""
config = self._get_config()
# 全局关闭 → 用默认值
if not config.enabled:
return self._default_engine
# 白名单匹配 / 百分比命中 → unified
if config.is_active(user_id):
return ENGINE_UNIFIED
# 未命中灰度 → 用默认值
return self._default_engine
def should_use_unified(self, user_id: Optional[str] = None) -> bool:
"""便捷方法:是否应该使用统一渲染引擎。"""
return self.get_engine(user_id) == ENGINE_UNIFIED
def force_refresh(self) -> None:
"""强制立即刷新配置(用于管理接口修改后立即生效)。"""
self._last_refresh = 0.0
if isinstance(self._store, RedisFeatureFlagStore):
self._store.invalidate_cache(FLAG_RENDER_ENGINE)
self._maybe_refresh()
def get_config_snapshot(self) -> dict:
"""获取当前配置快照(用于管理接口展示)。"""
config = self._get_config()
return {
"flag_name": FLAG_RENDER_ENGINE,
"default_engine": self._default_engine,
"enabled": config.enabled,
"percentage": config.percentage,
"whitelist": sorted(config.whitelist),
"refresh_interval": self._refresh_interval,
"last_refresh": self._last_refresh,
}
def set_flag(self, config: FeatureFlagConfig) -> None:
"""设置 flag 配置(管理接口用)。"""
config.name = FLAG_RENDER_ENGINE
self._store.set(config)
self.force_refresh()
# 全局单例
_resolver: Optional[RenderEngineResolver] = None
_resolver_lock = threading.Lock()
def get_render_engine_resolver() -> RenderEngineResolver:
"""获取全局单例(基于 worker 配置)。"""
global _resolver
if _resolver is not None:
return _resolver
with _resolver_lock:
if _resolver is not None:
return _resolver
try:
from worker_app.core.config import get_settings
settings = get_settings()
redis_url = getattr(settings, "redis_url", None) or getattr(
settings, "broker_url", None
)
default = getattr(settings, "render_engine", ENGINE_LEGACY)
_resolver = RenderEngineResolver(
default_engine=default,
redis_url=redis_url,
)
logger.info(
"RenderEngineResolver initialized: default=%s, redis=%s",
default,
bool(redis_url),
)
except Exception as exc:
logger.warning("Failed to init RenderEngineResolver from settings: %s", exc)
_resolver = RenderEngineResolver(default_engine=ENGINE_LEGACY)
return _resolver