| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567 |
- <?php
- namespace App\Console\Commands;
- use App\Models\Channel;
- use App\Models\PaliSentence;
- use App\Models\Progress;
- use App\Models\ProgressChapter;
- use App\Services\AuthService;
- use Firebase\JWT\JWT;
- use Firebase\JWT\Key;
- use Illuminate\Console\Attributes\Description;
- use Illuminate\Console\Attributes\Signature;
- use Illuminate\Console\Command;
- use Illuminate\Support\Facades\Http;
- use Symfony\Component\Process\Process;
- #[Signature('test:mq-progress {--book=93} {--channel=} {--limit=} {--wait=1} {--base-url=} {--mq-log=} {--no-start} {--keep} {--show-every=10} {--drain-timeout=14400}')]
- #[Description('Loop-test mq:progress by writing one sentence translation per API call and monitoring MQ output, logs and DB tables')]
- class TestMqProgress extends Command
- {
- /** worker 输出 / 日志里需要高亮为异常的关键词 */
- private const ERROR_MARKERS = [
- 'ambiguous',
- 'Command "',
- 'is not defined',
- 'exception',
- 'Exception',
- 'SQLSTATE',
- 'fail',
- 'task error',
- ];
- /** laravel.log 里值得展示的关键词(其余 debug/info 噪音折叠为计数) */
- private const LARAVEL_RELEVANT = [
- 'mq:progress',
- 'upgrade:progress',
- 'mq worker',
- 'received message',
- 'mq done',
- 'ERROR',
- 'exception',
- 'ambiguous',
- 'Command "',
- 'SQLSTATE',
- 'fail',
- ];
- private string $mqLogPath = '';
- private int $mqLogOffset = 0;
- private int $laravelLogOffset = 0;
- private int $laravelSuppressed = 0;
- public function handle(): int
- {
- $book = (int) ($this->option('book') ?: 93);
- $channelUid = $this->option('channel');
- $limit = $this->option('limit') !== null && $this->option('limit') !== '' ? (int) $this->option('limit') : null;
- $wait = max(0, (int) ($this->option('wait') ?: 1));
- $baseUrl = rtrim((string) ($this->option('base-url') ?: config('app.url')), '/');
- $this->mqLogPath = (string) ($this->option('mq-log') ?: storage_path('logs/mq-progress-test.log'));
- $showEvery = max(1, (int) ($this->option('show-every') ?: 10));
- $drainTimeout = max(0, (int) ($this->option('drain-timeout') ?: 14400));
- $noStart = (bool) $this->option('no-start');
- $keep = (bool) $this->option('keep');
- $token = trim((string) env('TESTING_TOKEN', ''));
- $account = (string) env('TESTING_ACCOUNT', '');
- if ($token === '') {
- $this->error('TESTING_TOKEN is empty in .env');
- return 1;
- }
- $accountUid = $this->decodeUid($token);
- if (! $accountUid) {
- $this->error('TESTING_TOKEN is invalid or expired');
- return 1;
- }
- if (! $channelUid) {
- $channelUid = $this->resolveChannel($accountUid);
- if (! $channelUid) {
- $this->error('No translation channel found for the account. Pass --channel=<uid>.');
- return 1;
- }
- $this->warn("Auto-selected channel: {$channelUid}");
- }
- $this->newLine();
- $this->info('== test:mq-progress ==');
- $this->line(" account : {$account} ({$accountUid})");
- $this->line(" book : {$book}");
- $this->line(" channel : {$channelUid}");
- $this->line(" base url : {$baseUrl}");
- $this->line(' mq log : '.$this->mqLogPath);
- $this->line(' laravel log : '.storage_path('logs/laravel.log'));
- $this->newLine();
- // 先确认 token 有效,避免每一句都 401
- $authResp = Http::timeout(15)->withToken($token)->get("{$baseUrl}/api/v2/auth/current");
- if ($authResp->failed() || $authResp->json('ok') !== true) {
- $this->error('auth check failed: HTTP '.$authResp->status().' '.mb_substr($authResp->body(), 0, 200));
- return 1;
- }
- $this->info('auth check ok.');
- $paliRows = PaliSentence::where('book', $book)
- ->orderBy('paragraph')
- ->orderBy('word_begin')
- ->get();
- $total = $paliRows->count();
- if ($limit !== null) {
- $paliRows = $paliRows->take($limit);
- }
- $this->info("pali_sentences book={$book}: {$total}".($limit !== null ? " (writing first {$paliRows->count()})" : ''));
- if ($paliRows->isEmpty()) {
- $this->error('no pali sentences found');
- return 1;
- }
- $before = $this->snapshot($book, $channelUid);
- $this->info('DB snapshot (before):');
- $this->table(['metric', 'value'], $this->snapshotRows($before));
- $this->newLine();
- // 重置 mq 输出文件并记录 laravel.log 当前位置
- $this->resetFile($this->mqLogPath);
- $this->mqLogOffset = 0;
- $this->laravelLogOffset = $this->fileSize(storage_path('logs/laravel.log'));
- $this->laravelSuppressed = 0;
- $process = null;
- if (! $noStart) {
- $process = $this->startWorker();
- $this->info('mq:progress started (pid='.$process->getPid().'), waiting for queue...');
- sleep(2);
- $this->flushMqLog($process);
- } else {
- $this->warn('--no-start: assuming mq:progress is already running');
- }
- $url = "{$baseUrl}/api/v2/sentence";
- $okCount = 0;
- $failCount = 0;
- $lastFingerprint = $before['fingerprint'];
- $mqErrors = 0;
- $this->newLine();
- $this->info("Start writing {$paliRows->count()} sentences (one POST each)...");
- $this->newLine();
- foreach ($paliRows as $i => $pali) {
- $idx = $i + 1;
- $content = mb_substr((string) $pali->text, 0, 20, 'UTF-8');
- if ($content === '') {
- $this->warn("[{$idx}] skip empty text para={$pali->paragraph} word_begin={$pali->word_begin}");
- continue;
- }
- $payload = [
- 'channel' => $channelUid,
- 'sentences' => [[
- 'book_id' => $book,
- 'paragraph' => (int) $pali->paragraph,
- 'word_start' => (int) $pali->word_begin,
- 'word_end' => (int) $pali->word_end,
- 'content' => $content,
- ]],
- ];
- $status = 0;
- $body = null;
- try {
- $resp = Http::timeout(30)->withToken($token)->post($url, $payload);
- $status = $resp->status();
- $body = $resp->json();
- $written = $body['data']['count'] ?? null;
- $ok = $resp->successful() && ($body['ok'] ?? false) === true;
- } catch (\Throwable $e) {
- $ok = false;
- $written = null;
- $this->error("[{$idx}] HTTP exception: {$e->getMessage()}");
- }
- if ($ok) {
- $okCount++;
- $this->line("[{$idx}/{$paliRows->count()}] para={$pali->paragraph} ws={$pali->word_begin} HTTP={$status} written={$written}");
- } else {
- $failCount++;
- $this->error("[{$idx}] para={$pali->paragraph} ws={$pali->word_begin} HTTP={$status} FAILED ".mb_substr((string) json_encode($body, JSON_UNESCAPED_UNICODE), 0, 220));
- }
- if ($wait > 0) {
- sleep($wait);
- }
- // 拉取 worker 控制台输出和 laravel.log 新内容
- $mqErrors += $this->flushMqLog($process);
- $mqErrors += $this->flushLaravelLog();
- // 每 N 句报告一次 DB 指纹变化
- if ($idx % $showEvery === 0 || $idx === $paliRows->count()) {
- $snap = $this->snapshot($book, $channelUid);
- if ($snap['fingerprint'] !== $lastFingerprint) {
- $lastFingerprint = $snap['fingerprint'];
- $this->line(' [db] progress_rows='.$snap['progress_count'].' all_strlen='.$snap['progress_sum']
- .' chapter_rows='.$snap['chapter_count'].' titles='.$snap['chapter_titles_count']);
- }
- }
- }
- // 等 worker 把队列全部消费完,保证最终 DB 快照准确
- $received = $okCount;
- if ($process !== null && $okCount > 0 && $drainTimeout > 0) {
- [$received, $drainErrors] = $this->waitForWorkerToDrain($process, $okCount, $drainTimeout);
- $mqErrors += $drainErrors;
- }
- sleep(1);
- $mqErrors += $this->flushMqLog($process);
- $mqErrors += $this->flushLaravelLog();
- $this->newLine();
- $this->info('== results ==');
- $after = $this->snapshot($book, $channelUid);
- $this->line('-- DB changes --');
- $this->table(['metric', 'before', 'after', 'delta'], [
- ['progress rows', (string) $before['progress_count'], (string) $after['progress_count'], (string) ($after['progress_count'] - $before['progress_count'])],
- ['progress all_strlen sum', (string) $before['progress_sum'], (string) $after['progress_sum'], (string) ($after['progress_sum'] - $before['progress_sum'])],
- ['progress_chapters rows', (string) $before['chapter_count'], (string) $after['chapter_count'], (string) ($after['chapter_count'] - $before['chapter_count'])],
- ['progress_chapters titles', (string) $before['chapter_titles_count'], (string) $after['chapter_titles_count'], (string) ($after['chapter_titles_count'] - $before['chapter_titles_count'])],
- ]);
- $this->line('-- progress_chapters sample (title 应为写入内容的前 20 字符) --');
- $samples = ProgressChapter::where('book', $book)
- ->where('channel_id', $channelUid)
- ->orderBy('para')
- ->limit(10)
- ->get(['para', 'title', 'progress', 'updated_at'])
- ->toArray();
- if ($samples) {
- $this->table(
- ['para', 'title', 'progress', 'updated_at'],
- array_map(fn ($r) => [
- (string) $r['para'],
- mb_substr((string) $r['title'], 0, 30, 'UTF-8'),
- (string) $r['progress'],
- (string) $r['updated_at'],
- ], $samples)
- );
- } else {
- $this->line(' (no rows)');
- }
- $this->newLine();
- $this->line('-- summary --');
- $this->line(" api writes ok : {$okCount}");
- $this->line(" api writes failed: {$failCount}");
- $this->line(" worker processed : {$received}/{$okCount}");
- $this->line(" error lines (mq console + laravel.log): {$mqErrors}");
- $this->line(' laravel.log suppressed noise: '.$this->laravelSuppressed);
- $dbChanged = $after['fingerprint'] !== $before['fingerprint'];
- $this->line(' db changed : '.($dbChanged ? 'yes' : 'no'));
- if ($okCount > 0 && ! $dbChanged) {
- $this->error('⚠ 有写入成功但 progress / progress_chapters 完全没有变化 —— 说明 MqProgress 没有真正更新 DB(疑似 bug)。');
- }
- if ($process && ! $keep) {
- $this->info('stopping mq:progress worker...');
- $this->stopWorker($process);
- } elseif ($keep) {
- $this->warn('--keep: worker left running');
- }
- return $failCount === 0 && $mqErrors === 0 ? 0 : 1;
- }
- /** 从 token 解析账号 uid */
- private function decodeUid(string $token): ?string
- {
- try {
- $jwt = JWT::decode($token, new Key(AuthService::getJwtKey(), 'HS512'));
- return $jwt->uid ?? null;
- } catch (\Throwable $e) {
- return null;
- }
- }
- /** 默认选账号名下第一个 translation channel */
- private function resolveChannel(string $accountUid): ?string
- {
- $candidates = Channel::where('owner_uid', $accountUid)
- ->where('type', 'translation')
- ->where('status', 30)
- ->orderBy('name')
- ->get(['uid', 'name']);
- if ($candidates->isEmpty()) {
- return null;
- }
- $this->line('candidate translation channels:');
- foreach ($candidates as $c) {
- $this->line(" - {$c->uid} ({$c->name})");
- }
- return $candidates->first()->uid;
- }
- private function snapshot(int $book, string $channelUid): array
- {
- $progressQuery = Progress::where('book', $book)->where('channel_id', $channelUid);
- $chapterQuery = ProgressChapter::where('book', $book)->where('channel_id', $channelUid);
- $snap = [
- 'progress_count' => (clone $progressQuery)->count(),
- 'progress_sum' => (int) (clone $progressQuery)->sum('all_strlen'),
- 'progress_max_updated_at' => (string) (clone $progressQuery)->max('updated_at'),
- 'chapter_count' => (clone $chapterQuery)->count(),
- 'chapter_titles_count' => (clone $chapterQuery)->whereNotNull('title')->where('title', '!=', '')->count(),
- 'chapter_max_updated_at' => (string) (clone $chapterQuery)->max('updated_at'),
- ];
- $snap['fingerprint'] = implode('|', [
- $snap['progress_count'],
- $snap['progress_sum'],
- $snap['progress_max_updated_at'],
- $snap['chapter_count'],
- $snap['chapter_titles_count'],
- $snap['chapter_max_updated_at'],
- ]);
- return $snap;
- }
- /** @return array<int, array{0: string, 1: string}> */
- private function snapshotRows(array $snap): array
- {
- return [
- ['progress rows', (string) $snap['progress_count']],
- ['progress all_strlen sum', (string) $snap['progress_sum']],
- ['progress max updated_at', $snap['progress_max_updated_at']],
- ['progress_chapters rows', (string) $snap['chapter_count']],
- ['progress_chapters titles', (string) $snap['chapter_titles_count']],
- ['progress_chapters max updated_at', $snap['chapter_max_updated_at']],
- ];
- }
- private function startWorker(): Process
- {
- $this->resetFile($this->mqLogPath);
- // 数组形式会让 Symfony 自动加 `exec`,stop() 的信号能直接打到 php 进程,不会留下孤儿 worker。
- $process = new Process(['php', 'artisan', 'mq:progress'], base_path(), null, null, null);
- $process->setTimeout(null);
- $process->start();
- return $process;
- }
- /** 把 worker 管道里的增量输出转存到 mq 日志文件(stdout 行缓冲,管道可实时读到) */
- private function drainWorker(Process $process): void
- {
- $out = $process->getIncrementalOutput();
- $err = $process->getIncrementalErrorOutput();
- if ($out !== '') {
- file_put_contents($this->mqLogPath, $out, FILE_APPEND);
- }
- if ($err !== '') {
- file_put_contents($this->mqLogPath, $err, FILE_APPEND);
- }
- }
- /**
- * 等待 worker 处理完 expected 条消息(以控制台里的 "Received book=" 行计数)。
- *
- * @return array{0: int, 1: int} [已处理条数, 期间命中的错误行数]
- */
- private function waitForWorkerToDrain(Process $process, int $expected, int $timeoutSeconds): array
- {
- $deadline = time() + $timeoutSeconds;
- $lastLog = 0;
- $received = 0;
- $errors = 0;
- while (true) {
- $before = $this->fileSize($this->mqLogPath);
- $this->drainWorker($process);
- $after = $this->fileSize($this->mqLogPath);
- if ($after > $before) {
- $errors += $this->scanFileErrors($this->mqLogPath, $before, $after);
- // 静默推进 offset,避免结束时把几千行 drain 内容刷屏
- $this->mqLogOffset = $after;
- }
- $received = substr_count((string) @file_get_contents($this->mqLogPath), 'Received book=');
- $now = time();
- if ($received >= $expected) {
- $this->info("worker drained all messages ({$received}/{$expected})");
- break;
- }
- if (! $process->isRunning()) {
- $this->warn("worker exited before draining ({$received}/{$expected})");
- break;
- }
- if ($now >= $deadline) {
- $this->warn("drain timeout after {$timeoutSeconds}s ({$received}/{$expected})");
- break;
- }
- if ($now - $lastLog >= 30) {
- $this->line(" [drain] worker processed {$received}/{$expected} messages, waiting...");
- $lastLog = $now;
- }
- sleep(2);
- }
- return [$received, $errors];
- }
- /** 统计文件 [start,end) 字节区间内命中错误关键词的行数 */
- private function scanFileErrors(string $path, int $start, int $end): int
- {
- $fh = @fopen($path, 'r');
- if (! $fh) {
- return 0;
- }
- fseek($fh, $start);
- $content = (string) fread($fh, $end - $start);
- fclose($fh);
- $errors = 0;
- foreach (preg_split('/\r\n|\r|\n/', $content) ?: [] as $line) {
- if ($line !== '' && $this->hasErrorMarker($line)) {
- $errors++;
- }
- }
- return $errors;
- }
- private function stopWorker(Process $process): void
- {
- if (! $process->isRunning()) {
- return;
- }
- try {
- $process->stop(5, 15);
- } catch (\Throwable $e) {
- // 忽略超时,下面强制杀
- }
- if ($process->isRunning()) {
- try {
- $process->stop(0, 9);
- } catch (\Throwable $e) {
- // ignore
- }
- }
- }
- /** 输出 worker 控制台文件新内容,返回命中的错误行数 */
- private function flushMqLog(?Process $process = null): int
- {
- if ($process !== null) {
- $this->drainWorker($process);
- }
- return $this->flushFile($this->mqLogPath, $this->mqLogOffset, 'mq', true);
- }
- private function flushLaravelLog(): int
- {
- return $this->flushFile(storage_path('logs/laravel.log'), $this->laravelLogOffset, 'laravel', false);
- }
- /** @param bool $showAll true=逐行全打印;false=只打印相关/错误行,其余折叠计数 */
- private function flushFile(string $path, int &$offset, string $label, bool $showAll): int
- {
- $size = $this->fileSize($path);
- if ($size <= $offset) {
- return 0;
- }
- $fh = @fopen($path, 'r');
- if (! $fh) {
- return 0;
- }
- fseek($fh, $offset);
- $content = (string) fread($fh, $size - $offset);
- $offset = $size;
- fclose($fh);
- $errors = 0;
- $lines = preg_split('/\r\n|\r|\n/', $content) ?: [];
- foreach ($lines as $line) {
- if ($line === '') {
- continue;
- }
- $hasError = $this->hasErrorMarker($line);
- if ($hasError) {
- $errors++;
- }
- if ($showAll || $hasError || $this->hasRelevantMarker($line)) {
- if ($hasError) {
- $this->error(" [{$label}] {$line}");
- } else {
- $this->line(" <comment>[{$label}]</comment> {$line}");
- }
- } elseif ($label === 'laravel') {
- $this->laravelSuppressed++;
- }
- }
- return $errors;
- }
- private function hasErrorMarker(string $line): bool
- {
- foreach (self::ERROR_MARKERS as $marker) {
- if (str_contains($line, $marker)) {
- return true;
- }
- }
- return false;
- }
- private function hasRelevantMarker(string $line): bool
- {
- foreach (self::LARAVEL_RELEVANT as $marker) {
- if (str_contains($line, $marker)) {
- return true;
- }
- }
- return false;
- }
- private function resetFile(string $path): void
- {
- @file_put_contents($path, '');
- }
- private function fileSize(string $path): int
- {
- $size = @filesize($path);
- return $size === false ? 0 : $size;
- }
- }
|