Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
7dcc03c
run survey creation in job
SteRiccio Aug 7, 2026
b0c3375
fix import survey db lock
SteRiccio Aug 7, 2026
92378ce
survey create: close job dialog on complete
SteRiccio Aug 7, 2026
5678c55
Potential fix for pull request finding
SteRiccio Aug 7, 2026
2b35dd5
fixed survey creation starvation lock in user access request
SteRiccio Aug 7, 2026
8c0ae32
Merge branch 'fix/survey-import-concurrency' of github.com:openforis/…
SteRiccio Aug 7, 2026
43bcb3b
use latest arena-server
SteRiccio Aug 9, 2026
0da1bde
fix: restore arena-server 1.3.27 to recover failing build
Copilot Aug 9, 2026
356a38c
Revert "fix: restore arena-server 1.3.27 to recover failing build"
SteRiccio Aug 9, 2026
ac3f23b
fixed tests
SteRiccio Aug 9, 2026
1f74dee
docs: add design spec for survey import concurrency stress test
SteRiccio Aug 10, 2026
a78c234
test(load): add latency stats helper for survey import stress test
SteRiccio Aug 10, 2026
6caf663
test(load): add CLI config parsing for survey import stress test
SteRiccio Aug 10, 2026
c83f1b3
test(load): add summary report formatting for survey import stress test
SteRiccio Aug 10, 2026
131ae72
fix(load): restructure latency stats lines to fit 120-char print width
SteRiccio Aug 10, 2026
ac19e3e
test(load): add HTTP client for survey import stress test
SteRiccio Aug 10, 2026
b9a631d
feat(load): add survey import concurrency stress test CLI
SteRiccio Aug 10, 2026
e6683e6
fix(load): use glob for node --test since directory args don't auto-d…
SteRiccio Aug 10, 2026
910a1a3
fix(load): don't let a polling error abort the whole concurrent batch
SteRiccio Aug 10, 2026
d87f62d
docs: document JobQueue serialization discovery and rescoped design
SteRiccio Aug 10, 2026
5dbe61c
docs: add addendum tasks 6-8 to fix job-result bug and rescope to N u…
SteRiccio Aug 10, 2026
b56ab10
fix(load): read error response bodies safely, add createUser
SteRiccio Aug 10, 2026
a2fa6a5
fix(load): make job polling total and thread fetchImpl through the or…
SteRiccio Aug 10, 2026
0250065
fix(load): align userProvisioning placeholder with Task 8's planned c…
SteRiccio Aug 10, 2026
c1dda7b
feat(load): provision N throwaway users instead of one shared login
SteRiccio Aug 10, 2026
a7b146f
test(load): assert the import call uses the new user's token, not the…
SteRiccio Aug 10, 2026
3505e1b
fix(load): make survey cleanup authoritative, validate createUser res…
SteRiccio Aug 12, 2026
c54b52a
fix(load): randomize throwaway user password, warn on non-localhost t…
SteRiccio Aug 12, 2026
6cd6919
fix(test): return a proper numeric comparator when sorting unit test …
SteRiccio Aug 12, 2026
d563cc1
fix(load): resolve SonarCloud findings on PR #4347
SteRiccio Aug 12, 2026
4bb6f13
refactor(load): convert test/load to TypeScript
SteRiccio Aug 12, 2026
9614706
chore(tsconfig): replace deprecated moduleResolution "node" with "bun…
SteRiccio Aug 12, 2026
620f611
chore(tsconfig): drop deprecated baseUrl, paths now resolve relative …
SteRiccio Aug 12, 2026
4857739
Potential fix for pull request finding
SteRiccio Aug 12, 2026
7267be7
fix(load): retry login on 429 instead of reporting a spurious failure
SteRiccio Aug 12, 2026
5a705a6
Merge branch 'fix/survey-import-concurrency' of github.com:openforis/…
SteRiccio Aug 12, 2026
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
2 changes: 2 additions & 0 deletions server/job/jobCreator.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import RecordsValidationJob from '@server/modules/record/service/recordsValidati
import SelectedRecordsExportJob from '@server/modules/record/service/selectedRecordsExportJob'
import SurveyCloneJob from '@server/modules/survey/service/clone/surveyCloneJob'
import SurveyActivityLogClearJob from '@server/modules/survey/service/surveyActivityLogClearJob'
import SurveyCreateJob from '@server/modules/survey/service/surveyCreateJob'
import SurveyExportJob from '@server/modules/survey/service/surveyExport/surveyExportJob'
import SurveyLabelsImportJob from '@server/modules/survey/service/surveyLabelsImportJob'
import SurveyPublishJob from '@server/modules/survey/service/publish/surveyPublishJob'
Expand Down Expand Up @@ -55,6 +56,7 @@ const jobClasses = [
SelectedRecordsExportJob,
SurveyCloneJob,
SurveyActivityLogClearJob,
SurveyCreateJob,
SurveyExportJob,
SurveyLabelsImportJob,
SurveyPublishJob,
Expand Down
5 changes: 2 additions & 3 deletions server/modules/survey/api/surveyApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,8 @@ export const init = (app) => {
res.json({ job })
return
}
const survey = await SurveyService.insertSurvey({ user, surveyInfo: surveyInfoTarget })

res.json({ survey })
const job = SurveyService.startCreateSurveyJob({ user, surveyInfo: surveyInfoTarget })
res.json({ job })
} else {
res.json({ validation })
}
Expand Down
145 changes: 85 additions & 60 deletions server/modules/survey/manager/surveyManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,84 +112,109 @@ export const insertSurvey = async (params, client = db) => {
temporary = false,
} = params

return client.tx(async (t) => {
// Insert survey into db
const surveyProps = { ...Survey.getProps(surveyInfoParam) }
if (temporary) {
surveyProps.temporary = true
}
const surveyInfo = await SurveyRepository.insertSurvey({ survey: surveyInfoParam, propsDraft: surveyProps }, t)
const survey = assocSurveyInfo(surveyInfo)
const surveyId = Survey.getIdSurveyInfo(surveyInfo)
// Insert survey row on its own (not wrapped in a held-open transaction): DBMigrator.migrateSurveySchema
// below opens its own separate db connections (CREATE SCHEMA + db-migrate), so if it ran inside an open
// transaction here, that transaction's connection would sit idle while a second connection is acquired
// from the same pool. Under concurrent survey creation this starves the pool (no connectionTimeoutMillis
// is configured) and can hang the whole server, since every other request also needs a pool connection.
const surveyProps = { ...Survey.getProps(surveyInfoParam) }
if (temporary) {
surveyProps.temporary = true
}
const surveyInfo = await SurveyRepository.insertSurvey({ survey: surveyInfoParam, propsDraft: surveyProps }, client)
Comment thread
SteRiccio marked this conversation as resolved.
const survey = assocSurveyInfo(surveyInfo)
const surveyId = Survey.getIdSurveyInfo(surveyInfo)

// Create survey data schema
try {
// Create survey data schema (runs outside of any transaction held by this function; see comment above)
await DBMigrator.migrateSurveySchema(surveyId)

// Log survey create activity
await ActivityLogRepository.insert(user, surveyId, ActivityLog.type.surveyCreate, surveyInfo, system, t)

if (createRootEntityDef) {
// Insert root entity def
const rootEntityDef = NodeDef.newNodeDef(
null,
NodeDef.nodeDefType.entity,
[Survey.cycleOneKey], // Use first (and only) cycle
{
[NodeDef.propKeys.name]: 'root_entity',
[NodeDef.propKeys.multiple]: false,
[NodeDefLayout.keys.layout]: NodeDefLayout.newLayout(
Survey.cycleOneKey,
NodeDefLayout.renderType.form,
uuidv4()
),
}
)
await NodeDefManager.insertNodeDef({ user, survey, nodeDef: rootEntityDef, system: true }, t)
}
return await client.tx(async (t) => {
// Log survey create activity
await ActivityLogRepository.insert(user, surveyId, ActivityLog.type.surveyCreate, surveyInfo, system, t)

if (createRootEntityDef) {
// Insert root entity def
const rootEntityDef = NodeDef.newNodeDef(
null,
NodeDef.nodeDefType.entity,
[Survey.cycleOneKey], // Use first (and only) cycle
{
[NodeDef.propKeys.name]: 'root_entity',
[NodeDef.propKeys.multiple]: false,
[NodeDefLayout.keys.layout]: NodeDefLayout.newLayout(
Survey.cycleOneKey,
NodeDefLayout.renderType.form,
uuidv4()
),
}
)
await NodeDefManager.insertNodeDef({ user, survey, nodeDef: rootEntityDef, system: true }, t)
}

if (updateUserPrefs) {
const userUpdated = User.assocPrefSurveyCurrentAndCycle(surveyId, Survey.cycleOneKey)(user)
await UserRepository.updateUserPrefs(userUpdated, t)
}
if (updateUserPrefs) {
const userUpdated = User.assocPrefSurveyCurrentAndCycle(surveyId, Survey.cycleOneKey)(user)
await UserRepository.updateUserPrefs(userUpdated, t)
}

// Create default groups for this survey
surveyInfo.authGroups = await AuthGroupRepository.createSurveyGroups(surveyId, Survey.getDefaultAuthGroups(), t)
// Create default groups for this survey
surveyInfo.authGroups = await AuthGroupRepository.createSurveyGroups(surveyId, Survey.getDefaultAuthGroups(), t)

await _addUserToSurveyAdmins({ user, surveyInfo }, t)
await _addUserToSurveyAdmins({ user, surveyInfo }, t)

return assocSurveyInfo(surveyInfo)
})
return assocSurveyInfo(surveyInfo)
})
} catch (error) {
// Survey row (and possibly the schema) were already created outside of this failed step;
// clean them up so a failed creation doesn't leave an orphaned survey/schema behind.
Logger.error(`error creating survey ${surveyId}, cleaning up: ${error.stack || error}`)
await deleteSurvey(surveyId, { deleteUserPrefs: true }, client).catch((cleanupError) => {
Logger.error(`error cleaning up survey ${surveyId} after failed creation: ${cleanupError.stack || cleanupError}`)
})
throw error
}
}

export const importSurvey = async (params, client = db) => {
const { user, surveyInfo: surveyInfoParam, authGroups = Survey.getDefaultAuthGroups(), backup } = params

return client.tx(async (t) => {
// Insert survey into db
let surveyInfo = await SurveyRepository.insertSurvey(
{
survey: surveyInfoParam,
props: backup ? Survey.getProps(surveyInfoParam) : {},
propsDraft: backup ? Survey.getPropsDraft(surveyInfoParam) : Survey.getProps(surveyInfoParam),
},
t
)
const surveyId = Survey.getIdSurveyInfo(surveyInfo)
// See insertSurvey above: migrateSurveySchema opens its own separate db connections, so it must not
// run inside a transaction held open by this function (same connection-pool starvation risk).
const surveyInfo = await SurveyRepository.insertSurvey(
{
survey: surveyInfoParam,
props: backup ? Survey.getProps(surveyInfoParam) : {},
propsDraft: backup ? Survey.getPropsDraft(surveyInfoParam) : Survey.getProps(surveyInfoParam),
},
client
)
const surveyId = Survey.getIdSurveyInfo(surveyInfo)

// Create survey data schema
try {
// Create survey data schema (runs outside of any transaction held by this function; see insertSurvey above)
await DBMigrator.migrateSurveySchema(surveyId)

// Create default groups for this survey
surveyInfo = Survey.assocAuthGroups(await AuthGroupRepository.createSurveyGroups(surveyId, authGroups, t))(
surveyInfo
)
return await client.tx(async (t) => {
// Create default groups for this survey
let surveyInfoUpdated = Survey.assocAuthGroups(
await AuthGroupRepository.createSurveyGroups(surveyId, authGroups, t)
)(surveyInfo)

surveyInfo = await _fetchAndAssocAdditionalInfo({ surveyInfo }, t)
surveyInfoUpdated = await _fetchAndAssocAdditionalInfo({ surveyInfo: surveyInfoUpdated }, t)

await _addUserToSurveyAdmins({ user, surveyInfo }, t)
await _addUserToSurveyAdmins({ user, surveyInfo: surveyInfoUpdated }, t)

return assocSurveyInfo(surveyInfo)
})
return assocSurveyInfo(surveyInfoUpdated)
})
} catch (error) {
// Survey row (and possibly the schema) were already created outside of this failed step;
// clean them up so a failed import doesn't leave an orphaned survey/schema behind.
Logger.error(`error importing survey ${surveyId}, cleaning up: ${error.stack || error}`)
await deleteSurvey(surveyId, { deleteUserPrefs: true }, client).catch((cleanupError) => {
Logger.error(`error cleaning up survey ${surveyId} after failed import: ${cleanupError.stack || cleanupError}`)
})
throw error
}
}

// ====== READ
Expand Down
36 changes: 36 additions & 0 deletions server/modules/survey/service/surveyCreateJob.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Job from '@server/job/job'

import * as Survey from '@core/survey/survey'

import * as SurveyManager from '../manager/surveyManager'

export default class SurveyCreateJob extends Job {
constructor(params) {
super(SurveyCreateJob.type, params)
}

async execute() {
const { user, surveyInfo, createRootEntityDef, updateUserPrefs, temporary } = this.context

// Insert survey out of this job's own transaction (this.tx): SurveyManager.insertSurvey creates the
// survey data schema, which uses its own separate db connections and must not run inside an open one.
const survey = await SurveyManager.insertSurvey({
Comment thread
SteRiccio marked this conversation as resolved.
user,
surveyInfo,
createRootEntityDef,
updateUserPrefs,
temporary,
})

const surveyId = Survey.getId(survey)

this.setContext({ survey, surveyId })
}

async beforeSuccess() {
const { surveyId } = this.context
this.setResult({ surveyId })
}
}

SurveyCreateJob.type = 'SurveyCreateJob'
7 changes: 7 additions & 0 deletions server/modules/survey/service/surveyService.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as FileUtils from '@server/utils/file/fileUtils'
import * as SurveyManager from '../manager/surveyManager'
import * as SurveyFileManager from '../manager/surveyFileManager'
import SurveyCloneJob from './clone/surveyCloneJob'
import SurveyCreateJob from './surveyCreateJob'
import SurveyPublishJob from './publish/surveyPublishJob'
import { SchemaSummaryExportJob } from './schemaSummary'
import SurveyActivityLogClearJob from './surveyActivityLogClearJob'
Expand Down Expand Up @@ -113,6 +114,12 @@ export const cloneSurvey = ({ user, surveyId, surveyInfoTarget, cycle = null })
return JobUtils.jobToJSON(job)
}

export const startCreateSurveyJob = ({ user, surveyInfo }) => {
const job = new SurveyCreateJob({ user, surveyInfo })
JobManager.enqueueJob(job)
return JobUtils.jobToJSON(job)
}

/**
* Starts the node definitions AI translation job.
* @param {object} params - Params.
Expand Down
26 changes: 10 additions & 16 deletions test/e2e/tests/_surveyCreate/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,26 +19,20 @@ export const createSurvey = (surveyToAdd) => {
testId: TestId.surveyCreate.surveyCloneFrom,
label: `${cloneFrom} - ${cloneFromLabel}`,
})

// press "Create survey" and wait for the job to complete
await page.click(getSelector(TestId.surveyCreate.submitBtn, 'button'))
await page.waitForSelector(getSelector(TestId.modal.modal))

// close the job dialog and wait fot the navigation to the survey dashboard
await Promise.all([
page.waitForNavigation(/* { url: `{BASE_URL}/app/home/landing/` } */),
page.click(TestId.modal.close),
])
} else {
await FormUtils.fillInput(TestId.surveyCreate.surveyLabel, label)

// press "Create survey" and wait for the navigation to the survey dashboard
await Promise.all([
page.waitForNavigation(/* { url: `{BASE_URL}/app/home/landing/` } */),
page.click(getSelector(TestId.surveyCreate.submitBtn, 'button')),
])
}

// press "Create survey" and wait for the job to complete (survey creation runs as a job too, to bound concurrency)
await page.click(getSelector(TestId.surveyCreate.submitBtn, 'button'))
await page.waitForSelector(getSelector(TestId.modal.modal))

// close the job dialog and wait fot the navigation to the survey dashboard
Comment thread
Copilot marked this conversation as resolved.
Outdated
await Promise.all([
page.waitForNavigation(/* { url: `{BASE_URL}/app/home/landing/` } */),
page.click(TestId.modal.close),
])

const surveyTitleSelector = getSelector(TestId.header.surveyTitle)
await expect(await page.innerText(surveyTitleSelector)).toBe(`${label} [${name}]`)
})
Expand Down
4 changes: 4 additions & 0 deletions test/integration/tests/001surveyIntegrationtest.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ describe('Survey Test', () => {

test('Create Survey', async () => SurveyIntegrationTest.createSurveyTest())

test('Create Surveys Concurrently', async () => SurveyIntegrationTest.createSurveysConcurrentlyTest())

test('Import Surveys Concurrently', async () => SurveyIntegrationTest.importSurveysConcurrentlyTest())

// ==== CATEGORY

test('Create Category', async () => CategoryTest.createCategoryTest())
Expand Down
43 changes: 43 additions & 0 deletions test/integration/tests/_survey/surveyTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,46 @@ export const createSurveyTest = async () => {
expect(Survey.getDefaultLanguage(surveyInfo)).toEqual(expectedDefaultLanguage)
expect(Survey.getDefaultLabel(surveyInfo)).toEqual(Survey.getDefaultLabel(surveyInfoTest))
}

// Regression test: SurveyManager.insertSurvey used to hold a db transaction open while
// DBMigrator.migrateSurveySchema acquired another connection from the same pool; concurrent survey
// creations could then exhaust the pool and hang the whole server (no connectionTimeoutMillis is set).
export const createSurveysConcurrentlyTest = async () => {
const user = getContextUser()

const newSurveyInfo = () =>
Survey.newSurvey({
ownerUuid: User.getUuid(user),
name: `do_not_use__test_survey_concurrent_${uuidv4()}`,
label: 'DO NOT USE! Test Survey (concurrent)',
languages: ['en'],
})

const [surveyA, surveyB] = await Promise.all([
SurveyManager.insertSurvey({ user, surveyInfo: newSurveyInfo() }),
SurveyManager.insertSurvey({ user, surveyInfo: newSurveyInfo() }),
])

expect(Survey.getId(surveyA)).not.toEqual(Survey.getId(surveyB))
}

// Regression test: SurveyManager.importSurvey (used when restoring an Arena backup file, and when
// cloning a survey) had the same transaction-held-open-during-migration bug as insertSurvey above.
export const importSurveysConcurrentlyTest = async () => {
const user = getContextUser()

const newSurveyInfo = () =>
Survey.newSurvey({
ownerUuid: User.getUuid(user),
name: `do_not_use__test_survey_import_concurrent_${uuidv4()}`,
label: 'DO NOT USE! Test Survey (import concurrent)',
languages: ['en'],
})

const [surveyA, surveyB] = await Promise.all([
SurveyManager.importSurvey({ user, surveyInfo: newSurveyInfo(), backup: true }),
SurveyManager.importSurvey({ user, surveyInfo: newSurveyInfo(), backup: true }),
])

expect(Survey.getId(surveyA)).not.toEqual(Survey.getId(surveyB))
}
Loading