Skip to content

Commit 2a10e64

Browse files
authored
Loading Fix For Named Version Selector (#250)
## 🚀 Named Version Optimization & Bug Fixes ### **Performance Issues Fixed:** 1. **Eliminated massive changeset over-fetching** - Previously loaded ALL changesets `[0 -> Inf)` upfront - Now uses efficient pagination (20 items at a time) 2. **Parallelized individual changeset queries** - Replaced sequential api calls with more efficient method of querying resulting in less load time ### **Critical Bug Fixed:** 3. **Missing index offset for Named Versions** - Fixed to properly apply `+1 offset` as required by [Changed Elements API](https://developer.bentley.com/tutorials/changed-elements-api/#221-using-the-api-to-get-changed-elements) ### **Result:** - **Correct comparison ranges** (was comparing wrong changeset ranges before) - Prevents massive amounts of queries due to preloading changesets The optimization maintains identical functionality while fixing the core performance bottleneck and ensuring API compliance.
1 parent 61284a8 commit 2a10e64

File tree

2 files changed

+136
-123
lines changed

2 files changed

+136
-123
lines changed

.changeset/loud-clowns-fall.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@itwin/changed-elements-react": patch
3+
---
4+
5+
### **Performance Issues Fixed:**
6+
7+
1. **Eliminated massive changeset over-fetching**
8+
- Previously loaded ALL changesets `[0 -> Inf)` upfront
9+
- Now uses efficient pagination (20 items at a time)
10+
11+
2. **Parallelized individual changeset queries**
12+
- Replaced sequential api calls with more efficient method
13+
14+
### **Critical Bug Fixed:**
15+
16+
3. **Missing index offset for Named Versions**
17+
- Fixed to properly apply `+1 offset` as required by [Changed Elements API](https://developer.bentley.com/tutorials/changed-elements-api/#221-using-the-api-to-get-changed-elements)

packages/changed-elements-react/src/NamedVersionSelector/useNamedVersionsList.ts

Lines changed: 119 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import { IModelApp } from "@itwin/core-frontend";
66
import { useEffect, useMemo, useState } from "react";
77

8-
import type { IModelsClient, NamedVersion } from "../clients/iModelsClient.js";
8+
import type { NamedVersion, Changeset } from "../clients/iModelsClient.js";
99
import { isAbortError } from "../utils/utils.js";
1010
import { useVersionCompare } from "../VersionCompareContext.js";
1111

@@ -116,129 +116,130 @@ export function useNamedVersionsList(args: UseNamedVersionListArgs): UseNamedVer
116116

117117
useEffect(
118118
() => {
119-
const abortController = new AbortController();
119+
let disposed = false;
120+
120121
setIsLoading(true);
121122
setIsError(false);
122123
setEntries([]);
124+
setCurrentNamedVersion(undefined);
123125

124126
void (async () => {
125127
try {
126-
abortController.signal.throwIfAborted();
127-
// Slow! This loads all Changesets [0 -> Inf) but we'll only use [currentChangeset -> 0].
128-
// We don't need the early Changesets yet because they represent the oldest
129-
// Named Versions which will most likely appear below the fold.
130-
const changesets = await iModelsClient.getChangesets({
128+
// First, get the current changeset to establish our baseline
129+
const currentChangeset = await iModelsClient.getChangeset({
131130
iModelId,
132-
signal: abortController.signal,
131+
changesetId: currentChangesetId,
133132
});
134-
abortController.signal.throwIfAborted();
133+
const allNamedVersions: NamedVersion[] = [];
134+
if (disposed) return;
135135

136-
// Discard all future Changesets relative to the current Changeset
137-
const currentChangesetArrayIndex = changesets.findIndex(
138-
({ id }) => id === currentChangesetId,
139-
);
140-
if (currentChangesetArrayIndex === -1) {
141-
setIsLoading(false);
136+
if (!currentChangeset) {
142137
setIsError(true);
143-
setCurrentNamedVersion(undefined);
138+
setIsLoading(false);
144139
return;
145140
}
146141

147-
changesets.splice(currentChangesetArrayIndex + 1);
148-
149-
// We'll be looking at the most recent Named Versions first thus order
150-
// Changesets from current to oldest; highest index to lowest.
151-
152-
changesets.reverse();
153-
const currentChangeset = changesets[0];
154-
let currentNamedVersion: NamedVersion | undefined = undefined;
155-
let seekHead = 1;
156-
157-
const iterator = loadNamedVersions(iModelsClient, iModelId, abortController.signal);
158-
for await (const page of iterator) {
159-
// Skip pages that are newer than the currentChangeset. We'll always
160-
// find the oldest (smallest) Changeset index at the back of the page.
161-
if (currentChangeset.index < page[page.length - 1].changesetIndex) {
162-
continue;
142+
let currentNamedVersionFound: NamedVersion | undefined;
143+
let currentPage = 0;
144+
const pageSize = 20;
145+
146+
// Load Named Versions in pages
147+
while (!disposed) {
148+
const namedVersions = await iModelsClient.getNamedVersions({
149+
iModelId,
150+
top: pageSize,
151+
skip: currentPage * pageSize,
152+
orderby: "changesetIndex",
153+
ascendingOrDescending: "desc",
154+
});
155+
allNamedVersions.push(...namedVersions);
156+
if (disposed) return;
157+
158+
// If no more results, we're done
159+
if (namedVersions.length === 0) {
160+
break;
163161
}
162+
// Filter to only versions older than current
163+
const relevantVersions = namedVersions.filter(
164+
nv => nv.changesetIndex < currentChangeset.index,
165+
);
166+
167+
// Process this page of named versions with Promise.allSettled for better error handling
168+
const changesetPromises = relevantVersions.map(async (namedVersion) => {
169+
// we must offset the named versions , because that changeset is "already applied" to the named version, see this:
170+
// https://developer.bentley.com/tutorials/changed-elements-api/#221-using-the-api-to-get-changed-elements
171+
// this assuming latest is current
172+
const offsetChangesetIndex = (namedVersion.changesetIndex + 1).toString();
173+
174+
const changeSet = await iModelsClient.getChangeset({
175+
iModelId: iModelId,
176+
changesetId: offsetChangesetIndex,
177+
});
164178

165-
// According to the Intermediate Value Theorem, we must have crossed
166-
// the current Named Version in between the start and the end of current
167-
// page. If we can't find it here, we'll assume currentChangeset exists
168-
// at its declared index but doesn't have a Named Version pointing at it.
169-
170-
const entries: NamedVersionEntry[] = [];
171-
172-
for (let i = 0; i < page.length; ++i) {
173-
const namedVersion = page[i];
174-
175-
if (!currentNamedVersion) {
176-
if (currentChangeset.index < namedVersion.changesetIndex) {
177-
continue;
178-
}
179-
180-
if (namedVersion.changesetId === currentChangeset.id) {
181-
currentNamedVersion = namedVersion;
182-
setCurrentNamedVersion(namedVersion);
183-
continue;
184-
}
185-
186-
currentNamedVersion = {
187-
id: currentChangeset.id,
188-
displayName: IModelApp.localization.getLocalizedString(
189-
"VersionCompare:versionCompare.currentChangeset",
190-
),
191-
changesetId: currentChangeset.id,
192-
changesetIndex: currentChangeset.index,
193-
description: currentChangeset.description,
194-
createdDateTime: currentChangeset.pushDateTime,
195-
};
196-
setCurrentNamedVersion(currentNamedVersion);
179+
return {
180+
namedVersion,
181+
changeSet,
182+
offsetChangesetIndex,
183+
};
184+
});
185+
186+
// Execute all in parallel with individual error handling
187+
const results = await Promise.allSettled(changesetPromises);
188+
189+
// Process results
190+
const pageEntries: NamedVersionEntry[] = [];
191+
results.forEach((result, index) => {
192+
if (result.status === "fulfilled" && result.value.changeSet) {
193+
pageEntries.push({
194+
namedVersion: {
195+
...result.value.namedVersion,
196+
targetChangesetId: result.value.changeSet.id,
197+
},
198+
job: undefined,
199+
});
200+
} else {
201+
const namedVersion = relevantVersions[index];
202+
// eslint-disable-next-line no-console
203+
console.warn(`Could not fetch target changeset for named version ${namedVersion.displayName}`);
197204
}
205+
});
198206

199-
// Changed Elements service asks for a changeset range to operate
200-
// on. Because user expects to see changes made since the selected
201-
// NamedVersion, we need to find the first Changeset that follows
202-
// the target NamedVersion.
203-
const recoveryPosition = seekHead;
204-
while (
205-
seekHead < changesets.length && namedVersion.changesetIndex < changesets[seekHead].index
206-
) {
207-
seekHead += 1;
208-
}
207+
if (disposed) return;
209208

210-
if (changesets[seekHead].id !== namedVersion.changesetId) {
211-
// We didn't find the Changeset that this Named Version is based
212-
// on. UI should mark this Named Version as invalid but that's not
213-
// yet implemented.
214-
seekHead = recoveryPosition;
215-
continue;
216-
}
209+
// Add to entries if we have any
210+
if (pageEntries.length > 0) {
211+
setEntries(prev => prev.concat(pageEntries));
212+
}
217213

218-
entries.push({
219-
namedVersion: {
220-
...namedVersion,
221-
targetChangesetId: changesets[seekHead - 1].id,
222-
},
223-
job: undefined,
224-
});
214+
// If we got fewer results than page size, we're done
215+
if (namedVersions.length < pageSize) {
216+
break;
225217
}
226218

227-
setEntries((prev) => prev.concat(entries));
219+
currentPage++;
220+
}
221+
// Set current named version if not found yet
222+
if (!currentNamedVersionFound) {
223+
currentNamedVersionFound = getOrCreateCurrentNamedVersion(
224+
allNamedVersions,
225+
currentChangeset,
226+
);
227+
if (disposed) return;
228+
setCurrentNamedVersion(currentNamedVersionFound);
228229
}
229-
230-
setIsLoading(false);
231230
} catch (error) {
232-
if (!isAbortError(error)) {
233-
// eslint-disable-next-line no-console
234-
console.error(error);
235-
setIsLoading(false);
231+
if (!disposed && !isAbortError(error)) {
236232
setIsError(true);
237233
}
234+
} finally {
235+
if (!disposed) {
236+
setIsLoading(false);
237+
}
238238
}
239239
})();
240+
240241
return () => {
241-
abortController.abort();
242+
disposed = true;
242243
};
243244
},
244245
[iModelsClient, iTwinId, iModelId, currentChangesetId],
@@ -253,33 +254,28 @@ export function useNamedVersionsList(args: UseNamedVersionListArgs): UseNamedVer
253254
};
254255
}
255256

256-
/** Returns pages of Named Versions in reverse chronological order. */
257-
async function* loadNamedVersions(
258-
iModelsClient: IModelsClient,
259-
iModelId: string,
260-
signal: AbortSignal,
261-
): AsyncGenerator<NamedVersion[]> {
262-
signal.throwIfAborted();
263-
264-
const pageSize = 20;
265-
let skip = 0;
266-
267-
while (true) {
268-
const namedVersions = await iModelsClient.getNamedVersions({
269-
iModelId,
270-
top: pageSize,
271-
skip,
272-
orderby: "changesetIndex",
273-
ascendingOrDescending: "desc",
274-
signal,
275-
});
276-
signal.throwIfAborted();
277-
278-
if (namedVersions.length === 0) {
279-
return;
280-
}
257+
function getOrCreateCurrentNamedVersion(
258+
namedVersions: NamedVersion[],
259+
currentChangeset: Changeset,
260+
): NamedVersion {
261+
// Check if current changeset has a named version
262+
const existingNamedVersion = namedVersions.find(
263+
nv => nv.changesetId === currentChangeset.id || nv.changesetIndex === currentChangeset.index,
264+
);
281265

282-
skip += namedVersions.length;
283-
yield namedVersions;
266+
if (existingNamedVersion) {
267+
return existingNamedVersion;
284268
}
269+
270+
// Create synthetic named version for current changeset
271+
return {
272+
id: currentChangeset.id,
273+
displayName: IModelApp.localization.getLocalizedString(
274+
"VersionCompare:versionCompare.currentChangeset",
275+
),
276+
changesetId: currentChangeset.id,
277+
changesetIndex: currentChangeset.index,
278+
description: currentChangeset.description || "",
279+
createdDateTime: currentChangeset.pushDateTime || new Date().toISOString(),
280+
};
285281
}

0 commit comments

Comments
 (0)