@itwin/core-backend
Version:
iTwin.js backend components
345 lines • 19.2 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
import { IModel, SubCategoryAppearance, } from "@itwin/core-common";
import * as chai from "chai";
import { assert, expect } from "chai";
import * as chaiAsPromised from "chai-as-promised";
import { HubWrappers, KnownTestLocations } from "../";
import { HubMock } from "../../internal/HubMock";
import { BriefcaseDb, ChannelControl, SpatialCategory, } from "../../core-backend";
import { IModelTestUtils, TestUserType } from "../IModelTestUtils";
chai.use(chaiAsPromised);
import * as sinon from "sinon";
export async function createNewModelAndCategory(rwIModel, parent) {
// Create a new physical model.
const [, modelId] = await IModelTestUtils.createAndInsertPhysicalPartitionAndModelAsync(rwIModel, IModelTestUtils.getUniqueModelCode(rwIModel, "newPhysicalModel"), true, parent);
// Find or create a SpatialCategory.
const dictionary = rwIModel.models.getModel(IModel.dictionaryId);
const newCategoryCode = IModelTestUtils.getUniqueSpatialCategoryCode(dictionary, "ThisTestSpatialCategory");
const category = SpatialCategory.create(rwIModel, IModel.dictionaryId, newCategoryCode.value);
const spatialCategoryId = rwIModel.elements.insertElement(category.toJSON());
category.setDefaultAppearance(new SubCategoryAppearance({ color: 0xff0000 }));
return { modelId, spatialCategoryId };
}
async function assertThrowsAsync(test, msg) {
try {
await test();
}
catch (e) {
if (e instanceof Error && msg) {
assert.equal(e.message, msg);
}
return;
}
throw new Error(`Failed to throw error with message: "${msg}"`);
}
describe.skip("Merge conflict & locking", () => {
let iTwinId;
before(() => {
HubMock.startup("MergeConflictTest", KnownTestLocations.outputDir);
iTwinId = HubMock.iTwinId;
});
after(() => HubMock.shutdown());
it("pull/merge causing update conflict - dirty read/modify (with no lock)", async () => {
/**
* To simulate a a data conflict for dirty read we update same aspect from two different briefcases
* and when the second briefcase try to push its changes it will fail with following error.
* "UPDATE/DELETE before value do not match with one in db or CASCADE action was triggered."
*/
const accessToken1 = await HubWrappers.getAccessToken(TestUserType.SuperManager);
const accessToken2 = await HubWrappers.getAccessToken(TestUserType.Regular);
const accessToken3 = await HubWrappers.getAccessToken(TestUserType.Super);
// Delete any existing iModels with the same name as the read-write test iModel
const iModelName = "TestIModel";
// Create a new empty iModel on the Hub & obtain a briefcase
const rwIModelId = await HubMock.createNewIModel({ accessToken: accessToken1, iTwinId, iModelName, description: "TestSubject", noLocks: undefined });
assert.isNotEmpty(rwIModelId);
// to reproduce the issue we will disable locks altogether.
const b1 = await HubWrappers.downloadAndOpenBriefcase({ accessToken: accessToken1, iTwinId, iModelId: rwIModelId, noLock: true });
b1.channels.addAllowedChannel(ChannelControl.sharedChannelName);
const b2 = await HubWrappers.downloadAndOpenBriefcase({ accessToken: accessToken2, iTwinId, iModelId: rwIModelId, noLock: true });
b2.channels.addAllowedChannel(ChannelControl.sharedChannelName);
const b3 = await HubWrappers.downloadAndOpenBriefcase({ accessToken: accessToken3, iTwinId, iModelId: rwIModelId, noLock: true });
// create and insert a new model with code1
const [, modelId] = IModelTestUtils.createAndInsertPhysicalPartitionAndModel(b1, IModelTestUtils.getUniqueModelCode(b1, "newPhysicalModel"), true);
const dictionary = b1.models.getModel(IModel.dictionaryId);
const newCategoryCode = IModelTestUtils.getUniqueSpatialCategoryCode(dictionary, "ThisTestSpatialCategory");
const spatialCategoryId = SpatialCategory.insert(dictionary.iModel, dictionary.id, newCategoryCode.value, new SubCategoryAppearance({ color: 0xff0000 }));
// insert element and aspect
const el1 = b1.elements.insertElement(IModelTestUtils.createPhysicalObject(b1, modelId, spatialCategoryId).toJSON());
await b1.pullChanges();
const aspectId1 = b1.elements.insertAspect({
classFullName: "BisCore:ExternalSourceAspect",
element: {
relClassName: "BisCore:ElementOwnsExternalSourceAspects",
id: el1,
},
kind: "",
identifier: "test identifier",
});
b1.saveChanges();
await b1.pushChanges({ accessToken: accessToken1, description: `inserted element with aspect ${el1}` });
// b1 same as b2 and now both will modify same aspect
await b2.pullChanges();
// b1 will change identifier from "test identifier" to "test identifier (modified by b1)"
b1.elements.updateAspect({
id: aspectId1,
classFullName: "BisCore:ExternalSourceAspect",
element: {
relClassName: "BisCore:ElementOwnsExternalSourceAspects",
id: el1,
},
kind: "",
identifier: "test identifier (modified by b1)",
});
b1.saveChanges();
await b1.pushChanges({ accessToken: accessToken1, description: `modify aspect ${aspectId1} with no lock` });
// b2 will change identifier from "test identifier" to "test identifier (modified by b2)"
b2.elements.updateAspect({
id: aspectId1,
classFullName: "BisCore:ExternalSourceAspect",
element: {
relClassName: "BisCore:ElementOwnsExternalSourceAspects",
id: el1,
},
kind: "",
identifier: "test identifier (modified by b2)",
});
b2.saveChanges();
const conflicts = [];
// we will not handle conflict and let the default conflict handler deal with it.
b2.txns.changeMergeManager.addConflictHandler({
id: "custom", handler: (arg) => {
conflicts.push({
cause: arg.cause,
tableName: arg.tableName,
opcode: arg.opcode,
id: arg.getValueId(0, arg.opcode === "Inserted" ? "New" : "Old"),
});
return undefined;
}
});
await b2.pushChanges({ accessToken: accessToken1, description: `modify aspect ${aspectId1} with no lock` });
expect(conflicts.length).to.be.equals(3);
chai.expect([
{
"cause": "Data",
"tableName": "bis_Element",
"opcode": "Updated",
"id": "0x20000000004"
},
{
"cause": "Data",
"tableName": "bis_ElementMultiAspect",
"opcode": "Updated",
"id": "0x20000000001"
},
{
"cause": "Data",
"tableName": "bis_Model",
"opcode": "Updated",
"id": "0x20000000001"
}
]).to.be.deep.equals(conflicts);
await b3.pullChanges();
await b1.pullChanges();
// b2 win as default conflict handler choose Replace to override local changes over master.
b2.elements.getAspect(aspectId1).identifier = "test identifier (modified by b2)";
b3.elements.getAspect(aspectId1).identifier = "test identifier (modified by b2)";
b1.elements.getAspect(aspectId1).identifier = "test identifier (modified by b2)";
b1.close();
b2.close();
b3.close();
});
it("pull/merge causing update conflict - update causing local changes (with no lock)", async () => {
/**
* To simulate a a data conflict for dirty read we update same aspect from two different briefcases
* and when the second briefcase try to push its changes it will fail with following error.
* "UPDATE/DELETE before value do not match with one in db or CASCADE action was triggered."
*/
const accessToken1 = await HubWrappers.getAccessToken(TestUserType.SuperManager);
const accessToken2 = await HubWrappers.getAccessToken(TestUserType.Regular);
const accessToken3 = await HubWrappers.getAccessToken(TestUserType.Super);
// Delete any existing iModels with the same name as the read-write test iModel
const iModelName = "TestIModel";
// Create a new empty iModel on the Hub & obtain a briefcase
const rwIModelId = await HubMock.createNewIModel({ accessToken: accessToken1, iTwinId, iModelName, description: "TestSubject", noLocks: undefined });
assert.isNotEmpty(rwIModelId);
// to reproduce the issue we will disable locks altogether.
const b1 = await HubWrappers.downloadAndOpenBriefcase({ accessToken: accessToken1, iTwinId, iModelId: rwIModelId, noLock: true });
b1.channels.addAllowedChannel(ChannelControl.sharedChannelName);
const b2 = await HubWrappers.downloadAndOpenBriefcase({ accessToken: accessToken2, iTwinId, iModelId: rwIModelId, noLock: true });
b2.channels.addAllowedChannel(ChannelControl.sharedChannelName);
const b3 = await HubWrappers.downloadAndOpenBriefcase({ accessToken: accessToken3, iTwinId, iModelId: rwIModelId, noLock: true });
// create and insert a new model with code1
const [, modelId] = IModelTestUtils.createAndInsertPhysicalPartitionAndModel(b1, IModelTestUtils.getUniqueModelCode(b1, "newPhysicalModel"), true);
const dictionary = b1.models.getModel(IModel.dictionaryId);
const newCategoryCode = IModelTestUtils.getUniqueSpatialCategoryCode(dictionary, "ThisTestSpatialCategory");
const spatialCategoryId = SpatialCategory.insert(dictionary.iModel, dictionary.id, newCategoryCode.value, new SubCategoryAppearance({ color: 0xff0000 }));
// insert element and aspect
const el1 = b1.elements.insertElement(IModelTestUtils.createPhysicalObject(b1, modelId, spatialCategoryId).toJSON());
b1.saveChanges();
await b1.pushChanges({ accessToken: accessToken1, description: `inserted element with aspect ${el1}` });
await b2.pullChanges();
const aspectId1 = b2.elements.insertAspect({
classFullName: "BisCore:ExternalSourceAspect",
element: {
relClassName: "BisCore:ElementOwnsExternalSourceAspects",
id: el1,
},
kind: "",
identifier: "test identifier",
});
b2.saveChanges();
b1.elements.deleteElement(el1);
b1.saveChanges();
await b1.pushChanges({ accessToken: accessToken1, description: `deleted element ${el1}` });
const conflicts = [];
// we will not handle conflict and let the default conflict handler deal with it.
b2.txns.changeMergeManager.addConflictHandler({
id: "custom", handler: (arg) => {
let id;
if (arg.cause !== "ForeignKey") {
id = arg.getValueId(0, arg.opcode === "Inserted" ? "New" : "Old");
}
conflicts.push({
cause: arg.cause,
tableName: arg.tableName,
opcode: arg.opcode,
id,
});
return undefined;
}
});
await assertThrowsAsync(async () => b2.pushChanges({ accessToken: accessToken1, description: `inserted element with aspect ${el1}` }), "Foreign key conflicts in ChangeSet. Aborting rebase.");
expect(conflicts.length).to.be.equals(3);
expect(conflicts).to.deep.equal([
{
cause: "NotFound",
tableName: "bis_Element",
opcode: "Updated",
id: "0x20000000004"
},
{
cause: "Data",
tableName: "bis_Model",
opcode: "Updated",
id: "0x20000000001"
},
{
cause: "ForeignKey",
tableName: "",
opcode: undefined,
id: undefined
}
]);
b2.txns.changeMergeManager.addConflictHandler({
id: "delete_aspect_when_element_is_deleted", handler: (arg) => {
if (arg.cause === "NotFound" && arg.tableName === "bis_Element") {
// if element does not exist any more let delete the aspects as well.
const elId = arg.getValueId(0, arg.opcode === "Inserted" ? "New" : "Old");
b2.elements.getAspects(elId).forEach((aspect) => {
b2.elements.deleteAspect(aspect.id);
});
}
return undefined;
}
});
b2.saveChanges();
conflicts.length = 0;
await b2.pushChanges({ accessToken: accessToken1, description: `nothing is pushed as the only change we made is reverted` });
await b3.pullChanges();
await b1.pullChanges();
expect(() => b1.elements.getElement(el1)).throws(`Element=${el1}`);
expect(() => b2.elements.getElement(el1)).throws(`Element=${el1}`);
expect(() => b3.elements.getElement(el1)).throws(`Element=${el1}`);
expect(() => b1.elements.getAspect(aspectId1)).throws(`ElementAspect not found ${aspectId1}`);
expect(() => b2.elements.getAspect(aspectId1)).throws(`ElementAspect not found ${aspectId1}`);
expect(() => b3.elements.getAspect(aspectId1)).throws(`ElementAspect not found ${aspectId1}`);
b1.close();
b2.close();
b3.close();
});
it("aspect insert, update & delete requires exclusive lock", async () => {
const accessToken1 = await HubWrappers.getAccessToken(TestUserType.SuperManager);
const accessToken2 = await HubWrappers.getAccessToken(TestUserType.Regular);
const accessToken3 = await HubWrappers.getAccessToken(TestUserType.Super);
// Delete any existing iModels with the same name as the read-write test iModel
const iModelName = "TestIModel";
// Create a new empty iModel on the Hub & obtain a briefcase
const rwIModelId = await HubMock.createNewIModel({ accessToken: accessToken1, iTwinId, iModelName, description: "TestSubject", noLocks: undefined });
assert.isNotEmpty(rwIModelId);
const b1 = await HubWrappers.downloadAndOpenBriefcase({ accessToken: accessToken1, iTwinId, iModelId: rwIModelId });
b1.channels.addAllowedChannel(ChannelControl.sharedChannelName);
const b2 = await HubWrappers.downloadAndOpenBriefcase({ accessToken: accessToken2, iTwinId, iModelId: rwIModelId });
b2.channels.addAllowedChannel(ChannelControl.sharedChannelName);
const b3 = await HubWrappers.downloadAndOpenBriefcase({ accessToken: accessToken3, iTwinId, iModelId: rwIModelId });
await b1.locks.acquireLocks({ shared: IModel.repositoryModelId });
await b2.locks.acquireLocks({ shared: IModel.repositoryModelId });
// create and insert a new model with code1
const [, modelId] = IModelTestUtils.createAndInsertPhysicalPartitionAndModel(b1, IModelTestUtils.getUniqueModelCode(b1, "newPhysicalModel"), true);
const dictionary = b1.models.getModel(IModel.dictionaryId);
const newCategoryCode = IModelTestUtils.getUniqueSpatialCategoryCode(dictionary, "ThisTestSpatialCategory");
await b1.locks.acquireLocks({ shared: dictionary.id });
const spatialCategoryId = SpatialCategory.insert(dictionary.iModel, dictionary.id, newCategoryCode.value, new SubCategoryAppearance({ color: 0xff0000 }));
const el1 = b1.elements.insertElement(IModelTestUtils.createPhysicalObject(b1, modelId, spatialCategoryId).toJSON());
b1.saveChanges();
await b1.pushChanges({ accessToken: accessToken1, description: `inserted element ${el1}` });
await b2.pullChanges();
let aspectId;
const insertAspectIntoB2 = () => {
aspectId = b2.elements.insertAspect({
classFullName: "BisCore:ExternalSourceAspect",
element: {
relClassName: "BisCore:ElementOwnsExternalSourceAspects",
id: el1,
},
kind: "",
identifier: "test identifier",
});
};
/* attempt to insert aspect without a lock */
assert.throws(insertAspectIntoB2, "Error inserting ElementAspect [exclusive lock not held on element for insert aspect (id=0x20000000004)], class: BisCore:ExternalSourceAspect");
/* acquire lock and try again */
await b2.locks.acquireLocks({ exclusive: el1 });
insertAspectIntoB2();
/* b1 cannot acquire lock on el1 as its already taken by b2 */
await expect(b1.locks.acquireLocks({ exclusive: el1 })).to.be.rejectedWith("exclusive lock is already held");
/* push changes on b2 to release lock on el1 */
b2.saveChanges();
await b2.pushChanges({ accessToken: accessToken2, description: `add aspect to element ${el1}` });
await b1.pullChanges();
const updateAspectIntoB1 = () => {
b1.elements.updateAspect({
id: aspectId,
classFullName: "BisCore:ExternalSourceAspect",
element: {
relClassName: "BisCore:ElementOwnsExternalSourceAspects",
id: el1,
},
kind: "",
identifier: "test identifier (modified)",
});
};
/* attempt to update aspect without a lock */
assert.throws(updateAspectIntoB1, "Error updating ElementAspect [exclusive lock not held on element for update aspect (id=0x20000000004)], id: 0x30000000001");
/* acquire lock and try again */
await b1.locks.acquireLocks({ exclusive: el1 });
updateAspectIntoB1();
/* delete the element */
b1.elements.deleteElement(el1);
b1.saveChanges();
await b1.pushChanges({ accessToken: accessToken1, description: `deleted element ${el1}` });
const onChangesetConflictStub = sinon.stub(BriefcaseDb.prototype, "onChangesetConflict");
/* we should be able to apply all changesets */
await b3.pullChanges();
expect(onChangesetConflictStub.callCount).eq(0, "native conflict handler should not be called BriefcaseDb.onChangesetConflict()");
onChangesetConflictStub.restore();
b1.close();
b2.close();
b3.close();
});
});
//# sourceMappingURL=MergeConflict.test.js.map