Skip to content

Fixed new survey import concurrency issue - #4347

Merged
SteRiccio merged 36 commits into
masterfrom
fix/survey-import-concurrency
Aug 12, 2026
Merged

Fixed new survey import concurrency issue#4347
SteRiccio merged 36 commits into
masterfrom
fix/survey-import-concurrency

Conversation

@SteRiccio

Copy link
Copy Markdown
Member

No description provided.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses a concurrency issue in survey creation/import where holding a DB transaction open while running schema migrations could exhaust the connection pool and hang the server. It refactors SurveyManager.insertSurvey and SurveyManager.importSurvey to avoid wrapping schema migration inside a held-open transaction, and updates the create-survey flow to return a job (aligning with the UI job-monitor pattern), with added regression coverage.

Changes:

  • Refactor survey create/import manager functions to run schema migration outside an open transaction and add cleanup on failure.
  • Change the /survey create endpoint to enqueue a new SurveyCreateJob (instead of returning a created survey synchronously).
  • Add integration regression tests for concurrent survey create/import; update E2E survey creation to wait for the job modal flow.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
test/integration/tests/001surveyIntegrationtest.js Adds new integration test cases for concurrent survey operations.
test/integration/tests/_survey/surveyTest.js Implements concurrent create/import regression tests using Promise.all.
test/e2e/tests/_surveyCreate/index.js Updates E2E flow to wait for job completion modal for survey creation/clone.
server/modules/survey/service/surveyService.js Adds startCreateSurveyJob to enqueue create-survey as a job.
server/modules/survey/service/surveyCreateJob.js Introduces a new job class for survey creation and returns surveyId as job result.
server/modules/survey/manager/surveyManager.js Refactors insertSurvey/importSurvey to avoid running schema migration under a held-open transaction; adds cleanup on error.
server/modules/survey/api/surveyApi.js Changes create survey API to return a job for new surveys (consistent with clone path).
server/job/jobCreator.js Registers SurveyCreateJob so it can be instantiated by the job system.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread test/e2e/tests/_surveyCreate/index.js Outdated
Comment thread server/modules/survey/service/surveyCreateJob.js
Comment thread server/modules/survey/manager/surveyManager.js
Co-authored-by: SteRiccio <1219739+SteRiccio@users.noreply.github.com>
@mergify

mergify Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

SteRiccio and others added 13 commits August 10, 2026 13:51
Spec for a standalone HTTP-level load-testing script to validate the
survey creation/import concurrency fixes on this branch under real
concurrent load (50+ simultaneous requests) via the actual API, JWT
auth, multipart upload, and job queue -- not just the in-process
2-concurrent regression tests already in test/integration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Split long template-literal strings into intermediate variables to conform
to project's prettier printWidth constraint. No change to output formatting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wires together the config/httpApi/report lib modules (Tasks 1-4) into
a runnable CLI that fires concurrent survey import requests against a
running Arena server, polls each job to completion, prints a latency
report, and cleans up created surveys.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…iscover on this Node build

node --test test/load/lib/ fails with MODULE_NOT_FOUND on Node v24.18.0
(directory args to --test aren't auto-discovered), while the equivalent
glob test/load/lib/*.test.js runs all 27 tests correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
runSingleImport only wrapped importSurveyZip in try/catch, so a
transient getJobStatus failure during pollJobUntilTerminal (plausible
under the exact concurrent load this tool induces) propagated out of
Promise.all in main(), discarding every other request's result and
skipping both the summary report and cleanupSurveys. Wrap the polling
phase in its own try/catch and return a 'rejected-at-http' result
entry instead, matching the shape used for import-phase failures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Final review of the stress-test tool found that server/job/JobQueue.js
serializes all survey-creation/import jobs to one at a time globally
(pre-existing queue infra, not introduced by this branch), so a single
shared login can never produce concurrent job execution and even N
distinct users only ever get one job running at once. Documents the
verified findings and the user-confirmed rescope to N throwaway users,
which still exercises real burst load through auth/upload/queueing that
the in-process regression tests don't cover.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…sers

Adds three tasks to the implementation plan: safe error-body handling in
httpApi.js (Task 6), fixing the job-status field extraction bug plus
threading fetchImpl through the orchestrator for real test coverage of
the previously-untested seam (Task 7), and provisioning N throwaway users
so the tool can actually fire N accepted requests instead of getting
immediately rejected by the server's per-user job-queue guard (Task 8).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Add readBody helper that reads response body as text and safely parses JSON
- Update login, importSurveyZip, getJobStatus, deleteSurvey to use readBody
  for error responses, preventing SyntaxError on non-JSON error bodies
- Add createUser function for user account creation (needed by Task 8)
- Add 5 new test cases covering non-JSON and empty error response bodies

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…chestrator

pollJobUntilTerminal now never rejects: a null job-status read no longer
crashes the poller, and up to MAX_CONSECUTIVE_POLL_ERRORS transient poll
failures are tolerated before giving up with status 'rejected-at-http'.
surveyId/errors/result are backfilled from the last non-terminal read when
the terminal read lacks them, fixing silent cleanup and "unknown error"
reporting. runSingleImport's now-dead try/catch around polling is removed.
cleanupSurveys deletes sequentially and returns {deletedCount, totalCount}
so a regression back to "deletes nothing" is visible in the output.

All three exported functions accept an injectable fetchImpl (default: the
global fetch), enabling real non-network unit tests for logic that
previously had none.

Also adds a minimal test/load/lib/userProvisioning.js placeholder
(buildLoadTestUserCredentials) purely so the orchestrator's require() of it
resolves -- main() does not call it yet (Task 8's job); its real design is
out of scope here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SteRiccio and others added 10 commits August 10, 2026 16:26
…ontent

The plan doc (docs/superpowers/plans/2026-08-10-survey-import-stress-test.md)
already specifies buildLoadTestUserCredentials's exact implementation for
Task 8. Replace this task's improvised placeholder with that exact content
(deterministic stress_test_<runId>_<i>@loadtest.local emails, fixed
LOAD_TEST_USER_PASSWORD) so there's no drift for Task 8 to reconcile later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds userProvisioning.test.js (userProvisioning.js was already implemented
in the prior task's commit due to a plan-sequencing dependency, and passes
unchanged). Wires runSingleUserImport into main(): create each throwaway
user as admin, log in as them, then run their import via the existing
runSingleImport/pollJobUntilTerminal. cleanupSurveys now uses the admin
token.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… admin's

The success-path test for runSingleUserImport recorded every fetchImpl call
but never checked calls[2] (the import-accept request), so a regression
that passed adminAuthToken instead of userAuthToken into runSingleImport
would still pass. Add an assertion that calls[2]'s Authorization header is
the new user's token from the mocked login response.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ponse

cleanupSurveys now queries the server directly for every survey whose name
starts with the run's prefix instead of only deleting surveyIds observed
during polling, so a job that timed out while still queued (and thus never
surfaced a surveyId locally) still gets cleaned up. Also, createUser now
treats a 200 response carrying a validation field as a failure, since the
server rejects invalid users that way instead of via a non-2xx status.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…arget

The throwaway load-test user password was a fixed string committed to a
public repo, on surveyManager-privileged accounts that can never be deleted
via the API. Generate a random password once per run instead (ephemeral,
never logged back into after the run completes). main() also now warns
loudly (non-blocking, so the tool still runs non-interactively) when --url
doesn't resolve to localhost/127.0.0.1, since a non-local run creates
permanent throwaway accounts on that server.

Also: fix test:load:unit to glob test/load/**/*.test.js so it actually runs
surveyImportStressTest.test.js (previously silently skipped, 44 -> 57
tests); add test/load/README.md documenting usage, the two known
limitations, and the cleanup SQL for the permanent throwaway accounts;
report acceptMs: null (not setup latency) when user setup fails, so it
doesn't distort the accept-latency stat; give 'timed-out' outcomes a real
error message instead of the literal string "unknown error"; and reject a
CLI flag value that looks like another flag (e.g. `--zip --count`) instead
of silently resolving it as a bogus path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…entry files

Array.prototype.sort() needs -1/0/1, not a boolean. The previous comparator
always returned true/false, so file bundling order effectively fell back to
whatever glob.globSync()'s underlying readdir happened to return -- not
deterministic across checkouts or runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- test/load/lib/config.js: replace the /\/+$/ trailing-slash regex with a
  plain loop (javascript:S8786 - potential super-linear backtracking)
- test/load/lib/httpApi.js: use optional chaining for the job.uuid check
  (javascript:S6582)
- test/load/lib/report.js: consolidate consecutive lines.push() calls into
  single calls (javascript:S7778, x7)
- test/load/lib/httpApi.test.js: add a real assertion (request URL/method/
  auth header) to the createUser success-path test instead of only
  asserting it doesn't throw (javascript:S2699, BLOCKER)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Converts all test/load files from CommonJS .js to ES module .ts, adding
real type annotations throughout (previously untyped since checkJs is
false for plain .js). Runs identically to before -- Node 24's native
type-stripping executes these files directly, no build step added.

- require()/module.exports -> import/export (the project's ESLint config
  forbids require() in .ts files; this also gets real cross-file type
  inference that a typed require() couldn't)
- require.main === module -> import.meta.main for CLI-entry detection
- require('dotenv').config() -> import 'dotenv/config', matching
  server/server.js's own convention
- tsconfig.json: add moduleDetection: "force" so files without their own
  import/export aren't merged into one implicit global script scope
  (needed once multiple CommonJS-shaped .ts files existed side by side);
  allowImportingTsExtensions: true so import specifiers can reference
  their real .ts extension, since Node's native loader requires it and
  noEmit: true means there's no compiled output to rewrite it against.
  Verified zero effect on the rest of the project's typecheck (identical
  39 pre-existing, unrelated errors before and after).
- package.json: test:load and test:load:unit point at the .ts entry
  points and glob.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…dler"

"node" (aka "node10") is deprecated as of TS 5.x and will be removed in
TS 7.0. "bundler" is the appropriate replacement here since the project
resolves modules through webpack, not Node's algorithm, for everything
except test/load's directly-executed .ts files. Verified: identical 39
pre-existing, unrelated typecheck errors before and after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…to tsconfig.json

baseUrl is deprecated as of TS 5.x and will be removed in TS 7.0. Since
TS 4.1, paths resolve relative to the tsconfig file's own directory when
baseUrl is omitted -- identical to baseUrl: "./" here, since tsconfig.json
already lives at the project root. Verified: identical 39 pre-existing,
unrelated typecheck errors before and after; this only affects tsc's own
resolution, not the runtime path aliases (those come from jsconfig.json
and the webpack config, unaffected by this file).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 31 changed files in this pull request and generated 1 comment.

Suppressed comments (3)

test/load/surveyImportStressTest.ts:100

  • Similarly, the lastKnown* fields should be updated with ?? rather than || to avoid skipping legitimate falsy values.
      lastKnownSurveyId = job.surveyId || lastKnownSurveyId
      lastKnownErrors = job.errors || lastKnownErrors
      lastKnownResult = job.result || lastKnownResult

test/load/surveyImportStressTest.ts:395

  • import.meta.main is not a Node.js standard entrypoint check and (when undefined) will prevent main() from running when this script is executed via node .... This will make yarn test:load appear to do nothing.
if (import.meta.main) {
  main().catch((error) => {
    console.error('Stress test failed to run:', error)
    process.exitCode = 1
  })

test/load/surveyImportStressTest.ts:94

  • Backfilling surveyId/errors/result using || will treat valid falsy values (e.g. empty arrays/objects) as “missing” and incorrectly fall back to stale values from a previous poll. Using nullish coalescing avoids that.

This issue also appears on line 98 of the same file.

        surveyId: job.surveyId || lastKnownSurveyId,
        errors: job.errors || lastKnownErrors,
        result: job.result || lastKnownResult,

Comment thread package.json Outdated
SteRiccio and others added 3 commits August 12, 2026 19:23
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Verified against a real run: the server rate-limits /auth/login (e.g. 10
req/30s, per @openforis/arena-server's RateLimitMiddleware) to guard
against brute-forcing, and this tool's burst of concurrent throwaway-user
logins from one IP reliably triggers it -- 21/30 users failed with
"Login failed (status 429)" in a real local run. That's expected server
behavior, not a bug, so the fix is to retry rather than fail.

login() now retries up to LOGIN_RATE_LIMIT_MAX_RETRIES (5) times on 429,
honoring the server's Retry-After header (express-rate-limit sends this
by default; confirmed via its source) and capping it at
LOGIN_RATE_LIMIT_MAX_RETRY_MS (30s) in case of an unreasonable value.
Falls back to LOGIN_RATE_LIMIT_DEFAULT_RETRY_MS (2s) when the header is
absent. Retry delay is injected via a sleepImpl parameter, mirroring the
existing fetchImpl pattern, so tests exercise the real retry loop without
real waiting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@SteRiccio
SteRiccio merged commit d315013 into master Aug 12, 2026
6 checks passed
@SteRiccio
SteRiccio deleted the fix/survey-import-concurrency branch August 12, 2026 19:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants