Skip to content

perf(admin): batch the delete cascade - #121

Merged
udaycodespace merged 1 commit into
udaycodespace:mainfrom
MOHITKOURAV01:perf/116-cascade-delete-batching
Aug 30, 2026
Merged

perf(admin): batch the delete cascade#121
udaycodespace merged 1 commit into
udaycodespace:mainfrom
MOHITKOURAV01:perf/116-cascade-delete-batching

Conversation

@MOHITKOURAV01

Copy link
Copy Markdown
Contributor

Closes #116.

The defect

removeUserDependents worked one row and one course at a time.

The learner counts were one awaited round trip per enrolment, not per
course:

for (const [courseId, count] of countsByCourse) {
  for (let step = 0; step < count; step += 1) {
    await CourseModel.updateOne(
      { _id: courseId, enrolled: { $gt: 0 } },
      { $inc: { enrolled: -1 } },
    );
  }
}

The authored courses were a full five-call cascade each.

work before after
decrement learner counts E 1
cascade authored courses 5 × A 5
own rows 5 5

A teacher with 40 courses and 300 enrolments was ~500 sequential operations,
each paying a latency hop, inside one HTTP request that deleteUserController
awaits before answering — with no progress indication on the admin dashboard.

find({ userId }) also loaded whole course documents; sections carries every
section's title, description and path, so on a twenty-section course it is the
largest field there is, fetched to be thrown away.

Same defect class as #96 and #104, on the write path.

What is here

decrementEnrolledCounts is one bulkWrite. The operations are unchanged
— still one guarded $inc: -1 per enrolment rather than a single
$inc: -count, because enrolled has drifted on existing data and the
enrolled: { $gt: 0 } filter re-evaluated per operation is what stops the
counter going negative and rendering LEARNERS: -3 on a card.

ordered: true is load-bearing: two unordered decrements against a course
sitting at 1 could both see the guard satisfied. Verified against a real
MongoDB, not only the stub:

drifted (was 1, asked -4) -> 0 (expected 0, never negative)
healthy (was 10, asked -3) -> 7 (expected 7)

removeCoursesDependents(courseIds, ...) takes many ids and cascades them
in four deleteMany calls whatever the count. removeCourseDependents is the
single-course wrapper around it, unchanged in signature and return shape,
because courseDeletionController calls it directly. A lone id stays an
equality match rather than a one-element $in, so the common path reads in the
profiler as the query it always was.

Projections: "_id sections" for the authored courses and "courseId" for
the user's own enrolments — the only fields either loop reads.

authoredCourses now counts what the delete removed rather than what the
find saw.

What stayed sequential

The video cleanup. Every section video is its own unlink, so it is the one
part of a cascade that is genuinely per-item, and firing an unbounded number at
once trades a latency problem for a file-descriptor one. Still one call per
course, receiving the course document, with a test pinning that.

Tests

All 10 existing tests pass unchanged against both implementations. That is
the point: same rows, same counters, same summary.

The 11 added ones assert the thing that actually changed, which nothing was
watching — 60 decrements across three courses is one round trip with the
arithmetic still 40→10 / 40→20 / 40→30; the bulk write is ordered; an empty
map issues no write; 25 courses cascade in one deleteMany per collection;
deleting a teacher with 20 courses touches Course exactly twice; the
projections are what they should be; one course still emits
{ courseId: "c1" }.

The stub collection grew a roundTrips log, a bulkWrite, a chainable
select() and $in matching, so cost is assertable without a database.

Checklist

  • cd backend && npm test — 424 pass (413 on main, 11 added)
  • Guard verified against a real MongoDB, not only the stub
  • removed block on the delete-user response unchanged
  • No frontend change
  • npm run lint in frontend/ — not run; no frontend files touched.

Note

The enrolled counter drift is not fixed here, only kept from getting worse. A
recount would rewrite history an admin has been looking at, which is a decision
rather than a cleanup.

Write-up: docs/issue-116-cascade-delete-batching.md.

Deleting an account issued one awaited write per enrolment and a full
five-call cascade per authored course. A teacher with 40 courses and 300
enrolments was around 500 sequential operations, each paying a latency
hop, inside one HTTP request the admin dashboard awaits with no progress
indication. The same defect udaycodespace#96 and udaycodespace#104 fixed on the read path.

decrementEnrolledCounts is one bulkWrite now. The operations are
unchanged — still one guarded $inc: -1 per enrolment rather than a single
$inc: -count, because `enrolled` has drifted on existing data and the
`enrolled > 0` filter re-evaluated per operation is what stops the counter
going negative. ordered: true is load-bearing: two unordered decrements
against a course sitting at 1 could both see the guard satisfied.
Confirmed against a real MongoDB, not only the stub.

removeCoursesDependents takes many ids and cascades them in four
deleteMany calls whatever the count; removeCourseDependents is the
single-course wrapper, unchanged for courseDeletionController. A lone id
stays an equality match rather than a one-element $in, so the common path
reads in the profiler as the query it always was.

The authored-course read asks for `_id sections` instead of whole
documents. sections carries every section's title, description and path,
so on a twenty-section course it is the largest field there is, and the
loop reads nothing else.

authoredCourses now counts what the delete removed rather than what the
find saw.

All 10 existing tests pass unchanged, which is the point — same rows, same
counters, same summary. The 11 added ones assert the round-trip count,
which nothing was watching. The stub collection grew a roundTrips log, a
bulkWrite, a chainable select() and $in matching to make that assertable.

Video cleanup stays one call per course: every section video is its own
unlink, so batching it would trade latency for file descriptors.
@udaycodespace
udaycodespace self-requested a review August 28, 2026 09:31
@udaycodespace udaycodespace added ECSoC26 Required label for a PR to be eligible for Sentinel scoring good-pr PA-awarded bonus for an exceptionally executed PR — +15 XP in review PR is up and waiting on maintainer review and removed in review PR is up and waiting on maintainer review labels Aug 28, 2026
@udaycodespace

Copy link
Copy Markdown
Owner

Reviewed this, @MOHITKOURAV01 . The delete cascade optimization is well scoped and preserves the existing behaviour while significantly reducing database round trips.

The bulkWrite handling, ordered decrements, batched course cleanup, targeted projections, and regression coverage all look good. I also noted that video cleanup intentionally remains sequential to avoid trading database latency for excessive file-system operations.

All backend tests are passing, including the 11 new tests.

Approved and merging.

@udaycodespace
udaycodespace merged commit be1b6c0 into udaycodespace:main Aug 30, 2026
1 check passed
@ecsoc-sentinel ecsoc-sentinel Bot added the ECSoC26-L3 Difficult, auto-assigned by Sentinel — 15 points label Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ECSoC26-L3 Difficult, auto-assigned by Sentinel — 15 points ECSoC26 Required label for a PR to be eligible for Sentinel scoring good-pr PA-awarded bonus for an exceptionally executed PR — +15 XP

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Performance]: Deleting a user issues one write per enrolment and one cascade round trip per authored course

2 participants