Continuously refill news research slots and report active event waits
Development CI / check-and-build (push) Failing after 2m28s
Development CI / deploy-development (push) Skipped
GitHub Git mirror / mirror (push) Successful in 6s

This commit is contained in:
eezd committed 2026-11-06 15:33:00 +08:00
1 parent 5f347f8ab4
commit 5b733cb1f2
15 files changed
+602 -657

No files matched your search

+11 -12
View File
@@ -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(
+7 -12
View File
@@ -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())