# Deployment

## Build

Run from repository root:

```bash
cd vehicle-data-platform
pnpm --dir apps/web install
pnpm run web:build
cd apps/api
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/platform-api ./cmd/platform-api
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/alert-evaluator ./cmd/alert-evaluator
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/alert-stream-evaluator ./cmd/alert-stream-evaluator
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/platform-migrate ./cmd/platform-migrate
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/oneos-scope-sync ./cmd/oneos-scope-sync
# Optional release/performance gate; this binary is run on demand, not installed as a service.
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/alert-benchmark ./cmd/alert-benchmark
```

## ECS Paths

```text
/opt/lingniu-vehicle-platform/current
/opt/lingniu-vehicle-platform/releases
/opt/lingniu-vehicle-platform/env/platform.env
```

## Browser-safe release switch

The web application splits every major route into a hashed lazy-loaded asset. An already-open browser tab can therefore request the previous release's route asset after `current` has switched. Keep exactly one previous generation of original build assets in the new release before the atomic symlink switch:

```bash
CURRENT_WEB=/opt/lingniu-vehicle-platform/current/web
NEW_WEB=/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/web

# Archives created on macOS must disable AppleDouble metadata. As a server-side
# guard, remove any metadata files before recording the release manifest.
COPYFILE_DISABLE=1 tar -C apps/web/dist -czf "/tmp/$PLATFORM_RELEASE-web.tar.gz" .
find "$NEW_WEB" -type f -name '._*' -delete

# Record only this release's own build assets before adding compatibility files.
find "$NEW_WEB/assets" -type f -printf '%P\n' | sort > "$NEW_WEB/.release-assets"

if [ -f "$CURRENT_WEB/.release-assets" ]; then
  while IFS= read -r asset; do
    test -n "$asset" || continue
    mkdir -p "$NEW_WEB/assets/$(dirname "$asset")"
    cp -n "$CURRENT_WEB/assets/$asset" "$NEW_WEB/assets/$asset"
  done < "$CURRENT_WEB/.release-assets"
else
  # One-time compatibility for releases created before the manifest existed.
  cp -an "$CURRENT_WEB/assets/." "$NEW_WEB/assets/"
fi
```

Never overwrite a new hashed file. The `.release-assets` manifest prevents compatibility files from accumulating recursively: the next release copies only the immediately previous build's original assets. The React route boundary also detects a failed dynamic import, reloads once with a short cooldown and keeps the application shell usable if recovery still fails.

After the switch, verify the root document, runtime configuration, every current asset and every compatibility asset from the immediately previous release. The smoke downloads each asset and compares it byte-for-byte with the release file, so a SPA fallback returning `index.html` with HTTP 200 cannot hide a missing chunk:

```bash
PREVIOUS_WEB=/opt/lingniu-vehicle-platform/releases/$PREVIOUS_RELEASE/web
/opt/lingniu-vehicle-platform/current/deploy/verify-web-release.sh \
  /opt/lingniu-vehicle-platform/current/web \
  http://127.0.0.1:20300 \
  "$PREVIOUS_WEB/.release-assets"
```

## Environment

```text
HTTP_ADDR=:20300
STATIC_DIR=/opt/lingniu-vehicle-platform/current/web
MYSQL_DSN=lingniu_vehicle:***@tcp(rm-bp179zbv481rnw3e2.mysql.rds.aliyuncs.com:3306)/lingniu_vehicle_data?parseTime=true&loc=Local
REDIS_ADDR=r-bp1u741kij7e51i481.redis.rds.aliyuncs.com:6379
REDIS_USERNAME=lingniu_vehicle
REDIS_PASSWORD=***
REDIS_DB=50
TDENGINE_DRIVER=taosWS
TDENGINE_DSN=root:***@ws(172.17.111.57:6041)/
TDENGINE_DATABASE=lingniu_vehicle_ts
CAPACITY_CHECK_BIN=/opt/lingniu-go-native/current/capacity-check
DATA_MODE=production
EXPORT_DIR=/opt/lingniu-vehicle-platform/data/exports
AUTH_MODE=enforce
# JSON array with viewer/operator/admin principals. Keep this file mode 0600.
AUTH_TOKENS_JSON=[{"token":"<at-least-16-random-characters>","name":"ecs-admin","role":"admin"}]
ONEOS_MYSQL_DSN=vehicle_scope_reader:***@tcp(rm-bp179zbv481rnw3e2.mysql.rds.aliyuncs.com:3306)/ln_asset_management?parseTime=true&loc=Asia%2FShanghai
ONEOS_SCOPE_SYNC_TIMEOUT_SEC=60
ONEOS_SCOPE_MAX_REJECTED=100
ONEOS_SCOPE_MAX_REJECT_RATIO=0.10
REQUEST_TIMEOUT_MS=5000
AMAP_WEB_JS_KEY=***
AMAP_SECURITY_JS_CODE=***
AMAP_SECURITY_SERVICE_HOST=/_AMapService
# Optional reserved server-side key for AMap REST/track services.
AMAP_API_KEY=***
PLATFORM_RELEASE=platform-YYYYMMDDHHMMSS
ALERT_EVALUATION_INTERVAL_SEC=10
ALERT_STREAM_MODE=active
# Defaults to KAFKA_BROKERS from the shared ingest environment when omitted.
ALERT_STREAM_KAFKA_BROKERS=<kafka-host>:9092
ALERT_STREAM_KAFKA_TOPICS=vehicle.fields.go.gb32960.v1,vehicle.fields.go.jt808.v1,vehicle.fields.go.yutong-mqtt.v1
ALERT_STREAM_KAFKA_GROUP=vehicle-alert-stream-shadow-v1
ALERT_STREAM_BATCH_SIZE=200
ALERT_STREAM_BATCH_WAIT_MS=100
ALERT_STREAM_LATENESS_SEC=120
```

`ALERT_STREAM_MODE=shadow` only validates envelopes and advances checkpoints; `active` additionally evaluates every non-`freshness_sec` rule in the same MySQL transaction. Keep the existing consumer group when switching so active resumes the shadow-proven offsets instead of replaying retained history. In active mode the snapshot evaluator retains exclusive ownership of `freshness_sec` and skips all dynamic telemetry rules. Roll back the mode to `shadow` and restart the API plus both evaluators if active batches fail; transaction rollback prevents a failed rule effect from advancing the authoritative checkpoint.

`AMAP_WEB_JS_KEY` is served to the browser through `/app-config.js` so the same static build can be reused across environments. For production, set both `AMAP_SECURITY_JS_CODE` and `AMAP_SECURITY_SERVICE_HOST=/_AMapService`; the API service keeps the security code on the server and proxies AMap requests with `jscode` appended. Only omit `AMAP_SECURITY_SERVICE_HOST` for controlled debugging where exposing `AMAP_SECURITY_JS_CODE` to the browser is acceptable. `AMAP_API_KEY` is reserved for backend-only AMap service APIs such as geocoding, route planning, geofence, or trajectory service integration.

`PLATFORM_RELEASE` is surfaced by `/api/ops/health` at `data.runtime.platformRelease` so operators can confirm which ECS release is currently active after a deployment.

All three platform systemd units first load `/opt/lingniu-go-native/env/base.env` for the existing MySQL, Redis, TDengine and Kafka connection settings, then load `platform.env` for platform-specific overrides. `DATA_MODE=production` is mandatory on ECS: a missing or unreachable MySQL connection returns `DATA_STORE_UNAVAILABLE` instead of silently serving demonstration data. Use `DATA_MODE=mock` only for local development.

`EXPORT_DIR` must point outside the release symlink. The API writes CSV files and an atomic `jobs.json` index there; completed tasks survive API restarts, while interrupted queued/running jobs are marked failed and can be recreated. Exports run one at a time, use 5,000-row forward-only TDengine cursors instead of `OFFSET`, stream into a `.part` file, and atomically publish the final CSV only after flush, `fsync`, and close succeed. A task is limited to five vehicles, 31 days, 32 metrics, 1,000,000 rows, and 30 minutes. Both the initial count and observed rows enforce the row cap so late-arriving data cannot bypass it. Keep the directory mode `0750` and include it in retention/backup policy. Export list and download APIs require `operator` or `admin`.

Release verification for this path includes the opt-in synthetic million-row gate:

```bash
cd vehicle-data-platform/apps/api
EXPORT_MILLION_TEST=1 go test ./internal/platform -run '^TestHistoryExportMillionRows$' -count=1 -v
```

Production smoke must create an export for an active VIN, wait for `completed`, verify `rowCount == processedRows == totalRows`, compare the downloaded SHA-256 and byte count before and after one API restart, and confirm that no `.part` file remains. CSV files begin with query-range and metric/unit metadata followed by the data header.

`AUTH_MODE=enforce` is mandatory on ECS. Tokens are stored as SHA-256 comparisons in memory and sent as Bearer credentials; the browser stores the entered token only in `sessionStorage`. Roles are cumulative:

| Role | Permissions |
| --- | --- |
| `viewer` | Read pages, query data and inspect evidence |
| `operator` | Viewer permissions plus exports, alert actions and notification reads |
| `admin` | Operator permissions plus rule and access-threshold configuration |

After editing the environment file, run `chmod 600 /opt/lingniu-vehicle-platform/env/platform.env`. Never put a real token in Git, static JavaScript, shell history or deployment logs.

`ONEOS_MYSQL_DSN` must use a dedicated account with direct table-level `SELECT` grants only. The sync binary runs `SHOW GRANTS FOR CURRENT_USER` before every read and accepts only global `USAGE` plus `SELECT` on the seven tables used by its query. It refuses database/global reads, `ALL PRIVILEGES`, DML, DDL, PROCESS, replication, roles, `SHOW VIEW`, or any other privilege. It then opens a repeatable-read, read-only transaction, applies a 10-second statement timeout, classifies invalid customer/contract relationships, and atomically publishes a content-addressed local snapshot. Never point it at the existing `ln-bi` account: production audit showed that account still has broad write and replication privileges.

Have an RDS administrator review and run `docs/oneos-scope-reader-provision.sql` separately. It restricts the login source to the verified ECS private address and grants `SELECT` on only the seven source tables used by the query. Do not include that DDL in application deployment or migration automation. Store `platform.env` as root-owned mode `0600`, and verify `SHOW GRANTS` before enabling the timer.

The active lifecycle comes from OneOS delivery and return task facts: the newest active delivery must have `delivery_status IN (2,3)`, and it must not have an active return task with `status IN (2,3,5)`. The sync intentionally does not use `vehicle_lease_order_record.last_return_time`, because OneOS currently writes that aggregate field while a return form is still a draft. The aggregate record remains a separate customer-ownership cross-check; missing, duplicate, or conflicting ownership fails closed.

The default two-minute timer reads one bounded current-scope result set (639 rows at the 2026-07-14 transactional-fact baseline), so it avoids per-request access to OneOS and adds negligible RDS load. A run is rejected before publication if it has no accepted rows, more than 100 rejected rows, or a rejected ratio above 10%. Current baseline is 613 accepted and 26 quarantined. An unchanged content hash records a successful run without duplicating the snapshot.

## Forward Migration

Before switching the API symlink, apply the idempotent access-threshold migration with a MySQL account allowed to create/alter platform-owned tables:

```bash
export MYSQL_DSN="$(sed -n 's/^MYSQL_DSN=//p' /opt/lingniu-go-native/env/base.env | tail -1)"
test -n "$MYSQL_DSN"
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/platform-migrate \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/001_access_thresholds.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/002_alert_center.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/003_alert_rule_advanced.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/004_alert_repeat_index.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/005_metric_catalog.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/006_vehicle_profile.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/007_alert_master_data_scope.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/008_access_projection.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/009_alert_stream_checkpoint.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/010_alert_stream_metric_mapping.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/011_alert_stream_invalid_evidence.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/012_business_scope_projection.sql
```

The API guards the access-threshold tables for compatibility, while alert APIs deliberately require the alert migrations to exist. Run every numbered migration explicitly before switching traffic so DDL permission and index creation failures are caught early. The migration journal records filename and SHA-256 and refuses a changed file; duplicate forward `ADD COLUMN` and `CREATE INDEX` statements are tolerated only when resuming partially executed MySQL DDL. Full-line SQL comments are removed before statement splitting, so punctuation in a comment cannot become executable SQL. Migration `008` adds forward-compatible access evidence columns and an index to the gateway-owned realtime snapshot table without changing its `(protocol, vin)` primary key. Migration `009` creates the per-group/topic/partition event-time checkpoint used to make MySQL effects authoritative before Kafka offsets are committed.

Install and start the evaluator as a separate unit only after API smoke checks and a small enabled-rule review:

```bash
sudo cp deploy/systemd/lingniu-vehicle-alert-evaluator.service /etc/systemd/system/
sudo cp deploy/systemd/lingniu-vehicle-alert-stream-evaluator.service /etc/systemd/system/
sudo cp deploy/systemd/lingniu-vehicle-oneos-scope-sync.service /etc/systemd/system/
sudo cp deploy/systemd/lingniu-vehicle-oneos-scope-sync.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now lingniu-vehicle-platform lingniu-vehicle-alert-evaluator lingniu-vehicle-alert-stream-evaluator lingniu-vehicle-oneos-scope-sync.timer
sudo systemctl status --no-pager lingniu-vehicle-alert-evaluator
sudo systemctl status --no-pager lingniu-vehicle-alert-stream-evaluator
sudo journalctl -u lingniu-vehicle-alert-evaluator -n 100 --no-pager
sudo journalctl -u lingniu-vehicle-alert-stream-evaluator -n 100 --no-pager
sudo systemctl status --no-pager lingniu-vehicle-oneos-scope-sync.timer
sudo journalctl -u lingniu-vehicle-oneos-scope-sync.service -n 100 --no-pager
```

## Health

```bash
read -rsp 'Platform admin token: ' PLATFORM_TOKEN; echo
AUTH_HEADER="Authorization: Bearer $PLATFORM_TOKEN"
curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/v2/session
curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/ops/health
curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/v2/metrics
curl -fsS -H "$AUTH_HEADER" 'http://127.0.0.1:20300/api/v2/monitor/summary?limit=200'
curl -fsS -H "$AUTH_HEADER" -H 'Content-Type: application/json' \
  -d '{"keyword":"LNXNEGRR7SR318212","status":"active","limit":20,"offset":0}' \
  http://127.0.0.1:20300/api/v2/alerts/events
curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/v2/vehicles/LNXNEGRR7SR318212/telemetry/latest
curl -fsS -H "$AUTH_HEADER" 'http://127.0.0.1:20300/api/vehicles?limit=5'
curl -fsS -H "$AUTH_HEADER" \
  'http://127.0.0.1:20300/api/v2/history/series?keyword=LNXNEGRR7SR318212&dateFrom=2026-07-14T00%3A00&dateTo=2026-07-14T06%3A00&targetPoints=240'
curl -fsS -X POST -H "$AUTH_HEADER" -H 'Content-Type: application/json' -d '{"limit":5,"offset":0}' \
  http://127.0.0.1:20300/api/v2/access/vehicles
curl -fsS -X POST -H "$AUTH_HEADER" -H 'Content-Type: application/json' -d '{"limit":20,"offset":0}' \
  http://127.0.0.1:20300/api/v2/access/unresolved-identities
curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/v2/access/thresholds
curl -fsS -X POST -H "$AUTH_HEADER" -H 'Content-Type: application/json' \
  -d '{"sourceSystem":"release-smoke","sourceVersion":"dry-run-1","conflictPolicy":"preserve","dryRun":true,"items":[{"vin":"RELEASE_SMOKE_MISSING","operationStatus":"unknown"}]}' \
  http://127.0.0.1:20300/api/v2/vehicle-profiles/sync
curl -fsS -X POST -H "$AUTH_HEADER" -H 'Content-Type: application/json' -d '{"limit":5,"offset":0}' \
  http://127.0.0.1:20300/api/v2/alerts/events
curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/v2/alerts/rules
curl -fsS -H "$AUTH_HEADER" 'http://127.0.0.1:20300/api/v2/alerts/notifications?unreadOnly=true&limit=5'
curl -fsS http://127.0.0.1:20300/app-config.js
curl -fsS http://127.0.0.1:20300/ | grep -q '<div id="root"></div>'
MAIN_ASSET="$(sed -n 's/.*src="\([^"]*\.js\)".*/\1/p' /opt/lingniu-vehicle-platform/current/web/index.html | head -1)"
curl -fsS "http://127.0.0.1:20300$MAIN_ASSET" >/dev/null
unset PLATFORM_TOKEN AUTH_HEADER
```

The release gate must include the root document, runtime configuration, every asset in the current `.release-assets` manifest and every asset in the immediately previous release manifest. API-only smoke checks are insufficient because an incomplete archive can leave the service healthy while the browser returns 404 or an HTML fallback for a missing chunk. Before upload, verify the archive contains `web/index.html`, `platform-api`, both evaluator binaries, `platform-migrate`, `oneos-scope-sync`, every numbered migration, the web-release verifier and all platform systemd units.

When `alertStream.lastInvalidCode` reports a recent `missing_vin_jt808`, query `/api/v2/access/unresolved-identities` as a viewer and hand the masked evidence to the identity owner. The response must not contain raw `phone` or unmasked identifier fields. Confirm the evidence against the GPS provider or vehicle owner, then maintain the authoritative `vehicle_identity_binding`; never derive VIN from the terminal number. The queue excludes a terminal after a valid binding exists. Retain the invalid counter as audit evidence and verify the recent warning clears after five minutes without another invalid frame.

The history-series gate must use an active production VIN and a current local-time window. Verify that `rawPointCount > 0`, every returned series remains within the requested point budget, `dateFrom`/`dateTo` and point timestamps describe the same absolute window, and the browser renders `Asia/Shanghai` labels. TDengine timestamp literals for this endpoint include an explicit RFC3339 offset; bare UTC-looking strings are unsafe because the server session may interpret them again in its local timezone.

Track smoke must also select the final playback event and verify synchronized SOC/direction/alarm availability plus a resolved current address. For high-frequency sources, confirm positive subsecond samples do not become alternating zero-second `数据间隔` segments; only non-increasing timestamps or intervals over ten minutes are gaps. Access smoke should exercise `model`/`provider` and one paired first/latest receive-time range, then confirm the filter survives a shareable URL and every returned row remains in scope.

Global-monitor smoke must verify `zoom=5` returns clusters, a city `bounds` at `zoom=13` returns at most 2,000 lightweight points, and invalid/reversed bounds return HTTP 400. In the browser, the rail renders at most 200 vehicle rows while the legend reports the independent map mode. Selecting `driving` or `idle` must constrain both the server-side count and every loaded row. A unique keyword result must discard the previous viewport and focus at point zoom. The synthetic release gate is `go test ./internal/platform -run TestMonitorMapTenThousandVehicles -count=1` plus `go test ./internal/platform -run '^$' -bench BenchmarkMonitorMapTenThousandVehicles -benchtime=30x -benchmem`.

Latest-telemetry smoke must use an actively reporting VIN and verify that populated categories exactly sum to `values.length`, catalog-mapped values retain both the unified `key` and exact `sourceField`, every value has frame/time/protocol evidence, and the quality reason agrees with freshness and parser status. `scannedFrames` must remain at or below 15: the implementation resolves identity once, queries the three supported protocol tables concurrently with five rows each, and never runs a pagination count. The browser must display source, compact device time and quality without deriving labels, units or categories from RAW keys. The bounded synthetic gate is `go test ./internal/platform -run 'TestBuildLatestTelemetryResponse|TestLatestTelemetryQuality|TestLatestTelemetryBoundsProtocolReads' -count=1` plus `go test ./internal/platform -run '^$' -bench BenchmarkLatestTelemetryHundredFrames -benchmem`.

Alert evaluation is intentionally not exposed as an HTTP route because it mutates candidate/event state. Production evaluation is owned by the evaluator unit. Confirm its `rules/vehicles/candidates_advanced/duplicate_observations/late_observations/stale_evidence_skipped/opened/recovered` log line and verify the resulting event through the read APIs. A release gate with an active scoped rule must survive at least one persisted candidate reload and one persisted last-trigger reload; MySQL `DATETIME(3)` values are scanned directly as times so millisecond values are never forced through an integer `UNIX_TIMESTAMP` conversion.

The Kafka stream evaluator accepts only explicit `shadow` and `active` modes. A new group starts at the latest retained offset, then resumes committed offsets. Each batch validates the canonical `FIELDS` envelope, protocol/topic pairing, field namespace, identity and event/receive time. In active mode it locks enabled dynamic rules, reads only state belonging to the current batch's rules/VINs/protocols, applies candidate/event/action/notification changes, and updates the MySQL checkpoint in one transaction; Kafka is committed only afterward. Replayed offsets below the database checkpoint are skipped. Evidence beyond the configured receive/event lateness window can still drive `data_delay_sec`, but cannot open or recover speed/SOC/alarm rules. `/api/ops/health.alertStream` and the `Alert Kafka stream` link expose mode, partitions, checkpoint lag, processed/valid/invalid/late/replay counts, last invalid code/time and last update. A recent `missing_vin_<protocol>` warning means a real terminal is still reporting without an authoritative VIN; resolve it through access/identity operations instead of fabricating a binding. Historical invalid totals remain audit evidence but stop holding the link in warning five minutes after the last invalid message. Release requires lag below the configured gate, no unexplained invalid messages and an active-rule canary with exactly-once event evidence.

## Alert Candidate Write Benchmark

`alert-benchmark` measures the evaluator's seven-column candidate batch shape against the configured MySQL/RDS connection. It never writes `vehicle_alert_candidate` itself and only accepts two compiled-in benchmark table names.

- The default `temporary` mode creates a connection-scoped temporary table. It is the lowest-risk network, SQL and index-path check.
- `durable` mode requires `--confirm-durable-write`, takes a zero-wait MySQL advisory lock, creates a dedicated physical `vehicle_alert_candidate_benchmark_durable` table with `LIKE vehicle_alert_candidate`, commits one transaction, verifies the row count, drops the table and verifies its removal through `information_schema`. A stale table from an interrupted prior run is removed only after the lock is held.
- Both modes are bounded to 100,000 rows and 1,000 rows per insert. The process uses an independent cleanup timeout so normal error returns still clean up the benchmark table.

```bash
export MYSQL_DSN="$(sed -n 's/^MYSQL_DSN=//p' /opt/lingniu-go-native/env/base.env | tail -1)"
/opt/lingniu-vehicle-platform/current/alert-benchmark --mode temporary --rows 10000 --batch-size 500
/opt/lingniu-vehicle-platform/current/alert-benchmark --mode durable --confirm-durable-write --rows 10000 --batch-size 500
/opt/lingniu-vehicle-platform/current/alert-benchmark --mode durable --confirm-durable-write --rows 100000 --batch-size 500
unset MYSQL_DSN
```

ECS baseline on 2026-07-14 against production RDS MySQL 8.0.36. Every row count matched and every dedicated table cleanup was verified:

| Mode | Rows | Batch | Transactions | Duration | Throughput | Observed redo delta | Cleanup |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| temporary | 10,000 | 500 | 1 | 192ms | 51,883 rows/s | n/a | verified |
| temporary | 100,000 | 500 | 1 | 1,765ms | 56,646 rows/s | n/a | verified |
| durable | 10,000 | 500 | 1 | 195ms | 51,234 rows/s | 3,121,664 bytes | 3ms, verified |
| durable | 100,000 | 500 | 1 | 1,971ms | 50,713 rows/s | 31,037,440 bytes | 3ms, verified |

`globalStatusDelta` is sampled from MySQL instance-global counters, including `Innodb_os_log_written`, `Binlog_cache_use`, `Binlog_cache_disk_use` and `Com_commit`. These values prove redo/binlog machinery was active around the durable run, but they are shared with concurrent RDS traffic and are not an exact per-benchmark attribution. The durable result closes the committed physical-table throughput gap without polluting business data; it still does not replace long-running evaluator observation with active rules, lock-contention tests, RDS Performance Insights, or binlog/redo monitoring over a representative peak window.

## Rollback

Keep the previous release directory until post-release monitoring is complete. Database changes are forward-compatible and are not rolled back. If API or browser checks fail, atomically restore the old symlink and restart both units:

```bash
PREVIOUS_RELEASE=/opt/lingniu-vehicle-platform/releases/<previous-release>
sudo ln -sfn "$PREVIOUS_RELEASE" /opt/lingniu-vehicle-platform/current
sudo systemctl restart lingniu-vehicle-platform
sudo systemctl stop lingniu-vehicle-alert-evaluator
sudo systemctl status --no-pager lingniu-vehicle-platform
```

Do not roll back the symlink if the old binary cannot tolerate the forward schema; validate this before every migration. The current `001`–`011` migrations are forward-only additions of tables, columns and indexes; the old realtime writer ignores the nullable/defaulted `008` columns and the prior API does not select them. Migration `003` once stopped after its first two MySQL `ALTER` statements because `last_value` was a reserved identifier; the final immutable migration uses `observed_value`, and the journaled runner safely resumed only the duplicate schema objects before creating the missing state table. Do not edit an applied migration—add the next numbered migration for future schema changes.
