Skip to content

Commit 725787a

Browse files
authored
Merge pull request #273 from Jambox11/feature/quote-simulation-redis-cache
feat(backend): Redis cache for quote Soroban simulations
2 parents 6da4125 + cf15a7c commit 725787a

15 files changed

Lines changed: 508 additions & 12 deletions

backend/docs/observability.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ Restrict it at the ingress/firewall level — it must not be publicly reachable.
2626

2727
`error_type` values: `client_error`, `unavailable`, `unknown`.
2828

29+
### Quote simulation cache
30+
31+
| Metric | Type | Labels | Description |
32+
|---|---|---|---|
33+
| `quote_simulation_cache_requests_total` | Counter | `result` | `hit` = Redis served; `miss` = computed via RPC; `bypass` = `Cache-Control: no-cache` |
34+
35+
See [quote-simulation-cache.md](./quote-simulation-cache.md) for TTL and invalidation.
36+
2937
### Cardinality notes
3038

3139
- `route` is normalised: numeric path segments → `:id`, UUIDs → `:uuid`,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Quote simulation Redis cache
2+
3+
## Purpose
4+
5+
`POST /api/quote/generate-premium` can simulate `generate_premium` on Soroban for every request, which adds latency and consumes RPC rate limits. A short-TTL Redis cache stores **successful on-chain simulation results** keyed by a deterministic hash of normalized quote inputs.
6+
7+
## Key design
8+
9+
- **Cache key**: `quote:sim:v1:` + SHA-256 of `CONTRACT_ID`, `STELLAR_NETWORK_PASSPHRASE`, and canonical JSON of sorted fields: `age`, `coverage_tier`, `policy_type`, `region`, `risk_score`, `source_account` (empty string if omitted).
10+
- **Stored value**: JSON of `{ premiumStroops, premiumXlm, minResourceFee, source: "simulation", inputs }` — same shape as the API response body (excluding redundant `inputs` merge).
11+
- **Not cached**:
12+
- Responses with `source: "local_fallback"` (contract simulation error path inside `SorobanService`).
13+
- Any thrown error (e.g. `ACCOUNT_NOT_FOUND`, `WRONG_NETWORK`) — transient failures must not be pinned in Redis.
14+
- Requests **without** `source_account` (local-only path; no RPC reduction target).
15+
16+
## Configuration
17+
18+
| Variable | Default | Description |
19+
|----------|---------|-------------|
20+
| `QUOTE_SIMULATION_CACHE_ENABLED` | `true` | Set to `false` or `0` to disable read/write. |
21+
| `QUOTE_SIMULATION_CACHE_TTL_SECONDS` | `30` | Redis TTL per entry (1–600 seconds in validation). |
22+
23+
## TTL tradeoffs
24+
25+
- **Too long**: Clients may see **stale premiums** after on-chain multiplier table updates until TTL expires or cache is invalidated.
26+
- **Too short**: **More RPC traffic** and less benefit; tune against Soroban quotas and p95 quote latency.
27+
28+
Operational mitigation: the indexer clears all `quote:sim:v1:*` keys when it observes contract event `niffyins:tbl_upd` (multiplier table update). Short TTL remains a safety bound.
29+
30+
## Bypass
31+
32+
Send header `Cache-Control: no-cache` (exact directive `no-cache` among comma-separated values). The handler skips Redis get/set and records metric `quote_simulation_cache_requests_total{result="bypass"}`.
33+
34+
## Metrics
35+
36+
Prometheus counter **`quote_simulation_cache_requests_total`** with label **`result`**:
37+
38+
- `hit` — served from Redis
39+
- `miss` — cache empty/disabled, performed simulation
40+
- `bypass``no-cache` request
41+
42+
Use hit/(hit+miss) on dashboards for cache effectiveness (exclude bypass from denominator if desired).

backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { validationSchema } from './config/env.validation';
77
import { HealthModule } from './health/health.module';
88
import { PrismaModule } from './prisma/prisma.module';
99
import { CacheModule } from './cache/cache.module';
10+
import { RedisService } from './cache/redis.service';
11+
import { RedisThrottlerStorage } from './common/guards/throttler-redis.storage';
1012
import { RpcModule } from './rpc/rpc.module';
1113
import { IndexerModule } from './indexer/indexer.module';
1214
import { IpfsModule } from './ipfs/ipfs.module';

backend/src/common/guards/throttler-redis.storage.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Injectable } from '@nestjs/common';
22
import { ThrottlerStorage } from '@nestjs/throttler';
3-
import { ThrottlerStorageRecord } from '@nestjs/throttler';
4-
import { RedisService } from '../cache/redis.service';
3+
import type { ThrottlerStorageRecord } from '@nestjs/throttler/dist/throttler-storage-record.interface';
4+
import { RedisService } from '../../cache/redis.service';
55

66
/**
77
* Redis-backed storage for @nestjs/throttler.

backend/src/config/env.validation.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,16 @@ export const validationSchema = Joi.object({
9393
CACHE_TTL_SECONDS: Joi.number()
9494
.default(60)
9595
.description("Cache TTL in seconds"),
96+
QUOTE_SIMULATION_CACHE_ENABLED: Joi.string()
97+
.valid("true", "false", "1", "0")
98+
.default("true")
99+
.description("Redis cache for successful Soroban quote simulations"),
100+
QUOTE_SIMULATION_CACHE_TTL_SECONDS: Joi.number()
101+
.integer()
102+
.min(1)
103+
.max(600)
104+
.default(30)
105+
.description("TTL for quote simulation cache entries (seconds)"),
96106
// CAPTCHA (Turnstile or hCaptcha)
97107
CAPTCHA_PROVIDER: Joi.string()
98108
.valid("turnstile", "hcaptcha")

backend/src/indexer/indexer.module.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { IndexerWorker } from './indexer.worker';
55
import { ReindexWorkerService } from './reindex.worker';
66
import { PrismaModule } from '../prisma/prisma.module';
77
import { RpcModule } from '../rpc/rpc.module';
8+
import { QuoteModule } from '../quote/quote.module';
89

910
@Module({
1011
imports: [PrismaModule, RpcModule, ConfigModule],

backend/src/indexer/indexer.service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,10 @@ export class IndexerService {
286286

287287
await this.advanceCursorInTx(tx, network, event.ledger);
288288
});
289+
290+
if (parsed?.key === 'niffyins:tbl_upd') {
291+
await this.quoteSimulationCache.invalidateAll();
292+
}
289293
}
290294

291295
private async handlePolicyInitiated(tx: IndexerTx, data: EventPayload, event: SorobanEvent) {

backend/src/metrics/metrics.service.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export class MetricsService implements OnModuleInit {
3434
readonly rpcCallDuration: client.Histogram<string>;
3535
readonly rpcCallTotal: client.Counter<string>;
3636
readonly rpcErrorTotal: client.Counter<string>;
37+
/** result: hit | miss | bypass — quote simulation Redis cache */
38+
readonly quoteSimulationCacheTotal: client.Counter<string>;
3739

3840
constructor() {
3941
this.registry = new client.Registry();
@@ -100,6 +102,13 @@ export class MetricsService implements OnModuleInit {
100102
labelNames: ['rpc_method', 'error_type'],
101103
registers: [this.registry],
102104
});
105+
106+
this.quoteSimulationCacheTotal = new client.Counter({
107+
name: 'quote_simulation_cache_requests_total',
108+
help: 'Quote simulation cache lookups (hit/miss/bypass)',
109+
labelNames: ['result'],
110+
registers: [this.registry],
111+
});
103112
}
104113

105114
onModuleInit() {
@@ -154,6 +163,10 @@ export class MetricsService implements OnModuleInit {
154163
}
155164
}
156165

166+
recordQuoteSimulationCache(result: 'hit' | 'miss' | 'bypass') {
167+
this.quoteSimulationCacheTotal.inc({ result });
168+
}
169+
157170
async getMetrics(): Promise<string> {
158171
return this.registry.metrics();
159172
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {
2+
buildNormalizedQuoteCanonicalJson,
3+
buildQuoteSimulationCacheKeyHash,
4+
} from './quote-simulation-cache-key.util';
5+
import {
6+
CoverageTierEnum,
7+
PolicyTypeEnum,
8+
RegionTierEnum,
9+
} from './dto/generate-premium.dto';
10+
11+
describe('quote-simulation-cache-key.util', () => {
12+
it('sorts keys for stable canonical JSON', () => {
13+
const a = buildNormalizedQuoteCanonicalJson({
14+
policy_type: PolicyTypeEnum.Auto,
15+
region: RegionTierEnum.Medium,
16+
coverage_tier: CoverageTierEnum.Standard,
17+
age: 40,
18+
risk_score: 3,
19+
source_account:
20+
'GBCPNZ6S7RK5N4BX6HBXBCX7P5QNBOJZFGDWBZBXCLK5T6KHWOPTLR3I',
21+
});
22+
expect(a).toBe(
23+
'{"age":40,"coverage_tier":"Standard","policy_type":"Auto","region":"Medium","risk_score":3,"source_account":"GBCPNZ6S7RK5N4BX6HBXBCX7P5QNBOJZFGDWBZBXCLK5T6KHWOPTLR3I"}',
24+
);
25+
});
26+
27+
it('same logical inputs produce same hash', () => {
28+
const dto = {
29+
policy_type: PolicyTypeEnum.Property,
30+
region: RegionTierEnum.High,
31+
coverage_tier: CoverageTierEnum.Premium,
32+
age: 22,
33+
risk_score: 8,
34+
source_account: 'GBCPNZ6S7RK5N4BX6HBXBCX7P5QNBOJZFGDWBZBXCLK5T6KHWOPTLR3I',
35+
};
36+
const h1 = buildQuoteSimulationCacheKeyHash(dto, 'C1', 'net-a');
37+
const h2 = buildQuoteSimulationCacheKeyHash(dto, 'C1', 'net-a');
38+
expect(h1).toBe(h2);
39+
expect(h1).toMatch(/^[a-f0-9]{64}$/);
40+
});
41+
42+
it('different contract id changes hash', () => {
43+
const dto = {
44+
policy_type: PolicyTypeEnum.Health,
45+
region: RegionTierEnum.Low,
46+
coverage_tier: CoverageTierEnum.Basic,
47+
age: 50,
48+
risk_score: 2,
49+
source_account: '',
50+
};
51+
const h1 = buildQuoteSimulationCacheKeyHash(dto, 'CAAA', 'net');
52+
const h2 = buildQuoteSimulationCacheKeyHash(dto, 'CBBB', 'net');
53+
expect(h1).not.toBe(h2);
54+
});
55+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { createHash } from 'crypto';
2+
import type { GeneratePremiumDto } from './dto/generate-premium.dto';
3+
4+
/**
5+
* Canonical JSON for cache keys: fixed key order (sorted) and normalized scalars.
6+
* Changing this function invalidates all existing quote cache entries.
7+
*/
8+
export function buildNormalizedQuoteCanonicalJson(dto: GeneratePremiumDto): string {
9+
const normalized: Record<string, string | number> = {
10+
age: dto.age,
11+
coverage_tier: dto.coverage_tier,
12+
policy_type: dto.policy_type,
13+
region: dto.region,
14+
risk_score: dto.risk_score,
15+
source_account: dto.source_account ?? '',
16+
};
17+
const keys = Object.keys(normalized).sort() as (keyof typeof normalized)[];
18+
const sorted: Record<string, string | number> = {};
19+
for (const k of keys) {
20+
sorted[k] = normalized[k];
21+
}
22+
return JSON.stringify(sorted);
23+
}
24+
25+
/** SHA-256 hex digest scoped by contract + network so environments do not collide. */
26+
export function buildQuoteSimulationCacheKeyHash(
27+
dto: GeneratePremiumDto,
28+
contractId: string,
29+
networkPassphrase: string,
30+
): string {
31+
const canonical = buildNormalizedQuoteCanonicalJson(dto);
32+
return createHash('sha256')
33+
.update(contractId)
34+
.update('\0')
35+
.update(networkPassphrase)
36+
.update('\0')
37+
.update(canonical)
38+
.digest('hex');
39+
}

0 commit comments

Comments
 (0)