getRevenue shadow comparison for live quotes

PR #6234 adds a Dealops 2 BullMQ validation harness that recomputes persisted quotes with both legacy and refactored getRevenue paths off the request path.

Author: @pk675 PR: #6234 Branch: claude/background-job-getrevenue-c6a1d1 Area: Dealops 2 pricing engine Files: 20 Diff: +809 / -5 State: open

What it adds

A new compareGetRevenueShadow background job runs the same quote summary twice: once forced to legacy, once forced to refactored.

It persists both outputs, timings, input hash, and byte-parity verdict.

What stays safe

The UI still uses the synchronous production path. The shadow run is fire-and-forget, deduped, low-concurrency, and guarded by DISABLE_GETREVENUE_SHADOW=true.

Why now

This validates PR #6093’s getRevenue refactor on live quote shapes before routing any org onto the refactored path.

Reviewer focus

Check the effective-input handling, queue load from GET, Prisma migration readiness, and whether full-output JSON storage is acceptable.

Summary entry points
Dispatch helper
BullMQ worker
PricingEngineSummary
Prisma table
Telemetry warning

1. Why this exists

Current validation gap

PR #6093 refactored getRevenue, but the org flag is a switch: an org uses either legacy or refactored.

That does not prove equivalence on real production quote inputs before rollout.

New validation mode

Every persisted quote summary can enqueue a background comparison.

The request still serves legacy output; the worker recomputes both paths later and records whether they are byte-identical.

4 entry-point trigger labels: CREATE, SAVE, GET, EXPLAIN
summary recomputes per job: legacy then refactored
1 new Prisma model: GetRevenueShadowComparison
3 worker concurrency cap for non-critical validation
Intent: prove byte parity on live traffic before using the refactored getRevenue path for any org.

2. What changes

2.1 Before / after shape

Before

One production summary path per request

  • Org flag decides legacy vs refactored.
  • Legacy remains the default production path.
  • No durable evidence comparing both outputs for the same live input.
After

Production path plus shadow validation

  • Request computes and returns as before.
  • Best-effort dispatch enqueues a deduped comparison job.
  • Worker forces both paths and upserts a verdict row.

2.2 File groups

Entrypoints

pricingQuote/update.ts, pricingQuote/get.ts, explainQuote.ts, and PricingQuoteService.ts now call dispatchGetRevenueShadowComparison after summary computation when a persisted quote exists.

trigger labels request path must not wait
Dispatch

dispatchGetRevenueShadowComparison.ts computes inputHash, builds a colon-free BullMQ job id, passes effectiveInput, and catches enqueue failures as warnings.

fire-and-forget kill switch
Worker

getRevenueShadowComparison.ts fetches the quote, rebuilds summary input, calls calculateSummary twice, diffs outputs, and writes the comparison row.

BullMQ forced path selection
Schema

get-revenue-shadow-comparison.prisma adds the new table with FKs to Organization and PricingQuote, plus relations on both existing models.

unique quote + inputHash migration/client regen required
Tests + docs

getRevenueShadowComparison.spec.ts covers diff paths, verdicts, input hashing, and job id stability. A knowledge concept documents the harness.

pure helper tests

2.3 New table contract

Field / index Purpose Reviewer note
legacyOutput, refactoredOutput Stores full PricingEngineSummaryOutput JSON from each forced path. Useful for offline debugging; can become large depending on quote shape.
isMatch, diffPaths Stores byte-parity verdict and human-scannable JSON paths for mismatches. diffPaths is null on match.
engineVersion, inputHash Records code SHA and stable input fingerprint. inputHash is computed from the effective input used by both runs.
legacyDurationMs, refactoredDurationMs Captures rough per-path timings. Not user-facing latency; this is worker runtime.
@@unique([pricingQuoteId, inputHash]) Repeated identical recomputes update one row. Prevents GET spam / no-op saves from piling up duplicate verdicts.

3. How it works

3.1 Dispatch contract

1

Do not block the request.

The dispatch helper starts an async IIFE and intentionally does not await it. Enqueue failures become errorNotificationService.logWarning, not request errors.

2

Dedupe by quote + input.

computeInputHash(input) uses stable JSON ordering, then getRevenueShadowJobId(pricingQuoteId, inputHash) hashes the composite key into a BullMQ-safe hex id.

3

Carry the effective input.

The job payload includes effectiveInput so amendment / expansion proration anchors like originalContractStartDate survive even when they are not persisted to quote.input.

3.2 Forced summary path

PricingEngineSummary.calculateSummary gains one option: forceUseLegacyGetRevenue?: boolean.

Option value Behavior Who uses it
undefined Normal production behavior: resolve isLegacyGetRevenueEnabledForOrg. Existing request paths.
true Force legacy getRevenue. First half of the shadow comparison.
false Force refactored getRevenue. Second half of the shadow comparison.

3.3 Verdict logic

Byte parity

compareSummaryOutputs compares stableStringify(legacy) to stableStringify(refactored).

Object key order does not create false mismatches.

Mismatch paths

diffJsonPaths returns paths like tcv.all.value or byProduct[2].arr.

The list is capped and appends when truncated.

Operational query shape
SELECT *
FROM "GetRevenueShadowComparison"
WHERE "isMatch" = false
ORDER BY "updatedAt" DESC;

4. What it doesn't change

5. Risks / rollback / open questions

Migration readiness: the PR adds Prisma schema only. Before deploy, reviewers should confirm the actual migration and generated Prisma client are produced and applied.

Risk: background load

GET can be frequent. The job is deduped by quote + input and worker concurrency is 3, but this still adds summary recomputation load.

Risk: storage growth

Each verdict row stores both full summary outputs as JSON. The unique key prevents identical duplicates, but changed inputs create new rows.

Rollback

Set DISABLE_GETREVENUE_SHADOW=true to stop new enqueues. Reverting the PR removes dispatch, worker registration, and schema references.

Open question

Should there be retention or cleanup for old matching rows once PR #6093 is fully validated?

Review watchpoint: mismatches are intentionally warnings, not blockers. That is right for non-critical validation, but the team needs a clear owner and query/alerting loop before using the data for rollout decisions.