UNPKG

@itwin/core-backend

Version:
345 lines • 19.2 kB
/*--------------------------------------------------------------------------------------------- * 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