Skip to content

Commit 31d079e

Browse files
authored
Merge pull request #366 from popsman01/feature/issue-94-performance-optimization
feat(perf): infrastructure performance optimization
2 parents 0575a5f + ac985d8 commit 31d079e

10 files changed

Lines changed: 249 additions & 19 deletions

File tree

PERFORMANCE.md

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,54 @@ The script checks:
1313
1. **Slow queries**`pg_stat_statements` top 10 by mean execution time
1414
2. **Index usage** — tables with low index-scan ratio (candidates for new indexes)
1515
3. **API latency** — live data from `/health/metrics`
16-
4. **Container resources** — CPU/memory via `docker stats`
17-
5. **Redis hit rate**`keyspace_hits` vs `keyspace_misses`
16+
4. **APM bottlenecks** — live data from `/performance/bottlenecks` (slow queries + index usage + cache stats)
17+
5. **Container resources** — CPU/memory via `docker stats`
18+
6. **Redis hit rate**`keyspace_hits` vs `keyspace_misses`
19+
20+
### Live Bottleneck API
21+
22+
`GET /performance/bottlenecks` returns a JSON report combining:
23+
- Top 10 slow queries from `pg_stat_statements`
24+
- Tables with low index-scan ratio
25+
- Current cache hit/miss stats
26+
27+
```bash
28+
curl http://localhost:3000/performance/bottlenecks | jq .
29+
```
30+
31+
### Slow Query Threshold
32+
33+
Queries exceeding **100ms** are automatically logged to the `slow_query_log` table.
34+
Query the table to find recurring slow patterns:
35+
36+
```sql
37+
SELECT query_hash, query_text, round(avg(duration_ms)::numeric, 1) AS avg_ms, count(*) AS hits
38+
FROM slow_query_log
39+
WHERE recorded_at > NOW() - INTERVAL '24 hours'
40+
GROUP BY query_hash, query_text
41+
ORDER BY avg_ms DESC
42+
LIMIT 20;
43+
```
1844

1945
---
2046

2147
## Caching Strategy
2248

2349
### Redis API Cache (`indexer/src/cache.rs`)
2450

25-
| Endpoint pattern | TTL | Rationale |
26-
|-----------------|-----|-----------|
27-
| `GET /events*` | 10s | High-frequency reads; Stellar ledger closes every ~5s |
28-
| `GET /search*` | 30s | Search results change infrequently |
29-
| `GET /stats` | 60s | Aggregate — expensive to compute |
30-
| `POST /events/replay` | no cache | Mutating |
51+
| Endpoint pattern | TTL | Config key | Rationale |
52+
|-----------------|-----|-----------|-----------|
53+
| `GET /events*` | 10s | `events_ttl_secs` | High-frequency reads; Stellar ledger closes every ~5s |
54+
| `GET /search*` | 30s | `search_ttl_secs` | Search results change infrequently |
55+
| `GET /stats` | 60s | `stats_ttl_secs` | Aggregate — expensive to compute |
56+
| `GET /analytics/dashboard` | 60s | `analytics_ttl_secs` | Heavy aggregation query |
57+
| `POST /events/replay` | no cache || Mutating |
3158

32-
**Activate:** set `redis_url` in `config.toml` or `REDIS_URL` env var.
59+
**Activate:** set `redis_url` in `config.toml` or `STELLAR_ESCROW__CACHE__REDIS_URL` env var.
3360
**Fallback:** if Redis is unavailable, all requests hit Postgres directly — no errors.
3461

62+
All TTLs are configurable in `indexer/config.toml` under `[cache]`.
63+
3564
### Client-Side Cache (`frontend/performance.js`)
3665

3766
`cachedFetch()` provides an in-memory TTL cache (default 30s) for API responses.
@@ -50,6 +79,17 @@ Call `invalidateCache(url)` after any write operation to keep the UI consistent.
5079

5180
## Resource Allocation
5281

82+
### Docker Resource Limits
83+
84+
Applied in `docker-compose.yml` for all application services:
85+
86+
| Service | CPU limit | Memory limit | CPU reservation | Memory reservation |
87+
|---------|-----------|-------------|-----------------|-------------------|
88+
| indexer | 1.0 | 512M | 0.25 | 128M |
89+
| api | 0.5 | 256M | 0.1 | 64M |
90+
91+
Adjust limits based on observed usage from `docker stats` or the Grafana infrastructure dashboard.
92+
5393
### Database Connection Pool
5494

5595
Configured in `indexer/config.toml`:
@@ -63,6 +103,8 @@ min_connections = 2 # keep warm connections ready
63103
**Rule of thumb:** `max_connections = (2 × CPU cores) + effective_spindle_count`
64104
For a 4-core host: set `max_connections = 10–15`.
65105

106+
Monitor pool saturation via `GET /performance/bottlenecks``index_usage` or the Grafana PostgreSQL connections panel.
107+
66108
### Nginx (`performance.nginx.conf`)
67109

68110
- `worker_processes auto` — one worker per CPU core
@@ -143,8 +185,11 @@ Metrics are collected at three layers:
143185
## Quick Wins Checklist
144186

145187
- [ ] Enable Redis (`redis_url` in config) — eliminates repeat DB hits for read-heavy endpoints
146-
- [ ] Enable `pg_stat_statements` extension — required for slow query analysis
188+
- [ ] Enable `pg_stat_statements` extension — required for slow query analysis (migration `20260329000001_perf_optimization.sql` does this)
147189
- [ ] Set `max_connections` based on actual CPU count
148190
- [ ] Include `performance.nginx.conf` in nginx http block
149-
- [ ] Add `REDIS_URL` to docker-compose environment
191+
- [ ] Add `STELLAR_ESCROW__CACHE__REDIS_URL` to docker-compose environment
150192
- [ ] Run `perf-analyze.sh` weekly and track trends
193+
- [ ] Review `GET /performance/bottlenecks` after each deploy
194+
- [ ] Check `slow_query_log` table weekly for recurring slow patterns
195+
- [ ] Tune Docker resource limits based on `docker stats` observations

docker-compose.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ services:
5757
read_only: true
5858
tmpfs:
5959
- /tmp
60+
deploy:
61+
resources:
62+
limits:
63+
cpus: '1.0'
64+
memory: 512M
65+
reservations:
66+
cpus: '0.25'
67+
memory: 128M
6068
depends_on:
6169
postgres:
6270
condition: service_healthy
@@ -87,6 +95,14 @@ services:
8795
read_only: true
8896
tmpfs:
8997
- /tmp
98+
deploy:
99+
resources:
100+
limits:
101+
cpus: '0.5'
102+
memory: 256M
103+
reservations:
104+
cpus: '0.1'
105+
memory: 64M
90106
depends_on:
91107
postgres:
92108
condition: service_healthy

indexer/config.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ min_connections = 2
1010
redis_url = "" # Set to redis://localhost:6379 to enable
1111
default_ttl_secs = 30
1212
events_ttl_secs = 10
13+
search_ttl_secs = 30
14+
analytics_ttl_secs = 60
15+
stats_ttl_secs = 60
1316

1417
[stellar]
1518
network = "testnet"
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
-- Enable pg_stat_statements for slow query analysis
2+
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
3+
4+
-- Slow query log table: populated by the application when a query exceeds the threshold
5+
CREATE TABLE IF NOT EXISTS slow_query_log (
6+
id BIGSERIAL PRIMARY KEY,
7+
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
8+
query_hash TEXT NOT NULL,
9+
query_text TEXT NOT NULL,
10+
duration_ms DOUBLE PRECISION NOT NULL,
11+
rows_returned INT
12+
);
13+
14+
CREATE INDEX IF NOT EXISTS idx_slow_query_recorded ON slow_query_log (recorded_at DESC);
15+
CREATE INDEX IF NOT EXISTS idx_slow_query_duration ON slow_query_log (duration_ms DESC);
16+
17+
-- Partial index on events.data for trade_id lookups (already exists, kept for reference)
18+
-- Composite covering index for the most common paginated event query
19+
CREATE INDEX IF NOT EXISTS idx_events_type_ledger_id
20+
ON events (event_type, ledger DESC, id)
21+
WHERE event_type IS NOT NULL;
22+
23+
-- Covering index for search_trades hot path
24+
CREATE INDEX IF NOT EXISTS idx_events_trade_search
25+
ON events (ledger DESC, timestamp DESC, event_type)
26+
INCLUDE (id, data)
27+
WHERE category = 'trade';

indexer/src/cache_service/mod.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,24 @@ impl CacheService {
115115
self.invalidate(&key).await;
116116
}
117117

118+
pub async fn get_search<T: serde::de::DeserializeOwned>(&self, cache_key: &str) -> Option<T> {
119+
self.get(cache_key).await
120+
}
121+
122+
pub async fn set_search<T: serde::Serialize>(&self, cache_key: &str, value: &T) {
123+
let ttl = Duration::from_secs(self.config.search_ttl_secs);
124+
self.set(cache_key, value, ttl).await;
125+
}
126+
127+
pub async fn get_analytics<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
128+
self.get(KEY_ANALYTICS_DASHBOARD).await
129+
}
130+
131+
pub async fn set_analytics<T: serde::Serialize>(&self, value: &T) {
132+
let ttl = Duration::from_secs(self.config.analytics_ttl_secs);
133+
self.set(KEY_ANALYTICS_DASHBOARD, value, ttl).await;
134+
}
135+
118136
/// Warm the cache by pre-loading frequently accessed keys.
119137
/// Called at startup and periodically.
120138
pub async fn warm<F, Fut>(&self, loader: F)

indexer/src/config.rs

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -143,14 +143,22 @@ pub struct CacheConfig {
143143
/// TTL for event list responses (seconds, default: 10)
144144
#[serde(default = "default_events_ttl")]
145145
pub events_ttl_secs: u64,
146-
}
147-
148-
fn default_cache_ttl() -> u64 {
149-
30
150-
}
151-
fn default_events_ttl() -> u64 {
152-
10
153-
}
146+
/// TTL for search results (seconds, default: 30)
147+
#[serde(default = "default_search_ttl")]
148+
pub search_ttl_secs: u64,
149+
/// TTL for analytics dashboard (seconds, default: 60)
150+
#[serde(default = "default_analytics_ttl")]
151+
pub analytics_ttl_secs: u64,
152+
/// TTL for platform stats (seconds, default: 60)
153+
#[serde(default = "default_stats_ttl")]
154+
pub stats_ttl_secs: u64,
155+
}
156+
157+
fn default_cache_ttl() -> u64 { 30 }
158+
fn default_events_ttl() -> u64 { 10 }
159+
fn default_search_ttl() -> u64 { 30 }
160+
fn default_analytics_ttl() -> u64 { 60 }
161+
fn default_stats_ttl() -> u64 { 60 }
154162

155163
#[derive(Debug, Clone, Serialize, Deserialize)]
156164
pub struct StellarConfig {

indexer/src/database.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,95 @@ impl Database {
5555
Self { pool }
5656
}
5757

58+
/// Record a slow query to the slow_query_log table (fire-and-forget).
59+
/// Only logs queries exceeding `threshold_ms`.
60+
pub fn log_slow_query(
61+
pool: PgPool,
62+
query_hash: String,
63+
query_text: String,
64+
duration_ms: f64,
65+
rows_returned: Option<i32>,
66+
) {
67+
const THRESHOLD_MS: f64 = 100.0;
68+
if duration_ms < THRESHOLD_MS {
69+
return;
70+
}
71+
tokio::spawn(async move {
72+
let _ = sqlx::query(
73+
"INSERT INTO slow_query_log (query_hash, query_text, duration_ms, rows_returned) \
74+
VALUES ($1, $2, $3, $4)",
75+
)
76+
.bind(&query_hash)
77+
.bind(&query_text)
78+
.bind(duration_ms)
79+
.bind(rows_returned)
80+
.execute(&pool)
81+
.await;
82+
});
83+
}
84+
85+
/// Fetch the top slow queries from pg_stat_statements.
86+
pub async fn get_slow_queries(&self, limit: i64) -> Result<Vec<serde_json::Value>, sqlx::Error> {
87+
let rows = sqlx::query(
88+
r#"
89+
SELECT query,
90+
calls,
91+
round(mean_exec_time::numeric, 2) AS mean_ms,
92+
round(total_exec_time::numeric, 2) AS total_ms,
93+
round(stddev_exec_time::numeric, 2) AS stddev_ms
94+
FROM pg_stat_statements
95+
ORDER BY mean_exec_time DESC
96+
LIMIT $1
97+
"#,
98+
)
99+
.bind(limit.clamp(1, 50))
100+
.fetch_all(&self.pool)
101+
.await?;
102+
103+
Ok(rows
104+
.into_iter()
105+
.map(|r| {
106+
serde_json::json!({
107+
"query": r.get::<String, _>("query"),
108+
"calls": r.get::<i64, _>("calls"),
109+
"mean_ms": r.get::<f64, _>("mean_ms"),
110+
"total_ms": r.get::<f64, _>("total_ms"),
111+
"stddev_ms":r.get::<f64, _>("stddev_ms"),
112+
})
113+
})
114+
.collect())
115+
}
116+
117+
/// Fetch tables with low index-scan ratio (candidates for new indexes).
118+
pub async fn get_index_usage(&self) -> Result<Vec<serde_json::Value>, sqlx::Error> {
119+
let rows = sqlx::query(
120+
r#"
121+
SELECT relname AS table,
122+
seq_scan, idx_scan,
123+
CASE WHEN seq_scan + idx_scan = 0 THEN 0
124+
ELSE round(100.0 * idx_scan / (seq_scan + idx_scan), 1)
125+
END AS idx_pct
126+
FROM pg_stat_user_tables
127+
ORDER BY idx_pct ASC
128+
LIMIT 20
129+
"#,
130+
)
131+
.fetch_all(&self.pool)
132+
.await?;
133+
134+
Ok(rows
135+
.into_iter()
136+
.map(|r| {
137+
serde_json::json!({
138+
"table": r.get::<String, _>("table"),
139+
"seq_scan": r.get::<i64, _>("seq_scan"),
140+
"idx_scan": r.get::<i64, _>("idx_scan"),
141+
"idx_pct": r.get::<f64, _>("idx_pct"),
142+
})
143+
})
144+
.collect())
145+
}
146+
58147
pub async fn insert_event(&self, event: &Event) -> Result<(), AppError> {
59148
sqlx::query(
60149
r#"

indexer/src/handlers.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,21 @@ pub async fn get_performance_history(
597597
Ok(Json(serde_json::json!({ "hours": rows })))
598598
}
599599

600+
/// GET /performance/bottlenecks — slow queries + index usage analysis.
601+
pub async fn get_performance_bottlenecks(
602+
State(state): State<AppState>,
603+
) -> Result<Json<serde_json::Value>, AppError> {
604+
let (slow_queries, index_usage) = tokio::join!(
605+
state.database.get_slow_queries(10),
606+
state.database.get_index_usage(),
607+
);
608+
Ok(Json(serde_json::json!({
609+
"slow_queries": slow_queries.unwrap_or_default(),
610+
"index_usage": index_usage.unwrap_or_default(),
611+
"cache": state.cache_service.get_stats_snapshot().await,
612+
})))
613+
}
614+
600615
// =============================================================================
601616
// Analytics Handlers
602617
// =============================================================================

indexer/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
294294
.route("/performance/alerts", get(get_performance_alerts))
295295
.route("/performance/record", post(record_performance_sample))
296296
.route("/performance/history", get(get_performance_history))
297+
.route("/performance/bottlenecks", get(get_performance_bottlenecks))
297298
// Compliance
298299
.route("/compliance/check", post(run_compliance_check))
299300
.route("/compliance/status/:address", get(get_compliance_status))

scripts/perf-analyze.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ API_METRICS=$(curl -sf "${API_BASE}/health/metrics" 2>/dev/null || echo "unavail
6060
log "$API_METRICS"
6161
REPORT[api_metrics]="$API_METRICS"
6262

63+
# ── 3b. APM bottleneck report ─────────────────────────────────────────────────
64+
log ""
65+
log "## APM Bottleneck Report (slow queries + index usage + cache)"
66+
sep
67+
APM_BOTTLENECKS=$(curl -sf "${API_BASE}/performance/bottlenecks" 2>/dev/null || echo "unavailable")
68+
log "$APM_BOTTLENECKS"
69+
REPORT[apm_bottlenecks]="$APM_BOTTLENECKS"
70+
6371
# ── 4. Docker container resource usage ───────────────────────────────────────
6472
log ""
6573
log "## Container Resource Usage"

0 commit comments

Comments
 (0)