ES /docs

[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#

  1. 2026-07-22 00:53:01 KSTPUT /api/v1/admin/quotes/870 (update)
  2. 2026-07-22 00:53:05 KSTPUT /api/v1/admin/quotes/870/paid
  3. 2026-07-22 00:53:06 KSTPUT /api/v1/admin/quotes/870/apply200 OK
  4. 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)
  5. 2026-07-22 00:54:03 KST[Quote][GYVGXJU55F] Quote application failed: Billable already has a scheduled quote: 870 (첫 실패)
  6. 2026-07-22 00:54:19 KST[Quote][M9X89PV6G6] Quote application failed: Billable already has a scheduled quote: 870 (본 클러스터)
  7. 2026-07-22 00:55:27 KST[Quote][ZT5TKAT9CA] 실패
  8. 2026-07-22 00:58:15 KST[Quote][F43544W7U8] 실패
  9. 2026-07-22 01:06:13 KST[Quote][VA8VQHH64X] 실패
  10. 2026-07-22 01:06:49 KST[Quote][N7KE58TH44] 실패
  11. 2026-07-22 01:08:37 KST[Quote][U5XQ1208JW] 실패 (마지막 시도)

Error Log#

Datadog Logs

text
[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 failederror 레벨로 기록되어 error-sweeper 가 인시던트로 승격했다는 두 가지 운영 이슈가 있다.

Technical Analysis#

Code Path#

  • Entry: PUT /api/v1/admin/quotes/:id/applyApi::V1::Admin::QuotesController#apply
  • Repository: Admin::QuoteRepository#apply calls @model.apply!
  • Model: Quote#apply!schedule_apply!(billing_started_at) when billing_started_at > Time.zone.today
  • Failure point: app/models/quote.rb:schedule_apply! — guard on existing scheduled_quote_id
app/repositories/admin/quote_repository.rb:45-50ruby
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
app/models/quote.rb:82-107 (origin/master)ruby
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
app/models/quote.rb:schedule_apply! (origin/master)ruby
# 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 StandardErrorerror 레벨 로그를 남기고 재-raise → 컨트롤러 계층에서 응답으로 반환.

Log Evidence#

Datadog query (7건 전체):

text
service:cupixworks-api status:error "Billable already has a scheduled quote"

동일 Workspace 의 이전 성공 로그와 이후 실패 로그 시퀀스:

text
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 성공 흐름:

text
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::Billing with BILL10000) 도 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 문법 미사용):

text
service:cupixworks-api status:error "Billable already has a scheduled quote"

apply 성공 대비 실패 비율 추적:

text
service:cupixworks-api "Quote application" (status:error OR "Quote application finished")

Quote apply 컨트롤러 요청 흐름:

text
service:cupixworks-api "Admin::QuotesController#apply"

Risk Assessment#

  • Risk level: low — 코드 결함 아님, 데이터/사용자 영향 없음, 확정적인 business rule guard.
  • 예상 복잡도: trivial — 즉시 코드 변경 필요 없음. 단기 로그 레벨 조정만 optional 로 검토.