Files
ulthon_admin/extend/base/common/service/DebugLogReaderServiceBase.php

511 lines
21 KiB
PHP
Raw 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\service;
use app\admin\model\DebugLogPosition;
use app\common\service\HostService;
use think\log\DebugLogToolkit;
/**
* debug 日志增量读取 serviceBase 层——JSONL 按行 + CSV fgetcsv 多行安全.
*
* 与 XhprofLogReaderServiceBase 的两处差异(有意为之,勿"顺手同步"回去):
* 1. JSONL read() 在 reader 内联 decode每行经 DebugLogToolkit::decodeLine()
* 数组化后才 yield坏行计 parseFails 不产出MAX_LINE_BYTES 收紧为 8MB
* ——debug 行是短文本行,远小于 xhprof 采样行(~100KB32MB 防御阈值不必要。
* 2. 新增 CSV 路径 readCsv():遗留 CSVDebugMysql.saveByFile 产物 {ymd}.csv
* 的 content 含换行是常态,一条逻辑记录跨多物理行——必须用 fgetcsv() 有状态
* 解析fgetcsv 原生处理引号内嵌入换行),【禁止】按 \n 切行后 str_getcsv
* (会把跨行记录撕碎成两条坏行)。
*
* 其余协议与 xhprof 版一致:按 offset 增量读、inode/size 双条件轮转检测归零、
* max 上限、每次产出前推进安全偏移(消费方任意时刻 getCurrentPosition() 取到的
* 都是"最后一条已消费行末尾"、JSONL 尾部半行不产出(下轮写入 \n 后补齐)、
* 不自动 savePosition进度落盘由 T6 导入任务在 insertBatch 同一事务内显式调用)、
* position 按 (node_id, file_path) 唯一定位 upsert。
*
* 平台限制Windows 下 fstat()['ino'] 恒为 0inode 轮转检测退化为仅 size<offset
* 兜底(生产 Linux 有真实 inode无此问题——继承自 xhprof 版协议的已知特性,
* 不是缺陷。
*
* 依赖倒置model 走 app\admin\model\DebugLogPosition 入口类;节点身份走
* app\common\service\HostService。本表为全新表无旧数据接管问题不含 lazyAdopt。
*/
class DebugLogReaderServiceBase
{
/** 单次 fread 的 buffer 大小64KB */
protected const BUFFER_SIZE = 65536;
/** 单行硬上限8MB超过整行丢弃计 dropped。真实 debug 行是短文本8MB 已是异常防御 */
protected const MAX_LINE_BYTES = 8388608;
/** 解析失败样本最多保留条数(超出丢弃,样本只做排查线索不做全量记录) */
protected const MAX_FAIL_SAMPLES = 3;
/** 单条失败样本截断长度(字符) */
protected const FAIL_SAMPLE_LENGTH = 200;
/** @var int 当前文件 inoderead/readCsv 期间有效Windows 恒为 0 */
protected $currentInode = 0;
/** @var int 安全偏移:最后一条已消费(产出/跳过/失败均算消费)逻辑记录的末尾 */
protected $currentOffset = 0;
/** @var string|null 最后一条已产出行的 md5 */
protected $currentLineHash = null;
/** @var bool 本轮读取是否干净到达 EOFJSONL尾部无半行CSVfgetcsv 返 false */
protected $reachedEof = false;
/** @var int 本轮 read 丢弃的超长行数(仅 JSONL 路径CSV 无缓冲切分逻辑恒为 0 */
protected $droppedLines = 0;
/** @var int 本轮读取解析失败计数JSONL 坏行 + CSV 字段数不符) */
protected $parseFails = 0;
/** @var string[] 解析失败样本(截断后的原始行,最多 MAX_FAIL_SAMPLES 条) */
protected $failSamples = [];
/**
* 增量读取 jsonl 文件yield decodeLine 数组化后的行.
*
* 读取协议:
* 1. fopen + fstat 取当前 inode/size读 position无则视为新建 offset=0
* 2. 轮转检测inode 不一致 OR filesize < offset → 归零重读
* 3. fseek 到 offset循环 fread 64KB定位已读段内最后 \n仅产出完整行
* 4. 每次 yield 前 currentOffset 推进到该行末尾(含 \n保证事务内
* savePosition 与已消费行严格对齐(坏行同样推进——毒行不重读)
* 5. 完整行经 DebugLogToolkit::decodeLine() 数组化decode 返 null 计
* parseFails 不产出(失败样本进 failSamples
* 6. 尾部半行(无 \n不产出currentOffset 停在半行起点,下次重读
* 7. 半行缓冲超过 MAX_LINE_BYTES整行丢弃计 dropped扫描至下一个 \n 继续
* 8. yield maxLines 后退出(不自动 savePosition由上层事务内落盘
*
* @param string $filePath jsonl 文件绝对路径
* @param int $maxLines 单次产出的最大行数
*
* @return \Generator<array> 每次迭代返回一行(键值与 DebugLogToolkit::FIELDS 一致)
*
* @throws \RuntimeException 文件无法打开或 stat/fseek/fread 失败时抛出(交由上层 try-catch
*/
public function read(string $filePath, int $maxLines = 100000): \Generator
{
// per-run 状态复位(同一个 reader 实例可串行处理多个文件)
$this->reachedEof = false;
$this->droppedLines = 0;
$this->currentLineHash = null;
$this->parseFails = 0;
$this->failSamples = [];
$fp = @fopen($filePath, 'rb');
if ($fp === false) {
throw new \RuntimeException("DebugLogReaderService: 无法打开文件 {$filePath}");
}
try {
clearstatcache(true, $filePath);
$stat = fstat($fp);
if ($stat === false) {
throw new \RuntimeException("DebugLogReaderService: fstat 失败 {$filePath}");
}
$currentInode = (int) $stat['ino'];
$currentSize = (int) $stat['size'];
$this->currentInode = $currentInode;
// 读 position无则默认 offset=0
$offset = 0;
$position = $this->getLastPosition($filePath);
if ($position !== null) {
$offset = (int) $position['offset'];
// 轮转检测inode 变化同日文件被重建OR size < offset防御性归零
if ((int) $position['inode'] !== $currentInode || $currentSize < $offset) {
$offset = 0;
}
}
$this->currentOffset = $offset;
if ($offset > 0 && fseek($fp, $offset) !== 0) {
throw new \RuntimeException("DebugLogReaderService: fseek 失败 offset={$offset} file={$filePath}");
}
$yielded = 0;
$buffer = '';
// $bufferOffset 始终等于 buffer[0] 对应的文件字节偏移
$bufferOffset = $offset;
while (true) {
$chunk = fread($fp, self::BUFFER_SIZE);
if ($chunk === false) {
throw new \RuntimeException("DebugLogReaderService: fread 失败 file={$filePath}");
}
if ($chunk === '') {
// EOFbuffer 为空说明干净收尾;有半行则 currentOffset 已停在最后完整行末尾
$this->reachedEof = ($buffer === '');
return;
}
$buffer .= $chunk;
// 内层循环:处理 buffer 中所有完整行(含末尾 \n 的)
$pos = 0;
while (($nlPos = strpos($buffer, "\n", $pos)) !== false) {
$line = substr($buffer, $pos, $nlPos - $pos);
$pos = $nlPos + 1;
// 先推进安全偏移再产出/判失败:坏行也算已消费(毒行不重读),
// 消费方在事务内 savePosition 拿到的 offset 与已处理行严格对齐
$this->currentOffset = $bufferOffset + $pos;
$row = DebugLogToolkit::decodeLine($line);
if ($row === null) {
$this->parseFails++;
$this->recordFailSample($line);
continue;
}
$this->currentLineHash = md5($line);
yield $row;
$yielded++;
if ($yielded >= $maxLines) {
return;
}
}
// buffer 中已消费 $pos 字节半行buffer[$pos..])保留
$bufferOffset += $pos;
$buffer = substr($buffer, $pos);
// 超长行防御:整行丢弃(禁止强制切行——会把一条 JSON 切成两条坏行)
if (strlen($buffer) >= self::MAX_LINE_BYTES) {
$this->droppedLines++;
$buffer = '';
// 扫描至下一个换行符,将其后内容作为新 buffer 继续
while (true) {
$chunk = fread($fp, self::BUFFER_SIZE);
if ($chunk === false || $chunk === '') {
// 至 EOF 仍未等到换行符:整个尾部按已丢弃处理,偏移推到文件末尾
$bufferOffset = (int) ftell($fp);
$this->currentOffset = $bufferOffset;
$this->reachedEof = true;
return;
}
$nl = strpos($chunk, "\n");
if ($nl !== false) {
$bufferOffset = (int) ftell($fp) - (strlen($chunk) - $nl - 1);
$this->currentOffset = $bufferOffset;
$buffer = substr($chunk, $nl + 1);
break;
}
}
}
}
} finally {
if (is_resource($fp)) {
fclose($fp);
}
}
}
/**
* 增量读取遗留 CSV 文件yield 关联数组化的数据行.
*
* 读取协议(与 read() 的差异全因 CSV 的多物理行特性):
* 1. fopen 'rb' + fstat 取 inode/size + position 轮转检测归零——与 read() 一致
* 2. fseek 到 offset 后循环 fgetcsv()fgetcsv 是有状态解析,原生处理引号内
* 嵌入换行(一条逻辑记录跨多物理行),返回值即一条完整逻辑记录
* 【禁止】按 \n 切行后 str_getcsv——引号内换行会被撕碎
* 3. 每读出一条逻辑记录(无论产出/表头跳过/失败)都把 currentOffset 推进到
* ftell($fp)(记录末尾),保证事务内 savePosition 与已消费记录对齐
* 4. position==0从文件头开始时首行经 DebugLogToolkit::isCsvHeader() 判定,
* 是表头则跳过不产出offset 仍推进);续读 offset 处不会是表头,不判定
* 5. 行字段数 !== 8 计 parseFails 不产出(失败样本进 failSamples
* 6. 行字段按 DebugLogToolkit::FIELDS 顺序 map 成关联数组create_time 为
* 数字字符串时转 intcreate_time_title 保留字符串CSV 一切皆字符串)
* 7. 产出 maxRows 条后退出(不自动 savePosition由上层事务内落盘
*
* 注CSV 路径无"尾部半行"概念——遗留 CSV 是已封存的静态文件(非追加中),
* fgetcsv 读到 EOF 返 false 即干净收尾reachedEof=true
*
* @param string $filePath csv 文件绝对路径
* @param int $maxRows 单次产出的最大数据行数(表头/失败行不计)
*
* @return \Generator<array> 每次迭代返回一行(键值与 DebugLogToolkit::FIELDS 一致)
*
* @throws \RuntimeException 文件无法打开或 stat/fseek 失败时抛出(交由上层 try-catch
*/
public function readCsv(string $filePath, int $maxRows = 100000): \Generator
{
// per-run 状态复位(与 read() 同一套状态位CSV 路径 droppedLines 恒 0
$this->reachedEof = false;
$this->droppedLines = 0;
$this->currentLineHash = null;
$this->parseFails = 0;
$this->failSamples = [];
$fp = @fopen($filePath, 'rb');
if ($fp === false) {
throw new \RuntimeException("DebugLogReaderService: 无法打开文件 {$filePath}");
}
try {
clearstatcache(true, $filePath);
$stat = fstat($fp);
if ($stat === false) {
throw new \RuntimeException("DebugLogReaderService: fstat 失败 {$filePath}");
}
$currentInode = (int) $stat['ino'];
$currentSize = (int) $stat['size'];
$this->currentInode = $currentInode;
// 读 position无则默认 offset=0
$offset = 0;
$position = $this->getLastPosition($filePath);
if ($position !== null) {
$offset = (int) $position['offset'];
// 轮转检测inode 变化 OR size < offset防御性归零
if ((int) $position['inode'] !== $currentInode || $currentSize < $offset) {
$offset = 0;
}
}
$this->currentOffset = $offset;
if ($offset > 0 && fseek($fp, $offset) !== 0) {
throw new \RuntimeException("DebugLogReaderService: fseek 失败 offset={$offset} file={$filePath}");
}
// 仅从文件头offset==0开始读时才做表头判定续读起点不会是表头
$checkHeader = ($offset === 0);
$yielded = 0;
while (true) {
// 显式传全部参数separator/enclosure/escape 与遗留 fputcsv 写出参数一致
// PHP 8.4+ 省略 escape 会触发 deprecation显式传可前向兼容
$fields = fgetcsv($fp, null, ',', '"', '\\');
if ($fields === false) {
// fgetcsv 返 false 即 EOF干净收尾无半行概念
$this->reachedEof = true;
return;
}
// 每读出一条逻辑记录就把安全偏移推进到记录末尾(含引号内换行占的
// 多物理行)——表头/失败行同样推进(毒行不重读),保证事务内
// savePosition 与已消费记录严格对齐
$this->currentOffset = (int) ftell($fp);
if ($checkHeader) {
$checkHeader = false;
if (DebugLogToolkit::isCsvHeader($this->normalizeCsvFields($fields))) {
// 表头行跳过不产出offset 已推进)
continue;
}
}
if (count($fields) !== 8) {
$this->parseFails++;
$this->recordFailSample('csv-fields=' . count($fields) . ': ' . implode(',', $this->stringifyCsvFields($fields)));
continue;
}
$row = [];
foreach (DebugLogToolkit::FIELDS as $i => $field) {
$value = $fields[$i] ?? '';
// fgetcsv 对空字段可能返回 null统一落为 ''(与 JSONL 行的空串语义对齐)
$row[$field] = $value === null ? '' : $value;
}
// create_time 数字字符串转 int时间戳恒为非负整数title 保留字符串
if (ctype_digit((string) $row['create_time'])) {
$row['create_time'] = (int) $row['create_time'];
}
$this->currentLineHash = md5(serialize($fields));
yield $row;
$yielded++;
if ($yielded >= $maxRows) {
return;
}
}
} finally {
if (is_resource($fp)) {
fclose($fp);
}
}
}
/**
* 当前安全读取位置read/readCsv 期间/结束后可调).
*
* @return array{inode: int, offset: int, last_line_hash: string|null}
*/
public function getCurrentPosition(): array
{
return [
'inode' => $this->currentInode,
'offset' => $this->currentOffset,
'last_line_hash' => $this->currentLineHash,
];
}
/**
* 本轮读取是否干净到达 EOFJSONL尾部无半行CSVfgetcsv 返 false.
*
* 导入侧据此判断"文件已导完"reachedEof 且 offset >= filesize 才允许 unlink。
*/
public function reachedEof(): bool
{
return $this->reachedEof;
}
/**
* 本轮 read 丢弃的超长行数(由导入侧并入 fails 统计CSV 路径恒 0.
*/
public function getDroppedLines(): int
{
return $this->droppedLines;
}
/**
* 本轮读取解析失败计数JSONL 坏行 + CSV 字段数不符,由导入侧传给 savePosition.
*/
public function getParseFails(): int
{
return $this->parseFails;
}
/**
* 解析失败样本json 数组字符串或 null由导入侧传给 savePosition.
*
* 每条样本截断至 FAIL_SAMPLE_LENGTH 字符,最多 MAX_FAIL_SAMPLES 条。
*/
public function getFailSamples(): ?string
{
if ($this->failSamples === []) {
return null;
}
$json = json_encode($this->failSamples, JSON_UNESCAPED_UNICODE);
return $json === false ? null : $json;
}
/**
* 查询文件的上次读取位置(按 node_id 隔离).
*
* @return array|null 命中时返回 [inode, offset, last_line_hash, parse_fail_count, parse_fail_samples],无记录返回 null
*/
public function getLastPosition(string $filePath): ?array
{
$nodeId = HostService::getNodeId();
$row = DebugLogPosition::where('file_path', $filePath)
->where('node_id', $nodeId)
->find();
if ($row === null) {
return null;
}
return [
'inode' => (int) $row->getData('inode'),
'offset' => (int) $row->getData('offset'),
'last_line_hash' => $row->getData('last_line_hash'),
'parse_fail_count' => (int) $row->getData('parse_fail_count'),
'parse_fail_samples' => $row->getData('parse_fail_samples'),
];
}
/**
* 保存读取位置upsert按 node_id 隔离).
*
* 由导入任务在"insertBatch 同一事务"内显式调用(本类不自动保存),
* 保证崩溃时入库与进度要么同时生效、要么同时回滚,消重复导入窗口。
*
* @param string $filePath 日志文件绝对路径
* @param int $inode 当前文件 inode
* @param int $offset 新的字节偏移(最后一条已消费记录末尾)
* @param string|null $lastLineHash 最后一条已产出记录 md5
* @param int $failCount 本批解析失败累计次数
* @param string|null $failSamples 解析失败样本json 或 null
*/
public function savePosition(string $filePath, int $inode, int $offset, ?string $lastLineHash, int $failCount = 0, ?string $failSamples = null): void
{
$now = time();
$nodeId = HostService::getNodeId();
$existing = DebugLogPosition::where('file_path', $filePath)
->where('node_id', $nodeId)
->find();
if ($existing !== null) {
$existing->save([
'inode' => $inode,
'offset' => $offset,
'last_read_time' => $now,
'last_line_hash' => $lastLineHash,
'parse_fail_count' => $failCount,
'parse_fail_samples' => $failSamples,
]);
return;
}
DebugLogPosition::create([
'node_id' => $nodeId,
'file_path' => $filePath,
'inode' => $inode,
'offset' => $offset,
'last_read_time' => $now,
'last_line_hash' => $lastLineHash,
'parse_fail_count' => $failCount,
'parse_fail_samples' => $failSamples,
'create_time' => $now,
'update_time' => $now,
]);
}
/**
* 记录一条解析失败样本(截断至 FAIL_SAMPLE_LENGTH超量丢弃.
*/
protected function recordFailSample(string $raw): void
{
if (count($this->failSamples) >= self::MAX_FAIL_SAMPLES) {
return;
}
$this->failSamples[] = mb_substr($raw, 0, self::FAIL_SAMPLE_LENGTH);
}
/**
* CSV 字段数组归一化后交 isCsvHeader 判定null 字段落为 ''.
*
* @param array $fields fgetcsv 原始返回值
*/
protected function normalizeCsvFields(array $fields): array
{
return array_map(static function ($value) {
return $value === null ? '' : $value;
}, $fields);
}
/**
* CSV 字段数组字符串化失败样本拼接用null 落为空串占位).
*
* @param array $fields fgetcsv 原始返回值
*
* @return string[]
*/
protected function stringifyCsvFields(array $fields): array
{
return array_map(static function ($value) {
return $value === null ? '' : (string) $value;
}, $fields);
}
}