feat: compute the fade circuit breaker from PostedOrders + the order service, in shadow - #496
Merged
Merged
Conversation
…yet wired) Adds a second implementation of the rows the fade cron scores, built from GPA-owned data instead of Redshift: PostedOrders (PR #495) for the "posted" half and the order service's GET /orders (orderStatus, fillBlock, fillTimestamp) for the outcome half. Nothing consumes it yet; the cron wiring lands in the next commit, in shadow mode. - PostedOrderRepository.recordOutcome: one idempotent UpdateItem that writes the terminal outcome (+ raw orderStatus, fill timing, faded, resolvedAt) and REMOVEs the sparse `pending` key, conditional on the row existing (no phantom rows for TTL-expired orders). PostedOrderOutcome gains FILLED / EXPIRED / CANCELLED / INSUFFICIENT_FUNDS / ERROR. - UniswapXServiceProvider.getOrdersByHashes: batch status read over GET /dutch-auction/orders?orderHashes=, capped at the service's 50-hash limit, 5s timeout, defensive parsing; axios is injectable so tests use a fake, not jest.mock. - OrderServiceFadesSource: each getFades() resolves pending orders past their deadline (<=1000/run, batches of 50, stops on time budget or 3 consecutive service failures), persists outcomes, then rebuilds V2_FADE_RATE_SQL's rows from the 24h window with the SQL's order of operations (completed only -> latest-100 per filler ADDRESS -> 24h / testnet / zero-filler / quoteId / permissioned-token / unresolved filters). Classification (status x type): filled -> fade iff fillBlock > decayStartBlock (V3) or fillTimestamp > decayStartTime (V2), a fill AT decay start is clean; expired -> fade; open past deadline -> poller lag, stays pending; cancelled / insufficient-funds / error -> recorded, and scored as fades only under the parity flag countNeverFilledTerminalAsFade (default true, matching the SQL's `fillTimestamp IS NULL` branch). That flag is the candidate behavior change to decide on after the shadow. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…Redshift breaker The fade cron now evaluates the GPA-owned fades source (previous commit) after the Redshift path has finished, and emits a comparison. It writes nothing to FillerCBTimestampsV2 and notifies nobody: the real decisions are unchanged. - fade-rate-v2.ts: the run body moves into runFadeRateCron(metrics, deps) with every dependency injectable; main() passes the same module-level clients as before, so the Redshift sequence (view -> endpoints -> rows -> stats -> decisions -> batch write) is unchanged. After the write, the shadow is invoked with the Redshift rows, the decisions just written, and a scorer = getFillersFadeStats + calculateNewTimestamps closed over the same stored state (metrics/row logging off). The call is try/caught and counted. - fade-rate-shadow.ts: runs the source under a 60s budget (source stops starting order service batches at the deadline; the runner races as a backstop), compares rows on (fillerAddress, deadline) restricted to orders posted since PostedOrders went live (2026-09-04 21:29Z), compares block decisions against production and against the floor-restricted Redshift rows, and emits CIRCUIT_BREAKER_SHADOW_* metrics + one report log. Never throws; failures count CIRCUIT_BREAKER_SHADOW_FAILURE. - CDK: PostedOrders is created before the CronStack and handed to it for a read/write grant on the fade cron; ORDER_SERVICE_URL is set on that Lambda only. Synth diff vs main: those two deltas plus asset-hash / deploy-marker noise. - Parity flag FADE_SHADOW_NEVER_FILLED_TERMINAL_AS_FADE (default true) wires the source's countNeverFilledTerminalAsFade policy. Tests (fakes only): the cron run end to end; identical writes/metrics with no shadow, a no-op shadow, and a throwing/rejecting shadow; shadow invoked after the write with no handle to the timestamp table; comparison functions; runner isolation (throwing source, throwing scorer, hanging source cut at the budget). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
SocksNFlops
approved these changes
Sep 4, 2026
claude Bot
pushed a commit
that referenced
this pull request
Sep 4, 2026
Second merge of main today. Brings in #495 (PostedOrders table) and #496 (fade circuit breaker computed from PostedOrders + the order service, in shadow). The only conflicting file was lib/entities/aws-metrics-logger.ts, where both sides appended new members to the Metric enum at the same point. Purely additive with no name collisions -- this branch's four CIRCUIT_BREAKER_V2_* metrics and main's CIRCUIT_BREAKER_SHADOW_* family are both kept. lib/cron/fade-rate-v2.ts auto-merged and was reviewed by hand rather than taken on trust, because #496 refactored main() into an injectable runFadeRateCron(metrics, deps): - The real Redshift path still calls getFillersFadeStats(...) and countEvaluationGaps(...) with the metrics logger, so all four metrics still emit once per run. - #496's shadow scoring callback calls getFillersFadeStats/calculateNewTimestamps without the trailing optional log/metrics arguments, so the shadow emits nothing and cannot double-count. That matches the shadow's stated contract of writing nothing and notifying nobody. - The address-mapping-miss drop site this branch instruments is untouched and still live: #496 adds a shadow alongside the Redshift path, it does not replace it. No metric here was superseded. Main's new metrics cover Redshift-vs-order-service agreement (CIRCUIT_BREAKER_SHADOW_*) and hard-quote post recording (POSTED_ORDER_*); neither answers the attribution-coverage question these four do, so nothing was dropped as a duplicate. No blocking behaviour change: the fade threshold, smoothing constants, window, block ladder, backoff cap and MAX_FILLER_ADDRESSES are all untouched, and lib/repositories/fades-repository.ts (open #477) is not modified. Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Cody Born <cody.born@uniswap.org>
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.
Summary
Computes the fade circuit breaker from GPA-owned data (the
PostedOrderstable from #495 + the order service'sGET /ordersstatus/fill timing) and runs it in shadow next to the existing Redshift computation. Every cron run now also emits a comparison. No real decision changes: the Redshift path is untouched, the shadow writes nothing toFillerCBTimestampsV2, and market makers are notified exactly as before (notifications are driven off that table at quote time).Two commits, reviewable separately:
feat: order-service-backed fades source—OrderServiceFadesSource(+PostedOrderRepository.recordOutcome,UniswapXServiceProvider.getOrdersByHashes), nothing wired.feat(cron): run the order-service fades source in shadow— cron wiring, comparison, metrics, CDK grant/env.How the new source works (each cron run)
pending-deadline-index, ≤1000/run, oldest first).GET /dutch-auction/orders?orderHashes=(50 per request = the service's cap, 5s timeout each; stops after 3 consecutive service failures or when the time budget is spent).UpdateItemthat alsoREMOVEs the sparsependingkey (conditional on the row existing, so no phantom rows).V2_FADE_RATE_SQL's rows from the 24h window, in the SQL's order of operations: completed only → latest-100 per filler address (slots consumed before any other filter, as the view partitions before the join) → 24h window / testnets (5, 8001, 420, 421613) / zero filler / missing quoteId /PERMISSIONED_TOKENSon either leg / unresolved.getFillersFadeStats+calculateNewTimestampsthe real path uses, against the same stored breaker state the real run just used.Classification (order-service
orderStatus× order type → fade?)orderStatusfilledfillTimestamp > decayStartTimefillBlock > decayStartBlockdecayStartTime < fillTimestamp/fillTimeBlocks > 0). A fill missing its timing field is unclassifiable: left pending, counted.expiredcancelledCANCELLED; scored by policy (below).insufficient-fundsINSUFFICIENT_FUNDS; scored by policy.errorERROR; scored by policy.open(past deadline)Parity flags / deliberate deviations
FADE_SHADOW_NEVER_FILLED_TERMINAL_AS_FADE(defaulttrue) →countNeverFilledTerminalAsFade. Today the SQL'sWHEN fillTimestamp IS NULL THEN 1counts cancelled / insufficient-funds / error orders as fades alongside expiries.truereproduces that.falsedrops those orders from the rows entirely (neither fade nor clean fill). This is the candidate behavior change to decide on after the shadow; the row stores the fact (outcome), not the verdict, so flipping the flag needs no re-resolution.filler-deadline-index); the real path drops unknown addresses anyway, so this is equivalent unless an address is registered to a different endpoint than the quote came from.postTimestampis GPA's post-confirmation time, the SQL's is the order service'screatedat(±1–2s). The comparison therefore matches rows on (fillerAddress, deadline), not postTimestamp.STREAK_FINALITY_LAG_SECS(2h) still applies to the new rows because the scoring code is shared. The new data is near-real-time, so that lag can shrink once flipped; not changed here.POSTED_ORDERS_LIVE_SINCE, when feat: record confirmed RFQ-won hard-quote posts in a GPA-owned PostedOrders table #495 went live): Redshift's 24h window contains older orders the new side can never have. Moot after the first day.Comparison signals to watch during the one-day shadow (
Service=CircuitBreaker)CIRCUIT_BREAKER_SHADOW_SUCCESS/_FAILURECIRCUIT_BREAKER_SHADOW_DURATIONCIRCUIT_BREAKER_SHADOW_PENDING_PAST_DEADLINECIRCUIT_BREAKER_SHADOW_RESOLVED/_STILL_OPEN/_NOT_FOUND/_UNCLASSIFIABLECIRCUIT_BREAKER_SHADOW_ROWS_OLDvs_ROWS_NEW,_ROWS_ONLY_OLD/_ROWS_ONLY_NEWCIRCUIT_BREAKER_SHADOW_FADES_OLDvs_FADES_NEWCIRCUIT_BREAKER_SHADOW_DECISION_AGREE_RESTRICTED/_DISAGREE_RESTRICTEDCIRCUIT_BREAKER_SHADOW_DECISION_AGREE/_DISAGREECIRCUIT_BREAKER_SHADOW_WOULD_BLOCKCIRCUIT_BREAKER_V2_ACTIVE_BLOCKSPer-filler old/new totals and fades, every decision disagreement (hash, both decisions), only-one-side row keys, and the resolution summary are in the
fade circuit breaker shadow reportlog line (FadeRatelogger,shadow: order-service-fades). Also setFADE_SHADOW_NEVER_FILLED_TERMINAL_AS_FADE=falseon the cron for a few runs near the end of the shadow to preview the candidate change in the same metrics.Isolation
updateTimestampsBatch, receives only the rows, the decisions just written,now, and a scorer closure; it has no handle toFillerCBTimestampsV2.runFadeRateShadownever throws (order-service timeout, DynamoDB error, classification/comparison bug, budget →CIRCUIT_BREAKER_SHADOW_FAILURE+ error log), and the cron additionally try/catches the call.Infra (synth diff vs main, semantic)
LambdaRoleDefaultPolicy: +2 statements =PostedOrdersTable.grantReadWriteData(fadeRateV2Cron)(CDK's standard read/write action set +GetRecords/GetShardIterator, on the table and/index/*). The shared role already hasAmazonDynamoDBFullAccess; the grant documents the dependency, as feat: record confirmed RFQ-won hard-quote posts in a GPA-owned PostedOrders table #495 did for the writer.FadeRateV2Cronenv:+ ORDER_SERVICE_URL(beta/prod secrets), no change to the reaper.PostedOrdersTableis now constructed beforeCronStack(same logical IDs; needed to pass the table in).Verification
yarn build✅ ·yarn test:unit✅ 41 suites / 432 tests (+80 new, all fakes, nojest.mock) ·yarn lint✅ 0 errors (85 pre-existing warnings, unchanged)RedshiftDataClient; resolution loop (batching, idempotence, still-open/not-found/unclassifiable left pending, failure and budget stops);recordOutcomeagainst DynamoDB Local;getOrdersByHasheswith a fake HTTP layer; cron run end to end proving identical writes and metrics with no shadow / a no-op shadow / a throwing shadow, and that the shadow runs after the write.npx cdk synthon main and on this branch from an identical convergedcdk.context.json, compared semantically (resource IDs, types, properties minus asset keys) — deltas listed above.Not in this PR / follow-ups
data-engdependency for the breaker.countNeverFilledTerminalAsFade.STREAK_FINALITY_LAG_SECSonce the source is near-real-time.🤖 Generated with Claude Code