Files
modalDeploy/src/BowongModalFunctions/router/cache.py
shuohigh@gmail.com c69509b5b5 - KVCache类改为可拓展,基于环境变量设置KV space
- test 环境配置与CF测试环境对齐
2025-06-04 12:37:13 +08:00

266 lines
12 KiB
Python
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.
import asyncio
import os
from typing import Annotated, Optional
import modal
from loguru import logger
from modal import current_function_call_id
import sentry_sdk
from fastapi import APIRouter, Depends, UploadFile, HTTPException, File, Form
from fastapi.responses import JSONResponse, RedirectResponse
from starlette import status
from ..config import WorkerConfig
from ..middleware.authorization import verify_token
from ..models.media_model import (MediaSources,
CacheResult,
MediaSource,
MediaCacheStatus,
DownloadResult,
UploadResultResponse,
UploadBase64Request
)
from ..models.web_model import SentryTransactionInfo
from ..utils.KVCache import MediaSourceKVCache
from ..utils.SentryUtils import SentryUtils
config = WorkerConfig()
router = APIRouter(prefix="/cache")
if not config.CF_KV_namespace_id:
raise ValueError("未配置Cloudflare KV namespace ID")
modal_kv_cache = MediaSourceKVCache(kv_name=config.modal_kv_name,
cf_kv_id=config.CF_KV_namespace_id,
environment=config.modal_environment)
@router.post("/",
tags=["缓存"],
summary="缓存视频文件",
description="异步缓存视频文件到S3存储桶和Modal Dict(KV)",
dependencies=[Depends(verify_token)])
async def cache(medias: MediaSources) -> CacheResult:
fn_id = current_function_call_id()
caches: MediaSources
sentry_trace = SentryTransactionInfo(x_trace_id=sentry_sdk.get_traceparent(),
x_baggage=sentry_sdk.get_baggage())
@SentryUtils.sentry_tracker(name="同步视频缓存", op="cache.get", fn_id=fn_id,
sentry_trace_id=None, sentry_baggage=None)
async def cache_handler(media: MediaSource):
cache_span = sentry_sdk.get_current_span()
cache_span.set_data("runner_id", fn_id)
cache_span.set_data("cache.key", [media.urn])
cached_media = modal_kv_cache.get_cache(media.urn)
cache_hit: bool = False
if not cached_media:
# start new download task
with cache_span.start_child(name="视频缓存任务入队",
op="queue.publish") as queue_publish_span:
fn = modal.Function.from_name(config.modal_app_name, 'cache_submit')
fn_task = fn.spawn(media, sentry_trace)
queue_publish_span.set_data("cache.key", media.urn)
queue_publish_span.set_data("messaging.message.id", fn_task.object_id)
queue_publish_span.set_data("messaging.destination.name",
"video-downloader.cache_submit")
queue_publish_span.set_data("messaging.message.body.size", 0)
media.status = MediaCacheStatus.downloading
media.downloader_id = fn_task.object_id
modal_kv_cache.set_cache(media)
else:
media = cached_media
match media.status:
case MediaCacheStatus.ready:
cache_hit = True
case MediaCacheStatus.downloading: # 下载任务已经在进行
cache_hit = True
case _:
# start new download task
with cache_span.start_child(name="视频缓存任务入队",
op="queue.publish") as queue_publish_span:
fn = modal.Function.from_name(config.modal_app_name, 'cache_submit')
fn_task = fn.spawn(media, sentry_trace)
queue_publish_span.set_data("cache.key", media.urn)
queue_publish_span.set_data("messaging.message.id", fn_task.object_id)
queue_publish_span.set_data("messaging.destination.name",
"video-downloader.cache_submit")
queue_publish_span.set_data("messaging.message.body.size", 0)
media.status = MediaCacheStatus.downloading
media.downloader_id = fn_task.object_id
modal_kv_cache.set_cache(media)
cache_hit = False
cache_span.set_data("cache.hit", cache_hit)
return media
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(cache_handler(media)) for media in medias.inputs]
cache_task_result_dict = {}
cache_task_result_list = []
for task in tasks:
result = task.result()
cache_task_result_dict[result.urn] = result.model_dump_json()
cache_task_result_list.append(result)
modal_kv_cache.batch_update_cloudflare_kv(cache_task_result_dict)
return CacheResult(caches={media.urn: media for media in cache_task_result_list})
@router.delete("/",
tags=["缓存"],
summary="清除指定的所有缓存",
description="清除指定的所有缓存(包括KV记录和S3存储文件)",
dependencies=[Depends(verify_token)])
async def purge_media_kv_file(medias: MediaSources):
fn_id = current_function_call_id()
fn = modal.Function.from_name(config.modal_app_name, "cache_delete", environment_name=config.modal_environment)
@SentryUtils.sentry_tracker(name="清除媒体源缓存", op="cache.purge", fn_id=fn_id,
sentry_trace_id=None, sentry_baggage=None)
async def purge_handle(media: MediaSource):
cache_media = modal_kv_cache.pop(media.urn)
if cache_media:
deleted_cache: MediaSource = await fn.remote.aio(cache_media)
return deleted_cache.urn
return None
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(purge_handle(media)) for media in medias.inputs]
keys = [task.result() for task in tasks]
modal_kv_cache.batch_remove_cloudflare_kv(keys)
return JSONResponse(content={"success": True, "keys": keys})
@router.post("/download",
tags=["缓存"],
summary="批量获取下载地址",
description="获取已缓存的视频下载地址",
dependencies=[Depends(verify_token)])
@sentry_sdk.trace
async def download_caches(medias: MediaSources) -> DownloadResult:
cdn_endpoint = config.S3_cdn_endpoint
urls = []
for media in medias.inputs:
urls.append(f"{cdn_endpoint}/{media.get_cdn_url()}")
return DownloadResult(urls=urls)
@router.get("/download",
tags=["缓存"],
summary="下载已缓存的视频",
description="通过CDN下载已缓存的视频文件")
@sentry_sdk.trace
async def download_cache(media: str) -> RedirectResponse:
cdn_endpoint = config.S3_cdn_endpoint
media = MediaSource.from_str(media)
return RedirectResponse(url=f"{cdn_endpoint}/{media.get_cdn_url()}", status_code=status.HTTP_302_FOUND)
@router.delete("/kv",
tags=["缓存"],
summary="清除KV记录",
description="清除当前环境下KV缓存过的所有数据(S3存储桶内的文件会保留)",
dependencies=[Depends(verify_token)])
async def purge_kv_all():
parent = sentry_sdk.get_current_span()
span = parent.start_child(name="清除缓存KV", op="purge.flush")
modal_kv_cache.clear()
span.set_data("cache.success", True)
span.finish()
return JSONResponse(content={"success": True})
@router.post("/kv",
tags=["缓存"],
summary="删除对应的KV记录",
description="删除请求中对应的视频缓存记录",
dependencies=[Depends(verify_token)])
async def purge_kv(medias: MediaSources):
try:
for media in medias.inputs:
modal_kv_cache.pop(media.urn)
keys = [media.urn for media in medias.inputs]
modal_kv_cache.batch_remove_cloudflare_kv(keys)
return JSONResponse(content={"success": True, "keys": keys})
except Exception as e:
return JSONResponse(content={"success": False, "error": str(e)})
@router.post("/media",
tags=["缓存"],
summary="清除指定的所有缓存",
description="清除指定的所有缓存(包括KV记录和S3存储文件), 将要被淘汰使用DELETE /cache/替代",
deprecated=True,
dependencies=[Depends(verify_token)])
async def purge_media(medias: MediaSources):
fn_id = current_function_call_id()
fn = modal.Function.from_name(config.modal_app_name, "cache_delete", environment_name=config.modal_environment)
@SentryUtils.sentry_tracker(name="清除媒体源缓存", op="cache.purge", fn_id=fn_id,
sentry_trace_id=None, sentry_baggage=None)
async def purge_handle(media: MediaSource):
cache_media = modal_kv_cache.pop(media.urn)
if cache_media:
deleted_cache: MediaSource = await fn.remote.aio(cache_media)
return deleted_cache.urn
return None
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(purge_handle(media)) for media in medias.inputs]
keys = [task.result() for task in tasks]
modal_kv_cache.batch_remove_cloudflare_kv(keys)
return JSONResponse(content={"success": True, "keys": keys})
@router.post("/upload-s3",
tags=['缓存'],
summary="上传文件到S3",
description="上传文件到S3的文件必须小于200M",
dependencies=[Depends(verify_token)])
async def s3_upload(file: Annotated[UploadFile, File(description="上传的文件")],
prefix: Annotated[Optional[str], Form()] = None) -> UploadResultResponse:
fn_id = current_function_call_id()
if file.size > 200 * 1024 * 1024: # 上传文件不大于200M
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="上传文件不可超过200MB")
key = f"upload/{prefix}/{file.filename}" if prefix else f"upload/{file.filename}"
local_path = f"{config.S3_mount_dir}/{key}"
logger.info(f"s3上传到{key}, size={file.size}")
os.makedirs(os.path.dirname(local_path), exist_ok=True)
with open(local_path, 'wb') as f:
f.write(file.file.read())
logger.info(f"{local_path} 保存成功")
media_source = MediaSource.from_str(f"s3://{config.S3_region}/{config.S3_bucket_name}/{key}")
media_source.status = MediaCacheStatus.ready
media_source.downloader_id = fn_id
return UploadResultResponse(media=media_source)
@router.post('/upload-s3-b64',
tags=['缓存'],
summary="基于Base64格式上传文件到S3",
description="上传文件到S3当文件必须小于200M",
dependencies=[Depends(verify_token)])
async def s3_upload_base64(body: UploadBase64Request) -> UploadResultResponse:
fn_id = current_function_call_id()
prefix = body.prefix
file = body.file
if file.size > 200 * 1024 * 1024: # 上传文件不大于200M
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="上传文件不可超过200MB")
key = f"upload/{prefix}/{file.filename}" if prefix else f"upload/{file.filename}"
local_path = f"{config.S3_mount_dir}/{key}"
logger.info(f"s3上传到{key}, size={file.size}")
os.makedirs(os.path.dirname(local_path), exist_ok=True)
with open(local_path, 'wb') as f:
f.write(file.raw_content)
logger.info(f"{local_path} 保存成功")
media_source = MediaSource.from_str(f"s3://{config.S3_region}/{config.S3_bucket_name}/{key}")
media_source.status = MediaCacheStatus.ready
media_source.downloader_id = fn_id
return UploadResultResponse(media=media_source)