Prioritize realtime news and make historical backfill resumable
Development CI / check-and-build (push) Successful in 3m0s
GitHub Git mirror / mirror (push) Successful in 5s
Development CI / deploy-development (push) Successful in 14m7s

This commit is contained in:
eezd committed 2026-11-06 14:28:39 +08:00
1 parent c90691d9a0
commit 5f347f8ab4
17 files changed
+697 -143

No files matched your search

@@ -0,0 +1,24 @@
"""Persist realtime boundaries and enforce one active call per subject."""
from alembic import op
import sqlalchemy as sa
revision = 'dd4567890123'
down_revision = 'dc3456789012'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('news_research_settings', sa.Column('realtime_from', sa.DateTime(timezone=True)))
op.add_column('news_research_events', sa.Column('realtime_pending', sa.Boolean(), nullable=False, server_default=sa.false()))
op.create_index('uq_news_attempt_active', 'news_research_attempts', ['package_id'], unique=True,
postgresql_where=sa.text("state = 'processing'"))
op.create_index('uq_news_version_active', 'news_research_versions', ['event_id'], unique=True,
postgresql_where=sa.text("state = 'processing'"))
def downgrade():
op.drop_index('uq_news_version_active', table_name='news_research_versions')
op.drop_index('uq_news_attempt_active', table_name='news_research_attempts')
op.drop_column('news_research_events', 'realtime_pending')
op.drop_column('news_research_settings', 'realtime_from')
+42 -16
View File
@@ -20,8 +20,9 @@ from alphapulse.news_research.models import (
Version,
Reference,
BatchEvent,
Batch,
)
from alphapulse.news_research import service, batches, vocabulary as vocab
from alphapulse.news_research import service, batches, scheduling, vocabulary as vocab
from alphapulse.news_research.models import VocabularyVersion
from alphapulse.news_research.contracts import (
INDEX_ASSIGN_SKILL,
@@ -90,6 +91,8 @@ class SettingsInput(Strict):
index: ModelConfig
research: ModelConfig
concurrency: int = Field(default=3, ge=1, le=16)
start_at: AwareDatetime | None = None
base_revision: int | None = Field(default=None, ge=0)
async def settings_row(session):
@@ -101,7 +104,7 @@ async def settings_row(session):
id=1,
config={},
enabled=False,
start_at=datetime(2026, 10, 31, 16, tzinfo=UTC),
start_at=datetime(2026, 11, 4, 16, tzinfo=UTC),
)
.on_conflict_do_nothing(index_elements=["id"])
)
@@ -115,6 +118,8 @@ async def settings(session: DB):
row = await settings_row(session)
return success(
{"config": row.config, "enabled": row.enabled, "start_at": row.start_at,
"control_revision": row.control_revision, "realtime_from": row.realtime_from,
"timezone": "Asia/Shanghai", "queue": await scheduling.queue_status(session, row),
"batch": await batches.progress(session)}
)
@@ -125,21 +130,24 @@ async def coverage(session: DB, start_at: AwareDatetime | None = None):
start = (start_at if start_at is not None else row.start_at).astimezone(UTC)
if start > datetime.now(UTC):
raise HTTPException(422, "回补起始时间不能晚于当前时间")
return success(await batches.coverage(session, start))
effective = min(start, row.realtime_from) if row.realtime_from else start
report = await batches.coverage(session, effective)
return success({**report, 'backfill_start_at': start, 'realtime_from': row.realtime_from})
@router.put("/settings")
async def save(body: SettingsInput, session: DB):
await settings_row(session)
await session.execute(
update(ResearchSettings)
.where(ResearchSettings.id == 1)
.values(
config=body.model_dump(),
enabled=False,
control_revision=ResearchSettings.control_revision + 1,
)
)
row = await session.get(ResearchSettings, 1, populate_existing=True, with_for_update=True)
if body.base_revision is not None and body.base_revision != row.control_revision:
raise HTTPException(409, "设置已变化,请重新读取后保存")
if body.start_at is not None:
if body.start_at > datetime.now(UTC):
raise HTTPException(422, "回补起始时间不能晚于当前时间")
row.start_at = body.start_at.astimezone(UTC)
row.config = body.model_dump(exclude={"start_at", "base_revision"})
row.enabled = False
row.control_revision += 1
await session.commit()
return success(None)
@@ -164,6 +172,8 @@ class EnableInput(Strict):
async def enable(session: DB, body: EnableInput | None = None):
row = await settings_row(session)
start_at = (body.start_at if body is not None else row.start_at).astimezone(UTC)
if start_at != row.start_at:
raise HTTPException(422, "请在模型与 Skills 中保存回补时间并暂停后再启用")
if start_at > datetime.now(UTC):
raise HTTPException(422, "回补起始时间不能晚于当前时间")
if not await session.scalar(text("SELECT pg_try_advisory_xact_lock(20261103, 2)")):
@@ -172,11 +182,11 @@ async def enable(session: DB, body: EnableInput | None = None):
raise HTTPException(409, "请先暂停两阶段处理")
running = await session.scalar(
select(func.count())
.select_from(Package)
.where(Package.state.in_(["index_processing", "assign_processing"]))
.select_from(Attempt)
.where(Attempt.state == "processing")
)
running += await session.scalar(
select(func.count()).select_from(Event).where(Event.state == "processing")
select(func.count()).select_from(Version).where(Version.state == "processing")
)
if running:
raise HTTPException(409, "请等待已接收任务完成后再验证")
@@ -259,7 +269,10 @@ async def enable(session: DB, body: EnableInput | None = None):
if row.control_revision != control_revision or row.config != original_config:
raise HTTPException(409, "验证期间已暂停或设置变化,请重新验证")
report = await batches.coverage(session, start_at)
row.start_at = start_at
if row.realtime_from is None:
row.realtime_from = datetime.now(UTC)
await session.execute(update(Batch).where(Batch.phase.in_(["assigning", "researching"]))
.values(phase="rescheduled", finished_at=datetime.now(UTC)))
row.enabled = True
row.control_revision += 1
await session.commit()
@@ -480,6 +493,18 @@ async def event(identifier: int, session: DB):
.order_by(Version.id.desc())
)
)
wait_reason = None
if row.state == 'pending':
settings = await settings_row(session)
unfinished = [(p, n) for p, n in members if p.state != 'done' or p.content_hash != n.content_hash]
if any(n.published_at < scheduling.boundary(settings) for p, n in unfinished):
wait_reason = '范围外成员尚未完成,扩大回补范围后可继续研究'
elif any(p.state.endswith('_failed') for p, n in unfinished):
wait_reason = '成员包处理失败,需手动重试后才能研究'
elif unfinished:
wait_reason = '等待成员包完成关键词与事件归属'
elif row.due_at > datetime.now(UTC):
wait_reason = '正在汇集事件变化,聚合窗口结束后研究'
latest = next((v for v in versions if v.state == "done"), None)
references = await version_references(session, latest.id) if latest else []
return success(
@@ -490,6 +515,7 @@ async def event(identifier: int, session: DB):
"result": row.result,
"error": row.error,
"revision": row.revision,
"wait_reason": wait_reason,
"packages": [await package_out(session, p, n) for p, n in members],
"references": references,
"versions": [
+40 -16
View File
@@ -5,6 +5,7 @@ from sqlalchemy import select, or_, and_, exists, func, any_, case
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
from alphapulse.news_research.scheduling import scoped_event, ready_event
async def coverage(session, start_at, cutoff=None):
@@ -48,13 +49,22 @@ async def coverage(session, start_at, cutoff=None):
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(
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")))
scope = [NewsItem.published_at >= settings.start_at, NewsItem.published_at <= cutoff]
realtime_from = getattr(settings, 'realtime_from', None)
if realtime_from is not None:
scope.append(NewsItem.published_at < realtime_from)
candidates = select(NewsItem.id).outerjoin(Package, Package.news_id == NewsItem.id).where(
*scope, or_(Package.id.is_(None), Package.content_hash != NewsItem.content_hash,
Package.state.in_(['index_pending', 'assign_pending'])))
ids = list(await session.scalars(candidates.order_by(NewsItem.published_at, NewsItem.id).limit(30)))
await enroll(session, ids)
existing_ids = list(await session.scalars(select(Package.id).where(Package.news_id.in_(ids))))
deferred = exists(select(BatchEvent.batch_id).where(BatchEvent.event_id == Event.id,
BatchEvent.revision == Event.revision, BatchEvent.state == 'deferred'))
event_query = select(Event.id).where(Event.state == 'pending', scoped_event(settings), ready_event(), Event.due_at <= cutoff, ~deferred)
if realtime_from is not None:
event_query = event_query.where(~Event.realtime_pending)
event_cutoff = list(await session.scalars(event_query.order_by(Event.due_at, Event.id).limit(30)))
# Failed items are reported once; only explicit retries or new source revisions rerun them.
previous_failure = exists(select(BatchPackage.batch_id).where(
BatchPackage.package_id == Package.id, BatchPackage.content_hash == Package.content_hash,
@@ -63,8 +73,6 @@ async def freeze(session, settings):
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 == any_(event_cutoff), Event.state == 'pending', ~deferred)))
if not packages and not events:
return None
@@ -83,7 +91,7 @@ async def freeze(session, settings):
return batch
async def actions(session, batch, active, slots):
async def actions(session, batch, active, slots, settings=None):
"""Return stage/ID pairs; callers commit before launching and never overlap phases."""
if batch.phase == 'assigning':
rows = list(await session.execute(select(BatchPackage, Package, NewsItem).join(
@@ -94,7 +102,9 @@ async def actions(session, batch, active, slots):
for item, package, news in rows:
if item.state != 'pending':
continue
if item.content_hash != news.content_hash or item.content_hash != package.content_hash:
if settings is not None and news.published_at < settings.start_at:
item.state = 'range_paused'
elif item.content_hash != news.content_hash or item.content_hash != package.content_hash:
item.state = 'deferred'
elif package.state == 'done':
item.state = 'done'
@@ -102,10 +112,12 @@ async def actions(session, batch, active, slots):
item.state = 'failed'
elif package.state in ('index_pending', 'assign_pending'):
stage = 'index' if package.state == 'index_pending' else 'assign'
if (stage, package.id) not in active and len(result) < slots and (stage != 'assign' or not assigning):
if not any(identifier == package.id and task != 'event' for task, identifier in active) and len(result) < slots and (stage != 'assign' or not assigning):
result.append((stage, package.id))
assigning |= stage == 'assign'
if any(item.state == 'pending' for item, _, _ in rows) or active:
package_ids = {package.id for _, package, _ in rows}
batch_active = any(stage != 'event' and identifier in package_ids for stage, identifier in active)
if any(item.state == 'pending' for item, _, _ in rows) or batch_active:
return result
# Memberships now include events created by this batch. Existing affected events
# were frozen up front, including memberships that have since been removed.
@@ -131,7 +143,13 @@ async def actions(session, batch, active, slots):
for item, event in rows:
if item.state not in ('pending', 'urgent_pending', 'processing') or ('event', event.id) in active:
continue
if item.revision != event.revision:
if settings is not None and not await session.scalar(select(Event.id).where(Event.id == event.id, scoped_event(settings))):
item.state = 'range_paused'
elif settings is not None and event.realtime_pending:
item.state = 'realtime'
elif event.due_at > datetime.now(UTC):
item.state = 'aggregating'
elif item.revision != event.revision:
item.state = 'deferred'
elif event.state == 'done' or event.state == 'retired':
item.state = 'done'
@@ -143,11 +161,17 @@ async def actions(session, batch, active, slots):
invalid = await session.scalar(select(Membership.event_id).join(Package).join(NewsItem).where(
Membership.event_id == event.id, or_(Package.state != 'done', Package.content_hash != NewsItem.content_hash)).limit(1))
if invalid:
item.state = 'deferred'
item.state = 'blocked'
elif len(result) < slots:
item.state = 'processing'
result.append(('event', event.id))
if not result and not active and all(item.state not in ('pending', 'urgent_pending', 'processing') for item, _ in rows):
event_ids = {event.id for _, event in rows}
batch_active = any(stage == 'event' and identifier in event_ids for stage, identifier in active)
active_packages = {identifier for stage, identifier in active if stage != 'event'}
if active_packages:
batch_active |= bool(await session.scalar(select(BatchPackage.package_id).where(
BatchPackage.batch_id == batch.id, BatchPackage.package_id.in_(active_packages)).limit(1)))
if not result and not batch_active and all(item.state not in ('pending', 'urgent_pending', 'processing') for item, _ in rows):
bad = any(item.state in ('failed', 'deferred') for item, _ in rows)
bad |= bool(await session.scalar(select(BatchPackage.batch_id).where(
BatchPackage.batch_id == batch.id, BatchPackage.state.in_(['failed', 'deferred'])).limit(1)))
@@ -163,5 +187,5 @@ async def progress(session):
result = {'id': batch.id, 'phase': batch.phase, 'cutoff_at': batch.cutoff_at}
for name, model in [('packages', BatchPackage), ('events', BatchEvent)]:
counts = Counter(await session.scalars(select(model.state).where(model.batch_id == batch.id)))
result.update({name + '_total': sum(counts.values()), **{name + '_' + s: counts[s] for s in ('done', 'failed', 'deferred')}})
result.update({name + '_total': sum(counts.values()), **{name + '_' + s: counts[s] for s in ('done', 'failed', 'deferred', 'range_paused', 'blocked', 'realtime')}})
return result
@@ -6,6 +6,8 @@ from sqlalchemy import (
DateTime,
ForeignKey,
Integer,
Index,
text,
String,
Text,
UniqueConstraint,
@@ -32,6 +34,7 @@ class ResearchSettings(Base):
Integer, default=0, server_default="0"
)
start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
realtime_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class Package(Base):
@@ -49,6 +52,7 @@ class Package(Base):
class Attempt(Base):
__tablename__ = "news_research_attempts"
__table_args__ = (Index("uq_news_attempt_active", "package_id", unique=True, postgresql_where=text("state = 'processing'")),)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
package_id: Mapped[int] = mapped_column(
ForeignKey("news_research_packages.id"), index=True
@@ -75,6 +79,7 @@ class Event(Base):
state: Mapped[str] = mapped_column(String(16), default="pending", index=True)
result: Mapped[dict | None] = mapped_column(JSONB)
error: Mapped[str | None] = mapped_column(Text)
realtime_pending: Mapped[bool] = mapped_column(default=False, server_default="false")
dirty_since: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
due_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
@@ -99,6 +104,7 @@ class Membership(Base):
class Version(Base):
__tablename__ = "news_research_versions"
__table_args__ = (Index("uq_news_version_active", "event_id", unique=True, postgresql_where=text("state = 'processing'")),)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
event_id: Mapped[int] = mapped_column(
ForeignKey("news_research_events.id"), index=True
@@ -0,0 +1,103 @@
"""Priority selection over durable package and event states; no paid work here."""
from datetime import UTC, datetime
from sqlalchemy import select, exists, or_, and_, func, case
from alphapulse.models import NewsItem
from alphapulse.news_research.models import Package, Event, Membership, Attempt, Version, ResearchSettings
def boundary(settings):
realtime_from = getattr(settings, 'realtime_from', None)
return min(settings.start_at, realtime_from) if realtime_from else settings.start_at
def eligible(settings):
return and_(NewsItem.published_at >= boundary(settings), NewsItem.published_at <= datetime.now(UTC))
def members(event_id):
return select(Membership.package_id).where(Membership.event_id == event_id)
def ready_event():
return ~exists(select(Membership.package_id).join(Package).join(NewsItem).where(
Membership.event_id == Event.id,
or_(Package.state != 'done', Package.content_hash != NewsItem.content_hash)))
def scoped_event(settings):
# Empty events must still reach the existing no-model retirement path.
return or_(exists(select(Membership.package_id).join(Package).join(NewsItem).where(
Membership.event_id == Event.id, eligible(settings))),
~exists(select(Membership.package_id).where(Membership.event_id == Event.id)))
async def control(session, config):
"""The control lock spans the durable claim, never the remote model call."""
if '_control_revision' not in config:
return None # Explicit bounded/offline callers retain their existing contract.
row = await session.get(ResearchSettings, 1, populate_existing=True, with_for_update=True)
if not row.enabled or row.control_revision != config['_control_revision']:
return False
return row
async def mark_priority(session, event, news):
settings = await session.get(ResearchSettings, 1)
if settings and settings.realtime_from and news.published_at >= settings.realtime_from:
event.realtime_pending = True
async def realtime_actions(session, settings, active, slots):
if settings.realtime_from is None or slots <= 0:
return []
now = datetime.now(UTC)
busy_packages = {identifier for stage, identifier in active if stage != 'event'}
busy_events = {identifier for stage, identifier in active if stage == 'event'}
result = []
events = await session.scalars(select(Event).where(
Event.state == 'pending', Event.realtime_pending, Event.due_at <= now,
scoped_event(settings), ready_event(), Event.id.not_in(busy_events),
~exists(select(Version.id).where(Version.event_id == Event.id, Version.state == 'processing')),
).order_by(Event.due_at, Event.id).limit(slots))
result.extend(('event', event.id) for event in events)
# Known historical dependencies inherit the waiting realtime event's priority.
dependency = exists(select(Membership.package_id).join(Event).where(
Membership.package_id == Package.id, Event.realtime_pending, Event.state == 'pending'))
priority = or_(NewsItem.published_at >= settings.realtime_from, dependency)
candidates = select(NewsItem.id).outerjoin(Package, Package.news_id == NewsItem.id).where(
eligible(settings), priority, or_(Package.id.is_(None), Package.content_hash != NewsItem.content_hash),
or_(Package.id.is_(None), Package.id.not_in(busy_packages))).order_by(NewsItem.published_at, NewsItem.id).limit(30)
ids = list(await session.scalars(candidates))
if ids:
from alphapulse.news_research.service import enroll
await enroll(session, ids)
assigning = any(stage == 'assign' for stage, _ in active)
for stage in ('assign', 'index'):
if stage == 'assign' and assigning:
continue
remaining = slots - len(result)
if remaining <= 0:
break
query = select(Package.id).join(NewsItem).where(
eligible(settings), priority, Package.state == stage + '_pending',
Package.id.not_in(busy_packages),
~exists(select(Attempt.id).where(Attempt.package_id == Package.id, Attempt.state == 'processing')),
).order_by(NewsItem.published_at, NewsItem.id).limit(1 if stage == 'assign' else remaining)
result.extend((stage, identifier) for identifier in await session.scalars(query))
return result
async def queue_status(session, settings):
running = await session.scalar(select(func.count()).select_from(Attempt).where(Attempt.state == 'processing'))
running += await session.scalar(select(func.count()).select_from(Version).where(Version.state == 'processing'))
realtime_from = settings.realtime_from
lane = case((NewsItem.published_at >= realtime_from, 'realtime'), else_='backfill') if realtime_from else 'backfill'
if realtime_from:
groups = (await session.execute(select(lane, Package.state, func.count()).join(NewsItem)
.where(eligible(settings)).group_by(lane, Package.state))).all()
else:
groups = [('backfill', state, count) for state, count in (await session.execute(
select(Package.state, func.count()).join(NewsItem).where(eligible(settings)).group_by(Package.state))).all()]
queues = {name: {state: count for selected, state, count in groups if selected == name}
for name in ('realtime', 'backfill')}
return {'running': running, 'draining': not settings.enabled and running > 0, **queues}
@@ -27,6 +27,7 @@ from alphapulse.news_research.models import (
Version,
Reference,
)
from alphapulse.news_research.scheduling import control, mark_priority, boundary
from alphapulse.news_research.contracts import (
INDEX_ASSIGN_SKILL,
task_skill,
@@ -280,6 +281,7 @@ async def enroll(session, ids):
.with_for_update()
):
dirty(event)
await mark_priority(session, event, row)
await session.commit()
@@ -429,6 +431,9 @@ async def process_package(identifier, config):
async def _process_package(identifier, config, expected_stage):
async with SessionLocal() as session:
settings = await control(session, config)
if settings is False:
return
package = await session.get(Package, identifier, with_for_update=True)
if package is None:
return
@@ -438,6 +443,10 @@ async def _process_package(identifier, config, expected_stage):
if package.content_hash != news.content_hash:
await enroll(session, [news.id])
return
if settings is not None and not boundary(settings) <= news.published_at <= datetime.now(UTC):
return
if await session.scalar(select(Attempt.id).where(Attempt.package_id == identifier, Attempt.state == 'processing').limit(1)):
return
source = article(news)
source_hash = package.content_hash
stage = "index" if package.state == "index_pending" else "assign"
@@ -566,12 +575,14 @@ async def _process_package(identifier, config, expected_stage):
existing.change_kind = target.change_kind
if target.change_kind != "repost" or event.result is None:
dirty(event, target.urgent)
await mark_priority(session, event, news)
for old in previous:
if old.event_id not in assigned:
event = await session.get(
Event, old.event_id, with_for_update=True
)
dirty(event)
await mark_priority(session, event, news)
await session.delete(old)
affected[event.id] = event
package.state = "done"
@@ -805,6 +816,9 @@ def validate_timeline(result, package_news):
async def process_event(identifier, config, force=False):
async with SessionLocal() as session:
settings = await control(session, config)
if settings is False:
return
event = await session.get(Event, identifier, with_for_update=True)
if (
event is None
@@ -812,6 +826,8 @@ async def process_event(identifier, config, force=False):
or (not force and event.due_at > datetime.now(UTC))
):
return
if await session.scalar(select(Version.id).where(Version.event_id == identifier, Version.state == 'processing').limit(1)):
return
members = list(
await session.execute(
select(Package, Membership, NewsItem)
@@ -821,6 +837,8 @@ async def process_event(identifier, config, force=False):
.order_by(Package.id)
)
)
if settings is not None and members and not any(boundary(settings) <= n.published_at <= datetime.now(UTC) for p, m, n in members):
return
if not members:
event.state = "retired"
await session.commit()
@@ -859,6 +877,7 @@ async def process_event(identifier, config, force=False):
)
session.add(version)
event.state = "processing"
event.dirty_since = None
await session.commit()
version_id, revision = version.id, event.revision
researcher = Research(event={"id": identifier, "revision": revision})
@@ -977,6 +996,7 @@ async def process_event(identifier, config, force=False):
current.title = output.title
current.state = "done"
current.dirty_since = None
current.realtime_pending = False
current.error = None
for ref in output.references:
session.add(
+38 -33
View File
@@ -13,38 +13,42 @@ from alphapulse.news_research.models import (
Batch, BatchEvent,
)
from alphapulse.news_research.service import process_package, process_event
from alphapulse.news_research import batches
from alphapulse.news_research import batches, scheduling
logger = logging.getLogger(__name__)
async def recover_interrupted():
async with SessionLocal() as session:
for stage in ("index", "assign"):
await session.execute(
update(Package)
.where(Package.state == stage + "_processing")
.values(state=stage + "_failed", error="进程中断,调用结果不明,请手动重试")
)
await session.execute(
update(Attempt)
.where(Attempt.state == "processing")
.values(state="failed", error="进程中断,调用结果不明,请手动重试")
)
await session.execute(
update(Event).where(Event.state == "processing").values(state="failed", error="进程中断,调用结果不明,请手动重试")
)
await session.execute(
update(Version)
.where(Version.state == "processing")
.values(state="failed", error="进程中断,调用结果不明,请手动重试")
)
await session.execute(update(BatchEvent).where(BatchEvent.state == 'processing').values(state='pending'))
await session.commit()
async def run():
async with engine.connect() as lock:
if not await lock.scalar(text("SELECT pg_try_advisory_lock(20261030, 1)")):
raise RuntimeError("新闻AI worker 已运行")
await lock.commit()
async with SessionLocal() as session:
for stage in ("index", "assign"):
await session.execute(
update(Package)
.where(Package.state == stage + "_processing")
.values(state=stage + "_pending")
)
await session.execute(
update(Attempt)
.where(Attempt.state == "processing")
.values(state="failed", error="进程中断,待重试")
)
await session.execute(
update(Event).where(Event.state == "processing").values(state="pending")
)
await session.execute(
update(Version)
.where(Version.state == "processing")
.values(state="failed", error="进程中断,待重试")
)
await session.execute(update(BatchEvent).where(BatchEvent.state == 'processing').values(state='pending'))
await session.commit()
await recover_interrupted()
tasks = {}
try:
while True:
@@ -58,20 +62,21 @@ async def run():
async with SessionLocal() as session:
settings = await session.get(ResearchSettings, 1)
if settings and settings.enabled:
active = set(tasks.values())
slots = max(0, settings.config.get('concurrency', 3) - len(tasks))
actions = await scheduling.realtime_actions(session, settings, active, slots)
batch = await session.scalar(select(Batch).where(
Batch.phase.in_(['assigning', 'researching'])).order_by(Batch.id).limit(1))
if batch is None:
batch = await batches.freeze(session, settings)
settings = await session.get(ResearchSettings, 1, populate_existing=True, with_for_update=True)
if batch is not None and settings.enabled:
active = set(tasks.values())
actions = await batches.actions(session, batch, active,
max(0, settings.config.get('concurrency', 3) - len(tasks)))
await session.commit()
for stage, identifier in actions:
call = (process_event(identifier, settings.config, force=True)
if stage == 'event' else process_package(identifier, settings.config))
tasks[asyncio.create_task(call)] = (stage, identifier)
if batch is not None:
actions += await batches.actions(session, batch, active | set(actions), slots - len(actions), settings)
await session.commit()
config = {**settings.config, '_control_revision': settings.control_revision}
for stage, identifier in actions:
call = (process_event(identifier, config)
if stage == 'event' else process_package(identifier, config))
tasks[asyncio.create_task(call)] = (stage, identifier)
await asyncio.sleep(1)
finally:
await asyncio.gather(*tasks, return_exceptions=True)
+46 -1
View File
@@ -302,7 +302,7 @@ def test_failed_enable_does_not_save_the_selected_backfill_start(trading, monkey
session = N(get=AsyncMock(return_value=row), scalar=AsyncMock(side_effect=[True, 0, 0]), commit=AsyncMock())
monkeypatch.setattr(service, "model_config", AsyncMock(side_effect=ValueError("Skill 不可用")))
with pytest.raises(HTTPException) as error:
asyncio.run(api.enable(session, api.EnableInput(start_at=previous - timedelta(days=30))))
asyncio.run(api.enable(session, api.EnableInput(start_at=previous)))
assert error.value.status_code == 502
assert row.start_at == previous and not row.enabled and row.control_revision == 7
session.commit.assert_not_called()
@@ -842,6 +842,7 @@ def test_alias_adjustment_audit_survives_index_failures(trading, monkeypatch, fa
session.get = AsyncMock(side_effect=lambda model, *args, **kwargs:
attempts[0] if model is Attempt else package if model is Package else news)
session.execute = AsyncMock()
session.scalar = AsyncMock(return_value=None)
monkeypatch.setattr(service, "SessionLocal", lambda: session)
monkeypatch.setattr(service.vocab, "snapshot", AsyncMock(return_value={"entries": []}))
monkeypatch.setattr(service, "model_config", AsyncMock(return_value=(None, "索引规则", {})))
@@ -903,3 +904,47 @@ def test_task_skill_keeps_custom_rules_and_accepts_crlf(trading):
content = "## 归属规则\r\n自定义归属\r\n### 细则\r\n保留规则\r\n## 索引规则\r\n自定义索引"
assert task_skill(content, "assign") == "自定义归属\n### 细则\n保留规则"
assert task_skill(content, "index") == "自定义索引"
def test_settings_save_atomically_pauses_and_preserves_model_contract(trading):
import asyncio
from unittest.mock import AsyncMock
from alphapulse.news_research import api
row = N(start_at=datetime(2026, 11, 4, 16, tzinfo=UTC), config={}, enabled=True, control_revision=3)
session = N(get=AsyncMock(return_value=row), commit=AsyncMock())
model = dict(provider_id=1, model='test', skill_id=1)
body = api.SettingsInput(index=model, research=model, start_at='2026-10-20T00:00:00+08:00', base_revision=3)
asyncio.run(api.save(body, session))
assert not row.enabled and row.control_revision == 4
assert row.start_at == datetime(2026, 10, 19, 16, tzinfo=UTC)
assert set(row.config) == {'index', 'research', 'concurrency'}
session.commit.assert_awaited_once()
def test_settings_save_rejects_other_tabs_revision_without_changes(trading):
import asyncio
from unittest.mock import AsyncMock
from fastapi import HTTPException
from alphapulse.news_research import api
row = N(start_at=datetime(2026, 11, 4, 16, tzinfo=UTC), config={}, enabled=True, control_revision=4)
session = N(get=AsyncMock(return_value=row), commit=AsyncMock())
model = dict(provider_id=1, model='test', skill_id=1)
with pytest.raises(HTTPException) as error:
asyncio.run(api.save(api.SettingsInput(index=model, research=model, base_revision=3), session))
assert error.value.status_code == 409
assert row.enabled and row.control_revision == 4
session.commit.assert_not_awaited()
def test_enable_cannot_apply_an_unsaved_backfill_time(trading):
import asyncio
from unittest.mock import AsyncMock
from fastapi import HTTPException
from alphapulse.news_research import api
row = N(start_at=datetime(2026, 11, 4, 16, tzinfo=UTC))
session = N(get=AsyncMock(return_value=row), scalar=AsyncMock(), commit=AsyncMock())
with pytest.raises(HTTPException) as error:
asyncio.run(api.enable(session, api.EnableInput(start_at='2026-10-20T00:00:00+08:00')))
assert error.value.status_code == 422
session.scalar.assert_not_awaited()
session.commit.assert_not_awaited()