|
1 | 1 | # Shudhi |
2 | 2 |
|
3 | | -A sidecar for managing in-memory caches across services. Deploy alongside any app that implements the [InMem Management Protocol](#app-contract), and get cross-service cache visibility, targeted key lookups, and coordinated cache invalidation — all through a shared Redis. |
4 | | - |
5 | | -## Architecture |
| 3 | +A sidecar for managing in-memory caches across services. Deploy it alongside your app to get cache visibility, key lookups, and coordinated invalidation — all through Redis. |
6 | 4 |
|
7 | 5 | ``` |
8 | | - ┌──────────────────────────┐ |
9 | | - │ Redis │ |
10 | | - │ │ |
11 | | - │ inmem:pod:<svc>:<pod> │ pod liveness (TTL 60s) |
12 | | - │ inmem:keys:<svc>:<pod> │ key registry (TTL 3d) |
13 | | - │ inmem:<svc> (pubsub) │ broadcast refresh |
14 | | - │ inmem:req:<svc>:<pod> │ targeted get (pubsub RPC) |
15 | | - └────────┬─────────────────┘ |
16 | | - │ |
17 | | - ┌──────────────┼──────────────┐ |
18 | | - │ │ │ |
19 | | - Sidecar A Sidecar B Sidecar C |
20 | | - (svc: rider) (svc: rider) (svc: driver) |
21 | | - │ │ │ |
22 | | - App Pod A App Pod B App Pod C |
| 6 | + Redis (shared) |
| 7 | + │ |
| 8 | + ┌──────────┼──────────┐ |
| 9 | + │ │ │ |
| 10 | + Sidecar A Sidecar B Sidecar C |
| 11 | + │ │ │ |
| 12 | + App Pod A App Pod B App Pod C |
23 | 13 | ``` |
24 | 14 |
|
25 | | -Every sidecar connects to the same Redis. Discovery (list services, pods, keys) works from any sidecar. Interaction (get value, refresh) routes to the right place via Redis pub/sub — no direct sidecar-to-sidecar HTTP required. |
26 | | - |
27 | | -## Configuration |
28 | | - |
29 | | -| Env Var | Default | Description | |
30 | | -|---------|---------|-------------| |
31 | | -| `APP_URL` | `http://localhost:8080` | Local app's base URL | |
32 | | -| `SIDECAR_PORT` | `8900` | Port the sidecar listens on | |
33 | | -| `REDIS_URL` | `localhost:6379` | Redis address | |
34 | | -| `POD_IP` | `127.0.0.1` | Pod IP (from k8s downward API) | |
35 | | -| `INMEM_TOKEN` | _(empty)_ | If set, sent as `x-inmem-token` header on all calls to the app and between sidecars | |
36 | | - |
37 | | -## Running |
| 15 | +## Quick Start |
38 | 16 |
|
39 | 17 | ```bash |
40 | | -# Binary |
41 | 18 | APP_URL=http://localhost:8080 REDIS_URL=localhost:6379 go run . |
42 | | - |
43 | | -# Docker |
44 | | -docker run -e APP_URL=http://localhost:8080 -e REDIS_URL=redis:6379 ghcr.io/<owner>/shudhi |
45 | 19 | ``` |
46 | 20 |
|
47 | | -## API |
48 | | - |
49 | | -All endpoints are served by the sidecar. The dashboard (or any client) only needs to reach one sidecar to interact with any service. |
| 21 | +| Env Var | Default | Description | |
| 22 | +|---------|---------|-------------| |
| 23 | +| `APP_URL` | `http://localhost:8080` | Your app's base URL | |
| 24 | +| `SIDECAR_PORT` | `8900` | Sidecar listen port | |
| 25 | +| `REDIS_URL` | `localhost:6379` | Redis address | |
| 26 | +| `POD_IP` | `127.0.0.1` | Pod IP (k8s downward API) | |
| 27 | +| `INMEM_TOKEN` | _(empty)_ | Shared auth token between sidecar and app | |
50 | 28 |
|
51 | | -### Discovery (reads from Redis, works from any sidecar) |
| 29 | +## Integrating Your App |
52 | 30 |
|
53 | | -#### `GET /api/services` |
| 31 | +Your app needs to expose 3 endpoints and make 1 optional call: |
54 | 32 |
|
55 | | -List all registered services. |
| 33 | +### 1. Tell the sidecar who you are (required) |
56 | 34 |
|
57 | | -```json |
58 | | -{ "services": ["rider-app", "driver-app", "payment-service"] } |
59 | 35 | ``` |
60 | | - |
61 | | -#### `GET /api/pods?service=rider-app` |
62 | | - |
63 | | -List live pods for a service. |
64 | | - |
65 | | -```json |
66 | | -{ |
67 | | - "pods": [ |
68 | | - { "podName": "rider-app-7b4f8d6c9-x2k4m", "sidecarUrl": "http://10.0.3.42:8900" } |
69 | | - ] |
70 | | -} |
| 36 | +GET /internal/inMem/serverInfo |
| 37 | +→ { "serviceName": "rider-app", "podName": "rider-app-7b4f8d6c9-x2k4m" } |
71 | 38 | ``` |
72 | 39 |
|
73 | | -#### `GET /api/keys?service=rider-app&pod=<optional>` |
| 40 | +Called once at startup. The sidecar uses this to register itself in Redis. |
74 | 41 |
|
75 | | -List registered cache keys. If `pod` is omitted, returns keys across all pods. |
| 42 | +### 2. Return a cached value on demand (required) |
76 | 43 |
|
77 | | -```json |
78 | | -{ |
79 | | - "keys": [ |
80 | | - { |
81 | | - "keyName": "RouteByRouteId:config-id:route-123", |
82 | | - "keySchema": null, |
83 | | - "ttlInSeconds": 3600, |
84 | | - "registeredAt": "2026-04-24T10:30:00Z", |
85 | | - "podName": "rider-app-7b4f8d6c9-x2k4m" |
86 | | - } |
87 | | - ] |
88 | | -} |
89 | 44 | ``` |
90 | | - |
91 | | -### Interaction (routes to the correct service/pod) |
92 | | - |
93 | | -#### `POST /api/pod/get` |
94 | | - |
95 | | -Get a cached value from a specific pod. Tries direct HTTP to the target sidecar, falls back to Redis pub/sub RPC. |
96 | | - |
97 | | -```json |
98 | | -// Request |
99 | | -{ |
100 | | - "serviceName": "rider-app", |
101 | | - "podName": "rider-app-7b4f8d6c9-x2k4m", |
102 | | - "key": "RouteByRouteId:config-id:route-123" |
103 | | -} |
104 | | - |
105 | | -// Response (proxied from app) |
106 | | -{ |
107 | | - "found": true, |
108 | | - "value": { "vehicleType": "SUV", "category": "premium" } |
109 | | -} |
| 45 | +POST /internal/inMem/get |
| 46 | +← { "key": "RouteByRouteId:config-id:route-123" } |
| 47 | +→ { "found": true, "value": { "vehicleType": "SUV" } } |
110 | 48 | ``` |
111 | 49 |
|
112 | | -#### `POST /api/refresh` |
113 | | - |
114 | | -Invalidate cache entries across all pods of a service. Publishes to the service's Redis pub/sub channel. |
| 50 | +### 3. Clear cache entries on demand (required) |
115 | 51 |
|
116 | | -```json |
117 | | -// Request |
118 | | -{ |
119 | | - "serviceName": "rider-app", |
120 | | - "keyInfix": "RouteByRouteId" // null to clear all |
121 | | -} |
122 | | - |
123 | | -// Response |
124 | | -{ "published": true, "service": "rider-app" } |
| 52 | +``` |
| 53 | +POST /internal/inMem/refresh |
| 54 | +← { "keyInfix": "RouteByRouteId" } // or null to clear all |
| 55 | +→ 200 OK |
125 | 56 | ``` |
126 | 57 |
|
127 | | -### App-facing |
| 58 | +Delete any cached entries whose key contains the given `keyInfix`. If `null`, clear everything. |
128 | 59 |
|
129 | | -#### `POST /api/registerKey` |
| 60 | +### 4. Register cached keys (optional, enables dashboard visibility) |
130 | 61 |
|
131 | | -Called by the app to register a cached key with the sidecar. |
| 62 | +Whenever your app caches something, tell the sidecar: |
132 | 63 |
|
133 | | -```json |
| 64 | +``` |
| 65 | +POST http://localhost:8900/api/registerKey |
134 | 66 | { |
135 | 67 | "keyName": "RouteByRouteId:config-id:route-123", |
136 | | - "keySchema": { "type": "object", "properties": { "vehicleType": { "type": "string" } } }, |
| 68 | + "keySchema": null, |
137 | 69 | "ttlInSeconds": 3600 |
138 | 70 | } |
139 | 71 | ``` |
140 | 72 |
|
141 | | -Stored in Redis hash `inmem:keys:<serviceName>:<podName>` with a 3-day TTL. |
| 73 | +This makes the key visible in the dashboard. If the sidecar isn't ready yet, the call is silently accepted. |
142 | 74 |
|
143 | | -### Infra |
| 75 | +> If `INMEM_TOKEN` is set, the sidecar sends it as `x-inmem-token` header on all calls to your app. Use it to verify requests come from a trusted sidecar. |
144 | 76 |
|
145 | | -#### `GET /api/health` |
| 77 | +## Sidecar API |
146 | 78 |
|
147 | | -```json |
148 | | -{ "redis": true, "app": true } |
149 | | -``` |
| 79 | +All endpoints work from any sidecar — the dashboard only needs to reach one. |
| 80 | + |
| 81 | +| Method | Endpoint | What it does | |
| 82 | +|--------|----------|-------------| |
| 83 | +| `GET` | `/api/services` | List all services | |
| 84 | +| `GET` | `/api/pods?service=X` | List live pods for a service | |
| 85 | +| `GET` | `/api/keys?service=X&pod=Y` | List registered cache keys (pod optional) | |
| 86 | +| `POST` | `/api/pod/get` | Get a cached value from a specific pod | |
| 87 | +| `POST` | `/api/refresh` | Clear cache entries across all pods of a service | |
| 88 | +| `POST` | `/api/registerKey` | Register a cache key (called by your app) | |
| 89 | +| `GET` | `/api/health` | Health check (`{ "redis": bool, "app": bool }`) | |
| 90 | + |
| 91 | +## How It Works |
| 92 | + |
| 93 | +**Startup**: The sidecar calls your app's `/serverInfo` once to learn its service name and pod name. It then registers itself in Redis (`inmem:pod:<svc>:<pod>` with a 60s TTL) and subscribes to the service's pub/sub channel. A heartbeat refreshes the TTL every 30s. On `SIGTERM`, the key is deleted immediately; on crash, it auto-expires within 60s. |
150 | 94 |
|
151 | | -Returns `503` if either Redis or the app is unreachable. |
| 95 | +**Getting a value**: When the dashboard (or any client) asks for a key from a specific pod, the sidecar looks up the target pod's URL from Redis and proxies the request directly. If direct HTTP fails (network policy, pod restarting), it falls back to a Redis pub/sub RPC — publishes a request to the target pod's channel and waits for a reply on an ephemeral channel. |
152 | 96 |
|
153 | | -## App Contract |
| 97 | +**Refreshing / clearing cache**: A refresh request publishes a message to the service's broadcast channel in Redis. Every sidecar subscribed to that channel receives it and forwards the clear command to its local app. The originating pod skips the broadcast (it already cleared locally). Retries up to 3 times with backoff if the app returns an error. |
154 | 98 |
|
155 | | -The app must expose these endpoints under `/internal/inMem/`: |
| 99 | +**Key registration**: When your app calls `/api/registerKey`, the sidecar writes the key metadata to a Redis hash (`inmem:keys:<svc>:<pod>`, 3-day TTL). The dashboard reads these hashes to show what's cached where. This is the only way keys appear in the dashboard — if you don't register, the sidecar still works for get/refresh, you just lose visibility. |
156 | 100 |
|
157 | | -| Method | Endpoint | Purpose | |
158 | | -|--------|----------|---------| |
159 | | -| `GET` | `/internal/inMem/serverInfo` | Returns `{ "serviceName": "...", "podName": "..." }` | |
160 | | -| `POST` | `/internal/inMem/get` | Returns `{ "found": bool, "value": json }` for a given key | |
161 | | -| `POST` | `/internal/inMem/refresh` | Deletes cached entries by prefix (or all) | |
| 101 | +**Nothing piles up in Redis.** Pub/Sub messages are fire-and-forget — delivered to active subscribers and immediately discarded. The only persistent keys are pod liveness (60s TTL) and key registrations (3-day TTL), both self-cleaning. |
162 | 102 |
|
163 | | -The sidecar calls `/serverInfo` once at startup to learn its identity. `/get` and `/refresh` are called on demand. |
| 103 | +## Use Cases |
164 | 104 |
|
165 | | -If `INMEM_TOKEN` is set, every request to these endpoints includes the `x-inmem-token` header. The app can use this to verify that requests are coming from a trusted sidecar. |
| 105 | +**"A config changed in the DB, clear it from all pods"** |
| 106 | +You updated a pricing config in the database, but 12 pods still have the old value in memory. Instead of restarting the deployment, hit the dashboard's Clear button (or call `/api/refresh`) and every pod drops the stale entry. Next request fetches fresh from DB. |
166 | 107 |
|
167 | | -## Pod Liveness |
| 108 | +**"Why is this user seeing stale data?"** |
| 109 | +A customer reports seeing outdated information. Open the dashboard, find the service, pick the user's pod, and click Get on the relevant cache key to see exactly what's in memory on that pod right now. No port-forwarding, no kubectl exec, no guesswork. |
168 | 110 |
|
169 | | -Each sidecar registers itself in Redis with a 60s TTL and refreshes every 30s. On graceful shutdown (`SIGTERM`), it deletes its key immediately. On crash, the key auto-expires within 60s. If the key exists, the pod is alive — no filtering logic needed. |
| 111 | +**"We need cache visibility across 5 microservices"** |
| 112 | +Each team owns their own service with their own in-memory cache. Deploy Shudhi as a sidecar to each, point them at the same Redis, and the dashboard shows all services, all pods, all cached keys in one place. Platform team gets visibility without each service team building custom tooling. |
170 | 113 |
|
171 | | -## Redis Keys |
| 114 | +**"Rolling out a feature flag change"** |
| 115 | +Feature flags cached in memory across pods. Instead of waiting for TTL expiry (could be hours), trigger a targeted refresh for the flag's cache key and every pod picks up the new value within seconds. |
172 | 116 |
|
173 | | -| Key Pattern | Type | TTL | Purpose | |
174 | | -|-------------|------|-----|---------| |
175 | | -| `inmem:pod:<svc>:<pod>` | STRING | 60s | Pod liveness + sidecar URL | |
176 | | -| `inmem:keys:<svc>:<pod>` | HASH | 3 days | Registered cache keys | |
177 | | -| `inmem:<svc>` | PUBSUB | — | Broadcast channel (refresh) | |
178 | | -| `inmem:req:<svc>:<pod>` | PUBSUB | — | Targeted request channel (get) | |
179 | | -| `inmem:reply:<pod>:<nonce>` | PUBSUB | — | Ephemeral reply channel for pub/sub RPC | |
| 117 | +**"Debugging cache inconsistency between pods"** |
| 118 | +Pod A returns one value, Pod B returns another for the same key. Use the dashboard to Get the value from each pod side by side and see exactly what diverged — no need to reproduce the issue or add temporary logging. |
0 commit comments