Harden background loops and API edge cases from code review
- crawler manager: retry transient errors inside each loop, never let log writes break supervision, run tasks in a TaskGroup so any escaped error restarts the container instead of leaving a half-dead process - market worker: tolerate symbols deleted mid-backfill, retry task selection and symbol queries, run loops in a TaskGroup - market WS: validate subscription messages and close the connection when either the receive or forward side ends - news pipeline: make first insert concurrency-safe with ON CONFLICT - market API: only proxy ticker/1s klines for added symbols, map Binance rate limits to 503 and upstream errors to 502 without blocking - news API: escape LIKE wildcards in search, return 422 for bad cursors - market page: cancel pending reconnect timer on unmount
This commit is contained in:
1 parent
aabc81ba5b
commit
554d224be2
7 files changed
+259
-114
No files matched your search
@@ -24,6 +24,7 @@ RESTART_DELAY_MAX = 300
|
||||
# 子进程稳定运行超过该时长后,异常退出的重启延迟重新从最小值开始
|
||||
STABLE_SECONDS = 60
|
||||
STOP_TIMEOUT = 15
|
||||
CONTROL_RECONNECT_SECONDS = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -40,8 +41,12 @@ class Worker:
|
||||
|
||||
|
||||
async def log(code: str, level: str, message: str) -> None:
|
||||
"""写 stdout 和 crawl_logs;写库失败只记 stdout,不能让守护任务因为日志而中断"""
|
||||
logger.log(logging.getLevelName(level), "[%s] %s", code, message)
|
||||
await write_logs([(code, None, level, message)])
|
||||
try:
|
||||
await write_logs([(code, None, level, message)])
|
||||
except Exception:
|
||||
logger.exception("[%s] 日志落库失败", code)
|
||||
|
||||
|
||||
class Manager:
|
||||
@@ -58,26 +63,22 @@ class Manager:
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, self.stopping.set)
|
||||
|
||||
tasks = [asyncio.create_task(self.supervise(w)) for w in self.workers.values()]
|
||||
tasks += [asyncio.create_task(c) for c in (self.schedule_loop(), self.control_loop(), self.status_loop())]
|
||||
await self.stopping.wait()
|
||||
logger.info("收到退出信号,停止全部子进程")
|
||||
await asyncio.gather(*(self.stop_process(w) for w in self.workers.values()))
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
# 各循环自行重试可恢复的错误;仍有异常逃出时 TaskGroup 让整个进程退出,由 docker 重启,
|
||||
# 避免出现"进程活着但调度或守护任务已经死掉"的状态
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
for w in self.workers.values():
|
||||
tg.create_task(self.supervise(w))
|
||||
loops = [tg.create_task(c) for c in (self.schedule_loop(), self.control_loop(), self.status_loop())]
|
||||
await self.stopping.wait()
|
||||
logger.info("收到退出信号,停止全部子进程")
|
||||
await asyncio.gather(*(self.stop_process(w) for w in self.workers.values()))
|
||||
for task in loops:
|
||||
task.cancel()
|
||||
await redis.delete(STATUS_KEY)
|
||||
|
||||
async def supervise(self, w: Worker) -> None:
|
||||
while not self.stopping.is_set():
|
||||
# 上次进程遗留的请求和未收尾的轮次都已失效,清理后从干净状态开始
|
||||
await redis.delete(f"{KEY_PREFIX}{w.code}:requests", start_urls_key(w.code))
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(
|
||||
update(CrawlRun)
|
||||
.where(CrawlRun.source_code == w.code, CrawlRun.status == "running")
|
||||
.values(status="interrupted", finished_at=datetime.now(UTC), error="子进程退出时本轮未完成")
|
||||
)
|
||||
await session.commit()
|
||||
await self.cleanup_before_spawn(w)
|
||||
w.proc = await asyncio.create_subprocess_exec("scrapy", "crawl", w.code)
|
||||
w.started_at = datetime.now(UTC)
|
||||
# 新进程的第一轮立即执行,并做全量回溯扫描
|
||||
@@ -104,6 +105,20 @@ class Manager:
|
||||
pass
|
||||
w.restart_delay = min(w.restart_delay * 2, RESTART_DELAY_MAX)
|
||||
|
||||
async def cleanup_before_spawn(self, w: Worker) -> None:
|
||||
"""上次进程遗留的请求和未收尾的轮次都已失效;清理失败不影响拉起子进程"""
|
||||
try:
|
||||
await redis.delete(f"{KEY_PREFIX}{w.code}:requests", start_urls_key(w.code))
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(
|
||||
update(CrawlRun)
|
||||
.where(CrawlRun.source_code == w.code, CrawlRun.status == "running")
|
||||
.values(status="interrupted", finished_at=datetime.now(UTC), error="子进程退出时本轮未完成")
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.exception("[%s] 拉起前清理失败,继续启动子进程", w.code)
|
||||
|
||||
async def stop_process(self, w: Worker) -> None:
|
||||
if w.proc is None:
|
||||
return
|
||||
@@ -115,38 +130,66 @@ class Manager:
|
||||
w.proc.kill()
|
||||
|
||||
async def schedule_loop(self) -> None:
|
||||
unknown: set[str] = set()
|
||||
while True:
|
||||
async with SessionLocal() as session:
|
||||
sources = (await session.scalars(select(Source))).all()
|
||||
now = datetime.now(UTC)
|
||||
for s in sources:
|
||||
w = self.workers[s.code]
|
||||
if not s.enabled or w.proc is None or w.restarting:
|
||||
continue
|
||||
if s.paused_until and s.paused_until > now:
|
||||
w.next_run_at = max(w.next_run_at, s.paused_until)
|
||||
continue
|
||||
if now < w.next_run_at:
|
||||
continue
|
||||
# 子进程还没取走上一条触发时不再堆积
|
||||
if await redis.llen(start_urls_key(s.code)) == 0:
|
||||
await redis.lpush(start_urls_key(s.code), json.dumps({"trigger": w.trigger, "backfill_hours": s.backfill_hours}))
|
||||
w.trigger = "schedule"
|
||||
w.next_run_at = now + timedelta(seconds=s.interval_seconds)
|
||||
try:
|
||||
await self.schedule_once(unknown)
|
||||
except Exception:
|
||||
logger.exception("调度出错,1s 后重试")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def schedule_once(self, unknown: set[str]) -> None:
|
||||
async with SessionLocal() as session:
|
||||
sources = (await session.scalars(select(Source))).all()
|
||||
now = datetime.now(UTC)
|
||||
for s in sources:
|
||||
w = self.workers.get(s.code)
|
||||
if w is None:
|
||||
# 启动后才新增的来源,需要重启管理进程才会接管
|
||||
if s.code not in unknown:
|
||||
unknown.add(s.code)
|
||||
logger.warning("来源 %s 不在本进程的守护列表中,重启管理进程后生效", s.code)
|
||||
continue
|
||||
if not s.enabled or w.proc is None or w.restarting:
|
||||
continue
|
||||
if s.paused_until and s.paused_until > now:
|
||||
w.next_run_at = max(w.next_run_at, s.paused_until)
|
||||
continue
|
||||
if now < w.next_run_at:
|
||||
continue
|
||||
# 子进程还没取走上一条触发时不再堆积
|
||||
if await redis.llen(start_urls_key(s.code)) == 0:
|
||||
await redis.lpush(start_urls_key(s.code), json.dumps({"trigger": w.trigger, "backfill_hours": s.backfill_hours}))
|
||||
w.trigger = "schedule"
|
||||
w.next_run_at = now + timedelta(seconds=s.interval_seconds)
|
||||
|
||||
async def control_loop(self) -> None:
|
||||
async with redis.pubsub() as pubsub:
|
||||
await pubsub.subscribe(CONTROL_CHANNEL)
|
||||
async for message in pubsub.listen():
|
||||
if message["type"] != "message":
|
||||
continue
|
||||
command = json.loads(message["data"])
|
||||
if command["action"] != "restart":
|
||||
logger.warning("未知指令:%s", command)
|
||||
continue
|
||||
codes = list(self.workers) if command["source"] == "all" else [command["source"]]
|
||||
await asyncio.gather(*(self.restart(self.workers[code]) for code in codes))
|
||||
while True:
|
||||
try:
|
||||
async with redis.pubsub() as pubsub:
|
||||
await pubsub.subscribe(CONTROL_CHANNEL)
|
||||
async for message in pubsub.listen():
|
||||
if message["type"] == "message":
|
||||
await self.handle_command(message["data"])
|
||||
except Exception:
|
||||
logger.exception("控制通道断开,%ds 后重新订阅", CONTROL_RECONNECT_SECONDS)
|
||||
await asyncio.sleep(CONTROL_RECONNECT_SECONDS)
|
||||
|
||||
async def handle_command(self, raw: str) -> None:
|
||||
"""单条指令出错只记日志,不影响后续指令"""
|
||||
try:
|
||||
command = json.loads(raw)
|
||||
if command.get("action") != "restart":
|
||||
logger.warning("未知指令:%s", raw)
|
||||
return
|
||||
source = command.get("source")
|
||||
codes = list(self.workers) if source == "all" else [source]
|
||||
if any(code not in self.workers for code in codes):
|
||||
logger.warning("重启指令中的来源不存在:%s", raw)
|
||||
return
|
||||
await asyncio.gather(*(self.restart(self.workers[code]) for code in codes))
|
||||
except Exception:
|
||||
logger.exception("处理指令失败:%s", raw)
|
||||
|
||||
async def restart(self, w: Worker) -> None:
|
||||
await log(w.code, "INFO", "收到重启指令")
|
||||
@@ -166,20 +209,26 @@ class Manager:
|
||||
|
||||
async def status_loop(self) -> None:
|
||||
while True:
|
||||
mapping = {
|
||||
w.code: json.dumps({
|
||||
"state": "restarting" if w.restarting else ("running" if w.proc else "stopped"),
|
||||
"pid": w.proc.pid if w.proc else None,
|
||||
"started_at": w.started_at.isoformat() if w.started_at else None,
|
||||
"next_run_at": w.next_run_at.isoformat(),
|
||||
})
|
||||
for w in self.workers.values()
|
||||
}
|
||||
async with redis.pipeline() as pipe:
|
||||
# 管理进程挂掉后状态自动过期,前端据此显示"管理进程未运行"
|
||||
await pipe.hset(STATUS_KEY, mapping=mapping).expire(STATUS_KEY, 10).execute()
|
||||
try:
|
||||
await self.publish_status()
|
||||
except Exception:
|
||||
logger.exception("发布状态失败,2s 后重试")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
async def publish_status(self) -> None:
|
||||
mapping = {
|
||||
w.code: json.dumps({
|
||||
"state": "restarting" if w.restarting else ("running" if w.proc else "stopped"),
|
||||
"pid": w.proc.pid if w.proc else None,
|
||||
"started_at": w.started_at.isoformat() if w.started_at else None,
|
||||
"next_run_at": w.next_run_at.isoformat(),
|
||||
})
|
||||
for w in self.workers.values()
|
||||
}
|
||||
async with redis.pipeline() as pipe:
|
||||
# 管理进程挂掉后状态自动过期,前端据此显示"管理进程未运行"
|
||||
await pipe.hset(STATUS_KEY, mapping=mapping).expire(STATUS_KEY, 10).execute()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Any
|
||||
|
||||
from scrapy.crawler import Crawler
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.core.redis import redis
|
||||
@@ -32,17 +33,26 @@ class NewsPipeline:
|
||||
spider = self.crawler.spider
|
||||
digest = content_hash(item)
|
||||
raw_response_id = item.pop("raw_response_id")
|
||||
locked = (
|
||||
select(NewsItem)
|
||||
.where(NewsItem.source_code == item["source_code"], NewsItem.external_id == item["external_id"])
|
||||
.with_for_update()
|
||||
)
|
||||
async with SessionLocal() as session:
|
||||
news = await session.scalar(
|
||||
select(NewsItem)
|
||||
.where(NewsItem.source_code == item["source_code"], NewsItem.external_id == item["external_id"])
|
||||
.with_for_update()
|
||||
)
|
||||
news = await session.scalar(locked)
|
||||
inserted_id = None
|
||||
if news is None:
|
||||
# 行不存在时 FOR UPDATE 锁不住任何东西;用 ON CONFLICT 处理同一条目被并发首次写入
|
||||
inserted_id = await session.scalar(
|
||||
pg_insert(NewsItem)
|
||||
.values(**item, content_hash=digest, revision_no=1)
|
||||
.on_conflict_do_nothing(index_elements=[NewsItem.source_code, NewsItem.external_id])
|
||||
.returning(NewsItem.id)
|
||||
)
|
||||
# 插入成功取新行;冲突说明另一处刚写入,按已存在条目走比较/修订流程
|
||||
news = await session.scalar(locked)
|
||||
if inserted_id is not None:
|
||||
kind = "new"
|
||||
news = NewsItem(**item, content_hash=digest, revision_no=1)
|
||||
session.add(news)
|
||||
await session.flush()
|
||||
elif news.content_hash != digest:
|
||||
kind = "revised"
|
||||
for field in UPDATABLE_FIELDS:
|
||||
|
||||
Reference in new issue
Block a user