Prioritize realtime news and make historical backfill resumable
This commit is contained in:
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')
|
||||
@@ -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": [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user