[Quote][M9X89PV6G6] Quote application failed: Billable already has a scheduled quote: 870
RCA: [Quote][M9X89PV6G6] Quote application failed: Billable already has a scheduled quote: 870
Overview#
What Happened#
2026-07-22 00:54 KST 시점에 cupixworks-api (production, ap-southeast-2) 에서 Back office 관리자가 Workspace 530 에 새 Quote M9X89PV6G6 를 적용(PUT /api/v1/admin/quotes/:id/apply)하려 했으나 BILL10000 / Billable already has a scheduled quote: 870 에러로 실패했다. 직전 00:53 KST 에 동일 Workspace 로 Quote R3B14IOIQL (id 870) 이 미래 시작일 2026-07-29 로 성공적으로 schedule 된 상태였고, 이후 15분 동안 관리자가 6건의 다른 Quote 를 같은 Workspace 로 반복 apply 하며 총 7건의 동일 에러가 누적되었다.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | Cupix::Errors::Billing |
| exception.message | Billable already has a scheduled quote: 870 |
| exception.code | BILL10000 |
| top_frame | app/models/quote.rb:schedule_apply! |
| deploy | TSLA-12975 feat: defer billing switch for future-dated quote applies (commit 489c15252 / 2f412c97d, merged 2026-06-15) |
| env | production, ap-southeast-2 |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| cupixworks-api (Back office billing 운영) | 7건 (Workspace 530, 15분 window) | Back office 관리자의 신규 라이선스 발급 재시도가 반복 차단됨. 최종 사용자 트래픽에는 영향 없음. |
Timeline#
- 2026-07-22 00:53:01 KST —
PUT /api/v1/admin/quotes/870(update) - 2026-07-22 00:53:05 KST —
PUT /api/v1/admin/quotes/870/paid - 2026-07-22 00:53:06 KST —
PUT /api/v1/admin/quotes/870/apply→200 OK - 2026-07-22 00:53:07 KST —
[Quote][R3B14IOIQL] Schedule quote application for 2026-07-29 on Workspace 530/Quote application finished(billable.scheduled_quote_id = 870) - 2026-07-22 00:54:03 KST —
[Quote][GYVGXJU55F] Quote application failed: Billable already has a scheduled quote: 870(첫 실패) - 2026-07-22 00:54:19 KST —
[Quote][M9X89PV6G6] Quote application failed: Billable already has a scheduled quote: 870(본 클러스터) - 2026-07-22 00:55:27 KST —
[Quote][ZT5TKAT9CA]실패 - 2026-07-22 00:58:15 KST —
[Quote][F43544W7U8]실패 - 2026-07-22 01:06:13 KST —
[Quote][VA8VQHH64X]실패 - 2026-07-22 01:06:49 KST —
[Quote][N7KE58TH44]실패 - 2026-07-22 01:08:37 KST —
[Quote][U5XQ1208JW]실패 (마지막 시도)
Error Log#
[Quote][M9X89PV6G6] Quote application failed: Billable already has a scheduled quote: 870
Impact#
- Service:
cupixworks-api - 발생 횟수: 1 (본 클러스터). 동일 fingerprint 계열(다른 Quote number, 같은 scheduled_quote_id=870) 로는 15분 내 7건.
- 최초 발생: 2026-07-22 00:54:19 KST
- 최근 발생: 2026-07-22 00:54:19 KST
- 영향 범위: Back office 관리자 한 명의 반복 시도.
Workspace 530의 기존 스케줄 (Quote 870, 시작일 2026-07-29) 은 정상 유지되며 최종 사용자 서비스 영향 없음.
Root Cause Summary#
Quote#apply! 는 billing_started_at 이 미래인 경우 schedule_apply! 경로로 진입해 billable.scheduled_quote_id 를 세팅한다 (TSLA-12975, 2026-06-15 배포). schedule_apply! 는 대상 billable 에 이미 다른 scheduled_quote_id 가 세팅되어 있으면 BILL10000 / Billable already has a scheduled quote: #{id} 로 명시적으로 raise 한다. 이는 의도된 방어 로직으로, 하나의 billable 에 여러 미래-스케줄 Quote 가 동시에 걸리는 것을 막는다. 이번 사건은 관리자가 Workspace 530 에 Quote 870 을 이미 2026-07-29 로 성공 schedule 한 뒤, 스케줄을 해제하지 않고 다른 Quote 들을 연속 apply 하여 발생한 정상 방어 로직 (business rule) 이며, 코드 결함이 아니다. 다만 (a) Back office 에는 scheduled_quote 를 해제/교체하는 API 가 없고, (b) Quote application failed 는 error 레벨로 기록되어 error-sweeper 가 인시던트로 승격했다는 두 가지 운영 이슈가 있다.
Technical Analysis#
Code Path#
- Entry:
PUT /api/v1/admin/quotes/:id/apply→Api::V1::Admin::QuotesController#apply - Repository:
Admin::QuoteRepository#applycalls@model.apply! - Model:
Quote#apply!→schedule_apply!(billing_started_at)whenbilling_started_at > Time.zone.today - Failure point:
app/models/quote.rb:schedule_apply!— guard on existingscheduled_quote_id
def apply
raise Cupix::Errors::Billing.new(code: 'BILL10000', reason: 'Permission denied operation: Only accounting deparment can apply the quote') unless Pundit.policy(self.current_user, @model).apply?
@model.apply!
@model
end
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
else
Cupix::Logger.info("[Quote][#{number}] Quote application finished")
self
end
# Future-dated contracts skip reset/activation: only billable.scheduled_quote_id is set and the Quote
# transitions to applied. Actual billing switchover is handled by cron (activate_scheduled_*) calling
# quote.activate_scheduled! once billing_started_at is reached.
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
기대 동작: 하나의 billable 에는 하나의 미래-스케줄 Quote 만 존재. 재적용 시 기존 스케줄을 먼저 해제(또는 동일 Quote id 로 재호출) 해야 한다.
실제 동작: 관리자가 Workspace 530 의 스케줄 (quote 870) 을 해제하지 않은 채 다른 Quote 를 apply → 위 가드가 BILL10000 을 raise → apply! 의 rescue StandardError 가 error 레벨 로그를 남기고 재-raise → 컨트롤러 계층에서 응답으로 반환.
Log Evidence#
Datadog query (7건 전체):
service:cupixworks-api status:error "Billable already has a scheduled quote"
동일 Workspace 의 이전 성공 로그와 이후 실패 로그 시퀀스:
2026-07-22 00:53:07 info [Quote][R3B14IOIQL] Quote application begins Workspace 530
2026-07-22 00:53:07 info [Quote][R3B14IOIQL] Schedule quote application for 2026-07-29 on Workspace 530
2026-07-22 00:53:07 info [Quote][R3B14IOIQL] Quote application finished
2026-07-22 00:54:03 info [Quote][GYVGXJU55F] Quote application begins Workspace 530
2026-07-22 00:54:03 error [Quote][GYVGXJU55F] Quote application failed: Billable already has a scheduled quote: 870
2026-07-22 00:54:19 info [Quote][M9X89PV6G6] Quote application begins Workspace 530
2026-07-22 00:54:19 error [Quote][M9X89PV6G6] Quote application failed: Billable already has a scheduled quote: 870
2026-07-22 00:55:27 error [Quote][ZT5TKAT9CA] Quote application failed: Billable already has a scheduled quote: 870
2026-07-22 00:58:15 error [Quote][F43544W7U8] Quote application failed: Billable already has a scheduled quote: 870
2026-07-22 01:06:13 error [Quote][VA8VQHH64X] Quote application failed: Billable already has a scheduled quote: 870
2026-07-22 01:06:49 error [Quote][N7KE58TH44] Quote application failed: Billable already has a scheduled quote: 870
2026-07-22 01:08:37 error [Quote][U5XQ1208JW] Quote application failed: Billable already has a scheduled quote: 870
Quote 870 (R3B14IOIQL) 의 apply 성공 흐름:
2026-07-22 00:53:01 info [200] PUT /api/v1/admin/quotes/870 (Admin::QuotesController#update)
2026-07-22 00:53:05 info [200] PUT /api/v1/admin/quotes/870/paid (Admin::QuotesController#paid)
2026-07-22 00:53:06 info [200] PUT /api/v1/admin/quotes/870/apply (Admin::QuotesController#apply)
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | 관리자가 이미 미래-스케줄된 Quote 870 을 해제하지 않고 다른 Quote 를 반복 apply → business rule guard 가 의도대로 raise | Quote 870 의 apply 성공 로그 (00:53:06 KST, Schedule quote application for 2026-07-29) 직후 15분 내 서로 다른 7개 Quote number 로 동일 에러 반복. app/models/quote.rb:schedule_apply! 의 가드가 정확히 이 조건을 raise. Back office 에는 scheduled_quote 해제 API 없음 (app/repositories/admin/quote_repository.rb 및 controller grep). |
없음 | Confirmed |
| H2 | 두 개의 Quote apply 요청이 동시성으로 경합하여 race condition | 없음 — 로그 타임스탬프가 15초~수 분 간격으로 순차적. apply! 는 ActiveRecord::Base.transaction 블록으로 감싸져 있음. scheduled_quote_id 는 첫 성공 시점에 커밋 완료됨 |
순차 실패 (동일 scheduled_quote_id=870 인용), race 라면 실패 원인 quote id 가 요청마다 달라져야 함 | Rejected |
| H3 | TSLA-12975 배포 이후 신규 회귀 (regression) — 이전에는 성공하던 흐름이 실패 | TSLA-12975 커밋에서 schedule_apply! 및 가드 신설 (2026-06-15 배포). 배포 전에는 미래-시작일 Quote 도 즉시 activate_apply! 로직으로 진행됨 |
그러나 신규 로직은 의도된 기능 추가이며 (docs/license-creation-flow.md 및 커밋 메시지 "defer billing switch for future-dated quote applies"), 가드 자체는 의도된 데이터 무결성 방어. "이전에도 되던 것을 못 하게 됐다" 라기보다 "미래-스케줄 개념 자체가 신설됨" |
Rejected (design change, not regression) |
| H4 | 외부 dependency (RDS, ElastiCache 등) 장애 | 없음 | 에러 메시지가 완전히 결정론적인 business rule violation. status-board 는 svc:cupixworks-api::unknown 로 dep 아님 |
Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
- 없음 (코드 변경 불필요) — 코드는 의도대로 동작한다. 관리자 재시도가 error 로그와 인시던트를 유발했을 뿐, 시스템/사용자 영향 없음.
Workspace 530의 스케줄 (Quote 870, 2026-07-29) 은 정상 상태로 남겨두어야 한다. - 운영 대응: Back office 담당자에게 "미래-스케줄된 Quote 가 이미 존재하는 billable 에는 새 Quote apply 가 차단됨" 을 안내. 필요시 DB 직접 수정으로
billable.scheduled_quote_id = nil,scheduled_billing_started_at = nil로 초기화한 뒤 원하는 Quote 재적용.
단기 개선 (1주 이내)#
- 로그 레벨 조정 검토:
app/models/quote.rb:apply!의rescue StandardError는 business rule violation (Cupix::Errors::BillingwithBILL10000) 도error레벨로 기록한다.Cupix::Errors::Parameter처럼Cupix::Errors::Billing(특히 BILL10000 business rule 계열) 을 별도 rescue 하여warn레벨로 낮추면, error-sweeper 인시던트 오탐을 줄일 수 있다. 단, "BILL10000" 은 여러 종류의 방어 로직에 재사용되고 있어 (예:BILL1201계정 상태) 무차별 downgrade 는 위험 — 스케줄 충돌만 특정하거나, exception class 를 세분화한 뒤 downgrade 하는 방향이 안전. - API/UI 개선 (프런트엔드 협의 필요, 자동 코드-fix 대상 아님): Back office 관리자에게 "이 workspace 에는 이미 2026-07-29 시작 예정인 Quote 870 이 스케줄되어 있습니다" 형태의 명확한 에러 메시지를 노출하고, 필요시 기존 스케줄을 취소/교체할 수 있는 워크플로우 (예:
DELETE /api/v1/admin/billables/:id/scheduled_quote) 를 추가하는 것을 검토. 이는 백엔드/프런트엔드 조율 필요.
장기 개선 (재발 방지)#
- Runbook 문서화:
docs/license-creation-flow.md에 "미래-스케줄 Quote 가 이미 존재하는 billable 에 다시 apply 하려면 먼저 스케줄 해제가 필요하다" 는 절차와 해제 방법을 명시. - status-board 오탐 필터:
Cupix::Errors::Billing계열의 결정론적 business rule 실패는 error-sweeper 인시던트 승격 대상에서 제외하도록 fingerprint/severity 룰 조정.
Monitoring#
기존 발생 여부 및 재발 감시용 Datadog 쿼리 (dashboard timeseries widget 용, monitor-only 문법 미사용):
service:cupixworks-api status:error "Billable already has a scheduled quote"
apply 성공 대비 실패 비율 추적:
service:cupixworks-api "Quote application" (status:error OR "Quote application finished")
Quote apply 컨트롤러 요청 흐름:
service:cupixworks-api "Admin::QuotesController#apply"
Risk Assessment#
- Risk level: low — 코드 결함 아님, 데이터/사용자 영향 없음, 확정적인 business rule guard.
- 예상 복잡도: trivial — 즉시 코드 변경 필요 없음. 단기 로그 레벨 조정만 optional 로 검토.