Skip to content

Commit 5c705e2

Browse files
authored
Merge pull request #42 from atomantic/better/bugs-perf
fix: resolve N+1 queries, memory leak, missing error forwarding
2 parents 34e9fb4 + 679954a commit 5c705e2

6 files changed

Lines changed: 75 additions & 56 deletions

File tree

server/src/lib/graph/pathLongest.ts

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,39 +11,56 @@ export const pathLongest = (
1111
source: string,
1212
target: string
1313
): string[] => {
14-
let longest: string[] = [];
15-
const queue: string[][] = [[source]];
16-
const depthMap: Record<string, number> = {
17-
[source]: 0,
18-
};
14+
// Track the best (longest) depth at which we've visited each node
15+
const depthMap: Record<string, number> = { [source]: 0 };
16+
// Parent pointers keyed by "node" storing the parent on the longest known path
17+
const parentOf: Record<string, string | null> = { [source]: null };
18+
// Track visited nodes on the current BFS path to detect cycles
19+
// Each queue entry is [node, depth, Set of ancestors on this path]
20+
const queue: Array<[string, number, Set<string>]> = [[source, 0, new Set([source])]];
21+
let longestDepth = -1;
22+
let foundTarget = false;
1923

2024
while (queue.length) {
21-
const path = queue.shift()!;
22-
const node = path[path.length - 1];
25+
const [node, depth, ancestors] = queue.shift()!;
2326

2427
if (node === target) {
25-
if (path.length > longest.length) {
26-
longest = path;
28+
if (depth > longestDepth) {
29+
longestDepth = depth;
30+
foundTarget = true;
31+
}
32+
continue;
33+
}
34+
35+
const children = graph[node]?.children || [];
36+
for (const child of children) {
37+
if (ancestors.has(child)) {
38+
logger.error('graph', `TIME TRAVELER! Cyclic relationship: ${child} <-> ${node}`);
39+
continue;
40+
}
41+
if (!depthMap[child]) depthMap[child] = 0;
42+
// only queue this child if it is a further distance than we have seen already
43+
if (depthMap[child] < depth + 1) {
44+
depthMap[child] = depth + 1;
45+
parentOf[child] = node;
46+
const childAncestors = new Set(ancestors);
47+
childAncestors.add(child);
48+
queue.push([child, depth + 1, childAncestors]);
2749
}
28-
} else {
29-
const children = graph[node]?.children || [];
30-
children.forEach((child) => {
31-
if (!depthMap[child]) depthMap[child] = 0;
32-
if (path.includes(child)) {
33-
logger.error('graph', `TIME TRAVELER! Cyclic relationship: ${child} <-> ${node} | ${path.slice(path.indexOf(child)).join(' -> ')}`);
34-
return;
35-
}
36-
// only queue this child if it is a further distance than we have seen already
37-
// for this child relationship to the source
38-
if (depthMap[child] < path.length) {
39-
queue.push([...path, child]);
40-
depthMap[child] = path.length;
41-
}
42-
});
4350
}
4451
}
4552

46-
return longest;
53+
if (!foundTarget) return [];
54+
55+
// Reconstruct path from parent pointers
56+
const path: string[] = [];
57+
let current: string | null = target;
58+
while (current !== null) {
59+
path.push(current);
60+
current = parentOf[current] ?? null;
61+
}
62+
path.reverse();
63+
return path;
4764
};
4865

4966
export default pathLongest;

server/src/routes/ai-discovery.routes.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import { Router, Request, Response } from 'express';
22
import { aiDiscoveryService } from '../services/ai-discovery.service.js';
33
import { favoritesService } from '../services/favorites.service.js';
44
import { logger } from '../lib/logger.js';
5+
import { asyncHandler } from '../utils/asyncHandler.js';
56

67
const router = Router();
78

89
/**
910
* Start a quick AI discovery to find interesting ancestors
1011
* POST /api/ai-discovery/:dbId/quick
1112
*/
12-
router.post('/:dbId/quick', async (req: Request, res: Response) => {
13+
router.post('/:dbId/quick', asyncHandler(async (req: Request, res: Response) => {
1314
const { dbId } = req.params;
1415
const { sampleSize, excludeBiblical, minBirthYear, maxGenerations, customPrompt } = req.body;
1516

@@ -31,13 +32,13 @@ router.post('/:dbId/quick', async (req: Request, res: Response) => {
3132
logger.done('ai-discovery', `Quick discovery complete: analyzed=${result.totalAnalyzed} candidates=${result.candidates.length}`);
3233
res.json({ success: true, data: result });
3334
}
34-
});
35+
}));
3536

3637
/**
3738
* Start a full AI discovery run (async)
3839
* POST /api/ai-discovery/:dbId/start
3940
*/
40-
router.post('/:dbId/start', async (req: Request, res: Response) => {
41+
router.post('/:dbId/start', asyncHandler(async (req: Request, res: Response) => {
4142
const { dbId } = req.params;
4243
const { batchSize, maxPersons } = req.body;
4344

@@ -52,7 +53,7 @@ router.post('/:dbId/start', async (req: Request, res: Response) => {
5253
if (result !== null) {
5354
res.json({ success: true, data: result });
5455
}
55-
});
56+
}));
5657

5758
/**
5859
* Get progress of a discovery run

server/src/routes/ancestry-hints.routes.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ import { Router, Request, Response } from 'express';
88
import { ancestryHintsService } from '../services/ancestry-hints.service.js';
99
import { logger } from '../lib/logger.js';
1010
import { initSSE } from '../utils/sseHelpers.js';
11+
import { asyncHandler } from '../utils/asyncHandler.js';
1112

1213
const router = Router();
1314

1415
/**
1516
* POST /:dbId/:personId - Process free hints for a single person
1617
* Returns the result with counts of hints processed
1718
*/
18-
router.post('/:dbId/:personId', async (req: Request, res: Response) => {
19+
router.post('/:dbId/:personId', asyncHandler(async (req: Request, res: Response) => {
1920
const { personId } = req.params;
2021

2122
logger.start('ancestry-hints', `Processing hints for person ${personId}`);
@@ -38,13 +39,13 @@ router.post('/:dbId/:personId', async (req: Request, res: Response) => {
3839
}
3940

4041
res.json({ success: true, data: result });
41-
});
42+
}));
4243

4344
/**
4445
* GET /:dbId/:personId/events - SSE stream for hints processing progress
4546
* Streams real-time progress events during hint processing
4647
*/
47-
router.get('/:dbId/:personId/events', async (req: Request, res: Response) => {
48+
router.get('/:dbId/:personId/events', asyncHandler(async (req: Request, res: Response) => {
4849
const { personId } = req.params;
4950

5051
initSSE(res);
@@ -82,7 +83,7 @@ router.get('/:dbId/:personId/events', async (req: Request, res: Response) => {
8283
}
8384

8485
res.end();
85-
});
86+
}));
8687

8788
/**
8889
* POST /:dbId/cancel - Cancel the running hints operation

server/src/routes/augmentation.routes.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,14 @@ function servePhoto(getPath: (id: string) => string | null, label: string) {
2929

3030
res.setHeader('Content-Type', contentType);
3131
res.setHeader('Cache-Control', 'public, max-age=86400');
32-
fs.createReadStream(photoPath).pipe(res);
32+
const stream = fs.createReadStream(photoPath);
33+
stream.on('error', (err) => {
34+
logger.error('augment', `Stream error serving ${label} photo: ${err.message}`);
35+
if (!res.headersSent) {
36+
res.status(500).json({ success: false, error: 'Error reading photo file' });
37+
}
38+
});
39+
stream.pipe(res);
3340
};
3441
}
3542

@@ -47,7 +54,7 @@ router.get('/:personId', async (req: Request, res: Response) => {
4754
});
4855

4956
// Link a Wikipedia article to a person
50-
router.post('/:personId/wikipedia', async (req: Request, res: Response) => {
57+
router.post('/:personId/wikipedia', asyncHandler(async (req: Request, res: Response) => {
5158
const personId = sanitizePersonId(req.params.personId);
5259
const { url } = req.body;
5360

@@ -70,7 +77,7 @@ router.post('/:personId/wikipedia', async (req: Request, res: Response) => {
7077
if (data) {
7178
res.json({ success: true, data });
7279
}
73-
});
80+
}));
7481

7582
// Update custom augmentation data
7683
router.put('/:personId', async (req: Request, res: Response) => {

server/src/services/browser.service.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export const browserService = {
7979
}
8080

8181
connectedBrowser = await chromium.connectOverCDP(url);
82-
broadcastStatusUpdate();
82+
await broadcastStatusUpdate();
8383
return connectedBrowser;
8484
},
8585

@@ -88,7 +88,7 @@ export const browserService = {
8888
await connectedBrowser.close();
8989
connectedBrowser = null;
9090
workerPage = null;
91-
broadcastStatusUpdate();
91+
await broadcastStatusUpdate();
9292
}
9393
},
9494

@@ -255,7 +255,7 @@ export const browserService = {
255255

256256
// Navigation may change FamilySearch login status
257257
if (url.includes('familysearch.org')) {
258-
broadcastStatusUpdate();
258+
await broadcastStatusUpdate();
259259
}
260260

261261
return page;
@@ -303,7 +303,7 @@ export const browserService = {
303303
await new Promise(resolve => setTimeout(resolve, 2000));
304304

305305
const nowRunning = await checkBrowserProcessRunning();
306-
broadcastStatusUpdate();
306+
await broadcastStatusUpdate();
307307
return {
308308
success: nowRunning,
309309
message: nowRunning ? 'Browser launched successfully' : 'Browser may still be starting...'

server/src/services/search.service.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -171,14 +171,11 @@ async function searchWithSqlite(
171171
offset,
172172
});
173173

174-
// Build full person objects
175-
const results: PersonWithId[] = [];
176-
for (const { person_id } of personIds) {
177-
const person = await databaseService.getPerson(dbId, person_id);
178-
if (person) {
179-
results.push(person);
180-
}
181-
}
174+
// Build full person objects (batch load in parallel)
175+
const persons = await Promise.all(
176+
personIds.map(({ person_id }) => databaseService.getPerson(dbId, person_id))
177+
);
178+
const results: PersonWithId[] = persons.filter((p): p is PersonWithId => p !== null);
182179

183180
const totalPages = Math.ceil(total / limit);
184181
return { results, total, page, limit, totalPages };
@@ -313,14 +310,10 @@ export const searchService = {
313310
}
314311
);
315312

316-
const results: PersonWithId[] = [];
317-
for (const { person_id } of personIds) {
318-
const person = await databaseService.getPerson(dbId, person_id);
319-
if (person) {
320-
results.push(person);
321-
}
322-
}
323-
return results;
313+
const persons = await Promise.all(
314+
personIds.map(({ person_id }) => databaseService.getPerson(dbId, person_id))
315+
);
316+
return persons.filter((p): p is PersonWithId => p !== null);
324317
}
325318

326319
// Fall back to simple prefix search

0 commit comments

Comments
 (0)