fix(orders/lock): heartbeat 가 빈 락을 다시 잡는 race condition 차단
Deploy momo-erp / deploy (push) Successful in 1m57s

증상: 발주 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) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-20 23:51:20 +09:00
parent 8e29a1f9da
commit bdccaa05c1
+40 -3
View File
@@ -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,
});