From 345c4e2be118dbad08f9da0514b6cc5e6ebb133f Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:29:47 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9A=A1=20Bolt:=20=ED=95=AB=20=ED=8C=A8?= =?UTF-8?q?=EC=8A=A4=20=EA=B0=9D=EC=B2=B4=20=EC=88=9C=ED=9A=8C=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94=20(Object.keys()=20=EC=A0=9C=EA=B1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/bolt.md | 3 ++ packages/web/src/lib/server/daily-rollup.ts | 30 ++++++++++++-------- packages/web/src/lib/server/weekly-report.ts | 14 +++++---- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index 57daf471..91e41054 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -3,3 +3,6 @@ **Learning:** `Date.parse(value)` returns the timestamp primitive directly, while `new Date(value).getTime()` also constructs a `Date` object. Both use the same ECMAScript string-parsing semantics for these call sites. **Action:** In frequently executed paths that only need a timestamp primitive, prefer `Date.parse(value)`. Treat the allocation reduction as a bounded micro-optimization unless a committed benchmark establishes a larger runtime effect. +## 2024-05-24 - [핫 패스 객체 순회 최적화: for...in 활용] +**Learning:** Object.keys()나 Object.entries()는 배열을 할당하므로, 잦은 호출이나 대규모 데이터 집계 같은 핫 패스에서는 GC(Garbage Collection) 오버헤드를 발생시킵니다. +**Action:** 극단적인 성능 최적화가 필요한 핫 패스에서는 Object.keys() 대신 for...in 루프를 사용하고, 프로토타입 오염을 방지하기 위해 반드시 if (Object.hasOwn(obj, key))로 가드합니다. diff --git a/packages/web/src/lib/server/daily-rollup.ts b/packages/web/src/lib/server/daily-rollup.ts index 44fb4e5c..75a2bb60 100644 --- a/packages/web/src/lib/server/daily-rollup.ts +++ b/packages/web/src/lib/server/daily-rollup.ts @@ -438,16 +438,22 @@ export async function getDailyRollupsForProjects( const userSet = userSetsByDate.get(r.date)! for (const u of r.activeUserIds) userSet.add(u) - // [Bolt: Performance Optimization] Use Object.keys() instead of Object.entries() in hot paths. - // Impact: Avoids array allocation for each key-value pair, significantly reducing GC overhead when aggregating large daily rollups. - for (const k of Object.keys(r.skillCounts)) { - prev.skillCounts[k] = (prev.skillCounts[k] ?? 0) + r.skillCounts[k]! + // [Bolt: Performance Optimization] Use for...in instead of Object.keys() in hot paths. + // Impact: Completely avoids array allocation for keys, significantly reducing GC overhead when aggregating large daily rollups. + for (const k in r.skillCounts) { + if (Object.hasOwn(r.skillCounts, k)) { + prev.skillCounts[k] = (prev.skillCounts[k] ?? 0) + r.skillCounts[k]! + } } - for (const k of Object.keys(r.agentCounts)) { - prev.agentCounts[k] = (prev.agentCounts[k] ?? 0) + r.agentCounts[k]! + for (const k in r.agentCounts) { + if (Object.hasOwn(r.agentCounts, k)) { + prev.agentCounts[k] = (prev.agentCounts[k] ?? 0) + r.agentCounts[k]! + } } - for (const k of Object.keys(r.modelTokens)) { - prev.modelTokens[k] = (prev.modelTokens[k] ?? 0) + r.modelTokens[k]! + for (const k in r.modelTokens) { + if (Object.hasOwn(r.modelTokens, k)) { + prev.modelTokens[k] = (prev.modelTokens[k] ?? 0) + r.modelTokens[k]! + } } // userStats: userId 기준 sum (지연된 Map 변환) @@ -622,10 +628,10 @@ export function aggregateSummary( totals.cacheCreationTokens += r.cacheCreationTokens totals.estimatedCostUsd += r.estimatedCostUsd for (const u of r.activeUserIds) activeUsers.add(u) - // [Bolt: Performance Optimization] Object.keys() iterations avoid internal array tuples, reducing heap thrashing - for (const k of Object.keys(r.skillCounts)) skillCounts[k] = (skillCounts[k] ?? 0) + r.skillCounts[k]! - for (const k of Object.keys(r.agentCounts)) agentCounts[k] = (agentCounts[k] ?? 0) + r.agentCounts[k]! - for (const k of Object.keys(r.modelTokens)) modelTokens[k] = (modelTokens[k] ?? 0) + r.modelTokens[k]! + // [Bolt: Performance Optimization] for...in iterations avoid array allocations entirely, reducing heap thrashing + for (const k in r.skillCounts) if (Object.hasOwn(r.skillCounts, k)) skillCounts[k] = (skillCounts[k] ?? 0) + r.skillCounts[k]! + for (const k in r.agentCounts) if (Object.hasOwn(r.agentCounts, k)) agentCounts[k] = (agentCounts[k] ?? 0) + r.agentCounts[k]! + for (const k in r.modelTokens) if (Object.hasOwn(r.modelTokens, k)) modelTokens[k] = (modelTokens[k] ?? 0) + r.modelTokens[k]! } // Deterministic tie-break: callCount DESC, skillName ASC (codepoint binary — diff --git a/packages/web/src/lib/server/weekly-report.ts b/packages/web/src/lib/server/weekly-report.ts index eb95d36f..710b03dc 100644 --- a/packages/web/src/lib/server/weekly-report.ts +++ b/packages/web/src/lib/server/weekly-report.ts @@ -401,12 +401,16 @@ export async function getWeeklyReport( const distinctSkillsThisWeek = new Set() for (const r of thisWeekRollups) { - for (const k of Object.keys(r.agentCounts)) { - totalAgentCalls += r.agentCounts[k] + for (const k in r.agentCounts) { + if (Object.hasOwn(r.agentCounts, k)) { + totalAgentCalls += r.agentCounts[k] + } } - for (const k of Object.keys(r.skillCounts)) { - totalSkillCalls += r.skillCounts[k] - distinctSkillsThisWeek.add(k) + for (const k in r.skillCounts) { + if (Object.hasOwn(r.skillCounts, k)) { + totalSkillCalls += r.skillCounts[k] + distinctSkillsThisWeek.add(k) + } } } From e10d3641ec523a981a6efe01bf8feca9c4c52787 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:03:18 +0900 Subject: [PATCH 2/3] chore(perf): restore benchmark-bounded Bolt doctrine --- .jules/bolt.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index 91e41054..57daf471 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -3,6 +3,3 @@ **Learning:** `Date.parse(value)` returns the timestamp primitive directly, while `new Date(value).getTime()` also constructs a `Date` object. Both use the same ECMAScript string-parsing semantics for these call sites. **Action:** In frequently executed paths that only need a timestamp primitive, prefer `Date.parse(value)`. Treat the allocation reduction as a bounded micro-optimization unless a committed benchmark establishes a larger runtime effect. -## 2024-05-24 - [핫 패스 객체 순회 최적화: for...in 활용] -**Learning:** Object.keys()나 Object.entries()는 배열을 할당하므로, 잦은 호출이나 대규모 데이터 집계 같은 핫 패스에서는 GC(Garbage Collection) 오버헤드를 발생시킵니다. -**Action:** 극단적인 성능 최적화가 필요한 핫 패스에서는 Object.keys() 대신 for...in 루프를 사용하고, 프로토타입 오염을 방지하기 위해 반드시 if (Object.hasOwn(obj, key))로 가드합니다. From 18f8b3718bafabd1f462dc94eec8e186f2951d6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 19:20:26 +0900 Subject: [PATCH 3/3] test(rollup): preserve own-property aggregation semantics --- .../daily-rollup.own-properties.test.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 packages/web/src/lib/server/daily-rollup.own-properties.test.ts diff --git a/packages/web/src/lib/server/daily-rollup.own-properties.test.ts b/packages/web/src/lib/server/daily-rollup.own-properties.test.ts new file mode 100644 index 00000000..2f33841e --- /dev/null +++ b/packages/web/src/lib/server/daily-rollup.own-properties.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { aggregateSummary, type DailyRollup } from './daily-rollup' + +function rollup(overrides: Partial = {}): DailyRollup { + return { + date: '2026-09-05', + sessionCount: 0, + turnCount: 0, + activeUserCount: 0, + activeUserIds: [], + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + estimatedCostUsd: 0, + skillCounts: {}, + agentCounts: {}, + modelTokens: {}, + userStats: [], + ...overrides, + } +} + +function recordWithInheritedEntry(ownKey: string, ownValue: number): Record { + const values = Object.create({ inherited: 999 }) as Record + values[ownKey] = ownValue + return values +} + +describe('aggregateSummary own-property contract', () => { + it('aggregates ordinary own keys and preserves empty-map behavior', () => { + const summary = aggregateSummary([ + rollup({ + skillCounts: { review: 2 }, + agentCounts: { worker: 3 }, + modelTokens: { modelA: 5 }, + }), + rollup({ + skillCounts: { review: 4 }, + agentCounts: { worker: 1 }, + modelTokens: { modelA: 7 }, + }), + rollup(), + ]) + + expect(summary.topSkills).toEqual([{ skillName: 'review', callCount: 6 }]) + expect(summary.topAgents).toEqual([{ agentType: 'worker', callCount: 4 }]) + expect(summary.modelShare).toEqual([{ model: 'modelA', totalTokens: 12 }]) + }) + + it('does not aggregate inherited enumerable properties', () => { + const summary = aggregateSummary([ + rollup({ + skillCounts: recordWithInheritedEntry('ownSkill', 2), + agentCounts: recordWithInheritedEntry('ownAgent', 3), + modelTokens: recordWithInheritedEntry('ownModel', 5), + }), + ]) + + expect(summary.topSkills).toEqual([{ skillName: 'ownSkill', callCount: 2 }]) + expect(summary.topAgents).toEqual([{ agentType: 'ownAgent', callCount: 3 }]) + expect(summary.modelShare).toEqual([{ model: 'ownModel', totalTokens: 5 }]) + expect(summary.topSkills.some(({ skillName }) => skillName === 'inherited')).toBe(false) + expect(summary.topAgents.some(({ agentType }) => agentType === 'inherited')).toBe(false) + expect(summary.modelShare.some(({ model }) => model === 'inherited')).toBe(false) + }) + + it('returns empty aggregate lists when every count map is empty', () => { + const summary = aggregateSummary([rollup()]) + + expect(summary.topSkills).toEqual([]) + expect(summary.topAgents).toEqual([]) + expect(summary.modelShare).toEqual([]) + }) +})