동시 UPDATE 데드락 방어 — abort+latest, X-Idempotency-Key, 직렬화 - api 통신에러 #813
@ -1,7 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
|
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
|
||||||
import useSWRMutation from 'swr/mutation'
|
|
||||||
import { useAxios } from '../useAxios'
|
import { useAxios } from '../useAxios'
|
||||||
import { unescapeString } from '@/util/common-utils'
|
import { unescapeString } from '@/util/common-utils'
|
||||||
import { moduleSelectionDataState, selectedModuleState } from '@/store/selectedModuleOptions'
|
import { moduleSelectionDataState, selectedModuleState } from '@/store/selectedModuleOptions'
|
||||||
@ -9,7 +8,7 @@ import { compasDegAtom } from '@/store/orientationAtom'
|
|||||||
import { canvasState, currentCanvasPlanState } from '@/store/canvasAtom'
|
import { canvasState, currentCanvasPlanState } from '@/store/canvasAtom'
|
||||||
import { POLYGON_TYPE } from '@/common/common'
|
import { POLYGON_TYPE } from '@/common/common'
|
||||||
import { useCircuitTrestle } from '../useCirCuitTrestle'
|
import { useCircuitTrestle } from '../useCirCuitTrestle'
|
||||||
import { useContext } from 'react'
|
import { useContext, useRef, useState } from 'react'
|
||||||
import { addedRoofsState } from '@/store/settingAtom'
|
import { addedRoofsState } from '@/store/settingAtom'
|
||||||
import { roofsState } from '@/store/roofAtom'
|
import { roofsState } from '@/store/roofAtom'
|
||||||
import { GlobalDataContext } from '@/app/GlobalDataProvider'
|
import { GlobalDataContext } from '@/app/GlobalDataProvider'
|
||||||
@ -91,21 +90,49 @@ export function useCanvasPopupStatusController(param = 1) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 팝업 상태 저장
|
* 팝업 상태 저장
|
||||||
|
* - 직전 in-flight 요청은 AbortController 로 취소 (slider/drag 연속 호출 시 마지막 값만 서버 도달)
|
||||||
|
* - isMutating: 진행 중 여부 (버튼 disabled / 스피너 바인딩용)
|
||||||
|
* - X-Idempotency-Key 는 useAxios 인터셉터에서 자동 부착
|
||||||
*/
|
*/
|
||||||
const { trigger, isMutating } = useSWRMutation(
|
const [isMutating, setIsMutating] = useState(false)
|
||||||
`/api/v1/canvas-popup-status?objectNo=${currentCanvasPlan.objectNo}&planNo=${currentCanvasPlan.planNo}&popupType=${popupType}`,
|
const abortRef = useRef(null)
|
||||||
(url, { arg }) => {
|
|
||||||
const params = {
|
|
||||||
objectNo: currentCanvasPlan.objectNo,
|
|
||||||
planNo: parseInt(currentCanvasPlan.planNo),
|
|
||||||
popupType: popupType.toString(),
|
|
||||||
// popupStatus: popupType === 1 ? arg : JSON.stringify(arg).replace(/"/g, '\"'),
|
|
||||||
popupStatus: JSON.stringify(arg).replace(/"/g, '\"'),
|
|
||||||
//hajebichi: arg.roofConstructions?.[0]?.addRoof?.hajebichi || '',
|
|
||||||
}
|
|
||||||
postFetcher(`/api/v1/canvas-popup-status`, params)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return { getModuleSelection, handleModuleSelectionTotal, trigger }
|
// 언마운트 시 in-flight 는 의도적으로 abort 하지 않는다.
|
||||||
|
// 이유: 사용자가 trigger 직후 즉시 모달을 닫는 race 에서 저장이 누락되는 회귀가 있었다 (기존 SWR fire-and-forget 시절엔 백엔드까지 도달).
|
||||||
|
// in-flight Promise 는 다음 호출 시 새 controller 로 교체되거나, 자연 완료 후 GC 된다.
|
||||||
|
|
||||||
|
const trigger = async (arg) => {
|
||||||
|
if (!currentCanvasPlan.objectNo || !currentCanvasPlan.planNo) return
|
||||||
|
|
||||||
|
// 직전 in-flight 취소
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort()
|
||||||
|
}
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
objectNo: currentCanvasPlan.objectNo,
|
||||||
|
planNo: parseInt(currentCanvasPlan.planNo),
|
||||||
|
popupType: popupType.toString(),
|
||||||
|
popupStatus: JSON.stringify(arg).replace(/"/g, '\"'),
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsMutating(true)
|
||||||
|
try {
|
||||||
|
return await postFetcher(`/api/v1/canvas-popup-status`, params, { signal: controller.signal })
|
||||||
|
} catch (e) {
|
||||||
|
// AbortController 로 인한 취소는 정상 흐름
|
||||||
|
if (e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED' || e?.name === 'AbortError') return
|
||||||
|
// 기존 SWR fire-and-forget 호환: 서버 에러도 호출자에게 throw 하지 않음 (호출자 8곳 중 6곳이 await/try 미사용)
|
||||||
|
console.error('🚀 ~ useCanvasPopupStatusController.trigger failed:', e)
|
||||||
|
} finally {
|
||||||
|
if (abortRef.current === controller) {
|
||||||
|
abortRef.current = null
|
||||||
|
setIsMutating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getModuleSelection, handleModuleSelectionTotal, trigger, isMutating }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -582,10 +582,9 @@ export function useCanvasSetting(executeEffect = true) {
|
|||||||
|
|
||||||
/** 모듈 선택 데이터 초기화 */
|
/** 모듈 선택 데이터 초기화 */
|
||||||
resetModuleSelectionData()
|
resetModuleSelectionData()
|
||||||
//1번 초기화
|
//1번/2번 초기화 — 동일 plan 의 popupType 다른 row 라도 같은 트랜잭션 가족 안에서 동시 UPDATE 가 SQL Server 데드락 victim 을 유발한 사례가 있어 직렬화한다.
|
||||||
orientationTrigger({ compasDeg: 0, common: {}, module: {} })
|
await orientationTrigger({ compasDeg: 0, common: {}, module: {} })
|
||||||
//2번 초기화
|
await moduleSelectedDataTrigger({ roofConstructions: [] })
|
||||||
moduleSelectedDataTrigger({ roofConstructions: [] })
|
|
||||||
const isModuleExist = canvas.getObjects().some((obj) => obj.name === POLYGON_TYPE.MODULE)
|
const isModuleExist = canvas.getObjects().some((obj) => obj.name === POLYGON_TYPE.MODULE)
|
||||||
if (!isModuleExist) {
|
if (!isModuleExist) {
|
||||||
resetSelectedModules()
|
resetSelectedModules()
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
import axios, { Axios } from 'axios'
|
import axios, { Axios } from 'axios'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
const AxiosType = {
|
const AxiosType = {
|
||||||
INTERNAL: 'Internal',
|
INTERNAL: 'Internal',
|
||||||
EXTERNAL: 'External',
|
EXTERNAL: 'External',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WRITE 요청 식별용. 호출자가 option.headers['X-Idempotency-Key'] 를 직접 지정하면 그대로 사용.
|
||||||
|
const WRITE_METHODS = ['post', 'put', 'patch']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* axios 인스턴스 생성 후 반환
|
* axios 인스턴스 생성 후 반환
|
||||||
* @param {String} lang
|
* @param {String} lang
|
||||||
@ -26,10 +30,24 @@ export function useAxios(lang = '') {
|
|||||||
}
|
}
|
||||||
url.startsWith('https') ? '' : (headerValue['lang'] = lang)
|
url.startsWith('https') ? '' : (headerValue['lang'] = lang)
|
||||||
|
|
||||||
return axios.create({
|
const instance = axios.create({
|
||||||
baseURL: type === AxiosType.INTERNAL ? process.env.NEXT_PUBLIC_API_SERVER_PATH : '',
|
baseURL: type === AxiosType.INTERNAL ? process.env.NEXT_PUBLIC_API_SERVER_PATH : '',
|
||||||
headers: headerValue,
|
headers: headerValue,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// WRITE 메서드에 X-Idempotency-Key 자동 부착 (override 가능)
|
||||||
|
instance.interceptors.request.use((config) => {
|
||||||
|
const method = (config.method || '').toLowerCase()
|
||||||
|
if (WRITE_METHODS.includes(method)) {
|
||||||
|
config.headers = config.headers || {}
|
||||||
|
if (!config.headers['X-Idempotency-Key']) {
|
||||||
|
config.headers['X-Idempotency-Key'] = uuidv4()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
return instance
|
||||||
}
|
}
|
||||||
|
|
||||||
// request 추가 로직
|
// request 추가 로직
|
||||||
@ -205,8 +223,8 @@ export function useAxios(lang = '') {
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
const postFetcher = async (url, arg) => {
|
const postFetcher = async (url, arg, option = {}) => {
|
||||||
const res = await post({ url, data: arg })
|
const res = await post({ url, data: arg, option })
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -32,6 +32,10 @@ import { QcastContext } from '@/app/QcastProvider'
|
|||||||
import { unescapeString } from '@/util/common-utils'
|
import { unescapeString } from '@/util/common-utils'
|
||||||
import { useTrestle } from '@/hooks/module/useTrestle'
|
import { useTrestle } from '@/hooks/module/useTrestle'
|
||||||
|
|
||||||
|
// putCanvasStatus 의 in-flight 요청 — module-scope 로 두어 usePlan() 인스턴스 간 공유.
|
||||||
|
// 큰 fabric JSON 의 동시 PUT 이 SQL Server 데드락 victim 을 양산해 온 이력 때문에 직전 호출은 abort.
|
||||||
|
const canvasStatusInflight = { controller: null }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 플랜 처리 훅
|
* 플랜 처리 훅
|
||||||
* 플랜을 표시하는 탭 UI 전반적인 처리 로직 관리
|
* 플랜을 표시하는 탭 UI 전반적인 처리 로직 관리
|
||||||
@ -342,16 +346,30 @@ export function usePlan(params = {}) {
|
|||||||
canvasStatus: canvasToDbFormat(canvasStatus),
|
canvasStatus: canvasToDbFormat(canvasStatus),
|
||||||
}
|
}
|
||||||
|
|
||||||
await promisePut({ url: '/api/canvas-management/canvas-statuses', data: planData })
|
// 직전 in-flight 가 살아있다면 취소. abort 된 호출은 .catch 에서 silent return.
|
||||||
|
if (canvasStatusInflight.controller) {
|
||||||
|
canvasStatusInflight.controller.abort()
|
||||||
|
}
|
||||||
|
const controller = new AbortController()
|
||||||
|
canvasStatusInflight.controller = controller
|
||||||
|
|
||||||
|
await promisePut({ url: '/api/canvas-management/canvas-statuses', data: planData, option: { signal: controller.signal } })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setPlans((plans) => plans.map((plan) => (plan.id === currentCanvasPlan.id ? { ...plan, canvasStatus: canvasStatus } : plan)))
|
setPlans((plans) => plans.map((plan) => (plan.id === currentCanvasPlan.id ? { ...plan, canvasStatus: canvasStatus } : plan)))
|
||||||
if (saveAlert) swalFire({ text: getMessage('plan.message.save') })
|
if (saveAlert) swalFire({ text: getMessage('plan.message.save') })
|
||||||
rtn = true
|
rtn = true
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
// AbortController 로 인한 취소는 정상 흐름 — 새 호출이 진행 중이므로 UX 메시지/로딩 해제 안 함
|
||||||
|
if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED' || error?.name === 'AbortError') return
|
||||||
swalFire({ text: error.message, icon: 'error' })
|
swalFire({ text: error.message, icon: 'error' })
|
||||||
setIsGlobalLoading(false)
|
setIsGlobalLoading(false)
|
||||||
})
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (canvasStatusInflight.controller === controller) {
|
||||||
|
canvasStatusInflight.controller = null
|
||||||
|
}
|
||||||
|
})
|
||||||
return rtn
|
return rtn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user