Skip to content

Commit 694f61e

Browse files
fix: paginate Explorer CRE load and batch-hydrate all_cres
Fix production H12 timeouts from GET /all_cres?per_page=1000 by batching N+1 link hydration in the DB layer, capping per_page at 100, scoping DataProvider to Explorer routes with incremental page loads, and using ensureFullExplorerData for graph views. Closes #930. Related: #847, #848. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 294b17e commit 694f61e

15 files changed

Lines changed: 557 additions & 175 deletions

File tree

application/database/db.py

Lines changed: 97 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from pprint import pprint
1212

13-
from collections import Counter
13+
from collections import Counter, defaultdict
1414
from itertools import permutations
1515
from typing import Any, Dict, List, Optional, Tuple, cast
1616
from neomodel.exceptions import (
@@ -1449,7 +1449,6 @@ def get_CREs(
14491449
include_only: Optional[List[str]] = None,
14501450
internal_id: Optional[str] = None,
14511451
) -> List[cre_defs.CRE]:
1452-
cres: List[cre_defs.CRE] = []
14531452
query = CRE.query
14541453
if not external_id and not name and not description and not internal_id:
14551454
logger.error(
@@ -1484,65 +1483,128 @@ def get_CREs(
14841483
)
14851484
return []
14861485

1487-
for matching_cre in dbcres:
1488-
cre = CREfromDB(matching_cre)
1489-
cre.links = self.__make_cre_links(
1490-
cre=matching_cre, include_only_nodes=include_only
1491-
)
1492-
cre.links.extend(self.__make_cre_internal_links(cre=matching_cre))
1493-
cres.append(cre)
1494-
return cres
1486+
return self._hydrate_cres_batch(dbcres, include_only_nodes=include_only)
14951487

1496-
def __make_cre_internal_links(self, cre: CRE) -> List[cre_defs.Link]:
1497-
links = []
1498-
internal_links = (
1488+
def _hydrate_cres_batch(
1489+
self,
1490+
dbcres: List[CRE],
1491+
include_only_nodes: Optional[List[str]] = None,
1492+
) -> List[cre_defs.CRE]:
1493+
if not dbcres:
1494+
return []
1495+
1496+
cre_ids = [cre.id for cre in dbcres]
1497+
node_links_by_cre: Dict[str, List[Links]] = defaultdict(list)
1498+
node_ids: set = set()
1499+
for link in self.session.query(Links).filter(Links.cre.in_(cre_ids)).all():
1500+
node_links_by_cre[link.cre].append(link)
1501+
node_ids.add(link.node)
1502+
1503+
nodes_by_id: Dict[str, Node] = {}
1504+
if node_ids:
1505+
nodes_by_id = {
1506+
node.id: node
1507+
for node in self.session.query(Node).filter(Node.id.in_(node_ids)).all()
1508+
}
1509+
1510+
internal_links_by_cre: Dict[str, List[InternalLinks]] = defaultdict(list)
1511+
linked_cre_ids: set = set()
1512+
for internal_link in (
14991513
self.session.query(InternalLinks)
15001514
.filter(
1501-
sqla.or_(InternalLinks.cre == cre.id, InternalLinks.group == cre.id)
1515+
sqla.or_(
1516+
InternalLinks.cre.in_(cre_ids),
1517+
InternalLinks.group.in_(cre_ids),
1518+
)
15021519
)
15031520
.all()
1504-
)
1521+
):
1522+
linked_cre_ids.add(internal_link.cre)
1523+
linked_cre_ids.add(internal_link.group)
1524+
if internal_link.cre in cre_ids:
1525+
internal_links_by_cre[internal_link.cre].append(internal_link)
1526+
if internal_link.group in cre_ids:
1527+
internal_links_by_cre[internal_link.group].append(internal_link)
1528+
1529+
cres_by_id: Dict[str, CRE] = {cre.id: cre for cre in dbcres}
1530+
extra_cre_ids = linked_cre_ids - set(cre_ids)
1531+
if extra_cre_ids:
1532+
for linked in (
1533+
self.session.query(CRE).filter(CRE.id.in_(extra_cre_ids)).all()
1534+
):
1535+
cres_by_id[linked.id] = linked
15051536

1506-
if len(internal_links) == 0:
1537+
result: List[cre_defs.CRE] = []
1538+
for matching_cre in dbcres:
1539+
cre = CREfromDB(matching_cre)
1540+
cre.links = self._assemble_cre_node_links(
1541+
node_links_by_cre.get(matching_cre.id, []),
1542+
nodes_by_id,
1543+
include_only_nodes,
1544+
)
1545+
seen_internal: set = set()
1546+
internal_rows = []
1547+
for row in internal_links_by_cre.get(matching_cre.id, []):
1548+
key = (row.cre, row.group, row.type)
1549+
if key not in seen_internal:
1550+
seen_internal.add(key)
1551+
internal_rows.append(row)
1552+
cre.links.extend(
1553+
self._assemble_cre_internal_links(
1554+
matching_cre, internal_rows, cres_by_id
1555+
)
1556+
)
1557+
result.append(cre)
1558+
return result
1559+
1560+
def _assemble_cre_internal_links(
1561+
self,
1562+
cre: CRE,
1563+
internal_links: List[InternalLinks],
1564+
cres_by_id: Dict[str, CRE],
1565+
) -> List[cre_defs.Link]:
1566+
links: List[cre_defs.Link] = []
1567+
if not internal_links:
15071568
logger.debug(
15081569
f"CRE {cre.name}:{cre.external_id}:{cre.id} has no internal links"
15091570
)
15101571

15111572
for internal_link in internal_links:
1512-
1513-
linked_cre_query = self.session.query(CRE)
15141573
link_type = cre_defs.LinkTypes.from_str(internal_link.type)
15151574

15161575
if internal_link.cre == cre.id:
1517-
# if we are the lower cre in this relationship, we need to flip the "Contains" linktypes
1518-
linked_cre = linked_cre_query.filter(
1519-
CRE.id == internal_link.group
1520-
).first() # get the higher cre so we can add the link
1576+
linked_cre = cres_by_id.get(internal_link.group)
1577+
if not linked_cre:
1578+
continue
15211579
if link_type == cre_defs.LinkTypes.Contains:
15221580
links.append(
15231581
cre_defs.Link(
15241582
ltype=cre_defs.LinkTypes.PartOf,
15251583
document=CREfromDB(linked_cre),
15261584
)
15271585
)
1528-
elif (
1529-
link_type == cre_defs.LinkTypes.Related
1530-
): # if it's not a "Contains" link, it's a "Related" link
1586+
elif link_type == cre_defs.LinkTypes.Related:
15311587
links.append(
15321588
cre_defs.Link(ltype=link_type, document=CREfromDB(linked_cre))
15331589
)
15341590
continue
1535-
# if we are are the higher cre then we don't need to do anything, relationship types are always "higher"->"lower"
1536-
linked_cre = linked_cre_query.filter(CRE.id == internal_link.cre).first()
1537-
links.append(cre_defs.Link(ltype=link_type, document=CREfromDB(linked_cre)))
1591+
1592+
linked_cre = cres_by_id.get(internal_link.cre)
1593+
if linked_cre:
1594+
links.append(
1595+
cre_defs.Link(ltype=link_type, document=CREfromDB(linked_cre))
1596+
)
15381597
return links
15391598

1540-
def __make_cre_links(
1541-
self, cre: CRE, include_only_nodes: List[str]
1599+
def _assemble_cre_node_links(
1600+
self,
1601+
node_links: List[Links],
1602+
nodes_by_id: Dict[str, Node],
1603+
include_only_nodes: Optional[List[str]],
15421604
) -> List[cre_defs.Link]:
1543-
links = []
1544-
for link in self.session.query(Links).filter(Links.cre == cre.id).all():
1545-
node = self.session.query(Node).filter(Node.id == link.node).first()
1605+
links: List[cre_defs.Link] = []
1606+
for link in node_links:
1607+
node = nodes_by_id.get(link.node)
15461608
if node and (not include_only_nodes or node.name in include_only_nodes):
15471609
links.append(
15481610
cre_defs.Link(
@@ -1647,13 +1709,11 @@ def export(self, dir: str = None, dry_run: bool = False) -> List[cre_defs.Docume
16471709
def all_cres_with_pagination(
16481710
self, page: int = 1, per_page: int = 10
16491711
) -> List[cre_defs.CRE]:
1650-
result: List[cre_defs.CRE] = []
16511712
cres = self.session.query(CRE).paginate(
16521713
page=int(page), per_page=per_page, error_out=False
16531714
)
16541715
total_pages = cres.pages
1655-
for cre in cres.items:
1656-
result.extend(self.get_CREs(external_id=cre.external_id))
1716+
result = self._hydrate_cres_batch(list(cres.items))
16571717
return result, page, total_pages
16581718

16591719
def get_cre_path(self, fromID: str, toID: str) -> List[cre_defs.Document]:
@@ -2202,10 +2262,7 @@ def get_root_cres(self):
22022262
)
22032263
.all()
22042264
)
2205-
result = []
2206-
for c in cres:
2207-
result.extend(self.get_CREs(external_id=c.external_id))
2208-
return result
2265+
return self._hydrate_cres_batch(list(cres))
22092266

22102267
def get_embeddings_by_doc_type(self, doc_type: str) -> Dict[str, List[float]]:
22112268
res = {}

application/frontend/src/App.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { QueryClient, QueryClientProvider } from 'react-query';
66
import { BrowserRouter } from 'react-router-dom';
77

88
import { GlobalFilterState, filterContext } from './hooks/applyFilters';
9-
import { DataProvider } from './providers/DataProvider';
109
import { MainContentArea } from './scaffolding';
1110

1211
const queryClient = new QueryClient();
@@ -15,12 +14,10 @@ const App = () => (
1514
<div className="app">
1615
<filterContext.Provider value={GlobalFilterState}>
1716
<QueryClientProvider client={queryClient}>
18-
<DataProvider>
19-
<BrowserRouter>
20-
<Toaster />
21-
<MainContentArea />
22-
</BrowserRouter>
23-
</DataProvider>
17+
<BrowserRouter>
18+
<Toaster />
19+
<MainContentArea />
20+
</BrowserRouter>
2421
</QueryClientProvider>
2522
</filterContext.Provider>
2623
</div>

application/frontend/src/const.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export const TWO_DAYS_MILLISECONDS = 1.728e8;
2+
export const EXPLORER_CRE_PAGE_SIZE = 20;
23

34
export const TYPE_IS_PART_OF = 'Is Part Of';
45
export const TYPE_LINKED_TO = 'Linked To';
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from 'react';
2+
3+
import { DataProvider } from '../../providers/DataProvider';
4+
5+
type ExplorerLayoutProps = {
6+
children: React.ReactNode;
7+
};
8+
9+
export const ExplorerLayout = ({ children }: ExplorerLayoutProps) => <DataProvider>{children}</DataProvider>;
10+
11+
export function withExplorerLayout<P extends object>(Component: React.ComponentType<P>): React.FC<P> {
12+
const Wrapped = (props: P) => (
13+
<ExplorerLayout>
14+
<Component {...props} />
15+
</ExplorerLayout>
16+
);
17+
Wrapped.displayName = `ExplorerLayout(${Component.displayName || Component.name || 'Component'})`;
18+
return Wrapped;
19+
}

application/frontend/src/pages/Explorer/explorer.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ import { GraphDebugPanel } from './GraphDebugPanel';
1313
import { LinkedStandards } from './LinkedStandards';
1414

1515
export const Explorer = () => {
16-
const { dataLoading, dataTree, dataStore } = useDataStore();
16+
const { dataLoading, dataTree, dataStore, hasMore, isLoadingMore, loadNextPage } = useDataStore();
1717
const [loading, setLoading] = useState<boolean>(false);
1818
const [filter, setFilter] = useState('');
1919
const [filteredTree, setFilteredTree] = useState<TreeDocument[]>();
2020
const [debugMode, setDebugMode] = useState<boolean>(false);
21+
const loadMoreRef = React.useRef<HTMLDivElement | null>(null);
2122

2223
const applyHighlight = (text, term) => {
2324
if (!term) return text;
@@ -84,6 +85,25 @@ export const Explorer = () => {
8485
setLoading(dataLoading);
8586
}, [dataLoading]);
8687

88+
useEffect(() => {
89+
const sentinel = loadMoreRef.current;
90+
if (!sentinel || !hasMore) {
91+
return;
92+
}
93+
94+
const observer = new IntersectionObserver(
95+
(entries) => {
96+
if (entries.some((entry) => entry.isIntersecting)) {
97+
loadNextPage();
98+
}
99+
},
100+
{ rootMargin: '200px' }
101+
);
102+
103+
observer.observe(sentinel);
104+
return () => observer.disconnect();
105+
}, [hasMore, loadNextPage, filteredTree]);
106+
87107
function processNode(item) {
88108
if (!item) {
89109
return <></>;
@@ -198,6 +218,8 @@ export const Explorer = () => {
198218
return processNode(item);
199219
})}
200220
</List>
221+
<div ref={loadMoreRef} style={{ height: 1 }} />
222+
{isLoadingMore && hasMore && <p className="explorer-load-more">Loading more requirements…</p>}
201223
</main>
202224
</>
203225
);

application/frontend/src/pages/Explorer/visuals/circles/circles.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Button, Icon } from 'semantic-ui-react';
1010
export const ExplorerCircles = () => {
1111
const { height, width } = useWindowDimensions();
1212
const [useFullScreen, setUseFullScreen] = useState(false);
13-
const { dataLoading, dataTree } = useDataStore();
13+
const { dataLoading, dataTree, ensureFullExplorerData, fullLoadProgress } = useDataStore();
1414
const [breadcrumb, setBreadcrumb] = useState<string[]>([]);
1515
const svgRef = React.useRef(null);
1616

@@ -21,6 +21,10 @@ export const ExplorerCircles = () => {
2121
const zoomToRef = React.useRef<any>(null);
2222
const margin = 20;
2323

24+
useEffect(() => {
25+
ensureFullExplorerData();
26+
}, [ensureFullExplorerData]);
27+
2428
const defaultSize = width > height ? height - 100 : width;
2529
const size = useFullScreen ? width : defaultSize;
2630

@@ -447,7 +451,10 @@ export const ExplorerCircles = () => {
447451
<g transform={`translate(${size / 2},${size / 2})`}></g>
448452
</svg>
449453
</div>
450-
<LoadingAndErrorIndicator loading={dataLoading} error={null} />
454+
<LoadingAndErrorIndicator loading={dataLoading || !!fullLoadProgress} error={null} />
455+
{fullLoadProgress && (
456+
<p className="explorer-full-load-progress">Loading graph data ({fullLoadProgress})…</p>
457+
)}
451458
</div>
452459
);
453460
};

application/frontend/src/pages/Explorer/visuals/force-graph/forceGraph.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ export const ExplorerForceGraph = () => {
2222
const [ignoreTypes, setIgnoreTypes] = useState(['same']);
2323
const [maxCount, setMaxCount] = useState(0);
2424
const [maxNodeSize, setMaxNodeSize] = useState(0);
25-
const { dataLoading, dataTree, getStoreKey, dataStore } = useDataStore();
25+
const { dataLoading, dataTree, getStoreKey, dataStore, ensureFullExplorerData, fullLoadProgress } =
26+
useDataStore();
2627
const fgRef = useRef<ForceGraphMethods>();
2728
// ADDING STATE FOR FILTERING LOGIC
2829
const [filterTypeA, setFilterTypeA] = useState('');
@@ -32,6 +33,10 @@ export const ExplorerForceGraph = () => {
3233
const [creOptions, setCreOptions] = useState<DropdownOption[]>([]);
3334
const [combinedOptions, setCombinedOptions] = useState<DropdownOption[]>([]);
3435

36+
useEffect(() => {
37+
ensureFullExplorerData();
38+
}, [ensureFullExplorerData]);
39+
3540
// Adding a show all checkbox
3641
const [showAll, setShowAll] = useState(true);
3742

@@ -431,7 +436,10 @@ export const ExplorerForceGraph = () => {
431436
}, [graphData]);
432437
return (
433438
<div>
434-
<LoadingAndErrorIndicator loading={dataLoading} error={null} />
439+
<LoadingAndErrorIndicator loading={dataLoading || !!fullLoadProgress} error={null} />
440+
{fullLoadProgress && (
441+
<p className="explorer-full-load-progress">Loading graph data ({fullLoadProgress})…</p>
442+
)}
435443

436444
<Checkbox
437445
label="Contains"

0 commit comments

Comments
 (0)