PR: Matched Fund Consistency Verification & Repair System - #219
Merged
BarryArinze merged 2 commits intoAug 28, 2026
Merged
Conversation
Implements periodic verification that Multiplier.matchedTotal equals
the true sum of MatchedFund.matchedAmount, with automatic repair.
New files:
- src/services/matchedFundVerification.service.ts
- queryFull: single aggregation JOIN, no in-process row loading
- querySampling: TABLESAMPLE SYSTEM(pct) for hourly early-warning
- queryTriggered: targeted verification of specific multiplier IDs
- repairOne: FOR UPDATE lock + re-sum inside transaction, retries
- injectInconsistency: test-only helper to simulate drift
- Systemic alert (>5% inconsistent) aborts repair to avoid silent
corruption patching; large-discrepancy alert per row
- src/workers/matchedFundVerification.worker.ts
- BullMQ worker: FULL_VERIFICATION (daily), SAMPLING_VERIFICATION
(hourly), TRIGGERED_VERIFICATION (on-demand)
- enqueueTriggeredVerification() for post-deploy/manual-fix use
- concurrency=1 prevents simultaneous full scans
- src/services/matchedFundVerification.service.test.ts
- 38 unit tests covering all acceptance criteria
Modified files:
- src/config/index.ts: matchedFundVerification config section (10 params)
- .env.example: all 10 env vars documented with defaults
- src/index.ts: worker registered alongside existing workers
All amounts use Prisma.Decimal; no IEEE-754 float at any boundary.
Repair uses the same Multiplier FOR UPDATE lock order as claimMatchCap
to prevent deadlocks with concurrent allocation transactions.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #216
Summary
Implements a production-grade consistency verification and repair system for the
matchedTotalcounter on theMultipliertable.matchedTotalis the runningtotal of all matched funds allocated against a multiplier's cap — it is the
authoritative value used by
claimMatchCapto enforce the cap atomically. Ifit drifts from the true sum of
MatchedFund.matchedAmountrows, the systemeither over-allocates matched funds (financial loss) or under-allocates them
(donor trust loss). This PR adds the safety net that detects and corrects such
drift automatically.
Problem
matchedTotalis maintained atomically by two code paths:MatchedFundAllocationService.claimMatchCap): incrementsmatchedTotalwith a CTE +FOR UPDATEin a single SQL statement.DonationService.refundDonation): decrementsmatchedTotalwiththe same pattern.
The ADR in
matchedFundAllocation.service.tsexplains why this is correct underconcurrent allocation. However, the invariant only holds if every write to
MatchedFundgoes through these two paths. Several real-world scenarios breakthat assumption:
matchedTotalMatchedFundrows directlyMatchedFundINSERT commits but beforeMultiplierUPDATE commitsmatchedTotalcolumn backfill in migration is incomplete or wrongWithout a verification layer, drift is invisible until it causes a financial
discrepancy in a report or an audit.
Solution Overview
Three new components work together:
Configuration is fully externalised — all thresholds, cron expressions, and
feature flags are driven by environment variables with safe defaults.
Files Changed
src/config/index.ts— newmatchedFundVerificationconfig blockAdds a
matchedFundVerificationsection with 10 tunable parameters:enabledMATCHED_FUND_VERIFICATION_ENABLEDtruefullVerificationCronMATCHED_FUND_FULL_VERIFICATION_CRON0 2 * * *samplingVerificationCronMATCHED_FUND_SAMPLING_CRON10 * * * *samplingPercentMATCHED_FUND_SAMPLING_PERCENT10precisionThresholdMATCHED_FUND_PRECISION_THRESHOLD0.00000001alertInconsistencyRateThresholdMATCHED_FUND_ALERT_INCONSISTENCY_RATE0.05alertLargeDiscrepancyThresholdMATCHED_FUND_ALERT_LARGE_DISCREPANCY1000repairMaxRetriesMATCHED_FUND_REPAIR_MAX_RETRIES3repairRetryDelayMsMATCHED_FUND_REPAIR_RETRY_DELAY_MS500repairBatchLimitMATCHED_FUND_REPAIR_BATCH_LIMIT100All values are plain strings/numbers — no runtime library construction in the
config module.
.env.example— new env varsAll 10 env vars documented with defaults and inline explanations, following the
existing style of the file.
src/services/matchedFundVerification.service.ts(new)The core service. All methods accept an injectable
dbparameter (defaults tothe module-level Prisma singleton) so they can be unit-tested without a real
database connection.
verify(opts, db?)— main entry pointOrchestrates a complete verification + repair pass. Accepts a
VerifyOptionsobject:
Flow:
queryFull,querySampling, orqueryTriggereddepending onmode.inconsistentCount / examined > alertInconsistencyRateThreshold, logs anERROR-levelsystemic_inconsistencyalert and skips the repair phase to avoid silently patching widespread
corruption.
alertLargeDiscrepancyThresholdand logs alarge_discrepancyalert if so.repairOnefor each inconsistent row (up torepairBatchLimit). Logsa warning if the batch limit is hit and rows are deferred to the next run.
VerificationResultwith full observability data:queryFull(precisionThreshold, db?)— full verificationRuns a single aggregation query against all
Multiplierrows:Key design points:
HAVINGclause pushes the threshold filter into Postgres — onlyinconsistent rows are returned over the wire; consistent rows (the vast
majority in a healthy system) produce zero network traffic.
refundedAt IS NULLexcludes refundedMatchedFundrows becauserefundDonationdecrementsmatchedTotaland setsrefundedAtatomically — excluding refunded rows keeps both sides of the invariant in
sync.
Promise.all)via a cheap
SELECT COUNT(*) FROM "Multiplier"(no JOIN) so the totalexamined count is available for the rate calculation without adding
overhead to the main query.
MatchedFundrows are loaded into Node.js memory regardless of datasetsize.
querySampling(samplingPercent, precisionThreshold, db?)— sampling passSame aggregation query but with
TABLESAMPLE SYSTEM($pct)on theMultiplierscan. PostgreSQL's
TABLESAMPLE SYSTEMoperates on 8 KB pages, so the actualsample fraction varies slightly from the nominal percentage — this is acceptable
for an early-warning signal. The percent is clamped to
[0.000001, 100]beforeuse.
queryTriggered(multiplierIds, precisionThreshold, db?)— targeted verificationAdds
WHERE m.id = ANY($ids::text[])to the aggregation. Short-circuitsimmediately (no DB call) when
multiplierIdsis empty. Theexaminedcountis set to
multiplierIds.lengthsince those are the rows we intended to check.repairOne(inconsistent, db?)— single-row repair with retriesConcurrency safety:
FOR UPDATEon theMultiplierrow serialises against concurrentclaimMatchCapandrefundDonationcalls on the same row.snapshot consistent with the locked row — no TOCTOU gap.
UPDATEmakes the repair idempotent: if aconcurrent transaction (allocation or a previous repair run) already
corrected the value between the scan and the repair, the re-check detects
discrepancy ≤ threshold and exits without writing. The locked value, not
the stale scan value, is used as
oldValuein the result.Multiplierrow lock is always the first lockacquired (same as in
claimMatchCap), so there is no lock-order cyclebetween repair and allocation.
On permanent failure (all retries exhausted), logs an
ERROR-levelrepair_failurealert and returnssuccess: false. Theverify()call sitecounts these as
repairFailureCountin the result.injectInconsistency(multiplierId, value, db?)— test helperDirectly sets
matchedTotalto an arbitrary value without going through theallocation service. Used in tests to simulate drift. Must never be called
in production code paths.
src/workers/matchedFundVerification.worker.ts(new)BullMQ queue + worker for the verification system.
Queue:
matched-fund-verificationDefault job options: 3 attempts, exponential backoff starting at 5 s,
removeOnComplete: true,removeOnFail: 100.Job types
FULL_VERIFICATIONfullVerificationCron)MultiplierrowsSAMPLING_VERIFICATIONsamplingVerificationCron)TRIGGERED_VERIFICATIONenqueueTriggeredVerification()scheduleVerificationJobs()— idempotent schedule registrationRegisters both recurring jobs using
repeat: { pattern: cron }+ a stablejobId. BullMQ deduplicates byjobId, so calling this on every startup issafe and correct. Skips registration entirely when the feature flag is off.
enqueueTriggeredVerification(multiplierIds, repair?)— ad-hoc triggerEnqueues a
TRIGGERED_VERIFICATIONjob with a uniquejobIdderived from thesorted multiplier IDs + timestamp. Logs a warning and returns immediately
(without throwing) when the feature flag is off.
Worker concurrency
concurrency: 1— only one verification job runs at a time. This preventstwo full-scan jobs from hammering the database simultaneously if a previous
run was delayed and two fire close together. It also prevents a full scan
and a sampling pass from running in parallel.
Observability
The worker logs structured JSON at
INFOlevel for job start/complete andERRORlevel for job failures, includingjobTypeand the error stack.src/index.ts— worker registrationAdds the verification worker startup alongside the existing workers:
Uses the same dynamic-import + feature-flag pattern as the moderation, receipt,
and email workers. The worker only connects to Redis/BullMQ when the module is
imported, so disabling the flag has zero overhead.
src/services/matchedFundVerification.service.test.ts(new)38 unit tests covering all acceptance criteria. No real database connection is
used — all DB interaction is through an injectable stub built with
makeDb().Test stub design
This design means a test that stages
[countResult, aggregateResult, lockResult, sumResult, updateResult]fully exercises the verify + repairpath without any Jest spy overhead on the Prisma model API.
Test suites
verify() — full moderepair: false;repairBatchLimitverify() — sampling modeverify() — triggered modequeryFull() — precision thresholdquerySampling()queryTriggered()repairOne()repair_failurealert; missing-row failure; Decimal precisioninjectInconsistency()VerificationResultstructureAcceptance criteria cross-reference
matchedTotal ≠ sum(matchedAmount)queryFull/querySamplingtests (mock structure forces single query)matchedTotalto actual sumverify() — full modesuiteverify() — sampling modesuiteverify() — triggered modesuiteAlerting Strategy
Three alert conditions emit
logger.errorwith a structured payload:The service does not integrate with any alerting SDK directly. In production,
these
ERROR-level structured log lines should be forwarded to the alertingbackend (PagerDuty, Datadog, etc.) by the log shipper — the same pattern used
everywhere else in this codebase.
Performance Characteristics
repairBatchLimit(default 100)The verification query does not hold any locks. Concurrent allocations and
refunds are unaffected during the read phase.
How to Use
Normal operation (automatic)
Nothing to do — the worker starts automatically when
MATCHED_FUND_VERIFICATION_ENABLED=true(the default). A daily full sweep runs at 02:00 UTC; an hourly sampling pass
runs at :10.
Trigger manually after a data fix
Simulate inconsistency in tests / staging
Tune via environment variables
Testing
All 38 tests pass. No existing tests were modified or broken.
Out of Scope (not in this PR)
sufficient and lower overhead.
enqueueTriggeredVerificationisthe programmatic API; exposing it over HTTP is a follow-up.