perf(activity): page, summarize and reconcile activity history in Main by index
Schema 57 rebuilds activity_history_records with order_seq (newer = larger;
a move to the front assigns a new maximum) and stored generated columns
(record_id, occurrence, status, status_group, kind, request_id,
conversation_id, project_id, created_at) with indexes. The order is migrated
from record_order_json exactly; legacy duplicates keep their occurrence and
only the first is shown. record_order_json is no longer written.
ActivityHistoryRepository owns the SQL: page({before, limit <= 500, filter,
projectId, conversationId}) newest first by index, summary() with the counts
and conversation titles/status the Activity page derived from the full list,
reconcile() joined with tasks with a renderer-localized detail line, update()
applied directly in SQL without reading the history, replace() for the
legacy migration, clear(). New activity-history:page/summary/reconcile IPC
with zod validation; page and summary run on the readonly worker.
perf:main measures 24,000 records: startup load 106 ms on Main (14 MB) ->
first page + summary 13.5 ms on the worker (0.6 ms loop block, 114 KB).
This commit is contained in:
@@ -6,14 +6,7 @@ import { appendConversationQuestionBlock } from '../../shared/conversation-quest
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { ExecutionStatsReader } from './execution-stats-reader'
|
||||
import { ReadonlyQueryReader, readWithFallback } from '../readonly-query-reader'
|
||||
import {
|
||||
isActivityHistoryPlanEmpty,
|
||||
parseActivityHistorySnapshot,
|
||||
planActivityHistoryReplace,
|
||||
planActivityHistoryUpdate,
|
||||
type ActivityHistoryPlan,
|
||||
type ActivityHistoryState
|
||||
} from './activity-history-plan'
|
||||
import { ActivityHistoryRepository, migrateActivityHistoryOrder } from './activity-history-repository'
|
||||
import { statSync } from 'node:fs'
|
||||
import { MagicNoteStorage } from '../magic-notes/magic-note-storage'
|
||||
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
|
||||
@@ -45,7 +38,9 @@ import type {
|
||||
AssistantTask,
|
||||
ExecutionStats,
|
||||
ExecutionStatsInput,
|
||||
ActivityHistoryPage,
|
||||
ActivityHistorySnapshot,
|
||||
ActivityHistorySummary,
|
||||
ConversationQueueItem,
|
||||
ConversationAnsweredQuestion,
|
||||
ConversationBranchInput,
|
||||
@@ -142,7 +137,7 @@ import {
|
||||
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 56
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 57
|
||||
|
||||
export type RemoteTaskEventInput = {
|
||||
taskId: string
|
||||
@@ -1974,7 +1969,7 @@ export class AssistantDatabase {
|
||||
private static readonly executionStatsCacheTtlMs = 30_000
|
||||
private static readonly executionStatsCacheLimit = 8
|
||||
private database?: DatabaseSync
|
||||
private activityHistoryCache?: { state: ActivityHistoryState; dataVersion: number }
|
||||
private activityHistoryRepository?: { database: DatabaseSync; repository: ActivityHistoryRepository }
|
||||
private executionStatsReader?: ExecutionStatsReader
|
||||
private readonlyReader?: ReadonlyQueryReader
|
||||
private readonlyWorkerPath?: string
|
||||
@@ -2412,7 +2407,7 @@ export class AssistantDatabase {
|
||||
this.readonlyReader = undefined
|
||||
this.foldedSearchConnection = undefined
|
||||
this.executionStatsCache.clear()
|
||||
this.activityHistoryCache = undefined
|
||||
this.activityHistoryRepository = undefined
|
||||
this.database?.close()
|
||||
this.database = undefined
|
||||
this.subagentProgress = undefined
|
||||
@@ -2472,7 +2467,7 @@ export class AssistantDatabase {
|
||||
throw error
|
||||
}
|
||||
this.dirtyMagicNotes.clear()
|
||||
this.activityHistoryCache = undefined
|
||||
this.activityHistoryRepository?.repository.invalidate()
|
||||
this.pendingMagicNoteCleanup.clear()
|
||||
this.reconcileMagicNoteFiles()
|
||||
this.options.onMagicNotesChanged?.()
|
||||
@@ -5295,154 +5290,59 @@ export class AssistantDatabase {
|
||||
return result
|
||||
}
|
||||
|
||||
getActivityHistory(): ActivityHistorySnapshot {
|
||||
/** Activity history storage and queries (PERF-15); the SQL lives in the repository. */
|
||||
activityHistory(): ActivityHistoryRepository {
|
||||
const database = this.requireDatabase()
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT record_order_json, legacy_history_may_be_incomplete
|
||||
FROM activity_history
|
||||
WHERE singleton = 1`
|
||||
)
|
||||
.get() as
|
||||
| {
|
||||
record_order_json: string
|
||||
legacy_history_may_be_incomplete: number
|
||||
}
|
||||
| undefined
|
||||
const records = database.prepare(
|
||||
`SELECT records.record_json
|
||||
FROM json_each(?) AS ordering
|
||||
JOIN activity_history_records AS records ON records.record_key = ordering.value
|
||||
ORDER BY CAST(ordering.key AS INTEGER)`
|
||||
).all(row?.record_order_json ?? '[]') as Array<{ record_json: string }>
|
||||
return activityHistorySnapshotSchema.parse({
|
||||
records: records.map((record) => JSON.parse(record.record_json)),
|
||||
legacyHistoryMayBeIncomplete:
|
||||
row?.legacy_history_may_be_incomplete === 1
|
||||
})
|
||||
if (this.activityHistoryRepository?.database !== database) {
|
||||
this.activityHistoryRepository = { database, repository: new ActivityHistoryRepository(database) }
|
||||
}
|
||||
return this.activityHistoryRepository.repository
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the whole newest-first list. Unchanged records are neither
|
||||
* re-validated, re-serialized nor written; a fully unchanged snapshot does
|
||||
* not open a write transaction (PERF-15).
|
||||
*/
|
||||
/** The whole stored list (tests, diagnostics); the renderer reads pages. */
|
||||
getActivityHistory(): ActivityHistorySnapshot {
|
||||
return this.activityHistory().snapshot()
|
||||
}
|
||||
|
||||
/** Persists a whole newest-first list; used for the legacy localStorage migration. */
|
||||
replaceActivityHistory(input: unknown): void {
|
||||
const database = this.requireDatabase()
|
||||
// Reuse is safe even from a stale cache: cached records are validated copies.
|
||||
const { items, incomplete } = parseActivityHistorySnapshot(input, this.activityHistoryCache?.state)
|
||||
const committed = this.committedActivityHistoryState(database)
|
||||
const planned = committed && planActivityHistoryReplace(items, incomplete, committed)
|
||||
if (planned && isActivityHistoryPlanEmpty(planned)) return
|
||||
this.writeActivityHistoryPlan(database, (previous) => previous === committed && planned
|
||||
? planned : planActivityHistoryReplace(items, incomplete, previous))
|
||||
this.activityHistory().replace(input)
|
||||
}
|
||||
|
||||
/** Applies incremental changes in one transaction (PERF-15). */
|
||||
updateActivityHistory(input: unknown): void {
|
||||
const database = this.requireDatabase()
|
||||
this.writeActivityHistoryPlan(database, (previous) => planActivityHistoryUpdate(input, previous))
|
||||
this.activityHistory().update(input)
|
||||
}
|
||||
|
||||
/** Deletes every activity record in one transaction. */
|
||||
clearActivityHistory(): void {
|
||||
this.activityHistory().clear()
|
||||
}
|
||||
|
||||
getActivityHistoryPage(input: unknown): ActivityHistoryPage {
|
||||
return this.activityHistory().page(input)
|
||||
}
|
||||
|
||||
getActivityHistorySummary(input: unknown): ActivityHistorySummary {
|
||||
return this.readSnapshot(() => this.activityHistory().summary(input))
|
||||
}
|
||||
|
||||
/** Pages and the summary on the readonly worker; same rules as listConversationSummariesAsync. */
|
||||
getActivityHistoryPageAsync(input: unknown): Promise<ActivityHistoryPage> {
|
||||
const database = this.requireDatabase()
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
database.exec('DELETE FROM activity_history_records')
|
||||
database.exec(
|
||||
`UPDATE activity_history
|
||||
SET record_order_json = '[]', legacy_history_may_be_incomplete = 0
|
||||
WHERE singleton = 1`
|
||||
)
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
this.activityHistoryCache = undefined
|
||||
return readWithFallback(database.isTransaction ? undefined : this.readonlyQueryReader(),
|
||||
'activityHistoryPage', [input], undefined, () => this.getActivityHistoryPage(input))
|
||||
}
|
||||
|
||||
/** The cached committed state, when no other connection committed since. */
|
||||
private committedActivityHistoryState(database: DatabaseSync): ActivityHistoryState | undefined {
|
||||
const cache = this.activityHistoryCache
|
||||
if (!cache || database.isTransaction) return undefined
|
||||
const { data_version: dataVersion } = database.prepare('PRAGMA data_version').get() as { data_version: number }
|
||||
return cache.dataVersion === dataVersion ? cache.state : undefined
|
||||
getActivityHistorySummaryAsync(input: unknown): Promise<ActivityHistorySummary> {
|
||||
const database = this.requireDatabase()
|
||||
return readWithFallback(database.isTransaction ? undefined : this.readonlyQueryReader(),
|
||||
'activityHistorySummary', [input], undefined, () => this.getActivityHistorySummary(input))
|
||||
}
|
||||
|
||||
private writeActivityHistoryPlan(
|
||||
database: DatabaseSync,
|
||||
plan: (previous: ActivityHistoryState) => ActivityHistoryPlan
|
||||
): void {
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const { data_version: dataVersion } = database.prepare('PRAGMA data_version').get() as { data_version: number }
|
||||
const previous = this.activityHistoryCache?.dataVersion === dataVersion
|
||||
? this.activityHistoryCache.state : this.readActivityHistoryState(database)
|
||||
const result = plan(previous)
|
||||
this.applyActivityHistoryPlan(database, result)
|
||||
database.exec('COMMIT')
|
||||
// Publish only committed records; a failed save must remain retryable.
|
||||
this.activityHistoryCache = { state: result.next, dataVersion }
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private readActivityHistoryState(database: DatabaseSync): ActivityHistoryState {
|
||||
const rows = database.prepare(
|
||||
'SELECT record_key, record_json FROM activity_history_records'
|
||||
).all() as Array<{ record_key: string; record_json: string }>
|
||||
const header = database.prepare(
|
||||
'SELECT record_order_json, legacy_history_may_be_incomplete FROM activity_history WHERE singleton = 1'
|
||||
).get() as { record_order_json: string; legacy_history_may_be_incomplete: number } | undefined
|
||||
const order = header ? JSON.parse(header.record_order_json) as unknown : undefined
|
||||
return {
|
||||
entries: new Map(rows.map((row) => [row.record_key, { json: row.record_json }])),
|
||||
...(Array.isArray(order) && order.every((key) => typeof key === 'string')
|
||||
? { order, orderJson: header!.record_order_json } : {}),
|
||||
...(header ? { incomplete: header.legacy_history_may_be_incomplete === 1 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
private applyActivityHistoryPlan(database: DatabaseSync, plan: ActivityHistoryPlan): void {
|
||||
if (plan.upserts.length > 0) {
|
||||
const upsert = database.prepare(
|
||||
`INSERT INTO activity_history_records (record_key, record_json) VALUES (?, ?)
|
||||
ON CONFLICT(record_key) DO UPDATE SET record_json = excluded.record_json`
|
||||
)
|
||||
for (const [key, json] of plan.upserts) upsert.run(key, json)
|
||||
}
|
||||
if (plan.removes.length > 0) {
|
||||
const remove = database.prepare('DELETE FROM activity_history_records WHERE record_key = ?')
|
||||
for (const key of plan.removes) remove.run(key)
|
||||
}
|
||||
if (plan.headerChanged) {
|
||||
database.prepare(
|
||||
`UPDATE activity_history
|
||||
SET record_order_json = ?, legacy_history_may_be_incomplete = ?
|
||||
WHERE singleton = 1`
|
||||
).run(plan.next.orderJson, Number(plan.next.incomplete))
|
||||
}
|
||||
}
|
||||
|
||||
/** Schema-44 migration: writes a parsed legacy snapshot into the new tables. */
|
||||
private writeActivityHistory(
|
||||
database: DatabaseSync,
|
||||
snapshot: ActivityHistorySnapshot,
|
||||
onProgress?: (processed: number) => void
|
||||
): void {
|
||||
const rows = database.prepare(
|
||||
'SELECT record_key, record_json FROM activity_history_records'
|
||||
).all() as Array<{ record_key: string; record_json: string }>
|
||||
// The order column still holds the legacy records; always rewrite it.
|
||||
const previous: ActivityHistoryState = {
|
||||
entries: new Map(rows.map((row) => [row.record_key, { json: row.record_json }]))
|
||||
}
|
||||
this.applyActivityHistoryPlan(database, planActivityHistoryReplace(
|
||||
snapshot.records.map((record) => ({ record })), snapshot.legacyHistoryMayBeIncomplete, previous, onProgress))
|
||||
/** Ends records left running by requests that are no longer active (see the repository). */
|
||||
reconcileActivityHistory(input: unknown): number {
|
||||
return this.activityHistory().reconcile(input)
|
||||
}
|
||||
|
||||
createTask(input: {
|
||||
@@ -12088,11 +11988,13 @@ export class AssistantDatabase {
|
||||
database.exec(`
|
||||
CREATE TABLE activity_history_records (
|
||||
record_key TEXT PRIMARY KEY NOT NULL,
|
||||
record_json TEXT NOT NULL
|
||||
record_json TEXT NOT NULL,
|
||||
order_seq INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
ALTER TABLE activity_history RENAME COLUMN records_json TO record_order_json;
|
||||
`)
|
||||
this.writeActivityHistory(database, snapshot, onProgress || isCancelled ? (processed) => {
|
||||
// Writes order_seq directly; schema 57 then only adds the indexed columns.
|
||||
new ActivityHistoryRepository(database).writeSnapshot(snapshot, onProgress || isCancelled ? (processed) => {
|
||||
onProgress?.({ stage: 'converting', processed, total: snapshot.records.length, bytesBefore })
|
||||
if (isCancelled?.()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
||||
} : undefined)
|
||||
@@ -12345,6 +12247,15 @@ export class AssistantDatabase {
|
||||
database.exec('PRAGMA user_version = 56; COMMIT;')
|
||||
} catch (error) { database.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
if (version.user_version < 57) {
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
// Activity history order and indexed columns (PERF-15): pages read by index,
|
||||
// and a move to the front no longer rewrites the order list.
|
||||
migrateActivityHistoryOrder(database)
|
||||
database.exec('PRAGMA user_version = 57; COMMIT;')
|
||||
} catch (error) { database.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
}
|
||||
|
||||
supervisionExperiences(): SupervisionExperienceStore {
|
||||
|
||||
Reference in New Issue
Block a user