perf: run blocking data endpoints in a threadpool, not on the event loop - #443
perf: run blocking data endpoints in a threadpool, not on the event loop#443anxkhn wants to merge 1 commit into
Conversation
Automated review summaryTriage: bug | Recommendation: needs-revision Findings
Conventions
This is a well-scoped, well-tested backend concurrency fix: it moves ten route handlers that were doing blocking pandas/file I/O directly inside async def bodies (silently stalling the whole event loop for every other request) to plain def so FastAPI offloads them to its threadpool, and wraps upload_project's two blocking calls in run_in_threadpool since that route must stay async. The new test file pins the contract both structurally (routes are sync; upload offloads correctly) and behaviorally (concurrent requests no longer serialize), and CI (backend lint, backend tests, e2e) is green aside from a pre-existing, unrelated frontend-lint formatting failure. The one thing worth a second look before merge: this same change removes the incidental serialization that was masking a lost-update race when transform_project/save_project/revert_to_checkpoint/undo_last_transformation are hit concurrently for the same project, and no test covers that path -- worth a follow-up (per-project lock or optimistic concurrency) even if it doesn't block this PR. PR title and description are exemplary (clear Conventional Commit title, thorough rationale and scoping notes, explicit red/green testing methodology), and the single commit is clean. Generated by |
db9e402 to
09869b3
Compare
|
addressed the race in 09869b3. mutating operations now take a per-project lock across their file and change-log work, while different projects and read-only previews remain concurrent. added a regression test that runs two same-project transforms concurrently and verifies both updates survive. also rebased onto current main and confirmed the frontend prettier check, frontend lint, backend ruff, and all 648 backend tests pass locally. @ivantha could you please re-review? |
09869b3 to
30fb849
Compare
Automated review summaryTriage: bug | Recommendation: needs-revision Findings
Conventions
This PR correctly diagnoses and fixes a real bug: ten data-heavy routes ran blocking pandas I/O directly on the asyncio event loop, so converting them to plain def and offloading upload_project's blocking calls via run_in_threadpool is the right fix, and the new project_write_lock is a necessary, correctly implemented companion change that prevents lost-update races between concurrent writers on the same project. However, the lock only guards the write endpoints: get_project_details, export_project, the four profiling routes, and preview-mode transform_project still read the same working-copy file unlocked, and since file_formats.py's writers truncate and rewrite the file in place rather than atomically, a concurrently running read can now observe a partially written file and return a spurious 400 or 500, a new failure mode this PR's own tests do not cover. Recommend closing that read or write gap (or making the writers atomic via temp file plus rename) before merging. The lock-scope inconsistency between save_project's tight critical section and the other three endpoints holding the lock for their entire body, including pure response construction, is a smaller, non-blocking follow-up. Generated by |
30fb849 to
3f29562
Compare
Automated review summaryTriage: bug | Recommendation: needs-revision Findings
Conventions
This PR converts ten routes doing blocking pandas file I/O from async def to plain def so FastAPI runs them in its threadpool instead of the event loop, wraps upload_project's blocking calls in run_in_threadpool, and adds a new per-project threading.Lock (project_write_lock) around the four write endpoints (save, revert, undo, non-preview transform) to prevent lost updates now that those writes can run truly concurrently. The core fix is sound and well tested (structural checks plus a behavioral overlap test), but the new lock only covers writer-vs-writer races: read-only routes (get_project_details, all four profiling endpoints, export, and preview-mode transform) still read the same file path unlocked, and since the format writers truncate-and-write the target path directly rather than via temp-file-plus-rename, a read can now race an in-flight write and see a partial file — a window that was effectively closed before this PR by the single event-loop thread. Recommend closing that read/write gap (e.g., a shared/read variant of the lock, or making saves atomic via temp file + os.replace) before or shortly after merge. Two minor cleanups are also worth a look: transform_project duplicates its dispatch call across both branches of the preview check, and the lock's "doesn't block other projects" guarantee isn't directly tested. Generated by |
Move blocking pandas and file I/O off the asyncio event loop. Same-project reads and writes of the working copy share a per-project lock so concurrent transforms, saves, reverts, undos, and file reads cannot tear or lose updates. Independent projects stay concurrent. Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
3f29562 to
17dcbb3
Compare
The data-heavy routes are declared
async defwhile doing synchronous, blockingpandas file I/O (
read_table_safe->pd.read_csv/read_excel/read_parquet,save_table_safe->df.to_csv/to_excel) and CPU-bound pandas work. InStarlette an
async defpath operation runs directly on the asyncio event loop,so a single request parsing a CSV/XLSX (up to the 10MB upload limit, which can
take hundreds of milliseconds to seconds) blocks every other concurrent request
for that whole duration.
GET /projects/get/{id}is the clearest case: it readsthe entire file on every paginated page view, then slices in memory.
FastAPI runs plain
defpath operations in an external threadpool for exactlythis reason, keeping the loop free. This PR moves the blocking work off the event
loop with the smallest change that preserves behavior:
awaitfromasync defto plaindef, so FastAPI runs them in its threadpool:projects.py:get_project_details,save_project,revert_to_checkpoint,export_project,undo_last_transformationtransformations.py:transform_projectprofiling.py:get_dataset_summary,get_column_profile,get_all_column_profiles,get_correlation_matrixupload_projectmust stayasync defbecause itawaits upload validation,so wrap its blocking
store_upload(disk write) andread_table_safe(parse)calls in
starlette.concurrency.run_in_threadpoolinstead.rename_project_endpointanddelete_project_endpointare intentionally leftasync def: they touch only DB metadata and do no pandas file I/O, so keepingthe diff off them stays focused on the actual problem.
This is a pure concurrency fix with no change to request/response behavior.
No existing issue tracks this, so there is no
Fixes #reference.Type of Change
How Has This Been Tested?
Added
dataloom-backend/tests/test_endpoint_concurrency.pywith three tests:def(not coroutines), so FastAPIoffloads them to the threadpool.
upload_projectstaysasync defand its body offloads bothstore_uploadandread_table_safeviarun_in_threadpool.read_table_safepatched to sleep, five overlappingGET /projects/get/{id}requests must finish well under the serialized floor(they overlap in the threadpool instead of running one after another on the loop).
Verified red -> green: on the pre-fix
async defcode the behavioral testserializes five 0.4s reads to about 2.1s and all three tests fail; after the fix
they overlap and finish in about 0.5s and pass.
Commands (from
dataloom-backend/, Python 3.12,DATABASE_URL=sqlite:///./test.db):uv run ruff check .-> All checks passeduv run ruff format --check .-> already formatteduv run pytest-> 482 passed (the pre-existing SQLModelsession.querydeprecation warnings are unrelated to this change)
Existing tests pass
New tests added
Manual testing
Screenshots (if applicable)
N/A (backend-only concurrency change).
Checklist
Files changed (diff = origin/main..HEAD)