-
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 8 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 | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,103 @@ | ||||||||||||
| .PHONY: up down restart logs build seed test lint clean help | ||||||||||||
|
|
||||||||||||
| # ============================================ | ||||||||||||
| # GrainGuard — Developer Makefile | ||||||||||||
| # ============================================ | ||||||||||||
|
|
||||||||||||
| help: ## Show this help | ||||||||||||
| @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' | ||||||||||||
|
|
||||||||||||
| # ============================================ | ||||||||||||
| # Docker Compose | ||||||||||||
| # ============================================ | ||||||||||||
|
|
||||||||||||
| up: ## Start all services | ||||||||||||
| docker compose -f infra/docker/docker-compose.yml up -d | ||||||||||||
|
|
||||||||||||
| down: ## Stop all services | ||||||||||||
| docker compose -f infra/docker/docker-compose.yml down | ||||||||||||
|
|
||||||||||||
| restart: ## Restart all services | ||||||||||||
| docker compose -f infra/docker/docker-compose.yml restart | ||||||||||||
|
|
||||||||||||
| logs: ## Tail logs for all services | ||||||||||||
| docker compose -f infra/docker/docker-compose.yml logs -f | ||||||||||||
|
|
||||||||||||
| logs-gateway: ## Tail gateway logs | ||||||||||||
| docker compose -f infra/docker/docker-compose.yml logs -f gateway | ||||||||||||
|
|
||||||||||||
| logs-bff: ## Tail BFF logs | ||||||||||||
| docker compose -f infra/docker/docker-compose.yml logs -f bff | ||||||||||||
|
|
||||||||||||
| logs-kafka: ## Tail Kafka logs | ||||||||||||
| docker compose -f infra/docker/docker-compose.yml logs -f kafka | ||||||||||||
|
|
||||||||||||
| build: ## Build all Docker images | ||||||||||||
| docker compose -f infra/docker/docker-compose.yml build | ||||||||||||
|
|
||||||||||||
| # ============================================ | ||||||||||||
| # Database | ||||||||||||
| # ============================================ | ||||||||||||
|
|
||||||||||||
| seed: ## Seed dev data (Postgres + Elasticsearch) | ||||||||||||
| cd scripts/seed && bash seed-postgres.sh | ||||||||||||
| cd scripts/seed && bash seed-elasticsearch.sh | ||||||||||||
|
|
||||||||||||
| migrate: ## Run database migrations | ||||||||||||
| go run libs/migrate/migrate.go | ||||||||||||
|
|
||||||||||||
| # ============================================ | ||||||||||||
| # Testing | ||||||||||||
| # ============================================ | ||||||||||||
|
|
||||||||||||
| test: ## Run all tests | ||||||||||||
| $(MAKE) test-go | ||||||||||||
| $(MAKE) test-react | ||||||||||||
|
|
||||||||||||
| test-go: ## Run Go tests | ||||||||||||
| go test ./... | ||||||||||||
|
|
||||||||||||
| test-react: ## Run React/Node tests | ||||||||||||
| cd apps/dashboard && npm test -- --run | ||||||||||||
| cd apps/bff && npm test -- --run | ||||||||||||
|
|
||||||||||||
| test-load: ## Run k6 load tests | ||||||||||||
| k6 run scripts/load-tests/gateway-load-test.js | ||||||||||||
| k6 run scripts/load-tests/bff-load-test.js | ||||||||||||
|
|
||||||||||||
| # ============================================ | ||||||||||||
| # Linting | ||||||||||||
| # ============================================ | ||||||||||||
|
|
||||||||||||
| lint: ## Lint all services | ||||||||||||
| $(MAKE) lint-go | ||||||||||||
| $(MAKE) lint-ts | ||||||||||||
|
|
||||||||||||
| lint-go: ## Lint Go services | ||||||||||||
| golangci-lint run ./... | ||||||||||||
|
|
||||||||||||
| lint-ts: ## Lint TypeScript services | ||||||||||||
| cd apps/gateway && npm run lint | ||||||||||||
| cd apps/bff && npm run lint | ||||||||||||
| cd apps/dashboard && npm run lint | ||||||||||||
|
|
||||||||||||
| # ============================================ | ||||||||||||
| # Cleanup | ||||||||||||
| # ============================================ | ||||||||||||
|
|
||||||||||||
| clean: ## Remove all containers and volumes | ||||||||||||
| docker compose -f infra/docker/docker-compose.yml down -v --remove-orphans | ||||||||||||
|
|
||||||||||||
| clean-cache: ## Clear Redis cache | ||||||||||||
| docker compose -f infra/docker/docker-compose.yml exec redis redis-cli FLUSHALL | ||||||||||||
|
|
||||||||||||
|
Comment on lines
+91
to
+93
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. 🧩 Analysis chain🏁 Script executed: # First, find and verify the Makefile exists
fd -type f -name "Makefile" | head -20Repository: pahuldeepp/GrainGuard- Length of output: 236 🏁 Script executed: # Once located, read the specific lines mentioned (91-93)
cat -n Makefile | sed -n '85,100p'Repository: pahuldeepp/GrainGuard- Length of output: 660 🏁 Script executed: # Check the docker-compose configuration for Redis setup
fd -type f -name "docker-compose.yml" | head -10Repository: pahuldeepp/GrainGuard- Length of output: 236 🏁 Script executed: # Find docker-compose.yml with correct syntax
find . -name "docker-compose.yml" -type fRepository: pahuldeepp/GrainGuard- Length of output: 99 🏁 Script executed: # Look at the docker-compose configuration
cat infra/docker/docker-compose.ymlRepository: pahuldeepp/GrainGuard- Length of output: 14710 🏁 Script executed: # Check if there are any references to Redis DB selection in the codebase
rg "redis|Redis|REDIS" --max-count=20 -iRepository: pahuldeepp/GrainGuard- Length of output: 28172 Use Line 92 executes Fix clean-cache: ## Clear Redis cache
- docker compose -f infra/docker/docker-compose.yml exec redis redis-cli FLUSHALL
+ docker compose -f infra/docker/docker-compose.yml exec redis redis-cli FLUSHDB📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||
| # ============================================ | ||||||||||||
| # Status | ||||||||||||
| # ============================================ | ||||||||||||
|
|
||||||||||||
| ps: ## Show running services | ||||||||||||
| docker compose -f infra/docker/docker-compose.yml ps | ||||||||||||
|
|
||||||||||||
| health: ## Check health of all services | ||||||||||||
| curl -s http://localhost:8086/health | jq | ||||||||||||
| curl -s http://localhost:4000/health | jq | ||||||||||||
|
Comment on lines
+101
to
+103
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. 🧩 Analysis chain🏁 Script executed: # First, locate and examine the Makefile
find . -name "Makefile" -type f | head -5Repository: pahuldeepp/GrainGuard- Length of output: 76 🏁 Script executed: # Once we have the Makefile, check the health target
sed -n '95,110p' MakefileRepository: pahuldeepp/GrainGuard- Length of output: 332 🌐 Web query:
💡 Result:
Common script pattern: curl -fsS https://example.com/resource # silent, show errors, fail (exit!=0) on 4xx/5xxSources: [1] [2] [3] [4] Add Lines 102–103 use Proposed fix health: ## Check health of all services
- curl -s http://localhost:8086/health | jq
- curl -s http://localhost:4000/health | jq
+ curl -fsS --max-time 5 http://localhost:8086/health | jq
+ curl -fsS --max-time 5 http://localhost:4000/health | jq🤖 Prompt for AI Agents |
||||||||||||
| 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); | ||
|
Comment on lines
+49
to
+67
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. Make this cache path tenant-aware or explicitly admin-only.
🤖 Prompt for AI Agents |
||
| 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:") | ||
| } | ||
|
|
||
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.
🧩 Analysis chain
🏁 Script executed:
Repository: pahuldeepp/GrainGuard-
Length of output: 1021
Add a default
alltarget and complete the.PHONYlist.The
.PHONYdeclaration on line 1 is incomplete, missing 12 targets that are actually defined later in the Makefile:logs-gateway,logs-bff,logs-kafka,migrate,test-go,test-react,test-load,lint-go,lint-ts,clean-cache,ps, andhealth. Additionally, thealltarget is missing entirely, which is a standard Makefile convention. These omissions can cause target/file name collisions and potential CI lint failures.Proposed fix
📝 Committable suggestion
🧰 Tools
🪛 checkmake (0.2.2)
[warning] 1-1: Missing required phony target "all"
(minphony)
🤖 Prompt for AI Agents