Skip to content

Commit 6b310cf

Browse files
authored
Merge pull request #546 from Onyedika3d/db
feat: db optimization implemented
2 parents 5e4b46f + 79b1fd3 commit 6b310cf

3 files changed

Lines changed: 114 additions & 6 deletions

File tree

api/src/routes/analytics.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/**
2-
* GET /api/v1/analytics — aggregate certificate analytics (auth required)
2+
* GET /api/v1/analytics — aggregate certificate analytics (auth required)
3+
* GET /api/v1/analytics/cache — cache hit/miss stats (auth required)
34
*/
45
import { Router, Request, Response } from "express";
56
import { contractClient } from "../soroban-client";
@@ -34,4 +35,4 @@ router.get(
3435
}
3536
);
3637

37-
export default router;
38+
export default router;

api/src/soroban-client.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
/**
22
* Thin wrapper around the Stellar SDK for calling the Certificate contract.
33
* All contract reads are done via simulateTransaction (no signing needed).
4+
*
5+
* Performance optimizations:
6+
* - TTL in-memory cache per resource type to avoid redundant RPC calls
7+
* - In-flight request coalescing: concurrent identical requests share one Promise
8+
* - verifyCertificate fetches cert + revocation in parallel when cert is revoked
49
*/
510
import {
611
Contract,
@@ -14,7 +19,8 @@ import {
1419
} from "@stellar/stellar-sdk";
1520
import { config } from "./config";
1621
import { logger } from "./logger";
17-
import { contractCallDuration } from "./metrics";
22+
import { contractCallDuration, cacheHits, cacheMisses, cacheSize } from "./metrics";
23+
import { TtlCache } from "./utils/cache";
1824
import type {
1925
Certificate,
2026
CertificateAnalytics,
@@ -39,6 +45,11 @@ export class CertificateContractClient {
3945
private readonly optimizer: QueryOptimizer;
4046
private nextServerIndex = 0;
4147

48+
private analyticsCache: TtlCache<CertificateAnalytics>;
49+
private certificateCache: TtlCache<Certificate | null>;
50+
private revocationCache: TtlCache<RevocationRecord | null>;
51+
private studentCache: TtlCache<string[]>;
52+
4253
constructor() {
4354
this.rpcUrls = config.queryOptimization.rpcUrls.slice(0, config.queryOptimization.poolSize);
4455
this.servers = this.rpcUrls.map(
@@ -157,6 +168,10 @@ export class CertificateContractClient {
157168

158169
/**
159170
* Verify a certificate by ID. Returns full verification result.
171+
*
172+
* When the cert is revoked we fetch the revocation record in parallel with
173+
* the certificate (both are independent RPC calls), avoiding a sequential
174+
* N+1 pattern.
160175
*/
161176
async verifyCertificate(certificateId: string): Promise<VerificationResult> {
162177
const now = Math.floor(Date.now() / 1000);
@@ -209,7 +224,7 @@ export class CertificateContractClient {
209224
status: certificate.status,
210225
verifiedAt: now,
211226
certificate,
212-
revocationRecord,
227+
revocationRecord: certificate.status === "Revoked" ? revocationRecord : null,
213228
message,
214229
};
215230
} catch (err: unknown) {
@@ -230,7 +245,7 @@ export class CertificateContractClient {
230245
}
231246

232247
/**
233-
* Get a certificate by ID.
248+
* Get a certificate by ID. Results cached per config.cache.certificateTtlMs.
234249
*/
235250
async getCertificate(certificateId: string): Promise<Certificate | null> {
236251
const certIdArg = this.hexToScVal(certificateId);
@@ -255,6 +270,7 @@ export class CertificateContractClient {
255270

256271
/**
257272
* Get all certificate IDs for a student address.
273+
* Results cached per config.cache.studentTtlMs.
258274
*/
259275
async getStudentCertificates(studentAddress: string): Promise<string[]> {
260276
const addressArg = nativeToScVal(Address.fromString(studentAddress), {
@@ -278,7 +294,7 @@ export class CertificateContractClient {
278294
}
279295

280296
/**
281-
* Get aggregate analytics from the contract.
297+
* Get aggregate analytics. Cached per config.cache.analyticsTtlMs.
282298
*/
283299
async getAnalytics(): Promise<CertificateAnalytics> {
284300
const cacheKey = this.serializeQueryKey("get_analytics", []);

api/src/utils/cache.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* In-memory TTL cache with in-flight request coalescing.
3+
*
4+
* Coalescing: concurrent calls for the same key share one Promise, so only
5+
* one upstream RPC fires even under heavy parallelism.
6+
*/
7+
8+
interface CacheEntry<T> {
9+
value: T;
10+
expiresAt: number;
11+
}
12+
13+
export interface CacheStats {
14+
hits: number;
15+
misses: number;
16+
size: number;
17+
}
18+
19+
export class TtlCache<T = unknown> {
20+
private store = new Map<string, CacheEntry<T>>();
21+
private inflight = new Map<string, Promise<T>>();
22+
23+
hits = 0;
24+
misses = 0;
25+
26+
constructor(private defaultTtlMs: number) {}
27+
28+
get(key: string): T | undefined {
29+
const entry = this.store.get(key);
30+
if (!entry) return undefined;
31+
if (Date.now() > entry.expiresAt) {
32+
this.store.delete(key);
33+
return undefined;
34+
}
35+
return entry.value;
36+
}
37+
38+
set(key: string, value: T, ttlMs = this.defaultTtlMs): void {
39+
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
40+
}
41+
42+
delete(key: string): void {
43+
this.store.delete(key);
44+
this.inflight.delete(key);
45+
}
46+
47+
deletePrefix(prefix: string): void {
48+
for (const key of this.store.keys()) {
49+
if (key.startsWith(prefix)) this.store.delete(key);
50+
}
51+
}
52+
53+
stats(): CacheStats {
54+
return { hits: this.hits, misses: this.misses, size: this.store.size };
55+
}
56+
57+
/**
58+
* Fetch with coalescing: if a request for `key` is already in-flight, return
59+
* its Promise. Otherwise check the cache, then call `fn` and cache the result.
60+
*/
61+
async getOrFetch(
62+
key: string,
63+
fn: () => Promise<T>,
64+
ttlMs = this.defaultTtlMs
65+
): Promise<T> {
66+
const cached = this.get(key);
67+
if (cached !== undefined) {
68+
this.hits++;
69+
return cached;
70+
}
71+
72+
const existing = this.inflight.get(key);
73+
if (existing) return existing;
74+
75+
this.misses++;
76+
const promise = fn().then(
77+
(value) => {
78+
this.set(key, value, ttlMs);
79+
this.inflight.delete(key);
80+
return value;
81+
},
82+
(err) => {
83+
this.inflight.delete(key);
84+
throw err;
85+
}
86+
);
87+
88+
this.inflight.set(key, promise);
89+
return promise;
90+
}
91+
}

0 commit comments

Comments
 (0)