Skip to content

feat: compute the fade circuit breaker from PostedOrders + the order service, in shadow - #496

Merged
alanhwu merged 2 commits into
mainfrom
feat/order-service-fades-shadow
Sep 4, 2026
Merged

feat: compute the fade circuit breaker from PostedOrders + the order service, in shadow#496
alanhwu merged 2 commits into
mainfrom
feat/order-service-fades-shadow

Conversation

@alanhwu

@alanhwu alanhwu commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Computes the fade circuit breaker from GPA-owned data (the PostedOrders table from #495 + the order service's GET /orders status/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 to FillerCBTimestampsV2, and market makers are notified exactly as before (notifications are driven off that table at quote time).

Two commits, reviewable separately:

  1. feat: order-service-backed fades sourceOrderServiceFadesSource (+ PostedOrderRepository.recordOutcome, UniswapXServiceProvider.getOrdersByHashes), nothing wired.
  2. 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)

  1. Load pending orders whose deadline has passed (pending-deadline-index, ≤1000/run, oldest first).
  2. Batch them into 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).
  3. Persist each terminal outcome with one idempotent UpdateItem that also REMOVEs the sparse pending key (conditional on the row existing, so no phantom rows).
  4. Rebuild 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_TOKENS on either leg / unresolved.
  5. Score with the same getFillersFadeStats + calculateNewTimestamps the real path uses, against the same stored breaker state the real run just used.

Classification (order-service orderStatus × order type → fade?)

orderStatus Dutch_V2 Dutch_V3 Notes
filled fade iff fillTimestamp > decayStartTime fade iff fillBlock > decayStartBlock A fill at decay start is clean (SQL: decayStartTime < fillTimestamp / fillTimeBlocks > 0). A fill missing its timing field is unclassifiable: left pending, counted.
expired fade fade
cancelled fade under parity flag fade under parity flag Recorded as CANCELLED; scored by policy (below).
insufficient-funds fade under parity flag fade under parity flag Recorded as INSUFFICIENT_FUNDS; scored by policy.
error fade under parity flag fade under parity flag Recorded as ERROR; scored by policy.
open (past deadline) Status-poller lag: stays pending, re-asked next run, excluded from rows (like the SQL's LEFT JOIN drops not-yet-archived rows).
anything else Unclassifiable: stays pending, counted.

Parity flags / deliberate deviations

  • FADE_SHADOW_NEVER_FILLED_TERMINAL_AS_FADE (default true)countNeverFilledTerminalAsFade. Today the SQL's WHEN fillTimestamp IS NULL THEN 1 counts cancelled / insufficient-funds / error orders as fades alongside expiries. true reproduces that. false drops 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.
  • Rows come from the configured filler endpoints (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.
  • Latest-100 per address is computed over the 24h window rather than over all completed orders. Only differs if an order created later has a deadline older than 24h while an earlier-created one is inside the window — practically never at ~1–5 minute order durations.
  • postTimestamp is GPA's post-confirmation time, the SQL's is the order service's createdat (±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.
  • The comparison is restricted to orders posted at/after 2026-09-04 21:29Z (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)

Metric What "good" looks like
CIRCUIT_BREAKER_SHADOW_SUCCESS / _FAILURE exactly one per run, FAILURE = 0
CIRCUIT_BREAKER_SHADOW_DURATION a few seconds; budget is 60s inside the 240s cron
CIRCUIT_BREAKER_SHADOW_PENDING_PAST_DEADLINE small and steady (≈ orders completed per 10 min); a value pinned at 1000 = backlog
CIRCUIT_BREAKER_SHADOW_RESOLVED / _STILL_OPEN / _NOT_FOUND / _UNCLASSIFIABLE RESOLVED ≈ PENDING; STILL_OPEN small and transient (poller lag); NOT_FOUND ≈ 0 (else GPA and the order service disagree about what was posted); UNCLASSIFIABLE = 0
CIRCUIT_BREAKER_SHADOW_ROWS_OLD vs _ROWS_NEW, _ROWS_ONLY_OLD / _ROWS_ONLY_NEW converge to equal / 0 after ~2h (Redshift loads hourly, so the new side leads)
CIRCUIT_BREAKER_SHADOW_FADES_OLD vs _FADES_NEW equal, once rows converge
CIRCUIT_BREAKER_SHADOW_DECISION_AGREE_RESTRICTED / _DISAGREE_RESTRICTED DISAGREE = 0 — the fair comparison (both sides floor-restricted)
CIRCUIT_BREAKER_SHADOW_DECISION_AGREE / _DISAGREE vs production as written; may disagree during the first 24h (pre-go-live rows), must converge after
CIRCUIT_BREAKER_SHADOW_WOULD_BLOCK tracks CIRCUIT_BREAKER_V2_ACTIVE_BLOCKS

Per-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 report log line (FadeRate logger, shadow: order-service-fades). Also set FADE_SHADOW_NEVER_FILLED_TERMINAL_AS_FADE=false on the cron for a few runs near the end of the shadow to preview the candidate change in the same metrics.

Isolation

  • The shadow runs strictly after updateTimestampsBatch, receives only the rows, the decisions just written, now, and a scorer closure; it has no handle to FillerCBTimestampsV2.
  • runFadeRateShadow never throws (order-service timeout, DynamoDB error, classification/comparison bug, budget → CIRCUIT_BREAKER_SHADOW_FAILURE + error log), and the cron additionally try/catches the call.
  • Time: 60s budget total; the source stops starting order-service batches at the deadline and the runner races the whole evaluation against it.

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 has AmazonDynamoDBFullAccess; 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.
  • FadeRateV2Cron env: + ORDER_SERVICE_URL (beta/prod secrets), no change to the reaper.
  • PostedOrdersTable is now constructed before CronStack (same logical IDs; needed to pass the table in).
  • Everything else in the diff is asset-hash / Lambda version churn and the dashboard deploy-marker window shifting.

Verification

  • yarn build ✅ · yarn test:unit ✅ 41 suites / 432 tests (+80 new, all fakes, no jest.mock) · yarn lint ✅ 0 errors (85 pre-existing warnings, unchanged)
  • New tests: table-driven classification (status × type × timing); row builder semantics incl. a shape test against the real Redshift formatter driven by a fake RedshiftDataClient; resolution loop (batching, idempotence, still-open/not-found/unclassifiable left pending, failure and budget stops); recordOutcome against DynamoDB Local; getOrdersByHashes with 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 synth on main and on this branch from an identical converged cdk.context.json, compared semantically (resource IDs, types, properties minus asset keys) — deltas listed above.

Not in this PR / follow-ups

  • Flipping the cron to the new source (after a clean shadow day), then dropping the Redshift view/query and the data-eng dependency for the breaker.
  • Decision on countNeverFilledTerminalAsFade.
  • Shrinking STREAK_FINALITY_LAG_SECS once the source is near-real-time.

🤖 Generated with Claude Code

alanhwu and others added 2 commits September 4, 2026 18:08
…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>
@alanhwu
alanhwu merged commit 0d731b0 into main Sep 4, 2026
6 checks passed
@alanhwu
alanhwu deleted the feat/order-service-fades-shadow branch September 4, 2026 22:30
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants