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.
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.
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.
This validates PR #6093’s getRevenue refactor on live quote shapes before routing any org onto the refactored path.
Check the effective-input handling, queue load from GET, Prisma migration readiness, and whether full-output JSON storage is acceptable.
1. Why this exists
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.
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.
getRevenue path for any org.
2. What changes
2.1 Before / after shape
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.
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
pricingQuote/update.ts, pricingQuote/get.ts, explainQuote.ts, and PricingQuoteService.ts now call dispatchGetRevenueShadowComparison after summary computation when a persisted quote exists.
dispatchGetRevenueShadowComparison.ts computes inputHash, builds a colon-free BullMQ job id, passes effectiveInput, and catches enqueue failures as warnings.
getRevenueShadowComparison.ts fetches the quote, rebuilds summary input, calls calculateSummary twice, diffs outputs, and writes the comparison row.
get-revenue-shadow-comparison.prisma adds the new table with FKs to Organization and PricingQuote, plus relations on both existing models.
getRevenueShadowComparison.spec.ts covers diff paths, verdicts, input hashing, and job id stability. A knowledge concept documents the harness.
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
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.
Dedupe by quote + input.
computeInputHash(input) uses stable JSON ordering, then getRevenueShadowJobId(pricingQuoteId, inputHash) hashes the composite key into a BullMQ-safe hex id.
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
compareSummaryOutputs compares stableStringify(legacy) to stableStringify(refactored).
Object key order does not create false mismatches.
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
- It does not route any org onto the refactored
getRevenuepath. - It does not change the user-facing summary response shape.
- It does not add client UI or new REST endpoints; touched routes are existing Dealops 2 tRPC handlers.
- It does not put
compareGetRevenueShadowin paging jobs. - It does not require a sandbox registry entry; the new table has a direct
Organizationforeign key. - It does not make shadow comparison mandatory for request success; enqueue is best-effort and globally disableable.
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.
GET can be frequent. The job is deduped by quote + input and worker concurrency is 3, but this still adds summary recomputation load.
Each verdict row stores both full summary outputs as JSON. The unique key prevents identical duplicates, but changed inputs create new rows.
Set DISABLE_GETREVENUE_SHADOW=true to stop new enqueues. Reverting the PR removes dispatch, worker registration, and schema references.
Should there be retention or cleanup for old matching rows once PR #6093 is fully validated?