Fixed new survey import concurrency issue - #4347
Conversation
There was a problem hiding this comment.
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
/surveycreate endpoint to enqueue a newSurveyCreateJob(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.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…arena into fix/survey-import-concurrency
Co-authored-by: SteRiccio <1219739+SteRiccio@users.noreply.github.com>
This reverts commit 0da1bde.
|
Tick the box to add this pull request to the merge queue (same as
|
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>
…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>
There was a problem hiding this comment.
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.mainis not a Node.js standard entrypoint check and (when undefined) will preventmain()from running when this script is executed vianode .... This will makeyarn test:loadappear 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/resultusing||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,
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>
…arena into fix/survey-import-concurrency
|



No description provided.