|
1 | | -Closes #995 |
| 1 | +Closes #1530 |
2 | 2 |
|
3 | 3 | ## Summary |
4 | 4 |
|
5 | | -Implements a paginated, filterable **rebalance history endpoint** at `GET /portfolio/:id/rebalance-history` that returns past rebalance outcomes for a given portfolio, including failed rebalances with error reasons. |
| 5 | +Implements a comprehensive chaos/load test script (`scripts/chaos/ws-portfolio-feed-load-test.mjs`) that simulates up to thousands of concurrent WebSocket subscriptions against the `portfolioFeed.ts` endpoint in order to measure connection acceptance latency, message delivery latency, and server resource usage under load. The test identifies and documents the practical concurrent-connection ceiling for current infrastructure sizing, enabling data-driven capacity planning. |
6 | 6 |
|
7 | 7 | --- |
8 | 8 |
|
9 | 9 | ## What was added |
10 | 10 |
|
11 | | -### New endpoint: `GET /portfolio/:id/rebalance-history` |
| 11 | +### New Script: `scripts/chaos/ws-portfolio-feed-load-test.mjs` |
12 | 12 |
|
13 | | -Returns a paginated list of past rebalances for a portfolio. Each record includes: |
| 13 | +A standalone Node.js ESM script that stress-tests the WebSocket portfolio feed endpoint (`/ws/portfolio/:id`) with configurable concurrency. Phase breakdown: |
14 | 14 |
|
15 | | -| Field | Description | |
| 15 | +| Phase | Description | |
16 | 16 | |-------|-------------| |
17 | | -| `timestamp` | ISO-8601 datetime of the rebalance | |
18 | | -| `trigger` | Raw trigger description | |
19 | | -| `triggerType` | Normalized: `manual`, `auto`, or `circuit_breaker` | |
20 | | -| `assetsTrades` | Number of asset trades executed | |
21 | | -| `totalFeeXlm` | Total gas fee in XLM (null if unavailable) | |
22 | | -| `totalFeeUsd` | Total gas fee in USD (null if unavailable) | |
23 | | -| `totalSlippageBps` | Total slippage in basis points (null if unavailable) | |
24 | | -| `status` | `success`, `partial`, or `failed` | |
25 | | -| `errorReason` | Error description for failed rebalances (null otherwise) | |
26 | | - |
27 | | -### Query Parameters (Filters) |
28 | | - |
29 | | -| Param | Type | Description | |
30 | | -|-------|------|-------------| |
31 | | -| `from` | ISO-8601 string | Lower-bound timestamp filter (inclusive) | |
32 | | -| `to` | ISO-8601 string | Upper-bound timestamp filter (inclusive) | |
33 | | -| `trigger_type` | `manual` \| `auto` \| `circuit_breaker` | Filter by trigger type | |
34 | | -| `status` | `success` \| `partial` \| `failed` | Filter by rebalance outcome | |
35 | | -| `page` | integer (default: 1) | Page number | |
36 | | -| `page_size` | integer (default: 50, max: 500) | Records per page | |
37 | | -| `sort` | `asc` \| `desc` (default: desc) | Sort order by timestamp | |
38 | | - |
39 | | -### Response Shape |
| 17 | +| **Phase 1: Health Check** | Verifies the backend is reachable via `GET /health` before starting | |
| 18 | +| **Phase 2: Ramp Up** | Opens WebSocket connections in configurable batches (supports `batch` and `linear` ramp strategies) | |
| 19 | +| **Phase 3: Sustain** | Maintains all open connections for a configurable duration while collecting message delivery metrics and server resource samples | |
| 20 | +| **Phase 4: Teardown** | Gracefully closes all connections with `1000` close code | |
| 21 | +| **Phase 5: Report** | Generates a comprehensive latency and resource report with capacity planning assessment | |
| 22 | + |
| 23 | +### Metrics Collected |
| 24 | + |
| 25 | +| Metric | Description | |
| 26 | +|--------|-------------| |
| 27 | +| **Connection Acceptance Latency** | Time from `new WebSocket()` to `open` event, with P50/P75/P95/P99/max/mean | |
| 28 | +| **First Message Delivery Latency** | Time from `CONNECTION_ACK` to first `PORTFOLIO_VALUE_UPDATE` | |
| 29 | +| **Message Delivery Latency** | Server timestamp vs. client receipt time for all `PORTFOLIO_VALUE_UPDATE` broadcasts | |
| 30 | +| **Message Throughput** | Total messages received and messages-per-second rate during sustain | |
| 31 | +| **Server Resource Usage** | Polls backend `/metrics` endpoint periodically during sustain phase | |
| 32 | +| **Client Overhead** | Tracks heap usage, RSS, and external memory of the load generator itself | |
| 33 | +| **Connection Drop Rate** | Connections that unexpectedly closed during the sustain phase | |
| 34 | +| **Connection Failure Rate** | Connections that failed to establish or receive `CONNECTION_ACK` | |
| 35 | + |
| 36 | +### Ceiling Assessment |
| 37 | + |
| 38 | +The script automatically assesses the practical connection ceiling based on: |
| 39 | +- Connection failure rate (>10% → degraded) |
| 40 | +- Connection drop rate during sustain (>5% → degraded) |
| 41 | +- P95 connection latency (>5s → degraded) |
| 42 | +- P95 message delivery latency (>2s → degraded) |
| 43 | + |
| 44 | +When no issues are detected, it reports the tested concurrency level as the _minimum_ ceiling and recommends re-running with higher concurrency to find the true limit. When issues are detected, it estimates the practical ceiling using a penalty factor. |
| 45 | + |
| 46 | +### Configuration (Environment Variables) |
| 47 | + |
| 48 | +| Variable | Default | Description | |
| 49 | +|----------|---------|-------------| |
| 50 | +| `CHAOS_WS_BACKEND_URL` | `http://localhost:3001` | Backend base URL | |
| 51 | +| `CHAOS_WS_CONCURRENT_CONNECTIONS` | `100` | Total WebSocket connections to simulate | |
| 52 | +| `CHAOS_WS_BATCH_SIZE` | `10` | Connections to open per batch | |
| 53 | +| `CHAOS_WS_BATCH_DELAY_MS` | `200` | Delay between batches | |
| 54 | +| `CHAOS_WS_DURATION_MS` | `60000` | How long to sustain load (ms) | |
| 55 | +| `CHAOS_WS_PORTFOLIO_ID_PREFIX` | `load-test-pf` | Prefix for generated portfolio IDs | |
| 56 | +| `CHAOS_WS_JWT_SECRET` | auto-generated | JWT secret for token generation | |
| 57 | +| `CHAOS_WS_AUTH_ENABLED` | `false` | Whether to require JWT auth tokens | |
| 58 | +| `CHAOS_WS_VERBOSE` | unset | Enable debug-level logging | |
| 59 | +| `CHAOS_WS_TIMEOUT_MS` | `10000` | Individual connection timeout | |
| 60 | +| `CHAOS_WS_RAMP_STRATEGY` | `batch` | `batch` or `linear` ramp strategy | |
| 61 | + |
| 62 | +### Usage |
| 63 | + |
| 64 | +```bash |
| 65 | +# Run with defaults (100 connections, 60s sustain) |
| 66 | +npm run test:chaos:ws-load |
| 67 | + |
| 68 | +# Run with custom concurrency |
| 69 | +CHAOS_WS_CONCURRENT_CONNECTIONS=500 CHAOS_WS_DURATION_MS=120000 \ |
| 70 | + npm run test:chaos:ws-load |
| 71 | + |
| 72 | +# Run with auth enabled (requires backend JWT secret) |
| 73 | +CHAOS_WS_AUTH_ENABLED=true CHAOS_WS_JWT_SECRET=my-secret-key \ |
| 74 | + npm run test:chaos:ws-load |
| 75 | +``` |
| 76 | + |
| 77 | +### CI Integration |
| 78 | + |
| 79 | +The script is added to the root `package.json` as `test:chaos:ws-load` alongside the existing `test:chaos` script. It can be integrated into CI for periodic capacity verification: |
40 | 80 |
|
41 | 81 | ```json |
42 | | -{ |
43 | | - "data": { |
44 | | - "history": [ /* PortfolioRebalanceHistoryItem[] */ ], |
45 | | - "pagination": { |
46 | | - "page": 1, |
47 | | - "pageSize": 50, |
48 | | - "total": 127, |
49 | | - "totalPages": 3 |
50 | | - }, |
51 | | - "filters": { |
52 | | - "from": null, |
53 | | - "to": null, |
54 | | - "trigger_type": null, |
55 | | - "status": null |
56 | | - } |
57 | | - } |
58 | | -} |
| 82 | +"test:chaos:ws-load": "node scripts/chaos/ws-portfolio-feed-load-test.mjs" |
59 | 83 | ``` |
60 | 84 |
|
61 | | -### Acceptance Criteria |
| 85 | +--- |
| 86 | + |
| 87 | +## Acceptance Criteria |
| 88 | + |
| 89 | +- ✅ Load test can simulate a **configurable number** of concurrent WS subscriptions (via `CHAOS_WS_CONCURRENT_CONNECTIONS`) |
| 90 | +- ✅ Test produces a report on **latency** (connection, first-message, delivery with P50/P75/P95/P99) and **resource usage** (server `/metrics` + client overhead) under the simulated load |
| 91 | +- ✅ **Practical connection ceiling** is documented for capacity planning (automatic `assessCeiling()` function) |
| 92 | +- ✅ Script follows existing chaos test patterns (`kill-backend-mid-rebalance.mjs`) with consistent logging, env var configuration, and phased execution |
| 93 | + |
| 94 | +--- |
| 95 | + |
| 96 | +## Review Fixes |
| 97 | + |
| 98 | +Resolves the technical issues raised in the PR thread: |
62 | 99 |
|
63 | | -- ✅ All rebalance outcomes recorded and returned (success, partial, failed) |
64 | | -- ✅ Failed rebalances include `errorReason` field with the failure description |
65 | | -- ✅ Response time monitoring: queries exceeding 200ms are logged as warnings |
66 | | -- ✅ Paginated with `page`, `page_size`, `total`, `totalPages` |
67 | | -- ✅ Filterable by `from`, `to`, `trigger_type`, `status` |
| 100 | +- **Intentional disconnects no longer counted as failures.** The teardown phase closes every open connection with close code `1000`. Previously the `close` handler incremented `connectionsDropped` for any connection closed while in the `open` state, so the intentional teardown inflated the drop rate to ~100% and corrupted the ceiling assessment and reported metrics. Connections are now marked `intentionalClose` before teardown and their closes are excluded from the drop/failure counts (`dropRate` reflects only unexpected disconnects during the sustain phase). |
| 101 | +- **JWT secret no longer serialized into CI artifacts.** The results JSON previously persisted the full `CONFIG` object, including the JWT secret, and the results file is uploaded as a public workflow artifact (`ws-load-test-report`). The persisted `configuration` now redacts `jwtSecret` via `sanitizeConfig()`. The workflow also no longer hardcodes plaintext `JWT_SECRET` values; it generates an ephemeral random secret at runtime with `openssl rand -hex 32`. |
| 102 | +- **CI job now authenticates correctly.** `portfolioFeed.ts` unconditionally requires a valid JWT, so the load test job now runs with `CHAOS_WS_AUTH_ENABLED=true` and passes the same runtime secret as `CHAOS_WS_JWT_SECRET`, ensuring the tokens signed by the test validate against the backend. |
| 103 | +- **Rebased onto the base branch** (231 commits behind at the time of rebasing) to restore compatibility and keep the PR mergeable. |
68 | 104 |
|
69 | 105 | --- |
70 | 106 |
|
71 | 107 | ## Files Changed |
72 | 108 |
|
73 | 109 | ### New files |
74 | | -- `backend/src/test/rebalanceHistory.routes.test.ts` — Unit tests covering: 404 for missing portfolio, default pagination, filter passthrough, failed event error reasons, and totalPages calculation. |
| 110 | +- `scripts/chaos/ws-portfolio-feed-load-test.mjs` — Load test script |
75 | 111 |
|
76 | 112 | ### Modified files |
77 | | -- `backend/src/api/portfolios.routes.ts` — Added `GET /portfolio/:id/rebalance-history` route |
78 | | -- `backend/src/api/validation.ts` — Added `portfolioRebalanceHistoryQuerySchema` with Zod validation for all query parameters |
79 | | -- `backend/src/db/rebalanceHistoryDb.ts` — Added `dbGetPortfolioRebalanceHistory()` — parameterised SQL query with dynamic WHERE clause construction, COUNT for total, and status/trigger mapping |
| 113 | +- `package.json` — Added `"test:chaos:ws-load"` script entry |
| 114 | +- `.github/workflows/performance-test.yml` — CI integration for the load test; no longer hardcodes `JWT_SECRET` |
| 115 | +- `pr_body.md` — PR description |
0 commit comments