-
Notifications
You must be signed in to change notification settings - Fork 216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Improve PartialECChangeUnifier to handle very large changeset #7607
base: master
Are you sure you want to change the base?
Changes from all commits
af3ea59
e5d2af4
dae94f9
71858b8
a94de0f
ca48592
849c0fd
b0a1935
4171bd4
9dadbb5
a0e11d0
4bb95d0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
{ | ||
"changes": [ | ||
{ | ||
"packageName": "@itwin/core-backend", | ||
"comment": "Improve change unifer to handle very large changeset", | ||
"type": "none" | ||
} | ||
], | ||
"packageName": "@itwin/core-backend" | ||
} |
Original file line number | Diff line number | Diff line change | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -5,8 +5,9 @@ | |||||||||||
/** @packageDocumentation | ||||||||||||
* @module ECDb | ||||||||||||
*/ | ||||||||||||
import { DbResult, GuidString, Id64String } from "@itwin/core-bentley"; | ||||||||||||
import { DbResult, Guid, GuidString, Id64String } from "@itwin/core-bentley"; | ||||||||||||
import { AnyDb, SqliteChange, SqliteChangeOp, SqliteChangesetReader, SqliteValueStage } from "./SqliteChangesetReader"; | ||||||||||||
import { Base64EncodedString } from "@itwin/core-common"; | ||||||||||||
|
||||||||||||
interface IClassRef { | ||||||||||||
classId: Id64String; | ||||||||||||
|
@@ -410,16 +411,29 @@ namespace DateTime { | |||||||||||
* span multiple tables. | ||||||||||||
* @beta | ||||||||||||
*/ | ||||||||||||
export class PartialECChangeUnifier { | ||||||||||||
private _cache = new Map<string, ChangedECInstance>(); | ||||||||||||
export class PartialECChangeUnifier implements Disposable { | ||||||||||||
private readonly _cacheTable = Guid.createValue(); | ||||||||||||
private _readonly = false; | ||||||||||||
public constructor(private _db: AnyDb) { | ||||||||||||
this.createTempTable(); | ||||||||||||
} | ||||||||||||
|
||||||||||||
/** | ||||||||||||
* Dispose the instance. | ||||||||||||
*/ | ||||||||||||
public [Symbol.dispose](): void { | ||||||||||||
if (this._db.isOpen) { | ||||||||||||
this.dropTempTable(); | ||||||||||||
} | ||||||||||||
} | ||||||||||||
|
||||||||||||
/** | ||||||||||||
* Get root class id for a given class | ||||||||||||
* @param classId given class id | ||||||||||||
* @param db use to find root class | ||||||||||||
* @returns return root class id | ||||||||||||
*/ | ||||||||||||
private static getRootClassId(classId: Id64String, db: AnyDb): Id64String | undefined { | ||||||||||||
private getRootClassId(classId: Id64String): Id64String | undefined { | ||||||||||||
const sql = ` | ||||||||||||
WITH | ||||||||||||
[base_class]([classId], [baseClassId], [Level]) AS( | ||||||||||||
|
@@ -443,7 +457,7 @@ export class PartialECChangeUnifier { | |||||||||||
AND [ss].[Name] = 'CoreCustomAttributes')) | ||||||||||||
ORDER BY [Level] DESC`; | ||||||||||||
|
||||||||||||
return db.withSqliteStatement(sql, (stmt) => { | ||||||||||||
return this._db.withSqliteStatement(sql, (stmt) => { | ||||||||||||
stmt.bindId(1, classId); | ||||||||||||
if (stmt.step() === DbResult.BE_SQLITE_ROW && !stmt.isValueNull(0)) { | ||||||||||||
return stmt.getValueString(0); | ||||||||||||
|
@@ -455,12 +469,12 @@ export class PartialECChangeUnifier { | |||||||||||
* Combine partial instance with instance with same key if already exists. | ||||||||||||
* @param rhs partial instance | ||||||||||||
*/ | ||||||||||||
private combine(rhs: ChangedECInstance, db?: AnyDb): void { | ||||||||||||
private combine(rhs: ChangedECInstance): void { | ||||||||||||
if (!rhs.$meta) { | ||||||||||||
throw new Error("PartialECChange being combine must have '$meta' property"); | ||||||||||||
} | ||||||||||||
const key = PartialECChangeUnifier.buildKey(rhs, db); | ||||||||||||
const lhs = this._cache.get(key); | ||||||||||||
const key = this.buildKey(rhs); | ||||||||||||
const lhs = this.getInstance(key); // get from temp db instead | ||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. comment is probably not needed anymore |
||||||||||||
if (lhs) { | ||||||||||||
const { $meta: _, ...restOfRhs } = rhs; | ||||||||||||
Object.assign(lhs, restOfRhs); | ||||||||||||
|
@@ -469,10 +483,10 @@ export class PartialECChangeUnifier { | |||||||||||
lhs.$meta.changeIndexes = [...rhs.$meta?.changeIndexes, ...lhs.$meta?.changeIndexes]; | ||||||||||||
|
||||||||||||
// we preserve child class name & id when merging instance. | ||||||||||||
if (rhs.$meta.fallbackClassId && lhs.$meta.fallbackClassId && db && rhs.$meta.fallbackClassId !== lhs.$meta.fallbackClassId) { | ||||||||||||
if (rhs.$meta.fallbackClassId && lhs.$meta.fallbackClassId && rhs.$meta.fallbackClassId !== lhs.$meta.fallbackClassId) { | ||||||||||||
const lhsClassId = lhs.$meta.fallbackClassId; | ||||||||||||
const rhsClassId = rhs.$meta.fallbackClassId; | ||||||||||||
const isRhsIsSubClassOfLhs = db.withPreparedStatement("SELECT ec_instanceof(?,?)", (stmt) => { | ||||||||||||
const isRhsIsSubClassOfLhs = this._db.withPreparedStatement("SELECT ec_instanceof(?,?)", (stmt) => { | ||||||||||||
stmt.bindId(1, rhsClassId); | ||||||||||||
stmt.bindId(2, lhsClassId); | ||||||||||||
stmt.step(); | ||||||||||||
|
@@ -484,20 +498,83 @@ export class PartialECChangeUnifier { | |||||||||||
} | ||||||||||||
} | ||||||||||||
} | ||||||||||||
this.setInstance(key, lhs); | ||||||||||||
} else { | ||||||||||||
this._cache.set(key, rhs); | ||||||||||||
this.setInstance(key, rhs); | ||||||||||||
} | ||||||||||||
} | ||||||||||||
|
||||||||||||
private getInstance(key: string): ChangedECInstance | undefined { | ||||||||||||
return this._db.withPreparedSqliteStatement(`SELECT [value] FROM [temp].[${this._cacheTable}] WHERE [key]=?`, (stmt) => { | ||||||||||||
stmt.bindString(1, key); | ||||||||||||
if (stmt.step() === DbResult.BE_SQLITE_ROW) { | ||||||||||||
const out = JSON.parse(stmt.getValueString(0)) as ChangedECInstance; | ||||||||||||
PartialECChangeUnifier.replaceBase64WithUint8Array(out); | ||||||||||||
return out; | ||||||||||||
} | ||||||||||||
return undefined; | ||||||||||||
}); | ||||||||||||
} | ||||||||||||
private static replaceBase64WithUint8Array(row: any): void { | ||||||||||||
Comment on lines
+517
to
+518
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add newline between methods
Suggested change
|
||||||||||||
for (const key of Object.keys(row)) { | ||||||||||||
const val = row[key]; | ||||||||||||
if (typeof val === "string") { | ||||||||||||
if (Base64EncodedString.hasPrefix(val)) { | ||||||||||||
row[key] = Base64EncodedString.toUint8Array(val); | ||||||||||||
} | ||||||||||||
} else if (typeof val === "object" && val !== null) { | ||||||||||||
this.replaceBase64WithUint8Array(val); | ||||||||||||
} | ||||||||||||
} | ||||||||||||
} | ||||||||||||
private static replaceUint8ArrayWithBase64(row: any): void { | ||||||||||||
Comment on lines
529
to
+530
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add newline between methods
Suggested change
|
||||||||||||
for (const key of Object.keys(row)) { | ||||||||||||
const val = row[key]; | ||||||||||||
if (val instanceof Uint8Array) { | ||||||||||||
row[key] = Base64EncodedString.fromUint8Array(val); | ||||||||||||
} else if (typeof val === "object" && val !== null) { | ||||||||||||
this.replaceUint8ArrayWithBase64(val); | ||||||||||||
} | ||||||||||||
} | ||||||||||||
} | ||||||||||||
private setInstance(key: string, value: ChangedECInstance): void { | ||||||||||||
Comment on lines
+539
to
+540
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add newline between methods
Suggested change
|
||||||||||||
const shallowCopy = Object.assign({}, value); | ||||||||||||
PartialECChangeUnifier.replaceUint8ArrayWithBase64(shallowCopy); | ||||||||||||
this._db.withPreparedSqliteStatement(`INSERT INTO [temp].[${this._cacheTable}] ([key], [value]) VALUES (?, ?) ON CONFLICT ([key]) DO UPDATE SET [value] = [excluded].[value]`, (stmt) => { | ||||||||||||
stmt.bindString(1, key); | ||||||||||||
stmt.bindString(2, JSON.stringify(shallowCopy)); | ||||||||||||
stmt.step(); | ||||||||||||
|
||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. remove empty line |
||||||||||||
}); | ||||||||||||
} | ||||||||||||
|
||||||||||||
private createTempTable(): void { | ||||||||||||
// table name should be unique in case two PartialECChangeUnifiers are made | ||||||||||||
this._db.withPreparedSqliteStatement(`CREATE TABLE [temp].[${this._cacheTable}] ([key] text primary key, [value] text)`, (stmt) => { | ||||||||||||
if (DbResult.BE_SQLITE_DONE !== stmt.step()) { | ||||||||||||
// throw new Error(db.nativeDb.getLastError()); | ||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. remove commented out line |
||||||||||||
throw new Error("unable to create temp table"); | ||||||||||||
} | ||||||||||||
}); | ||||||||||||
} | ||||||||||||
private dropTempTable(): void { | ||||||||||||
Comment on lines
+559
to
+560
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add newline between methods
Suggested change
|
||||||||||||
this._db.withPreparedSqliteStatement(`DROP TABLE IF EXISTS [temp].[${this._cacheTable}]`, (stmt) => { | ||||||||||||
if (DbResult.BE_SQLITE_DONE !== stmt.step()) { | ||||||||||||
// throw new Error(db.nativeDb.getLastError()); | ||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. remove commented out line |
||||||||||||
throw new Error("unable to drop temp table"); | ||||||||||||
} | ||||||||||||
}); | ||||||||||||
} | ||||||||||||
/** | ||||||||||||
Comment on lines
+567
to
568
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add newline between methods
Suggested change
|
||||||||||||
* Build key from EC change. | ||||||||||||
* @param change EC change | ||||||||||||
* @returns key created from EC change. | ||||||||||||
*/ | ||||||||||||
private static buildKey(change: ChangedECInstance, db?: AnyDb): string { | ||||||||||||
private buildKey(change: ChangedECInstance): string { | ||||||||||||
let classId = change.ECClassId; | ||||||||||||
if (typeof classId === "undefined") { | ||||||||||||
if (db && change.$meta?.fallbackClassId) { | ||||||||||||
classId = this.getRootClassId(change.$meta.fallbackClassId, db); | ||||||||||||
if (change.$meta?.fallbackClassId) { | ||||||||||||
classId = this.getRootClassId(change.$meta.fallbackClassId); | ||||||||||||
} | ||||||||||||
if (typeof classId === "undefined") { | ||||||||||||
throw new Error(`unable to resolve ECClassId to root class id.`); | ||||||||||||
|
@@ -520,33 +597,43 @@ export class PartialECChangeUnifier { | |||||||||||
throw new Error("this instance is marked as readonly."); | ||||||||||||
} | ||||||||||||
|
||||||||||||
// todo: create table here if not exist on adapter.reader.db | ||||||||||||
|
||||||||||||
if (adaptor.op === "Updated" && adaptor.inserted && adaptor.deleted) { | ||||||||||||
this.combine(adaptor.inserted, adaptor.reader.db); | ||||||||||||
this.combine(adaptor.deleted, adaptor.reader.db); | ||||||||||||
this.combine(adaptor.inserted); | ||||||||||||
this.combine(adaptor.deleted); | ||||||||||||
} else if (adaptor.op === "Inserted" && adaptor.inserted) { | ||||||||||||
this.combine(adaptor.inserted, adaptor.reader.db); | ||||||||||||
this.combine(adaptor.inserted); | ||||||||||||
} else if (adaptor.op === "Deleted" && adaptor.deleted) { | ||||||||||||
this.combine(adaptor.deleted, adaptor.reader.db); | ||||||||||||
this.combine(adaptor.deleted); | ||||||||||||
} | ||||||||||||
} | ||||||||||||
/** | ||||||||||||
* Delete $meta from all the instances. | ||||||||||||
*/ | ||||||||||||
public stripMetaData(): void { | ||||||||||||
for (const inst of this._cache.values()) { | ||||||||||||
if ("$meta" in inst) { | ||||||||||||
delete inst.$meta; | ||||||||||||
} | ||||||||||||
} | ||||||||||||
this._db.withPreparedSqliteStatement(`UPDATE [temp].[${this._cacheTable}] SET [value] = json_remove([value], '$meta')`, (stmt) => { | ||||||||||||
stmt.step(); | ||||||||||||
}); | ||||||||||||
this._readonly = true; | ||||||||||||
} | ||||||||||||
private *getInstances(): IterableIterator<ChangedECInstance> { | ||||||||||||
Comment on lines
619
to
+620
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add newline between methods
Suggested change
|
||||||||||||
const stmt = this._db.prepareSqliteStatement(`SELECT [value] FROM [temp].[${this._cacheTable}]`); | ||||||||||||
while (stmt.step() === DbResult.BE_SQLITE_ROW) { | ||||||||||||
const value = JSON.parse(stmt.getValueString(0)) as ChangedECInstance; | ||||||||||||
PartialECChangeUnifier.replaceBase64WithUint8Array(value); | ||||||||||||
yield value; | ||||||||||||
} | ||||||||||||
stmt[Symbol.dispose](); | ||||||||||||
} | ||||||||||||
/** | ||||||||||||
Comment on lines
+628
to
629
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add newline between methods
Suggested change
|
||||||||||||
* Returns complete EC change instances. | ||||||||||||
* @beta | ||||||||||||
*/ | ||||||||||||
public get instances(): IterableIterator<ChangedECInstance> { return this._cache.values(); } | ||||||||||||
public get instances(): IterableIterator<ChangedECInstance> { | ||||||||||||
return this.getInstances(); | ||||||||||||
} | ||||||||||||
} | ||||||||||||
|
||||||||||||
/** | ||||||||||||
* Transform sqlite change to ec change. EC change is partial change as | ||||||||||||
* it is per table while a single instance can span multiple table. | ||||||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you think about turning using a temp table into an option? Because this method should be quite a bit slower than the old method of just keeping the values in a map in RAM.
Maybe just a boolean parameter, then the question is which should be the default? Only stupidly large iModels/Changesets will have this issue - but those aren't THAT uncommon or unexpected. It'd be nice if we could automatically make an educated guess for which method to use based on the size of the iModel and changes - but is that feasible here?