231 lines · 7.0 KB · TypeScript
RawDownload
import {  existsSync,  mkdirSync,  readdirSync,  statSync,  unlinkSync,  openSync,  closeSync,  readSync,} from "node:fs";import { tmpdir } from "node:os";import { join, resolve } from "node:path"; const LOG_PREFIX = "vllm_";const LOG_SUFFIX = ".log";const FALLBACK_LOG_DIR = tmpdir(); export interface LogFileEntry {  sessionId: string;  path: string;  mtimeMs: number;  sizeBytes: number;  source: "data_dir" | "tmp";} export interface LogCleanupOptions {  maxAgeMs: number;  maxFiles: number;  maxTotalBytes: number;  excludePaths?: Set<string>;} export const getLogCleanupDefaultsFromEnvironment = (): Omit<LogCleanupOptions, "excludePaths"> => {  const clampInt = (value: number, min: number, max: number): number =>    Math.min(Math.max(value, min), max);  const parseIntOr = (raw: string | undefined, fallback: number): number => {    if (!raw) return fallback;    const n = Number.parseInt(raw, 10);    return Number.isFinite(n) ? n : fallback;  };   // 0 means "no cap" for size/files and "no age expiry" for days.  const days = parseIntOr(process.env["LOCAL_STUDIO_LOG_RETENTION_DAYS"], 30);  const maxFiles = parseIntOr(process.env["LOCAL_STUDIO_LOG_MAX_FILES"], 200);  const maxTotalBytes = parseIntOr(process.env["LOCAL_STUDIO_LOG_MAX_TOTAL_BYTES"], 1_000_000_000);   const maxAgeMs =    days <= 0 ? Number.POSITIVE_INFINITY : clampInt(days, 1, 3650) * 24 * 60 * 60 * 1000;   return {    maxAgeMs,    maxFiles: maxFiles <= 0 ? Number.MAX_SAFE_INTEGER : clampInt(maxFiles, 1, 100_000),    maxTotalBytes:      maxTotalBytes <= 0 ? Number.MAX_SAFE_INTEGER : Math.max(1_000_000, maxTotalBytes),  };}; export const sanitizeLogSessionId = (sessionId: string): string => {  const safe = Array.from(sessionId)    .filter((char) => /[a-zA-Z0-9._-]/.test(char))    .join("");  return safe;}; export const ensureLogsDirectory = (dataDirectory: string): string => {  const directory = resolve(dataDirectory, "logs");  mkdirSync(directory, { recursive: true });  return directory;}; export const primaryLogPathFor = (dataDirectory: string, sessionId: string): string => {  const safe = sanitizeLogSessionId(sessionId);  return join(ensureLogsDirectory(dataDirectory), `${LOG_PREFIX}${safe}${LOG_SUFFIX}`);}; export const fallbackLogPathFor = (sessionId: string): string => {  const safe = sanitizeLogSessionId(sessionId);  return join(FALLBACK_LOG_DIR, `${LOG_PREFIX}${safe}${LOG_SUFFIX}`);}; export const resolveExistingLogPath = (dataDirectory: string, sessionId: string): string | null => {  const primary = primaryLogPathFor(dataDirectory, sessionId);  if (existsSync(primary)) return primary;  const fallback = fallbackLogPathFor(sessionId);  if (existsSync(fallback)) return fallback;  return null;}; const scanLogDirectory = (directory: string, source: LogFileEntry["source"]): LogFileEntry[] => {  if (!existsSync(directory)) return [];  try {    return readdirSync(directory)      .filter((name) => name.startsWith(LOG_PREFIX) && name.endsWith(LOG_SUFFIX))      .map((name) => {        const path = join(directory, name);        const stat = statSync(path);        const sessionId = name          .replace(new RegExp(`^${LOG_PREFIX}`), "")          .replace(new RegExp(`${LOG_SUFFIX}$`), "");        return {          sessionId,          path,          mtimeMs: stat.mtimeMs,          sizeBytes: stat.size,          source,        } satisfies LogFileEntry;      });  } catch {    return [];  }}; export const listLogFiles = (dataDirectory: string): LogFileEntry[] => {  const primaryDirectory = resolve(dataDirectory, "logs");  const all = [    ...scanLogDirectory(primaryDirectory, "data_dir"),    ...scanLogDirectory(FALLBACK_LOG_DIR, "tmp"),  ];   // Deduplicate by session id, preferring the newest mtime.  const bySession = new Map<string, LogFileEntry>();  for (const entry of all) {    const existing = bySession.get(entry.sessionId);    if (!existing || entry.mtimeMs > existing.mtimeMs) {      bySession.set(entry.sessionId, entry);    }  }   return Array.from(bySession.values()).sort((a, b) => b.mtimeMs - a.mtimeMs);}; export const cleanupLogFiles = (  dataDirectory: string,  options: LogCleanupOptions,): { deleted: number } => {  const { maxAgeMs, maxFiles, maxTotalBytes, excludePaths } = options;  const now = Date.now();   const entries = [    ...scanLogDirectory(resolve(dataDirectory, "logs"), "data_dir"),    ...scanLogDirectory(FALLBACK_LOG_DIR, "tmp"),  ]    .filter((entry) => !(excludePaths && excludePaths.has(entry.path)))    .sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first   const shouldDeleteAge = (entry: LogFileEntry): boolean => now - entry.mtimeMs > maxAgeMs;   const deletedPaths: string[] = [];  const safeUnlink = (path: string): void => {    try {      unlinkSync(path);      deletedPaths.push(path);    } catch {      // Ignore races or permission issues; retention is best-effort.    }  };   // 1) Age-based retention.  for (const entry of entries) {    if (shouldDeleteAge(entry)) safeUnlink(entry.path);  }   // 2) Recompute after deletions.  const remaining = entries.filter((entry) => !deletedPaths.includes(entry.path));   // 3) File-count cap.  if (remaining.length > maxFiles) {    const overflow = remaining.length - maxFiles;    for (const entry of remaining.slice(0, overflow)) safeUnlink(entry.path);  }   // 4) Total-bytes cap.  const stillRemaining = remaining.filter((entry) => !deletedPaths.includes(entry.path));  let totalBytes = stillRemaining.reduce((sum, entry) => sum + entry.sizeBytes, 0);  if (totalBytes > maxTotalBytes) {    for (const entry of stillRemaining) {      if (totalBytes <= maxTotalBytes) break;      safeUnlink(entry.path);      totalBytes -= entry.sizeBytes;    }  }   return { deleted: deletedPaths.length };};  export const tailFileLines = (  path: string,  limit: number,  maxBytes = 10 * 1024 * 1024,): string[] => {  if (limit <= 0) return [];  if (!existsSync(path)) return [];   // Read from the end until we have enough newlines or hit maxBytes.  const fd = openSync(path, "r");  try {    const stat = statSync(path);    let pos = stat.size;    if (pos <= 0) return [];     const chunkSize = 64 * 1024;    const chunks: Buffer[] = [];    let bytesRead = 0;    let newlineCount = 0;     while (pos > 0 && newlineCount < limit + 1 && bytesRead < maxBytes) {      const readSize = Math.min(chunkSize, pos, maxBytes - bytesRead);      pos -= readSize;      const buf = Buffer.allocUnsafe(readSize);      const n = readSync(fd, buf, 0, readSize, pos);      const slice = buf.slice(0, n);      chunks.push(slice);      bytesRead += n;       // Count newlines in this chunk.      for (let index = 0; index < slice.length; index++) {        if (slice[index] === 0x0a) newlineCount += 1;      }    }     const text = Buffer.concat(chunks.reverse()).toString("utf-8");    const lines = text.split(/\r?\n/);    if (lines.length > 0 && lines[lines.length - 1] === "") {      lines.pop();    }    return lines.slice(Math.max(0, lines.length - limit));  } finally {    closeSync(fd);  }};