Files
ulthon_admin/extend/base/common/command/TimerBase.php
augushong f478ae0e4e feat(timer): runLoop 热加载并发实例与运行时配置参数
TimerBase 实现 shouldExecuteTask 手动触发原子消费、reloadRequestList 合并式扩缩容、runLoop 脏标记检测+定时兜底+有效域钳制+drain 优雅缩容+checkTriggerTtl 超时复位。config/timer.php 新增 trigger_ttl/force_reload_interval/drain_max_lifetime 三参数。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-25 21:57:25 +08:00

850 lines
37 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace base\common\command;
use app\common\console\Command;
use app\common\service\HostService;
use app\common\service\TimerService;
use GuzzleHttp\Client;
use GuzzleHttp\Promise\Utils;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
use think\facade\Cache;
use think\facade\Config;
use think\facade\Db;
use think\facade\Log;
use GuzzleHttp\Handler\CurlMultiHandler;
use GuzzleHttp\HandlerStack;
class TimerBase extends Command
{
protected $host;
protected $siteDomain;
protected $siteHost;
protected $requestList;
protected $callList;
// T6: 热加载状态(跨 runLoop 轮次保持)
protected $lastVersion; // 上次观测到的 timer_config_version脏标记比对基准
protected $lastForceReload; // 上次兜底 reload 的时间戳
protected function configure()
{
parent::configure();
// 指令配置
$this->setName('timer')
->addOption('temp', null, Option::VALUE_NONE)
->addOption('local', null, Option::VALUE_NONE)
->addOption('local-host', null, Option::VALUE_OPTIONAL, '本地域名', 'http://localhost')
->addOption('local-port', null, Option::VALUE_OPTIONAL, '本地端口', '8000')
->setDescription('内置秒级定时器');
}
protected function execute(Input $input, Output $output)
{
try {
// 指令输出
$output->writeln('start timer');
$site_domain = sysconfig('site', 'site_domain');
if (empty($site_domain)) {
$output->writeln('请前往后台设置站点域名site_domain配置项');
return;
}
$host = $site_domain;
if ($input->hasOption('local')) {
$host = $input->getOption('local-host') . ':' . $input->getOption('local-port');
}
$output->writeln('站点域名:' . $host);
$site_host = parse_url($host, PHP_URL_HOST);
// 同步配置到数据库
TimerService::syncConfigToDatabase();
// 设置配置的任务
$timer_service = new TimerService();
$request_list = $timer_service->generateAllRequestList();
$call_list = $timer_service->generateAllCallList();
// 内置的节点注册任务
$system_host_register =
[
'name' => 'system_host_register', // 定时任务的名称,不能重复
'type' => 'call', // 定时任务的类型默认只支持site你也可以重写定时器命令行以支持其他命令
'target' => [HostService::class, 'heartbeat'], // 要访问的地址如果不是以https开头那么以后台的系统配置中读取相关配置如果没有配置则不执行
'frequency' => 30, // 执行频率单位填写10则每10秒过后执行一次
];
$system_host_call_list = TimerService::generateTaskInstanceFromConfig($system_host_register);
$call_list = array_merge($call_list, $system_host_call_list);
// 运行时可变结构基础:每个 site 实例注入 state 字段active/draining
// reload 时按 task_name 粒度 diff 合并不全量重建D15/M10
foreach ($request_list as $state_key => $state_item) {
$request_list[$state_key]['state'] = 'active';
}
$this->host = $host;
$this->siteDomain = $site_domain;
$this->siteHost = $site_host;
$this->requestList = $request_list;
$this->callList = $call_list;
// 运行定时器
$this->runLoop();
} catch (\Throwable $e) {
throw $e;
}
}
/**
* 基于 run_type 判断当前节点是否应执行该任务(仅对 site 类型生效).
* 在 Cache 节流之后、实际执行之前调用。
*
* @param array|\ArrayAccess $task_item 任务实例(含 name/type/frequency 等)
* @return bool true=应执行 false=跳过
*/
protected function shouldExecuteTask($task_item): bool
{
// (1) call 类型任务不受 run_type 调度层影响
if (($task_item['type'] ?? '') === 'call') {
return true;
}
$task_name = $task_item['name'] ?? '';
try {
$config = Db::name('system_timer_config')
->where('task_name', $task_name)
->find();
} catch (\Throwable $e) {
Log::warning('shouldExecuteTask: 查询 system_timer_config 失败 - ' . $e->getMessage());
return true;
}
// 无数据库记录时不阻塞(回退到默认行为)
if (empty($config)) {
return true;
}
// (2) 手动触发优先(穿透 status跨 run_type 通用,定向到本节点。
// 原子条件 UPDATE —— WHERE 含 trigger_node_id = current_node_idD19无 IS NULL 回退分支,
// 单标志位 manual_trigger + 单赢家 UPDATE 只能定向单节点)。
// affected_rows=1 表示本节点消费了这次触发,立即 return true
// affected_rows=0 表示本节点不是目标节点 → fall through 到步骤 (3)/(4) 走正常调度
// manual 类型会在步骤 (4) return false即仅目标节点执行
if (!empty($config['manual_trigger'])) {
$current_node_id = HostService::getNodeId();
try {
$affected = Db::name('system_timer_config')
->where('task_name', $task_name)
->where('manual_trigger', 1)
->where('trigger_node_id', $current_node_id)
->update([
'manual_trigger' => 0,
'trigger_node_id' => null,
'last_trigger_time' => null,
'update_time' => time(),
]);
} catch (\Throwable $e) {
Log::warning('shouldExecuteTask manual trigger consume exception: ' . $e->getMessage());
$affected = 0;
}
if ($affected === 1) {
Log::info('[timer] trigger task=' . $task_name . ' node=' . $current_node_id . ' consumed');
return true;
}
// affected=0本节点不是目标fall through不 return false
}
// (3) status=0 表示任务已禁用(只拦截自动调度,手动已在步骤 (2) 穿透)
if (isset($config['status']) && (int) $config['status'] === 0) {
return false;
}
$run_type = $config['run_type'] ?? 'auto';
// (4) run_type 调度
switch ($run_type) {
case 'main':
// 仅主节点执行
$master_node = HostService::getMasterNode();
$node_id = HostService::getNodeId();
return ($master_node === $node_id);
case 'auto':
// 两阶段 DB 行锁竞争:竞争成功后释放锁,再执行任务
Db::startTrans();
try {
$row = Db::name('system_timer_config')
->where('task_name', $task_name)
->lock(true)
->find();
if ($row && !empty($row['last_execute_time']) && $row['last_execute_time'] > 0) {
if ((time() - (int) $row['last_execute_time']) < ($task_item['frequency'] ?? 0)) {
Db::commit();
return false; // 另一个节点刚执行过
}
}
Db::name('system_timer_config')
->where('task_name', $task_name)
->update([
'last_execute_node' => HostService::getNodeId(),
'last_execute_time' => time(),
'update_time' => time(),
]);
Db::commit();
return true;
} catch (\Throwable $e) {
Db::rollback();
Log::warning('shouldExecuteTask auto mode exception: ' . $e->getMessage());
return false;
}
case 'all':
// 所有节点都执行
return true;
case 'manual':
// manual 类型:永不自动执行,执行全靠步骤 (2) 的手动触发
return false;
default:
// 未知 run_type 不阻塞
return true;
}
}
public function runLoop()
{
$host = $this->host;
$site_host = $this->siteHost;
$output = $this->output;
$input = $this->input;
// D20: 不再用局部副本($request_list = $this->requestList 是 COW 拷贝),
// 直接遍历 $this->requestList 属性。PHP foreach 进入时拷贝数组值,
// while 每轮重新 foreach → reload 修改属性后下一轮可见mid-iteration append 安全。
$handler = new CurlMultiHandler([
'select_timeout' => Config::get('timer.select_timeout', 0.001),
'max_handles' => Config::get('timer.max_handles', 100),
]);
$stack = HandlerStack::create($handler);
$client = new Client([
'handler' => $stack,
'base_uri' => $host,
'timeout' => Config::get('timer.timeout', 86400),
'connect_timeout' => Config::get('timer.connect_timeout', 30),
'headers' => [
'Host' => $site_host,
'Accept' => 'application/json,text/plain',
],
'verify' => false,
]);
$pending = [];
// T6: 热加载状态初始化(进程启动时快照当前 version避免首轮误触发 reload
if (!isset($this->lastVersion)) {
$this->lastVersion = Cache::get('timer_config_version', 0); // 故意不打 tagm1防 Cache::tag('system_timer')->clear() 连带丢)
$this->lastForceReload = time();
}
while (true) {
try {
// T6: 热加载触发(脏标记检测 + 定时兜底)
// 脏标记timer_config_version 变化T4 trigger/编辑 commit 时 +1
// 兜底:每 force_reload_interval 秒强制 reload防 cache 丢失导致脏标记漏检)
$current_version = Cache::get('timer_config_version', 0); // 故意不打 tagm1
$force_interval = (int) Config::get('timer.force_reload_interval', 15);
if ($current_version !== $this->lastVersion || (time() - $this->lastForceReload) >= $force_interval) {
$effective_map = $this->computeEffectiveConcurrency(); // 钳制有效域
$this->reloadRequestList($effective_map); // T5 合并方法(不全量重建)
$this->checkTriggerTtl(); // TTL 复位检查
$this->lastVersion = $current_version;
$this->lastForceReload = time();
}
$has_new_task = false;
// T6/D22: 批量预取 task configsmanual 预检用)
// shouldExecuteTask 内部自查询不变T3 区域不改),此处仅服务 runLoop 层 manual 预检
$task_configs_batch = [];
if (!empty($this->requestList)) {
try {
$batch_rows = Db::name('system_timer_config')->select();
foreach ($batch_rows as $r) {
$task_configs_batch[$r['task_name']] = $r;
}
} catch (\Throwable $e) {
// 降级manual 预检失效,走正常 throttle不阻塞主循环
}
}
// --- site 任务:非阻塞发火 ---
// D20: 直接遍历 $this->requestList 属性非局部副本reload 后下一轮可见
foreach ($this->requestList as $arr_key => $request_item) {
$name = $request_item['name'];
$key = $name . '_' . $request_item['concurrency_id'];
$state = $request_item['state'] ?? 'active';
// draining 实例不发新请求pending 无此 key 时移除drain 完成)
// 保留在结构中直到在飞请求结束,保证 drain 连续性
// T7: 超过 drain_max_lifetime 强制移除(防永不 drain如任务卡死/异常)
if ($state === 'draining') {
$drain_max = (int) Config::get('timer.drain_max_lifetime', Config::get('timer.timeout', 86400));
$drain_started = $request_item['drain_started_at'] ?? 0;
$timed_out = ($drain_started > 0 && (time() - $drain_started) > $drain_max);
if (!isset($pending[$key]) || $timed_out) {
// drain 完成pending 空)或超时(防 86400s 永不 drain
unset($this->requestList[$arr_key]);
Log::info('[timer] drain-done task=' . $name . ' id=' . $request_item['concurrency_id'] . ($timed_out ? ' (timeout)' : ''));
}
continue;
}
// 已在飞 -> 跳过
if (isset($pending[$key])) {
continue;
}
$cache_key = 'timer_request_' . $name . '_' . $request_item['concurrency_id'];
$cache_tag = 'system_timer';
// T6/D22: manual 预检(在 cache throttle 检查之前)
// manual_trigger=1 且定向本节点 → bypass 频率节流检查,立即可达 shouldExecuteTask
// 关键:仍写节流 cache防 all/main 无 DB 频率兜底的任务跨 tick 双发)
$task_cfg = $task_configs_batch[$name] ?? null;
$bypass_throttle = !empty($task_cfg['manual_trigger'])
&& !empty($task_cfg['trigger_node_id'])
&& $task_cfg['trigger_node_id'] === HostService::getNodeId();
if (!$bypass_throttle) {
$last_exec_time = Cache::get($cache_key, 0);
if ($last_exec_time >= time() - $request_item['frequency']) {
continue;
}
}
// 无论 bypass 与否都写节流 cacheD22仅 bypass 节流检查,不 bypass 节流写入)
Cache::tag($cache_tag)->set($cache_key, time());
// run_type 调度检查Cache 节流之后、实际执行之前)
if (!$this->shouldExecuteTask($request_item)) {
continue;
}
$output->writeln(date('Y-m-d H:i:s') . ': build site request async: ' . $request_item['target']);
$promise = $client->getAsync($request_item['target']);
$pending[$key] = $promise;
$has_new_task = true;
$promise->then(
function ($response) use ($key, &$pending, $output) {
unset($pending[$key]);
$output->writeln(date('Y-m-d H:i:s') . ': ' . $key . ' finished');
},
function ($reason) use ($key, &$pending, $output) {
unset($pending[$key]);
$output->writeln(date('Y-m-d H:i:s') . ': ' . $key . ' error: ' . $reason->getMessage());
}
);
}
// --- 非阻塞推进 curl ---
$handler->tick();
Utils::queue()->run();
// --- 空闲提示 ---
if (empty($pending) && !$has_new_task) {
if (!$input->hasOption('quiet')) {
$output->writeln(date('Y-m-d H:i:s') . ' no request');
}
}
} catch (\Throwable $th) {
$output->writeln('error:' . $th->getMessage());
Log::error($th->getMessage());
}
// 运行call任务
$call_list = $this->callList;
foreach ($call_list as $call_item) {
$name = $call_item['name'];
$cache_key = 'timer_call_' . $name . '_' . $call_item['concurrency_id'];
$cache_tag = 'call_timer';
$last_exec_time = Cache::get($cache_key, 0);
if ($last_exec_time >= time() - $call_item['frequency']) {
continue;
}
Cache::tag($cache_tag)->set($cache_key, time());
try {
call_user_func($call_item['target']);
$output->writeln(date('Y-m-d H:i:s') . ': call function ' . $name . ' finished');
} catch (\Throwable $th) {
$output->writeln(date('Y-m-d H:i:s') . ': call function ' . $name . ' error: ' . $th->getMessage());
Log::error('timer call [' . $name . '] error: ' . $th->getMessage());
}
}
if ($input->hasOption('temp')) {
if (!empty($pending)) {
try {
Utils::unwrap($pending);
} catch (\Throwable $th) {
$output->writeln('error:' . $th->getMessage());
Log::error($th->getMessage());
}
}
break;
}
// --- sleep 策略 ---
if (empty($pending)) {
usleep(200000);
} else {
usleep(50000);
}
}
}
/**
* 合并式 reload按 task_name 粒度 diff 当前 requestList 与目标 concurrency
* 扩容 appendconcurrency_id 从 max+1 续编D3缩容把高 id 置 state=draining保留在结构中
*
* 不全量重建D15/M10保留其他 task 的 draining 实例,不影响其 drain 连续性。
* drain 完成的移除由 runLoop 遍历时处理pending 空 + state=draining → unset
*
* 崩溃恢复契约M4drain 状态纯内存,进程重启后以 DB concurrency 为准全重建
* generateAllRequestList 产出 0..N-1 完整分片),放弃 drain 连续性——
* 重启前正在 draining 的实例不会恢复 draining直接按新 DB 值重建为 active。
*
* 注意:本方法只提供数据结构 + 合并逻辑,**不实现 reload 触发**
* (脏标记检测/定时兜底/有效域钳制 = T6。T6 在 runLoop 检测到变化时调用本方法,
* 可传入已钳制的 effective_concurrency_map 覆盖默认的 DB ?? code 解析。
*
* @param array|null $effective_concurrency_map [task_name => effective_int]
* 调用方T6钳制后的有效并发映射
* null 时本方法内部读 DB concurrency ?? 代码默认T5 简化版)。
* 未出现的 task_name 保持不变(保守)。
*/
protected function reloadRequestList(?array $effective_concurrency_map = null): void
{
// (1) 收集当前 requestList 中各 task 的实例(按 name 分组)
// 记录每个实例的数组 key用于修改 state / append 定位)与 state
$by_task = []; // [task_name => [concurrency_id => ['arr_key' => int, 'state' => string]]]
foreach ($this->requestList as $arr_key => $item) {
$name = $item['name'];
$cid = $item['concurrency_id'];
$state = $item['state'] ?? 'active';
$by_task[$name][$cid] = ['arr_key' => $arr_key, 'state' => $state];
}
if (empty($by_task)) {
return;
}
// (2) 确定每个 task 的目标 concurrency
// 优先用调用方传入的 effective mapT6 钳制后),否则内部读 DB ?? codeT5 简化)
$code_defaults = $this->collectCodeConcurrencyDefaults($by_task);
$target_map = $effective_concurrency_map ?? $this->resolveEffectiveConcurrencyFromDb($code_defaults);
// (3) 对每个已存在的 task 做 diff合并不重建
foreach ($by_task as $name => $instances) {
if (!isset($target_map[$name])) {
// 目标映射未覆盖该 task可能是新增任务归 T7 处理;这里保守不动)
continue;
}
$target = (int) $target_map[$name];
if ($target < 0) {
$target = 0;
}
// 分离 active / draining
$active_ids = [];
foreach ($instances as $cid => $meta) {
if ($meta['state'] === 'active') {
$active_ids[$cid] = $meta;
}
}
$active_count = count($active_ids);
if ($target > $active_count) {
// 扩容appendconcurrency_id 从 max(所有现有 id)+1 续编D3
// 含 draining 实例的 id 也参与 max 计算,保证 id 单调递增不重用
$all_ids = array_keys($instances);
$max_id = empty($all_ids) ? -1 : max($all_ids);
$need = $target - $active_count;
$code_concurrency_count = $code_defaults[$name] ?? $target;
for ($i = 0; $i < $need; $i++) {
$max_id++;
$new_item = $this->buildScaledInstance($name, $max_id, $target, $code_concurrency_count);
$this->requestList[] = $new_item;
}
$msg = '[timer] reload task=' . $name . ' concurrency ' . $active_count . '->' . $target . ' effective=' . $target;
Log::info($msg);
$this->output->writeln($msg);
} elseif ($target < $active_count) {
// 缩容:把高 id 置 drainingconcurrency_id 降序,直到 active_count == target
// D3缩容先 drain 高 idconcurrency_id=0 永远第一分片(最后才 drain
$sorted_active = $active_ids;
krsort($sorted_active); // 降序
$to_drain_count = $active_count - $target;
$drained = 0;
foreach ($sorted_active as $cid => $meta) {
if ($drained >= $to_drain_count) {
break;
}
$arr_key = $meta['arr_key'];
// T7: 置 draining 时清理节流 cache防下次扩容回原值被旧窗口误判跳过
// 注意Cache::tag()->delete() 不存在TagSet 无 delete 方法),用 Cache::delete() 直接删
$cache_key = 'timer_request_' . $name . '_' . $cid;
Cache::delete($cache_key);
$this->requestList[$arr_key]['state'] = 'draining';
// T7: 记录 drain 开始时间runLoop 据此判断 drain 超时强制移除
$this->requestList[$arr_key]['drain_started_at'] = time();
$msg = '[timer] drain task=' . $name . ' id=' . $cid;
Log::info($msg);
$this->output->writeln($msg);
$drained++;
}
}
// target == active_count无变化
}
}
/**
* 从当前 requestList 实例中提取各 task 的代码默认 concurrency
* (实例的 concurrency_count 字段,由 generateTaskInstanceFromConfig 注入)。
*
* @param array $by_task reloadRequestList 内部分组结构
* @return array [task_name => code_default_concurrency_int]
*/
protected function collectCodeConcurrencyDefaults(array $by_task): array
{
$defaults = [];
foreach ($by_task as $name => $instances) {
// 取任意一个实例的 concurrency_count 作为代码默认(同 task 所有实例一致)
foreach ($instances as $meta) {
if (isset($this->requestList[$meta['arr_key']]['concurrency_count'])) {
$defaults[$name] = (int) $this->requestList[$meta['arr_key']]['concurrency_count'];
}
break;
}
if (!isset($defaults[$name])) {
$defaults[$name] = 1;
}
}
return $defaults;
}
/**
* 读 DB system_timer_config 的 concurrency 字段NULL 用代码默认T5 简化版)。
* T6 会替换为钳制后的 effective mapmax/min/Σ 预算),本方法仅作 fallback。
*
* @param array $code_defaults [task_name => code_default_int]
* @return array [task_name => effective_int]
*/
protected function resolveEffectiveConcurrencyFromDb(array $code_defaults): array
{
if (empty($code_defaults)) {
return [];
}
try {
$rows = Db::name('system_timer_config')
->where('task_name', 'in', array_keys($code_defaults))
->column('concurrency', 'task_name');
} catch (\Throwable $e) {
Log::warning('reloadRequestList: read system_timer_config failed - ' . $e->getMessage() . ' (fallback to code defaults)');
return $code_defaults;
}
$map = [];
foreach ($code_defaults as $name => $code_val) {
$db_val = $rows[$name] ?? null;
// NULL → 代码默认字段注释NULL=继承代码默认)
$map[$name] = ($db_val === null) ? $code_val : (int) $db_val;
}
return $map;
}
/**
* 基于 requestList 中同 task 的现有实例构造一个新的分片实例(用于扩容 append
* 克隆模板实例,重写 target 的 query 参数concurrency_id/concurrency_count/host_id
* 保证 target URL 与 generateTaskInstanceFromConfig 的产出同构。
*
* @param string $task_name 任务名
* @param int $new_cid 新 concurrency_idmax+1 续编)
* @param int $new_count 新 concurrency_count目标 concurrency
* @param int $code_concurrency_count 代码默认 concurrencyfallback
* @return \app\common\model\VirtualModel 带新分片参数的实例state=active
*/
protected function buildScaledInstance(string $task_name, int $new_cid, int $new_count, int $code_concurrency_count = 1)
{
// 从 requestList 找一个同 task 的现有实例作为模板(取第一个)
$template = null;
foreach ($this->requestList as $item) {
if ($item['name'] === $task_name) {
$template = $item;
break;
}
}
if ($template === null) {
throw new \RuntimeException('TimerBase::buildScaledInstance: cannot find template instance for task: ' . $task_name);
}
// clone 后修改think\Model 浅拷贝data 数组 COW修改不影响原对象
$new_item = clone $template;
// 重写 target 的 query 参数concurrency_id / concurrency_count / host_id
$target = $new_item['target'];
$target_info = parse_url($target);
$query_params = [];
if (isset($target_info['query'])) {
parse_str($target_info['query'], $query_params);
}
$query_params['concurrency_id'] = $new_cid;
$query_params['concurrency_count'] = $new_count;
$query_params['host_id'] = HostService::getNodeId();
$query_params['task_name'] = $task_name;
$target_info['query'] = http_build_query($query_params);
$new_item['target'] = unparse_url($target_info);
$new_item['concurrency_id'] = $new_cid;
$new_item['concurrency_count'] = $new_count;
$new_item['state'] = 'active';
return $new_item;
}
/**
* T6: 计算各 task 钳制后的有效并发数reload 前).
*
* effective = max(1, min(DB concurrency ?? 代码默认, 控制器 cap, 剩余 max_handles 预算))
*
* - DB concurrency = NULL → 继承代码默认(字段语义)
* - DB concurrency <= 0 → 钳为 1防静默吞任务M5
* - 控制器 cap本期用"代码默认 concurrency_count"近似(实例携带),精确控制器 $concurrency 反射留 TODOT10+
* - Σ 预算max_handles - 其他 task 已占 active 数(本 task 当前 active 先释放再重分配)
*
* 超限effective != raw时 Log::info + writeln 告警D21 clamp 证据)。
*
* @return array [task_name => effective_int] 传给 reloadRequestList 的 effective_map
*/
protected function computeEffectiveConcurrency(): array
{
// (1) 收集当前 requestList 各 task 分组(复用 T5 的分组结构)
$by_task = [];
foreach ($this->requestList as $arr_key => $item) {
$name = $item['name'];
$state = $item['state'] ?? 'active';
$by_task[$name][$item['concurrency_id']] = ['arr_key' => $arr_key, 'state' => $state];
}
if (empty($by_task)) {
return [];
}
// (2) 代码默认 concurrency实例的 concurrency_countT5 collectCodeConcurrencyDefaults
$code_defaults = $this->collectCodeConcurrencyDefaults($by_task);
// (3) 读 DB concurrencyNULL → 代码默认)
try {
$rows = Db::name('system_timer_config')
->where('task_name', 'in', array_keys($code_defaults))
->column('concurrency', 'task_name');
} catch (\Throwable $e) {
Log::warning('computeEffectiveConcurrency: read system_timer_config failed - ' . $e->getMessage() . ' (fallback to code defaults)');
$rows = [];
}
// (4) Σ 预算max_handles 减去其他 task 已占 active 数
$max_handles = (int) Config::get('timer.max_handles', 100);
$active_total = 0;
foreach ($this->requestList as $item) {
if (($item['state'] ?? 'active') === 'active') {
$active_total++;
}
}
// (5) 逐 task 钳制
$effective_map = [];
foreach ($code_defaults as $name => $code_val) {
$db_val = $rows[$name] ?? null;
// NULL → 代码默认;否则取 DB 值
$raw = ($db_val === null) ? (int) $code_val : (int) $db_val;
// 控制器 cap反射 site target 对应控制器类的 $concurrency 属性(含继承链)
// TODO: 当前仅解析 {module}/{dir.Controller}/{action} 格式,多级模块/自定义路由留 T10+ 增强
$cap = $this->resolveControllerCap($name, $by_task[$name] ?? []);
// 本 task 当前 active 数(预算计算时先释放本 task 占用,再按 effective 重分配)
$current_active = 0;
if (isset($by_task[$name])) {
foreach ($by_task[$name] as $meta) {
if ($meta['state'] === 'active') {
$current_active++;
}
}
}
$budget_after_release = $max_handles - ($active_total - $current_active);
$effective = max(1, min($raw, $cap, $budget_after_release));
if ($effective !== $raw) {
$db_display = $db_val === null ? 'null' : (string) $db_val;
$msg = '[timer] clamp task=' . $name . ' db=' . $db_display . ' effective=' . $effective;
Log::info($msg);
$this->output->writeln($msg);
}
$effective_map[$name] = $effective;
}
return $effective_map;
}
/**
* 反射 site target 对应控制器类的 $concurrency 属性作为 cap.
*
* 解析 target 路径 /{module}/{dir.Controller}/{action} → app\{module}\controller\{dir\Controller}
* 用 ReflectionClass::getDefaultProperties() 读取继承链上的 $concurrency 默认值(含 Base 层)。
* 反射结果缓存到 $controllerCapCache控制器类不变进程生命周期内稳定
*
* @param string $task_name 任务名(缓存 key
* @param array $instances reloadRequestList 分组结构(取 target 用)
* @return int 控制器 cap反射失败 fallback 1
*/
protected $controllerCapCache = [];
protected function resolveControllerCap(string $task_name, array $instances): int
{
if (isset($this->controllerCapCache[$task_name])) {
return $this->controllerCapCache[$task_name];
}
$cap = 1; // fallback保守
// 从分组实例取 target取第一个实例
$target = null;
foreach ($instances as $meta) {
$arr_key = $meta['arr_key'] ?? null;
if ($arr_key !== null && isset($this->requestList[$arr_key]['target'])) {
$target = $this->requestList[$arr_key]['target'];
break;
}
}
if ($target) {
try {
$path = parse_url($target, PHP_URL_PATH);
$path = trim($path, '/');
$parts = explode('/', $path);
if (count($parts) >= 2) {
// {module}/{dir.Controller}/{action} → app\{module}\controller\{dir\Controller}
// 点表示子目录timer.ClearLog → timer\ClearLog
$controller_segment = str_replace('.', '\\', $parts[1]);
$controller_class = 'app\\' . $parts[0] . '\\controller\\' . $controller_segment;
if (class_exists($controller_class)) {
$defaults = (new \ReflectionClass($controller_class))->getDefaultProperties();
if (isset($defaults['concurrency'])) {
$cap = (int) $defaults['concurrency'];
}
}
}
} catch (\Throwable $e) {
// 反射失败:保守 fallback cap=1控制器层 T8 cap 防御仍兜底)
Log::warning('resolveControllerCap: reflect failed for task=' . $task_name . ' - ' . $e->getMessage());
}
}
if ($cap < 1) {
$cap = 1;
}
$this->controllerCapCache[$task_name] = $cap;
return $cap;
}
/**
* T6: 手动触发 TTL 复位检查reload 兜底分支调用).
*
* manual_trigger=1 但 last_trigger_time 超过 trigger_ttl默认 300s未消费的任务
* 视为触发请求丢失(目标节点离线/异常),自动复位 manual_trigger=0/trigger_node_id=NULL/last_trigger_time=NULL。
*
* 防 manual_trigger 永久卡住D17
*/
protected function checkTriggerTtl(): void
{
$ttl = (int) Config::get('timer.trigger_ttl', 300);
try {
$rows = Db::name('system_timer_config')
->where('manual_trigger', 1)
->select();
} catch (\Throwable $e) {
Log::warning('checkTriggerTtl: read system_timer_config failed - ' . $e->getMessage());
return;
}
$now = time();
foreach ($rows as $r) {
$last_trigger = $r['last_trigger_time'] ?? null;
if (empty($last_trigger)) {
continue; // 无时间戳(异常数据),跳过等下次
}
if (($now - (int) $last_trigger) <= $ttl) {
continue; // 未超时
}
try {
Db::name('system_timer_config')
->where('id', $r['id'])
->where('manual_trigger', 1) // 防并发消费后误复位
->update([
'manual_trigger' => 0,
'trigger_node_id' => null,
'last_trigger_time' => null,
'update_time' => $now,
]);
$msg = '[timer] trigger-expire task=' . ($r['task_name'] ?? '') . ' node=' . ($r['trigger_node_id'] ?? '') . ' reset';
Log::info($msg);
$this->output->writeln($msg);
} catch (\Throwable $e) {
Log::warning('checkTriggerTtl: update failed for task=' . ($r['task_name'] ?? '') . ' - ' . $e->getMessage());
}
}
}
}