From 4826dac0d8f6f7516703d03852971f653ba39432 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 3 Sep 2026 03:02:42 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(=EB=8C=80=ED=99=94?= =?UTF-8?q?=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=8B=9C=EA=B0=81=EC=9D=84=20?= =?UTF-8?q?DB=20=EC=8B=9C=EA=B3=84=EB=A1=9C=20=EC=B1=84=EB=B2=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 릴리즈 PR #272 Codex P1 반영: 앱 호스트 시계로 채번한 created_at은 다중 인스턴스 배포에서 노드 간 시계 오차로 잠금 순서와 어긋날 수 있어, 시계가 늦은 노드의 답장이 last_read_at보다 과거 시각을 받아 안읽음 배지에서 영구 누락될 수 있다. - 구매자 전송·판매자 답장 모두 대화 잠금 획득 후 DB 시계(SELECT NOW(3))로 시각을 채번 — DB가 단일 시계 소스라 인스턴스 수와 무관하게 잠금 순서 = 시각 순서 = 커밋 순서 유지 - 읽음 마커는 이미 메시지 created_at 파생이라 추가 변경 없음 --- .../repositories/conversation.repository.ts | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index 93c58a4..8e13448 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -376,11 +376,11 @@ export class ConversationRepository { return this.prisma.$transaction(async (tx) => { const conversation = await this.lockOrCreateConversation(tx, args); const conversationId = conversation.id; - // 메시지 시각은 대화 잠금 획득 "이후"에 채번한다 — 잠금 밖에서 미리 - // 받은 시각은 커밋 순서와 어긋나, 늦게 커밋된 과거 시각 메시지가 - // 읽음 마커(last_read_at)를 건너뛰는 레이스를 만든다(리뷰 반영). - // 잠금 순서 = 시각 순서 = 커밋 순서가 대화 단위로 보장된다(NTP 전제). - const now = new Date(); + // 메시지 시각은 대화 잠금 획득 "이후" DB 시계(NOW(3))로 채번한다 — + // 앱 호스트 시계는 다중 인스턴스에서 노드 간 오차로 잠금 순서와 + // 어긋날 수 있다(릴리즈 리뷰 반영). DB가 단일 시계 소스이므로 + // 잠금 순서 = 시각 순서 = 커밋 순서가 대화 단위로 보장된다. + const now = await this.fetchDbNow(tx); // 인사말 필요 여부는 실제 메시지 수로 판정한다 — "생성 여부" 플래그는 // 동시 첫 전송·실패 재시도에서 인사말 계약(항상 첫 메시지)을 깨뜨린다. @@ -467,6 +467,17 @@ export class ConversationRepository { }); } + /** DB 시계(NOW(3)) 조회 — 인스턴스 간 단일 시계 소스. 잠금 획득 후 호출 전제. */ + private async fetchDbNow(tx: Prisma.TransactionClient): Promise { + const rows = await tx.$queryRaw<{ now: Date }[]>`SELECT NOW(3) AS now`; + const now = rows[0]?.now; + if (!(now instanceof Date)) { + // 드라이버가 Date 매핑에 실패하는 비정상 경로 — 전송을 막지 않는다 + return new Date(); + } + return now; + } + /** * 트랜잭션 안에서 (account_id, store_id) 대화를 잠그거나 생성한다. * - 기존 대화: id FOR UPDATE 잠금(초기화 직렬화). 유니크 제약은 soft-delete @@ -543,10 +554,11 @@ export class ConversationRepository { bodyHtml: string | null; }) { return this.prisma.$transaction(async (tx) => { - // 구매자 전송·읽음 처리와 같은 대화 잠금 아래에서 시각을 채번해 - // 커밋 순서와 시각 순서를 대화 단위로 일치시킨다(읽음 마커 정합). + // 구매자 전송·읽음 처리와 같은 대화 잠금 아래에서 DB 시계로 시각을 + // 채번해 커밋 순서와 시각 순서를 대화 단위로 일치시킨다(읽음 마커 + // 정합 — 앱 호스트 시계는 다중 인스턴스 오차에 취약, 릴리즈 리뷰 반영). await tx.$queryRaw`SELECT id FROM store_conversation WHERE id = ${args.conversationId} FOR UPDATE`; - const now = new Date(); + const now = await this.fetchDbNow(tx); const message = await tx.storeConversationMessage.create({ data: { From a4a8ad26605a9f22f505363b121f6b151b773a7e Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 3 Sep 2026 03:09:28 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(=EC=8B=9C=EA=B3=84?= =?UTF-8?q?=20=EC=BB=B7=EC=98=A4=EB=B2=84=20=EB=8C=80=EB=B9=84=20=EB=8C=80?= =?UTF-8?q?=ED=99=94=20=EB=8B=A8=EC=9C=84=20=EB=8B=A8=EC=A1=B0=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=20=EB=B3=B4=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #273 Codex P1 반영: 배포 전 앱 시계가 DB보다 앞섰던 노드가 남긴 미래 last_read_at/last_message_at이 있으면, 컷오버 직후 NOW(3) 채번이 그보다 과거/동률이 되어 새 답장이 안읽음 판정(created_at > last_read_at) 에서 영구 누락될 수 있다. - 채번을 GREATEST(NOW(3), last_message_at+1ms, last_read_at+1ms)로 보정 — 잠금 아래라 대화 단위 단조성이 race 없이 보장되고, ms 동률 배제까지 해소 - 미래 마커 재현 회귀 spec 추가 --- .../conversation.repository.spec.ts | 24 ++++++++++++++++ .../repositories/conversation.repository.ts | 28 +++++++++++++++---- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/features/conversation/repositories/conversation.repository.spec.ts b/src/features/conversation/repositories/conversation.repository.spec.ts index 46ccb63..d668c46 100644 --- a/src/features/conversation/repositories/conversation.repository.spec.ts +++ b/src/features/conversation/repositories/conversation.repository.spec.ts @@ -147,5 +147,29 @@ describe('ConversationRepository (real DB)', () => { ); expect(updatedConv.store_id).toBe(store.id); }); + + it('기존 마커가 미래 시각이어도 새 메시지는 그보다 뒤 시각을 받는다(시계 컷오버 보정)', async () => { + const { conversation } = await setupConversation(); + const seller = await createAccount(prisma, { account_type: 'SELLER' }); + // 앱 시계가 앞섰던 노드가 남긴 미래 마커 재현 + const futureMarker = new Date(Date.now() + 60 * 60 * 1000); + await prisma.storeConversation.update({ + where: { id: conversation.id }, + data: { last_read_at: futureMarker, last_message_at: futureMarker }, + }); + + const message = await repo.createSellerConversationMessage({ + conversationId: conversation.id, + sellerAccountId: seller.id, + bodyFormat: 'TEXT', + bodyText: '컷오버 이후 답장', + bodyHtml: null, + }); + + // created_at > last_read_at 이어야 안읽음 판정에서 누락되지 않는다 + expect(message.created_at.getTime()).toBeGreaterThan( + futureMarker.getTime(), + ); + }); }); }); diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index 8e13448..9f2a525 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -380,7 +380,7 @@ export class ConversationRepository { // 앱 호스트 시계는 다중 인스턴스에서 노드 간 오차로 잠금 순서와 // 어긋날 수 있다(릴리즈 리뷰 반영). DB가 단일 시계 소스이므로 // 잠금 순서 = 시각 순서 = 커밋 순서가 대화 단위로 보장된다. - const now = await this.fetchDbNow(tx); + const now = await this.fetchMonotonicNow(tx, conversationId); // 인사말 필요 여부는 실제 메시지 수로 판정한다 — "생성 여부" 플래그는 // 동시 첫 전송·실패 재시도에서 인사말 계약(항상 첫 메시지)을 깨뜨린다. @@ -467,12 +467,28 @@ export class ConversationRepository { }); } - /** DB 시계(NOW(3)) 조회 — 인스턴스 간 단일 시계 소스. 잠금 획득 후 호출 전제. */ - private async fetchDbNow(tx: Prisma.TransactionClient): Promise { - const rows = await tx.$queryRaw<{ now: Date }[]>`SELECT NOW(3) AS now`; + /** + * 대화 단위 단조 시각 채번 — 인스턴스 간 단일 시계(DB NOW(3))를 쓰되, + * 해당 대화의 기존 last_message_at/last_read_at보다 1ms 이상 뒤로 보정한다. + * 앱 시계로 찍힌 과거 row(시계가 DB보다 앞섰던 노드)가 남아 있어도 새 + * 메시지가 마커보다 과거/동률 시각을 받아 안읽음 판정(created_at > + * last_read_at)에서 누락되지 않는다(릴리즈 리뷰 반영). 잠금 획득 후 호출 전제. + */ + private async fetchMonotonicNow( + tx: Prisma.TransactionClient, + conversationId: bigint, + ): Promise { + const rows = await tx.$queryRaw<{ now: Date }[]>` + SELECT GREATEST( + NOW(3), + COALESCE(TIMESTAMPADD(MICROSECOND, 1000, last_message_at), NOW(3)), + COALESCE(TIMESTAMPADD(MICROSECOND, 1000, last_read_at), NOW(3)) + ) AS now + FROM store_conversation + WHERE id = ${conversationId}`; const now = rows[0]?.now; if (!(now instanceof Date)) { - // 드라이버가 Date 매핑에 실패하는 비정상 경로 — 전송을 막지 않는다 + // row 부재/드라이버 매핑 실패의 비정상 경로 — 전송을 막지 않는다 return new Date(); } return now; @@ -558,7 +574,7 @@ export class ConversationRepository { // 채번해 커밋 순서와 시각 순서를 대화 단위로 일치시킨다(읽음 마커 // 정합 — 앱 호스트 시계는 다중 인스턴스 오차에 취약, 릴리즈 리뷰 반영). await tx.$queryRaw`SELECT id FROM store_conversation WHERE id = ${args.conversationId} FOR UPDATE`; - const now = await this.fetchDbNow(tx); + const now = await this.fetchMonotonicNow(tx, args.conversationId); const message = await tx.storeConversationMessage.create({ data: { From a94857ace1acfba01da9b8a9c6880d24860d4f87 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 3 Sep 2026 03:15:03 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(=EB=8B=A8=EC=A1=B0?= =?UTF-8?q?=20=EC=8B=9C=EA=B0=81=20=EC=B1=84=EB=B2=88=EC=9D=84=20=EC=9E=A0?= =?UTF-8?q?=EA=B8=88=20=EC=A1=B0=ED=9A=8C=EB=A1=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #273 Codex P2 반영: GREATEST 채번 SELECT가 일반(비잠금) 조회라 트랜잭션 초입 스냅샷을 읽어, 잠금 대기 중 커밋된 마커 갱신을 놓칠 수 있었다. FOR UPDATE 잠금 조회로 전환 — 최신 커밋 값을 읽고, row는 이미 본 트랜잭션이 잠근 상태라 추가 대기 없음. --- .../conversation/repositories/conversation.repository.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/features/conversation/repositories/conversation.repository.ts b/src/features/conversation/repositories/conversation.repository.ts index 9f2a525..36028b2 100644 --- a/src/features/conversation/repositories/conversation.repository.ts +++ b/src/features/conversation/repositories/conversation.repository.ts @@ -478,6 +478,9 @@ export class ConversationRepository { tx: Prisma.TransactionClient, conversationId: bigint, ): Promise { + // FOR UPDATE 잠금 조회 — 일반 조회는 트랜잭션 초입 스냅샷을 읽어, + // 잠금 대기 중 커밋된 마커 갱신을 놓칠 수 있다(릴리즈 리뷰 반영). + // row는 이미 본 트랜잭션이 잠갔으므로 추가 대기는 없다. const rows = await tx.$queryRaw<{ now: Date }[]>` SELECT GREATEST( NOW(3), @@ -485,7 +488,8 @@ export class ConversationRepository { COALESCE(TIMESTAMPADD(MICROSECOND, 1000, last_read_at), NOW(3)) ) AS now FROM store_conversation - WHERE id = ${conversationId}`; + WHERE id = ${conversationId} + FOR UPDATE`; const now = rows[0]?.now; if (!(now instanceof Date)) { // row 부재/드라이버 매핑 실패의 비정상 경로 — 전송을 막지 않는다