Skip to content

[Bug]: Tag creation race condition in POST /api/doubts can crash doubt creation with 500 error #950

Description

@vipul674

Bug Description

The POST /api/doubts handler creates new tags via db.insert(tagsTable).values(tagsToInsert).returning() without any conflict handling. When two users simultaneously create doubts that introduce the same new tag name, both requests pass the "does tag exist?" check, both attempt to INSERT, and the unique constraint violation on normalizedName crashes one of the requests with an unhandled 500 error — silently discarding the user's doubt.

Steps to Reproduce

  1. Open two browser windows, both authenticated
  2. In the same classroom, send POST /api/doubts from both windows simultaneously
  3. Both requests include a tag name that does not yet exist in the tags table (e.g., "Quantum Computing")
  4. Both requests pass the existingClassroomTags check (tag doesn't exist yet)
  5. One request succeeds; the other fails with a 500 error due to unique constraint violation on tagsTable.normalizedName

Alternatively, send two concurrent POST /api/doubts requests with the same new tag using a tool like curl in parallel:

curl -X POST ... -d '{"subject":"Physics","content":"test","tags":["QuantumComputing"]}' &
curl -X POST ... -d '{"subject":"Physics","content":"test","tags":["QuantumComputing"]}' &

Expected Behavior

The second request should gracefully handle the conflict by either:

  • Using onConflictDoNothing() and re-querying the existing tag (as done elsewhere in the codebase), or
  • Wrapping the tag creation in a unique constraint-aware insert that returns the existing row on conflict

Actual Behavior

In src/app/api/doubts/route.ts (approximately lines 228-244):

if (tagsToInsert.length > 0) {
  const insertedRows = await db.insert(tagsTable).values(tagsToInsert).returning();
  savedTags.push(...insertedRows);
}

There is no onConflictDoNothing() or any conflict handling. The unique constraint on tagsTable.normalizedName will throw a Postgres error on the second concurrent insert.

Contrast with the proper pattern in src/app/api/doubts/action/[id]/route.ts (PATCH handler):

const [createdTag] = await tx.insert(tagsTable).values({...}).onConflictDoNothing().returning();
if (createdTag) { tagRecord = createdTag; } else { /* re-query */ }

The action route correctly handles the race condition with onConflictDoNothing() and a fallback re-query. The main POST route does not.

Environment

  • OS: All
  • Browser: All
  • Node.js version: 18+

Additional Context

While concurrent tag creation is rare in practice, the impact is severe: the user's doubt creation fails entirely because the tag insert throws before the doubt is returned. The user loses their typed content (which is not recoverable from a 500 response). This is a correctness issue that contradicts the principle that concurrent identical mutations should be safe.

Suggested Fix

Add onConflictDoNothing() to the tag insert, then re-query for the existing tag when the insert returns no rows:

if (tagsToInsert.length > 0) {
  const insertedRows = await db
    .insert(tagsTable)
    .values(tagsToInsert)
    .onConflictDoNothing()
    .returning();

  if (insertedRows.length < tagsToInsert.length) {
    const remainingNames = tagsToInsert
      .filter(t => !insertedRows.some((r: any) => r.normalizedName === t.normalizedName))
      .map(t => t.normalizedName);
    if (remainingNames.length > 0) {
      const existingTags = await db
        .select()
        .from(tagsTable)
        .where(
          and(
            inArray(tagsTable.normalizedName, remainingNames),
            classroomId ? eq(tagsTable.classroomId, classroomId) : isNull(tagsTable.classroomId),
          ),
        );
      insertedRows.push(...existingTags);
    }
  }

  savedTags.push(...insertedRows);
}

GSSoC 2026

I would like to work on this issue under GSSoC 2026. Please assign it to me.

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendAPI routes, database, server logicdatabaseDatabase schema, queries, migrationsgssoc'26GSSoC program issuelevel:advancedAdvanced level tasktype:bugBug fix

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions