Skip to content

OUT-3763 (2/2) | Stop comment-create from leaving stale content on attachment paths#1252

Open
arpandhakal wants to merge 1 commit into
mainfrom
OUT-3763-write-fix
Open

OUT-3763 (2/2) | Stop comment-create from leaving stale content on attachment paths#1252
arpandhakal wants to merge 1 commit into
mainfrom
OUT-3763-write-fix

Conversation

@arpandhakal
Copy link
Copy Markdown
Collaborator

Changes

Fixes the underlying race that produced the ~26 corrupted rows OUT-3763 is patching on read. The previous flow:

  1. moveAttachment(oldPath, newPath) — rename, file gone from oldPath immediately.
  2. getSignedUrl(newPath) — Supabase's signer can briefly lag the rename. On failure, getSignedUrl returned undefined, which the call site silently dropped (if (newUrl) push(...)).
  3. comment.update({ content: newContent }) — saved content with the old (now-empty) URL still in place.

In src/app/api/comments/comment.service.ts:

updateCommentIdOfAttachmentsAfterCreation:

  • moveAttachmentcopyAttachment. The original stays in place during the rewrite.
  • Signing the new path now retries once after 200ms (handles Supabase's metadata-vs-signer race immediately after a write), and throws an APIError on persistent failure instead of silently dropping the replacement.
  • htmlString.replacehtmlString.replaceAll so the same file embedded twice gets both occurrences rewritten.
  • Returns { content, oldFilePaths } so the caller can defer deleting the originals until after the rewritten content commits.

create:

  • Destructures the new return shape; holds oldFilePathsToRemove outside the try block.
  • After comment.update succeeds, removes each original file (per-file try/catch — a delete failure is logged, doesn't fail the request, recoverable via scrap-media cleanup).
  • Catch block now re-throws after comment.delete so the activity log, notifications, and webhook below don't fire for a comment that no longer exists in the DB.

Failure modes after the change

Failure point Old file New file DB Result
copyAttachment fails intact none comment row deleted 500, safe to retry
signing fails twice intact orphan (scrap-media catches) comment deleted 500, safe to retry
createMultipleAttachments fails intact orphan comment deleted 500, safe to retry
comment.update fails intact orphan comment deleted 500, safe to retry
removeAttachment (post-commit) fails duplicate left at old path correctly placed committed success; minor storage leak

The previous "comment committed with stale content + file gone from old path" state is no longer reachable.

Testing Criteria

  • Create a fresh comment with one image → renders immediately and after reload; Comments.content contains the comment-scoped path.
  • Create a fresh comment with a PDF/doc → download works immediately and after reload.
  • Create a comment that embeds the same image twice (paste twice in editor) → both occurrences render after reload.
  • Force a signing failure (stub getSignedUrl to return undefined for any path containing /comments/) → create attempt returns 500, no Comment row remains in the DB, no activity log entry created, no webhook fired.
  • Verify the original file at the pre-rewrite path is gone after a successful comment-create (removeAttachment cleanup ran).

Notes

  • This is the underlying-cause half of OUT-3763. The user-facing read-side fallback for the existing 26 broken rows is in a sibling PR — they're independent, either can land first.
  • The 26 already-corrupted Comments.content rows still need a backfill (separate ticket).
  • The same code pattern exists in tasksShared.service.ts:498 for tasks. Not changed here per scope (you confirmed tasks aren't actually hitting this path in current usage). Worth a follow-up sanity check that the new-task-with-staged-files flow doesn't trigger the same bug class.

Impact & Surface Area of Change

  • Every comment-create call now flows through copy → sign → write → delete-old. Failure mode shifts from "silently saves stale content" (silent bug) to "returns 500 + comment rolled back" (loud failure). withErrorHandler already surfaces this as a clean HTTP error.
  • Slightly slower happy path on creates with attachments: one extra Supabase round-trip per attachment (copy + delete-old vs. a single rename). 200ms retry only fires when signing actually fails.
  • No change to comment reads, comment updates, comment deletes, or replies.

🤖 Generated with Claude Code

…chment paths

The previous flow moved (renamed) each attachment to its comment-scoped
path, then signed the new path. If signing failed — Supabase's signer
can briefly lag a fresh write — the URL was silently dropped, no throw,
and the comment was saved with content still pointing at the now-empty
old path.

Switch to copy-not-move, retry signing once after 200ms, and throw on
persistent failure so the existing try/catch rolls back the comment.
Originals are removed only after the rewritten content commits, so any
failure mid-flight is recoverable. Re-throw on rollback so the activity
log, notifications, and webhook don't fire for a deleted comment.

Also: replace → replaceAll covers the same-file-embedded-twice edge
case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 22, 2026

OUT-3763

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 22, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tasks-app Ready Ready Preview, Comment May 22, 2026 10:26am

Request Review

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 22, 2026

Greptile Summary

This PR fixes the race condition behind OUT-3763 where a comment's content column could be committed with stale attachment URLs pointing at files that no longer existed. The change replaces an atomic moveAttachment (rename) with a copy-then-delete-after-commit strategy and threads a throw through the rollback catch block so activity logs, notifications, and webhooks never fire for comments that were subsequently deleted.

  • Copy-before-write safety: copyAttachment now runs before any DB write; removeAttachment on the originals only fires after comment.update succeeds. A signing failure causes a hard 500 with comment rollback rather than a silent save of stale content.
  • replaceAll + deduplication: a seenSrcs set prevents double-copying the same file, and replaceAll ensures every occurrence of a repeated embed is rewritten consistently.
  • Catch-block re-throw: the previously missing throw e is now in place, preventing the activity log and webhook from firing for a rolled-back comment.

Confidence Score: 4/5

The core race fix is sound and the failure table in the PR description maps cleanly to the code. The only meaningful concern is that attachment processing is now fully sequential, which can add noticeable latency on comments with several files.

The copy-before-write approach correctly eliminates the stale-content race. The missing throw e in the catch block was a real pre-existing bug (activity logs fired for deleted comments) and is now fixed. The sequential loop is a deliberate trade-off called out in the PR description, but it does meaningfully increase p50 latency for multi-attachment comments and could be revisited. The hard-coded 200 ms retry delay is a minor maintainability concern.

src/app/api/comments/comment.service.ts — specifically the sequential attachment-processing loop (lines 380–408) if multi-attachment comment performance becomes a concern.

Important Files Changed

Filename Overview
src/app/api/comments/comment.service.ts Core change: copy-before-write attachment strategy, signed-URL retry with hard throw on persistent failure, replaceAll for duplicate embeds, deferred old-path cleanup, and missing catch re-throw added. Sequential per-attachment processing is a deliberate latency trade-off; retry delay is magic-numbered.

Reviews (1): Last reviewed commit: "fix(OUT-3763): stop comment-create from ..." | Re-trigger Greptile

Comment on lines +395 to +399
if (!newUrl) {
// Supabase's signer can briefly lag a fresh write.
await new Promise((resolve) => setTimeout(resolve, 200))
newUrl = await getSignedUrl(newFilePath)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 The 200 ms signer-lag wait is a magic number that's easy to lose track of. signedUrlTtl lives in src/constants/attachments.ts — a companion constant like SIGNER_LAG_RETRY_MS there would make intent clear and allow it to be tuned alongside the TTL.

Suggested change
if (!newUrl) {
// Supabase's signer can briefly lag a fresh write.
await new Promise((resolve) => setTimeout(resolve, 200))
newUrl = await getSignedUrl(newFilePath)
}
if (!newUrl) {
// Supabase's signer can briefly lag a fresh write.
await new Promise((resolve) => setTimeout(resolve, SIGNER_LAG_RETRY_MS))
newUrl = await getSignedUrl(newFilePath)
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines 380 to +408
for (const { originalSrc, filePath, fileName } of matches) {
const newFilePath = `${this.user.workspaceId}/${task_id}/comments/${commentId}/${fileName}`
const supabaseActions = new SupabaseActions()

const fileMetaData = await supabaseActions.getMetaData(filePath)
createAttachmentPayloads.push(
CreateAttachmentRequestSchema.parse({
commentId: commentId,
commentId,
filePath: newFilePath,
fileSize: fileMetaData?.size,
fileType: fileMetaData?.contentType,
fileName: fileMetaData?.metadata?.originalFileName || getFileNameFromPath(newFilePath),
}),
)
copyAttachmentPromises.push(supabaseActions.moveAttachment(filePath, newFilePath))
newFilePaths.push({ originalSrc, newFilePath })
await supabaseActions.copyAttachment(filePath, newFilePath)

let newUrl = await getSignedUrl(newFilePath)
if (!newUrl) {
// Supabase's signer can briefly lag a fresh write.
await new Promise((resolve) => setTimeout(resolve, 200))
newUrl = await getSignedUrl(newFilePath)
}
if (!newUrl) {
throw new APIError(
httpStatus.INTERNAL_SERVER_ERROR,
`Failed to sign URL for newly copied attachment at ${newFilePath}`,
)
}
replacements.push({ originalSrc, newUrl })
oldFilePaths.push(filePath)
newFilePathsList.push(newFilePath)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Sequential attachment processing adds O(N) latency

The new loop runs getMetaData → copyAttachment → getSignedUrl (× up to 2) serially for every matched attachment. The previous code parallelised all copies via Promise.all(copyAttachmentPromises) and all signings via Promise.all(signedUrlPromises). For a comment with N attachments at ~150 ms per round-trip, the old path cost roughly max(copy) + max(sign) ≈ 300 ms; the new path costs N × (getMetaData + copy + sign)N × 450 ms.

The serialisation was needed to get clean early-exit semantics on the first signing failure, but the full potential parallelism still exists — all getMetaData calls are independent, all copyAttachment calls are independent, and signing can be batched once all copies are in flight. Consider structuring as three sequential Promise.all passes (fetch metadata → copy → sign with per-item retry) so failure on any single item still aborts cleanly but the N-1 happy-path attachments are processed concurrently.

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.

1 participant