Skip to content

sign: cache signatures by content and reuse them across frames while the price is unchanged - #71

Open
kaisbaccour wants to merge 3 commits into
mainfrom
feat/signature-cache
Open

sign: cache signatures by content and reuse them across frames while the price is unchanged#71
kaisbaccour wants to merge 3 commits into
mainfrom
feat/signature-cache

Conversation

@kaisbaccour

@kaisbaccour kaisbaccour commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

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 HSM AsymmetricSign. 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:

signatures built 10,793
distinct inputs (symbol, direction, frame) 4,575
re-signing identical bytes 58%
price frames per symbol one every ~5s

Change, two layers

1. Content cache within a frame (sign.rs). Signer::sign_context caches 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 a watch channel; 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 least signing.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 = 0 in 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 test 103 passed, clippy and fmt clean.

🤖 Generated with Claude Code

kaisbaccour and others added 3 commits September 2, 2026 11:19
…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>
@kaisbaccour kaisbaccour changed the title sign: cache signatures by content, one KMS call per pair per price frame sign: cache signatures by content and reuse them across frames while the price is unchanged Sep 2, 2026
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.

1 participant