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:
Jaakko
2026-02-21 16:06:27 -05:00
committed by GitHub
co-authored by Claude Opus 4.5 Matthew Breedlove
parent 3abc41f122
commit ecd8d0b6cc
3 changed files with 419 additions and 2 deletions
+2 -2
View File
@@ -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
+291
View File
@@ -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();
});
});
+126
View File
@@ -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 {