TextScriptGenerateDispatchCommand.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. <?php
  2. namespace App\Console\Commands;
  3. use Illuminate\Console\Command;
  4. use Illuminate\Support\Facades\DB;
  5. /**
  6. * 剧本资产生成异步任务调度器
  7. *
  8. * 由 cron 每分钟触发一次。进入后以 5 秒为间隔轮询 pending 任务,
  9. * 发现任务立即拉起独立 worker 子进程(text:generate:run);
  10. * 若连续 50 秒没有任何任务则退出,避免长时间占用调度进程。
  11. */
  12. class TextScriptGenerateDispatchCommand extends Command
  13. {
  14. /**
  15. * The name and signature of the console command.
  16. *
  17. * @var string
  18. */
  19. protected $signature = 'text:generate:dispatch';
  20. /**
  21. * The console command description.
  22. *
  23. * @var string
  24. */
  25. protected $description = '剧本资产生成异步任务调度:每5秒查询pending任务并拉起worker';
  26. /**
  27. * Execute the console command.
  28. *
  29. * @return int
  30. */
  31. public function handle()
  32. {
  33. dLog('command')->info('开始执行剧本资产生成任务调度...');
  34. // 每次调度窗口执行一次历史 chunks 清理(任务完结后 chunks 仅供 SSE 推送,无长期保留价值)
  35. $cleaned = $this->cleanupExpiredChunks();
  36. if ($cleaned > 0) {
  37. dLog('command')->info("已清理过期剧本资产生成chunks: {$cleaned} 条");
  38. }
  39. $concurrency = (int)env('SCRIPT_TEXT_GENERATION_CONCURRENCY', 3);
  40. if ($concurrency < 1) {
  41. $concurrency = 1;
  42. }
  43. $start = time();
  44. $idleSince = null;
  45. $spawnedTotal = 0;
  46. while (true) {
  47. $now = time();
  48. // 孤儿任务恢复:processing 但超过5分钟且处理锁已空闲,说明 worker 异常退出,重置回 pending
  49. $this->recoverOrphanTasks();
  50. // 连续 50 秒没有任何任务则退出
  51. if ($idleSince !== null && $now - $idleSince >= 50) {
  52. dLog('command')->info('已连续50秒无待处理任务,退出本次调度');
  53. break;
  54. }
  55. // 整体安全上限(防止异常情况下长时间占用)
  56. if ($now - $start > 75) {
  57. dLog('command')->info('达到调度窗口安全上限,退出本次调度');
  58. break;
  59. }
  60. // 并发上限 = 配置上限 - 已在运行的 processing 任务数 - 本窗口已拉起数
  61. $activeCount = (int)DB::table('mp_script_generate_tasks')
  62. ->where('status', 'processing')
  63. ->count();
  64. $available = $concurrency - $activeCount - $spawnedTotal;
  65. if ($available <= 0) {
  66. dLog('command')->info("当前活跃任务已达并发上限,等待worker完成...");
  67. sleep(5);
  68. continue;
  69. }
  70. $pendingIds = DB::table('mp_script_generate_tasks')
  71. ->where('status', 'pending')
  72. ->orderBy('id')
  73. ->pluck('id')
  74. ->all();
  75. if (empty($pendingIds)) {
  76. if ($idleSince === null) {
  77. $idleSince = $now;
  78. }
  79. if ($now - $idleSince >= 50) {
  80. break;
  81. }
  82. sleep(5);
  83. continue;
  84. }
  85. $idleSince = null;
  86. foreach ($pendingIds as $taskId) {
  87. if ($available <= 0) {
  88. break;
  89. }
  90. // 仅当该任务的处理锁空闲时才拉起,避免与手动/其它调度重复
  91. $lockName = TextScriptGenerateRunCommand::LOCK_PREFIX . $taskId;
  92. $isFree = DB::selectOne('SELECT IS_FREE_LOCK(?) AS is_free', [$lockName]);
  93. if (!$isFree || (int)$isFree->is_free !== 1) {
  94. dLog('command')->info("task_id: {$taskId} 正在被其他进程处理,跳过");
  95. continue;
  96. }
  97. try {
  98. $this->spawnWorker((int)$taskId);
  99. $spawnedTotal++;
  100. $available--;
  101. dLog('command')->info("已拉起剧本资产生成worker, task_id: {$taskId}");
  102. } catch (\Throwable $e) {
  103. dLog('command')->error("拉起剧本资产生成worker失败 task_id: {$taskId}: " . $e->getMessage());
  104. logDB('command', 'error', '剧本资产生成拉起失败', [
  105. 'task_id' => (int)$taskId,
  106. 'error' => $e->getMessage(),
  107. ]);
  108. }
  109. }
  110. sleep(5);
  111. }
  112. dLog('command')->info("剧本资产生成任务调度结束,本次拉起worker数: {$spawnedTotal}");
  113. return 0;
  114. }
  115. /**
  116. * 清理已完结任务的过期 chunks
  117. *
  118. * 保留期默认 30 天,可通过环境变量 SCRIPT_GENERATE_CHUNK_RETENTION_DAYS 调整。
  119. * 只清理 status 为 success/failed 且 completed_at 早于保留期的任务;
  120. * 按任务 id 升序游标分批删除,避免单次删除量过大造成长时间锁表。
  121. *
  122. * @return int 删除的 chunks 记录数
  123. */
  124. private function cleanupExpiredChunks(): int
  125. {
  126. $retentionDays = (int)env('SCRIPT_GENERATE_CHUNK_RETENTION_DAYS', 7);
  127. if ($retentionDays < 1) {
  128. $retentionDays = 7;
  129. }
  130. $cutoff = date('Y-m-d H:i:s', strtotime("-{$retentionDays} days"));
  131. $batchRows = (int)env('SCRIPT_GENERATE_CHUNK_DELETE_BATCH', 5000);
  132. if ($batchRows < 1000) {
  133. $batchRows = 5000;
  134. }
  135. $deletedTotal = 0;
  136. // 按行数小批循环删除,避免单次 DELETE 量过大造成长事务/长锁。
  137. // MySQL 单表 DELETE 支持 LIMIT;目标表是 chunks,子查询引用 tasks,不同表无同表限制。
  138. $deleteSql = 'DELETE FROM mp_script_generate_task_chunks '
  139. . 'WHERE task_id IN ('
  140. . 'SELECT id FROM mp_script_generate_tasks '
  141. . 'WHERE status IN (?, ?) AND completed_at IS NOT NULL AND completed_at < ?'
  142. . ') LIMIT ' . (int)$batchRows;
  143. $maxRounds = 2000; // 防御性上限,正常数据远用不到
  144. for ($round = 0; $round < $maxRounds; $round++) {
  145. $deleted = DB::affectingStatement($deleteSql, ['success', 'failed', $cutoff]);
  146. if ($deleted <= 0) {
  147. break;
  148. }
  149. $deletedTotal += $deleted;
  150. if ($deleted < $batchRows) {
  151. break;
  152. }
  153. }
  154. return $deletedTotal;
  155. }
  156. /**
  157. * 拉起一个后台 worker 子进程处理指定任务
  158. *
  159. * @param int $taskId
  160. * @return void
  161. */
  162. private function spawnWorker(int $taskId)
  163. {
  164. $command = [
  165. $this->phpBinary(),
  166. base_path('artisan'),
  167. 'text:generate:run',
  168. (string)$taskId,
  169. ];
  170. if (DIRECTORY_SEPARATOR === '/') {
  171. // Linux:用 nohup 后台启动并立即返回
  172. $logFile = storage_path('logs/text-generate-worker-' . date('Ymd') . '.log');
  173. $commandLine = 'nohup ' . implode(' ', array_map('escapeshellarg', $command))
  174. . ' >> ' . escapeshellarg($logFile) . ' 2>&1 &';
  175. exec($commandLine);
  176. dLog('command')->info('拉起剧本资产生成worker命令: ' . $commandLine);
  177. } else {
  178. // Windows:使用 cmd start /B 分离启动。
  179. // 注意:不能用 Symfony Process::start(),其对象析构时会 stop(0) 终止刚拉起的子进程。
  180. $logFile = storage_path('logs/text-generate-worker-' . date('Ymd') . '.log');
  181. $commandLine = 'start /B "" ' . implode(' ', array_map('escapeshellarg', $command))
  182. . ' >> ' . escapeshellarg($logFile) . ' 2>&1';
  183. pclose(popen($commandLine, 'r'));
  184. dLog('command')->info('拉起剧本资产生成worker命令: ' . $commandLine);
  185. }
  186. }
  187. /**
  188. * 恢复异常退出遗留的孤儿任务
  189. *
  190. * worker 进程被强杀/崩溃时不会执行收尾,任务会一直停留在 processing。
  191. * 若任务已 processing 超过 5 分钟且其处理锁处于空闲状态,说明原 worker 已不在,
  192. * 将任务重置回 pending,等待重新调度。
  193. *
  194. * @return int 恢复的任务数
  195. */
  196. private function recoverOrphanTasks(): int
  197. {
  198. $recovered = 0;
  199. $threshold = date('Y-m-d H:i:s', strtotime('-5 minutes'));
  200. $candidates = DB::table('mp_script_generate_tasks')
  201. ->where('status', 'processing')
  202. ->where('started_at', '<', $threshold)
  203. ->pluck('id')
  204. ->all();
  205. foreach ($candidates as $taskId) {
  206. $lockName = TextScriptGenerateRunCommand::LOCK_PREFIX . $taskId;
  207. $isFree = DB::selectOne('SELECT IS_FREE_LOCK(?) AS is_free', [$lockName]);
  208. if (!$isFree || (int)$isFree->is_free !== 1) {
  209. continue;
  210. }
  211. $updated = DB::table('mp_script_generate_tasks')
  212. ->where('id', $taskId)
  213. ->where('status', 'processing')
  214. ->update([
  215. 'status' => 'pending',
  216. 'error_message' => null,
  217. 'completed_at' => null,
  218. 'updated_at' => date('Y-m-d H:i:s'),
  219. ]);
  220. if ($updated) {
  221. $recovered++;
  222. dLog('command')->warning("孤儿任务恢复, task_id: {$taskId} 已重置为pending");
  223. logDB('command', 'warning', '孤儿任务恢复', [
  224. 'task_id' => (int)$taskId,
  225. 'message' => 'worker异常退出,任务重置为pending等待重新调度',
  226. ]);
  227. }
  228. }
  229. return $recovered;
  230. }
  231. /**
  232. * 获取当前 PHP 可执行文件的真实路径
  233. *
  234. * @return string
  235. */
  236. private function phpBinary()
  237. {
  238. $envBinary = getenv('PHP_BINARY');
  239. if ($envBinary && is_executable($envBinary)) {
  240. return $envBinary;
  241. }
  242. if (DIRECTORY_SEPARATOR === '/' && ($real = @readlink('/proc/self/exe')) && is_executable($real)) {
  243. return $real;
  244. }
  245. return PHP_BINARY;
  246. }
  247. }