Skip to content

Commit

Permalink
Prevent concurrent changes to same entity from different submissions
Browse files Browse the repository at this point in the history
  • Loading branch information
matthew-white committed Sep 20, 2024
1 parent b80f19b commit 2a0f9de
Showing 1 changed file with 11 additions and 3 deletions.
14 changes: 11 additions & 3 deletions lib/model/query/entities.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const { map, mergeRight, pickAll } = require('ramda');
const { blankStringToNull, construct } = require('../../util/util');
const { QueryOptions } = require('../../util/db');
const { odataFilter, odataOrderBy } = require('../../data/odata-filter');
const { odataToColumnMap, parseSubmissionXml, getDiffProp, ConflictType } = require('../../data/entity');
const { odataToColumnMap, parseSubmissionXml, getDiffProp, ConflictType, normalizeUuid } = require('../../data/entity');
const { isTrue } = require('../../util/http');
const Problem = require('../../util/problem');
const { getOrReject, runSequentially } = require('../../util/promise');
Expand Down Expand Up @@ -382,7 +382,7 @@ const _getFormDefActions = (oneFirst, datasetId, formDefId) => oneFirst(sql`

// Main submission event processing function, which runs within a transaction
// so any errors can be rolled back and logged as an entity processing error.
const _processSubmissionEvent = (event, parentEvent) => async ({ Audits, Datasets, Entities, Submissions, Forms, oneFirst }) => {
const _processSubmissionEvent = (event, parentEvent) => async ({ Audits, Datasets, Entities, Submissions, Forms, oneFirst, run }) => {
const { submissionId, submissionDefId } = event.details;
const forceOutOfOrderProcessing = parentEvent?.details?.force === true;

Expand Down Expand Up @@ -441,6 +441,9 @@ const _processSubmissionEvent = (event, parentEvent) => async ({ Audits, Dataset
throw Problem.user.entityActionNotPermitted({ action, permitted: permittedActions });
}

// Prevent concurrent changes to the entity.
await _lockEntity(run, normalizeUuid(entityData.system.id));

Check failure on line 445 in lib/model/query/entities.js

View workflow job for this annotation

GitHub Actions / standard-tests

'_lockEntity' was used before it was defined

let maybeEntity = null;
// Try update before create (if both are specified)
if (entityData.system.update === '1' || entityData.system.update === 'true')
Expand Down Expand Up @@ -613,7 +616,12 @@ const _get = (includeSource) => {
// We can't use `FOR UPDATE` clause because of "Read Committed Isolation Level",
// i.e. blocked transaction gets the row version that was at the start of the command,
// (after lock is released by the first transaction), even if transaction with lock has updated that row.
const _lockEntity = (exec, uuid) => exec(sql`SELECT pg_advisory_xact_lock(id) FROM entities WHERE uuid = ${uuid};`);
const _lockEntity = (exec, uuid) => {
// pg_advisory_xact_lock() takes a bigint. A 16-digit hex number could exceed
// the bigint max, so we only use the first 15 digits of the UUID.
const lockId = Number.parseInt(uuid.replaceAll('-', '').slice(0, 15), 16);
return exec(sql`SELECT pg_advisory_xact_lock(${lockId})`);
};

const assignCurrentVersionCreator = (entity) => {
const currentVersion = new Entity.Def(entity.aux.currentVersion, { creator: entity.aux.currentVersionCreator });
Expand Down

0 comments on commit 2a0f9de

Please sign in to comment.