feat(go): add 100k ingest hardening baseline

This commit is contained in:
lingniu
2026-07-03 17:55:22 +08:00
parent 378a5d5e57
commit 79e591f864
20 changed files with 1360 additions and 2 deletions

View File

@@ -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`

View File

@@ -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` | 稳态为 0burst 后下降 |
| 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
- 错误日志

View File

@@ -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 服务健康和最近日志。

View File

@@ -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
```
## 告警阈值建议
| 信号 | 建议阈值 | 含义 |

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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,
)
}

View File

@@ -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)
}
}
}

View File

@@ -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"}

View File

@@ -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"

View File

@@ -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

View File

@@ -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
}

View File

@@ -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")
}
})
}
}

View File

@@ -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
}
}

View File

@@ -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) }

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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"
}
}