A production-grade microservices system for real-time household energy monitoring. Ingests device readings at 606 msg/s peak, processes them through a 3-stage Kafka pipeline with 1.09M+ messages, persists to InfluxDB (129K+ data points, 155 MB), classifies WARNING/CRITICAL alerts via 1-hour rolling aggregations, and delivers billing projections through async email — all observable via Prometheus + Grafana with 4 dashboards, 19 alert rules, and p95 < 27 ms HTTP latency at 100% success rate.
- Architecture
- Services
- Tech Stack
- Kafka Pipeline
- Observability
- Load Testing Results
- JVM Metrics Under Load
- InfluxDB Data
- Getting Started
- API Reference
- Access Points
- Project Structure
- Key Design Decisions
┌─────────────────────────────────────────────────────────────────────┐
│ CLIENT / POSTMAN │
└────────────────────────────┬────────────────────────────────────────┘
│ HTTP
▼
┌─────────────────────────────────────────────────────────────────────┐
│ API GATEWAY :9000 │
│ Spring Cloud Gateway · Circuit Breaker (Resilience4j) │
│ OAuth2 Resource Server (Keycloak JWT) │
└───┬──────────────┬───────────────┬──────────────┬───────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ user │ │ device │ │ingestion │ │ insight │
│ :8080 │ │ :8081 │ │ :8082 │ │ :8085 │
└────┬───┘ └────┬─────┘ └────┬─────┘ └──────────┘
│ │ │
▼ ▼ │ Kafka Producer
┌─────────────────────┐ │ topic: energy-usage
│ PostgreSQL │ │ (5 partitions, KRaft)
│ home_energy_tracker│ ▼
└─────────────────────┘ ┌─────────────────────────────┐
│ usage-service :8083 │
│ Kafka Consumer (5 threads) │
│ Synchronous InfluxDB write │
│ Scheduled 5s Flux query │
│ WARNING/CRITICAL classifier │
└──────┬──────────┬────────────┘
│ │
▼ ▼
┌──────────┐ Kafka Producer
│ InfluxDB │ topic: energy-alerts
│ :8072 │ (5 partitions)
│ 129K pts │ │
│ 155 MB │ ▼
└──────────┘ ┌─────────────────────┐
│ alert-service :8084 │
│ Kafka Consumer │
│ 5 concurrent threads│
│ Async email (SMTP) │
│ 10,599 alerts sent │
└──────────┬───────────┘
│
▼
┌─────────────┐
│ Mailpit │
│ :8025 │
└─────────────┘
| Diagram | Preview |
|---|---|
| Full Microservices Flow | ![]() |
| Circuit Breaker (API Gateway) | ![]() |
| Network Separation | ![]() |
| Observability Stack | ![]() |
| Background & Requirements | ![]() |
| Service | Port | Responsibility |
|---|---|---|
| api-gateway | 9000 |
Single entry point — routing, circuit breaking, JWT validation |
| user-service | 8080 |
User accounts, alerting preferences, energy thresholds |
| device-service | 8081 |
Device registry — CRUD for smart meters and plugs |
| ingestion-service | 8082 |
Accept energy readings via HTTP, produce to Kafka energy-usage |
| usage-service | 8083 |
Consume readings → write to InfluxDB → aggregate → produce alerts |
| alert-service | 8084 |
Consume alerts → send WARNING/CRITICAL email with billing projection |
| insight-service | 8085 |
AI-powered usage insights via Spring AI + Ollama (optional) |
| complaint-service | 8086 |
Complaint management with parallel S3 multi-file upload (AWS Mumbai) |
| Category | Technology |
|---|---|
| Language | Java 21 |
| Framework | Spring Boot 4.0 (all services), Spring Boot 3.5 (insight-service) |
| Messaging | Apache Kafka (KRaft mode, no ZooKeeper), 5 partitions per topic |
| Time-series DB | InfluxDB 2.x (Flux query language), 1-week retention |
| Relational DB | PostgreSQL (users, devices, alerts) |
| Cloud Storage | AWS S3 (ap-south-1) — complaint file uploads |
| API Gateway | Spring Cloud Gateway Server WebMVC + Resilience4j circuit breakers |
| Security | Keycloak (OAuth2/JWT), Spring Security OAuth2 Resource Server |
| Observability | Micrometer + Prometheus + Grafana (4 dashboards, 19 alert rules) |
| Email (dev) | Mailpit (SMTP trap, web UI at :8025) |
| Containerization | Docker + Docker Compose |
| Build | Maven (per-service mvnw wrapper) |
| AI/ML | Spring AI + Ollama (insight-service) |
The core data flow is a 3-stage Kafka pipeline running in KRaft mode (no ZooKeeper):
ingestion-service usage-service alert-service
│ │ │
│ POST /api/v1/ingestion │ │
│ (built-in mock simulator) │ │
│ ──────────────────► │ │
│ │ │
│ produce → energy-usage │ │
│ ═══════════════════════════► │ │
│ (5 partitions) │ consume (5 threads) │
│ RoundRobin │ sync InfluxDB write │
│ │ manual Kafka ack │
│ │ scheduled 5s Flux query │
│ │ threshold classification │
│ │ │
│ │ produce → energy-alerts │
│ │ ════════════════════════► │
│ │ (5 partitions) │ consume (5 threads)
│ │ │ async email send
│ │ │ PostgreSQL save
All tests run locally on a single machine with the full Docker Compose stack running (Kafka, InfluxDB, PostgreSQL, Mailpit, Prometheus, Grafana) plus all 5 microservices on the host JVM.
| Metric | Value |
|---|---|
| Peak ingestion rate | 606 req/s |
| Mean ingestion rate | 285 req/s |
Total Kafka messages (energy-usage) |
1,091,258 |
Total Kafka messages (energy-alerts) |
10,599 |
energy-usage topic size |
155 MB |
energy-alerts topic size |
4 MB |
| Ingestion success rate | 100% (0 failed) |
| Total failed messages | 0 |
| HTTP P95 latency (ingestion endpoint) | 26.135 ms |
| HTTP P50 latency (ingestion endpoint) | < 1 ms |
| HTTP status 200 peak rate | 606 req/s |
| HTTP status 201 peak rate | 1.06K req/s |
| InfluxDB data points written | 129,500+ |
| InfluxDB total stored data | 155 MB |
| Kafka partitions per topic | 5 |
| Kafka partition strategy | Round-robin |
| Usage-service consumer threads | 5 concurrent |
| Alert-service consumer threads | 5 concurrent |
| Kafka ack mode | Manual (after InfluxDB write) |
| Alert cooldown per user | 1 hour |
600 ┤ ╭╮
500 ┤ ╭╯╰╮
400 ┤ ╭╯ ╰╮
300 ┤ ╭─────╯ ╰────── 285 avg
200 ┤ ╭────╯
100 ┤ ╭────╯
0 ┼──╯
t=0 t+5m t+10m t+15m
Usage consumed (1hr rolling window) > user threshold
└── < 1.5x threshold → WARNING alert → email via Mailpit
└── ≥ 1.5x threshold → CRITICAL alert → email via Mailpit
└── cooldown active (< 1hr since last alert) → skipped
10,599 alerts triggered across the full test run, stored in both Kafka (energy-alerts) and PostgreSQL.
All uploads went to AWS S3 (ap-south-1) with parallel processing via CompletableFuture + parallelStream.
| # | Scenario | Users | Files | Total Payload | Requests | Error % | API Response |
|---|---|---|---|---|---|---|---|
| 1 | Single user, bulk payload | 1 | 307 files | ~91 MB | 1 | 0% | 5.17 s |
| 2 | Single user, 300 images | 1 | 300 images | ~50 MB | 300 | 0% | sub-2 s each |
| 3 | Multi-user JMeter load test | 5 users | 2 files each | — | 1,450 | 0% | 40 ms median |
| Metric | Value |
|---|---|
| Total Requests | 1,450 |
| Error Rate | 0.00% |
| Average Response Time | 250 ms |
| Median (P50) | 40 ms |
| P90 | 396 ms |
| P95 | 1,429 ms |
| P99 | 4,119 ms |
| Max Response Time | 5,688 ms |
| Throughput | 5.9 req/s |
Measured on usage-service during the 1.09M+ message load test. usage-service is the highest-pressure service due to its @Scheduled(fixedRate=5000) aggregation loop creating short-lived objects every 5 seconds.
| Metric | Value | Health |
|---|---|---|
| Heap Used | ~57 MB | Very good |
| Heap Max | ~2 GB | Plenty of headroom |
| Non-Heap (Metaspace + CodeCache) | ~90 MB | Normal |
| Heap Used % | ~3% | Excellent |
| Metric | Value | Health |
|---|---|---|
| GC Type | G1GC (default JDK 21) | — |
| Minor GC frequency | ~1.3 / min | Normal |
| GC Pause Time | ~0.09 ms avg | Excellent |
| GC Pause P95 | < 1 ms | Excellent |
| GC Pause P99 | < 5 ms | Good |
| Heap Allocation Rate | spikes every 5s (scheduler) | Expected |
| Memory Promoted to Old Gen | minimal | No leak detected |
The 5-second spikes in allocation rate directly correlate with
aggregateDeviceEnergyUsage()— it createsList<FluxTable>,List<DeviceEnergy>,Map<Long, List<...>>per scheduler tick. All are short-lived and cleaned up by Minor GC without promoting to Old Gen.
| Metric | Value | Health |
|---|---|---|
| Live Threads | ~50 | Normal |
| Daemon Threads | ~35 | Normal |
| Blocked Threads | 0 | Excellent |
| Peak Threads | ~55 | Normal |
| Kafka Consumer Threads | 5 | Configured |
| Metric | Value | Health |
|---|---|---|
| Active Connections | 1–3 | Normal |
| Idle Connections | 7–9 | Normal |
| Pending Connections | 0 | Excellent |
| P95 Acquisition Time | < 5 ms | Good |
Four production-grade Grafana dashboards auto-provisioned on startup via /docker/grafana/provisioning/.
System Health row
- Services Online (stat, green/yellow/red threshold)
- Services Offline (stat, green/red)
- Total HTTP Req/s all services (stat)
- 5xx Error Rate (stat)
- P95 Latency all services (stat)
- Total JVM Heap Used (stat)
Service Up/Down Status row
- Per-service UP/DOWN status (stat, horizontal, color-coded)
HTTP Traffic row
- HTTP Request Rate by Service (timeseries)
- HTTP Request Rate by Status Code (timeseries, 2xx green / 4xx yellow / 5xx red)
- P50/P95/P99 Latency all services (timeseries)
- P95 Latency per Service (timeseries)
JVM Memory & CPU row
- JVM Heap Used per Service (timeseries)
- JVM Heap Used vs Max per Service (bar gauge, horizontal, 70%/85% thresholds)
- CPU Usage per Service — system + process (timeseries)
- JVM GC Pause Time per Service (timeseries)
Threads & DB Connections row
- JVM Live Threads per Service (timeseries)
- HikariCP Connection Pool — active / idle / pending (timeseries, color-coded)
Top Endpoints row
- Top 10 Busiest Endpoints (table, sorted by req/s)
- Top 10 Slowest Endpoints P95 (table, sorted by latency)
GC Overview (all services) row
- GC Pause Time / s (timeseries)
- GC Collections / min (timeseries)
- Heap Allocation Rate bytes/s (timeseries)
usage-service GC Spotlight row
- Heap Used vs Max (timeseries, max shown as red dashed line)
- GC Pause by action + cause (timeseries)
- Memory Promoted to Old Gen vs Allocated (timeseries)
- Service dropdown variable (all services)
- Request rate by HTTP method (GET/POST/PUT/DELETE)
- 4xx and 5xx breakdown by endpoint
- Latency heatmap (P50/P95/P99)
- JVM heap vs non-heap, buffer pools (direct/mapped)
- GC collections/min and pause duration
- Thread states (runnable / waiting / blocked — blocked in red)
- HikariCP P95 acquisition time
Ingestion Scorecards
- Total Messages Sent (counter)
- Total Failed Messages (counter, red if > 0)
- Ingestion Rate ms (stat)
- Success Rate % (stat, green at 100%)
- Ingestion Service HTTP req/s (stat)
- Ingestion P95 Latency ms (stat)
Ingestion Throughput
- Success vs Failed message rate (timeseries)
- Cumulative Messages Sent (timeseries, total counter)
- HTTP Rate by Status Code (timeseries)
- Ingestion Latency P50/P95/P99 (timeseries)
Usage-service & Alert-service panels
- Usage-service GC pressure and blocked threads
- Alert-service heap used vs max (with threshold line)
- All-services request rate comparison
- All-services CPU comparison
GC Scorecards row
- GC Pause Time avg (stat, thresholds at 50ms/200ms)
- GC Collections / min (stat, thresholds at 10/30)
- Heap Allocation Rate bytes/s (stat, thresholds at 5MB/20MB)
- Memory Promoted to Old Gen / s (stat)
- Heap Used % (gauge, thresholds at 70%/85%)
- Non-Heap (Metaspace) Used (stat, threshold at 128MB)
GC Pause Analysis row
- GC Pause Time by action & cause (timeseries)
- GC Collections / min by action (timeseries)
- GC Pause P99 stop-the-world (timeseries)
- GC Pause P50/P95/P99 combined (timeseries)
Heap Memory — Allocation & Promotion row
- Heap Used vs Max (timeseries, max as red dashed)
- Allocation Rate vs Promotion Rate (timeseries, promoted in orange)
- Heap Memory Pool Breakdown — used per pool (timeseries)
- Non-Heap — Metaspace / CodeCache Used (timeseries)
usage-service Scheduler GC Correlation row
- GC Pause + Allocation Rate dual-axis (correlates 5s scheduler spikes)
- Heap % gauge (green/yellow/red)
Threads & Buffer Pools row
- Thread States — blocked/runnable/waiting (timeseries, color-coded)
- Buffer Pools — direct/mapped used vs capacity (timeseries)
- Loaded vs Unloaded Classes (timeseries)
- Live / Daemon / Peak Threads (timeseries)
All rules in docker/prometheus/alert-rules.yml, evaluated every 15 seconds.
| Alert | Expression | For | Severity |
|---|---|---|---|
ServiceDown |
up == 0 |
30s | critical |
ServiceRestartDetected |
increase(process_uptime_seconds[2m]) < 0 |
0s | warning |
| Alert | Expression | Threshold | For | Severity |
|---|---|---|---|---|
HighHttpErrorRate5xx |
rate(http_server_requests_seconds_count{status=~"5.."}[2m]) |
> 0.5 req/s | 1m | critical |
ElevatedHttpErrorRate4xx |
rate(http_server_requests_seconds_count{status=~"4.."}[2m]) |
> 5 req/s | 2m | warning |
HighP95Latency |
histogram_quantile(0.95, ...) |
> 2s | 2m | warning |
CriticalP99Latency |
histogram_quantile(0.99, ...) |
> 5s | 2m | critical |
ZeroRequestRate |
rate(http_server_requests_seconds_count[5m]) == 0 and up == 1 |
— | 3m | warning |
| Alert | Expression | Threshold | For | Severity |
|---|---|---|---|---|
HighJvmHeapUsage |
jvm_memory_used_bytes / jvm_memory_max_bytes |
> 80% | 2m | warning |
CriticalJvmHeapUsage |
jvm_memory_used_bytes / jvm_memory_max_bytes |
> 92% | 1m | critical |
FrequentGCPauses |
rate(jvm_gc_pause_seconds_count[2m]) * 60 |
> 10/min | 2m | warning |
LongGCPauseDuration |
rate(jvm_gc_pause_seconds_sum[2m]) |
> 0.3 s/s | 2m | warning |
| Alert | Expression | Threshold | For | Severity |
|---|---|---|---|---|
HighBlockedThreadCount |
jvm_threads_states_threads{state="blocked"} |
> 10 | 1m | warning |
ThreadCountSpike |
jvm_threads_live_threads |
> 200 | 2m | warning |
| Alert | Expression | Threshold | For | Severity |
|---|---|---|---|---|
HikariConnectionPoolExhausted |
hikaricp_connections_pending |
> 0 | 30s | warning |
HikariConnectionPoolCritical |
hikaricp_connections_pending |
> 5 | 30s | critical |
HighConnectionAcquisitionTime |
histogram_quantile(0.95, hikaricp_connections_acquire_seconds_bucket) |
> 0.5s | 2m | warning |
| Alert | Expression | Threshold | For | Severity |
|---|---|---|---|---|
IngestionStalled |
rate(ingestion_messages_success_total[3m]) == 0 and up == 1 |
— | 3m | critical |
IngestionFailureDetected |
rate(ingestion_messages_failed_total[1m]) |
> 0 | 30s | warning |
IngestionHighErrorRatio |
failed / (success + failed) |
> 5% | 2m | critical |
Registered at startup via MeterRegistry in UsageService:
| Metric Name | Type | Description |
|---|---|---|
usage.events.consumed |
Counter | Total energy-usage Kafka events consumed |
usage.influx.writes |
Counter | Total points written to InfluxDB |
usage.alerts.produced |
Counter | Total alerts published to energy-alerts |
usage.alerts.warning |
Counter | Total WARNING-level alerts |
usage.alerts.critical |
Counter | Total CRITICAL-level alerts |
All exposed at /actuator/prometheus and scraped by Prometheus every 15s.
All services scraped at /actuator/prometheus, interval 15s:
| Job | Target |
|---|---|
user-service |
host.docker.internal:8080 |
device-service |
host.docker.internal:8081 |
ingestion-service |
host.docker.internal:8082 |
usage-service |
host.docker.internal:8083 |
alert-service |
host.docker.internal:8084 |
insight-service |
host.docker.internal:8085 |
api-gateway |
host.docker.internal:9000 |
Services run on the host; Prometheus runs in Docker. host.docker.internal resolves to the host machine via extra_hosts in docker-compose.yml.
- Bucket:
usage-bucket - Org:
leetjourney - Retention: 1 week
- Measurement:
energy_usage - Tags:
deviceId - Fields:
energyConsumed(double, watts)
Total records stored:
from(bucket: "usage-bucket")
|> range(start: -7d)
|> filter(fn: (r) => r["_measurement"] == "energy_usage")
|> filter(fn: (r) => r["_field"] == "energyConsumed")
|> count()
|> sum(column: "_value")
Records per device:
from(bucket: "usage-bucket")
|> range(start: -7d)
|> filter(fn: (r) => r["_measurement"] == "energy_usage")
|> filter(fn: (r) => r["_field"] == "energyConsumed")
|> group(columns: ["deviceId"])
|> count()
Distinct device count:
from(bucket: "usage-bucket")
|> range(start: -7d)
|> filter(fn: (r) => r["_measurement"] == "energy_usage")
|> keep(columns: ["deviceId"])
|> distinct(column: "deviceId")
|> count()
1-hour rolling sum per device (same query the scheduler uses):
from(bucket: "usage-bucket")
|> range(start: -1h)
|> filter(fn: (r) => r["_measurement"] == "energy_usage")
|> filter(fn: (r) => r["_field"] == "energyConsumed")
|> group(columns: ["deviceId"])
|> sum(column: "_value")
- JDK 21
- Docker and Docker Compose
- Maven (optional — each service has
./mvnw)
git clone https://github.com/zexxitywave/home-energy-tracker.git
cd home-energy-trackerdocker compose up -dStarts: Kafka (KRaft), PostgreSQL, InfluxDB, Mailpit, Kafka UI, Prometheus, Grafana.
Wait ~30 seconds for all containers to be healthy.
cd user-service && ./mvnw -q package -DskipTests && cd ..
cd device-service && ./mvnw -q package -DskipTests && cd ..
cd ingestion-service && ./mvnw -q package -DskipTests && cd ..
cd usage-service && ./mvnw -q package -DskipTests && cd ..
cd alert-service && ./mvnw -q package -DskipTests && cd ..Start each in a separate terminal (or via IntelliJ Services panel):
cd user-service && ./mvnw spring-boot:run # terminal 1
cd device-service && ./mvnw spring-boot:run # terminal 2
cd ingestion-service && ./mvnw spring-boot:run # terminal 3 — mock simulator starts automatically
cd usage-service && ./mvnw spring-boot:run # terminal 4
cd alert-service && ./mvnw spring-boot:run # terminal 5Services connect to Kafka on
localhost:9094(external listener). Docker Compose must be running first.
# Send a test reading directly to ingestion-service (no JWT needed)
curl -X POST http://localhost:8082/api/v1/ingestion \
-H 'Content-Type: application/json' \
-d '{"deviceId":1,"timestamp":"2026-01-01T12:00:00Z","energyConsumed":1.5}'- Kafka UI → http://localhost:8070 — messages in
energy-usagetopic - InfluxDB → http://localhost:8072 — query
usage-bucketforenergy_usage - Mailpit → http://localhost:8025 — alert emails appear when threshold exceeded
- Grafana → http://localhost:3000 — live dashboards
Lower a user's threshold to trigger an alert within the next 5-second scheduler tick:
UPDATE users SET energy_alerting_threshold = 100, alerting = true WHERE id = 1;Then check Mailpit at http://localhost:8025 for the WARNING/CRITICAL email.
curl http://localhost:8082/api/v1/ingestion/stats{
"totalSent": 1091258,
"successCount": 1091258,
"failedCount": 0,
"successRate%": 100
}| Method | Endpoint | Description |
|---|---|---|
POST |
/api/v1/user |
Create user |
GET |
/api/v1/user/{id} |
Get user by ID |
PUT |
/api/v1/user/{id} |
Update user |
DELETE |
/api/v1/user/{id} |
Delete user |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/v1/device/create |
Register a device |
GET |
/api/v1/device/{id} |
Get device by ID |
GET |
/api/v1/device/user/{userId} |
Get all devices for user |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/v1/ingestion |
Submit energy reading |
GET |
/api/v1/ingestion/stats |
Get ingestion statistics |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/usage/{userId}?days=7 |
Get usage data for user (N days) |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/alerts/{userId} |
Get alert history for user |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/v1/complaint |
Submit complaint with file attachments (multipart) |
GET |
/api/v1/complaint/{userId} |
Get complaints for user |
| Service | URL | Credentials |
|---|---|---|
| Grafana | http://localhost:3000 | admin / admin |
| Prometheus | http://localhost:9091 | — |
| Kafka UI | http://localhost:8070 | — |
| Mailpit | http://localhost:8025 | — |
| InfluxDB | http://localhost:8072 | token: my-token |
| Keycloak | http://localhost:8091 | admin / admin |
| API Gateway | http://localhost:9000 | JWT required |
home-energy-tracker/
├── docker-compose.yml
├── docker/
│ ├── prometheus/
│ │ ├── prometheus.yml # 7 scrape targets, 15s interval
│ │ └── alert-rules.yml # 19 alert rules across 6 groups
│ ├── grafana/
│ │ └── provisioning/
│ │ ├── datasources/ # Prometheus datasource
│ │ └── dashboards/
│ │ ├── dashboards.yml
│ │ └── json/
│ │ ├── het-overview.json # 33 panels
│ │ ├── het-service-health.json # per-service deep dive
│ │ ├── het-kafka-pipeline.json # ingestion pipeline
│ │ └── het-jvm-gc.json # 25-panel GC deep dive
│ ├── mysql/init.sql
│ └── keycloak/
├── diagrams/
├── user-service/
├── device-service/
├── ingestion-service/
├── usage-service/
├── alert-service/
├── complaint-service/
├── insight-service/
├── api-gateway/
└── AGENTS.md
Kafka KRaft (no ZooKeeper) — Single broker process, no separate quorum to manage in local dev.
InfluxDB for usage data — Time-series readings every second per device are a poor fit for relational storage. Flux makes 1-hour rolling aggregations and group by deviceId trivial.
Manual Kafka ack after InfluxDB write — Consumer only acknowledges a message after the InfluxDB write succeeds. If InfluxDB is down, no ack → Kafka redelivers → zero data loss.
Round-robin partitioning — With 5 partitions and 5 consumer threads, round-robin keeps all threads busy. Key-based partitioning caused all traffic to land on one partition, leaving 4 threads idle.
Async email in alert-service — SMTP calls wrapped in @Async with a dedicated thread pool so Kafka consumer threads are never blocked waiting on email delivery.
1-hour alert cooldown per user — Prevents email flooding when a user stays above threshold for extended periods. Tracked in a ConcurrentHashMap<Long, Instant> in-memory.
5-second aggregation scheduler — Queries InfluxDB for the last hour of data every 5 seconds, groups by deviceId, sums per user, then compares against per-user thresholds. Short-lived objects created per tick are all cleaned by Minor GC (~0.09 ms pause).
- Frontend SPA — real-time energy charts, alert history, device management
- Kubernetes deployment — Helm charts, HPA, external secrets
- End-to-end tests — contract tests across gateway → services → Kafka → DB
- Multi-tenant support — per-household isolation and billing
- WebSocket push — real-time alert delivery without polling
- Centralized config — Spring Cloud Config or Vault for secrets
Built with Spring Boot 4, Java 21, and the full modern microservices stack.
The entry point of the pipeline. Accepts energy readings via HTTP and publishes to Kafka. Intentionally thin — no DB write, no business logic.
Starts automatically on boot via CommandLineRunner. Fires HTTP requests to its own endpoint using a newCachedThreadPool:
simulation.endpoint=http://localhost:8082/api/v1/ingestion
simulation.requests-per-interval=200
simulation.interval-ms=1000
simulation.parallel-threads=200200 threads fire every 1000ms = 200 req/s sustained. Peak recorded: 606 req/s.
Default new RestTemplate() opens a new TCP socket per request. At 200 threads this exhausts Windows ephemeral ports (Address already in use). Fixed by using PoolingHttpClientConnectionManager with 300 max connections — same sockets reused across all threads.
Without handling, Kafka producer buffer contents are lost on JVM exit. On shutdown:
@PreDestroycallskafkaTemplate.flush()— forces all buffered messages to Kafkaspring.lifecycle.timeout-per-shutdown-phase=10sgives Spring 10s to completedelivery.timeout.ms=35000— must be >request.timeout.ms(30s default) or Kafka throwsConfigExceptionat startup
[ SHUTDOWN ] Flushing Kafka producer buffer...
[ SHUTDOWN ] Final stats — Published: 335842 | Failed: 0
Every 20 seconds:
[ INGESTION STATS ] Published: 45000 | Failed: 0
| Metric | Type | Description |
|---|---|---|
ingestion.messages.success |
Counter | Total Kafka messages successfully sent |
ingestion.messages.failed |
Counter | Total Kafka messages that failed |
spring.kafka.producer.properties.linger.ms=0
spring.kafka.producer.properties.delivery.timeout.ms=35000
spring.lifecycle.timeout-per-shutdown-phase=10sThe core processing service. Consumes from energy-usage, writes to InfluxDB, aggregates per user, and produces alerts.
- 5 consumer threads (
spring.kafka.listener.concurrency=5) - Manual acknowledgment —
ack.acknowledge()called only after InfluxDB write succeeds - If InfluxDB is down → no ack → Kafka redelivers → zero data loss
- Uses
writeApiBlocking()— thread blocks until InfluxDB responds (guaranteed delivery)
influxDBClient.getWriteApiBlocking().writePoint(bucket, org, point);
ack.acknowledge(); // only after successful writeEvery Kafka message writes one point to the energy_usage measurement:
- Tag:
deviceId - Field:
energyConsumed(double, watts) - Timestamp: current time, millisecond precision
Runs every 5 seconds. Queries InfluxDB for the last 1 hour of data per device, groups by user, sums total consumption, compares against threshold:
from(bucket: "usage-bucket")
|> range(start: -1h)
|> filter(fn: (r) => r["_measurement"] == "energy_usage")
|> filter(fn: (r) => r["_field"] == "energyConsumed")
|> group(columns: ["deviceId"])
|> sum(column: "_value")
totalConsumption > threshold × 1.5 → CRITICAL
totalConsumption > threshold → WARNING
totalConsumption ≤ threshold → "User X within threshold" (no alert)
1-hour cooldown per user tracked in ConcurrentHashMap<Long, Instant>. Prevents email flooding when a user stays above threshold. Map is in-memory — resets on service restart.
private final Map<Long, Instant> lastAlertTime = new ConcurrentHashMap<>();
private static final long ALERT_COOLDOWN_SECONDS = 3600;Important: If alerts stop firing unexpectedly, restart usage-service to clear the cooldown map.
| Metric | Type | Description |
|---|---|---|
usage.events.consumed |
Counter | Total energy-usage Kafka events consumed |
usage.influx.writes |
Counter | Total InfluxDB points written |
usage.alerts.produced |
Counter | Total alerts sent to energy-alerts topic |
usage.alerts.warning |
Counter | Total WARNING alerts |
usage.alerts.critical |
Counter | Total CRITICAL alerts |
Every 5-second scheduler tick creates and discards:
List<FluxTable>— entire InfluxDB result setList<DeviceEnergy>— one per deviceMap<Long, List<DeviceEnergy>>— grouping mapMap<Long, Double>— threshold mapAlertingEvent— one per alert
All cleaned by Minor GC (~0.09ms pause, ~1.3/min). The only long-lived object is lastAlertTime map — grows with user count, never shrinks.
Consumes from energy-alerts, sends email via SMTP, persists alert to PostgreSQL.
- 5 concurrent consumer threads
- All 5 partitions of
energy-alertsassigned (one per thread) - Auto-offset — resumes from last committed offset on restart
Uses Spring Mail with Mailpit (dev SMTP trap) or Resend (production):
spring.mail.host=smtp.resend.com
spring.mail.port=465
spring.mail.password=${RESEND_API_KEY}Email content includes:
- Alert level (WARNING / CRITICAL)
- Device names that triggered alert
- Total energy consumed (W and kWh)
- Estimated cost (₹)
- Projected monthly cost
@Async with dedicated thread pool (email-async-*) — Kafka consumer threads never block on SMTP delivery. All 5 consumer threads stay free to poll messages.
Alert-service only consumes what usage-service produces. If energy-alerts topic has 0 new messages it simply waits. Check:
- Is usage-service running?
- Do users exist with
alerting=true? - Is user threshold low enough to be crossed?
- Is the 1-hour cooldown active? (restart usage-service to reset)
docker stop influxdb
# wait 30-60 seconds
docker start influxdbResult:
- usage-service:
InfluxDB write failed for deviceId=X, will retry - Kafka consumer lag builds (messages not acked)
- On InfluxDB restart: consumer catches up, lag drains to 0
- Zero data loss — manual ack design proved
docker stop kafka
# wait, then restart
docker start kafkaResult:
- ingestion-service:
Bootstrap broker localhost:9094 disconnected TimeoutException: Topic energy-usage not present in metadata after 60000ms- HTTP endpoint returns 500 during Kafka downtime
- On Kafka restart: producer auto-reconnects (
Rebootstrapping) - usage-service consumer rebalances and resumes
Result (before graceful shutdown fix):
- 3K-5K messages lost in producer buffer
- Grafana: red spike on failed counter at exact shutdown moment
Expiring 111 record(s): 120002ms has passed since batch creation
Result (after graceful shutdown fix):
kafkaTemplate.flush()drains buffer before JVM exit- Failed count stays at 0
[ SHUTDOWN ] Final stats — Published: 335842 | Failed: 0
Deliberately added a static List<LeakyHolder> accumulating every event with a 10KB padding array per entry:
200 msg/s × 10KB = 2 MB/s leak rate
512 MB heap ÷ 2 MB/s = ~4 minutes to OOM
Observed on Grafana JVM GC Deep Dive:
- G1 Old Gen: climbed linearly from ~70MB to 512MB
- Heap allocation rate: 20-35 MB/s (vs normal ~12 MB/s)
- GC pause time: increased from 0.09ms to 200ms+ as Full GCs triggered
- Service crashed with
OutOfMemoryError: Java heap space
Recovery:
- Remove leak code, restart service
- Old Gen drops back to ~70MB within 2 GC cycles
- GC pause returns to ~0.09ms
At 200 threads with new RestTemplate():
- Each request opens new TCP socket, closes after response
- Windows keeps closed sockets in
TIME_WAITfor 120 seconds - At 200 req/s: ~24,000 ports in TIME_WAIT after 2 minutes
- Result:
Address already in use: connect
Fix: PoolingHttpClientConnectionManager with 300 max connections — reuses existing TCP connections.
| ID | Name | Threshold | Devices | |
|---|---|---|---|---|
| 13 | Jagdish Kumar | jagdish.kumar@gmail.com | 50W | 5 |
| 14 | Priya Sharma | priya.sharma@gmail.com | 50W | 4 |
| 15 | Rahul Verma | rahul.verma@gmail.com | 50W | 5 |
| 16 | Sneha Patel | sneha.patel@gmail.com | 50W | 4 |
| 17 | Amit Singh | amit.singh@gmail.com | 50W | 5 |
| 18 | Kavya Reddy | kavya.reddy@gmail.com | 50W | 4 |
| 19 | Arjun Nair | arjun.nair@gmail.com | 50W | 5 |
| 20 | Divya Iyer | divya.iyer@gmail.com | 50W | 4 |
| 21 | Rohan Gupta | rohan.gupta@gmail.com | 50W | 5 |
| 22 | Meera Joshi | meera.joshi@gmail.com | 50W | 4 |
Device types allowed by DB constraint: THERMOSTAT, LIGHT, LOCK, CAMERA, DOORBELL, SPEAKER
4-5 devices per user covering: thermostat, smart light, security camera, smart lock, smart speaker/doorbell.
# Simulator sends data for device IDs 56-105
# random.nextInt(50) + 56-- Lower all thresholds to force alerts
UPDATE users SET energy_alerting_threshold = 50, alerting = true;
-- Verify data
SELECT u.id, u.name, u.energy_alerting_threshold, COUNT(d.id) as devices
FROM users u
LEFT JOIN device d ON d.user_id = u.id
GROUP BY u.id, u.name, u.energy_alerting_threshold
ORDER BY u.id;Seed data SQL: docker/postgres/seed-data.sql
| Symptom | Cause | Fix |
|---|---|---|
Failed to fetch device X in usage-service |
Old device IDs in InfluxDB, device deleted from DB | Wait 1 hour for old data to drop out of query window |
Aggregated device energies: [] |
device-service not running or devices not found | Start DeviceServiceApplication, verify devices exist in DB |
| No alerts firing despite high consumption | 1-hour cooldown active | Restart usage-service to clear lastAlertTime map |
Topic energy-usage not present after 60000ms |
Kafka is down | docker start kafka |
Address already in use: connect |
Port exhaustion from new socket per request | Already fixed with connection pooling |
delivery.timeout.ms should be >= linger.ms + request.timeout.ms |
delivery.timeout too low | Set to 35000ms (> default request.timeout 30000ms) |
ConfigException on ingestion-service startup |
Kafka producer misconfiguration | Check application.properties producer properties |
Grafana context canceled |
Service crashed (OOM or otherwise), Prometheus can't scrape | Restart the crashed service |
| Alert service idle, 0 messages consumed | No alerts produced by usage-service | Check user thresholds, cooldown, device-service health |




