sign: cache signatures by content and reuse them across frames while the price is unchanged - #71
Open
kaisbaccour wants to merge 3 commits into
Open
sign: cache signatures by content and reuse them across frames while the price is unchanged#71kaisbaccour wants to merge 3 commits into
kaisbaccour wants to merge 3 commits into
Conversation
…ll per pair Every slot the oracle signs (price, publish_time, session window, token addresses, quote expiry) is derived from the pricing frame and the requested pair. Nothing comes from the wall clock or the caller. So two requests for the same pair inside one price frame sign byte-identical data, and each one paid for its own Cloud HSM AsymmetricSign. Measured 2026-09-01 15:00-15:05 UTC on production: 10,793 signatures built, 4,575 distinct inputs. 58% of KMS operations re-signed bytes signed seconds earlier. Frames arrive every ~5s per symbol while the main taker polls each pair every 2-3s; KMS was ~GBP 29/day of a GBP 35/day project bill. The signer now keeps a content-addressed cache: keccak256 of the packed context (the very hash that gets signed) -> signature. A hit returns the earlier signature; it is over the same bytes, so it is exactly as valid, with the same expiry. Concurrent requests for a not-yet-signed input coalesce on a per-entry lock so a burst pays for one sign, not N. A failed sign leaves the slot empty and the next caller retries. Bounds are memory hygiene, not validity: 2-minute TTL (~24 frames) and a 16k-entry cap that drops the whole map if the TTL sweep ever falls behind. Both are overridable via with_cache_bounds for tests and local dev. Counters oracle_signature_cache_hits_total / _misses_total and the gauge oracle_signature_cache_entries expose the effect on /metrics. Consumers change nothing. Expected ceiling after this lands: 39 symbols x 2 directions per 5s frame, ~900k signatures a day instead of 2.66M. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review fixes for the signature cache. The per-entry lock was held across the KMS call. On a failure every coalesced waiter took the lock in turn and repeated the full 10s attempt-plus-retry, so a KMS blackhole turned into N x 10s of serial hanging instead of one shared failure. And because the sign ran inside the first caller's request future, a client hanging up aborted a KMS call that was already billed and left the slot empty for the next waiter to start over. Now a cache miss spawns a detached task that signs and publishes the outcome on a watch channel. Every request for those bytes, the one that started the task included, waits on the receiver with a bounded timeout. The call completes whoever is still listening, a failure reaches all waiters at once, and the dead slot is evicted first so a retry finds a fresh one. Eviction is now an idle TTL measured from the last hit (a frozen price that takers keep polling is never re-signed), swept every 256 inserts rather than only at the cap, and the cap never drops an in-flight sign. The map lock is a std Mutex since it never spans an await. The context hash is computed incrementally instead of through a packed Vec. The three cache metrics are declared in MetricsHandle::declare so they appear on /metrics with HELP text from boot. Docs no longer claim the signed bytes never depend on the wall clock: the session slots come from the market-hours cache, and an empty calendar makes the cache useless (though still correct) until the hourly refresh loads it. Tests: the determinism test bypasses the cache; the concurrency test uses a delayed sign so 32 requests really overlap; new tests cover a caller aborted mid-sign, a failure broadcast to all waiters, and the idle TTL being refreshed by hits. Test-only hooks are cfg(test). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d and unexpired The content cache (previous commit) removes duplicate signatures inside one price frame. A new frame every 5 seconds still meant a new signature per pair even when the price had not moved, because publish_time and expiry advance with the frame and are part of the signed bytes. This layer works across frames. For v5 and v6 the handler remembers the last response per (schema, symbol, direction) together with a fingerprint of everything signed except the two timestamps: price bytes, session tag and bounds, input and output token, and for v6 the NAV ratio. When a new frame arrives with the same fingerprint and the previous quote still has at least signing.reuse_min_remaining_secs (default 10) before its expiry, the previous response is returned as is. The taker sees the older publish_time and the original expiry in the signed bytes and judges freshness itself. A moving price, a session change or a NAV change always signs fresh. v1 and v4 never reuse: they sign no expiry, and the strategy's staleness rule on chain is not visible here. Pricing stamps expiry 20 to 30 seconds after the frame today, so a stable symbol now costs one signature per ~20s instead of one per 5s frame. Set reuse_min_remaining_secs = 0 in the [signing] table to sign every frame. oracle_signature_reuse_total counts the KMS calls avoided. LiveClient::seed lets the integration tests push new frames between requests; five new tests cover reuse, the expiry margin, v4 exclusion, the disabled setting and the v6 NAV ratio. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.
Problem
KMS signing is most of the production oracle bill (~GBP 29 of GBP 35/day on 2026-09-02). Every
/context/*request made its own Cloud HSMAsymmetricSign. The signed price, publish time and expiry come from the pricing frame, the token addresses from the pair, and the session window from the market-hours cache, so two requests for one pair inside one frame sign byte-identical data. And a new frame every ~5s does not mean a new price: an unchanged price was re-signed on every frame because only the two timestamps had moved.Measured on production logs, 2026-09-01 15:00–15:05 UTC:
Change, two layers
1. Content cache within a frame (
sign.rs).Signer::sign_contextcaches by the keccak256 of the packed context, the exact hash that gets signed. A hit returns the earlier signature, over the same bytes and therefore exactly as valid, with the same quote expiry. A miss spawns a detached task that signs and broadcasts the outcome on awatchchannel; every request for those bytes waits on it with a bounded timeout. So a client hanging up cannot abort a KMS call it started, and a failure reaches all waiters at once (the dead slot is evicted first, so a retry finds a fresh one) instead of each waiter retrying in series. Eviction: idle TTL of 2 minutes from the last hit, swept every 256 inserts, 16k hard cap that never drops an in-flight sign.2. Reuse across frames for v5 and v6 (
reuse.rs). The handler keeps the last response per (schema, symbol, direction) with a fingerprint of everything signed except the two timestamps: price bytes, session tag and bounds, input and output token, and the v6 NAV ratio. A new frame with the same fingerprint, while the previous quote still has at leastsigning.reuse_min_remaining_secs(default 10) before its expiry, gets the previous response back unchanged. The taker reads the older publish time and the original expiry in the signed bytes and judges freshness itself. A moving price, a session change or a NAV change signs fresh. v1 and v4 never reuse: they sign no expiry.[signing] reuse_min_remaining_secs = 0in the runtime TOML disables the layer; the t0.devops config needs no change for the default.Metrics, declared from boot:
oracle_signature_cache_hits_total,oracle_signature_cache_misses_total,oracle_signature_cache_entries,oracle_signature_reuse_total. The KMS bill follows the misses.Consumers change nothing.
Known limit
If the market-hours calendar failed to load (the server starts anyway and retries hourly) the session window is
now, the signed bytes change every second, and layer 1 stops helping until the calendar loads. Still correct, just no saving; the misses counter shows it.Expected effect
Layer 1 caps signatures at one per pair per frame (~900k/day vs 2.66M today, however hard takers poll). Layer 2 brings a stable pair from one per 5s frame to one per ~20s (expiry is stamped 20 to 30s after the frame). Together the KMS line follows how often prices actually change.
Tests
Layer 1: identical bytes hit, different bytes never do; idle TTL expiry and refresh; cap overflow; 32 overlapping requests (delayed sign) produce one miss and 31 hits; the request that started a sign is aborted mid-flight and the sign is still reused; a failed sign fails all 8 waiters at once then the next request signs fresh; determinism test bypasses the cache; roundtrip fuzz unchanged.
Layer 2: reuse on unchanged price, no reuse under the expiry margin, v4 always fresh, disabled setting signs every frame, v6 requires the same NAV ratio.
cargo test103 passed, clippy and fmt clean.🤖 Generated with Claude Code