Files
lingniu-vehicle-ingest/vehicle-data-platform/docs/deployment.md
2026-07-16 01:51:31 +08:00

26 KiB
Raw Blame History

Deployment

Build

Run from repository root:

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

/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:

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:

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

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:

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:

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:

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

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

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