TestMqProgress.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Models\Channel;
  4. use App\Models\PaliSentence;
  5. use App\Models\Progress;
  6. use App\Models\ProgressChapter;
  7. use App\Services\AuthService;
  8. use Firebase\JWT\JWT;
  9. use Firebase\JWT\Key;
  10. use Illuminate\Console\Attributes\Description;
  11. use Illuminate\Console\Attributes\Signature;
  12. use Illuminate\Console\Command;
  13. use Illuminate\Support\Facades\Http;
  14. use Symfony\Component\Process\Process;
  15. #[Signature('test:mq-progress {--book=93} {--channel=} {--limit=} {--wait=1} {--base-url=} {--mq-log=} {--no-start} {--keep} {--show-every=10} {--drain-timeout=14400}')]
  16. #[Description('Loop-test mq:progress by writing one sentence translation per API call and monitoring MQ output, logs and DB tables')]
  17. class TestMqProgress extends Command
  18. {
  19. /** worker 输出 / 日志里需要高亮为异常的关键词 */
  20. private const ERROR_MARKERS = [
  21. 'ambiguous',
  22. 'Command "',
  23. 'is not defined',
  24. 'exception',
  25. 'Exception',
  26. 'SQLSTATE',
  27. 'fail',
  28. 'task error',
  29. ];
  30. /** laravel.log 里值得展示的关键词(其余 debug/info 噪音折叠为计数) */
  31. private const LARAVEL_RELEVANT = [
  32. 'mq:progress',
  33. 'upgrade:progress',
  34. 'mq worker',
  35. 'received message',
  36. 'mq done',
  37. 'ERROR',
  38. 'exception',
  39. 'ambiguous',
  40. 'Command "',
  41. 'SQLSTATE',
  42. 'fail',
  43. ];
  44. private string $mqLogPath = '';
  45. private int $mqLogOffset = 0;
  46. private int $laravelLogOffset = 0;
  47. private int $laravelSuppressed = 0;
  48. public function handle(): int
  49. {
  50. $book = (int) ($this->option('book') ?: 93);
  51. $channelUid = $this->option('channel');
  52. $limit = $this->option('limit') !== null && $this->option('limit') !== '' ? (int) $this->option('limit') : null;
  53. $wait = max(0, (int) ($this->option('wait') ?: 1));
  54. $baseUrl = rtrim((string) ($this->option('base-url') ?: config('app.url')), '/');
  55. $this->mqLogPath = (string) ($this->option('mq-log') ?: storage_path('logs/mq-progress-test.log'));
  56. $showEvery = max(1, (int) ($this->option('show-every') ?: 10));
  57. $drainTimeout = max(0, (int) ($this->option('drain-timeout') ?: 14400));
  58. $noStart = (bool) $this->option('no-start');
  59. $keep = (bool) $this->option('keep');
  60. $token = trim((string) env('TESTING_TOKEN', ''));
  61. $account = (string) env('TESTING_ACCOUNT', '');
  62. if ($token === '') {
  63. $this->error('TESTING_TOKEN is empty in .env');
  64. return 1;
  65. }
  66. $accountUid = $this->decodeUid($token);
  67. if (! $accountUid) {
  68. $this->error('TESTING_TOKEN is invalid or expired');
  69. return 1;
  70. }
  71. if (! $channelUid) {
  72. $channelUid = $this->resolveChannel($accountUid);
  73. if (! $channelUid) {
  74. $this->error('No translation channel found for the account. Pass --channel=<uid>.');
  75. return 1;
  76. }
  77. $this->warn("Auto-selected channel: {$channelUid}");
  78. }
  79. $this->newLine();
  80. $this->info('== test:mq-progress ==');
  81. $this->line(" account : {$account} ({$accountUid})");
  82. $this->line(" book : {$book}");
  83. $this->line(" channel : {$channelUid}");
  84. $this->line(" base url : {$baseUrl}");
  85. $this->line(' mq log : '.$this->mqLogPath);
  86. $this->line(' laravel log : '.storage_path('logs/laravel.log'));
  87. $this->newLine();
  88. // 先确认 token 有效,避免每一句都 401
  89. $authResp = Http::timeout(15)->withToken($token)->get("{$baseUrl}/api/v2/auth/current");
  90. if ($authResp->failed() || $authResp->json('ok') !== true) {
  91. $this->error('auth check failed: HTTP '.$authResp->status().' '.mb_substr($authResp->body(), 0, 200));
  92. return 1;
  93. }
  94. $this->info('auth check ok.');
  95. $paliRows = PaliSentence::where('book', $book)
  96. ->orderBy('paragraph')
  97. ->orderBy('word_begin')
  98. ->get();
  99. $total = $paliRows->count();
  100. if ($limit !== null) {
  101. $paliRows = $paliRows->take($limit);
  102. }
  103. $this->info("pali_sentences book={$book}: {$total}".($limit !== null ? " (writing first {$paliRows->count()})" : ''));
  104. if ($paliRows->isEmpty()) {
  105. $this->error('no pali sentences found');
  106. return 1;
  107. }
  108. $before = $this->snapshot($book, $channelUid);
  109. $this->info('DB snapshot (before):');
  110. $this->table(['metric', 'value'], $this->snapshotRows($before));
  111. $this->newLine();
  112. // 重置 mq 输出文件并记录 laravel.log 当前位置
  113. $this->resetFile($this->mqLogPath);
  114. $this->mqLogOffset = 0;
  115. $this->laravelLogOffset = $this->fileSize(storage_path('logs/laravel.log'));
  116. $this->laravelSuppressed = 0;
  117. $process = null;
  118. if (! $noStart) {
  119. $process = $this->startWorker();
  120. $this->info('mq:progress started (pid='.$process->getPid().'), waiting for queue...');
  121. sleep(2);
  122. $this->flushMqLog($process);
  123. } else {
  124. $this->warn('--no-start: assuming mq:progress is already running');
  125. }
  126. $url = "{$baseUrl}/api/v2/sentence";
  127. $okCount = 0;
  128. $failCount = 0;
  129. $lastFingerprint = $before['fingerprint'];
  130. $mqErrors = 0;
  131. $this->newLine();
  132. $this->info("Start writing {$paliRows->count()} sentences (one POST each)...");
  133. $this->newLine();
  134. foreach ($paliRows as $i => $pali) {
  135. $idx = $i + 1;
  136. $content = mb_substr((string) $pali->text, 0, 20, 'UTF-8');
  137. if ($content === '') {
  138. $this->warn("[{$idx}] skip empty text para={$pali->paragraph} word_begin={$pali->word_begin}");
  139. continue;
  140. }
  141. $payload = [
  142. 'channel' => $channelUid,
  143. 'sentences' => [[
  144. 'book_id' => $book,
  145. 'paragraph' => (int) $pali->paragraph,
  146. 'word_start' => (int) $pali->word_begin,
  147. 'word_end' => (int) $pali->word_end,
  148. 'content' => $content,
  149. ]],
  150. ];
  151. $status = 0;
  152. $body = null;
  153. try {
  154. $resp = Http::timeout(30)->withToken($token)->post($url, $payload);
  155. $status = $resp->status();
  156. $body = $resp->json();
  157. $written = $body['data']['count'] ?? null;
  158. $ok = $resp->successful() && ($body['ok'] ?? false) === true;
  159. } catch (\Throwable $e) {
  160. $ok = false;
  161. $written = null;
  162. $this->error("[{$idx}] HTTP exception: {$e->getMessage()}");
  163. }
  164. if ($ok) {
  165. $okCount++;
  166. $this->line("[{$idx}/{$paliRows->count()}] para={$pali->paragraph} ws={$pali->word_begin} HTTP={$status} written={$written}");
  167. } else {
  168. $failCount++;
  169. $this->error("[{$idx}] para={$pali->paragraph} ws={$pali->word_begin} HTTP={$status} FAILED ".mb_substr((string) json_encode($body, JSON_UNESCAPED_UNICODE), 0, 220));
  170. }
  171. if ($wait > 0) {
  172. sleep($wait);
  173. }
  174. // 拉取 worker 控制台输出和 laravel.log 新内容
  175. $mqErrors += $this->flushMqLog($process);
  176. $mqErrors += $this->flushLaravelLog();
  177. // 每 N 句报告一次 DB 指纹变化
  178. if ($idx % $showEvery === 0 || $idx === $paliRows->count()) {
  179. $snap = $this->snapshot($book, $channelUid);
  180. if ($snap['fingerprint'] !== $lastFingerprint) {
  181. $lastFingerprint = $snap['fingerprint'];
  182. $this->line(' [db] progress_rows='.$snap['progress_count'].' all_strlen='.$snap['progress_sum']
  183. .' chapter_rows='.$snap['chapter_count'].' titles='.$snap['chapter_titles_count']);
  184. }
  185. }
  186. }
  187. // 等 worker 把队列全部消费完,保证最终 DB 快照准确
  188. $received = $okCount;
  189. if ($process !== null && $okCount > 0 && $drainTimeout > 0) {
  190. [$received, $drainErrors] = $this->waitForWorkerToDrain($process, $okCount, $drainTimeout);
  191. $mqErrors += $drainErrors;
  192. }
  193. sleep(1);
  194. $mqErrors += $this->flushMqLog($process);
  195. $mqErrors += $this->flushLaravelLog();
  196. $this->newLine();
  197. $this->info('== results ==');
  198. $after = $this->snapshot($book, $channelUid);
  199. $this->line('-- DB changes --');
  200. $this->table(['metric', 'before', 'after', 'delta'], [
  201. ['progress rows', (string) $before['progress_count'], (string) $after['progress_count'], (string) ($after['progress_count'] - $before['progress_count'])],
  202. ['progress all_strlen sum', (string) $before['progress_sum'], (string) $after['progress_sum'], (string) ($after['progress_sum'] - $before['progress_sum'])],
  203. ['progress_chapters rows', (string) $before['chapter_count'], (string) $after['chapter_count'], (string) ($after['chapter_count'] - $before['chapter_count'])],
  204. ['progress_chapters titles', (string) $before['chapter_titles_count'], (string) $after['chapter_titles_count'], (string) ($after['chapter_titles_count'] - $before['chapter_titles_count'])],
  205. ]);
  206. $this->line('-- progress_chapters sample (title 应为写入内容的前 20 字符) --');
  207. $samples = ProgressChapter::where('book', $book)
  208. ->where('channel_id', $channelUid)
  209. ->orderBy('para')
  210. ->limit(10)
  211. ->get(['para', 'title', 'progress', 'updated_at'])
  212. ->toArray();
  213. if ($samples) {
  214. $this->table(
  215. ['para', 'title', 'progress', 'updated_at'],
  216. array_map(fn ($r) => [
  217. (string) $r['para'],
  218. mb_substr((string) $r['title'], 0, 30, 'UTF-8'),
  219. (string) $r['progress'],
  220. (string) $r['updated_at'],
  221. ], $samples)
  222. );
  223. } else {
  224. $this->line(' (no rows)');
  225. }
  226. $this->newLine();
  227. $this->line('-- summary --');
  228. $this->line(" api writes ok : {$okCount}");
  229. $this->line(" api writes failed: {$failCount}");
  230. $this->line(" worker processed : {$received}/{$okCount}");
  231. $this->line(" error lines (mq console + laravel.log): {$mqErrors}");
  232. $this->line(' laravel.log suppressed noise: '.$this->laravelSuppressed);
  233. $dbChanged = $after['fingerprint'] !== $before['fingerprint'];
  234. $this->line(' db changed : '.($dbChanged ? 'yes' : 'no'));
  235. if ($okCount > 0 && ! $dbChanged) {
  236. $this->error('⚠ 有写入成功但 progress / progress_chapters 完全没有变化 —— 说明 MqProgress 没有真正更新 DB(疑似 bug)。');
  237. }
  238. if ($process && ! $keep) {
  239. $this->info('stopping mq:progress worker...');
  240. $this->stopWorker($process);
  241. } elseif ($keep) {
  242. $this->warn('--keep: worker left running');
  243. }
  244. return $failCount === 0 && $mqErrors === 0 ? 0 : 1;
  245. }
  246. /** 从 token 解析账号 uid */
  247. private function decodeUid(string $token): ?string
  248. {
  249. try {
  250. $jwt = JWT::decode($token, new Key(AuthService::getJwtKey(), 'HS512'));
  251. return $jwt->uid ?? null;
  252. } catch (\Throwable $e) {
  253. return null;
  254. }
  255. }
  256. /** 默认选账号名下第一个 translation channel */
  257. private function resolveChannel(string $accountUid): ?string
  258. {
  259. $candidates = Channel::where('owner_uid', $accountUid)
  260. ->where('type', 'translation')
  261. ->where('status', 30)
  262. ->orderBy('name')
  263. ->get(['uid', 'name']);
  264. if ($candidates->isEmpty()) {
  265. return null;
  266. }
  267. $this->line('candidate translation channels:');
  268. foreach ($candidates as $c) {
  269. $this->line(" - {$c->uid} ({$c->name})");
  270. }
  271. return $candidates->first()->uid;
  272. }
  273. private function snapshot(int $book, string $channelUid): array
  274. {
  275. $progressQuery = Progress::where('book', $book)->where('channel_id', $channelUid);
  276. $chapterQuery = ProgressChapter::where('book', $book)->where('channel_id', $channelUid);
  277. $snap = [
  278. 'progress_count' => (clone $progressQuery)->count(),
  279. 'progress_sum' => (int) (clone $progressQuery)->sum('all_strlen'),
  280. 'progress_max_updated_at' => (string) (clone $progressQuery)->max('updated_at'),
  281. 'chapter_count' => (clone $chapterQuery)->count(),
  282. 'chapter_titles_count' => (clone $chapterQuery)->whereNotNull('title')->where('title', '!=', '')->count(),
  283. 'chapter_max_updated_at' => (string) (clone $chapterQuery)->max('updated_at'),
  284. ];
  285. $snap['fingerprint'] = implode('|', [
  286. $snap['progress_count'],
  287. $snap['progress_sum'],
  288. $snap['progress_max_updated_at'],
  289. $snap['chapter_count'],
  290. $snap['chapter_titles_count'],
  291. $snap['chapter_max_updated_at'],
  292. ]);
  293. return $snap;
  294. }
  295. /** @return array<int, array{0: string, 1: string}> */
  296. private function snapshotRows(array $snap): array
  297. {
  298. return [
  299. ['progress rows', (string) $snap['progress_count']],
  300. ['progress all_strlen sum', (string) $snap['progress_sum']],
  301. ['progress max updated_at', $snap['progress_max_updated_at']],
  302. ['progress_chapters rows', (string) $snap['chapter_count']],
  303. ['progress_chapters titles', (string) $snap['chapter_titles_count']],
  304. ['progress_chapters max updated_at', $snap['chapter_max_updated_at']],
  305. ];
  306. }
  307. private function startWorker(): Process
  308. {
  309. $this->resetFile($this->mqLogPath);
  310. // 数组形式会让 Symfony 自动加 `exec`,stop() 的信号能直接打到 php 进程,不会留下孤儿 worker。
  311. $process = new Process(['php', 'artisan', 'mq:progress'], base_path(), null, null, null);
  312. $process->setTimeout(null);
  313. $process->start();
  314. return $process;
  315. }
  316. /** 把 worker 管道里的增量输出转存到 mq 日志文件(stdout 行缓冲,管道可实时读到) */
  317. private function drainWorker(Process $process): void
  318. {
  319. $out = $process->getIncrementalOutput();
  320. $err = $process->getIncrementalErrorOutput();
  321. if ($out !== '') {
  322. file_put_contents($this->mqLogPath, $out, FILE_APPEND);
  323. }
  324. if ($err !== '') {
  325. file_put_contents($this->mqLogPath, $err, FILE_APPEND);
  326. }
  327. }
  328. /**
  329. * 等待 worker 处理完 expected 条消息(以控制台里的 "Received book=" 行计数)。
  330. *
  331. * @return array{0: int, 1: int} [已处理条数, 期间命中的错误行数]
  332. */
  333. private function waitForWorkerToDrain(Process $process, int $expected, int $timeoutSeconds): array
  334. {
  335. $deadline = time() + $timeoutSeconds;
  336. $lastLog = 0;
  337. $received = 0;
  338. $errors = 0;
  339. while (true) {
  340. $before = $this->fileSize($this->mqLogPath);
  341. $this->drainWorker($process);
  342. $after = $this->fileSize($this->mqLogPath);
  343. if ($after > $before) {
  344. $errors += $this->scanFileErrors($this->mqLogPath, $before, $after);
  345. // 静默推进 offset,避免结束时把几千行 drain 内容刷屏
  346. $this->mqLogOffset = $after;
  347. }
  348. $received = substr_count((string) @file_get_contents($this->mqLogPath), 'Received book=');
  349. $now = time();
  350. if ($received >= $expected) {
  351. $this->info("worker drained all messages ({$received}/{$expected})");
  352. break;
  353. }
  354. if (! $process->isRunning()) {
  355. $this->warn("worker exited before draining ({$received}/{$expected})");
  356. break;
  357. }
  358. if ($now >= $deadline) {
  359. $this->warn("drain timeout after {$timeoutSeconds}s ({$received}/{$expected})");
  360. break;
  361. }
  362. if ($now - $lastLog >= 30) {
  363. $this->line(" [drain] worker processed {$received}/{$expected} messages, waiting...");
  364. $lastLog = $now;
  365. }
  366. sleep(2);
  367. }
  368. return [$received, $errors];
  369. }
  370. /** 统计文件 [start,end) 字节区间内命中错误关键词的行数 */
  371. private function scanFileErrors(string $path, int $start, int $end): int
  372. {
  373. $fh = @fopen($path, 'r');
  374. if (! $fh) {
  375. return 0;
  376. }
  377. fseek($fh, $start);
  378. $content = (string) fread($fh, $end - $start);
  379. fclose($fh);
  380. $errors = 0;
  381. foreach (preg_split('/\r\n|\r|\n/', $content) ?: [] as $line) {
  382. if ($line !== '' && $this->hasErrorMarker($line)) {
  383. $errors++;
  384. }
  385. }
  386. return $errors;
  387. }
  388. private function stopWorker(Process $process): void
  389. {
  390. if (! $process->isRunning()) {
  391. return;
  392. }
  393. try {
  394. $process->stop(5, 15);
  395. } catch (\Throwable $e) {
  396. // 忽略超时,下面强制杀
  397. }
  398. if ($process->isRunning()) {
  399. try {
  400. $process->stop(0, 9);
  401. } catch (\Throwable $e) {
  402. // ignore
  403. }
  404. }
  405. }
  406. /** 输出 worker 控制台文件新内容,返回命中的错误行数 */
  407. private function flushMqLog(?Process $process = null): int
  408. {
  409. if ($process !== null) {
  410. $this->drainWorker($process);
  411. }
  412. return $this->flushFile($this->mqLogPath, $this->mqLogOffset, 'mq', true);
  413. }
  414. private function flushLaravelLog(): int
  415. {
  416. return $this->flushFile(storage_path('logs/laravel.log'), $this->laravelLogOffset, 'laravel', false);
  417. }
  418. /** @param bool $showAll true=逐行全打印;false=只打印相关/错误行,其余折叠计数 */
  419. private function flushFile(string $path, int &$offset, string $label, bool $showAll): int
  420. {
  421. $size = $this->fileSize($path);
  422. if ($size <= $offset) {
  423. return 0;
  424. }
  425. $fh = @fopen($path, 'r');
  426. if (! $fh) {
  427. return 0;
  428. }
  429. fseek($fh, $offset);
  430. $content = (string) fread($fh, $size - $offset);
  431. $offset = $size;
  432. fclose($fh);
  433. $errors = 0;
  434. $lines = preg_split('/\r\n|\r|\n/', $content) ?: [];
  435. foreach ($lines as $line) {
  436. if ($line === '') {
  437. continue;
  438. }
  439. $hasError = $this->hasErrorMarker($line);
  440. if ($hasError) {
  441. $errors++;
  442. }
  443. if ($showAll || $hasError || $this->hasRelevantMarker($line)) {
  444. if ($hasError) {
  445. $this->error(" [{$label}] {$line}");
  446. } else {
  447. $this->line(" <comment>[{$label}]</comment> {$line}");
  448. }
  449. } elseif ($label === 'laravel') {
  450. $this->laravelSuppressed++;
  451. }
  452. }
  453. return $errors;
  454. }
  455. private function hasErrorMarker(string $line): bool
  456. {
  457. foreach (self::ERROR_MARKERS as $marker) {
  458. if (str_contains($line, $marker)) {
  459. return true;
  460. }
  461. }
  462. return false;
  463. }
  464. private function hasRelevantMarker(string $line): bool
  465. {
  466. foreach (self::LARAVEL_RELEVANT as $marker) {
  467. if (str_contains($line, $marker)) {
  468. return true;
  469. }
  470. }
  471. return false;
  472. }
  473. private function resetFile(string $path): void
  474. {
  475. @file_put_contents($path, '');
  476. }
  477. private function fileSize(string $path): int
  478. {
  479. $size = @filesize($path);
  480. return $size === false ? 0 : $size;
  481. }
  482. }