fix(orders/lock): heartbeat 가 빈 락을 다시 잡는 race condition 차단
Deploy momo-erp / deploy (push) Successful in 1m57s
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:
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user