mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-07-17 00:16:42 +00:00
perf: cache block timer metrics to reduce I/O (#161)
* perf: cache block timer metrics to reduce I/O Cache block start time to ~/.cache/ccstatusline/block-cache.json to avoid parsing all JSONL files on every status line render. Cache is invalidated when the 5-hour block expires. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use hashed per-config block cache files --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
Matthew Breedlove
parent
3abc41f122
commit
ecd8d0b6cc
+2
-2
@@ -15,7 +15,7 @@ import {
|
||||
saveSettings
|
||||
} from './utils/config';
|
||||
import {
|
||||
getBlockMetrics,
|
||||
getCachedBlockMetrics,
|
||||
getSessionDuration,
|
||||
getTokenMetrics
|
||||
} from './utils/jsonl';
|
||||
@@ -100,7 +100,7 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
|
||||
let blockMetrics: BlockMetrics | null = null;
|
||||
if (hasBlockTimer) {
|
||||
blockMetrics = getBlockMetrics();
|
||||
blockMetrics = getCachedBlockMetrics();
|
||||
}
|
||||
|
||||
// Create render context
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import * as fs from 'fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
getBlockCachePath,
|
||||
readBlockCache,
|
||||
writeBlockCache
|
||||
} from '../jsonl';
|
||||
|
||||
function getExpectedCachePath(homeDir: string, configDir: string): string {
|
||||
const normalizedConfigDir = path.resolve(configDir);
|
||||
const configHash = createHash('sha256')
|
||||
.update(normalizedConfigDir)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
|
||||
return path.join(homeDir, '.cache', 'ccstatusline', `block-cache-${configHash}.json`);
|
||||
}
|
||||
|
||||
describe('Block Cache Functions', () => {
|
||||
let tempDir: string;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a temp directory for test isolation
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-test-'));
|
||||
// Mock os.homedir to use temp directory
|
||||
vi.spyOn(os, 'homedir').mockReturnValue(tempDir);
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, '.claude-default');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalClaudeConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
}
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('getBlockCachePath', () => {
|
||||
it('should return the correct cache path', () => {
|
||||
const cachePath = getBlockCachePath();
|
||||
expect(cachePath).toBe(getExpectedCachePath(tempDir, path.join(tempDir, '.claude-default')));
|
||||
});
|
||||
|
||||
it('should return different cache paths for different config directories', () => {
|
||||
const profileA = path.join(tempDir, '.claude-profile-a');
|
||||
const profileB = path.join(tempDir, '.claude-profile-b');
|
||||
|
||||
const pathA = getBlockCachePath(profileA);
|
||||
const pathB = getBlockCachePath(profileB);
|
||||
|
||||
expect(pathA).toBe(getExpectedCachePath(tempDir, profileA));
|
||||
expect(pathB).toBe(getExpectedCachePath(tempDir, profileB));
|
||||
expect(pathA).not.toBe(pathB);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readBlockCache', () => {
|
||||
it('should return null when cache file does not exist', () => {
|
||||
const result = readBlockCache();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return cached date when cache file is valid', () => {
|
||||
const testDate = new Date('2025-01-26T14:00:00.000Z');
|
||||
const cachePath = getBlockCachePath();
|
||||
const cacheDir = path.dirname(cachePath);
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.writeFileSync(cachePath, JSON.stringify({ startTime: testDate.toISOString() }));
|
||||
|
||||
const result = readBlockCache();
|
||||
expect(result).toEqual(testDate);
|
||||
});
|
||||
|
||||
it('should return cached date when configDir matches expected value', () => {
|
||||
const testDate = new Date('2025-01-26T14:00:00.000Z');
|
||||
const configDir = path.join(tempDir, '.claude-profile-a');
|
||||
const cachePath = getBlockCachePath(configDir);
|
||||
const cacheDir = path.dirname(cachePath);
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({ startTime: testDate.toISOString(), configDir })
|
||||
);
|
||||
|
||||
const result = readBlockCache(configDir);
|
||||
expect(result).toEqual(testDate);
|
||||
});
|
||||
|
||||
it('should return null when cache configDir does not match expected value', () => {
|
||||
const testDate = new Date('2025-01-26T14:00:00.000Z');
|
||||
const expectedConfigDir = path.join(tempDir, '.claude-profile-b');
|
||||
const cachePath = getBlockCachePath(expectedConfigDir);
|
||||
const cacheDir = path.dirname(cachePath);
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({
|
||||
startTime: testDate.toISOString(),
|
||||
configDir: path.join(tempDir, '.claude-profile-a')
|
||||
})
|
||||
);
|
||||
|
||||
const result = readBlockCache(expectedConfigDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when expected configDir is provided but cache has legacy schema', () => {
|
||||
const testDate = new Date('2025-01-26T14:00:00.000Z');
|
||||
const expectedConfigDir = path.join(tempDir, '.claude-profile-a');
|
||||
const cachePath = getBlockCachePath(expectedConfigDir);
|
||||
const cacheDir = path.dirname(cachePath);
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.writeFileSync(cachePath, JSON.stringify({ startTime: testDate.toISOString() }));
|
||||
|
||||
const result = readBlockCache(expectedConfigDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when cache file has invalid JSON', () => {
|
||||
const cachePath = getBlockCachePath();
|
||||
const cacheDir = path.dirname(cachePath);
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.writeFileSync(cachePath, 'not valid json');
|
||||
|
||||
const result = readBlockCache();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when cache file has missing startTime', () => {
|
||||
const cachePath = getBlockCachePath();
|
||||
const cacheDir = path.dirname(cachePath);
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.writeFileSync(cachePath, JSON.stringify({}));
|
||||
|
||||
const result = readBlockCache();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when startTime is not a string', () => {
|
||||
const cachePath = getBlockCachePath();
|
||||
const cacheDir = path.dirname(cachePath);
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.writeFileSync(cachePath, JSON.stringify({ startTime: 12345 }));
|
||||
|
||||
const result = readBlockCache();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when startTime is invalid date string', () => {
|
||||
const cachePath = getBlockCachePath();
|
||||
const cacheDir = path.dirname(cachePath);
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.writeFileSync(cachePath, JSON.stringify({ startTime: 'not a date' }));
|
||||
|
||||
const result = readBlockCache();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeBlockCache', () => {
|
||||
it('should create directory and write cache file', () => {
|
||||
const testDate = new Date('2025-01-26T14:00:00.000Z');
|
||||
const configDir = path.join(tempDir, '.claude-profile-a');
|
||||
|
||||
writeBlockCache(testDate, configDir);
|
||||
|
||||
const cachePath = getBlockCachePath(configDir);
|
||||
expect(fs.existsSync(cachePath)).toBe(true);
|
||||
const content = fs.readFileSync(cachePath, 'utf-8');
|
||||
const parsed = JSON.parse(content) as { startTime: string; configDir: string };
|
||||
expect(parsed.startTime).toBe(testDate.toISOString());
|
||||
expect(parsed.configDir).toBe(path.resolve(configDir));
|
||||
});
|
||||
|
||||
it('should overwrite existing cache file', () => {
|
||||
const firstDate = new Date('2025-01-26T14:00:00.000Z');
|
||||
const secondDate = new Date('2025-01-26T16:00:00.000Z');
|
||||
const configDir = path.join(tempDir, '.claude-profile-a');
|
||||
|
||||
writeBlockCache(firstDate, configDir);
|
||||
writeBlockCache(secondDate, configDir);
|
||||
|
||||
const cachePath = getBlockCachePath(configDir);
|
||||
const content = fs.readFileSync(cachePath, 'utf-8');
|
||||
const parsed = JSON.parse(content) as { startTime: string; configDir: string };
|
||||
expect(parsed.startTime).toBe(secondDate.toISOString());
|
||||
expect(parsed.configDir).toBe(path.resolve(configDir));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCachedBlockMetrics integration', () => {
|
||||
let tempDir: string;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-test-'));
|
||||
vi.spyOn(os, 'homedir').mockReturnValue(tempDir);
|
||||
// Mock CLAUDE_CONFIG_DIR to point to a non-existent directory
|
||||
// This ensures getBlockMetrics returns null when cache is expired
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, '.claude-nonexistent');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// Restore original CLAUDE_CONFIG_DIR
|
||||
if (originalClaudeConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
}
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should return cached result when cache is within block duration', async () => {
|
||||
const { getCachedBlockMetrics, writeBlockCache, getBlockCachePath } = await import('../jsonl');
|
||||
|
||||
// Set up a cache with a start time 2 hours ago (within 5-hour block)
|
||||
const testStartTime = new Date();
|
||||
testStartTime.setHours(testStartTime.getHours() - 2);
|
||||
|
||||
writeBlockCache(testStartTime);
|
||||
|
||||
// Verify cache was written
|
||||
expect(fs.existsSync(getBlockCachePath())).toBe(true);
|
||||
|
||||
const result = getCachedBlockMetrics();
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.startTime.getTime()).toBe(testStartTime.getTime());
|
||||
});
|
||||
|
||||
it('should recalculate when cache is expired (beyond block duration)', async () => {
|
||||
const { getCachedBlockMetrics, writeBlockCache } = await import('../jsonl');
|
||||
|
||||
// Set up a cache with a start time 6 hours ago (beyond 5-hour block)
|
||||
const expiredStartTime = new Date();
|
||||
expiredStartTime.setHours(expiredStartTime.getHours() - 6);
|
||||
|
||||
writeBlockCache(expiredStartTime);
|
||||
|
||||
const result = getCachedBlockMetrics();
|
||||
|
||||
// Should return null because cache is expired and no real JSONL files exist
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should recalculate when no cache exists', async () => {
|
||||
const { getCachedBlockMetrics } = await import('../jsonl');
|
||||
|
||||
const result = getCachedBlockMetrics();
|
||||
|
||||
// Should return null because no cache and no real JSONL files exist
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should recalculate when cache belongs to a different config directory', async () => {
|
||||
const { getCachedBlockMetrics, writeBlockCache } = await import('../jsonl');
|
||||
const profileA = path.join(tempDir, '.claude-profile-a');
|
||||
const profileB = path.join(tempDir, '.claude-profile-b');
|
||||
|
||||
const testStartTime = new Date();
|
||||
testStartTime.setHours(testStartTime.getHours() - 2);
|
||||
|
||||
process.env.CLAUDE_CONFIG_DIR = profileA;
|
||||
writeBlockCache(testStartTime);
|
||||
|
||||
process.env.CLAUDE_CONFIG_DIR = profileB;
|
||||
const result = getCachedBlockMetrics();
|
||||
|
||||
// Should return null because cache is fresh but scoped to a different profile
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import * as fs from 'fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { globSync } from 'tinyglobby';
|
||||
import { promisify } from 'util';
|
||||
@@ -15,6 +17,130 @@ import { getClaudeConfigDir } from './claude-settings';
|
||||
const readFile = promisify(fs.readFile);
|
||||
const readFileSync = fs.readFileSync;
|
||||
const statSync = fs.statSync;
|
||||
const writeFileSync = fs.writeFileSync;
|
||||
const mkdirSync = fs.mkdirSync;
|
||||
const existsSync = fs.existsSync;
|
||||
|
||||
// --- Block Cache Functions ---
|
||||
|
||||
interface BlockCache {
|
||||
startTime: string;
|
||||
configDir?: string;
|
||||
}
|
||||
|
||||
function normalizeConfigDir(configDir: string): string {
|
||||
return path.resolve(configDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the block cache file for a specific Claude config directory
|
||||
*/
|
||||
export function getBlockCachePath(configDir = getClaudeConfigDir()): string {
|
||||
const normalizedConfigDir = normalizeConfigDir(configDir);
|
||||
const configHash = createHash('sha256')
|
||||
.update(normalizedConfigDir)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
|
||||
return path.join(
|
||||
os.homedir(),
|
||||
'.cache',
|
||||
'ccstatusline',
|
||||
`block-cache-${configHash}.json`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the block cache file and returns the cached start time
|
||||
* Returns null if cache doesn't exist or is invalid
|
||||
*/
|
||||
export function readBlockCache(expectedConfigDir?: string): Date | null {
|
||||
try {
|
||||
const normalizedExpectedConfigDir = expectedConfigDir !== undefined
|
||||
? normalizeConfigDir(expectedConfigDir)
|
||||
: undefined;
|
||||
const cachePath = getBlockCachePath(normalizedExpectedConfigDir);
|
||||
if (!existsSync(cachePath)) {
|
||||
return null;
|
||||
}
|
||||
const content = readFileSync(cachePath, 'utf-8');
|
||||
const cache = JSON.parse(content) as BlockCache;
|
||||
if (typeof cache.startTime !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (normalizedExpectedConfigDir !== undefined) {
|
||||
if (typeof cache.configDir !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (cache.configDir !== normalizedExpectedConfigDir) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const date = new Date(cache.startTime);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the block start time to the cache file
|
||||
* Creates the cache directory if it doesn't exist
|
||||
*/
|
||||
export function writeBlockCache(startTime: Date, configDir = getClaudeConfigDir()): void {
|
||||
try {
|
||||
const normalizedConfigDir = normalizeConfigDir(configDir);
|
||||
const cachePath = getBlockCachePath(normalizedConfigDir);
|
||||
const cacheDir = path.dirname(cachePath);
|
||||
if (!existsSync(cacheDir)) {
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
const cache: BlockCache = {
|
||||
startTime: startTime.toISOString(),
|
||||
configDir: normalizedConfigDir
|
||||
};
|
||||
writeFileSync(cachePath, JSON.stringify(cache), 'utf-8');
|
||||
} catch {
|
||||
// Silently fail - caching is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets block metrics with caching support
|
||||
* Returns cached result if still valid, otherwise recalculates
|
||||
*/
|
||||
export function getCachedBlockMetrics(sessionDurationHours = 5): BlockMetrics | null {
|
||||
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
const activeConfigDir = getClaudeConfigDir();
|
||||
|
||||
// Check cache first
|
||||
const cachedStartTime = readBlockCache(activeConfigDir);
|
||||
if (cachedStartTime) {
|
||||
const blockEndTime = new Date(cachedStartTime.getTime() + sessionDurationMs);
|
||||
if (now.getTime() <= blockEndTime.getTime()) {
|
||||
// Cache is valid - return cached result
|
||||
return {
|
||||
startTime: cachedStartTime,
|
||||
lastActivity: now // We don't cache lastActivity, use current time
|
||||
};
|
||||
}
|
||||
// Cache expired - need to recalculate
|
||||
}
|
||||
|
||||
// Cache miss or expired - run full calculation
|
||||
const metrics = getBlockMetrics();
|
||||
|
||||
// Write to cache if we found a valid block
|
||||
if (metrics) {
|
||||
writeBlockCache(metrics.startTime, activeConfigDir);
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
export async function getSessionDuration(transcriptPath: string): Promise<string | null> {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user