mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(relationships): release session databases during graph compute
This commit is contained in:
1 parent
1566cfcb2b
commit
f8e482e2a9
6 files changed
+135
-6
No files matched your search
@@ -72,6 +72,7 @@ class TestEnv {
|
||||
|
||||
this.adapter = {
|
||||
listSessionIds: () => [...this.dbPaths.keys()],
|
||||
listSessionCandidateIds: () => [...this.dbPaths.keys()],
|
||||
openReadonly: (id) => open(id, true),
|
||||
openWritable: (id) => open(id, false),
|
||||
closeSession: () => {},
|
||||
@@ -189,6 +190,64 @@ function makeGraphEdge(sourceKey: string, targetKey: string, weight: number): Pe
|
||||
}
|
||||
}
|
||||
|
||||
test('processes database candidates without retaining all session connections', (t) => {
|
||||
const env = new TestEnv()
|
||||
t.after(() => env.cleanup())
|
||||
|
||||
for (const id of ['private-a', 'private-b']) {
|
||||
env.seed({
|
||||
id,
|
||||
platform: 'weixin',
|
||||
type: 'private',
|
||||
ownerId: 'owner',
|
||||
members: [
|
||||
{ id: 1, platformId: 'owner', accountName: 'Me' },
|
||||
{ id: 2, platformId: `friend-${id}`, accountName: `Friend ${id}` },
|
||||
],
|
||||
messages: [
|
||||
{ id: 1, senderId: 1, ts: 1704103200 },
|
||||
{ id: 2, senderId: 2, ts: 1704103201 },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const baseAdapter = env.adapter
|
||||
const activeDbs = new Map<string, DatabaseAdapter>()
|
||||
const closedSessionIds = new Set<string>()
|
||||
let maxActiveDbs = 0
|
||||
const adapter: SessionRuntimeAdapter = {
|
||||
...baseAdapter,
|
||||
listSessionIds: () => {
|
||||
throw new Error('relationship computation must use database candidates')
|
||||
},
|
||||
openReadonly: (sessionId) => {
|
||||
const db = baseAdapter.openReadonly(sessionId)
|
||||
if (db) {
|
||||
activeDbs.set(sessionId, db)
|
||||
maxActiveDbs = Math.max(maxActiveDbs, activeDbs.size)
|
||||
}
|
||||
return db
|
||||
},
|
||||
closeSession: (sessionId) => {
|
||||
const db = activeDbs.get(sessionId)
|
||||
if (db) db.close()
|
||||
activeDbs.delete(sessionId)
|
||||
closedSessionIds.add(sessionId)
|
||||
},
|
||||
}
|
||||
|
||||
const snapshot = computePeopleRelationshipsSnapshot({
|
||||
adapter,
|
||||
signature: 'sig-candidates',
|
||||
timeRangePreset: 'all',
|
||||
})
|
||||
|
||||
assert.equal(snapshot.workerStats.totalSessions, 2)
|
||||
assert.equal(maxActiveDbs, 1)
|
||||
assert.equal(activeDbs.size, 0)
|
||||
assert.deepEqual([...closedSessionIds].sort(), ['private-a', 'private-b'])
|
||||
})
|
||||
|
||||
test('computes a cropped relationship galaxy from private contacts and group interaction edges', (t) => {
|
||||
const env = new TestEnv()
|
||||
t.after(() => env.cleanup())
|
||||
|
||||
@@ -193,7 +193,7 @@ export function computePeopleRelationshipsSnapshot(
|
||||
options: ComputePeopleRelationshipsSnapshotOptions
|
||||
): PeopleRelationshipsSnapshot {
|
||||
const startedAt = options.now?.() ?? Date.now()
|
||||
const sessionIds = options.adapter.listSessionIds()
|
||||
const sessionIds = options.adapter.listSessionCandidateIds?.() ?? options.adapter.listSessionIds()
|
||||
const limits = normalizeLimits(options.limits)
|
||||
const factsCache = options.factsCacheDir ? createFactsCacheContext(options.factsCacheDir) : null
|
||||
const timeRange = resolvePeopleRelationshipsTimeRange(
|
||||
@@ -437,6 +437,7 @@ function computePeopleRelationships(options: {
|
||||
diagnostics.skippedFailedSessions++
|
||||
appLogger.error('people-relationships', `failed to process people relationship session: ${sessionId}`, error)
|
||||
} finally {
|
||||
options.adapter.closeSession(sessionId)
|
||||
processedSessions++
|
||||
options.onProgress?.({ processedSessions, totalSessions: options.sessionIds.length, currentSessionId: sessionId })
|
||||
}
|
||||
@@ -477,6 +478,8 @@ function findGlobalLatestMessageTs(
|
||||
`failed to inspect people relationship session range: ${sessionId}`,
|
||||
error
|
||||
)
|
||||
} finally {
|
||||
adapter.closeSession(sessionId)
|
||||
}
|
||||
}
|
||||
return latest
|
||||
|
||||
@@ -599,11 +599,22 @@ test('starts a background task when snapshot is missing and returns task state i
|
||||
try {
|
||||
let runnerCalls = 0
|
||||
let releaseRunner!: (snapshot: PeopleRelationshipsSnapshot) => void
|
||||
let runnerSignature = ''
|
||||
const baseAdapter = makeAdapter()
|
||||
const adapter: SessionRuntimeAdapter = {
|
||||
...baseAdapter,
|
||||
listSessionIds: () => {
|
||||
throw new Error('relationship service must use database candidates')
|
||||
},
|
||||
listSessionCandidateIds: () => ['session-a', 'session-b'],
|
||||
getDbPath: (sessionId) => path.join('/tmp', `chatlab-${sessionId}.db`),
|
||||
}
|
||||
const service = createPeopleRelationshipsService({
|
||||
adapter: makeAdapter(),
|
||||
adapter,
|
||||
systemDir: dir,
|
||||
runner: () => {
|
||||
runner: ({ signature }) => {
|
||||
runnerCalls++
|
||||
runnerSignature = signature
|
||||
return new Promise<PeopleRelationshipsSnapshot>((resolve) => {
|
||||
releaseRunner = resolve
|
||||
})
|
||||
@@ -615,8 +626,12 @@ test('starts a background task when snapshot is missing and returns task state i
|
||||
|
||||
assert.equal(response.cache.status, 'missing')
|
||||
assert.equal(response.task?.status, 'running')
|
||||
assert.equal(response.task?.totalSessions, 2)
|
||||
assert.equal(runnerCalls, 1)
|
||||
releaseRunner(makeSnapshot(makeFreshSignature()))
|
||||
releaseRunner({
|
||||
...makeSnapshot(runnerSignature),
|
||||
workerStats: { durationMs: 1, totalSessions: 2, processedSessions: 2, skippedFailedSessions: 0 },
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await service.close()
|
||||
} finally {
|
||||
|
||||
@@ -186,6 +186,7 @@ class DefaultPeopleRelationshipsService implements PeopleRelationshipsService {
|
||||
private ensureTaskStarted(signature: string, timeRangePreset: ContactsTimeRangePreset): void {
|
||||
if (this.inFlight) return
|
||||
|
||||
const sessionIds = this.deps.adapter.listSessionCandidateIds?.() ?? this.deps.adapter.listSessionIds()
|
||||
const taskId = `people_relationships_${this.now()}_${Math.random().toString(36).slice(2)}`
|
||||
this.task = {
|
||||
id: taskId,
|
||||
@@ -193,7 +194,7 @@ class DefaultPeopleRelationshipsService implements PeopleRelationshipsService {
|
||||
startedAt: this.now(),
|
||||
finishedAt: null,
|
||||
processedSessions: 0,
|
||||
totalSessions: this.deps.adapter.listSessionIds().length,
|
||||
totalSessions: sessionIds.length,
|
||||
timeRangePreset,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Run: pnpm test -- packages/node-runtime/src/services/people/relationships/signature.test.ts
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import type { SessionRuntimeAdapter } from '../../adapters'
|
||||
import { PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION } from './compute'
|
||||
import { buildPeopleRelationshipsSignature } from './signature'
|
||||
|
||||
test('builds the signature from database candidates without opening every session', (t) => {
|
||||
const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir())
|
||||
const dir = fs.mkdtempSync(path.join(baseDir, 'chatlab-relationships-signature-'))
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
|
||||
|
||||
const pathsById = new Map([
|
||||
['session-b', path.join(dir, 'b.db')],
|
||||
['session-a', path.join(dir, 'a.db')],
|
||||
])
|
||||
for (const dbPath of pathsById.values()) fs.writeFileSync(dbPath, 'db')
|
||||
|
||||
const adapter: SessionRuntimeAdapter = {
|
||||
listSessionIds: () => {
|
||||
throw new Error('signature must not validate or open every database')
|
||||
},
|
||||
listSessionCandidateIds: () => [...pathsById.keys()],
|
||||
openReadonly: () => {
|
||||
throw new Error('signature must not open databases')
|
||||
},
|
||||
openWritable: () => null,
|
||||
closeSession: () => {},
|
||||
getDbPath: (sessionId) => pathsById.get(sessionId) ?? '',
|
||||
deleteSessionFile: () => false,
|
||||
ensureReadonly: () => {
|
||||
throw new Error('signature must not open databases')
|
||||
},
|
||||
ensureWritable: () => {
|
||||
throw new Error('signature must not open databases')
|
||||
},
|
||||
}
|
||||
|
||||
const signature = buildPeopleRelationshipsSignature(adapter)
|
||||
|
||||
assert.match(signature, new RegExp(`algorithm:${PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION}`))
|
||||
assert.match(signature, /range:1y/)
|
||||
assert.ok(signature.indexOf('session-a:') < signature.indexOf('session-b:'))
|
||||
})
|
||||
@@ -12,7 +12,8 @@ export function buildPeopleRelationshipsSignature(
|
||||
`algorithm:${PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION}`,
|
||||
`range:${normalizePeopleRelationshipsTimeRangePreset(timeRangePreset)}`,
|
||||
]
|
||||
for (const sessionId of [...adapter.listSessionIds()].sort()) {
|
||||
const sessionIds = adapter.listSessionCandidateIds?.() ?? adapter.listSessionIds()
|
||||
for (const sessionId of [...sessionIds].sort()) {
|
||||
const dbPath = adapter.getDbPath(sessionId)
|
||||
parts.push(`${sessionId}:${getDbFileVersion(dbPath)}`)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user