Quote#schedule_apply! — 기존 scheduled_quote 미정리 시 반복 적용 차단
RCA: [Quote][N7KE58TH44] Quote application failed: Billable already has a scheduled quote: 870
Overview#
What Happened#
2026-07-22 01:06 KST 무렵 production cupixworks-api 에서 admin 사용자가 Workspace 530 에 여러 Quote (872/874/876/878/880/884/886) 를 순차적으로 PUT /api/v1/admin/quotes/{id}/apply 로 적용하려 했고, 각각 400 BILL10000 Billable already has a scheduled quote: 870 로 실패했다. 최초 시도에서 Workspace 530 에 Quote 870 이 scheduled_quote 로 등록되어 있어 미래 시점 시작 quote 를 새로 스케줄링하려는 후속 요청이 schedule_apply! 의 중복 스케줄 방지 가드에 걸린 것이다. 스택은 정상적으로 동작 중이며 명시적 사용자/데이터 손실은 없고, admin 조작이 진행되지 못하는 상태이다.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | Cupix::Errors::Billing |
| exception.message | Billable already has a scheduled quote: 870 |
| top_frame | app/models/quote.rb:168 (raise) / app/models/quote.rb:165 (guard) |
| deploy | TSLA-12975 (489c15252 on origin/master) — future-dated quote apply 분리 |
| env | production, ap-southeast-2 |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| cupixworks-api (Admin Billing) | 7 (14:54–15:08 UTC 창) | Workspace 530 대상 admin quote apply 시도가 반복 실패, 정상 청구/스케줄 자체는 그대로 유지 |
Timeline#
- 2026-07-22 00:54:03 KST —
[Quote][GYVGXJU55F] Quote application failed: Billable already has a scheduled quote: 870— quote 872 apply 실패 - 2026-07-22 00:54:19 KST —
[Quote][M9X89PV6G6]— quote 874 apply 실패 - 2026-07-22 00:55:27 KST —
[Quote][ZT5TKAT9CA]— quote 876 apply 실패 - 2026-07-22 00:58:15 KST —
[Quote][F43544W7U8]— quote 878 apply 실패 - 2026-07-22 01:06:13 KST —
[Quote][VA8VQHH64X]— quote 880 apply 실패 - 2026-07-22 01:06:49 KST —
[Quote][N7KE58TH44] Quote application begins Workspace 530직후 동일 에러 (본 클러스터의 대표 로그, quote 884) - 2026-07-22 01:08:37 KST —
[Quote][U5XQ1208JW]— quote 886 apply 실패 (마지막 시도)
Error Log#
[Quote][N7KE58TH44] Quote application failed: Billable already has a scheduled quote: 870
Impact#
- Service:
cupixworks-api - 발생 횟수: 1 (fingerprint 기준). 동일 원인(
Billable already has a scheduled quote: 870) 로그 전체는 14건, 그 중 error 레벨 7건이 동일 창에서 발생. - 최초 발생: 2026-07-22 01:06:49 KST
- 최근 발생: 2026-07-22 01:06:49 KST
fingerprint 는 Quote 번호(N7KE58TH44) 를 포함해 클러스터 단위로는 1건이지만, 실제로는 동일 admin 조작 반복이 서로 다른 Quote 번호로 여러 클러스터(예: svc:cupixworks-api::unknown 인시던트에 묶인 7개 클러스터) 로 파편화되어 있다. 상태 보드에서 이 클러스터는 2026-07-21-svc-cupixworks-api--unknown-3 오픈 인시던트에 속함.
Root Cause Summary#
Workspace 530 은 이미 scheduled_quote_id = 870 을 가진 상태(과거에 미래 시작일 Quote 를 성공적으로 스케줄링해 놓은 상태)였다. 이후 admin 이 시작일이 미래인 다른 Quote 들(872, 874, 876, 878, 880, 884, 886) 을 동일 Workspace 에 적용하려 하자 Quote#apply! → schedule_apply! 경로가 실행되었고, app/models/quote.rb:165 의 "이 billable 에 이미 다른 예약 Quote 가 걸려있으면 거부" 가드가 의도대로 요청을 차단했다. 즉 코드 자체 버그는 아니며, TSLA-12975 로 도입된 정책(billable 당 예약 Quote 1개) 에 따라 기존 예약(870) 을 먼저 취소/치환하지 않은 상태에서 새 예약을 시도한 admin 워크플로우가 원인이다. 사용자가 인지할 수 있는 메시지도 quote 번호가 아닌 raw scheduled_quote_id 를 노출하고 있어 상황 파악을 방해했다.
Technical Analysis#
Code Path#
- Entry point:
app/controllers/api/v1/admin/quotes_controller.rb:37—PUT /api/v1/admin/quotes/:id/apply가repository_instance.apply를 호출. - Model action:
app/models/quote.rb:80Quote#apply!— 시작일이 오늘 이후이면schedule_apply!로 분기. - Failure point:
app/models/quote.rb:165-169— billable 에 다른 scheduled quote 가 이미 있으면Cupix::Errors::Billing(BILL10000)발생. - Log emission:
app/models/quote.rb:100rescue StandardError가[Quote][#{number}] Quote application failed: #{e.message}를 error 레벨로 기록.
def apply!
Cupix::Logger.info("[Quote][#{number}] Quote application begins #{billable.class.name} #{billable.id}")
check_appliable
_billing_started_at, _billing_expires_at = extract_billing_dates
if _billing_expires_at > MAX_BILLING_EXPIRES_AT
raise Cupix::Errors::Parameter.new(code: 'ARG14000', reason: 'Billing expiration date exceeds the maximum allowed value')
end
ActiveRecord::Base.transaction do
if _billing_started_at > Time.zone.today
schedule_apply!(_billing_started_at)
else
activate_apply!(_billing_started_at, _billing_expires_at)
end
end
rescue Cupix::Errors::Parameter => e
Cupix::Logger.error("[Quote][#{number}] Invalid Parameter : #{e.message}", class: self.class.name, function: __method__, module: 'Parameter::Quote')
raise e
rescue StandardError => e
Cupix::Logger.error("[Quote][#{number}] Quote application failed: #{e.message}")
raise e
def schedule_apply!(billing_started_at)
if billable.scheduled_quote_id.present? && billable.scheduled_quote_id != id
raise Cupix::Errors::Billing.new(
code: 'BILL10000',
reason: "Billable already has a scheduled quote: #{billable.scheduled_quote_id}"
)
end
Cupix::Logger.info("[Quote][#{number}] Schedule quote application for #{billing_started_at} on #{billable.class.name} #{billable.id}")
billable.update!(
scheduled_quote: self,
scheduled_billing_started_at: billing_started_at
)
applied_state! if state_paid?
end
기대 동작: 미래 시작일 Quote 를 처음 적용하면 billable.scheduled_quote_id 가 자기 자신 id 로 채워지고 이후 cron(Cupix::Cron::ScheduledQuote.activate_for) 이 시작일 도달 시 activate_scheduled! 로 전환. 하나의 billable 에는 동시에 하나의 예약만 허용.
실제 동작: Workspace 530 은 이미 Quote 870 을 예약 상태로 가지고 있어 다른 새 Quote (id ≠ 870) 를 예약하려 하면 반드시 BILL10000 로 거부. HTTP 응답은 Cupix::Errors::Billing 이 [400] PUT /api/v1/admin/quotes/{id}/apply 로 매핑되어 admin 이 반복적으로 400 을 받는 상황.
Log Evidence#
Datadog query:
service:cupixworks-api "N7KE58TH44"
대표 로그 (해당 클러스터):
2026-07-22 01:06:49 info [Quote][N7KE58TH44] Quote application begins Workspace 530
2026-07-22 01:06:49 error [Quote][N7KE58TH44] Quote application failed: Billable already has a scheduled quote: 870
Broader query 로 확인한 동일 원인 반복:
service:cupixworks-api "Billable already has a scheduled quote"
{
"timestamp": "2026-07-22 01:06:51",
"status": "info",
"message": "[400] PUT /api/v1/admin/quotes/884/apply (Api::V1::Admin::QuotesController#apply)",
"error": {
"reason": "Billable already has a scheduled quote: 870",
"code": "BILL10000",
"message": "Billable already has a scheduled quote: 870",
"class": "Cupix::Errors::Billing"
}
}
동일 창에서 서로 다른 Quote id(872, 874, 876, 878, 880, 884, 886) 로 7번 실패했고, 실패 이유의 scheduled_quote_id 는 모두 870 으로 고정 — Workspace 530 에 걸려있는 예약이 계속 그대로라는 신호. activate_scheduled! 성공 로그(Activated scheduled quote ... on Workspace 530) 는 이 시간대에 검색되지 않았음. status-board 상 오픈 인시던트 2026-07-21-svc-cupixworks-api--unknown-3 에 7개 클러스터가 묶여있어 admin 반복 시도로 인한 신호임을 뒷받침한다.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | Admin 이 이미 예약된 Quote 870 을 해제/치환하지 않은 채 새 예약 Quote 들을 적용하려 함 → schedule_apply! 의 중복 예약 가드에 걸림 |
7건 모두 scheduled_quote_id=870 로 동일; 서로 다른 Quote id 로 실패; Api::V1::Admin::QuotesController#apply 로부터 400 응답; quote.rb:165 가드가 정확히 이 문구 생성 |
— | Confirmed |
| H2 | 미래 시작일이 아닌 quote 인데 잘못 분기되어 schedule_apply! 로 들어감 (분기 버그) |
apply! 는 _billing_started_at > Time.zone.today 로만 분기하므로 실수 여지 있음 |
실패 메시지 자체가 예약 가드 문구이며, 애초에 시작일이 과거였다면 activate_apply! 로 들어가 다른 오류가 났을 것. 로그에 다른 실패 메시지 없음 |
Rejected |
| H3 | activate_scheduled! cron 이 실패해서 Quote 870 이 예약 상태에 갇힘 |
scheduled_quote_id=870 이 오래 유지되고 있는 정황과 부합 |
Datadog 에 Failed to activate scheduled quote on Workspace 530 로그 없음(별도 검색 필요); cron 실패는 원인이 아닌 부수적 사실 — admin 이 새 예약을 시도한 것이 실제 실패 원인 |
Inconclusive |
| H4 | 동시성/race — 두 요청이 동시에 예약을 걸어 scheduled_quote_id 가 다른 값으로 덮여씀 |
여러 요청이 짧은 창에서 발생 | 모든 실패의 scheduled_quote_id 가 870 으로 완전히 고정. race 라면 값이 흔들려야 함 |
Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
- 코드 변경 불필요. 현재 관측된 로그는
schedule_apply!의 의도된 방어 가드가 정상 작동한 결과이며 코드 버그가 아니다. - 운영 대응으로 다음 중 하나를 admin/영업 담당과 협의 후 수행:
- 기존 Quote 870 의 스케줄을 취소 (Workspace 530 에서
scheduled_quote_id,scheduled_billing_started_at을 nil 로 정리) 하고 원하는 새 Quote 를 다시 apply. - Quote 870 이 곧 시작일 도달 예정이면 cron 이
activate_scheduled!로 처리하도록 두고, 새 Quote 는 활성 시점 이후 재적용.
- 기존 Quote 870 의 스케줄을 취소 (Workspace 530 에서
- 에러 메시지 UX 개선 (
app/models/quote.rb:168):scheduled_quote_id대신 해당 Quote 의number를 노출해 admin 이 어떤 예약이 걸려 있는지 즉시 파악하게 한다. 예:"Billable already has a scheduled quote: #{billable.scheduled_quote.number}". 조회 실패 대비로 id 를 병기하는 편이 안전.
단기 개선 (1주 이내)#
- Admin UI/워크플로우: Workspace/Team billing 화면에서
scheduled_quote_id가 존재할 때 "예약된 Quote (#…) 를 먼저 취소해야 합니다" 안내를 표시하고, 새 Quote 를 apply 하기 전에 명시적으로 취소 흐름을 밟도록 유도. (프런트엔드 담당자와 협의 필요 — 자동 code-fix 범위 밖.) Api::V1::Admin::QuotesController#apply응답에existing_scheduled_quote: { id:, number:, scheduled_billing_started_at: }를 담아 UI 가 바로 표시할 수 있도록 확장.Cupix::Cron::ScheduledQuote.activate_for의 실패 로그(Failed to activate scheduled quote ...) 를 Workspace 530 에 대해 소급 확인해, Quote 870 이 왜 이렇게 오래 예약 상태로 남아있는지 검토.
장기 개선 (재발 방지)#
- Quote → billable 예약 정책을 다중 예약 큐 형태로 확장할지 (예: 순차 스케줄) 정책 결정이 필요. 현재는 "billable 당 예약 1개" 가 하드 제약이라 계약 변경/연장 시 항상 취소-재예약이 강제된다.
schedule_apply!이 예약을 덮어쓰지 않는 방어는 유지하되, 명시적force: true/cancel_existing: true옵션을 admin API 에만 제공해 승격된 관리자 조작만 원자적 치환을 수행할 수 있게 하는 안 검토.
Monitoring#
- Admin quote apply 실패율:
sum:trace.rack.request.errors{service:cupixworks-api,resource_name:api_v1_admin_quotes_controller_apply}.as_count()
- 특정 원인(
Billable already has a scheduled quote) 로그 카운트 (원인 판별용):
service:cupixworks-api status:error "Billable already has a scheduled quote"
대시보드 timeseries 에 사용할 때는 Log Analytics count aggregation 위젯으로 embed. (writing-datadog-monitoring-queries 규칙에 따라 | stats / count by(...) 같은 monitor-only 문법은 넣지 않음.)
- Scheduled quote cron 실패 로그:
service:cupixworks-api status:error "Failed to activate scheduled quote"
Risk Assessment#
- Risk level: low (사용자 데이터/청구에 영향 없음, admin 워크플로우 마찰 수준. 재시도 400 반복만 발생).
- 예상 복잡도: trivial — 즉시 조치는 데이터 정리 + 메시지 문구 수정. 단기 개선은 프런트엔드 조율 필요.