Read chat logs incrementally and drain retention (Refs #62)
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { open, stat } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { rootOfServer, gameOfServer } from '../config.js';
|
||||
import { nativeTail } from '../backends/files.js';
|
||||
import { nativeTail, nativeLogChunk } from '../backends/files.js';
|
||||
|
||||
// Whitelisted log files exposed to the UI, mapped to their path relative
|
||||
// to each server's root directory. Adding a new supported log = one entry
|
||||
@@ -48,6 +49,48 @@ export interface TailResult {
|
||||
truncated: boolean; // true when we didn't read from the start of the file
|
||||
}
|
||||
|
||||
export interface LogCursor {
|
||||
fileId: string; offset: number; size: number; modified: string;
|
||||
anchorLength: number; anchorHash: string;
|
||||
}
|
||||
export interface LogChunk {
|
||||
contentBase64: string; size: number; mtime: string; truncated: boolean;
|
||||
cursor: LogCursor; reset: boolean;
|
||||
}
|
||||
|
||||
/** Bounded byte reads; an anchor before the offset detects copy-truncate and regrowth. */
|
||||
export async function readLogChunk(path: string, cursor?: LogCursor): Promise<LogChunk | TailResult | null> {
|
||||
const remote = await nativeLogChunk(path, cursor);
|
||||
if (remote !== undefined) return remote;
|
||||
let file;
|
||||
try { file = await open(path, 'r'); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error; }
|
||||
try {
|
||||
const st = await file.stat();
|
||||
if (!st.isFile()) return null;
|
||||
const fileId = `${st.dev}:${st.ino}`, modified = `${st.mtimeMs}:${st.ctimeMs}`, size = st.size;
|
||||
const mtime = st.mtime.toISOString();
|
||||
if (cursor?.fileId === fileId && cursor.modified === modified && cursor.size === size && cursor.offset === size) {
|
||||
return { contentBase64: '', size, mtime, truncated: cursor.offset > 0, cursor, reset: false };
|
||||
}
|
||||
const hash = (value: Buffer) => createHash('sha256').update(value).digest('hex');
|
||||
let reset = !cursor || cursor.fileId !== fileId || cursor.offset > size;
|
||||
if (cursor && !reset) {
|
||||
const anchor = Buffer.alloc(cursor.anchorLength);
|
||||
const read = await file.read(anchor, 0, anchor.length, cursor.offset - anchor.length);
|
||||
reset = read.bytesRead !== anchor.length || hash(anchor) !== cursor.anchorHash;
|
||||
}
|
||||
const start = reset ? Math.max(0, size - READ_WINDOW_BYTES) : cursor!.offset;
|
||||
const content = Buffer.alloc(Math.min(READ_WINDOW_BYTES, size - start));
|
||||
const read = await file.read(content, 0, content.length, start);
|
||||
const offset = start + read.bytesRead;
|
||||
const anchor = Buffer.alloc(Math.min(256, offset));
|
||||
await file.read(anchor, 0, anchor.length, offset - anchor.length);
|
||||
return { contentBase64: content.subarray(0, read.bytesRead).toString('base64'), size, mtime, truncated: start > 0, reset,
|
||||
cursor: { fileId, modified, size, offset, anchorLength: anchor.length, anchorHash: hash(anchor) } };
|
||||
} finally { await file.close(); }
|
||||
}
|
||||
|
||||
const DEFAULT_TAIL_LINES = 200;
|
||||
const MAX_TAIL_LINES = 2000;
|
||||
const READ_WINDOW_BYTES = 256 * 1024; // 256 KB — plenty for a few hundred lines
|
||||
|
||||
Reference in New Issue
Block a user