From bdccaa05c1b8d8a1de317039094a7f3304df9ed8 Mon Sep 17 00:00:00 2001 From: chpark Date: Wed, 20 May 2026 23:51:20 +0900 Subject: [PATCH] =?UTF-8?q?fix(orders/lock):=20heartbeat=20=EA=B0=80=20?= =?UTF-8?q?=EB=B9=88=20=EB=9D=BD=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20=EC=9E=A1?= =?UTF-8?q?=EB=8A=94=20race=20condition=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상: 발주 15 → 16 클릭 시 본인 화면에 15, 16 모두 "내가 수정 중" 표시. (다른 사용자 화면에는 16만 정상 표시 — DB 가 잠시 둘 다 me 로 오염됐다가 TTL 2분 후 알아서 풀림) 원인: heartbeat 와 acquire 가 같은 분기를 공유. "빈 락이거나 자기 락 → UPDATE editing_by=me, editing_at=NOW()" 로 처리. release(15) 직전 발사된 heartbeat(15) 가 release 응답보다 늦게 도착하면 빈 락을 자기 락으로 다시 잡아버림. 수정: heartbeat 액션을 분리. - "UPDATE editing_at = NOW() WHERE objid = $1 AND editing_by = $2 AND alive" - 자기 락이 살아있을 때만 갱신, 빈 락은 절대 잡지 않음 - rowCount=0 이면 409 + 현재 락 보유자 정보 반환 acquire 분기는 그대로 — 빈 락이거나 자기 락이면 신규 잡기. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/m/admin/orders/lock/route.ts | 43 ++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/app/api/m/admin/orders/lock/route.ts b/src/app/api/m/admin/orders/lock/route.ts index f772301..5b9d4d9 100644 --- a/src/app/api/m/admin/orders/lock/route.ts +++ b/src/app/api/m/admin/orders/lock/route.ts @@ -52,7 +52,44 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: true }); } - // acquire / heartbeat 공통: 현재 락 상태 조회 + if (action === "heartbeat") { + // heartbeat: 자기 락일 때만 editing_at 만 갱신 (절대 빈 락을 새로 잡지 않음). + // 사용자가 다른 발주로 이동해서 release 가 적용된 후 늦게 도착한 heartbeat 이 + // 빈 락을 다시 me 로 잡아버리는 race condition 차단. + const upd = await client.query( + `UPDATE momo_orders + SET editing_at = NOW() + WHERE objid::text = $1 + AND editing_by = $2 + AND editing_at > NOW() - INTERVAL '${TTL_MINUTES} minutes'`, + [orderObjid, me] + ); + if (upd.rowCount === 0) { + // 자기 락이 아니거나 이미 만료 — 다른 사람이 가져갔을 수도 + const cur = await client.query<{ editing_by: string | null; lock_name: string | null }>( + `SELECT O.editing_by, U.user_name AS lock_name + FROM momo_orders O + LEFT JOIN user_info U ON U.user_id = O.editing_by + WHERE O.objid::text = $1 + AND O.editing_at IS NOT NULL + AND O.editing_at > NOW() - INTERVAL '${TTL_MINUTES} minutes'`, + [orderObjid] + ); + const lockedBy = cur.rows[0]?.editing_by || ""; + const lockedByName = cur.rows[0]?.lock_name || ""; + return NextResponse.json({ + success: false, + lockedBy, + lockedByName, + message: lockedBy && lockedBy !== me + ? `${lockedByName || lockedBy} 님이 수정 중입니다.` + : "락이 해제되었습니다.", + }, { status: 409 }); + } + return NextResponse.json({ success: true, action: "heartbeat", lockedBy: me, lockedByName: myName }); + } + + // action === "acquire": 현재 락 상태 조회 const cur = await client.query<{ editing_by: string | null; alive: boolean; lock_name: string | null }>( `SELECT O.editing_by, (O.editing_at IS NOT NULL AND O.editing_at > NOW() - INTERVAL '${TTL_MINUTES} minutes') AS alive, @@ -78,14 +115,14 @@ export async function POST(req: NextRequest) { }, { status: 409 }); } - // 빈 락이거나 자기 락 — UPSERT + // 빈 락이거나 자기 락 — acquire 만 새로 잡음 await client.query( `UPDATE momo_orders SET editing_by = $2, editing_at = NOW() WHERE objid::text = $1`, [orderObjid, me] ); return NextResponse.json({ success: true, - action, + action: "acquire", lockedBy: me, lockedByName: myName, });