Files
lingniu-vehicle-ingest/docs/superpowers/plans/2026-07-02-storage-simplification.md
2026-07-02 19:38:24 +08:00

377 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Storage Simplification 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:** 精简车辆接入落库模型,去掉重复持久化,同时保持 raw 可回放、实时可查、历史位置和日指标可用。
**Architecture:** TDengine 只保留 raw 证据和位置历史两类高写入时间序列MySQL 只保留身份映射、实时当前态和日指标。先兼容 API再停止重复写入最后在生产验证后删除旧表/旧列。
**Tech Stack:** Go, TDengine, MySQL, Redis, Kafka, NATS JetStream, `go test`.
---
## File Structure
- Modify: `go/vehicle-gateway/internal/history/schema.go`
- Stop creating `vehicle_mileage_points` after API compatibility is in place.
- Modify: `go/vehicle-gateway/internal/history/writer.go`
- Stop calling `AppendMileagePoint`; keep total mileage in `vehicle_locations`.
- Modify: `go/vehicle-gateway/internal/history/query.go`
- Make `MileagePointRepository` read from `vehicle_locations` with `total_mileage_km IS NOT NULL`.
- Modify: `go/vehicle-gateway/internal/history/query_test.go`
- Update mileage point query expectations to `vehicle_locations`.
- Modify: `go/vehicle-gateway/internal/history/writer_test.go`
- Prove no `vehicle_mileage_points` stable/table is created or written.
- Modify: `go/vehicle-gateway/internal/history/schema.go`
- In a later task, remove `fields_json` from new `raw_frames` schema.
- Modify: `go/vehicle-gateway/internal/history/writer.go`
- In a later task, stop writing `fields_json`.
- Modify: `go/vehicle-gateway/internal/history/query.go`
- In a later task, make raw query tolerate schemas without `fields_json`.
- Modify: `docs/architecture/storage-minimal-contract.md`
- Keep the target storage contract updated as implementation lands.
### Task 1: Move mileage point queries onto `vehicle_locations`
**Files:**
- Modify: `go/vehicle-gateway/internal/history/query.go`
- Modify: `go/vehicle-gateway/internal/history/query_test.go`
- [ ] **Step 1: Write the failing test**
Update `TestMileagePointHandlerReturnsMileageByVehicleKey` in `go/vehicle-gateway/internal/history/query_test.go` so it expects `vehicle_locations`, not `vehicle_mileage_points`:
```go
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM lingniu_vehicle_ts.vehicle_locations").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(17))
mock.ExpectQuery("SELECT ts, event_id, frame_id, received_at, total_mileage_km, speed_kmh, longitude, latitude, protocol, vehicle_key, vin, phone, device_id FROM lingniu_vehicle_ts.vehicle_locations").
WillReturnRows(sqlmock.NewRows([]string{
"ts", "event_id", "frame_id", "received_at", "total_mileage_km", "speed_kmh", "longitude", "latitude",
"protocol", "vehicle_key", "vin", "phone", "device_id",
}).AddRow("2026-07-02 10:00:00.000", "event-1", "frame-1", "2026-07-02 10:00:01.000", 8792.8, 8.0, 121.07764, 31.22436, "JT808", "JT808:13307811350", "VIN001", "13307811350", "dev001"))
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd go/vehicle-gateway
go test ./internal/history -run 'TestMileagePointHandlerReturnsMileageByVehicleKey|TestBuildMileagePointSQLUsesLiteralsForTDengine' -count=1
```
Expected: FAIL because `MileagePointRepository.tableName()` still returns `vehicle_mileage_points`.
- [ ] **Step 3: Point mileage repository at location table**
Change `MileagePointRepository.tableName()` in `go/vehicle-gateway/internal/history/query.go`:
```go
func (r *MileagePointRepository) tableName() string {
if r.database == "" {
return "vehicle_locations"
}
return r.database + ".vehicle_locations"
}
```
Change `mileagePointWhere` so it filters only rows with total mileage:
```go
func mileagePointWhere(query MileagePointQuery) []string {
where := []string{"total_mileage_km IS NOT NULL"}
add := func(condition string) {
where = append(where, condition)
}
if query.Protocol != "" {
add("protocol = '" + quote(query.Protocol) + "'")
}
if query.VehicleKey != "" {
add("vehicle_key = '" + quote(query.VehicleKey) + "'")
}
if query.VIN != "" {
add("vin = '" + quote(query.VIN) + "'")
}
if query.Phone != "" {
add("phone = '" + quote(query.Phone) + "'")
}
if query.DeviceID != "" {
add("device_id = '" + quote(query.DeviceID) + "'")
}
if query.DateFrom != "" {
add("ts >= '" + quote(normalizeDateTimeLiteral(query.DateFrom)) + "'")
}
if query.DateTo != "" {
add("ts <= '" + quote(normalizeDateTimeLiteral(query.DateTo)) + "'")
}
return where
}
```
- [ ] **Step 4: Run tests**
Run:
```bash
cd go/vehicle-gateway
go test ./internal/history -count=1
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add go/vehicle-gateway/internal/history/query.go go/vehicle-gateway/internal/history/query_test.go
git commit -m "refactor(go): read mileage points from locations"
```
### Task 2: Stop writing duplicate mileage point table
**Files:**
- Modify: `go/vehicle-gateway/internal/history/schema.go`
- Modify: `go/vehicle-gateway/internal/history/writer.go`
- Modify: `go/vehicle-gateway/internal/history/writer_test.go`
- [ ] **Step 1: Write the failing tests**
Update `go/vehicle-gateway/internal/history/writer_test.go`:
```go
for _, want := range []string{"CREATE DATABASE IF NOT EXISTS test_ts", "raw_frames", "vehicle_locations"} {
if got := countSQL(exec.calls, want); got == 0 {
t.Fatalf("expected schema statement containing %q", want)
}
}
if got := countSQL(exec.calls, "vehicle_mileage_points"); got != 0 {
t.Fatalf("schema should not create vehicle_mileage_points, got %d", got)
}
if got := countSQL(exec.calls, "INSERT INTO mil_"); got != 0 {
t.Fatalf("history writer should not insert duplicate mileage point rows, got %d", got)
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd go/vehicle-gateway
go test ./internal/history -run 'TestWriter' -count=1
```
Expected: FAIL because schema and writer still create/write `vehicle_mileage_points`.
- [ ] **Step 3: Remove duplicate write path**
Change `AppendAll` in `go/vehicle-gateway/internal/history/writer.go`:
```go
func (w *Writer) AppendAll(ctx context.Context, env envelope.FrameEnvelope) error {
if err := w.AppendRawFrame(ctx, env); err != nil {
return err
}
return w.AppendLocation(ctx, env)
}
```
Remove `vehicle_mileage_points` from `SchemaStatements` in `go/vehicle-gateway/internal/history/schema.go`. Keep `AppendMileagePoint` only if tests still use it directly; otherwise remove the function and its helper after compile errors guide cleanup.
- [ ] **Step 4: Run tests**
Run:
```bash
cd go/vehicle-gateway
go test ./internal/history ./cmd/history-writer -count=1
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add go/vehicle-gateway/internal/history/schema.go go/vehicle-gateway/internal/history/writer.go go/vehicle-gateway/internal/history/writer_test.go
git commit -m "refactor(go): stop writing duplicate mileage history"
```
### Task 3: Remove `fields_json` from new raw writes
**Files:**
- Modify: `go/vehicle-gateway/internal/history/schema.go`
- Modify: `go/vehicle-gateway/internal/history/writer.go`
- Modify: `go/vehicle-gateway/internal/history/writer_test.go`
- Modify: `go/vehicle-gateway/internal/history/query.go`
- Modify: `go/vehicle-gateway/internal/history/query_test.go`
- [ ] **Step 1: Write failing writer test**
Add this assertion in the raw insert test:
```go
rawInsert := findSQL(exec.calls, "INSERT INTO raw_")
if strings.Contains(rawInsert, "fields_json") {
t.Fatalf("raw insert should not write duplicate fields_json: %s", rawInsert)
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd go/vehicle-gateway
go test ./internal/history -run 'TestWriter' -count=1
```
Expected: FAIL because raw insert still includes `fields_json`.
- [ ] **Step 3: Remove `fields_json` from schema and writer**
In `go/vehicle-gateway/internal/history/schema.go`, remove:
```sql
fields_json BINARY(4096),
```
In `go/vehicle-gateway/internal/history/writer.go`, remove the `fieldsJSON` variable and `fields_json` insert column. Keep `parsed_json` complete.
- [ ] **Step 4: Make raw query backward compatible**
Keep API response field `fields_json,omitempty`, but query only columns that exist. Add a repository option so tests and startup can select the schema shape explicitly:
```go
type RawFrameRepository struct {
db Queryer
database string
includeFieldsJSON bool
}
func NewRawFrameRepository(db Queryer, database string) *RawFrameRepository {
return NewRawFrameRepositoryWithOptions(db, database, RawFrameRepositoryOptions{
IncludeFieldsJSON: true,
})
}
type RawFrameRepositoryOptions struct {
IncludeFieldsJSON bool
}
func NewRawFrameRepositoryWithOptions(db Queryer, database string, opts RawFrameRepositoryOptions) *RawFrameRepository {
if db == nil {
panic("raw frame query db must not be nil")
}
database = strings.TrimSpace(database)
if database != "" && !safeIdentifier(database) {
database = ""
}
return &RawFrameRepository{db: db, database: database, includeFieldsJSON: opts.IncludeFieldsJSON}
}
```
Then split raw SQL construction:
```go
func buildRawFrameSQL(table string, query RawFrameQuery, includeFieldsJSON bool) (string, []any) {
fieldsColumn := ""
if includeFieldsJSON {
fieldsColumn = "fields_json, "
}
sqlText := `SELECT ts, frame_id, event_id, message_id, event_time, received_at, raw_size_bytes, raw_hex, raw_text, parsed_json, ` +
fieldsColumn +
`parse_status, parse_error, source_endpoint, protocol, vehicle_key, vin, phone, device_id FROM ` + table
where := rawFrameWhere(query)
if len(where) > 0 {
sqlText += " WHERE " + strings.Join(where, " AND ")
}
sqlText += " ORDER BY " + rawFrameOrderColumn(query.OrderBy) + " DESC LIMIT " + strconv.Itoa(query.Limit) + " OFFSET " + strconv.Itoa(query.Offset)
return sqlText, nil
}
```
The new-schema test must scan a row without `fields_json` and assert the JSON response omits `fields_json`. The old-schema test must use `IncludeFieldsJSON: true` and assert `fields_json` is hydrated.
- [ ] **Step 5: Run tests**
Run:
```bash
cd go/vehicle-gateway
go test ./internal/history ./cmd/history-writer ./cmd/realtime-api -count=1
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add go/vehicle-gateway/internal/history/schema.go go/vehicle-gateway/internal/history/writer.go go/vehicle-gateway/internal/history/writer_test.go go/vehicle-gateway/internal/history/query.go go/vehicle-gateway/internal/history/query_test.go
git commit -m "refactor(go): remove duplicate raw fields json"
```
### Task 4: Production migration and cleanup gate
**Files:**
- Modify: `docs/architecture/storage-minimal-contract.md`
- Modify: `docs/ops/vehicle-ingest-runbook.md`
- [ ] **Step 1: Deploy the code changes**
Run the existing build/deploy flow for Go services, then restart:
```bash
systemctl restart lingniu-go-history-writer.service
systemctl restart lingniu-go-realtime-api.service
```
- [ ] **Step 2: Verify service health**
Run on ECS:
```bash
for port in 20212 20200; do
curl -fsS "http://127.0.0.1:${port}/readyz"
echo
done
curl -fsS http://127.0.0.1:20212/metrics | grep vehicle_history_kafka_lag
curl -fsS http://127.0.0.1:20212/metrics | grep vehicle_history_writes_total
```
Expected:
- both readiness endpoints return `status=ok`
- history lag drains to `0` or remains bounded
- history writes continue increasing
- [ ] **Step 3: Verify no new duplicate mileage table writes**
Run TDengine checks on ECS:
```sql
SELECT COUNT(*) FROM lingniu_vehicle_ts.vehicle_locations WHERE total_mileage_km IS NOT NULL;
SELECT COUNT(*) FROM lingniu_vehicle_ts.vehicle_mileage_points;
```
Expected: `vehicle_locations` grows; `vehicle_mileage_points` stops increasing after deployment time.
- [ ] **Step 4: Drop old table only after observation**
After at least one production observation window with no direct reads from `vehicle_mileage_points`, run:
```sql
DROP STABLE IF EXISTS lingniu_vehicle_ts.vehicle_mileage_points;
```
- [ ] **Step 5: Commit docs update**
```bash
git add docs/architecture/storage-minimal-contract.md docs/ops/vehicle-ingest-runbook.md
git commit -m "docs: record storage simplification migration"
```
## Self-Review
- Spec coverage: The plan addresses minimal storage, duplicate mileage storage, raw field duplication, production verification, and delayed destructive cleanup.
- Red-flag scan: The plan has no open-ended task. Task 3 specifies the compatibility option and the new/old schema test expectations.
- Type consistency: The plan keeps existing public API types and changes only their backing tables.