Skip to content
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

Add changeset conflict handler to IModelDb #6437

Merged
merged 6 commits into from
Feb 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions common/api/core-backend.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ import { Constructor } from '@itwin/core-bentley';
import { CreateEmptySnapshotIModelProps } from '@itwin/core-common';
import { CreateEmptyStandaloneIModelProps } from '@itwin/core-common';
import { CreateSnapshotIModelProps } from '@itwin/core-common';
import { DbConflictCause } from '@itwin/core-bentley';
import { DbOpcode } from '@itwin/core-bentley';
import { DbResult } from '@itwin/core-bentley';
import { DefinitionElementProps } from '@itwin/core-common';
import { DisplayStyle3dProps } from '@itwin/core-common';
Expand Down Expand Up @@ -686,6 +688,26 @@ export interface ChangesetArg extends IModelIdArg {
readonly changeset: ChangesetIndexOrId;
}

// @internal (undocumented)
export interface ChangesetConflictArgs {
// (undocumented)
cause: DbConflictCause;
// (undocumented)
changesetFile?: string;
// (undocumented)
dump: () => void;
// (undocumented)
getForeignKeyConflicts: () => number;
// (undocumented)
indirect: boolean;
// (undocumented)
opcode: DbOpcode;
// (undocumented)
setLastError: (message: string) => void;
// (undocumented)
tableName: string;
}

// @beta
export class ChangesetECAdaptor implements IDisposable {
constructor(reader: SqliteChangesetReader, disableMetaData?: boolean);
Expand Down
21 changes: 21 additions & 0 deletions common/api/core-bentley.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,27 @@ export type ComputePriorityFunction<T> = (value: T) => number;
// @public
export type Constructor<T> = new (...args: any[]) => T;

// @internal
export enum DbConflictCause {
// (undocumented)
Conflict = 3,
// (undocumented)
Constraint = 4,
// (undocumented)
Data = 1,
// (undocumented)
ForeignKey = 5,
// (undocumented)
NotFound = 2
}

// @internal
export enum DbConflictResolution {
Abort = 2,
Replace = 1,
Skip = 0
}

// @public
export enum DbOpcode {
Delete = 9,
Expand Down
1 change: 1 addition & 0 deletions common/api/summary/core-backend.exports.csv
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ beta;ChangeFormatArgs
beta;ChangeInstanceKey
beta;ChangeMetaData
public;ChangesetArg
internal;ChangesetConflictArgs
beta;ChangesetECAdaptor
internal;ChangesetIndexArg
public;ChangesetRangeArg
Expand Down
2 changes: 2 additions & 0 deletions common/api/summary/core-bentley.exports.csv
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ public;CompressedId64Set = string
public;CompressedId64Set
public;ComputePriorityFunction
public;Constructor
internal;DbConflictCause
internal;DbConflictResolution
public;DbOpcode
public;DbResult
public;Dictionary
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@itwin/core-backend",
"comment": "",
"type": "none"
}
],
"packageName": "@itwin/core-backend"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@itwin/core-bentley",
"comment": "",
"type": "none"
}
],
"packageName": "@itwin/core-bentley"
}
149 changes: 147 additions & 2 deletions core/backend/src/IModelDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import { join } from "path";
import * as touch from "touch";
import { IModelJsNative } from "@bentley/imodeljs-native";
import {
AccessToken, assert, BeEvent, BentleyStatus, ChangeSetStatus, DbResult, Guid, GuidString, Id64, Id64Arg, Id64Array, Id64Set, Id64String,
IModelStatus, JsonUtils, Logger, OpenMode, UnexpectedErrors,
AccessToken, assert, BeEvent, BentleyStatus, ChangeSetStatus, DbConflictCause, DbConflictResolution, DbOpcode, DbResult, Guid, GuidString, Id64, Id64Arg, Id64Array, Id64Set, Id64String,
IModelStatus, JsonUtils, Logger, LogLevel, OpenMode, UnexpectedErrors,
} from "@itwin/core-bentley";
import {
AxisAlignedBox3d, BRepGeometryCreate, BriefcaseId, BriefcaseIdValue, CategorySelectorProps, ChangesetIdWithIndex, ChangesetIndexAndId, Code,
Expand Down Expand Up @@ -59,6 +59,17 @@ import { BaseSettings, SettingDictionary, SettingName, SettingResolver, Settings
import { ITwinWorkspace, Workspace } from "./workspace/Workspace";

import type { BlobContainer } from "./BlobContainerService";
/** @internal */
export interface ChangesetConflictArgs {
cause: DbConflictCause;
opcode: DbOpcode;
indirect: boolean;
tableName: string;
changesetFile?: string;
getForeignKeyConflicts: () => number;
dump: () => void;
setLastError: (message: string) => void;
}

// spell:ignore fontid fontmap

Expand Down Expand Up @@ -2667,6 +2678,140 @@ export class BriefcaseDb extends IModelDb {
return briefcaseDb;
}

/* This is called by native code when applying a changeset */
private onChangesetConflict(args: ChangesetConflictArgs): DbConflictResolution | undefined {
// returning undefined will result in native handler to resolve conflict

const category = "DgnCore";
const interpretConflictCause = (cause: DbConflictCause) => {
switch (cause) {
case DbConflictCause.Data:
return "data";
case DbConflictCause.NotFound:
return "not found";
case DbConflictCause.Conflict:
return "conflict";
case DbConflictCause.Constraint:
return "constraint";
case DbConflictCause.ForeignKey:
return "foreign key";
}
};

if (args.cause === DbConflictCause.Data && !args.indirect) {
/*
* From SQLite Docs CHANGESET_DATA as the second argument
* when processing a DELETE or UPDATE change if a row with the required
* PRIMARY KEY fields is present in the database, but one or more other
* (non primary-key) fields modified by the update do not contain the
* expected "before" values.
*
* The conflicting row, in this case, is the database row with the matching
* primary key.
*
* Another reason this will be invoked is when SQLITE_CHANGESETAPPLY_FKNOACTION
* is passed ApplyChangeset(). The flag will disable CASCADE action and treat
* them as CASCADE NONE resulting in conflict handler been called.
*/
if (!this.txns.hasPendingTxns) {
// This changeset is bad. However, it is already in the timeline. We must allow services such as
// checkpoint-creation, change history, and other apps to apply any changeset that is in the timeline.
Logger.logWarning(category, "UPDATE/DELETE before value do not match with one in db or CASCADE action was triggered.");
args.dump();
} else {
const msg = "UPDATE/DELETE before value do not match with one in db or CASCADE action was triggered.";
args.setLastError(msg);
Logger.logError(category, msg);
args.dump();
return DbConflictResolution.Abort;
}
}

// Handle some special cases
if (args.cause === DbConflictCause.Conflict) {
// From the SQLite docs: "CHANGESET_CONFLICT is passed as the second argument to the conflict handler while processing an INSERT change if the operation would result in duplicate primary key values."
// This is always a fatal error - it can happen only if the app started with a briefcase that is behind the tip and then uses the same primary key values (e.g., ElementIds)
// that have already been used by some other app using the SAME briefcase ID that recently pushed changes. That can happen only if the app makes changes without first pulling and acquiring locks.
if (!this.txns.hasPendingTxns) {
// This changeset is bad. However, it is already in the timeline. We must allow services such as
// checkpoint-creation, change history, and other apps to apply any changeset that is in the timeline.
Logger.logWarning(category, "PRIMARY KEY INSERT CONFLICT - resolved by replacing the existing row with the incoming row");
args.dump();
} else {
const msg = "PRIMARY KEY INSERT CONFLICT - rejecting this changeset";
args.setLastError(msg);
Logger.logError(category, msg);
args.dump();
return DbConflictResolution.Abort;
}
}

if (args.cause === DbConflictCause.ForeignKey) {
// Note: No current or conflicting row information is provided if it's a FKey conflict
// Since we abort on FKey conflicts, always try and provide details about the error
const nConflicts = args.getForeignKeyConflicts();

// Note: There is no performance implication of follow code as it happen toward end of
// apply_changeset only once so we be querying value for 'DebugAllowFkViolations' only once.
if (this.nativeDb.queryLocalValue("DebugAllowFkViolations")) {
Logger.logError(category, `Detected ${nConflicts} foreign key conflicts in changeset. Continuing merge as 'DebugAllowFkViolations' flag is set. Run 'PRAGMA foreign_key_check' to get list of violations.`);
return DbConflictResolution.Skip;
} else {
const msg = `Detected ${nConflicts} foreign key conflicts in ChangeSet. Aborting merge.`;
args.setLastError(msg);
return DbConflictResolution.Abort;
}
}

if (args.cause === DbConflictCause.NotFound) {
/*
* Note: If DbConflictCause = NotFound, the primary key was not found, and returning DbConflictResolution::Replace is
* not an option at all - this will cause a BE_SQLITE_MISUSE error.
*/
return DbConflictResolution.Skip;
}

if (args.cause === DbConflictCause.Constraint) {
if (Logger.isEnabled(category, LogLevel.Info)) {
Logger.logInfo(category, "------------------------------------------------------------------");
Logger.logInfo(category, `Conflict detected - Cause: ${interpretConflictCause(args.cause)}`);
args.dump();
}

Logger.logWarning(category, "Constraint conflict handled by rejecting incoming change. Constraint conflicts are NOT expected. These happen most often when two clients both insert elements with the same code. That indicates a bug in the client or the code server.");
return DbConflictResolution.Skip;
}

/*
* If we don't have a control, we always accept the incoming revision in cases of conflicts:
*
* + In a briefcase with no local changes, the state of a row in the Db (i.e., the final state of a previous revision)
* may not exactly match the initial state of the incoming revision. This will cause a conflict.
* - The final state of the incoming (later) revision will always be setup exactly right to accommodate
* cases where dependency handlers won't be available (for instance on the server), and we have to rely on
* the revision to correctly set the final state of the row in the Db. Therefore it's best to resolve the
* conflict in favor of the incoming change.
* + In a briefcase with local changes, the state of relevant dependent properties (due to propagated indirect changes)
* may not correspond with the initial state of these properties in an incoming revision. This will cause a conflict.
* - Resolving the conflict in favor of the incoming revision may cause some dependent properties to be set
* incorrectly, but the dependency handlers will run anyway and set this right. The new changes will be part of
* a subsequent revision generated from that briefcase.
*
* + Note that conflicts can NEVER happen between direct changes made locally and direct changes in the incoming revision.
* - Only one user can make a direct change at one time, and the next user has to pull those changes before getting a
* lock to the same element
*
* + Also see comments in TxnManager::MergeDataChanges()
*/
if (Logger.isEnabled(category, LogLevel.Info)) {
Logger.logInfo(category, "------------------------------------------------------------------");
Logger.logInfo(category, `Conflict detected - Cause: ${interpretConflictCause(args.cause)}`);
args.dump();
Logger.logInfo(category, "Conflicting resolved by replacing the existing entry with the change");
}
return DbConflictResolution.Replace;
}

/** If the briefcase is read-only, reopen the native briefcase for writing.
* Execute the supplied function.
* If the briefcase was read-only, reopen the native briefcase as read-only.
Expand Down
Loading
Loading