fix: one timezone per daily cache, re-derive settling days on seal (#1696)

Cache adoption keeps one timezone and prefers complete parses. Sealing a day re-derives the last seven, and a sealed day only takes a fresh parse with at least as many calls.
This commit is contained in:
Resham Joshi 2026-10-08 20:18:23 +02:00 • committed by GitHub
parent eb51aba46a
commit b875d9ca1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 134 additions and 16 deletions

View file

@ -798,7 +798,7 @@ async function adoptOlderDailyCaches(): Promise<DailyCache> {
} catch {
return emptyCache()
}
const candidates: { parsed: AdoptableCache; mtimeMs: number }[] = []
let candidates: { parsed: AdoptableCache; mtimeMs: number }[] = []
for (const name of names) {
if (!name.startsWith('daily-cache') || !name.includes('.json')) continue
if (name === DAILY_CACHE_FILENAME) continue
@ -815,9 +815,24 @@ async function adoptOlderDailyCaches(): Promise<DailyCache> {
}
}
if (candidates.length === 0) return emptyCache()
// Priority: newer schema first, then most recently written. Higher priority
// wins per (day, provider); lower priority only fills what is missing.
candidates.sort((a, b) => (b.parsed.version - a.parsed.version) || (b.mtimeMs - a.mtimeMs))
// Priority: files finalized by a complete parse first (an interrupted
// backfill can hold under-read days), then newer schema, then most recently
// written. Higher priority wins per (day, provider); lower priority only
// fills what is missing. Not "more calls wins": older generations hold both
// days a later re-derive truncated and calls a later dedup removed, and a
// call count cannot tell the two apart.
candidates.sort((a, b) => Number(b.parsed.complete === true) - Number(a.parsed.complete === true) || (b.parsed.version - a.parsed.version) || (b.mtimeMs - a.mtimeMs))
// A date is a local-midnight bucket, so two files written under different
// timezones hold different hours under the same date and any per-slice union
// of them counts the hours between the two midnights twice. Adopt one
// timezone only: the machine's when some file has it, else the top file's,
// and tag the result with the zone it was bucketed in. Files from before
// tzKey existed cannot be told apart and stay in.
const machineTz = currentTzKey()
const adoptTz = candidates.some(c => c.parsed.tzKey === machineTz)
? machineTz
: candidates.find(c => c.parsed.tzKey !== undefined)?.parsed.tzKey ?? machineTz
candidates = candidates.filter(c => c.parsed.tzKey === undefined || c.parsed.tzKey === adoptTz)
let base: DailyCache
let rest = candidates
@ -848,6 +863,7 @@ async function adoptOlderDailyCaches(): Promise<DailyCache> {
}
const adopted: DailyCache = {
...base,
tzKey: adoptTz,
lastComputedDate,
days,
// Anything adopted out of an OLDER file was derived under an older
@ -1413,9 +1429,14 @@ export function mergeDayEntries(
/// a slice it could not produce at all is carried by the branch above,
/// exactly as before.
pendingRederive?: ReadonlySet<string>,
/// Days before this date count as settled for the guard even inside the
/// settle window: the gap path passes its first unsealed day, so a day it
/// re-derives after sealing it before can grow or re-price but never shrink.
sealedBefore?: string,
): DailyEntry[] {
const byDate = new Map<string, DailyEntry>()
const settleCutoff = settleCutoffDate(new Date())
const windowCutoff = settleCutoffDate(new Date())
const settleCutoff = sealedBefore && sealedBefore > windowCutoff ? sealedBefore : windowCutoff
for (const day of primary) byDate.set(day.date, structuredClone(day))
for (const day of secondary) {
const existing = byDate.get(day.date)
@ -1784,7 +1805,16 @@ export async function ensureCacheHydrated(
: new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)
if (gapStart.getTime() <= yesterdayEnd.getTime()) {
const gapRange: DateRange = { start: gapStart, end: yesterdayEnd }
// Sealing a new day also re-derives the still-settling ones before it. A
// day sealed once is otherwise frozen, while every surface that parses
// that date live (report --day, a week period) prefers the live parse
// whenever it finds more calls: a call that landed after the seal, or a
// price change since, then made history.daily disagree with report for
// the same date. The merge below lets the fresh parse win there when it
// explains at least as many calls; a sealed day never shrinks, since a
// transcript cleaned up or a partial provider sync looks the same.
const settleStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - SETTLE_DAYS)
const gapRange: DateRange = { start: settleStart < gapStart ? settleStart : gapStart, end: yesterdayEnd }
const gapProjects = await parseSessions(gapRange)
const gapDays = daysInRange(aggregateDays(gapProjects), gapRange)
const parseWasComplete = sessionComplete()
@ -1798,7 +1828,7 @@ export async function ensureCacheHydrated(
// partial one only fills days and slices the baseline lacks, so the gap
// merge is strictly additive and no cached data can shrink.
const merged = parseWasComplete
? mergeDayEntries(gapDays, c.days, false, undefined, true)
? mergeDayEntries(gapDays, c.days, false, undefined, true, undefined, toDateString(gapStart))
: mergeDayEntries(c.days, gapDays, false)
// Finalize as complete ONLY when the session parse that produced these days
// was itself complete. If it was partial, leave `complete: false` so the

View file

@ -449,6 +449,46 @@ describe('adoption union across older cache files', () => {
expect(JSON.parse(await readFile(join(TMP_CACHE_ROOT, 'daily-cache.json.v9.bak'), 'utf-8'))).toEqual(JSON.parse(JSON.stringify(v9bak)))
})
it('never mixes days from a file written under another timezone', async () => {
const foreignTz = currentTzKey() === 'UTC' ? 'America/Los_Angeles' : 'UTC'
const local = {
version: 27, tzKey: currentTzKey(), complete: true, lastComputedDate: '2026-07-28',
days: [day('2026-07-27', { claude: slice(307.02, 900) })],
}
const foreign = {
version: 28, tzKey: foreignTz, complete: true, lastComputedDate: '2026-07-28',
days: [day('2026-07-27', { claude: slice(625.71, 1500) }), day('2026-07-28', { codex: slice(40, 30) })],
}
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v27.json'), JSON.stringify(local), 'utf-8')
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v28.json'), JSON.stringify(foreign), 'utf-8')
const cache = await loadDailyCache()
expect(cache.days).toHaveLength(1)
expect(cache.days[0]).toMatchObject({ date: '2026-07-27', cost: 307.02, calls: 900 })
expect(cache.tzKey).toBe(currentTzKey())
})
it('adopts a foreign-timezone history whole and tagged when it is all there is', async () => {
const foreignTz = currentTzKey() === 'UTC' ? 'America/Los_Angeles' : 'UTC'
const onlyFile = {
version: 28, tzKey: foreignTz, complete: true, lastComputedDate: '2026-07-28',
days: [day('2026-07-27', { claude: slice(625.71, 1500) })],
}
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v28.json'), JSON.stringify(onlyFile), 'utf-8')
const cache = await loadDailyCache()
expect(cache.days[0]).toMatchObject({ date: '2026-07-27', cost: 625.71 })
// Tagged with the zone it was bucketed in, so hydration re-buckets it.
expect(cache.tzKey).toBe(foreignTz)
})
it('a file finalized by a complete parse outranks a newer interrupted one', async () => {
const complete = { version: 27, complete: true, days: [day('2026-07-27', { claude: slice(300, 900) })] }
const interrupted = { version: 28, complete: false, days: [day('2026-07-27', { claude: slice(20, 40) })] }
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v27.json'), JSON.stringify(complete), 'utf-8')
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v28.json'), JSON.stringify(interrupted), 'utf-8')
const cache = await loadDailyCache()
expect(cache.days[0]).toMatchObject({ cost: 300, calls: 900 })
})
it('skips malformed candidates without failing the adoption', async () => {
const good = {
version: 12,

View file

@ -263,10 +263,10 @@ describe('daily cache: a residue-only day is re-derived, not served', () => {
'cfg-A',
() => true,
)
// The watermark moved back to daysAgoStr(4), so the gap parse started on
// the residue day itself instead of skipping it forever.
// The watermark moved back to daysAgoStr(4), so the gap parse covers the
// residue day instead of skipping it forever.
expect(ranges).toHaveLength(1)
expect(toDateString(ranges[0]!.start)).toBe(daysAgoStr(3))
expect(toDateString(ranges[0]!.start) <= daysAgoStr(3)).toBe(true)
const healed = out.days.find(d => d.date === daysAgoStr(3))!
expect(healed.providers['claude']).toMatchObject({ cost: 33, calls: 210 })
expect(healed.cost).toBe(33)
@ -277,22 +277,70 @@ describe('daily cache: a residue-only day is re-derived, not served', () => {
})
})
describe('daily cache: sealing a day re-derives the still-settling ones', () => {
it('a day sealed before a late call or a re-price reads the same as a live parse of it', async () => {
// The field case: Oct 4 sealed at $376.71 / 5563 calls; a live parse of it
// later finds 5564 calls at a new price ($356.17). report --day used the
// live parse, history.daily the sealed day.
await seed({
lastComputedDate: daysAgoStr(2),
watermarkTrusted: true,
days: [day(daysAgoStr(10), { claude: slice(80, 700) }), day(daysAgoStr(4), { claude: slice(376.71, 5563) })],
})
const out = await ensureCacheHydrated(
async () => [],
() => [day(daysAgoStr(4), { claude: slice(356.17, 5564) }), day(daysAgoStr(1), { claude: slice(10, 50) })],
'cfg-A',
() => true,
)
expect(out.days.find(d => d.date === daysAgoStr(4))).toMatchObject({ cost: 356.17, calls: 5564 })
expect(out.days.find(d => d.date === daysAgoStr(1))).toMatchObject({ cost: 10 })
expect(out.days.find(d => d.date === daysAgoStr(10))).toMatchObject({ cost: 80, calls: 700 })
})
})
describe('daily cache: a re-derived sealed day never shrinks', () => {
it('keeps a sealed day whose fresh parse lost calls, re-prices one with the same calls', async () => {
await seed({
lastComputedDate: daysAgoStr(2),
watermarkTrusted: true,
days: [
day(daysAgoStr(4), { claude: slice(200, 1000) }),
day(daysAgoStr(3), { claude: slice(376.71, 5563) }),
],
})
const out = await ensureCacheHydrated(
async () => [],
() => [
day(daysAgoStr(4), { claude: slice(15, 40) }),
day(daysAgoStr(3), { claude: slice(356.17, 5563) }),
day(daysAgoStr(1), { claude: slice(10, 50) }),
],
'cfg-A',
() => true,
)
expect(out.days.find(d => d.date === daysAgoStr(4))).toMatchObject({ cost: 200, calls: 1000 })
expect(out.days.find(d => d.date === daysAgoStr(3))).toMatchObject({ cost: 356.17, calls: 5563 })
expect(out.days.find(d => d.date === daysAgoStr(1))).toMatchObject({ cost: 10 })
})
})
describe('daily cache: out-of-range residue days never reach the gap merge', () => {
it('a straddling turn anchored before the gap leaves the cached day untouched', async () => {
// Issue #1130: the gap range starts on D+1, but a turn anchored on day D
// survives range slicing whole, so the aggregator emits a residue day for
// D — outside the parsed range. Previously the merge guard had to defuse
// it; now the range filter drops it before the merge runs at all.
const populatedD = day(daysAgoStr(4), { claude: slice(120, 900) })
await seed({ days: [VANISHED, populatedD] })
const before = (await loadDailyCache()).days.find(d => d.date === daysAgoStr(4))!
const residue = residueDay(daysAgoStr(4))
const populatedD = day(daysAgoStr(9), { claude: slice(120, 900) })
await seed({ days: [VANISHED, populatedD], lastComputedDate: daysAgoStr(9) })
const before = (await loadDailyCache()).days.find(d => d.date === daysAgoStr(9))!
const residue = residueDay(daysAgoStr(9))
const inRange = day(daysAgoStr(2), { claude: slice(30, 300) })
const out = await ensureCacheHydrated(noSessions, () => [residue, inRange], 'cfg-A', () => true)
// Day D persisted untouched: no residue categories leaked in.
const kept = out.days.find(d => d.date === daysAgoStr(4))!
const kept = out.days.find(d => d.date === daysAgoStr(9))!
expect(kept).toEqual(before)
expect(out.days.map(d => d.date)).toEqual([daysAgoStr(40), daysAgoStr(4), daysAgoStr(2)])
expect(out.days.map(d => d.date)).toEqual([daysAgoStr(40), daysAgoStr(9), daysAgoStr(2)])
expect(out.lastComputedDate).toBe(daysAgoStr(1))
expect(out.complete).toBe(true)
})