Continuously refill news research slots and report active event waits
This commit is contained in:
1 parent
5f347f8ab4
commit
5b733cb1f2
15 files changed
+602
-657
No files matched your search
@@ -19,10 +19,8 @@ from alphapulse.news_research.models import (
|
||||
Membership,
|
||||
Version,
|
||||
Reference,
|
||||
BatchEvent,
|
||||
Batch,
|
||||
)
|
||||
from alphapulse.news_research import service, batches, scheduling, vocabulary as vocab
|
||||
from alphapulse.news_research import service, scheduling, vocabulary as vocab, coverage as reports
|
||||
from alphapulse.news_research.models import VocabularyVersion
|
||||
from alphapulse.news_research.contracts import (
|
||||
INDEX_ASSIGN_SKILL,
|
||||
@@ -120,7 +118,7 @@ async def settings(session: DB):
|
||||
{"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)}
|
||||
"batch": None}
|
||||
)
|
||||
|
||||
|
||||
@@ -131,7 +129,7 @@ async def coverage(session: DB, start_at: AwareDatetime | None = None):
|
||||
if start > datetime.now(UTC):
|
||||
raise HTTPException(422, "回补起始时间不能晚于当前时间")
|
||||
effective = min(start, row.realtime_from) if row.realtime_from else start
|
||||
report = await batches.coverage(session, effective)
|
||||
report = await reports.coverage(session, effective)
|
||||
return success({**report, 'backfill_start_at': start, 'realtime_from': row.realtime_from})
|
||||
|
||||
|
||||
@@ -268,11 +266,9 @@ 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)
|
||||
report = await reports.coverage(session, 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()
|
||||
@@ -497,12 +493,16 @@ async def event(identifier: int, session: DB):
|
||||
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):
|
||||
if any(v.state == 'processing' for v in versions):
|
||||
wait_reason = '等待上一轮研究完成后处理事件新变化'
|
||||
elif 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 await session.scalar(select(Event.id).where(Event.id == row.id, scheduling.manual_review())):
|
||||
wait_reason = '当前事件版本需手动重试,不会自动重复调用'
|
||||
elif row.due_at > datetime.now(UTC):
|
||||
wait_reason = '正在汇集事件变化,聚合窗口结束后研究'
|
||||
latest = next((v for v in versions if v.state == "done"), None)
|
||||
@@ -544,9 +544,8 @@ async def retry_event(identifier: int, session: DB):
|
||||
raise HTTPException(409, "事件正在处理")
|
||||
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))
|
||||
deferred = await session.scalar(select(Event.id).where(
|
||||
Event.id == row.id, scheduling.manual_review()))
|
||||
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)
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
"""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_, 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):
|
||||
"""Count each original once, including unfinished research after assignment."""
|
||||
cutoff = cutoff or datetime.now(UTC)
|
||||
research = select(
|
||||
Membership.package_id,
|
||||
func.bool_or(Event.state == 'failed').label('failed'),
|
||||
func.bool_or(Event.state == 'processing').label('processing'),
|
||||
func.bool_or(Event.state.not_in(['done', 'retired'])).label('unfinished'),
|
||||
).join(Event, Event.id == Membership.event_id).group_by(Membership.package_id).subquery()
|
||||
state = case(
|
||||
(Package.id.is_(None), 'missing'),
|
||||
(Package.content_hash != NewsItem.content_hash, 'stale'),
|
||||
(Package.state.in_(['index_failed', 'assign_failed']), 'failed'),
|
||||
(Package.state.in_(['index_processing', 'assign_processing']), 'processing'),
|
||||
(Package.state != 'done', 'pending'),
|
||||
(research.c.failed, 'failed'),
|
||||
(research.c.processing, 'processing'),
|
||||
(or_(research.c.package_id.is_(None), research.c.unfinished), 'pending'),
|
||||
else_='completed',
|
||||
)
|
||||
in_range = and_(NewsItem.published_at >= start_at, NewsItem.published_at <= cutoff)
|
||||
rows = (await session.execute(
|
||||
select(in_range, state, func.count()).select_from(NewsItem)
|
||||
.outerjoin(Package, Package.news_id == NewsItem.id)
|
||||
.outerjoin(research, research.c.package_id == Package.id)
|
||||
.where(or_(in_range, Package.id.is_not(None)))
|
||||
.group_by(in_range, state)
|
||||
)).all()
|
||||
counts = {status: count for selected, status, count in rows if selected}
|
||||
outside_counts = {status: count for selected, status, count in rows if not selected}
|
||||
outside = {name: outside_counts.get(name, 0) for name in
|
||||
('stale', 'pending', 'processing', 'failed')}
|
||||
result = {name: counts.get(name, 0) for name in
|
||||
('missing', 'stale', 'pending', 'processing', 'failed', 'completed')}
|
||||
return {'start_at': start_at, 'checked_at': cutoff, 'total': sum(result.values()),
|
||||
'unprocessed': sum(result.values()) - result['completed'], **result,
|
||||
'outside': {'unprocessed': sum(outside.values()), **outside}}
|
||||
|
||||
|
||||
async def freeze(session, settings):
|
||||
cutoff = datetime.now(UTC)
|
||||
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,
|
||||
BatchPackage.state == 'failed'))
|
||||
packages = list(await session.scalars(select(Package).where(
|
||||
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))))
|
||||
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)
|
||||
session.add(batch)
|
||||
await session.flush()
|
||||
session.add_all(BatchPackage(batch_id=batch.id, package_id=p.id, content_hash=p.content_hash,
|
||||
state='failed' if p.state.endswith('_failed') else 'pending') for p in packages)
|
||||
event_ids = {e.id for e in events}
|
||||
if packages:
|
||||
event_ids.update(await session.scalars(select(Membership.event_id).where(
|
||||
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
|
||||
|
||||
|
||||
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(
|
||||
Package, Package.id == BatchPackage.package_id).join(NewsItem, NewsItem.id == Package.news_id)
|
||||
.where(BatchPackage.batch_id == batch.id).order_by(NewsItem.published_at, NewsItem.id)))
|
||||
result = []
|
||||
assigning = any(stage == 'assign' for stage, _ in active)
|
||||
for item, package, news in rows:
|
||||
if item.state != 'pending':
|
||||
continue
|
||||
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'
|
||||
elif package.state.endswith('_failed'):
|
||||
item.state = 'failed'
|
||||
elif package.state in ('index_pending', 'assign_pending'):
|
||||
stage = 'index' if package.state == 'index_pending' else 'assign'
|
||||
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'
|
||||
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.
|
||||
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 == 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(
|
||||
BatchPackage.batch_id == batch.id, Attempt.stage == 'assign', Attempt.state == 'done').group_by(Attempt.package_id)
|
||||
urgent = {int(event_id) for attempt in await session.scalars(select(Attempt).where(Attempt.id.in_(latest)))
|
||||
for event_id, target in attempt.rules.get('membership_targets', {}).items() if target.get('urgent')}
|
||||
for item, event in await session.execute(select(BatchEvent, Event).join(Event).where(BatchEvent.batch_id == batch.id)):
|
||||
if item.event_id in urgent:
|
||||
item.state = 'urgent_pending'
|
||||
item.revision = event.revision
|
||||
batch.phase = 'researching'
|
||||
return []
|
||||
result = []
|
||||
rows = list(await session.execute(select(BatchEvent, Event).join(Event).where(
|
||||
BatchEvent.batch_id == batch.id).order_by((BatchEvent.state == 'urgent_pending').desc(), Event.due_at, Event.id)))
|
||||
for item, event in rows:
|
||||
if item.state not in ('pending', 'urgent_pending', 'processing') or ('event', event.id) in active:
|
||||
continue
|
||||
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'
|
||||
elif event.state == 'failed':
|
||||
item.state = 'failed'
|
||||
elif item.state == 'processing':
|
||||
item.state = 'deferred' # A completed call was superseded; do not spend again in this batch.
|
||||
else:
|
||||
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 = 'blocked'
|
||||
elif len(result) < slots:
|
||||
item.state = 'processing'
|
||||
result.append(('event', event.id))
|
||||
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)))
|
||||
batch.phase = 'partial' if bad else 'done'
|
||||
batch.finished_at = datetime.now(UTC)
|
||||
return result
|
||||
|
||||
|
||||
async def progress(session):
|
||||
batch = await session.scalar(select(Batch).order_by(Batch.id.desc()).limit(1))
|
||||
if batch is None:
|
||||
return None
|
||||
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', 'range_paused', 'blocked', 'realtime')}})
|
||||
return result
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Coverage reporting over durable news and research states."""
|
||||
from datetime import UTC, datetime
|
||||
from sqlalchemy import select, or_, and_, func, case
|
||||
from alphapulse.models import NewsItem
|
||||
from alphapulse.news_research.models import Package, Event, Membership
|
||||
|
||||
|
||||
async def coverage(session, start_at, cutoff=None):
|
||||
"""Count each original once, including unfinished research after assignment."""
|
||||
cutoff = cutoff or datetime.now(UTC)
|
||||
research = select(
|
||||
Membership.package_id,
|
||||
func.bool_or(Event.state == 'failed').label('failed'),
|
||||
func.bool_or(Event.state == 'processing').label('processing'),
|
||||
func.bool_or(Event.state.not_in(['done', 'retired'])).label('unfinished'),
|
||||
).join(Event, Event.id == Membership.event_id).group_by(Membership.package_id).subquery()
|
||||
state = case(
|
||||
(Package.id.is_(None), 'missing'),
|
||||
(Package.content_hash != NewsItem.content_hash, 'stale'),
|
||||
(Package.state.in_(['index_failed', 'assign_failed']), 'failed'),
|
||||
(Package.state.in_(['index_processing', 'assign_processing']), 'processing'),
|
||||
(Package.state != 'done', 'pending'),
|
||||
(research.c.failed, 'failed'),
|
||||
(research.c.processing, 'processing'),
|
||||
(or_(research.c.package_id.is_(None), research.c.unfinished), 'pending'),
|
||||
else_='completed',
|
||||
)
|
||||
in_range = and_(NewsItem.published_at >= start_at, NewsItem.published_at <= cutoff)
|
||||
rows = (await session.execute(
|
||||
select(in_range, state, func.count()).select_from(NewsItem)
|
||||
.outerjoin(Package, Package.news_id == NewsItem.id)
|
||||
.outerjoin(research, research.c.package_id == Package.id)
|
||||
.where(or_(in_range, Package.id.is_not(None)))
|
||||
.group_by(in_range, state)
|
||||
)).all()
|
||||
counts = {status: count for selected, status, count in rows if selected}
|
||||
outside_counts = {status: count for selected, status, count in rows if not selected}
|
||||
outside = {name: outside_counts.get(name, 0) for name in
|
||||
('stale', 'pending', 'processing', 'failed')}
|
||||
result = {name: counts.get(name, 0) for name in
|
||||
('missing', 'stale', 'pending', 'processing', 'failed', 'completed')}
|
||||
return {'start_at': start_at, 'checked_at': cutoff, 'total': sum(result.values()),
|
||||
'unprocessed': sum(result.values()) - result['completed'], **result,
|
||||
'outside': {'unprocessed': sum(outside.values()), **outside}}
|
||||
@@ -1,8 +1,8 @@
|
||||
"""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 sqlalchemy import select, exists, or_, and_, func, case, literal
|
||||
from alphapulse.models import NewsItem
|
||||
from alphapulse.news_research.models import Package, Event, Membership, Attempt, Version, ResearchSettings
|
||||
from alphapulse.news_research.models import Package, Event, Membership, Attempt, Version, ResearchSettings, BatchEvent
|
||||
|
||||
|
||||
def boundary(settings):
|
||||
@@ -47,57 +47,124 @@ async def mark_priority(session, event, news):
|
||||
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.
|
||||
def manual_review():
|
||||
"""Legacy deferred revisions and rejected current snapshots require explicit retry."""
|
||||
return or_(
|
||||
exists(select(BatchEvent.event_id).where(BatchEvent.event_id == Event.id,
|
||||
BatchEvent.revision == Event.revision, BatchEvent.state == 'deferred')),
|
||||
exists(select(Version.id).where(Version.event_id == Event.id,
|
||||
Version.revision == Event.revision, Version.state == 'superseded')),
|
||||
)
|
||||
|
||||
|
||||
def priority_package(settings):
|
||||
if settings.realtime_from is None:
|
||||
return literal(False)
|
||||
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 or_(NewsItem.published_at >= settings.realtime_from, dependency)
|
||||
|
||||
|
||||
def active_research():
|
||||
return exists(select(Version.id).where(Version.event_id == Event.id, Version.state == 'processing'))
|
||||
|
||||
|
||||
def runnable_event(settings):
|
||||
return and_(Event.state == 'pending', scoped_event(settings), ready_event(),
|
||||
Event.due_at <= datetime.now(UTC), ~manual_review(),
|
||||
~active_research())
|
||||
|
||||
|
||||
async def lane_actions(session, settings, active, slots, realtime):
|
||||
if slots <= 0 or (realtime and settings.realtime_from is None):
|
||||
return []
|
||||
busy_packages = {identifier for stage, identifier in active if stage != 'event'}
|
||||
busy_events = {identifier for stage, identifier in active if stage == 'event'}
|
||||
scope = priority_package(settings) if realtime else ~priority_package(settings)
|
||||
result = []
|
||||
# The chronological head includes missing and still-indexing originals. Failed
|
||||
# source revisions are skipped, but changed originals become eligible again.
|
||||
if not any(stage == 'assign' for stage, _ in active):
|
||||
head = (await session.execute(select(Package.id, Package.state, Package.content_hash,
|
||||
NewsItem.content_hash).select_from(NewsItem).outerjoin(Package, Package.news_id == NewsItem.id)
|
||||
.where(eligible(settings), scope, or_(Package.id.is_(None),
|
||||
Package.content_hash != NewsItem.content_hash,
|
||||
Package.state.in_(['index_pending', 'index_processing', 'assign_pending', 'assign_processing'])))
|
||||
.order_by(NewsItem.published_at, NewsItem.id).limit(1))).first()
|
||||
if head and head[1] == 'assign_pending' and head[2] == head[3] and head[0] not in busy_packages:
|
||||
if not await session.scalar(select(Attempt.id).where(
|
||||
Attempt.package_id == head[0], Attempt.state == 'processing').limit(1)):
|
||||
result.append(('assign', head[0]))
|
||||
events = await session.scalars(select(Event.id).where(
|
||||
runnable_event(settings), Event.realtime_pending == realtime,
|
||||
Event.id.not_in(busy_events)).order_by(Event.due_at, Event.id).limit(slots - len(result)))
|
||||
result.extend(('event', identifier) for identifier in events)
|
||||
remaining = slots - len(result)
|
||||
if remaining > 0:
|
||||
ids = list(await session.scalars(select(NewsItem.id).outerjoin(Package, Package.news_id == NewsItem.id)
|
||||
.where(eligible(settings), scope,
|
||||
or_(Package.id.is_(None), Package.content_hash != NewsItem.content_hash, Package.state == 'index_pending'),
|
||||
or_(Package.id.is_(None), 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(remaining)))
|
||||
if ids:
|
||||
from alphapulse.news_research.service import enroll
|
||||
await enroll(session, ids)
|
||||
result.extend(('index', identifier) for identifier in await session.scalars(
|
||||
select(Package.id).join(NewsItem).where(Package.news_id.in_(ids), Package.state == 'index_pending')
|
||||
.order_by(NewsItem.published_at, NewsItem.id)))
|
||||
return result
|
||||
|
||||
|
||||
async def realtime_actions(session, settings, active, slots):
|
||||
return await lane_actions(session, settings, active, slots, True)
|
||||
|
||||
|
||||
async def actions(session, settings, active, slots):
|
||||
result = await realtime_actions(session, settings, active, slots)
|
||||
result += await lane_actions(session, settings, active | set(result), slots - len(result), False)
|
||||
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}
|
||||
stages = dict((await session.execute(select(Attempt.stage, func.count()).where(
|
||||
Attempt.state == 'processing').group_by(Attempt.stage))).all())
|
||||
stages['research'] = await session.scalar(select(func.count()).select_from(Version).where(Version.state == 'processing'))
|
||||
stages = {stage: stages.get(stage, 0) for stage in ('index', 'assign', 'research')}
|
||||
running = sum(stages.values())
|
||||
capacity = settings.config.get('concurrency', 3)
|
||||
lane = case((priority_package(settings), 'realtime'), else_='backfill')
|
||||
state = case((Package.id.is_(None), 'missing'),
|
||||
(Package.content_hash != NewsItem.content_hash, 'stale'), else_=Package.state)
|
||||
groups = (await session.execute(select(lane, state, func.count()).select_from(NewsItem)
|
||||
.outerjoin(Package, Package.news_id == NewsItem.id).where(eligible(settings)).group_by(lane, state))).all()
|
||||
queues = {name: {status: count for selected, status, count in groups if selected == name}
|
||||
for name in ('realtime', 'backfill')}
|
||||
return {'running': running, 'draining': not settings.enabled and running > 0, **queues}
|
||||
event_state = case((Event.state == 'processing', 'processing'),
|
||||
(active_research(), 'waiting_previous'), (Event.state == 'failed', 'failed'),
|
||||
(manual_review(), 'manual_review'), (~ready_event(), 'blocked'),
|
||||
(Event.due_at > datetime.now(UTC), 'aggregating'), else_='ready')
|
||||
event_groups = (await session.execute(select(Event.realtime_pending, event_state, func.count()).where(
|
||||
Event.state.in_(['pending', 'processing', 'failed']), scoped_event(settings))
|
||||
.group_by(Event.realtime_pending, event_state))).all()
|
||||
events = {name: {status: count for realtime, status, count in event_groups if realtime == (name == 'realtime')}
|
||||
for name in ('realtime', 'backfill')}
|
||||
reasons = []
|
||||
if running < capacity:
|
||||
if not settings.enabled:
|
||||
reasons.append('draining' if running else 'paused')
|
||||
else:
|
||||
if any(q.get('assign_pending', 0) for q in queues.values()):
|
||||
reasons.append('assignment_serial' if stages['assign'] else 'preceding_index')
|
||||
for status, reason in [('waiting_previous', 'waiting_previous'), ('aggregating', 'aggregation'), ('blocked', 'dependencies'),
|
||||
('manual_review', 'manual_retry'), ('failed', 'manual_retry')]:
|
||||
if any(q.get(status, 0) for q in events.values()) and reason not in reasons:
|
||||
reasons.append(reason)
|
||||
if any(count for q in queues.values() for state, count in q.items() if state.endswith('_failed')) and 'manual_retry' not in reasons:
|
||||
reasons.append('manual_retry')
|
||||
if not reasons:
|
||||
reasons.append('no_work' if not any(
|
||||
q.get(s, 0) for q in queues.values() for s in ('missing', 'stale', 'index_pending', 'assign_pending')
|
||||
) and not any(q.get('ready', 0) for q in events.values()) else 'dispatching')
|
||||
return {'running': running, 'capacity': capacity, 'running_by_stage': stages,
|
||||
'draining': not settings.enabled and running > 0, 'events': events, 'idle_reasons': reasons, **queues}
|
||||
@@ -38,6 +38,7 @@ from alphapulse.news_research.contracts import (
|
||||
)
|
||||
|
||||
from alphapulse.news_research import vocabulary as vocab
|
||||
from alphapulse.news_research.scheduling import manual_review, active_research
|
||||
|
||||
publication_lock = asyncio.Lock()
|
||||
assignment_lock = asyncio.Lock()
|
||||
@@ -826,7 +827,9 @@ 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)):
|
||||
if await session.scalar(select(Event.id).where(Event.id == identifier, active_research())):
|
||||
return
|
||||
if await session.scalar(select(Event.id).where(Event.id == identifier, manual_review())):
|
||||
return
|
||||
members = list(
|
||||
await session.execute(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy import select, text, update
|
||||
from sqlalchemy import text, update
|
||||
from alphapulse.core.db import SessionLocal, engine
|
||||
from alphapulse.news_research.models import (
|
||||
ResearchSettings,
|
||||
@@ -10,10 +10,9 @@ from alphapulse.news_research.models import (
|
||||
Event,
|
||||
Attempt,
|
||||
Version,
|
||||
Batch, BatchEvent,
|
||||
)
|
||||
from alphapulse.news_research.service import process_package, process_event
|
||||
from alphapulse.news_research import batches, scheduling
|
||||
from alphapulse.news_research import scheduling
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,7 +38,6 @@ async def recover_interrupted():
|
||||
.where(Version.state == "processing")
|
||||
.values(state="failed", error="进程中断,调用结果不明,请手动重试")
|
||||
)
|
||||
await session.execute(update(BatchEvent).where(BatchEvent.state == 'processing').values(state='pending'))
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -64,20 +62,17 @@ async def run():
|
||||
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)
|
||||
if batch is not None:
|
||||
actions += await batches.actions(session, batch, active | set(actions), slots - len(actions), settings)
|
||||
actions = await scheduling.actions(session, settings, active, slots)
|
||||
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)
|
||||
if tasks:
|
||||
await asyncio.wait(tasks, timeout=1, return_when=asyncio.FIRST_COMPLETED)
|
||||
else:
|
||||
await asyncio.sleep(1)
|
||||
finally:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
@@ -948,3 +948,78 @@ def test_enable_cannot_apply_an_unsaved_backfill_time(trading):
|
||||
assert error.value.status_code == 422
|
||||
session.scalar.assert_not_awaited()
|
||||
session.commit.assert_not_awaited()
|
||||
|
||||
|
||||
def test_worker_refills_on_completion_without_waiting_for_other_tasks(trading, monkeypatch):
|
||||
"""A slow peer must not turn the shared pool back into a batch barrier."""
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
from alphapulse.news_research import worker
|
||||
|
||||
async def scenario():
|
||||
settings = N(enabled=True, config={'concurrency': 8}, control_revision=1)
|
||||
|
||||
class Session:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
async def get(self, *args):
|
||||
return settings
|
||||
|
||||
async def commit(self):
|
||||
pass
|
||||
|
||||
class Lock(Session):
|
||||
async def scalar(self, *args):
|
||||
return True
|
||||
|
||||
release = asyncio.Event()
|
||||
first_done = asyncio.Event()
|
||||
initial_full = asyncio.Event()
|
||||
refilled = asyncio.Event()
|
||||
entered = []
|
||||
live = 0
|
||||
peak = 0
|
||||
next_id = 0
|
||||
|
||||
async def actions(session, settings, active, slots):
|
||||
nonlocal next_id
|
||||
result = [('index', i) for i in range(next_id, next_id + slots)]
|
||||
next_id += slots
|
||||
return result
|
||||
|
||||
async def process(identifier, config):
|
||||
nonlocal live, peak
|
||||
live += 1
|
||||
peak = max(peak, live)
|
||||
entered.append(identifier)
|
||||
if len(entered) == 8:
|
||||
initial_full.set()
|
||||
if len(entered) > 8:
|
||||
refilled.set()
|
||||
try:
|
||||
await (first_done.wait() if identifier == 0 else release.wait())
|
||||
finally:
|
||||
live -= 1
|
||||
|
||||
monkeypatch.setattr(worker, 'engine', N(connect=Lock))
|
||||
monkeypatch.setattr(worker, 'SessionLocal', Session)
|
||||
monkeypatch.setattr(worker, 'recover_interrupted', AsyncMock())
|
||||
monkeypatch.setattr(worker.scheduling, 'actions', actions)
|
||||
monkeypatch.setattr(worker, 'process_package', process)
|
||||
task = asyncio.create_task(worker.run())
|
||||
try:
|
||||
await asyncio.wait_for(initial_full.wait(), 2)
|
||||
first_done.set()
|
||||
await asyncio.wait_for(refilled.wait(), 2)
|
||||
assert peak == 8 and live == 8 and not release.is_set()
|
||||
assert len(entered) == 9
|
||||
finally:
|
||||
task.cancel()
|
||||
release.set()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
asyncio.run(scenario())
|
||||
Reference in new issue
Block a user