Skip to content

Commit cde877e

Browse files
committed
fix: rewrite sparse-tree service to use SQLite queries
- Replaced in-memory database loading with SQLite-based path finding - Used BFS from root to ancestors for efficient path discovery - Added batch person data fetching to minimize database queries - Sparse tree page now loads in ~3s instead of freezing on large databases
1 parent b9782df commit cde877e

2 files changed

Lines changed: 114 additions & 67 deletions

File tree

.changelog/v0.4.x.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ This release adds AI-powered ancestor discovery, test coverage reporting UI, and
2626
- **Cause**: WAL file grew to 1.2GB causing SQLite lock contention between reader and writer processes
2727
- **Fix**: Added proper busy_timeout and wal_autocheckpoint pragmas, plus periodic checkpointing
2828

29+
### Fixed: Sparse Tree Page Freezing (v0.4.2)
30+
- **Symptom**: App froze when navigating to favorites sparse-tree page on large databases
31+
- **Cause**: Service was loading entire database (138k+ persons) into memory for BFS path finding
32+
- **Fix**: Rewrote sparse-tree service to use SQLite queries for path finding with iterative BFS
33+
2934
## Technical Details
3035

3136
The SQLite WAL (Write-Ahead Log) mode provides excellent read concurrency, but can cause issues when:

server/src/services/sparse-tree.service.ts

Lines changed: 109 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,95 @@
11
import fs from 'fs';
22
import path from 'path';
3-
import type { SparseTreeNode, SparseTreeResult, Database, FavoriteData, PersonAugmentation } from '@fsf/shared';
3+
import type { SparseTreeNode, SparseTreeResult, FavoriteData, PersonAugmentation } from '@fsf/shared';
44
import { databaseService } from './database.service.js';
55
import { favoritesService } from './favorites.service.js';
6+
import { sqliteService } from '../db/sqlite.service.js';
7+
import { idMappingService } from './id-mapping.service.js';
68

79
const DATA_DIR = path.resolve(import.meta.dirname, '../../../data');
810
const AUGMENT_DIR = path.join(DATA_DIR, 'augment');
911
const PHOTOS_DIR = path.join(DATA_DIR, 'photos');
1012

1113
/**
12-
* BFS to find shortest path from source to target through parents (ancestors)
13-
* This traverses upward from the root person to their ancestors
14+
* Get a path from root DOWN to an ancestor (favorite)
15+
* Root is the "self" person (descendant), favorites are ancestors
16+
* We walk UP from root to find the ancestor, then reverse the path
1417
*/
15-
function findShortestPath(db: Database, source: string, target: string): string[] {
16-
const queue = [source];
17-
const visited: Record<string, boolean> = { [source]: true };
18-
const cameFrom: Record<string, string> = {};
18+
function getPathToAncestor(rootId: string, ancestorId: string, maxDepth = 100): string[] {
19+
// BFS from root upward to find ancestor
20+
// We need to search broadly since we don't know which parent line leads to the ancestor
21+
const visited = new Set<string>([rootId]);
22+
const parent: Map<string, string> = new Map(); // Maps child -> parent used to reach child
23+
const queue: Array<{ id: string; depth: number }> = [{ id: rootId, depth: 0 }];
1924

2025
while (queue.length > 0) {
21-
const id = queue.shift()!;
22-
const person = db[id];
23-
if (!person) continue;
24-
25-
// Traverse parents to find ancestors
26-
const parentIds = person.parents || [];
27-
for (const parentId of parentIds) {
28-
if (!parentId || visited[parentId]) continue;
29-
visited[parentId] = true;
30-
31-
if (parentId === target) {
32-
const pathArr = [parentId];
33-
let current = id;
34-
while (current !== source) {
35-
pathArr.push(current);
36-
current = cameFrom[current];
37-
}
38-
pathArr.push(source);
39-
pathArr.reverse();
40-
return pathArr;
26+
const current = queue.shift()!;
27+
if (current.id === ancestorId) {
28+
// Reconstruct path from root to ancestor
29+
const path: string[] = [ancestorId];
30+
let node = ancestorId;
31+
while (parent.has(node)) {
32+
node = parent.get(node)!;
33+
path.push(node);
4134
}
35+
return path.reverse(); // Reverse to get root -> ancestor order
36+
}
37+
38+
if (current.depth >= maxDepth) continue;
39+
40+
// Get parents of current person
41+
const parents = sqliteService.queryAll<{ parent_id: string }>(
42+
'SELECT parent_id FROM parent_edge WHERE child_id = @current',
43+
{ current: current.id }
44+
);
4245

43-
cameFrom[parentId] = id;
44-
queue.push(parentId);
46+
for (const p of parents) {
47+
if (!visited.has(p.parent_id)) {
48+
visited.add(p.parent_id);
49+
parent.set(p.parent_id, current.id);
50+
queue.push({ id: p.parent_id, depth: current.depth + 1 });
51+
}
4552
}
4653
}
4754

48-
return [];
55+
return []; // No path found
56+
}
57+
58+
/**
59+
* Batch fetch person data for a list of IDs from SQLite
60+
*/
61+
function batchFetchPersons(personIds: string[]): Map<string, { name: string; lifespan: string }> {
62+
if (personIds.length === 0) return new Map();
63+
64+
const placeholders = personIds.map((_, i) => `@id${i}`).join(',');
65+
const params: Record<string, string> = {};
66+
personIds.forEach((id, i) => { params[`id${i}`] = id; });
67+
68+
const rows = sqliteService.queryAll<{
69+
person_id: string;
70+
display_name: string;
71+
birth_year: number | null;
72+
death_year: number | null;
73+
}>(
74+
`SELECT p.person_id, p.display_name,
75+
(SELECT date_year FROM vital_event WHERE person_id = p.person_id AND event_type = 'birth') as birth_year,
76+
(SELECT date_year FROM vital_event WHERE person_id = p.person_id AND event_type = 'death') as death_year
77+
FROM person p
78+
WHERE p.person_id IN (${placeholders})`,
79+
params
80+
);
81+
82+
const result = new Map<string, { name: string; lifespan: string }>();
83+
for (const row of rows) {
84+
const birthStr = row.birth_year ? String(row.birth_year) : '';
85+
const deathStr = row.death_year ? String(row.death_year) : '';
86+
const lifespan = birthStr || deathStr ? `${birthStr}-${deathStr}` : '';
87+
result.set(row.person_id, {
88+
name: row.display_name,
89+
lifespan,
90+
});
91+
}
92+
return result;
4993
}
5094

5195
/**
@@ -60,31 +104,26 @@ function getFavoriteData(personId: string): FavoriteData | null {
60104

61105
/**
62106
* Get photo URL for a person
63-
* Priority: Ancestry > WikiTree > Wikipedia > FamilySearch scraped
64107
*/
65108
function getPhotoUrl(personId: string): string | undefined {
66-
// Check for Ancestry photo (highest priority)
67109
const ancestryJpgPath = path.join(PHOTOS_DIR, `${personId}-ancestry.jpg`);
68110
const ancestryPngPath = path.join(PHOTOS_DIR, `${personId}-ancestry.png`);
69111
if (fs.existsSync(ancestryJpgPath) || fs.existsSync(ancestryPngPath)) {
70112
return `/api/augment/${personId}/ancestry-photo`;
71113
}
72114

73-
// Check for WikiTree photo
74115
const wikiTreeJpgPath = path.join(PHOTOS_DIR, `${personId}-wikitree.jpg`);
75116
const wikiTreePngPath = path.join(PHOTOS_DIR, `${personId}-wikitree.png`);
76117
if (fs.existsSync(wikiTreeJpgPath) || fs.existsSync(wikiTreePngPath)) {
77118
return `/api/augment/${personId}/wikitree-photo`;
78119
}
79120

80-
// Check for Wikipedia photo
81121
const wikiJpgPath = path.join(PHOTOS_DIR, `${personId}-wiki.jpg`);
82122
const wikiPngPath = path.join(PHOTOS_DIR, `${personId}-wiki.png`);
83123
if (fs.existsSync(wikiJpgPath) || fs.existsSync(wikiPngPath)) {
84124
return `/api/augment/${personId}/wiki-photo`;
85125
}
86126

87-
// Check for scraped FamilySearch photo
88127
const jpgPath = path.join(PHOTOS_DIR, `${personId}.jpg`);
89128
const pngPath = path.join(PHOTOS_DIR, `${personId}.png`);
90129
if (fs.existsSync(jpgPath) || fs.existsSync(pngPath)) {
@@ -97,25 +136,27 @@ function getPhotoUrl(personId: string): string | undefined {
97136
export const sparseTreeService = {
98137
/**
99138
* Generate a sparse tree showing only favorites and their paths from root
139+
* Uses reverse traversal (from each favorite to root) for efficiency
100140
*/
101141
async getSparseTree(dbId: string): Promise<SparseTreeResult> {
102142
const dbInfo = await databaseService.getDatabaseInfo(dbId);
103-
const db = await databaseService.getDatabase(dbId);
104143
const rootId = dbInfo.rootId;
105144

106145
// Get all favorites in this database
107146
const favoritesInDb = await favoritesService.getFavoritesInDatabase(dbId);
108147

148+
// Resolve rootId to canonical ID for SQLite queries
149+
const canonicalRootId = idMappingService.resolveId(rootId, 'familysearch') || rootId;
150+
109151
if (favoritesInDb.length === 0) {
110-
// Return just root with no children
111-
const rootPerson = db[rootId];
112-
const rootFavorite = getFavoriteData(rootId);
152+
const rootData = batchFetchPersons([canonicalRootId]).get(canonicalRootId);
153+
const rootFavorite = getFavoriteData(canonicalRootId);
113154
return {
114155
root: {
115-
id: rootId,
116-
name: rootPerson?.name || rootId,
117-
lifespan: rootPerson?.lifespan || '',
118-
photoUrl: getPhotoUrl(rootId),
156+
id: canonicalRootId,
157+
name: rootData?.name || rootId,
158+
lifespan: rootData?.lifespan || '',
159+
photoUrl: getPhotoUrl(canonicalRootId),
119160
whyInteresting: rootFavorite?.whyInteresting,
120161
tags: rootFavorite?.tags,
121162
generationFromRoot: 0,
@@ -127,31 +168,39 @@ export const sparseTreeService = {
127168
};
128169
}
129170

130-
// Find shortest path from root to each favorite
171+
// Get ancestor chain for each favorite (reverse traversal - fast!)
131172
const paths: Map<string, string[]> = new Map();
173+
const allPersonIds = new Set<string>([canonicalRootId]);
174+
132175
for (const fav of favoritesInDb) {
133-
const pathArr = findShortestPath(db, rootId, fav.personId);
134-
if (pathArr.length > 0) {
135-
paths.set(fav.personId, pathArr);
176+
const canonicalFavId = idMappingService.resolveId(fav.personId, 'familysearch') || fav.personId;
177+
const pathArr = getPathToAncestor(canonicalRootId, canonicalFavId);
178+
if (pathArr.length > 0 && pathArr[0] === canonicalRootId) {
179+
paths.set(canonicalFavId, pathArr);
180+
pathArr.forEach(id => allPersonIds.add(id));
136181
}
137182
}
138183

139-
const favoriteIds = new Set(favoritesInDb.map(f => f.personId));
184+
// Batch fetch all person data we need
185+
const personData = batchFetchPersons([...allPersonIds]);
186+
187+
const favoriteIds = new Set(favoritesInDb.map(f =>
188+
idMappingService.resolveId(f.personId, 'familysearch') || f.personId
189+
));
140190

141-
// Build a full tree structure from all paths
191+
// Build tree structure from paths
142192
interface TreeBuildNode {
143193
id: string;
144194
generation: number;
145195
children: Map<string, TreeBuildNode>;
146196
}
147197

148198
const fullTree: TreeBuildNode = {
149-
id: rootId,
199+
id: canonicalRootId,
150200
generation: 0,
151201
children: new Map(),
152202
};
153203

154-
// Add all paths to tree
155204
for (const [, pathArr] of paths) {
156205
let current = fullTree;
157206
for (let i = 1; i < pathArr.length; i++) {
@@ -167,11 +216,10 @@ export const sparseTreeService = {
167216
}
168217
}
169218

170-
// Find nodes that should be shown: favorites, root, and branch points (non-favorites with 2+ children leading to favorites)
171-
const nodesToShow = new Set<string>([rootId, ...favoriteIds]);
219+
// Find branch points
220+
const nodesToShow = new Set<string>([canonicalRootId, ...favoriteIds]);
172221

173222
const findBranchPoints = (node: TreeBuildNode): boolean => {
174-
// Returns true if this node has any favorite descendants
175223
if (favoriteIds.has(node.id)) return true;
176224

177225
let branchesWithFavorites = 0;
@@ -181,8 +229,7 @@ export const sparseTreeService = {
181229
}
182230
}
183231

184-
// If this non-favorite node has 2+ branches leading to favorites, it's a branch point
185-
if (branchesWithFavorites >= 2 && !favoriteIds.has(node.id) && node.id !== rootId) {
232+
if (branchesWithFavorites >= 2 && !favoriteIds.has(node.id) && node.id !== canonicalRootId) {
186233
nodesToShow.add(node.id);
187234
}
188235

@@ -191,18 +238,16 @@ export const sparseTreeService = {
191238

192239
findBranchPoints(fullTree);
193240

194-
// Build sparse tree showing only selected nodes
241+
// Build sparse tree
195242
const buildSparseNode = (node: TreeBuildNode, lastShownGeneration: number): SparseTreeNode | null => {
196243
const shouldShow = nodesToShow.has(node.id);
197-
const person = db[node.id];
244+
const person = personData.get(node.id);
198245
const favorite = getFavoriteData(node.id);
199246

200-
// Recursively build children
201247
const childResults: SparseTreeNode[] = [];
202248
for (const [, child] of node.children) {
203249
const childResult = buildSparseNode(child, shouldShow ? node.generation : lastShownGeneration);
204250
if (childResult) {
205-
// If child is a "pass-through" (not shown), merge its children up
206251
if (Array.isArray(childResult.children) && !nodesToShow.has(childResult.id)) {
207252
childResults.push(...childResult.children);
208253
} else {
@@ -227,12 +272,10 @@ export const sparseTreeService = {
227272
};
228273
}
229274

230-
// Not shown - pass children through
231275
if (childResults.length === 1) {
232276
return childResults[0];
233277
}
234278
if (childResults.length > 1) {
235-
// Return a placeholder to pass multiple children up
236279
return {
237280
id: node.id,
238281
name: '',
@@ -247,7 +290,6 @@ export const sparseTreeService = {
247290

248291
const sparseRoot = buildSparseNode(fullTree, -1);
249292

250-
// Calculate max generation
251293
let maxGeneration = 0;
252294
const findMaxGen = (node: SparseTreeNode) => {
253295
maxGeneration = Math.max(maxGeneration, node.generationFromRoot);
@@ -259,11 +301,11 @@ export const sparseTreeService = {
259301

260302
return {
261303
root: sparseRoot || {
262-
id: rootId,
263-
name: db[rootId]?.name || rootId,
264-
lifespan: db[rootId]?.lifespan || '',
304+
id: canonicalRootId,
305+
name: personData.get(canonicalRootId)?.name || rootId,
306+
lifespan: personData.get(canonicalRootId)?.lifespan || '',
265307
generationFromRoot: 0,
266-
isFavorite: favoriteIds.has(rootId),
308+
isFavorite: favoriteIds.has(canonicalRootId),
267309
},
268310
totalFavorites: favoritesInDb.length,
269311
maxGeneration,

0 commit comments

Comments
 (0)