> **Superseded:** This 2026-06-23 service-split plan is historical context.
> Use `docs/target-architecture.md` and
> `docs/superpowers/specs/2026-06-29-vehicle-ingest-redesign.md` for the
> current production architecture: GB32960, JT808, and Yutong MQTT are the
> active ingest apps, history is backed by TDengine, and Xinda/event-file-store
> paths have been removed from the current build surface.
# GB32960 Service Split 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:** Split the current all-in-one GB32960 production path into separate ingest, history, and analytics runnable apps with Kafka as the durability boundary and ACK after Kafka success.
**Architecture:** Add three Spring Boot app modules while keeping `bootstrap-all` available for development and rollback. First isolate runtime composition, then introduce GB32960-specific Kafka topics, then change the GB32960 ACK path so realtime/report ACKs are written after required Kafka production succeeds. History and analytics become Kafka consumers in separate apps and no longer share a JVM with TCP ingest.
**Tech Stack:** Java 25, Spring Boot 3.5, Maven multi-module build, Netty, Kafka clients, Protobuf envelope, DuckDB/Parquet event store, local archive sink.
---
## Current-State Notes
- Existing all-in-one entrypoint: `modules/apps/bootstrap-all/src/main/java/com/lingniu/ingest/bootstrap/IngestApplication.java`.
- Existing all-in-one module at the time of this plan imported protocols,
sinks, history, state, stat, command gateway, MQTT, and JT modules. Xinda was
later removed from source and build configuration.
- Existing GB32960 handler: `modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandler.java`.
- Existing handler currently writes report ACK before `dispatcher.dispatch(rf)`.
- Existing `DisruptorEventBus` publishes to sinks asynchronously and does not wait for sink completion.
- Existing `KafkaEventSink.accepts()` rejects `VehicleEvent.RawArchive`.
- Existing versioned topic names are not yet present; current topic names live under `lingniu.ingest.sink.kafka.topics`.
- Existing history consumer logic: `modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/EventHistoryEnvelopeIngestor.java`.
- Existing analytics consumers: `VehicleStateEnvelopeIngestor` and `VehicleStatEnvelopeIngestor`.
- There is an unrelated working-tree change in `modules/apps/bootstrap-all/src/main/resources/application.yml` for GB32960 diagnostics capacity. Do not revert it. Do not include it in service-split commits unless the user explicitly asks.
## File Structure
Create app modules:
- `modules/apps/gb32960-ingest-app/pom.xml`
- `modules/apps/gb32960-ingest-app/src/main/java/com/lingniu/ingest/gb32960app/Gb32960IngestApplication.java`
- `modules/apps/gb32960-ingest-app/src/main/resources/application.yml`
- `modules/apps/gb32960-ingest-app/src/test/java/com/lingniu/ingest/gb32960app/Gb32960IngestAppCompositionTest.java`
- `modules/apps/gb32960-ingest-app/src/test/java/com/lingniu/ingest/gb32960app/Gb32960IngestAppDefaultsTest.java`
- `modules/apps/vehicle-history-app/pom.xml`
- `modules/apps/vehicle-history-app/src/main/java/com/lingniu/ingest/historyapp/VehicleHistoryApplication.java`
- `modules/apps/vehicle-history-app/src/main/resources/application.yml`
- `modules/apps/vehicle-history-app/src/test/java/com/lingniu/ingest/historyapp/VehicleHistoryAppCompositionTest.java`
- `modules/apps/vehicle-history-app/src/test/java/com/lingniu/ingest/historyapp/VehicleHistoryAppDefaultsTest.java`
- `modules/apps/vehicle-analytics-app/pom.xml`
- `modules/apps/vehicle-analytics-app/src/main/java/com/lingniu/ingest/analyticsapp/VehicleAnalyticsApplication.java`
- `modules/apps/vehicle-analytics-app/src/main/resources/application.yml`
- `modules/apps/vehicle-analytics-app/src/test/java/com/lingniu/ingest/analyticsapp/VehicleAnalyticsAppCompositionTest.java`
- `modules/apps/vehicle-analytics-app/src/test/java/com/lingniu/ingest/analyticsapp/VehicleAnalyticsAppDefaultsTest.java`
Modify shared build and Kafka contract files:
- `pom.xml`
- `modules/sinks/sink-kafka/src/main/java/com/lingniu/ingest/sink/kafka/KafkaSinkProperties.java`
- `modules/sinks/sink-kafka/src/main/java/com/lingniu/ingest/sink/kafka/TopicRouter.java`
- `modules/sinks/sink-kafka/src/main/java/com/lingniu/ingest/sink/kafka/KafkaEventSink.java`
- `modules/sinks/sink-kafka/src/test/java/com/lingniu/ingest/sink/kafka/TopicRouterTest.java`
- `modules/sinks/sink-kafka/src/test/java/com/lingniu/ingest/sink/kafka/KafkaEventSinkTest.java`
Modify ACK/Kafka durability boundary files:
- `modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/sink/EventSink.java`
- `modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/Dispatcher.java`
- `modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/DisruptorEventBus.java`
- `modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandler.java`
- `modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandlerAckBoundaryTest.java`
Optional later follow-up files after the first split is running:
- `docs/operations/gb32960-service-split-runbook.md`
- Deployment manifests or service manager files used by the user's environment.
---
### Task 1: Register the Three New App Modules
**Files:**
- Modify: `pom.xml`
- Create: `modules/apps/gb32960-ingest-app/pom.xml`
- Create: `modules/apps/vehicle-history-app/pom.xml`
- Create: `modules/apps/vehicle-analytics-app/pom.xml`
- [ ] **Step 1: Add failing module-presence test by running Maven before modules exist**
Run:
```bash
export JAVA_HOME=/opt/homebrew/opt/openjdk@25/libexec/openjdk.jdk/Contents/Home
export PATH="$JAVA_HOME/bin:/opt/homebrew/bin:$PATH"
mvn -pl :gb32960-ingest-app -am validate
```
Expected: FAIL with a message that Maven cannot find project `:gb32960-ingest-app`.
- [ ] **Step 2: Add modules to the root reactor**
In `pom.xml`, add these module entries after `modules/apps/command-gateway` and before `modules/apps/bootstrap-all`:
```xml
modules/apps/gb32960-ingest-app
modules/apps/vehicle-history-app
modules/apps/vehicle-analytics-app
```
Also add dependency management entries near the existing `command-gateway` entry:
```xml
com.lingniu.ingest
gb32960-ingest-app
${project.version}
com.lingniu.ingest
vehicle-history-app
${project.version}
com.lingniu.ingest
vehicle-analytics-app
${project.version}
```
- [ ] **Step 3: Create `gb32960-ingest-app` POM**
Create `modules/apps/gb32960-ingest-app/pom.xml`:
```xml
4.0.0
com.lingniu.ingest
lingniu-vehicle-ingest
0.1.0-SNAPSHOT
../../../pom.xml
gb32960-ingest-app
gb32960-ingest-app
GB32960 protocol ingress runtime: TCP decode, auth, Kafka production, and ACK.
com.lingniu.ingest
ingest-core
com.lingniu.ingest
session-core
com.lingniu.ingest
observability
com.lingniu.ingest
protocol-gb32960
com.lingniu.ingest
sink-kafka
org.springframework.boot
spring-boot-starter
org.springframework.boot
spring-boot-starter-test
test
gb32960-ingest-app
org.springframework.boot
spring-boot-maven-plugin
--sun-misc-unsafe-memory-access=allow
repackage
```
- [ ] **Step 4: Create `vehicle-history-app` POM**
Create `modules/apps/vehicle-history-app/pom.xml`:
```xml
4.0.0
com.lingniu.ingest
lingniu-vehicle-ingest
0.1.0-SNAPSHOT
../../../pom.xml
vehicle-history-app
vehicle-history-app
Vehicle raw archive, event history, and GB32960 frame query runtime.
com.lingniu.ingest
ingest-api
com.lingniu.ingest
observability
com.lingniu.ingest
sink-kafka
com.lingniu.ingest
sink-archive
com.lingniu.ingest
event-file-store
com.lingniu.ingest
event-history-service
com.lingniu.ingest
protocol-gb32960
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-test
test
vehicle-history-app
org.springframework.boot
spring-boot-maven-plugin
--sun-misc-unsafe-memory-access=allow
repackage
```
- [ ] **Step 5: Create `vehicle-analytics-app` POM**
Create `modules/apps/vehicle-analytics-app/pom.xml`:
```xml
4.0.0
com.lingniu.ingest
lingniu-vehicle-ingest
0.1.0-SNAPSHOT
../../../pom.xml
vehicle-analytics-app
vehicle-analytics-app
Vehicle state, daily statistics, alarms, and analytics runtime.
com.lingniu.ingest
ingest-api
com.lingniu.ingest
observability
com.lingniu.ingest
sink-kafka
com.lingniu.ingest
vehicle-state-service
com.lingniu.ingest
vehicle-stat-service
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-test
test
vehicle-analytics-app
org.springframework.boot
spring-boot-maven-plugin
--sun-misc-unsafe-memory-access=allow
repackage
```
- [ ] **Step 6: Verify the modules are visible to Maven**
Run:
```bash
export JAVA_HOME=/opt/homebrew/opt/openjdk@25/libexec/openjdk.jdk/Contents/Home
export PATH="$JAVA_HOME/bin:/opt/homebrew/bin:$PATH"
mvn -pl :gb32960-ingest-app,:vehicle-history-app,:vehicle-analytics-app -am validate
```
Expected: BUILD SUCCESS.
- [ ] **Step 7: Commit**
```bash
git add pom.xml modules/apps/gb32960-ingest-app/pom.xml modules/apps/vehicle-history-app/pom.xml modules/apps/vehicle-analytics-app/pom.xml
git commit -m "build: add split service app modules"
```
---
### Task 2: Add App Entrypoints and Runtime Defaults
**Files:**
- Create: `modules/apps/gb32960-ingest-app/src/main/java/com/lingniu/ingest/gb32960app/Gb32960IngestApplication.java`
- Create: `modules/apps/gb32960-ingest-app/src/main/resources/application.yml`
- Create: `modules/apps/vehicle-history-app/src/main/java/com/lingniu/ingest/historyapp/VehicleHistoryApplication.java`
- Create: `modules/apps/vehicle-history-app/src/main/resources/application.yml`
- Create: `modules/apps/vehicle-analytics-app/src/main/java/com/lingniu/ingest/analyticsapp/VehicleAnalyticsApplication.java`
- Create: `modules/apps/vehicle-analytics-app/src/main/resources/application.yml`
- [ ] **Step 1: Create the GB32960 ingest main class**
Create `modules/apps/gb32960-ingest-app/src/main/java/com/lingniu/ingest/gb32960app/Gb32960IngestApplication.java`:
```java
package com.lingniu.ingest.gb32960app;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = "com.lingniu.ingest")
public class Gb32960IngestApplication {
private static final Logger log = LoggerFactory.getLogger(Gb32960IngestApplication.class);
public static void main(String[] args) {
try {
SpringApplication.run(Gb32960IngestApplication.class, args);
} catch (Throwable t) {
log.error("GB32960 ingest application failed to start, forcing JVM exit", t);
System.exit(1);
}
}
}
```
- [ ] **Step 2: Create the GB32960 ingest config**
Create `modules/apps/gb32960-ingest-app/src/main/resources/application.yml`:
```yaml
spring:
application:
name: gb32960-ingest-app
threads:
virtual:
enabled: true
server:
port: ${HTTP_PORT:20100}
lingniu:
ingest:
gb32960:
enabled: true
port: ${GB32960_PORT:32960}
boss-threads: ${GB32960_BOSS_THREADS:1}
worker-threads: ${GB32960_WORKER_THREADS:0}
auth:
enabled: ${GB32960_AUTH_ENABLED:false}
case-sensitive: false
whitelist: []
platforms:
- username: ${GB32960_PLATFORM_USER_HYUNDAI:Hyundai}
password: ${GB32960_PLATFORM_PWD_HYUNDAI:f2e3445d7cda409fb4f278f6fb890734}
allowed-ips:
- ${GB32960_PLATFORM_IP_HYUNDAI:8.134.95.166}
description: 外部下级平台 - Hyundai
tls:
enabled: ${GB32960_TLS_ENABLED:false}
cert-path: ${GB32960_TLS_CERT:}
key-path: ${GB32960_TLS_KEY:}
trust-cert-path: ${GB32960_TLS_CA:}
require-client-auth: true
vendor-extensions:
- name: guangdong-fc
match:
platform-accounts:
- Hyundai
vin-prefixes: []
vins: []
parse:
lenient-block-failure: true
diagnostics:
max-logged-frame-keys-per-channel: ${GB32960_DIAGNOSTICS_MAX_LOGGED_FRAME_KEYS_PER_CHANNEL:4096}
pipeline:
disruptor:
ring-buffer-size: ${PIPELINE_RING_BUFFER_SIZE:131072}
wait-strategy: ${PIPELINE_WAIT_STRATEGY:yielding}
producer-type: multi
dedup:
enabled: true
cache-size: 200000
ttl-seconds: 600
rate-limit:
per-vin-qps: 50
session:
store: ${SESSION_STORE:redis}
ttl: ${SESSION_TTL:30m}
identity:
store: ${VEHICLE_IDENTITY_STORE:mysql}
mysql:
jdbc-url: ${VEHICLE_IDENTITY_MYSQL_JDBC_URL:jdbc:mysql://127.0.0.1:3306/lingniu_vehicle?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai}
username: ${VEHICLE_IDENTITY_MYSQL_USERNAME:root}
password: ${VEHICLE_IDENTITY_MYSQL_PASSWORD:}
table-name: ${VEHICLE_IDENTITY_MYSQL_TABLE:vehicle_identity_bindings}
initialize-schema: ${VEHICLE_IDENTITY_MYSQL_INITIALIZE_SCHEMA:true}
sink:
kafka:
enabled: ${KAFKA_ENABLED:true}
bootstrap-servers: ${KAFKA_BROKERS:114.55.58.251:9092}
compression-type: zstd
linger-ms: 20
batch-size: 65536
acks: all
enable-idempotence: true
node-id: ${KAFKA_NODE_ID:gb32960-ingest-local}
topics:
realtime: ${KAFKA_TOPIC_GB32960_EVENT:vehicle.event.gb32960.v1}
location: ${KAFKA_TOPIC_GB32960_EVENT:vehicle.event.gb32960.v1}
alarm: ${KAFKA_TOPIC_GB32960_EVENT:vehicle.event.gb32960.v1}
session: ${KAFKA_TOPIC_GB32960_EVENT:vehicle.event.gb32960.v1}
media-meta: ${KAFKA_TOPIC_MEDIA_META:vehicle.media.meta.v1}
raw-archive: ${KAFKA_TOPIC_GB32960_RAW:vehicle.raw.gb32960.v1}
dlq: ${KAFKA_TOPIC_GB32960_DLQ:vehicle.dlq.gb32960.v1}
consumer:
enabled: false
event-file-store:
enabled: false
event-history:
enabled: false
vehicle-state:
enabled: false
vehicle-stat:
enabled: false
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,env
health:
redis:
enabled: ${MANAGEMENT_HEALTH_REDIS_ENABLED:false}
metrics:
tags:
application: gb32960-ingest-app
logging:
level:
root: INFO
com.lingniu.ingest: INFO
```
- [ ] **Step 3: Create the history app main class**
Create `modules/apps/vehicle-history-app/src/main/java/com/lingniu/ingest/historyapp/VehicleHistoryApplication.java`:
```java
package com.lingniu.ingest.historyapp;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = "com.lingniu.ingest")
public class VehicleHistoryApplication {
private static final Logger log = LoggerFactory.getLogger(VehicleHistoryApplication.class);
public static void main(String[] args) {
try {
SpringApplication.run(VehicleHistoryApplication.class, args);
} catch (Throwable t) {
log.error("Vehicle history application failed to start, forcing JVM exit", t);
System.exit(1);
}
}
}
```
- [ ] **Step 4: Create the history app config**
Create `modules/apps/vehicle-history-app/src/main/resources/application.yml`:
```yaml
spring:
application:
name: vehicle-history-app
threads:
virtual:
enabled: true
server:
port: ${HTTP_PORT:20200}
lingniu:
ingest:
gb32960:
enabled: false
sink:
kafka:
enabled: ${KAFKA_ENABLED:true}
bootstrap-servers: ${KAFKA_BROKERS:114.55.58.251:9092}
topics:
realtime: ${KAFKA_TOPIC_GB32960_EVENT:vehicle.event.gb32960.v1}
raw-archive: ${KAFKA_TOPIC_GB32960_RAW:vehicle.raw.gb32960.v1}
dlq: ${KAFKA_TOPIC_GB32960_DLQ:vehicle.dlq.gb32960.v1}
consumer:
enabled: ${KAFKA_CONSUMER_ENABLED:true}
client-id-prefix: ${KAFKA_CONSUMER_CLIENT_ID_PREFIX:vehicle-history}
auto-offset-reset: ${KAFKA_CONSUMER_AUTO_OFFSET_RESET:earliest}
max-poll-records: ${KAFKA_CONSUMER_MAX_POLL_RECORDS:500}
bindings:
eventHistoryEnvelopeConsumerProcessor:
enabled: true
group-id: ${KAFKA_GROUP_HISTORY:vehicle-history}
topics:
- ${KAFKA_TOPIC_GB32960_EVENT:vehicle.event.gb32960.v1}
- ${KAFKA_TOPIC_GB32960_RAW:vehicle.raw.gb32960.v1}
archive:
enabled: ${SINK_ARCHIVE_ENABLED:true}
type: local
path: ${SINK_ARCHIVE_PATH:./archive/}
event-file-store:
enabled: ${EVENT_FILE_STORE_ENABLED:true}
path: ${EVENT_FILE_STORE_PATH:./target/event-store/}
zone-id: ${EVENT_FILE_STORE_ZONE_ID:Asia/Shanghai}
batch-size: ${EVENT_FILE_STORE_BATCH_SIZE:500}
flush-interval-millis: ${EVENT_FILE_STORE_FLUSH_INTERVAL_MILLIS:1000}
event-history:
enabled: true
vehicle-state:
enabled: false
vehicle-stat:
enabled: false
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,env
health:
redis:
enabled: ${MANAGEMENT_HEALTH_REDIS_ENABLED:false}
metrics:
tags:
application: vehicle-history-app
```
- [ ] **Step 5: Create the analytics app main class**
Create `modules/apps/vehicle-analytics-app/src/main/java/com/lingniu/ingest/analyticsapp/VehicleAnalyticsApplication.java`:
```java
package com.lingniu.ingest.analyticsapp;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = "com.lingniu.ingest")
public class VehicleAnalyticsApplication {
private static final Logger log = LoggerFactory.getLogger(VehicleAnalyticsApplication.class);
public static void main(String[] args) {
try {
SpringApplication.run(VehicleAnalyticsApplication.class, args);
} catch (Throwable t) {
log.error("Vehicle analytics application failed to start, forcing JVM exit", t);
System.exit(1);
}
}
}
```
- [ ] **Step 6: Create the analytics app config**
Create `modules/apps/vehicle-analytics-app/src/main/resources/application.yml`:
```yaml
spring:
application:
name: vehicle-analytics-app
threads:
virtual:
enabled: true
server:
port: ${HTTP_PORT:20300}
lingniu:
ingest:
gb32960:
enabled: false
sink:
kafka:
enabled: ${KAFKA_ENABLED:true}
bootstrap-servers: ${KAFKA_BROKERS:114.55.58.251:9092}
topics:
realtime: ${KAFKA_TOPIC_GB32960_EVENT:vehicle.event.gb32960.v1}
dlq: ${KAFKA_TOPIC_GB32960_DLQ:vehicle.dlq.gb32960.v1}
consumer:
enabled: ${KAFKA_CONSUMER_ENABLED:true}
client-id-prefix: ${KAFKA_CONSUMER_CLIENT_ID_PREFIX:vehicle-analytics}
auto-offset-reset: ${KAFKA_CONSUMER_AUTO_OFFSET_RESET:earliest}
max-poll-records: ${KAFKA_CONSUMER_MAX_POLL_RECORDS:500}
bindings:
vehicleStateEnvelopeConsumerProcessor:
enabled: ${VEHICLE_STATE_ENABLED:false}
group-id: ${KAFKA_GROUP_STATE:vehicle-state}
topics:
- ${KAFKA_TOPIC_GB32960_EVENT:vehicle.event.gb32960.v1}
vehicleStatEnvelopeConsumerProcessor:
enabled: ${VEHICLE_STAT_ENABLED:true}
group-id: ${KAFKA_GROUP_STAT:vehicle-stat}
topics:
- ${KAFKA_TOPIC_GB32960_EVENT:vehicle.event.gb32960.v1}
archive:
enabled: false
event-file-store:
enabled: false
event-history:
enabled: false
vehicle-state:
enabled: ${VEHICLE_STATE_ENABLED:false}
vehicle-stat:
enabled: ${VEHICLE_STAT_ENABLED:true}
file-path: ${VEHICLE_STAT_FILE_PATH:./target/vehicle-stat/}
zone-id: ${VEHICLE_STAT_ZONE_ID:Asia/Shanghai}
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,env
health:
redis:
enabled: ${MANAGEMENT_HEALTH_REDIS_ENABLED:false}
metrics:
tags:
application: vehicle-analytics-app
```
- [ ] **Step 7: Build all three app jars**
Run:
```bash
export JAVA_HOME=/opt/homebrew/opt/openjdk@25/libexec/openjdk.jdk/Contents/Home
export PATH="$JAVA_HOME/bin:/opt/homebrew/bin:$PATH"
mvn -pl :gb32960-ingest-app,:vehicle-history-app,:vehicle-analytics-app -am package -Dmaven.test.skip=true
```
Expected: BUILD SUCCESS and jars under each app module's `target/`.
- [ ] **Step 8: Commit**
```bash
git add modules/apps/gb32960-ingest-app modules/apps/vehicle-history-app modules/apps/vehicle-analytics-app
git commit -m "feat: add split service entrypoints"
```
---
### Task 3: Add Composition Tests for Service Boundaries
**Files:**
- Create: `modules/apps/gb32960-ingest-app/src/test/java/com/lingniu/ingest/gb32960app/Gb32960IngestAppCompositionTest.java`
- Create: `modules/apps/vehicle-history-app/src/test/java/com/lingniu/ingest/historyapp/VehicleHistoryAppCompositionTest.java`
- Create: `modules/apps/vehicle-analytics-app/src/test/java/com/lingniu/ingest/analyticsapp/VehicleAnalyticsAppCompositionTest.java`
- [ ] **Step 1: Write GB32960 ingest composition test**
Create `modules/apps/gb32960-ingest-app/src/test/java/com/lingniu/ingest/gb32960app/Gb32960IngestAppCompositionTest.java`:
```java
package com.lingniu.ingest.gb32960app;
import com.lingniu.ingest.eventfilestore.EventFileStore;
import com.lingniu.ingest.protocol.gb32960.config.Gb32960AutoConfiguration;
import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960NettyServer;
import com.lingniu.ingest.sink.archive.ArchiveStore;
import com.lingniu.ingest.sink.kafka.KafkaEventSink;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
class Gb32960IngestAppCompositionTest {
@Test
void gb32960IngestRuntimeStartsProtocolAndKafkaWithoutHistoryStorage() {
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(Gb32960AutoConfiguration.class))
.withPropertyValues(
"lingniu.ingest.gb32960.enabled=true",
"lingniu.ingest.gb32960.port=0",
"lingniu.ingest.sink.kafka.enabled=false",
"lingniu.ingest.sink.archive.enabled=false",
"lingniu.ingest.event-file-store.enabled=false",
"lingniu.ingest.event-history.enabled=false",
"lingniu.ingest.vehicle-state.enabled=false",
"lingniu.ingest.vehicle-stat.enabled=false")
.run(context -> {
assertThat(context).hasSingleBean(Gb32960NettyServer.class);
assertThat(context).doesNotHaveBean(ArchiveStore.class);
assertThat(context).doesNotHaveBean(EventFileStore.class);
assertThat(context).doesNotHaveBean(KafkaEventSink.class);
});
}
}
```
If this test does not create required supporting beans, add the same auto-config classes used by existing bootstrap tests: `IngestCoreAutoConfiguration`, `SessionCoreAutoConfiguration`, and `VehicleIdentityAutoConfiguration`.
- [ ] **Step 2: Run GB32960 ingest composition test**
Run:
```bash
mvn -pl :gb32960-ingest-app -Dtest=Gb32960IngestAppCompositionTest test
```
Expected after adding required auto-config classes: PASS.
- [ ] **Step 3: Write history composition test**
Create `modules/apps/vehicle-history-app/src/test/java/com/lingniu/ingest/historyapp/VehicleHistoryAppCompositionTest.java`:
```java
package com.lingniu.ingest.historyapp;
import com.lingniu.ingest.eventfilestore.EventFileStore;
import com.lingniu.ingest.eventhistory.EventHistoryController;
import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960NettyServer;
import com.lingniu.ingest.sink.archive.ArchiveStore;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class VehicleHistoryAppCompositionTest {
@TempDir
Path tempDir;
@Test
void historyRuntimeCreatesStorageAndQueryBeansWithoutProtocolListener() {
new ApplicationContextRunner()
.withPropertyValues(
"lingniu.ingest.gb32960.enabled=false",
"lingniu.ingest.sink.archive.enabled=true",
"lingniu.ingest.sink.archive.path=" + tempDir.resolve("archive"),
"lingniu.ingest.event-file-store.enabled=true",
"lingniu.ingest.event-file-store.path=" + tempDir.resolve("event-store"),
"lingniu.ingest.event-history.enabled=true",
"lingniu.ingest.vehicle-state.enabled=false",
"lingniu.ingest.vehicle-stat.enabled=false",
"lingniu.ingest.sink.kafka.enabled=false")
.withConfiguration(AutoConfigurations.of(
com.lingniu.ingest.sink.archive.config.SinkArchiveAutoConfiguration.class,
com.lingniu.ingest.eventfilestore.config.EventFileStoreAutoConfiguration.class,
com.lingniu.ingest.eventhistory.config.EventHistoryAutoConfiguration.class))
.run(context -> {
assertThat(context).hasSingleBean(ArchiveStore.class);
assertThat(context).hasSingleBean(EventFileStore.class);
assertThat(context).hasSingleBean(EventHistoryController.class);
assertThat(context).doesNotHaveBean(Gb32960NettyServer.class);
});
}
}
```
- [ ] **Step 4: Run history composition test**
Run:
```bash
mvn -pl :vehicle-history-app -Dtest=VehicleHistoryAppCompositionTest test
```
Expected: PASS.
- [ ] **Step 5: Write analytics composition test**
Create `modules/apps/vehicle-analytics-app/src/test/java/com/lingniu/ingest/analyticsapp/VehicleAnalyticsAppCompositionTest.java`:
```java
package com.lingniu.ingest.analyticsapp;
import com.lingniu.ingest.eventfilestore.EventFileStore;
import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960NettyServer;
import com.lingniu.ingest.vehiclestat.DailyVehicleStatService;
import com.lingniu.ingest.vehiclestate.VehicleStateRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class VehicleAnalyticsAppCompositionTest {
@TempDir
Path tempDir;
@Test
void analyticsRuntimeCreatesStatisticsWithoutProtocolOrHistoryStorage() {
new ApplicationContextRunner()
.withPropertyValues(
"lingniu.ingest.gb32960.enabled=false",
"lingniu.ingest.sink.archive.enabled=false",
"lingniu.ingest.event-file-store.enabled=false",
"lingniu.ingest.event-history.enabled=false",
"lingniu.ingest.vehicle-state.enabled=false",
"lingniu.ingest.vehicle-stat.enabled=true",
"lingniu.ingest.vehicle-stat.file-path=" + tempDir.resolve("vehicle-stat"),
"lingniu.ingest.sink.kafka.enabled=false")
.withConfiguration(AutoConfigurations.of(
com.lingniu.ingest.vehiclestat.config.VehicleStatAutoConfiguration.class,
com.lingniu.ingest.vehiclestate.config.VehicleStateAutoConfiguration.class))
.run(context -> {
assertThat(context).hasSingleBean(DailyVehicleStatService.class);
assertThat(context).doesNotHaveBean(VehicleStateRepository.class);
assertThat(context).doesNotHaveBean(Gb32960NettyServer.class);
assertThat(context).doesNotHaveBean(EventFileStore.class);
});
}
}
```
- [ ] **Step 6: Run analytics composition test**
Run:
```bash
mvn -pl :vehicle-analytics-app -Dtest=VehicleAnalyticsAppCompositionTest test
```
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add modules/apps/gb32960-ingest-app/src/test modules/apps/vehicle-history-app/src/test modules/apps/vehicle-analytics-app/src/test
git commit -m "test: cover split service composition"
```
---
### Task 4: Version Kafka Topics for GB32960 Raw/Event/DLQ
**Files:**
- Modify: `modules/sinks/sink-kafka/src/main/java/com/lingniu/ingest/sink/kafka/KafkaSinkProperties.java`
- Modify: `modules/sinks/sink-kafka/src/main/java/com/lingniu/ingest/sink/kafka/TopicRouter.java`
- Modify: `modules/sinks/sink-kafka/src/main/java/com/lingniu/ingest/sink/kafka/KafkaEventSink.java`
- Create or modify: `modules/sinks/sink-kafka/src/test/java/com/lingniu/ingest/sink/kafka/TopicRouterTest.java`
- Create or modify: `modules/sinks/sink-kafka/src/test/java/com/lingniu/ingest/sink/kafka/KafkaEventSinkTest.java`
- [ ] **Step 1: Write topic routing tests**
Create `modules/sinks/sink-kafka/src/test/java/com/lingniu/ingest/sink/kafka/TopicRouterTest.java` if it does not exist. Add tests proving GB32960 raw and normalized events can route to versioned topics. Use the existing `VehicleEvent` constructors from `EventFileStoreSinkTest` and `EnvelopeMapperTelemetrySnapshotTest` as examples.
Expected test names:
```java
@Test
void rawArchiveRoutesToVersionedGb32960RawTopic() {
KafkaSinkProperties.Topics topics = new KafkaSinkProperties.Topics();
topics.setRawArchive("vehicle.raw.gb32960.v1");
TopicRouter router = new TopicRouter(topics);
VehicleEvent.RawArchive raw = new VehicleEvent.RawArchive(
"event-1",
"trace-1",
ProtocolId.GB32960,
"VIN001",
Instant.parse("2026-06-23T07:00:00Z"),
0x02,
new byte[] {0x23, 0x23},
Map.of("platformAccount", "Hyundai"));
assertThat(router.route(raw)).isEqualTo("vehicle.raw.gb32960.v1");
}
```
Also add a normalized realtime event test:
```java
@Test
void realtimeRoutesToVersionedGb32960EventTopic() {
KafkaSinkProperties.Topics topics = new KafkaSinkProperties.Topics();
topics.setRealtime("vehicle.event.gb32960.v1");
TopicRouter router = new TopicRouter(topics);
VehicleEvent.Realtime realtime = new VehicleEvent.Realtime(
"event-2",
"trace-2",
ProtocolId.GB32960,
"VIN001",
Instant.parse("2026-06-23T07:00:01Z"),
0x02,
Map.of("soc", "80"),
Map.of("platformAccount", "Hyundai"));
assertThat(router.route(realtime)).isEqualTo("vehicle.event.gb32960.v1");
}
```
Adjust constructor arguments to match the current sealed `VehicleEvent` definitions in `modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/VehicleEvent.java`.
- [ ] **Step 2: Run topic router test and verify failure if topic defaults are still old**
Run:
```bash
mvn -pl :sink-kafka -Dtest=TopicRouterTest test
```
Expected before implementation: compile failure if constructors are wrong or assertion failure if defaults are old. Fix constructors first; keep routing assertions.
- [ ] **Step 3: Update `KafkaSinkProperties.Topics` defaults**
In `KafkaSinkProperties.Topics`, change defaults:
```java
private String realtime = "vehicle.event.gb32960.v1";
private String location = "vehicle.event.gb32960.v1";
private String alarm = "vehicle.event.gb32960.v1";
private String session = "vehicle.event.gb32960.v1";
private String mediaMeta = "vehicle.media.meta.v1";
private String rawArchive = "vehicle.raw.gb32960.v1";
private String dlq = "vehicle.dlq.gb32960.v1";
```
- [ ] **Step 4: Allow raw archive records in KafkaEventSink**
Change `KafkaEventSink.accepts()` to:
```java
@Override
public boolean accepts(VehicleEvent event) {
return true;
}
```
Update the JavaDoc above it to say Kafka carries both normalized events and raw archive records in split-service mode.
- [ ] **Step 5: Run sink-kafka tests**
Run:
```bash
mvn -pl :sink-kafka test
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add modules/sinks/sink-kafka/src/main/java/com/lingniu/ingest/sink/kafka/KafkaSinkProperties.java \
modules/sinks/sink-kafka/src/main/java/com/lingniu/ingest/sink/kafka/TopicRouter.java \
modules/sinks/sink-kafka/src/main/java/com/lingniu/ingest/sink/kafka/KafkaEventSink.java \
modules/sinks/sink-kafka/src/test/java/com/lingniu/ingest/sink/kafka/TopicRouterTest.java \
modules/sinks/sink-kafka/src/test/java/com/lingniu/ingest/sink/kafka/KafkaEventSinkTest.java
git commit -m "feat: route gb32960 kafka topics"
```
---
### Task 5: Introduce an ACK-Aware GB32960 Dispatch Boundary
**Files:**
- Modify: `modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/Dispatcher.java`
- Modify: `modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/DisruptorEventBus.java`
- Modify: `modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandler.java`
- Create: `modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandlerAckBoundaryTest.java`
Design choice for first implementation:
- Keep `Dispatcher.dispatch(RawFrame)` for existing async protocols and `bootstrap-all`.
- Add `Dispatcher.dispatchAndAwait(RawFrame, Duration)` returning `CompletableFuture` or throwing a checked runtime exception on timeout/failure.
- Implement `DisruptorEventBus.publishAndAwait(VehicleEvent, Duration)` by synchronously calling accepted sinks' `publish()` futures and waiting for all to complete.
- Use the synchronous path only from GB32960 report/heartbeat/session commands that require ACK after Kafka success.
- [ ] **Step 1: Write failing ACK boundary test**
Create `Gb32960ChannelHandlerAckBoundaryTest` using `EmbeddedChannel` and a fake dispatcher that records whether ACK happens before the future completes.
Core assertion:
```java
@Test
void realtimeReportAckWaitsForKafkaDurabilityBoundary() {
CompletableFuture durability = new CompletableFuture<>();
RecordingAckService ackService = new RecordingAckService();
AwaitingDispatcher dispatcher = new AwaitingDispatcher(durability);
Gb32960ChannelHandler handler = handlerWith(dispatcher, ackService);
EmbeddedChannel channel = new EmbeddedChannel(handler);
channel.writeInbound(validRealtimeFrameBytes());
assertThat(ackService.writtenTags()).isEmpty();
durability.complete(null);
channel.runPendingTasks();
assertThat(ackService.writtenTags()).contains("report-ack-0x2");
}
```
Implementation notes:
- Reuse valid frame fixture builders from existing GB32960 codec tests.
- If `Gb32960AckService` is hard to spy because methods are concrete, subclass it in test and override `writeResponse`/`writeAndClose` to record tags instead of writing bytes.
- If `Dispatcher` is not subclassable, extract a small interface such as `FrameDispatcher` in `ingest-core` and adapt `Dispatcher` to implement it.
- [ ] **Step 2: Run the failing test**
Run:
```bash
mvn -pl :protocol-gb32960 -Dtest=Gb32960ChannelHandlerAckBoundaryTest test
```
Expected before implementation: FAIL because current handler writes ACK before dispatch completion.
- [ ] **Step 3: Add await API to event bus**
In `DisruptorEventBus`, add:
```java
public CompletableFuture publishAndAwait(VehicleEvent event) {
List> futures = sinks.stream()
.filter(sink -> sink.accepts(event))
.map(sink -> sink.publish(event))
.toList();
if (futures.isEmpty()) {
return CompletableFuture.completedFuture(null);
}
return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new));
}
```
This requires promoting `sinks` from constructor-local variable to a field:
```java
private final List sinks;
```
Set it in the constructor:
```java
this.sinks = List.copyOf(sinks);
```
- [ ] **Step 4: Add await API to Dispatcher**
In `Dispatcher`, add:
```java
public CompletableFuture dispatchAndAwait(RawFrame frame) {
List events = mapFrameToEvents(frame);
CompletableFuture>[] futures = events.stream()
.map(eventBus::publishAndAwait)
.toArray(CompletableFuture[]::new);
return CompletableFuture.allOf(futures);
}
```
Refactor existing `dispatch(RawFrame)` to reuse `mapFrameToEvents(frame)`:
```java
public void dispatch(RawFrame frame) {
for (VehicleEvent e : mapFrameToEvents(frame)) {
eventBus.publish(e);
}
}
```
If `Dispatcher` currently interleaves raw archive event publication and handler mapping, move that logic into a private method:
```java
private List mapFrameToEvents(RawFrame frame) {
List events = new ArrayList<>();
events.add(toRawArchive(frame));
events.addAll(handlerEvents(frame));
return events;
}
```
Use exact existing helper names from `Dispatcher.java`; do not duplicate mapping logic.
- [ ] **Step 5: Move GB32960 ACK after dispatch completion**
In `Gb32960ChannelHandler.channelRead0`, for commands that currently ACK before `dispatcher.dispatch(rf)`, build `RawFrame` first, call `dispatcher.dispatchAndAwait(rf)`, and write ACK in the completion callback:
```java
dispatcher.dispatchAndAwait(rf).whenComplete((ignored, ex) -> {
if (ex != null) {
log.warn("[gb32960] dispatch failed before ack peer={} vin={} cmd=0x{}",
addr(ctx), vin, Integer.toHexString(cmd.code()), ex);
ctx.close();
return;
}
writeAckForCommand(ctx, msg, rawVin);
});
```
Extract current ACK logic into command-specific method:
```java
private void writeAckForCommand(ChannelHandlerContext ctx, Gb32960Message msg, byte[] rawVin) {
switch (msg.header().command()) {
case VEHICLE_LOGIN -> writeVehicleLoginAck(ctx, msg, rawVin);
case VEHICLE_LOGOUT -> writeVehicleLogoutAck(ctx, msg, rawVin);
case PLATFORM_LOGIN -> writePlatformLoginAck(ctx, msg, rawVin);
case PLATFORM_LOGOUT -> writePlatformLogoutAck(ctx, msg, rawVin);
case REALTIME_REPORT, RESEND_REPORT, HEARTBEAT -> writeReportOrHeartbeatAck(ctx, msg, rawVin);
case TIME_CALIBRATION -> writeTimeCalibrationAck(ctx, msg, rawVin);
default -> { }
}
}
```
Keep authorization failure ACKs immediate because they are not accepted frames and do not cross the Kafka durability boundary.
- [ ] **Step 6: Run ACK boundary test**
Run:
```bash
mvn -pl :protocol-gb32960 -Dtest=Gb32960ChannelHandlerAckBoundaryTest test
```
Expected: PASS.
- [ ] **Step 7: Run core dispatcher tests**
Run:
```bash
mvn -pl :ingest-core test
```
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/Dispatcher.java \
modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/DisruptorEventBus.java \
modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandler.java \
modules/protocols/protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandlerAckBoundaryTest.java
git commit -m "feat: ack gb32960 after kafka boundary"
```
---
### Task 6: Wire History and Analytics Consumer Bindings
**Files:**
- Modify: `modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/EventHistoryEnvelopeIngestor.java`
- Modify: `modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEnvelopeIngestor.java`
- Modify: `modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateEnvelopeIngestor.java`
- Modify tests under the same service modules.
- [ ] **Step 1: Verify event-history accepts raw and normalized envelopes**
Run:
```bash
rg -n "RawArchive|raw_archive|TelemetrySnapshot|Envelope" modules/services/event-history-service/src/main/java modules/services/event-history-service/src/test/java
```
Expected: identify whether `EventHistoryEnvelopeIngestor` already persists raw archive envelopes. If raw archive envelopes are ignored, continue with Step 2.
- [ ] **Step 2: Add event-history raw archive ingestion test**
Add a test to the existing event-history test suite that creates an envelope with `raw_archive` populated and asserts an `EventFileRecord` with `eventType = RAW_ARCHIVE` is written with `rawArchiveUri`.
Expected assertion shape:
```java
assertThat(record.eventType()).isEqualTo("RAW_ARCHIVE");
assertThat(record.rawArchiveUri()).isEqualTo("archive://2026/06/23/GB32960/VIN001/event-1.bin");
```
- [ ] **Step 3: Implement raw archive ingestion if missing**
In `EventHistoryEnvelopeIngestor`, map raw archive envelopes to `EventFileRecord` using the same conventions as `EventFileStoreSink`.
Implementation rule:
- Do not embed raw bytes into Parquet history records.
- Store `rawArchiveUri`, event id, protocol, VIN, command, event time, and metadata.
- [ ] **Step 4: Verify analytics consumers ignore raw topic records**
Add or update tests for `VehicleStatEnvelopeIngestor` and `VehicleStateEnvelopeIngestor`:
```java
@Test
void ignoresRawArchiveEnvelopeForAnalytics() {
ConsumerProcessResult result = ingestor.ingest(rawArchiveEnvelopeRecord());
assertThat(result.success()).isTrue();
assertThat(repositoryWrites()).isEmpty();
}
```
Use the actual result type currently returned by the ingestor.
- [ ] **Step 5: Run service consumer tests**
Run:
```bash
mvn -pl :event-history-service,:vehicle-state-service,:vehicle-stat-service test
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add modules/services/event-history-service modules/services/vehicle-state-service modules/services/vehicle-stat-service
git commit -m "feat: split history and analytics consumers"
```
---
### Task 7: Add App Default Tests
**Files:**
- Create: `modules/apps/gb32960-ingest-app/src/test/java/com/lingniu/ingest/gb32960app/Gb32960IngestAppDefaultsTest.java`
- Create: `modules/apps/vehicle-history-app/src/test/java/com/lingniu/ingest/historyapp/VehicleHistoryAppDefaultsTest.java`
- Create: `modules/apps/vehicle-analytics-app/src/test/java/com/lingniu/ingest/analyticsapp/VehicleAnalyticsAppDefaultsTest.java`
- [ ] **Step 1: Add YAML property loader helper to each test**
Use this helper in each defaults test:
```java
private static Properties loadApplicationProperties() {
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
yaml.setResources(new ClassPathResource("application.yml"));
Properties props = yaml.getObject();
assertThat(props).isNotNull();
return props;
}
```
Imports:
```java
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
import org.springframework.core.io.ClassPathResource;
import java.util.Properties;
import static org.assertj.core.api.Assertions.assertThat;
```
- [ ] **Step 2: Assert GB32960 ingest defaults**
Create `Gb32960IngestAppDefaultsTest`:
```java
@Test
void gb32960IngestDefaultsOnlyEnableProtocolAndKafkaProducer() {
Properties props = loadApplicationProperties();
assertThat(props.getProperty("spring.application.name")).isEqualTo("gb32960-ingest-app");
assertThat(props.getProperty("lingniu.ingest.gb32960.enabled")).isEqualTo("true");
assertThat(props.getProperty("lingniu.ingest.gb32960.port")).isEqualTo("${GB32960_PORT:32960}");
assertThat(props.getProperty("lingniu.ingest.sink.kafka.enabled")).isEqualTo("${KAFKA_ENABLED:true}");
assertThat(props.getProperty("lingniu.ingest.sink.kafka.consumer.enabled")).isEqualTo("false");
assertThat(props.getProperty("lingniu.ingest.event-file-store.enabled")).isEqualTo("false");
assertThat(props.getProperty("lingniu.ingest.event-history.enabled")).isEqualTo("false");
assertThat(props.getProperty("lingniu.ingest.vehicle-state.enabled")).isEqualTo("false");
assertThat(props.getProperty("lingniu.ingest.vehicle-stat.enabled")).isEqualTo("false");
}
```
- [ ] **Step 3: Assert history defaults**
Create `VehicleHistoryAppDefaultsTest`:
```java
@Test
void historyDefaultsEnableStorageAndHistoryConsumerOnly() {
Properties props = loadApplicationProperties();
assertThat(props.getProperty("spring.application.name")).isEqualTo("vehicle-history-app");
assertThat(props.getProperty("lingniu.ingest.gb32960.enabled")).isEqualTo("false");
assertThat(props.getProperty("lingniu.ingest.sink.archive.enabled")).isEqualTo("${SINK_ARCHIVE_ENABLED:true}");
assertThat(props.getProperty("lingniu.ingest.event-file-store.enabled")).isEqualTo("${EVENT_FILE_STORE_ENABLED:true}");
assertThat(props.getProperty("lingniu.ingest.event-history.enabled")).isEqualTo("true");
assertThat(props.getProperty("lingniu.ingest.vehicle-state.enabled")).isEqualTo("false");
assertThat(props.getProperty("lingniu.ingest.vehicle-stat.enabled")).isEqualTo("false");
assertThat(props.getProperty("lingniu.ingest.sink.kafka.consumer.bindings.eventHistoryEnvelopeConsumerProcessor.group-id"))
.isEqualTo("${KAFKA_GROUP_HISTORY:vehicle-history}");
}
```
- [ ] **Step 4: Assert analytics defaults**
Create `VehicleAnalyticsAppDefaultsTest`:
```java
@Test
void analyticsDefaultsEnableStatConsumerAndDisableProtocolAndHistoryStorage() {
Properties props = loadApplicationProperties();
assertThat(props.getProperty("spring.application.name")).isEqualTo("vehicle-analytics-app");
assertThat(props.getProperty("lingniu.ingest.gb32960.enabled")).isEqualTo("false");
assertThat(props.getProperty("lingniu.ingest.sink.archive.enabled")).isEqualTo("false");
assertThat(props.getProperty("lingniu.ingest.event-file-store.enabled")).isEqualTo("false");
assertThat(props.getProperty("lingniu.ingest.event-history.enabled")).isEqualTo("false");
assertThat(props.getProperty("lingniu.ingest.vehicle-stat.enabled")).isEqualTo("${VEHICLE_STAT_ENABLED:true}");
assertThat(props.getProperty("lingniu.ingest.sink.kafka.consumer.bindings.vehicleStatEnvelopeConsumerProcessor.group-id"))
.isEqualTo("${KAFKA_GROUP_STAT:vehicle-stat}");
}
```
- [ ] **Step 5: Run app default tests**
Run:
```bash
mvn -pl :gb32960-ingest-app,:vehicle-history-app,:vehicle-analytics-app test
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add modules/apps/gb32960-ingest-app/src/test/java/com/lingniu/ingest/gb32960app/Gb32960IngestAppDefaultsTest.java \
modules/apps/vehicle-history-app/src/test/java/com/lingniu/ingest/historyapp/VehicleHistoryAppDefaultsTest.java \
modules/apps/vehicle-analytics-app/src/test/java/com/lingniu/ingest/analyticsapp/VehicleAnalyticsAppDefaultsTest.java
git commit -m "test: assert split app defaults"
```
---
### Task 8: End-to-End Verification With Local Kafka
**Files:**
- Create: `docs/operations/gb32960-service-split-runbook.md`
- [ ] **Step 1: Build all modules**
Run:
```bash
export JAVA_HOME=/opt/homebrew/opt/openjdk@25/libexec/openjdk.jdk/Contents/Home
export PATH="$JAVA_HOME/bin:/opt/homebrew/bin:$PATH"
mvn -pl :gb32960-ingest-app,:vehicle-history-app,:vehicle-analytics-app -am package -Dmaven.test.skip=true
```
Expected: BUILD SUCCESS.
- [ ] **Step 2: Start Kafka test environment**
Use the repository's existing Kafka setup if present. If there is no repository-local Kafka script, use a local Kafka compatible with `KAFKA_BROKERS=127.0.0.1:9092`.
Create topics:
```bash
kafka-topics --bootstrap-server 127.0.0.1:9092 --create --if-not-exists --topic vehicle.raw.gb32960.v1 --partitions 12 --replication-factor 1
kafka-topics --bootstrap-server 127.0.0.1:9092 --create --if-not-exists --topic vehicle.event.gb32960.v1 --partitions 12 --replication-factor 1
kafka-topics --bootstrap-server 127.0.0.1:9092 --create --if-not-exists --topic vehicle.dlq.gb32960.v1 --partitions 3 --replication-factor 1
```
- [ ] **Step 3: Start split services on local ports**
Run each command in a separate terminal:
```bash
KAFKA_BROKERS=127.0.0.1:9092 \
GB32960_PORT=32960 \
HTTP_PORT=20100 \
java --sun-misc-unsafe-memory-access=allow \
-jar modules/apps/gb32960-ingest-app/target/gb32960-ingest-app.jar
```
```bash
KAFKA_BROKERS=127.0.0.1:9092 \
HTTP_PORT=20200 \
EVENT_FILE_STORE_PATH=./target/split-event-store \
SINK_ARCHIVE_PATH=./target/split-archive \
java --sun-misc-unsafe-memory-access=allow \
-jar modules/apps/vehicle-history-app/target/vehicle-history-app.jar
```
```bash
KAFKA_BROKERS=127.0.0.1:9092 \
HTTP_PORT=20300 \
VEHICLE_STAT_FILE_PATH=./target/split-vehicle-stat \
java --sun-misc-unsafe-memory-access=allow \
-jar modules/apps/vehicle-analytics-app/target/vehicle-analytics-app.jar
```
- [ ] **Step 4: Verify health endpoints**
Run:
```bash
curl -sS http://127.0.0.1:20100/actuator/health
curl -sS http://127.0.0.1:20200/actuator/health
curl -sS http://127.0.0.1:20300/actuator/health
```
Expected: each returns `{"status":"UP"}`.
- [ ] **Step 5: Send a known-valid GB32960 frame**
Use an existing GB32960 test fixture from `modules/protocols/protocol-gb32960/src/test/java`. If no command-line sender exists, add a small test-only utility or use `nc` with a hex-decoded fixture:
```bash
xxd -r -p /tmp/gb32960-valid-realtime.hex | nc 127.0.0.1 32960
```
Expected:
- Kafka topic `vehicle.raw.gb32960.v1` receives one raw record.
- Kafka topic `vehicle.event.gb32960.v1` receives one or more normalized records.
- Client receives a GB32960 success ACK.
- `target/split-archive` contains a `.bin` file.
- `target/split-event-store` contains `events.duckdb` or Parquet files after flush.
- `target/split-vehicle-stat` contains updated stat files after analytics consumes the event.
- [ ] **Step 6: Write runbook**
Create `docs/operations/gb32960-service-split-runbook.md` with:
```markdown
# GB32960 Split Service Runbook
## Services
- `gb32960-ingest-app`: TCP 32960, HTTP 20100
- `vehicle-history-app`: HTTP 20200
- `vehicle-analytics-app`: HTTP 20300
## Kafka Topics
- `vehicle.raw.gb32960.v1`
- `vehicle.event.gb32960.v1`
- `vehicle.dlq.gb32960.v1`
## ACK Semantics
GB32960 success ACK is sent after required Kafka production succeeds. History and analytics failures do not block ACK.
## Local Start
Use the commands from Task 8 Step 3.
## Verification
Use health endpoints, Kafka topic consumption, archive file count, event-store query APIs, analytics output files, and ACK latency metrics.
## Rollback
Stop split services and start `bootstrap-all` with the previous environment. Keep `bootstrap-all` available until split service counts and sample queries match production expectations.
```
- [ ] **Step 7: Commit**
```bash
git add docs/operations/gb32960-service-split-runbook.md
git commit -m "docs: add gb32960 split service runbook"
```
---
### Task 9: Final Verification and Cutover Checklist
**Files:**
- Modify: `docs/operations/gb32960-service-split-runbook.md`
- [ ] **Step 1: Run targeted tests**
Run:
```bash
mvn -pl :sink-kafka,:ingest-core,:protocol-gb32960,:event-history-service,:vehicle-state-service,:vehicle-stat-service test
```
Expected: BUILD SUCCESS.
- [ ] **Step 2: Run split app tests**
Run:
```bash
mvn -pl :gb32960-ingest-app,:vehicle-history-app,:vehicle-analytics-app test
```
Expected: BUILD SUCCESS.
- [ ] **Step 3: Run package verification**
Run:
```bash
mvn -pl :gb32960-ingest-app,:vehicle-history-app,:vehicle-analytics-app -am package -Dmaven.test.skip=true
```
Expected: BUILD SUCCESS.
- [ ] **Step 4: Update runbook with measured results**
Append a section:
```markdown
## Latest Local Verification
- Build:
- Unit tests:
- Split app startup:
- Kafka raw records:
- Kafka event records:
- Archive files:
- Event-store files:
- Analytics output:
- ACK behavior:
```
Fill the fields with the actual command outputs and counts from the run.
- [ ] **Step 5: Commit final verification docs**
```bash
git add docs/operations/gb32960-service-split-runbook.md
git commit -m "docs: record gb32960 split verification"
```
---
## Self-Review
Spec coverage:
- Three services are covered by Tasks 1, 2, 3, and 7.
- Kafka topic and routing contract is covered by Task 4.
- ACK after Kafka success is covered by Task 5.
- History consumption and storage is covered by Task 6 and Task 8.
- Analytics consumption and processing is covered by Task 6 and Task 8.
- `bootstrap-all` remains untouched except for pre-existing unrelated worktree changes.
- Verification and runbook are covered by Tasks 8 and 9.
Implementation risk notes:
- Task 5 is the most invasive task because it changes protocol ACK ordering. Implement it behind the smallest possible new API and keep `Dispatcher.dispatch(RawFrame)` behavior for other protocols.
- If Kafka transactions are needed to atomically produce raw and normalized records, add that as a follow-up after the split app boundary is working. The first implementation can use idempotent producer plus shared `eventId`/`sourceRawEventId`.
- If composition tests need additional auto-config classes, copy the exact pattern from `ProtocolAutoConfigurationCompositionTest` rather than widening app dependencies.