204 lines
6.5 KiB
Python
204 lines
6.5 KiB
Python
import asyncio
|
|
import logging
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
|
|
from yuxi.channel.cron.types import (
|
|
CronAgentExecutionPhase,
|
|
CronDeliveryReceipt,
|
|
CronDeliveryTarget,
|
|
CronJob,
|
|
CronRunDiagnostic,
|
|
DeliveryHandler,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_MAX_RETRIES = 3
|
|
DEFAULT_RETRY_BASE_MS = 500
|
|
DEFAULT_RETRY_MAX_MS = 10_000
|
|
DEFAULT_RETRY_BACKOFF = 2.0
|
|
|
|
|
|
@dataclass
|
|
class DeliveryConfig:
|
|
max_retries: int = DEFAULT_MAX_RETRIES
|
|
retry_base_ms: int = DEFAULT_RETRY_BASE_MS
|
|
retry_max_ms: int = DEFAULT_RETRY_MAX_MS
|
|
retry_backoff: float = DEFAULT_RETRY_BACKOFF
|
|
concurrency: int = 5
|
|
request_timeout_ms: int = 30_000
|
|
total_timeout_ms: int = 120_000
|
|
|
|
|
|
def resolve_delivery_targets(job: CronJob) -> list[CronDeliveryTarget]:
|
|
return [t for t in job.delivery if t.enabled and t.channel and t.target_id]
|
|
|
|
|
|
def format_delivery_content(
|
|
job: CronJob,
|
|
result: dict | None,
|
|
phase: CronAgentExecutionPhase | None,
|
|
error: str | None,
|
|
) -> str:
|
|
job_label = job.name or job.id
|
|
|
|
if error:
|
|
return f"⛔ Cron 任务执行失败\n\n任务:{job_label}\n阶段:{phase.value if phase else '未知'}\n错误:{error}"
|
|
|
|
if result is None:
|
|
return f"✅ Cron 任务执行完成\n\n任务:{job_label}\n阶段:{phase.value if phase else '未知'}\n结果:无返回值"
|
|
|
|
result_text = _format_result_value(result)
|
|
return f"✅ Cron 任务执行完成\n\n任务:{job_label}\n阶段:{phase.value if phase else '未知'}\n结果:\n{result_text}"
|
|
|
|
|
|
def _format_result_value(result: dict) -> str:
|
|
if not result:
|
|
return "(空)"
|
|
lines = []
|
|
for k, v in result.items():
|
|
if isinstance(v, (list, dict)):
|
|
v = str(v)[:500]
|
|
elif isinstance(v, str) and len(v) > 500:
|
|
v = v[:500] + "..."
|
|
lines.append(f" {k}: {v}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _backoff_delay(attempt: int, config: DeliveryConfig) -> int:
|
|
base = config.retry_base_ms * (config.retry_backoff ** (attempt - 1))
|
|
return min(int(base), config.retry_max_ms)
|
|
|
|
|
|
async def deliver_to_target(
|
|
handler: DeliveryHandler,
|
|
target: CronDeliveryTarget,
|
|
content: str,
|
|
config: DeliveryConfig,
|
|
) -> CronDeliveryReceipt:
|
|
receipt = CronDeliveryReceipt(target=target)
|
|
started_mono = time.monotonic()
|
|
receipt.sent_at_ms = int(time.time() * 1000)
|
|
total_started_mono = time.monotonic()
|
|
|
|
for attempt in range(1, config.max_retries + 1):
|
|
try:
|
|
success = await asyncio.wait_for(
|
|
handler(target.channel, target.target_id, content),
|
|
timeout=config.request_timeout_ms / 1000.0,
|
|
)
|
|
if success:
|
|
receipt.success = True
|
|
receipt.retries = attempt - 1
|
|
receipt.sent_at_ms = int(time.time() * 1000)
|
|
receipt.duration_ms = int((time.monotonic() - started_mono) * 1000)
|
|
return receipt
|
|
receipt.error = "handler returned False"
|
|
except asyncio.TimeoutError:
|
|
receipt.error = f"delivery timeout ({config.request_timeout_ms}ms)"
|
|
logger.warning(
|
|
"Cron delivery attempt %d/%d to %s:%s timed out after %dms",
|
|
attempt,
|
|
config.max_retries,
|
|
target.channel,
|
|
target.target_id,
|
|
config.request_timeout_ms,
|
|
)
|
|
except Exception as e:
|
|
receipt.error = str(e)
|
|
logger.warning(
|
|
"Cron delivery attempt %d/%d to %s:%s failed: %s",
|
|
attempt,
|
|
config.max_retries,
|
|
target.channel,
|
|
target.target_id,
|
|
e,
|
|
)
|
|
|
|
total_elapsed_ms = int((time.monotonic() - total_started_mono) * 1000)
|
|
if total_elapsed_ms >= config.total_timeout_ms:
|
|
receipt.error = f"total delivery timeout ({config.total_timeout_ms}ms)"
|
|
logger.warning(
|
|
"Cron delivery to %s:%s exceeded total timeout %dms",
|
|
target.channel,
|
|
target.target_id,
|
|
config.total_timeout_ms,
|
|
)
|
|
break
|
|
|
|
if attempt < config.max_retries:
|
|
delay = _backoff_delay(attempt, config)
|
|
await asyncio.sleep(delay / 1000.0)
|
|
|
|
receipt.success = False
|
|
receipt.retries = config.max_retries
|
|
receipt.duration_ms = int((time.monotonic() - started_mono) * 1000)
|
|
return receipt
|
|
|
|
|
|
async def batch_deliver(
|
|
handler: DeliveryHandler,
|
|
job: CronJob,
|
|
result: dict | None,
|
|
error: str | None,
|
|
diagnostic: CronRunDiagnostic | None,
|
|
config: DeliveryConfig | None = None,
|
|
) -> list[CronDeliveryReceipt]:
|
|
targets = resolve_delivery_targets(job)
|
|
if not targets:
|
|
return []
|
|
|
|
config = config or DeliveryConfig()
|
|
content = format_delivery_content(job, result, CronAgentExecutionPhase.DELIVERING, error)
|
|
|
|
semaphore = asyncio.Semaphore(config.concurrency)
|
|
|
|
async def _deliver_one(target: CronDeliveryTarget) -> CronDeliveryReceipt:
|
|
async with semaphore:
|
|
return await deliver_to_target(handler, target, content, config)
|
|
|
|
receipts = await asyncio.gather(*[_deliver_one(t) for t in targets], return_exceptions=True)
|
|
|
|
results: list[CronDeliveryReceipt] = []
|
|
for i, r in enumerate(receipts):
|
|
if isinstance(r, CronDeliveryReceipt):
|
|
results.append(r)
|
|
elif isinstance(r, Exception):
|
|
logger.exception("Cron delivery task crashed for target [%d]", i)
|
|
target = targets[i] if i < len(targets) else CronDeliveryTarget(channel="unknown", target_id="unknown")
|
|
results.append(
|
|
CronDeliveryReceipt(
|
|
target=target,
|
|
success=False,
|
|
error=str(r),
|
|
)
|
|
)
|
|
|
|
if diagnostic is not None:
|
|
diagnostic.delivery_receipts = results
|
|
|
|
succeeded = sum(1 for r in results if r.success)
|
|
failed = len(results) - succeeded
|
|
if failed > 0:
|
|
logger.warning(
|
|
"Cron batch delivery for '%s': %d/%d succeeded, %d failed",
|
|
job.id,
|
|
succeeded,
|
|
len(results),
|
|
failed,
|
|
)
|
|
|
|
return results
|
|
|
|
|
|
async def deliver_result(
|
|
handler: DeliveryHandler,
|
|
job: CronJob,
|
|
result: dict | None,
|
|
error: str | None,
|
|
diagnostic: CronRunDiagnostic | None,
|
|
config: DeliveryConfig | None = None,
|
|
) -> list[CronDeliveryReceipt]:
|
|
return await batch_deliver(handler, job, result, error, diagnostic, config)
|