fix(news): recover deferred work and support large research batches
Development CI / check-and-build (push) Successful in 3m20s
GitHub Git mirror / mirror (push) Successful in 6s
Development CI / deploy-development (push) Successful in 2m30s

This commit is contained in:
eezd committed 2026-11-05 21:10:26 +08:00
1 parent c7b54ce3ee
commit f2ef39e99a
5 files changed
+100 -18

No files matched your search

+8 -1
View File
@@ -19,6 +19,7 @@ from alphapulse.news_research.models import (
Membership,
Version,
Reference,
BatchEvent,
)
from alphapulse.news_research import service, batches, vocabulary as vocab
from alphapulse.news_research.models import VocabularyVersion
@@ -496,7 +497,13 @@ async def retry_event(identifier: int, session: DB):
raise HTTPException(404, "事件不存在")
if row.state == "processing":
raise HTTPException(409, "事件正在处理")
if row.state == "done":
deferred = None
if row.state == "pending":
deferred = await session.scalar(select(BatchEvent.batch_id).where(
BatchEvent.event_id == row.id, BatchEvent.revision == row.revision,
BatchEvent.state == "deferred").limit(1))
if row.state == "done" or deferred is not None:
# Escape a deferred revision only on explicit retry; ordinary retries reuse evidence.
service.dirty(row, True)
else:
row.state = "pending"
@@ -1,7 +1,7 @@
"""Durable admission barriers: finish this batch's assignments before any research."""
from collections import Counter
from datetime import UTC, datetime
from sqlalchemy import select, or_, exists, func
from sqlalchemy import select, or_, and_, exists, func, any_
from alphapulse.models import NewsItem
from alphapulse.news_research.models import Batch, BatchPackage, BatchEvent, Package, Event, Membership, Attempt
from alphapulse.news_research.service import enroll
@@ -10,8 +10,8 @@ from alphapulse.news_research.service import enroll
async def freeze(session, settings):
cutoff = datetime.now(UTC)
ids = list(await session.scalars(select(NewsItem.id).outerjoin(Package, Package.news_id == NewsItem.id).where(
NewsItem.published_at >= settings.start_at, NewsItem.published_at <= cutoff,
or_(Package.id.is_(None), Package.content_hash != NewsItem.content_hash))))
or_(and_(NewsItem.published_at >= settings.start_at, NewsItem.published_at <= cutoff, Package.id.is_(None)),
and_(Package.id.is_not(None), Package.content_hash != NewsItem.content_hash)))))
existing_ids = list(await session.scalars(select(Package.id).where(
Package.state.in_(['index_pending', 'assign_pending', 'index_failed', 'assign_failed']))))
event_cutoff = list(await session.scalars(select(Event.id).where(Event.state == "pending")))
@@ -21,12 +21,12 @@ async def freeze(session, settings):
BatchPackage.package_id == Package.id, BatchPackage.content_hash == Package.content_hash,
BatchPackage.state == 'failed'))
packages = list(await session.scalars(select(Package).where(
or_(Package.id.in_(existing_ids), Package.news_id.in_(ids)), or_(
or_(Package.id == any_(existing_ids), Package.news_id == any_(ids)), or_(
Package.state.in_(['index_pending', 'assign_pending']),
Package.state.in_(['index_failed', 'assign_failed']) & ~previous_failure))))
deferred = exists(select(BatchEvent.batch_id).where(BatchEvent.event_id == Event.id,
BatchEvent.revision == Event.revision, BatchEvent.state == 'deferred'))
events = list(await session.scalars(select(Event).where(Event.id.in_(event_cutoff), Event.state == 'pending', ~deferred)))
events = list(await session.scalars(select(Event).where(Event.id == any_(event_cutoff), Event.state == 'pending', ~deferred)))
if not packages and not events:
return None
batch = Batch(phase='assigning', cutoff_at=cutoff)
@@ -37,8 +37,8 @@ async def freeze(session, settings):
event_ids = {e.id for e in events}
if packages:
event_ids.update(await session.scalars(select(Membership.event_id).where(
Membership.package_id.in_([p.id for p in packages]))))
for e in await session.scalars(select(Event).where(Event.id.in_(event_ids))):
Membership.package_id == any_([p.id for p in packages]))))
for e in await session.scalars(select(Event).where(Event.id == any_(list(event_ids)))):
session.add(BatchEvent(batch_id=batch.id, event_id=e.id, revision=e.revision, state='pending'))
await session.commit()
return batch
@@ -73,7 +73,7 @@ async def actions(session, batch, active, slots):
ids = set(await session.scalars(select(Membership.event_id).join(
BatchPackage, BatchPackage.package_id == Membership.package_id).where(BatchPackage.batch_id == batch.id)))
existing = {x.event_id for x in await session.scalars(select(BatchEvent).where(BatchEvent.batch_id == batch.id))}
for e in await session.scalars(select(Event).where(Event.id.in_(ids - existing))):
for e in await session.scalars(select(Event).where(Event.id == any_(list(ids - existing)))):
session.add(BatchEvent(batch_id=batch.id, event_id=e.id, revision=e.revision, state='pending'))
await session.flush()
latest = select(func.max(Attempt.id)).join(BatchPackage, BatchPackage.package_id == Attempt.package_id).where(
@@ -5,7 +5,7 @@ import json
import re
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from sqlalchemy import select, update, func, or_, case, column, exists, true, literal
from sqlalchemy import select, update, func, or_, case, column, exists, true, literal, any_
from sqlalchemy.dialects.postgresql import insert
from alphapulse.core.db import SessionLocal
from alphapulse.models import NewsItem
@@ -233,7 +233,7 @@ async def model_config(session, config, stage):
async def enroll(session, ids):
news = list(await session.scalars(select(NewsItem).where(NewsItem.id.in_(ids))))
news = list(await session.scalars(select(NewsItem).where(NewsItem.id == any_(list(ids)))))
for row in news:
await session.execute(
insert(Package)
@@ -124,22 +124,22 @@ def test_new_event_waits_for_aggregation_unless_urgent(trading, urgent):
assert event.due_at - event.dirty_since == timedelta(minutes=0 if urgent else 5)
@pytest.mark.parametrize("state", ["failed", "pending", "done"])
def test_event_retry_preserves_content_revision_until_content_changes(trading, state):
@pytest.mark.parametrize("state,deferred", [("failed", False), ("pending", False), ("pending", True), ("done", False)])
def test_event_retry_preserves_content_revision_unless_done_or_deferred(trading, state, deferred):
import asyncio
from unittest.mock import AsyncMock
from alphapulse.news_research.api import retry_event
event = N(
revision=7,
id=1, revision=7,
state=state,
dirty_since=None,
due_at=datetime.now(UTC),
error="Previous failure",
)
session = N(get=AsyncMock(return_value=event), commit=AsyncMock())
session = N(get=AsyncMock(return_value=event), scalar=AsyncMock(return_value=12 if deferred else None), commit=AsyncMock())
asyncio.run(retry_event(1, session))
assert event.revision == (8 if state == "done" else 7)
assert event.revision == (8 if state == "done" or deferred else 7)
assert event.state == "pending" and event.error is None
assert event.due_at <= datetime.now(UTC)