Skip to content

Commit 5e63834

Browse files
committed
fix(coexist): handle oversized attachments and message id collisions in historical import
1 parent cc910e9 commit 5e63834

4 files changed

Lines changed: 277 additions & 44 deletions

File tree

apps/worker/__tests__/bulk-import-messages.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,52 @@ describe("bulkImportMessages", () => {
340340
expect(result.importedMessages).toBe(1)
341341
})
342342

343+
test("converges a multi-row PK collision by splitting and re-minting only the colliding row", async () => {
344+
// Batch of two distinct messages; only src-2 collides with an existing DB
345+
// row on (id, createdAt). The bulk insert fails, the batch is split, the
346+
// src-1 half inserts cleanly, and only src-2 is re-minted to a free slot.
347+
const pkError = Object.assign(new Error("duplicate key value"), {
348+
cause: { code: "23505", constraint: "173_Message_pkey" },
349+
})
350+
351+
let call = 0
352+
mockBulkCreate.mockImplementation(
353+
(rows: { id: string; sourceId: string | null }[]) => {
354+
call++
355+
// call 1: the full batch [src-1, src-2] — collides.
356+
// call 3: the isolated original src-2 — still collides.
357+
if (call === 1 || call === 3) {
358+
throw pkError
359+
}
360+
// call 2: [src-1] inserts; call 4: [re-minted src-2] inserts.
361+
return rows.map((r) => ({ id: r.id, sourceId: r.sourceId }))
362+
},
363+
)
364+
365+
const result = await bulkImportMessages({
366+
...BASE_PROPS,
367+
messages: [makeMessage("src-1"), makeMessage("src-2")],
368+
})
369+
370+
expect(mockBulkCreate).toHaveBeenCalledTimes(4)
371+
// Both messages land — no data loss from the collision.
372+
expect(result.importedMessages).toBe(2)
373+
374+
const originalSrc2Id = (
375+
mockBulkCreate.mock.calls[2][0] as { id: string; sourceId: string }[]
376+
)[0].id
377+
const remintedSrc2Id = (
378+
mockBulkCreate.mock.calls[3][0] as { id: string; sourceId: string }[]
379+
)[0].id
380+
// The isolated collider is re-minted to a different id; src-1 is untouched.
381+
expect(remintedSrc2Id).not.toBe(originalSrc2Id)
382+
const src1Call = mockBulkCreate.mock.calls[1][0] as {
383+
id: string
384+
sourceId: string
385+
}[]
386+
expect(src1Call[0].sourceId).toBe("src-1")
387+
})
388+
343389
test("does not bump activity itself — returns message timestamps for the caller to batch", async () => {
344390
// Activity bumps (lastMessageAt / lastActivityAt) are the caller's job now;
345391
// bulkImportMessages only inserts and reports the newest message time.
@@ -421,6 +467,22 @@ describe("bulkImportMessages", () => {
421467
expect(result.newestIncomingMessageAt).toEqual(incomingTimestamp)
422468
})
423469

470+
test("fails loudly when a single row cannot find a free slot after the re-mint cap", async () => {
471+
// A row that keeps colliding on every re-mint must eventually give up with a
472+
// clear error rather than looping forever.
473+
const pkError = Object.assign(new Error("duplicate key value"), {
474+
cause: { code: "23505", constraint: "173_Message_pkey" },
475+
})
476+
mockBulkCreate.mockRejectedValue(pkError)
477+
478+
await expect(
479+
bulkImportMessages({ ...BASE_PROPS, messages: [makeMessage("src-1")] }),
480+
).rejects.toThrow("unresolved after")
481+
// Initial bulk insert plus the bounded re-mint attempts — proves it looped
482+
// and gave up, not that it tried once.
483+
expect(mockBulkCreate.mock.calls.length).toBeGreaterThan(2)
484+
})
485+
424486
test("does NOT retry on non-PK errors — they propagate", async () => {
425487
const fkError = Object.assign(new Error("fk violation"), {
426488
cause: { code: "23503", constraint: "Message_conversationId_fkey" },

apps/worker/__tests__/coexist-attachment-download.test.ts

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,10 @@ vi.mock("../src/lib/logger", () => ({
122122
// Import the handler AFTER mocks
123123
// ---------------------------------------------------------------------------
124124

125-
import { coexistAttachmentDownload } from "../src/integration/handlers/coexist/attachment-download"
125+
import {
126+
coexistAttachmentDownload,
127+
MAX_ATTACHMENT_BYTES,
128+
} from "../src/integration/handlers/coexist/attachment-download"
126129

127130
// ---------------------------------------------------------------------------
128131
// Fixtures
@@ -358,30 +361,41 @@ describe("coexistAttachmentDownload", () => {
358361
})
359362

360363
// ─────────────────────────────────────────────────────────────────────────
361-
// SECURITY: size cap
364+
// SIZE CAP: an oversized attachment is a PERMANENT condition, so the handler
365+
// must skip it terminally (log a warning and return) rather than throw —
366+
// throwing would burn all BullMQ retry attempts on a job that can never
367+
// succeed. See AttachmentTooLargeError.
362368
// ─────────────────────────────────────────────────────────────────────────
363369

364-
it("rejects Messenger response with content-length exceeding MAX_ATTACHMENT_BYTES", async () => {
370+
it("skips (no throw) a Messenger response whose content-length exceeds the cap", async () => {
365371
wireSelectChain({
366372
id: "att-001",
367373
originPath: "https://example.com/media/huge.mp4",
368374
mimeType: "video/mp4",
369375
})
370376
wireIntegrationLookup()
371377

372-
const OVER_LIMIT = String(51 * 1024 * 1024) // 51 MB
378+
const OVER_LIMIT = String(MAX_ATTACHMENT_BYTES + 1)
373379
const fetchMock = vi.fn(() =>
374380
Promise.resolve(makeFetchResponse({ contentLength: OVER_LIMIT })),
375381
)
376382
vi.stubGlobal("fetch", fetchMock)
377383

378-
await expect(coexistAttachmentDownload(BASE_DATA)).rejects.toThrow()
384+
// Must resolve (terminal skip), never reject — a reject would trigger retry.
385+
await expect(coexistAttachmentDownload(BASE_DATA)).resolves.toBeUndefined()
379386
expect(mockPutObject).not.toHaveBeenCalled()
387+
expect(mockUpdateAttachment).not.toHaveBeenCalled()
388+
expect(mockLoggerWarn).toHaveBeenCalledWith(
389+
expect.objectContaining({ attachmentId: BASE_DATA.attachmentId }),
390+
expect.stringContaining("exceeds size cap"),
391+
)
392+
// Terminal skip must NOT be logged as an error (that reads as a failure).
393+
expect(mockLoggerError).not.toHaveBeenCalled()
380394

381395
vi.unstubAllGlobals()
382396
})
383397

384-
it("rejects Messenger response whose body bytes exceed MAX_ATTACHMENT_BYTES (no content-length)", async () => {
398+
it("skips (no throw) a Messenger response whose streamed body exceeds the cap (no content-length)", async () => {
385399
wireSelectChain({
386400
id: "att-001",
387401
originPath: "https://example.com/media/huge.mp4",
@@ -393,34 +407,60 @@ describe("coexistAttachmentDownload", () => {
393407
Promise.resolve(
394408
makeFetchResponse({
395409
contentLength: undefined, // no header — must be caught by streaming cap
396-
totalBytes: 51 * 1024 * 1024, // 51 MB streamed
410+
totalBytes: MAX_ATTACHMENT_BYTES + 1, // one byte past the cap
411+
chunkSize: 10 * 1024 * 1024,
397412
}),
398413
),
399414
)
400415
vi.stubGlobal("fetch", fetchMock)
401416

402-
await expect(coexistAttachmentDownload(BASE_DATA)).rejects.toThrow()
417+
await expect(coexistAttachmentDownload(BASE_DATA)).resolves.toBeUndefined()
403418
expect(mockPutObject).not.toHaveBeenCalled()
419+
expect(mockLoggerWarn).toHaveBeenCalledWith(
420+
expect.objectContaining({ attachmentId: BASE_DATA.attachmentId }),
421+
expect.stringContaining("exceeds size cap"),
422+
)
404423

405424
vi.unstubAllGlobals()
406425
})
407426

408-
it("rejects WhatsApp response with content-length exceeding MAX_ATTACHMENT_BYTES", async () => {
427+
it("skips (no throw) a WhatsApp response whose content-length exceeds the cap", async () => {
409428
wireSelectChain({
410429
id: "att-001",
411430
originPath: "wa-media:media-id-xyz",
412431
mimeType: "video/mp4",
413432
})
414433
wireIntegrationLookup({ ...FAKE_INTEGRATION_ROW, channel: "whatsapp" })
415434

416-
const OVER_LIMIT = String(51 * 1024 * 1024)
435+
const OVER_LIMIT = String(MAX_ATTACHMENT_BYTES + 1)
417436
const fetchMock = vi.fn(() =>
418437
Promise.resolve(makeFetchResponse({ contentLength: OVER_LIMIT })),
419438
)
420439
vi.stubGlobal("fetch", fetchMock)
421440

422-
await expect(coexistAttachmentDownload(WA_DATA)).rejects.toThrow()
441+
await expect(coexistAttachmentDownload(WA_DATA)).resolves.toBeUndefined()
442+
expect(mockPutObject).not.toHaveBeenCalled()
443+
444+
vi.unstubAllGlobals()
445+
})
446+
447+
it("still throws (retryable) on a transient download failure — non-size errors keep retrying", async () => {
448+
wireSelectChain({
449+
id: "att-001",
450+
originPath: "https://example.com/media/photo.jpg",
451+
mimeType: "image/jpeg",
452+
})
453+
wireIntegrationLookup()
454+
455+
// A 5xx is transient: the handler must rethrow so BullMQ retries it.
456+
const fetchMock = vi.fn(() =>
457+
Promise.resolve(makeFetchResponse({ ok: false })),
458+
)
459+
vi.stubGlobal("fetch", fetchMock)
460+
461+
await expect(coexistAttachmentDownload(BASE_DATA)).rejects.toThrow()
423462
expect(mockPutObject).not.toHaveBeenCalled()
463+
expect(mockLoggerError).toHaveBeenCalled()
424464

425465
vi.unstubAllGlobals()
426466
})

apps/worker/src/integration/handlers/coexist/attachment-download.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,23 @@ const getStorageExtension = (
6262
return MIME_EXTENSION_MAP[mimeType] ?? ""
6363
}
6464

65-
/** Maximum allowed attachment size in bytes (50 MiB). */
66-
const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024
65+
/** Maximum allowed attachment size in bytes (100 MiB). */
66+
export const MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024
67+
68+
/**
69+
* Thrown when an attachment is genuinely larger than `MAX_ATTACHMENT_BYTES`.
70+
* This is a PERMANENT condition — the same bytes exceed the cap on every
71+
* retry — so the download handler treats it as a terminal skip (logs and
72+
* returns) instead of rethrowing, which would burn all 5 BullMQ attempts and
73+
* spam identical error logs for a job that can never succeed. Transient
74+
* failures (network, 5xx, timeout) keep throwing so they still retry.
75+
*/
76+
export class AttachmentTooLargeError extends Error {
77+
constructor(message: string) {
78+
super(message)
79+
this.name = "AttachmentTooLargeError"
80+
}
81+
}
6782

6883
const isPendingOriginPath = (path: string): boolean =>
6984
path.startsWith("http://") ||
@@ -91,7 +106,7 @@ const readBodyWithCap = async (
91106
if (declaredHeader !== null) {
92107
const declared = Number.parseInt(declaredHeader, 10)
93108
if (!Number.isNaN(declared) && declared > MAX_ATTACHMENT_BYTES) {
94-
throw new SdkException(
109+
throw new AttachmentTooLargeError(
95110
`[coexist-attachment] ${label} exceeds size limit: ${declared} bytes (max ${MAX_ATTACHMENT_BYTES})`,
96111
)
97112
}
@@ -116,7 +131,7 @@ const readBodyWithCap = async (
116131
total += value.byteLength
117132
if (total > MAX_ATTACHMENT_BYTES) {
118133
await reader.cancel()
119-
throw new SdkException(
134+
throw new AttachmentTooLargeError(
120135
`[coexist-attachment] ${label} body exceeds size limit: >${MAX_ATTACHMENT_BYTES} bytes`,
121136
)
122137
}
@@ -293,6 +308,18 @@ export const coexistAttachmentDownload = async (
293308
row.mimeType,
294309
)
295310
} catch (err) {
311+
// An oversized attachment is a permanent condition: retrying re-downloads
312+
// the same too-large bytes and fails identically, burning every BullMQ
313+
// attempt. Terminate the job cleanly (no rethrow → no retry) and leave the
314+
// row pending; a future coexist sync may re-enqueue it, but it never enters
315+
// a retry storm. All other failures stay retryable.
316+
if (err instanceof AttachmentTooLargeError) {
317+
logger.warn(
318+
{ attachmentId, channel, err },
319+
"[coexist-attachment] attachment exceeds size cap — skipping (permanent)",
320+
)
321+
return
322+
}
296323
logger.error(
297324
{ err, attachmentId, channel },
298325
"[coexist-attachment] download failed",

0 commit comments

Comments
 (0)