From 79e591f86447bc85a61216bf8713e6df396b2196 Mon Sep 17 00:00:00 2001 From: lingniu Date: Fri, 3 Jul 2026 17:55:22 +0800 Subject: [PATCH] feat(go): add 100k ingest hardening baseline --- .../tdengine-batch-writer-design.md | 65 +++ docs/ops/100k-capacity-baseline.md | 105 ++++ docs/ops/go-vehicle-ingest-memory.md | 2 +- docs/ops/vehicle-ingest-runbook.md | 19 + ...026-07-03-100k-vehicle-ingest-hardening.md | 546 ++++++++++++++++++ go/vehicle-gateway/cmd/gateway/main.go | 2 +- go/vehicle-gateway/cmd/gateway/main_test.go | 10 + go/vehicle-gateway/cmd/load-sim/main.go | 45 ++ go/vehicle-gateway/cmd/load-sim/main_test.go | 28 + .../cmd/realtime-api/main_test.go | 29 + .../internal/gateway/tcp_server.go | 11 + .../internal/gateway/tcp_server_test.go | 24 + go/vehicle-gateway/internal/loadsim/config.go | 82 +++ .../internal/loadsim/config_test.go | 73 +++ go/vehicle-gateway/internal/loadsim/runner.go | 103 ++++ .../internal/loadsim/runner_test.go | 73 +++ .../internal/loadsim/schedule.go | 18 + .../internal/loadsim/schedule_test.go | 24 + .../internal/loadsim/template_test.go | 50 ++ .../internal/loadsim/templates.go | 53 ++ 20 files changed, 1360 insertions(+), 2 deletions(-) create mode 100644 docs/architecture/tdengine-batch-writer-design.md create mode 100644 docs/ops/100k-capacity-baseline.md create mode 100644 docs/superpowers/plans/2026-07-03-100k-vehicle-ingest-hardening.md create mode 100644 go/vehicle-gateway/cmd/load-sim/main.go create mode 100644 go/vehicle-gateway/cmd/load-sim/main_test.go create mode 100644 go/vehicle-gateway/internal/loadsim/config.go create mode 100644 go/vehicle-gateway/internal/loadsim/config_test.go create mode 100644 go/vehicle-gateway/internal/loadsim/runner.go create mode 100644 go/vehicle-gateway/internal/loadsim/runner_test.go create mode 100644 go/vehicle-gateway/internal/loadsim/schedule.go create mode 100644 go/vehicle-gateway/internal/loadsim/schedule_test.go create mode 100644 go/vehicle-gateway/internal/loadsim/template_test.go create mode 100644 go/vehicle-gateway/internal/loadsim/templates.go diff --git a/docs/architecture/tdengine-batch-writer-design.md b/docs/architecture/tdengine-batch-writer-design.md new file mode 100644 index 00000000..4b79329c --- /dev/null +++ b/docs/architecture/tdengine-batch-writer-design.md @@ -0,0 +1,65 @@ +# TDengine Batch Writer Design + +更新时间:2026-07-03 + +## Problem + +`history-writer` 当前按 Kafka 消息逐条处理,并对每个 raw frame/location 调用 TDengine 写入。这个路径简单、可恢复,但在 10,000+ FPS 后会优先暴露三个瓶颈: + +- 每帧一次 SQL/网络往返,吞吐上限低。 +- Kafka offset 逐条提交,commit 开销随帧率线性增长。 +- burst 流量下无法用批量 flush 平滑 TDengine 写入压力。 + +## Target + +按 topic/protocol/目标表分组批量写 TDengine,按数量或时间触发 flush。 + +初始建议: + +| 参数 | 初始值 | +| --- | --- | +| raw frame batch size | 200 | +| location batch size | 500 | +| flush interval | 100ms | +| operation timeout | 5s | +| max SQL payload | 8MB guardrail | +| Kafka commit | TDengine batch 成功后提交本批最大 offset | + +## Write Semantics + +1. Kafka consumer 拉取消息后解析一次 envelope。 +2. envelope 已携带 `parsed_fields` 时,TDengine 写入直接复用,不重新从 raw JSON 解析。 +3. raw frame 和 location 可以分批写,但 offset 只能在本消息涉及的所有写入成功后提交。 +4. TDengine 写入失败时不提交 offset,让 Kafka 自动重放。 +5. 依赖 `event_id` 保持幂等,避免失败重放造成重复业务记录。 + +## Non-goals + +- 第一阶段不改变 raw frame 表结构。 +- 第一阶段不合并不同 raw frame。 +- 不丢弃 parse error frame。 +- 不把 MySQL snapshot 或 Redis 当前态塞进 history-writer。 + +## Risks + +- 批次越大,失败重试的重复写风险越高,必须依赖 `event_id` 或等价键。 +- `parsed_json` 较大时容易让单条 SQL 超限,需要按 payload size 提前切批。 +- batch flush 需要暴露 pending、flush duration、write error metrics,否则问题只会表现为 Kafka lag。 + +## Metrics Required + +| 指标 | 含义 | +| --- | --- | +| `vehicle_history_batch_flush_total{table,status}` | 批写成功/失败次数 | +| `vehicle_history_batch_rows_total{table,status}` | 批写行数 | +| `vehicle_history_batch_flush_duration_ms{table,status}` | 批写耗时 | +| `vehicle_history_batch_pending_rows{table}` | 当前待 flush 行数 | +| `vehicle_history_kafka_lag` | 下游是否追得上 Kafka | + +## Rollout + +1. 保留当前逐条写实现作为 fallback。 +2. 增加批写器单元测试,覆盖 count flush、interval flush、失败不 commit。 +3. 在测试环境打开 batch writer。 +4. 生产先用小批次 `100/100ms`,观察 TDengine latency 和 Kafka lag。 +5. 逐步提升到 `200-500/100ms`。 diff --git a/docs/ops/100k-capacity-baseline.md b/docs/ops/100k-capacity-baseline.md new file mode 100644 index 00000000..d4d9d444 --- /dev/null +++ b/docs/ops/100k-capacity-baseline.md @@ -0,0 +1,105 @@ +# 100K 车辆接入容量基线 + +更新时间:2026-07-03 + +## 目标 + +支撑 100,000 台车辆接入,入口服务在高连接数和高帧率下保持可观测、可背压、可恢复。 + +## 初始容量假设 + +| 项 | 目标 | +| --- | --- | +| 连接车辆数 | 100,000 | +| 平均帧间隔 | 10-30 秒 | +| 平均入口 FPS | 3,000-10,000 | +| 短时 burst FPS | 20,000 | +| Gateway p99 parse + enqueue | < 50ms | +| Redis 当前态延迟 | < 3s | +| TDengine 历史写入 lag | 可观测且 burst 后下降 | +| MySQL 当前态 | 异步,不能阻塞 Redis | + +## 当前生产观测 + +当前 ECS `115.29.187.205` 在低负载下健康: + +- JT808 连接约 242。 +- GB32960 连接约 2。 +- Kafka lag 为 0。 +- NATS ack pending 为 0。 +- Redis 写入约 0ms。 +- MySQL 投影约 1-2ms。 +- ECS load 约 `0.09 / 0.15 / 0.21`。 +- 可用内存约 6.6GB。 +- 根分区使用约 37%。 + +## 当前已知缺口 + +- `net.core.somaxconn=128`,无法作为 100K 连接生产基线。 +- Gateway 默认 `TCP_MAX_CONNECTIONS` 已调整为 `120000`,生产仍可通过环境变量覆盖。 +- 已新增可重复的 TCP 连接压测工具,后续需要用它跑 10K/50K/100K 阶段测试并记录结果。 +- 仍缺按协议维度的连接生命周期、读超时、parse/publish p95/p99 直方图。 +- TDengine history writer 当前逐消息插入,后续需要批量写入。 +- Kafka topic 当前 12 分区,100K 目标下需要结合实际 FPS 再评估分区数。 + +## ECS OS 参数建议 + +100K 连接目标需要至少以下系统参数作为起点: + +```bash +sysctl -w net.core.somaxconn=65535 +sysctl -w net.ipv4.tcp_max_syn_backlog=65535 +sysctl -w net.ipv4.ip_local_port_range="10000 65000" +sysctl -w net.ipv4.tcp_tw_reuse=1 +``` + +systemd gateway 已配置 `LimitNOFILE=1048576`,需要持续保持。 + +## 验收指标 + +| 层 | 指标 | 目标 | +| --- | --- | --- | +| Gateway | `vehicle_gateway_active_connections` | 能稳定到阶段目标连接数 | +| Gateway | `vehicle_gateway_connection_rejections_total` | 容量目标内不增长 | +| Gateway | `vehicle_gateway_publish_total{status="ok"}` | 持续增长 | +| Gateway | parse/publish duration | p99 小于容量目标 | +| NATS bridge | `vehicle_bridge_nats_consumer_ack_pending` | 稳态为 0 | +| NATS bridge | `vehicle_bridge_nats_consumer_pending` | burst 后下降 | +| Kafka consumers | `vehicle_*_kafka_lag` | 稳态为 0,burst 后下降 | +| Realtime Redis | `vehicle_realtime_store_update_duration_ms{store="redis"}` | 毫秒级 | +| Realtime MySQL | async queue depth | 不持续增长 | + +## 分阶段压测 + +压测入口: + +```bash +cd /opt/lingniu-go-native/current/go/vehicle-gateway + +# 100 连接 smoke test +go run ./cmd/load-sim \ + -protocol jt808 \ + -addr 127.0.0.1:808 \ + -connections 100 \ + -connect-rate 100 \ + -send-interval 10s \ + -duration 2m \ + -template 0200 +``` + +1. 100 连接 smoke test。 +2. 10,000 连接保持测试。 +3. 10,000 连接 + 1 FPS/连接短测。 +4. 50,000 连接保持测试。 +5. 100,000 连接保持测试。 +6. 按真实协议分布进行混合帧率测试。 + +每一阶段必须记录: + +- active connections +- gateway frame/publish counters +- NATS pending 和 ack pending +- Kafka lag +- Redis/MySQL/TDengine 写入耗时 +- CPU/load/memory/file descriptors +- 错误日志 diff --git a/docs/ops/go-vehicle-ingest-memory.md b/docs/ops/go-vehicle-ingest-memory.md index a9335085..1c39a33c 100644 --- a/docs/ops/go-vehicle-ingest-memory.md +++ b/docs/ops/go-vehicle-ingest-memory.md @@ -18,6 +18,7 @@ Go 版本车辆数据接入链路已经作为生产主链路运行在 ECS `115.2 3. Kafka raw 供 TDengine 历史、Redis 实时、MySQL 当前态/统计消费。 4. 尽量避免 Java 老链路、Xinda 相关链路和重复存储。 5. 解析字段只投影一次,避免 TDengine/Redis/MySQL 各自重复 flatten。 +6. 新长期目标:朝 10W 车辆生产稳定运行优化,容量基线见 `docs/ops/100k-capacity-baseline.md`。 ## 服务和端口 @@ -338,4 +339,3 @@ journalctl -u lingniu-go-gateway.service --since "2 minutes ago" --no-pager | gr 3. 读 `docs/architecture/production-data-plane-inventory.md`。 4. 执行 `git status --short`。 5. 如果涉及生产,先查 ECS 服务健康和最近日志。 - diff --git a/docs/ops/vehicle-ingest-runbook.md b/docs/ops/vehicle-ingest-runbook.md index b37204ea..3f016149 100644 --- a/docs/ops/vehicle-ingest-runbook.md +++ b/docs/ops/vehicle-ingest-runbook.md @@ -15,6 +15,8 @@ 当前生产服务、topic、表和 Redis key 的清单见 [生产数据面清单](../architecture/production-data-plane-inventory.md)。 +10W 车辆容量目标、当前缺口和压测口径见 [100K 车辆接入容量基线](100k-capacity-baseline.md)。 + ## 服务地图 | 层级 | 服务 | systemd 单元 | 本机端点 | @@ -88,6 +90,23 @@ curl -fsS http://127.0.0.1:20200/metrics | grep vehicle_realtime_kafka_lag GB32960 和 JT/T 808 的活跃连接数受上游平台连接方式影响,不能直接等同于车辆数。突然归零或持续异常下降才是信号。 +## 压测入口 + +Go 版本提供 `cmd/load-sim` 用于阶段性连接和帧写入压测。压测生产入口前必须先确认上游真实数据窗口,避免和业务流量混淆。 + +```bash +cd /opt/lingniu-go-native/current/go/vehicle-gateway + +go run ./cmd/load-sim \ + -protocol jt808 \ + -addr 127.0.0.1:808 \ + -connections 100 \ + -connect-rate 100 \ + -send-interval 10s \ + -duration 2m \ + -template 0200 +``` + ## 告警阈值建议 | 信号 | 建议阈值 | 含义 | diff --git a/docs/superpowers/plans/2026-07-03-100k-vehicle-ingest-hardening.md b/docs/superpowers/plans/2026-07-03-100k-vehicle-ingest-hardening.md new file mode 100644 index 00000000..ccc23171 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-100k-vehicle-ingest-hardening.md @@ -0,0 +1,546 @@ +# 100K Vehicle Ingest Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move the Go vehicle ingest platform toward stable production operation for 100,000 vehicles. + +**Architecture:** Keep the current Gateway -> NATS -> Kafka -> TDengine/Redis/MySQL architecture, but make capacity explicit, add reproducible load testing, harden OS/systemd/network limits, and remove single-threaded or per-message bottlenecks from storage projectors. The first target is predictable behavior under high connection count and high frame rate; horizontal scaling comes after measurable single-node limits. + +**Tech Stack:** Go 1.26, systemd, Linux TCP tuning, NATS JetStream, Kafka, Redis, TDengine, MySQL, Prometheus-style `/metrics`. + +--- + +## Current Baseline + +Observed on 2026-07-03 from ECS `115.29.187.205`: + +| Item | Current Observation | +| --- | --- | +| Gateway active connections | JT808 about `242`, GB32960 about `2` | +| ECS load | about `0.09 / 0.15 / 0.21` | +| Memory | about `7.5 GB total`, `6.6 GB available` | +| Disk | `/` about `37%` used | +| NATS bridge ack pending | `0` | +| NATS bridge pending | about `26` | +| Kafka lag | `0` across history/realtime/stat sampled metrics | +| Redis projector latency | about `0 ms` gauge | +| MySQL projector latency | about `1-2 ms` gauge | +| Gateway file limit | `1048576` | +| Gateway max processes | `30123` | +| Kernel `net.core.somaxconn` | `128` | +| Gateway listen backlog | Go `net.Listen` default, bounded by `somaxconn` | +| Gateway default max connections | env default adjusted to `TCP_MAX_CONNECTIONS=120000` | + +Key gap: production is healthy at current load, but it has not proved 100K connection or high burst behavior. The current OS accept backlog and gateway default connection cap are below the target. + +## Capacity Assumptions + +Use these as starting targets. Revise after load test evidence. + +| Scenario | Target | +| --- | --- | +| Connected vehicles | `100,000` total | +| Average frame interval | 10-30 seconds depending protocol | +| Average ingress FPS | `3,000-10,000 frames/s` | +| Burst ingress FPS | `20,000 frames/s` for short periods | +| Gateway p99 parse + enqueue | `< 50 ms` | +| Gateway publish queue drops | `0` under target load | +| NATS ack pending | sustained `0`, short spikes acceptable | +| Kafka lag | bounded and decreasing after burst | +| Redis current-state lag | `< 3 seconds` under normal load | +| TDengine history lag | bounded and observable | +| MySQL snapshot lag | can be lower priority and async; must not block Redis | + +## File Map + +| File | Responsibility | +| --- | --- | +| `docs/superpowers/plans/2026-07-03-100k-vehicle-ingest-hardening.md` | This implementation plan | +| `docs/ops/go-vehicle-ingest-memory.md` | Operational memory, update after each production change | +| `docs/ops/vehicle-ingest-runbook.md` | Production runbook, add capacity and tuning commands | +| `go/vehicle-gateway/cmd/load-sim/main.go` | New load simulator for JT808/GB32960 TCP and MQTT-like payload replay | +| `go/vehicle-gateway/internal/loadsim/*` | New reusable load simulation helpers | +| `go/vehicle-gateway/internal/gateway/tcp_server.go` | Gateway connection handling, metrics, connection admission | +| `go/vehicle-gateway/cmd/gateway/main.go` | Gateway env defaults for 100K mode | +| `go/vehicle-gateway/internal/eventbus/async_sink.go` | Async publish queue metrics/backpressure behavior | +| `go/vehicle-gateway/cmd/nats-fast-writer/main.go` | Fast writer batching and worker behavior | +| `go/vehicle-gateway/cmd/history-writer/main.go` | Kafka/TDengine consume/write batching | +| `go/vehicle-gateway/internal/history/writer.go` | TDengine write path; later batch insert | +| `go/vehicle-gateway/cmd/realtime-api/main.go` | Redis-first and MySQL async projector behavior | +| `deploy/systemd/*.conf` or `docs/ops/*.md` | Documented sysctl/systemd tuning until deployment templates exist | + +## Task 1: Capacity Baseline and Production Guardrails + +**Files:** +- Modify: `docs/ops/go-vehicle-ingest-memory.md` +- Modify: `docs/ops/vehicle-ingest-runbook.md` +- Create: `docs/ops/100k-capacity-baseline.md` + +- [x] **Step 1: Create the capacity baseline document** + +Create `docs/ops/100k-capacity-baseline.md` with: + +```markdown +# 100K 车辆接入容量基线 + +更新时间:2026-07-03 + +## 目标 + +支撑 100,000 台车辆接入,入口服务在高连接数和高帧率下保持可观测、可背压、可恢复。 + +## 初始容量假设 + +| 项 | 目标 | +| --- | --- | +| 连接车辆数 | 100,000 | +| 平均帧间隔 | 10-30 秒 | +| 平均入口 FPS | 3,000-10,000 | +| 短时 burst FPS | 20,000 | +| Gateway p99 parse + enqueue | < 50ms | +| Redis 当前态延迟 | < 3s | +| TDengine 历史写入 lag | 可观测且 burst 后下降 | +| MySQL 当前态 | 异步,不能阻塞 Redis | + +## 当前生产观测 + +当前 ECS `115.29.187.205` 在低负载下健康: + +- JT808 连接约 242。 +- GB32960 连接约 2。 +- Kafka lag 为 0。 +- NATS ack pending 为 0。 +- Redis 写入约 0ms。 +- MySQL 投影约 1-2ms。 + +## 当前已知缺口 + +- `net.core.somaxconn=128`,无法作为 100K 连接生产基线。 +- Gateway 默认 `TCP_MAX_CONNECTIONS` 已调整为 `120000`,生产仍可通过环境变量覆盖。 +- 已新增可重复的 TCP 连接压测工具,后续需要用它跑 10K/50K/100K 阶段测试并记录结果。 +- 仍缺按协议维度的连接生命周期、读超时、parse/publish p95/p99 直方图。 +- TDengine history writer 当前逐消息插入,后续需要批量写入。 +- Kafka topic 当前 12 分区,100K 目标下需要结合实际 FPS 再评估分区数。 +``` + +- [x] **Step 2: Update runbook with capacity baseline link** + +Add this line near the top of `docs/ops/vehicle-ingest-runbook.md` after the production inventory link: + +```markdown +10W 车辆容量目标、当前缺口和压测口径见 [100K 车辆接入容量基线](100k-capacity-baseline.md)。 +``` + +- [x] **Step 3: Verify docs render** + +Run: + +```bash +rg -n "100K|10W|容量基线|somaxconn|TCP_MAX_CONNECTIONS" docs/ops docs/architecture +``` + +Expected: the new baseline and runbook link are found. + +- [ ] **Step 4: Commit** + +```bash +git add docs/ops/100k-capacity-baseline.md docs/ops/vehicle-ingest-runbook.md docs/ops/go-vehicle-ingest-memory.md +git commit -m "docs: add 100k ingest capacity baseline" +``` + +## Task 2: Load Simulator Skeleton + +**Files:** +- Create: `go/vehicle-gateway/internal/loadsim/config.go` +- Create: `go/vehicle-gateway/internal/loadsim/config_test.go` +- Create: `go/vehicle-gateway/internal/loadsim/templates.go` +- Create: `go/vehicle-gateway/internal/loadsim/template_test.go` +- Create: `go/vehicle-gateway/internal/loadsim/schedule.go` +- Create: `go/vehicle-gateway/internal/loadsim/schedule_test.go` +- Create: `go/vehicle-gateway/internal/loadsim/runner.go` +- Create: `go/vehicle-gateway/internal/loadsim/runner_test.go` +- Create: `go/vehicle-gateway/cmd/load-sim/main.go` +- Create: `go/vehicle-gateway/cmd/load-sim/main_test.go` + +- [x] **Step 1: Write failing tests for loadsim config, templates, schedule, runner and CLI stats** + +Implemented tests: + +- `TestConfigFromFlagsParsesCapacityKnobs` +- `TestConfigFromFlagsRejectsUnsafeValues` +- `TestFrameTemplateReturnsParsableJT808LocationFrame` +- `TestFrameTemplateReturnsParsableGB32960RealtimeFrame` +- `TestConnectionBatchesSpreadsConnectionsByRate` +- `TestRunnerDialsTargetConnectionsAndWritesFrames` +- `TestFormatStatsIncludesCapacityCounters` + +- [x] **Step 2: Run tests to verify RED** + +Run: + +```bash +cd go/vehicle-gateway +go test ./internal/loadsim +``` + +Expected: FAIL because package or symbols do not exist. + +- [x] **Step 3: Implement minimal load simulator** + +Implemented: + +- flag config validation +- parsable JT808 0x0200 and GB32960 realtime frame templates +- connection batch scheduling +- TCP runner with dial injection for tests +- `cmd/load-sim` CLI + +- [x] **Step 4: Run test to verify GREEN** + +Run: + +```bash +cd go/vehicle-gateway +go test ./internal/loadsim +``` + +Expected: PASS. + +- [x] **Step 5: Add CLI skeleton** + +Implemented `go/vehicle-gateway/cmd/load-sim/main.go`. + +- [x] **Step 6: Verify CLI builds** + +Run: + +```bash +cd go/vehicle-gateway +go test ./cmd/load-sim ./internal/loadsim +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add go/vehicle-gateway/internal/loadsim go/vehicle-gateway/cmd/load-sim +git commit -m "test(go): add vehicle ingest load simulator skeleton" +``` + +## Task 3: Gateway Capacity Configuration + +**Files:** +- Modify: `go/vehicle-gateway/cmd/gateway/main.go` +- Modify: `docs/ops/100k-capacity-baseline.md` +- Modify: `docs/ops/vehicle-ingest-runbook.md` + +- [x] **Step 1: Write test for 100K connection env default** + +Added `TestGatewayDefaultsTo100KConnectionCeiling`. + +- [x] **Step 2: Run test to verify RED before changing default** + +Run: + +```bash +cd go/vehicle-gateway +go test ./cmd/gateway +``` + +Expected: FAIL while call site still used `20000`. + +- [x] **Step 3: Change gateway default** + +In `go/vehicle-gateway/cmd/gateway/main.go`, change the TCP server config default: + +```go +MaxConnections: envInt("TCP_MAX_CONNECTIONS", 120_000), +``` + +- [x] **Step 4: Document required sysctl** + +Add to `docs/ops/100k-capacity-baseline.md`: + +```markdown +## ECS OS 参数建议 + +100K 连接目标需要至少以下系统参数作为起点: + +```bash +sysctl -w net.core.somaxconn=65535 +sysctl -w net.ipv4.tcp_max_syn_backlog=65535 +sysctl -w net.ipv4.ip_local_port_range="10000 65000" +sysctl -w net.ipv4.tcp_tw_reuse=1 +``` + +systemd gateway 已配置 `LimitNOFILE=1048576`,需要持续保持。 +``` +``` + +- [x] **Step 5: Run tests** + +Run: + +```bash +cd go/vehicle-gateway +go test ./cmd/gateway ./internal/gateway +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add go/vehicle-gateway/cmd/gateway/main.go go/vehicle-gateway/cmd/gateway/main_test.go docs/ops/100k-capacity-baseline.md docs/ops/vehicle-ingest-runbook.md +git commit -m "chore(go): raise gateway connection target" +``` + +## Task 4: Gateway Runtime Metrics for 100K Operations + +**Files:** +- Modify: `go/vehicle-gateway/internal/gateway/tcp_server.go` +- Modify: `go/vehicle-gateway/internal/metrics/metrics.go` +- Test: `go/vehicle-gateway/internal/gateway/tcp_server_test.go` +- Test: `go/vehicle-gateway/internal/metrics/metrics_test.go` + +- [x] **Step 1: Add metrics test for rejected connections** + +Added `TestTCPServerRecordsConnectionRejectionMetric` in `go/vehicle-gateway/internal/gateway/tcp_server_test.go`. + +- [x] **Step 2: Run RED or confirm helper coverage** + +Run: + +```bash +cd go/vehicle-gateway +go test ./internal/gateway -run TestTCPServerRecordsConnectionRejectionMetric +``` + +Expected: FAIL before `recordConnectionRejection` exists. + +- [x] **Step 3: Record max connection rejections** + +In `go/vehicle-gateway/internal/gateway/tcp_server.go`, when the semaphore default case rejects a connection, add: + +```go +server.recordConnectionRejection("max_connections") +``` + +Implemented `recordConnectionRejection`. + +- [x] **Step 4: Run gateway tests** + +Run: + +```bash +cd go/vehicle-gateway +go test ./internal/gateway ./internal/metrics +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add go/vehicle-gateway/internal/gateway/tcp_server.go go/vehicle-gateway/internal/gateway/tcp_server_test.go go/vehicle-gateway/internal/metrics/metrics.go go/vehicle-gateway/internal/metrics/metrics_test.go +git commit -m "feat(go): expose gateway connection rejection metrics" +``` + +## Task 5: Redis-First Realtime Projector Safety + +**Files:** +- Modify: `go/vehicle-gateway/cmd/realtime-api/main.go` +- Test: `go/vehicle-gateway/cmd/realtime-api/main_test.go` + +- [x] **Step 1: Add test proving MySQL queue drops do not fail Redis update** + +Add a test around `asyncSecondaryRealtimeUpdater` in `go/vehicle-gateway/cmd/realtime-api/main_test.go` using existing test patterns. The test should: + +1. Create an updater with queue size `1`. +2. Fill the queue. +3. Call `Update` with a new envelope. +4. Assert `Update` returns `nil`. +5. Assert metric `vehicle_realtime_async_queue_total{status="dropped"}` increments. + +- [x] **Step 2: Run test** + +Run: + +```bash +cd go/vehicle-gateway +go test ./cmd/realtime-api -run TestAsyncSecondaryQueueDropDoesNotFailPrimaryUpdate +``` + +Actual: PASS. The behavior already existed; this test locks it as regression coverage. + +- [x] **Step 3: Ensure queue drop is observable and non-fatal** + +In `go/vehicle-gateway/cmd/realtime-api/main.go`, preserve current Redis-first behavior: + +- Redis update remains synchronous and primary. +- MySQL update remains async secondary. +- Full secondary queue increments dropped metric and does not return error to Kafka processing. + +- [x] **Step 4: Run tests** + +```bash +cd go/vehicle-gateway +go test ./cmd/realtime-api ./internal/realtime +``` + +- [ ] **Step 5: Commit** + +```bash +git add go/vehicle-gateway/cmd/realtime-api/main.go go/vehicle-gateway/cmd/realtime-api/main_test.go +git commit -m "test(go): lock redis-first realtime projector behavior" +``` + +## Task 6: TDengine History Writer Batching Design + +**Files:** +- Create: `docs/architecture/tdengine-batch-writer-design.md` +- Later Modify: `go/vehicle-gateway/cmd/history-writer/main.go` +- Later Modify: `go/vehicle-gateway/internal/history/writer.go` + +- [x] **Step 1: Document the batch writer decision** + +Created `docs/architecture/tdengine-batch-writer-design.md`. + +- [ ] **Step 2: Commit design before code** + +```bash +git add docs/architecture/tdengine-batch-writer-design.md +git commit -m "docs: design tdengine batch writer" +``` + +## Task 7: Production Sysctl and Service Guardrail Rollout + +**Files:** +- Modify: `docs/ops/vehicle-ingest-runbook.md` +- Modify: `docs/ops/go-vehicle-ingest-memory.md` + +- [ ] **Step 1: Apply sysctl changes on ECS** + +Run on ECS: + +```bash +cat >/etc/sysctl.d/99-lingniu-vehicle-ingest.conf <<'EOF' +net.core.somaxconn = 65535 +net.ipv4.tcp_max_syn_backlog = 65535 +net.ipv4.ip_local_port_range = 10000 65000 +net.ipv4.tcp_tw_reuse = 1 +EOF +sysctl --system +``` + +- [ ] **Step 2: Verify sysctl** + +Run: + +```bash +sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlog net.ipv4.ip_local_port_range net.ipv4.tcp_tw_reuse +``` + +Expected: + +```text +net.core.somaxconn = 65535 +net.ipv4.tcp_max_syn_backlog = 65535 +net.ipv4.ip_local_port_range = 10000 65000 +net.ipv4.tcp_tw_reuse = 1 +``` + +- [ ] **Step 3: Restart gateway** + +Run: + +```bash +systemctl restart lingniu-go-gateway.service +curl -fsS http://127.0.0.1:20211/readyz +ss -ltnp | grep -E ':(808|32960|20211) ' +``` + +- [ ] **Step 4: Document rollout** + +Add the applied sysctl values and timestamp to `docs/ops/go-vehicle-ingest-memory.md`. + +- [ ] **Step 5: Commit** + +```bash +git add docs/ops/go-vehicle-ingest-memory.md docs/ops/vehicle-ingest-runbook.md +git commit -m "ops: document 100k gateway sysctl rollout" +``` + +## Task 8: First 10K Connection Test + +**Files:** +- Modify: `docs/ops/100k-capacity-baseline.md` +- Modify: `go/vehicle-gateway/cmd/load-sim/main.go` +- Modify: `go/vehicle-gateway/internal/loadsim/*` + +- [ ] **Step 1: Implement JT808 connection hold mode** + +Extend load-sim to open N TCP connections and keep them open for `duration`, without sending frames. + +Expected CLI: + +```bash +go run ./cmd/load-sim -protocol jt808 -addr 115.29.187.205:808 -connections 10000 -send-interval 30s -duration 5m +``` + +- [ ] **Step 2: Run from non-production client** + +Run a small smoke first: + +```bash +go run ./cmd/load-sim -protocol jt808 -addr 115.29.187.205:808 -connections 100 -duration 30s +``` + +Then run 10K only after smoke succeeds. + +- [ ] **Step 3: Capture metrics during test** + +On ECS: + +```bash +curl -fsS http://127.0.0.1:20211/metrics | grep vehicle_gateway_active_connections +uptime +free -m +ss -tn sport = :808 | wc -l +journalctl -u lingniu-go-gateway.service --since '10 minutes ago' --no-pager | grep -Ei 'error|failed|panic|fatal' || true +``` + +- [ ] **Step 4: Update capacity baseline** + +Record: + +- max active connections reached +- CPU/load +- memory +- connection rejection count +- errors + +- [ ] **Step 5: Commit** + +```bash +git add docs/ops/100k-capacity-baseline.md go/vehicle-gateway/cmd/load-sim go/vehicle-gateway/internal/loadsim +git commit -m "test(go): record 10k gateway connection baseline" +``` + +## Self-Review + +Spec coverage: + +- 10W production objective: covered by capacity assumptions and phased tasks. +- Gateway connection limit and OS tuning: Tasks 3, 4, 7, 8. +- NATS/Kafka stability: baseline metrics and future batching notes; bridge-specific tuning remains a follow-up after first load test. +- Redis/MySQL projector stability: Task 5. +- TDengine write bottleneck: Task 6 sets design; implementation should follow once load evidence shows the threshold. +- ECS operations memory: Tasks 1 and 7. + +Known intentional gaps: + +- Full 100K test is not first. The first safe milestone is 10K connection test, then 50K, then 100K. +- Multi-ECS horizontal sharding is not implemented in this plan. It should follow after single-node evidence. +- Kafka partition changes are not included yet; changing partitions without measured FPS and consumer bottlenecks would be premature. diff --git a/go/vehicle-gateway/cmd/gateway/main.go b/go/vehicle-gateway/cmd/gateway/main.go index 6a5b1f91..84e7397c 100644 --- a/go/vehicle-gateway/cmd/gateway/main.go +++ b/go/vehicle-gateway/cmd/gateway/main.go @@ -74,7 +74,7 @@ func main() { Metrics: registry, ReadBufferSize: envInt("TCP_READ_BUFFER_BYTES", 64*1024), IdleTimeout: time.Duration(envInt("TCP_IDLE_TIMEOUT_SECONDS", 180)) * time.Second, - MaxConnections: envInt("TCP_MAX_CONNECTIONS", 20_000), + MaxConnections: envInt("TCP_MAX_CONNECTIONS", 120_000), PublishUnified: publishUnified, }) if err != nil { diff --git a/go/vehicle-gateway/cmd/gateway/main_test.go b/go/vehicle-gateway/cmd/gateway/main_test.go index 0c1e5603..fe18dba6 100644 --- a/go/vehicle-gateway/cmd/gateway/main_test.go +++ b/go/vehicle-gateway/cmd/gateway/main_test.go @@ -46,6 +46,16 @@ func TestGatewayConfiguresIdentityLookupCacheTTL(t *testing.T) { } } +func TestGatewayDefaultsTo100KConnectionCeiling(t *testing.T) { + source, err := os.ReadFile("main.go") + if err != nil { + t.Fatalf("read main.go: %v", err) + } + if !strings.Contains(string(source), `envInt("TCP_MAX_CONNECTIONS", 120_000)`) { + t.Fatal("gateway should default TCP_MAX_CONNECTIONS to a 100K-ready ceiling") + } +} + func TestNATSSinkConfigFromEnvDefaultsToGoSubjects(t *testing.T) { t.Setenv("NATS_URL", "nats://172.17.111.56:4222") diff --git a/go/vehicle-gateway/cmd/load-sim/main.go b/go/vehicle-gateway/cmd/load-sim/main.go new file mode 100644 index 00000000..5d2c90c7 --- /dev/null +++ b/go/vehicle-gateway/cmd/load-sim/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/loadsim" +) + +func main() { + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + flagCfg := loadsim.RegisterFlags(flag.CommandLine) + if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { + log.Fatal(err) + } + cfg, err := flagCfg.Build() + if err != nil { + log.Fatal(err) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + log.Printf("load simulation started protocol=%s addr=%s connections=%d connect_rate=%d send_interval=%s duration=%s template=%s", + cfg.Protocol, cfg.Addr, cfg.Connections, cfg.ConnectRatePerSecond, cfg.SendInterval, cfg.Duration, cfg.Template) + stats, err := (loadsim.Runner{}).Run(ctx, cfg) + if err != nil { + log.Fatal(err) + } + log.Print(formatStats(stats)) +} + +func formatStats(stats loadsim.Stats) string { + return fmt.Sprintf("connections_opened=%d connections_failed=%d frames_written=%d write_errors=%d", + stats.ConnectionsOpened, + stats.ConnectionsFailed, + stats.FramesWritten, + stats.WriteErrors, + ) +} diff --git a/go/vehicle-gateway/cmd/load-sim/main_test.go b/go/vehicle-gateway/cmd/load-sim/main_test.go new file mode 100644 index 00000000..b41ead55 --- /dev/null +++ b/go/vehicle-gateway/cmd/load-sim/main_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "strings" + "testing" + + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/loadsim" +) + +func TestFormatStatsIncludesCapacityCounters(t *testing.T) { + out := formatStats(loadsim.Stats{ + ConnectionsOpened: 10, + ConnectionsFailed: 2, + FramesWritten: 300, + WriteErrors: 1, + }) + + for _, want := range []string{ + "connections_opened=10", + "connections_failed=2", + "frames_written=300", + "write_errors=1", + } { + if !strings.Contains(out, want) { + t.Fatalf("formatStats() = %q, missing %q", out, want) + } + } +} diff --git a/go/vehicle-gateway/cmd/realtime-api/main_test.go b/go/vehicle-gateway/cmd/realtime-api/main_test.go index 3466fe25..67c113cf 100644 --- a/go/vehicle-gateway/cmd/realtime-api/main_test.go +++ b/go/vehicle-gateway/cmd/realtime-api/main_test.go @@ -134,6 +134,35 @@ func TestAsyncSecondaryRealtimeUpdaterDoesNotBlockPrimaryPath(t *testing.T) { } } +func TestAsyncSecondaryQueueDropDoesNotFailPrimaryUpdate(t *testing.T) { + env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001"} + primary := &contextCheckingRealtimeUpdater{} + registry := metrics.NewRegistry() + updater := &asyncSecondaryRealtimeUpdater{ + primary: primary, + secondary: &contextCheckingRealtimeUpdater{}, + queue: make(chan envelope.FrameEnvelope, 1), + registry: registry, + } + updater.queue <- envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN000"} + + if err := updater.Update(context.Background(), env); err != nil { + t.Fatalf("Update() error = %v", err) + } + if primary.count != 1 { + t.Fatalf("primary updates = %d, want 1", primary.count) + } + text := registry.Render() + for _, want := range []string{ + `vehicle_realtime_async_queue_total{protocol="JT808",status="dropped",store="mysql"} 1`, + `vehicle_realtime_async_queue_depth{protocol="JT808",store="mysql"} 1`, + } { + if !strings.Contains(text, want) { + t.Fatalf("async queue drop metric missing %s:\n%s", want, text) + } + } +} + func TestStoreMetricUpdaterRecordsStoreUpdateResults(t *testing.T) { registry := metrics.NewRegistry() env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001"} diff --git a/go/vehicle-gateway/internal/gateway/tcp_server.go b/go/vehicle-gateway/internal/gateway/tcp_server.go index 8177a765..d6817cfc 100644 --- a/go/vehicle-gateway/internal/gateway/tcp_server.go +++ b/go/vehicle-gateway/internal/gateway/tcp_server.go @@ -143,6 +143,7 @@ func (s *TCPServer) ListenAndServe(ctx context.Context) error { s.handleConnection(ctx, conn) }() default: + s.recordConnectionRejection("max_connections") s.logger.Warn("tcp connection rejected: max connections reached", "protocol", s.protocol.Protocol, "remote", conn.RemoteAddr().String()) _ = conn.Close() } @@ -367,6 +368,16 @@ func (s *TCPServer) recordConnectionMetric(delta float64) { }, delta) } +func (s *TCPServer) recordConnectionRejection(reason string) { + if s.metrics == nil { + return + } + s.metrics.IncCounter("vehicle_gateway_connection_rejections_total", metrics.Labels{ + "protocol": string(s.protocol.Protocol), + "reason": reason, + }) +} + func identityStatus(env envelope.FrameEnvelope) string { if strings.TrimSpace(env.VIN) != "" { return "resolved" diff --git a/go/vehicle-gateway/internal/gateway/tcp_server_test.go b/go/vehicle-gateway/internal/gateway/tcp_server_test.go index 90d1f256..dc0fe5da 100644 --- a/go/vehicle-gateway/internal/gateway/tcp_server_test.go +++ b/go/vehicle-gateway/internal/gateway/tcp_server_test.go @@ -137,6 +137,30 @@ func TestTCPServerRecordsActiveConnectionGauge(t *testing.T) { } } +func TestTCPServerRecordsConnectionRejectionMetric(t *testing.T) { + registry := metrics.NewRegistry() + server, err := NewTCPServer(TCPServerConfig{ + Protocol: TCPProtocol{ + Protocol: envelope.ProtocolJT808, + Addr: ":0", + Extract: jt808.ExtractFrames, + Parse: jt808.ParseFrame, + }, + Sink: &recordingSink{}, + Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)), + Metrics: registry, + }) + if err != nil { + t.Fatalf("NewTCPServer() error = %v", err) + } + + server.recordConnectionRejection("max_connections") + + if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_connection_rejections_total{protocol="JT808",reason="max_connections"} 1`) { + t.Fatalf("connection rejection metric missing:\n%s", text) + } +} + func TestTCPServerPublishesBadFrameOnlyToRaw(t *testing.T) { good := buildGBFrame(0x02, 0xfe, "LNBSCB3D4R1234567", nil) good[len(good)-1] ^= 0xff diff --git a/go/vehicle-gateway/internal/loadsim/config.go b/go/vehicle-gateway/internal/loadsim/config.go new file mode 100644 index 00000000..3dabb107 --- /dev/null +++ b/go/vehicle-gateway/internal/loadsim/config.go @@ -0,0 +1,82 @@ +package loadsim + +import ( + "errors" + "flag" + "fmt" + "strings" + "time" +) + +type Protocol string + +const ( + ProtocolGB32960 Protocol = "gb32960" + ProtocolJT808 Protocol = "jt808" + ProtocolYutongMQTT Protocol = "yutong-mqtt" +) + +type Config struct { + Protocol Protocol + Addr string + Connections int + ConnectRatePerSecond int + SendInterval time.Duration + Duration time.Duration + Template string +} + +type FlagConfig struct { + protocol string + addr string + connections int + connectRate int + sendInterval time.Duration + duration time.Duration + template string +} + +func RegisterFlags(fs *flag.FlagSet) *FlagConfig { + cfg := &FlagConfig{} + fs.StringVar(&cfg.protocol, "protocol", string(ProtocolJT808), "protocol: jt808, gb32960, yutong-mqtt") + fs.StringVar(&cfg.addr, "addr", "", "target host:port") + fs.IntVar(&cfg.connections, "connections", 1, "target concurrent connections") + fs.IntVar(&cfg.connectRate, "connect-rate", 100, "new connections per second") + fs.DurationVar(&cfg.sendInterval, "send-interval", 10*time.Second, "frame send interval per connection") + fs.DurationVar(&cfg.duration, "duration", 10*time.Minute, "load test duration") + fs.StringVar(&cfg.template, "template", "", "frame template name") + return cfg +} + +func (c *FlagConfig) Build() (Config, error) { + protocol := Protocol(strings.ToLower(strings.TrimSpace(c.protocol))) + switch protocol { + case ProtocolGB32960, ProtocolJT808, ProtocolYutongMQTT: + default: + return Config{}, fmt.Errorf("unsupported protocol %q", c.protocol) + } + if strings.TrimSpace(c.addr) == "" { + return Config{}, errors.New("addr is required") + } + if c.connections <= 0 { + return Config{}, errors.New("connections must be positive") + } + if c.connectRate <= 0 { + return Config{}, errors.New("connect-rate must be positive") + } + if c.sendInterval <= 0 { + return Config{}, errors.New("send-interval must be positive") + } + if c.duration <= 0 { + return Config{}, errors.New("duration must be positive") + } + return Config{ + Protocol: protocol, + Addr: strings.TrimSpace(c.addr), + Connections: c.connections, + ConnectRatePerSecond: c.connectRate, + SendInterval: c.sendInterval, + Duration: c.duration, + Template: strings.TrimSpace(c.template), + }, nil +} diff --git a/go/vehicle-gateway/internal/loadsim/config_test.go b/go/vehicle-gateway/internal/loadsim/config_test.go new file mode 100644 index 00000000..3938c3ca --- /dev/null +++ b/go/vehicle-gateway/internal/loadsim/config_test.go @@ -0,0 +1,73 @@ +package loadsim + +import ( + "flag" + "testing" + "time" +) + +func TestConfigFromFlagsParsesCapacityKnobs(t *testing.T) { + fs := flag.NewFlagSet("load-sim", flag.ContinueOnError) + cfg := RegisterFlags(fs) + + err := fs.Parse([]string{ + "-protocol", "jt808", + "-addr", "115.29.187.205:808", + "-connections", "10000", + "-connect-rate", "800", + "-send-interval", "5s", + "-duration", "30m", + "-template", "0200", + }) + if err != nil { + t.Fatalf("parse flags: %v", err) + } + + got, err := cfg.Build() + if err != nil { + t.Fatalf("build config: %v", err) + } + + if got.Protocol != ProtocolJT808 { + t.Fatalf("Protocol = %q, want %q", got.Protocol, ProtocolJT808) + } + if got.Addr != "115.29.187.205:808" { + t.Fatalf("Addr = %q", got.Addr) + } + if got.Connections != 10000 { + t.Fatalf("Connections = %d", got.Connections) + } + if got.ConnectRatePerSecond != 800 { + t.Fatalf("ConnectRatePerSecond = %d", got.ConnectRatePerSecond) + } + if got.SendInterval != 5*time.Second { + t.Fatalf("SendInterval = %s", got.SendInterval) + } + if got.Duration != 30*time.Minute { + t.Fatalf("Duration = %s", got.Duration) + } + if got.Template != "0200" { + t.Fatalf("Template = %q", got.Template) + } +} + +func TestConfigFromFlagsRejectsUnsafeValues(t *testing.T) { + for name, args := range map[string][]string{ + "missing addr": {"-protocol", "jt808", "-connections", "1"}, + "bad protocol": {"-protocol", "x", "-addr", "127.0.0.1:808", "-connections", "1"}, + "zero connections": {"-protocol", "jt808", "-addr", "127.0.0.1:808", "-connections", "0"}, + "zero connect rate": {"-protocol", "jt808", "-addr", "127.0.0.1:808", "-connections", "1", "-connect-rate", "0"}, + "zero interval": {"-protocol", "jt808", "-addr", "127.0.0.1:808", "-connections", "1", "-send-interval", "0s"}, + } { + t.Run(name, func(t *testing.T) { + fs := flag.NewFlagSet("load-sim", flag.ContinueOnError) + cfg := RegisterFlags(fs) + if err := fs.Parse(args); err != nil { + t.Fatalf("parse flags: %v", err) + } + if _, err := cfg.Build(); err == nil { + t.Fatalf("Build() error = nil, want validation error") + } + }) + } +} diff --git a/go/vehicle-gateway/internal/loadsim/runner.go b/go/vehicle-gateway/internal/loadsim/runner.go new file mode 100644 index 00000000..f8cbc19a --- /dev/null +++ b/go/vehicle-gateway/internal/loadsim/runner.go @@ -0,0 +1,103 @@ +package loadsim + +import ( + "context" + "net" + "sync" + "sync/atomic" + "time" +) + +type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error) + +type Runner struct { + Dial DialFunc +} + +type Stats struct { + ConnectionsOpened int64 + ConnectionsFailed int64 + FramesWritten int64 + WriteErrors int64 +} + +func (r Runner) Run(ctx context.Context, cfg Config) (Stats, error) { + if _, err := (&FlagConfig{ + protocol: string(cfg.Protocol), + addr: cfg.Addr, + connections: cfg.Connections, + connectRate: cfg.ConnectRatePerSecond, + sendInterval: cfg.SendInterval, + duration: cfg.Duration, + template: cfg.Template, + }).Build(); err != nil { + return Stats{}, err + } + payload, err := FrameTemplate(cfg.Protocol, cfg.Template) + if err != nil { + return Stats{}, err + } + dial := r.Dial + if dial == nil { + dialer := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second} + dial = dialer.DialContext + } + + runCtx, cancel := context.WithTimeout(ctx, cfg.Duration) + defer cancel() + + var stats Stats + var wg sync.WaitGroup + for i, batch := range ConnectionBatches(cfg.Connections, cfg.ConnectRatePerSecond) { + if i > 0 { + if !sleepOrDone(runCtx, time.Second) { + break + } + } + for range batch { + if runCtx.Err() != nil { + break + } + conn, err := dial(runCtx, "tcp", cfg.Addr) + if err != nil { + atomic.AddInt64(&stats.ConnectionsFailed, 1) + continue + } + atomic.AddInt64(&stats.ConnectionsOpened, 1) + wg.Add(1) + go func() { + defer wg.Done() + defer conn.Close() + writeLoop(runCtx, conn, payload, cfg.SendInterval, &stats) + }() + } + } + <-runCtx.Done() + wg.Wait() + return stats, nil +} + +func writeLoop(ctx context.Context, conn net.Conn, payload []byte, interval time.Duration, stats *Stats) { + for { + _ = conn.SetWriteDeadline(time.Now().Add(3 * time.Second)) + if _, err := conn.Write(payload); err != nil { + atomic.AddInt64(&stats.WriteErrors, 1) + return + } + atomic.AddInt64(&stats.FramesWritten, 1) + if !sleepOrDone(ctx, interval) { + return + } + } +} + +func sleepOrDone(ctx context.Context, duration time.Duration) bool { + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} diff --git a/go/vehicle-gateway/internal/loadsim/runner_test.go b/go/vehicle-gateway/internal/loadsim/runner_test.go new file mode 100644 index 00000000..6d610b72 --- /dev/null +++ b/go/vehicle-gateway/internal/loadsim/runner_test.go @@ -0,0 +1,73 @@ +package loadsim + +import ( + "context" + "net" + "sync/atomic" + "testing" + "time" +) + +func TestRunnerDialsTargetConnectionsAndWritesFrames(t *testing.T) { + var writes atomic.Int64 + var closed atomic.Int64 + runner := Runner{ + Dial: func(ctx context.Context, network, addr string) (net.Conn, error) { + return &recordingConn{ + write: func(p []byte) (int, error) { + writes.Add(1) + return len(p), nil + }, + close: func() error { + closed.Add(1) + return nil + }, + }, nil + }, + } + + stats, err := runner.Run(context.Background(), Config{ + Protocol: ProtocolJT808, + Addr: "127.0.0.1:808", + Connections: 3, + ConnectRatePerSecond: 1000, + SendInterval: time.Millisecond, + Duration: 5 * time.Millisecond, + Template: "0200", + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + + if stats.ConnectionsOpened != 3 { + t.Fatalf("ConnectionsOpened = %d, want 3", stats.ConnectionsOpened) + } + if stats.FramesWritten == 0 { + t.Fatalf("FramesWritten = 0, want at least one") + } + if writes.Load() != int64(stats.FramesWritten) { + t.Fatalf("recorded writes = %d, stats = %d", writes.Load(), stats.FramesWritten) + } + if closed.Load() != 3 { + t.Fatalf("closed = %d, want 3", closed.Load()) + } +} + +type recordingConn struct { + write func([]byte) (int, error) + close func() error +} + +func (c *recordingConn) Read(p []byte) (int, error) { return 0, context.Canceled } +func (c *recordingConn) Write(p []byte) (int, error) { return c.write(p) } +func (c *recordingConn) Close() error { return c.close() } +func (c *recordingConn) LocalAddr() net.Addr { return testAddr("local") } +func (c *recordingConn) RemoteAddr() net.Addr { return testAddr("remote") } +func (c *recordingConn) SetDeadline(t time.Time) error { return nil } +func (c *recordingConn) SetReadDeadline(t time.Time) error { return nil } +func (c *recordingConn) SetWriteDeadline(t time.Time) error { return nil } + +type testAddr string + +func (a testAddr) Network() string { return "tcp" } +func (a testAddr) String() string { return string(a) } diff --git a/go/vehicle-gateway/internal/loadsim/schedule.go b/go/vehicle-gateway/internal/loadsim/schedule.go new file mode 100644 index 00000000..0e3ec8cb --- /dev/null +++ b/go/vehicle-gateway/internal/loadsim/schedule.go @@ -0,0 +1,18 @@ +package loadsim + +func ConnectionBatches(total int, ratePerSecond int) []int { + if total <= 0 || ratePerSecond <= 0 { + return nil + } + batches := make([]int, 0, (total+ratePerSecond-1)/ratePerSecond) + remaining := total + for remaining > 0 { + batch := ratePerSecond + if remaining < batch { + batch = remaining + } + batches = append(batches, batch) + remaining -= batch + } + return batches +} diff --git a/go/vehicle-gateway/internal/loadsim/schedule_test.go b/go/vehicle-gateway/internal/loadsim/schedule_test.go new file mode 100644 index 00000000..bbd32741 --- /dev/null +++ b/go/vehicle-gateway/internal/loadsim/schedule_test.go @@ -0,0 +1,24 @@ +package loadsim + +import "testing" + +func TestConnectionBatchesSpreadsConnectionsByRate(t *testing.T) { + got := ConnectionBatches(2500, 1000) + want := []int{1000, 1000, 500} + + if len(got) != len(want) { + t.Fatalf("len = %d, want %d: %#v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("batch[%d] = %d, want %d: %#v", i, got[i], want[i], got) + } + } +} + +func TestConnectionBatchesUsesOneBatchWhenTargetBelowRate(t *testing.T) { + got := ConnectionBatches(300, 1000) + if len(got) != 1 || got[0] != 300 { + t.Fatalf("ConnectionBatches() = %#v, want []int{300}", got) + } +} diff --git a/go/vehicle-gateway/internal/loadsim/template_test.go b/go/vehicle-gateway/internal/loadsim/template_test.go new file mode 100644 index 00000000..21041755 --- /dev/null +++ b/go/vehicle-gateway/internal/loadsim/template_test.go @@ -0,0 +1,50 @@ +package loadsim + +import ( + "testing" + + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/gb32960" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/jt808" +) + +func TestFrameTemplateReturnsParsableJT808LocationFrame(t *testing.T) { + frame, err := FrameTemplate(ProtocolJT808, "0200") + if err != nil { + t.Fatalf("FrameTemplate() error = %v", err) + } + + frames, remainder, err := jt808.ExtractFrames(frame) + if err != nil { + t.Fatalf("ExtractFrames() error = %v", err) + } + if len(remainder) != 0 { + t.Fatalf("remainder len = %d", len(remainder)) + } + if len(frames) != 1 { + t.Fatalf("frames len = %d", len(frames)) + } + if _, err := jt808.ParseFrame(frames[0], 1782918600000, "127.0.0.1:808"); err != nil { + t.Fatalf("ParseFrame() error = %v", err) + } +} + +func TestFrameTemplateReturnsParsableGB32960RealtimeFrame(t *testing.T) { + frame, err := FrameTemplate(ProtocolGB32960, "realtime") + if err != nil { + t.Fatalf("FrameTemplate() error = %v", err) + } + + frames, remainder, err := gb32960.ExtractFrames(frame) + if err != nil { + t.Fatalf("ExtractFrames() error = %v", err) + } + if len(remainder) != 0 { + t.Fatalf("remainder len = %d", len(remainder)) + } + if len(frames) != 1 { + t.Fatalf("frames len = %d", len(frames)) + } + if _, err := gb32960.ParseFrame(frames[0], 1782918600000, "127.0.0.1:32960"); err != nil { + t.Fatalf("ParseFrame() error = %v", err) + } +} diff --git a/go/vehicle-gateway/internal/loadsim/templates.go b/go/vehicle-gateway/internal/loadsim/templates.go new file mode 100644 index 00000000..31d4032e --- /dev/null +++ b/go/vehicle-gateway/internal/loadsim/templates.go @@ -0,0 +1,53 @@ +package loadsim + +import ( + "encoding/hex" + "fmt" + "strings" +) + +const sampleJT808LocationFrame = "7E020000320133077954250001000000000048000301D2C4C707376139000A00E6004F26063016235701040001900C2504000000000202000030011F31010F867E" + +const sampleGB32960RealtimeFrame = "232302FE4C423941333241323152304C53313730370102ED1A070116091C0102030100000008297D161F27104C020007D0000002010104564E204E205815CA271003000A000000B40002666902800200000100A101000400FFFF001205000733444601E2B5DB06013A0F6C018E0F4001014B01054A07000000000000000000080101161F271000900001900F520F530F520F640F660F650F650F650F650F660F650F650F640F640F550F550F540F560F580F570F540F570F560F550F5A0F550F580F570F5C0F5F0F5E0F610F5E0F5D0F5E0F5F0F5E0F5D0F610F5F0F610F600F600F610F610F610F600F630F630F600F610F610F610F620F620F610F6A0F6C0F6B0F6A0F6C0F6B0F6A0F6B0F6B0F6B0F6A0F610F630F630F630F640F660F640F630F630F630F580F5A0F580F5B0F550F580F5A0F5A0F590F580F5A0F620F650F650F650F660F640F640F640F640F640F660F650F640F650F660F650F670F670F670F670F630F620F680F670F650F650F670F650F610F5F0F600F620F5E0F5C0F590F4C0F490F480F440F470F480F470F420F430F450F420F450F440F440F430F530F540F520F400F410F4109010100084B4B4B4B4A4A4B4A3001026907B2FF00FF00380002002800000008006C00016C00080008000000080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008002800070007000700070007000700070007000700070007000700070007000700070007000700070007000700070007000700080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000800080008000831010E9C0005FFFFFFFF0E9CFFFFFFFF0083320E9C1B6115E212465733FFFFFFFFFF34FFFFFFFFFF002B800009690E9C2710000D000E83002402020500002700080001000000FD00000000FFFFFFFFFFFFFFFFFFFFFFFF1FFE1FF3001DBA" + +func FrameTemplate(protocol Protocol, name string) ([]byte, error) { + template := strings.ToLower(strings.TrimSpace(name)) + if template == "" { + template = defaultTemplate(protocol) + } + var rawHex string + switch protocol { + case ProtocolJT808: + switch template { + case "0200", "location", "default": + rawHex = sampleJT808LocationFrame + default: + return nil, fmt.Errorf("unsupported jt808 template %q", name) + } + case ProtocolGB32960: + switch template { + case "0200", "realtime", "default": + rawHex = sampleGB32960RealtimeFrame + default: + return nil, fmt.Errorf("unsupported gb32960 template %q", name) + } + default: + return nil, fmt.Errorf("unsupported protocol %q", protocol) + } + frame, err := hex.DecodeString(rawHex) + if err != nil { + return nil, err + } + return frame, nil +} + +func defaultTemplate(protocol Protocol) string { + switch protocol { + case ProtocolJT808: + return "0200" + case ProtocolGB32960: + return "realtime" + default: + return "default" + } +}