Skip to content

Commit

Permalink
query to check for non-contiguous interrupted branches
Browse files Browse the repository at this point in the history
  • Loading branch information
ktuite committed Oct 22, 2024
1 parent 21a580d commit f4d54fc
Show file tree
Hide file tree
Showing 2 changed files with 175 additions and 2 deletions.
31 changes: 30 additions & 1 deletion lib/model/query/entities.js
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,12 @@ const _updateEntity = (dataset, entityData, submissionId, submissionDef, submiss
if (conflict !== ConflictType.HARD) { // We don't want to downgrade conflict here
conflict = conflictingProperties.length > 0 ? ConflictType.HARD : ConflictType.SOFT;
}
} else {
// This may still be a soft conflict if the new version is not contiguous with this branch's trunk version
const interrupted = await Entities._interruptedBranch(serverEntity.id, clientEntity);
if (interrupted) {
conflict = ConflictType.SOFT;
}
}

// merge data
Expand Down Expand Up @@ -541,6 +547,29 @@ const processSubmissionEvent = (event, parentEvent) => (container) =>
////////////////////////////////////////////////////////////////////////////////
// Submission processing helper functions

// Used by _updateEntity to determine if a new offline update is contiguous with its trunk version
// by searching for an interrupting version with a different or null branchId that has a higher
// version than the trunk version of the given branch.
const _interruptedBranch = (entityId, clientEntity) => async ({ maybeOne }) => {
// If there is no branchId, the branch cannot be interrupted
if (clientEntity.def.branchId == null)
return false;

// look for a version of a different branch that has a version
// higher than the trunkVersion, which indicates an interrupting version.
// if trunkVersion is null (becuase it is part of a branch beginning with
// an offline create), look for a version higher than 1 because version
// 1 is implicitly the create action of that offline branch.
const interruptingVersion = await maybeOne(sql`
SELECT version
FROM entity_defs
WHERE "branchId" IS DISTINCT FROM ${clientEntity.def.branchId}
AND version > ${clientEntity.def.trunkVersion || 1}
AND "entityId" = ${entityId}
LIMIT 1`);
return interruptingVersion.isDefined();
};

// Used by _computeBaseVersion to hold submissions that are not yet ready to be processed
const _holdSubmission = (eventId, submissionId, submissionDefId, entityUuid, branchId, branchBaseVersion) => async ({ run }) => run(sql`
INSERT INTO entity_submission_backlog ("auditId", "submissionId", "submissionDefId", "entityUuid", "branchId", "branchBaseVersion", "loggedAt")
Expand Down Expand Up @@ -780,7 +809,7 @@ module.exports = {
createSource,
createMany,
_createEntity, _updateEntity,
_computeBaseVersion,
_computeBaseVersion, _interruptedBranch,
_holdSubmission, _checkHeldSubmission,
_getNextHeldSubmissionInBranch, _deleteHeldSubmissionByEventId,
_getHeldSubmissionsAsEvents,
Expand Down
146 changes: 145 additions & 1 deletion test/integration/api/offline-entities.js
Original file line number Diff line number Diff line change
Expand Up @@ -1380,7 +1380,7 @@ describe('Offline Entities', () => {
});
}));

it('should mark an update that is not contiguous with its trunk version as a soft conflict', testOfflineEntities(async (service, container) => {
it('should mark an update that is not contiguous (due to force processing) as a soft conflict', testOfflineEntities(async (service, container) => {
const asAlice = await service.login('alice');
const branchId = uuid();

Expand Down Expand Up @@ -1427,6 +1427,150 @@ describe('Offline Entities', () => {
versions.map(v => v.conflict).should.eql([null, null, 'hard', 'soft']);
});
}));

it('should mark an update that is not contiguous with its trunk version as a soft conflict on entity despite earlier conflict resolution', testOfflineEntities(async (service, container) => {
const asAlice = await service.login('alice');
const branchId = uuid();

// Update existing entity on server (change age from 22 to 24)
await asAlice.patch('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc?baseVersion=1')
.send({ data: { age: '24' } })
.expect(200);

// Send update (change status from null to arrived)
await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions')
.send(testData.instances.offlineEntity.one
.replace('branchId=""', `branchId="${branchId}"`)
)
.set('Content-Type', 'application/xml')
.expect(200);
await exhaust(container);

await asAlice.patch('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc?resolve=true&baseVersion=3')
.expect(200);

// Send second update (change age from 22 to 26)
await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions')
.send(testData.instances.offlineEntity.one
.replace('branchId=""', `branchId="${branchId}"`)
.replace('one', 'one-update2')
.replace('baseVersion="1"', 'baseVersion="2"')
.replace('<status>arrived</status>', '<age>26</age>')
)
.set('Content-Type', 'application/xml')
.expect(200);
await exhaust(container);

await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/versions')
.then(({ body: versions }) => {
versions.map(v => v.conflict).should.eql([null, null, 'soft', 'soft']);
});

await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc')
.then(({ body: entity }) => {
should(entity.conflict).equal('soft');
});
}));

it('should mark an update that is not contiguous (from an offline create branch) as a soft conflict on entity despite earlier conflict resolution', testOfflineEntities(async (service, container) => {
const asAlice = await service.login('alice');
const branchId = uuid();

// Send initial submission to create entity
await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions')
.send(testData.instances.offlineEntity.two)
.set('Content-Type', 'application/xml')
.expect(200);
await exhaust(container);

// Update existing entity on server before getting the rest of the branch
await asAlice.patch('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789ddd?baseVersion=1')
.send({ data: { age: '24' } })
.expect(200);

// Send update (change status from new to arrived)
await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions')
.send(testData.instances.offlineEntity.two
.replace('two', 'two-update1')
.replace('branchId=""', `branchId="${branchId}"`)
.replace('create="1"', 'update="1"')
.replace('baseVersion=""', 'baseVersion="1"')
.replace('<status>new</status>', '<status>arrived</status>')
)
.set('Content-Type', 'application/xml')
.expect(200);
await exhaust(container);

// Conflict is hard here
await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789ddd')
.then(({ body: entity }) => {
should(entity.conflict).equal('hard');
});

// resolve the conflict
await asAlice.patch('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789ddd?resolve=true&baseVersion=3')
.expect(200);

// Send second update in offline create-update-update chain (change age from 22 to 26)
await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions')
.send(testData.instances.offlineEntity.two
.replace('two', 'two-update2')
.replace('branchId=""', `branchId="${branchId}"`)
.replace('create="1"', 'update="1"')
.replace('baseVersion=""', 'baseVersion="2"')
.replace('<status>new</status>', '<status>arrived</status>')
.replace('<age>20</age>', '<age>27</age>')
)
.set('Content-Type', 'application/xml')
.expect(200);
await exhaust(container);

await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789ddd/versions')
.then(({ body: versions }) => {
versions.map(v => v.conflict).should.eql([null, null, 'hard', 'soft']);
});

await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789ddd')
.then(({ body: entity }) => {
should(entity.conflict).equal('soft');
});
}));

it('should check that interrupting version logic is doesnt flag non-conflicts as conflicts', testOfflineEntities(async (service, container) => {
const asAlice = await service.login('alice');
const branchId = uuid();

// Send initial submission to create entity
await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions')
.send(testData.instances.offlineEntity.two)
.set('Content-Type', 'application/xml')
.expect(200);
await exhaust(container);

// Send second update in offline create-update-update chain (change age from 22 to 26)
await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions')
.send(testData.instances.offlineEntity.two
.replace('two', 'two-update')
.replace('branchId=""', `branchId="${branchId}"`)
.replace('create="1"', 'update="1"')
.replace('baseVersion=""', 'baseVersion="1"')
.replace('<status>new</status>', '<status>arrived</status>')
.replace('<age>20</age>', '<age>27</age>')
)
.set('Content-Type', 'application/xml')
.expect(200);
await exhaust(container);

await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789ddd/versions')
.then(({ body: versions }) => {
versions.map(v => v.conflict).should.eql([null, null]);
});

await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789ddd')
.then(({ body: entity }) => {
should(entity.conflict).equal(null);
});
}));
});

describe('locking an entity while processing a related submission', function() {
Expand Down

0 comments on commit f4d54fc

Please sign in to comment.