-
Notifications
You must be signed in to change notification settings - Fork 0
feat(bff): add PgBouncer pooling, Redis cache with stampede protectio… #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
dad20be
6aeb102
a0c7a05
5c9b1a4
1544192
c0891ea
562ad09
51b7622
17f201c
8689719
7c843cc
bc75555
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,3 +20,4 @@ reviews: | |
| Verify eventId and tenantId are always set. | ||
| chat: | ||
| auto_reply: true | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -69,3 +69,4 @@ export async function closeCassandraClient(): Promise<void> { | |
| client = null; | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -51,3 +51,4 @@ export const search = { | |
| } | ||
| }, | ||
| }; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,16 @@ | ||
| import { Pool } from "pg"; | ||
| import { postgresCircuitBreaker } from "../lib/circuitBreaker"; | ||
| import { cache } from "./redis"; | ||
|
|
||
| const pool = new Pool({ | ||
| host: process.env.READ_DB_HOST || "postgres-read", | ||
| host: process.env.READ_DB_HOST || "pgbouncer", | ||
| port: parseInt(process.env.READ_DB_PORT || "5432"), | ||
| database: process.env.READ_DB_NAME || "grainguard_read", | ||
| user: process.env.READ_DB_USER || "postgres", | ||
| password: process.env.READ_DB_PASSWORD || "postgres", | ||
| max: 10, | ||
| max: 50, | ||
| }); | ||
|
|
||
|
|
||
| // Circuit-breaker-wrapped query helper | ||
| async function cbQuery(text: string, values?: any[]): Promise<import("pg").QueryResult<any>> { | ||
| return postgresCircuitBreaker.execute(() => pool.query(text, values)); | ||
|
|
@@ -33,9 +33,6 @@ export async function tenantQuery( | |
| }); | ||
| } | ||
|
|
||
| // Tenant-scoped query — sets app.current_tenant_id for RLS enforcement | ||
| // Use this for all queries that should be tenant-isolated | ||
|
|
||
| export const db = { | ||
|
|
||
| async getDevice(deviceId: string) { | ||
|
|
@@ -49,14 +46,29 @@ export const db = { | |
| }, | ||
|
|
||
| async getAllDevices(limit: number = 20) { | ||
| const result = await cbQuery( | ||
| `SELECT device_id, tenant_id, serial_number, created_at | ||
| FROM device_projections | ||
| ORDER BY created_at DESC | ||
| LIMIT $1`, | ||
| [limit] | ||
| ); | ||
| return result.rows; | ||
| const cacheKey = `devices:all:${limit}`; | ||
| const cached = await cache.get<any[]>(cacheKey); | ||
| if (cached) return cached; | ||
|
|
||
| const locked = await cache.acquireLock(cacheKey, 5); | ||
| if (!locked) { | ||
| await new Promise(r => setTimeout(r, 100)); | ||
| return await cache.get<any[]>(cacheKey) || []; | ||
| } | ||
|
|
||
| try { | ||
| const result = await cbQuery( | ||
| `SELECT device_id, tenant_id, serial_number, created_at | ||
| FROM device_projections | ||
| ORDER BY created_at DESC | ||
| LIMIT $1`, | ||
| [limit] | ||
| ); | ||
| await cache.set(cacheKey, result.rows, 30); | ||
| return result.rows; | ||
| } finally { | ||
| await cache.releaseLock(cacheKey); | ||
|
Comment on lines
+49
to
+70
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not let cache contention or Redis failures turn into empty/failed reads. On a contested miss, the loser sleeps 100ms and then returns Also applies to: 85-120, 165-205 🤖 Prompt for AI Agents
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| }, | ||
|
|
||
| async getDeviceTelemetry(deviceId: string) { | ||
|
|
@@ -70,26 +82,43 @@ export const db = { | |
| }, | ||
|
|
||
| async getAllTelemetry(limit: number = 20, tenantId?: string) { | ||
| if (tenantId) { | ||
| const cacheKey = `telemetry:all:${tenantId || "global"}:${limit}`; | ||
| const cached = await cache.get<any[]>(cacheKey); | ||
| if (cached) return cached; | ||
|
|
||
| const locked = await cache.acquireLock(cacheKey, 5); | ||
| if (!locked) { | ||
| await new Promise(r => setTimeout(r, 100)); | ||
| return await cache.get<any[]>(cacheKey) || []; | ||
| } | ||
|
|
||
| try { | ||
| if (tenantId) { | ||
| const result = await cbQuery( | ||
| `SELECT t.device_id, t.temperature, t.humidity, t.recorded_at, t.updated_at, t.version | ||
| FROM device_telemetry_latest t | ||
| INNER JOIN device_projections d ON d.device_id = t.device_id | ||
| WHERE d.tenant_id = $1 | ||
| ORDER BY t.updated_at DESC | ||
| LIMIT $2`, | ||
| [tenantId, limit] | ||
| ); | ||
|
Comment on lines
+96
to
+105
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the tenant-scoped query path on these branches. These branches already have a Also applies to: 176-187 🤖 Prompt for AI Agents |
||
| await cache.set(cacheKey, result.rows, 30); | ||
| return result.rows; | ||
| } | ||
|
|
||
| const result = await cbQuery( | ||
| `SELECT t.device_id, t.temperature, t.humidity, t.recorded_at, t.updated_at, t.version | ||
| FROM device_telemetry_latest t | ||
| INNER JOIN device_projections d ON d.device_id = t.device_id | ||
| WHERE d.tenant_id = $1 | ||
| ORDER BY t.updated_at DESC | ||
| LIMIT $2`, | ||
| [tenantId, limit] | ||
| `SELECT device_id, temperature, humidity, recorded_at, updated_at, version | ||
| FROM device_telemetry_latest | ||
| ORDER BY updated_at DESC | ||
| LIMIT $1`, | ||
| [limit] | ||
| ); | ||
| await cache.set(cacheKey, result.rows, 30); | ||
| return result.rows; | ||
| } finally { | ||
| await cache.releaseLock(cacheKey); | ||
| } | ||
| const result = await cbQuery( | ||
| `SELECT device_id, temperature, humidity, recorded_at, updated_at, version | ||
| FROM device_telemetry_latest | ||
| ORDER BY updated_at DESC | ||
| LIMIT $1`, | ||
| [limit] | ||
| ); | ||
| return result.rows; | ||
| }, | ||
|
|
||
| async getDeviceWithTelemetry(deviceId: string) { | ||
|
|
@@ -133,45 +162,48 @@ export const db = { | |
| }, | ||
|
|
||
| async getAllDevicesWithTelemetry(limit: number = 20, tenantId?: string) { | ||
| if (tenantId) { | ||
| const cacheKey = `devices:telemetry:${tenantId || "global"}:${limit}`; | ||
| const cached = await cache.get<any[]>(cacheKey); | ||
| if (cached) return cached; | ||
|
|
||
| const locked = await cache.acquireLock(cacheKey, 5); | ||
| if (!locked) { | ||
| await new Promise(r => setTimeout(r, 100)); | ||
| return await cache.get<any[]>(cacheKey) || []; | ||
| } | ||
|
|
||
| try { | ||
| if (tenantId) { | ||
| const result = await cbQuery( | ||
| `SELECT | ||
| d.device_id, d.tenant_id, d.serial_number, d.created_at, | ||
| t.temperature, t.humidity, t.recorded_at, t.version | ||
| FROM device_projections d | ||
| LEFT JOIN device_telemetry_latest t ON d.device_id = t.device_id | ||
| WHERE d.tenant_id = $1 | ||
| ORDER BY d.created_at DESC | ||
| LIMIT $2`, | ||
| [tenantId, limit] | ||
| ); | ||
| await cache.set(cacheKey, result.rows, 30); | ||
| return result.rows; | ||
| } | ||
|
|
||
| const result = await cbQuery( | ||
| `SELECT | ||
| d.device_id, | ||
| d.tenant_id, | ||
| d.serial_number, | ||
| d.created_at, | ||
| t.temperature, | ||
| t.humidity, | ||
| t.recorded_at, | ||
| t.version | ||
| d.device_id, d.tenant_id, d.serial_number, d.created_at, | ||
| t.temperature, t.humidity, t.recorded_at, t.version | ||
| FROM device_projections d | ||
| LEFT JOIN device_telemetry_latest t | ||
| ON d.device_id = t.device_id | ||
| WHERE d.tenant_id = $1 | ||
| LEFT JOIN device_telemetry_latest t ON d.device_id = t.device_id | ||
| ORDER BY d.created_at DESC | ||
| LIMIT $2`, | ||
| [tenantId, limit] | ||
| LIMIT $1`, | ||
| [limit] | ||
| ); | ||
| await cache.set(cacheKey, result.rows, 30); | ||
| return result.rows; | ||
| } finally { | ||
| await cache.releaseLock(cacheKey); | ||
| } | ||
| const result = await cbQuery( | ||
| `SELECT | ||
| d.device_id, | ||
| d.tenant_id, | ||
| d.serial_number, | ||
| d.created_at, | ||
| t.temperature, | ||
| t.humidity, | ||
| t.recorded_at, | ||
| t.version | ||
| FROM device_projections d | ||
| LEFT JOIN device_telemetry_latest t | ||
| ON d.device_id = t.device_id | ||
| ORDER BY d.created_at DESC | ||
| LIMIT $1`, | ||
| [limit] | ||
| ); | ||
| return result.rows; | ||
| }, | ||
|
|
||
| async getDevicesWithCursor(first: number = 20, after: string | null = null, tenantId?: string) { | ||
|
|
@@ -264,4 +296,4 @@ export const db = { | |
| }, | ||
| }; | ||
| }, | ||
| }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -44,4 +44,4 @@ export const cache = { | |
| async releaseLock(key: string): Promise<void> { | ||
| await client.del(`lock:${key}`); | ||
| } | ||
| }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -142,3 +142,4 @@ export const postgresCircuitBreaker = new CircuitBreaker({ | |
| successThreshold: 2, | ||
| timeout: 30_000, | ||
| }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -226,3 +226,4 @@ export const resolvers = { | |
| }, | ||
| }, | ||
| }; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -90,3 +90,4 @@ | |
| tenantTelemetryUpdated(tenantId: String!): Telemetry! | ||
| } | ||
| `; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -180,3 +180,4 @@ startServer().catch((err) => { | |
| console.error("Failed to start BFF:", err); | ||
| process.exit(1); | ||
| }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -176,3 +176,4 @@ func init() { | |
| // Ensure uuid package is used | ||
| _ = uuid.New() | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -217,3 +217,4 @@ func isSkippable(err error) bool { | |
| } | ||
| return errors.Is(err, context.Canceled) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -460,3 +460,4 @@ func isSkippable(err error) bool { | |
| return errors.Is(err, context.Canceled) || | ||
| strings.HasPrefix(s, "skip:") | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -155,4 +155,4 @@ func normalizeTime(t string) string { | |
| } | ||
|
|
||
| return time.Now().UTC().Format(time.RFC3339Nano) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,4 +18,4 @@ const preview: Preview = { | |
| }, | ||
| }; | ||
|
|
||
| export default preview; | ||
| export default preview; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -51,3 +51,4 @@ export function useSearchDevices(query: string, limit = 20) { | |
| error, | ||
| }; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -51,3 +51,4 @@ export const SEARCH_DEVICES = gql` | |
| } | ||
| } | ||
| `; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,3 +29,4 @@ export function useAuth() { | |
| getToken, | ||
| }; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,4 +20,4 @@ export function useDarkMode() { | |
| }, [isDark]); | ||
|
|
||
| return { isDark, toggle: () => setIsDark((d) => !d) }; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,4 +14,4 @@ export function useDebounce<T>(value: T, delay: number): T { | |
| }, [value, delay]); | ||
|
|
||
| return debouncedValue; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -52,3 +52,4 @@ export function useTenantTelemetrySubscription(tenantId: string) { | |
| error, | ||
| }; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -52,3 +52,4 @@ export const Small: Story = { | |
| label: 'Button', | ||
| }, | ||
| }; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,3 +32,4 @@ export const LoggedIn: Story = { | |
| }; | ||
|
|
||
| export const LoggedOut: Story = {}; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make this cache path tenant-aware or explicitly admin-only.
devices:all:${limit}is a shared cross-tenant key, and the query still returns rows for every tenant. If this method is reachable from normal tenant-scoped resolver flows, one caller can warm a payload that every other caller reuses. ThreadtenantIdthrough this API and key/query on it, or split it into an explicitly admin-only path. As per coding guidelines,apps/bff/src/**/*.ts: Check all queries use tenantId for RLS. Verify cache keys include tenantId.🤖 Prompt for AI Agents