811 lines
28 KiB
Markdown
811 lines
28 KiB
Markdown
# GB32960 History DuckDB Hot Store 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:** Replace the current high-write-risk Parquet rewrite history path with a DuckDB hot history store that supports fast `vin + time` queries and full RAW frame replay.
|
|
|
|
**Architecture:** Add a new `DuckDbHotEventFileStore` implementation behind the existing `EventFileStore` interface, backed by append/upsert DuckDB tables instead of rewriting per-vehicle Parquet files. Keep the current HTTP history API and RAW archive reader intact, then switch `vehicle-history-app` to the hot DuckDB store by configuration.
|
|
|
|
**Tech Stack:** Java 26, Spring Boot, DuckDB JDBC, JUnit 5, AssertJ, existing Kafka `VehicleEnvelope`, existing `ArchiveStore`.
|
|
|
|
---
|
|
|
|
## Scope
|
|
|
|
This plan implements Phase 1 from `docs/superpowers/specs/2026-06-23-gb32960-production-readiness-design.md`.
|
|
|
|
Included:
|
|
|
|
- DuckDB hot event table for `EventFileRecord`.
|
|
- Idempotent writes by `event_id`.
|
|
- Query by protocol/date/VIN/eventType/eventTime/order/limit.
|
|
- Lookup by `rawArchiveUri`.
|
|
- Spring configuration to select the hot store.
|
|
- Tests that prove history endpoints still replay RAW frames.
|
|
- Local verification using the existing `vehicle-history-app`.
|
|
|
|
Not included in this plan:
|
|
|
|
- MySQL daily statistics.
|
|
- VIN-to-platform local mapping.
|
|
- Full telemetry point columnar table.
|
|
- Alarm timeline MySQL tables.
|
|
- Cold Parquet export.
|
|
|
|
Those will be separate plans after this foundation lands.
|
|
|
|
## Files
|
|
|
|
- Create: `modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/DuckDbHotEventFileStore.java`
|
|
- Create: `modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/DuckDbHotEventFileStoreTest.java`
|
|
- Modify: `modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreProperties.java`
|
|
- Modify: `modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfiguration.java`
|
|
- Modify: `modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfigurationTest.java`
|
|
- Modify: `modules/apps/vehicle-history-app/src/main/resources/application.yml`
|
|
- Modify: `modules/apps/vehicle-history-app/src/test/java/com/lingniu/ingest/historyapp/VehicleHistoryAppDefaultsTest.java`
|
|
- Modify: `modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/Gb32960DecodedFrameServiceTest.java`
|
|
|
|
## Task 1: Add Hot Store Configuration
|
|
|
|
**Files:**
|
|
- Modify: `modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreProperties.java`
|
|
- Modify: `modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfiguration.java`
|
|
- Test: `modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfigurationTest.java`
|
|
|
|
- [ ] **Step 1: Write the failing auto-configuration test**
|
|
|
|
Add a test that sets `lingniu.ingest.event-file-store.storage=duckdb-hot` and expects the bean class to be `DuckDbHotEventFileStore`.
|
|
|
|
```java
|
|
@Test
|
|
void createsDuckDbHotStoreWhenStorageIsDuckDbHot() {
|
|
contextRunner
|
|
.withPropertyValues(
|
|
"lingniu.ingest.event-file-store.enabled=true",
|
|
"lingniu.ingest.event-file-store.storage=duckdb-hot",
|
|
"lingniu.ingest.event-file-store.path=" + tempDir.resolve("history"))
|
|
.run(context -> assertThat(context)
|
|
.hasSingleBean(EventFileStore.class)
|
|
.getBean(EventFileStore.class)
|
|
.isInstanceOf(DuckDbHotEventFileStore.class));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the new test and verify it fails**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl :event-file-store -Dtest=EventFileStoreAutoConfigurationTest#createsDuckDbHotStoreWhenStorageIsDuckDbHot test
|
|
```
|
|
|
|
Expected: compilation failure because `DuckDbHotEventFileStore` and `storage` property do not exist.
|
|
|
|
- [ ] **Step 3: Add the `storage` property**
|
|
|
|
Add to `EventFileStoreProperties`:
|
|
|
|
```java
|
|
/**
|
|
* Storage backend. `duckdb-hot` is the production backend; `parquet-sidecar`
|
|
* keeps the previous implementation available for compatibility tests.
|
|
*/
|
|
private String storage = "duckdb-hot";
|
|
|
|
public String getStorage() {
|
|
return storage;
|
|
}
|
|
|
|
public void setStorage(String storage) {
|
|
this.storage = storage;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Switch auto-configuration by storage mode**
|
|
|
|
Change `eventFileStore(...)` to:
|
|
|
|
```java
|
|
@Bean
|
|
@ConditionalOnMissingBean
|
|
public EventFileStore eventFileStore(EventFileStoreProperties properties,
|
|
ObjectProvider<ObjectMapper> objectMapper) {
|
|
ObjectMapper mapper = mapper(objectMapper);
|
|
Path root = Path.of(properties.getPath());
|
|
ZoneId zoneId = ZoneId.of(properties.getZoneId());
|
|
String storage = properties.getStorage() == null ? "" : properties.getStorage().trim();
|
|
return switch (storage) {
|
|
case "", "duckdb-hot" -> new DuckDbHotEventFileStore(root, zoneId, mapper);
|
|
case "parquet-sidecar" -> new DuckDbParquetEventFileStore(root, zoneId, mapper);
|
|
default -> throw new IllegalStateException(
|
|
"unsupported event-file-store storage: " + properties.getStorage());
|
|
};
|
|
}
|
|
```
|
|
|
|
Import `DuckDbHotEventFileStore`.
|
|
|
|
- [ ] **Step 5: Run configuration tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl :event-file-store -Dtest=EventFileStoreAutoConfigurationTest test
|
|
```
|
|
|
|
Expected: tests pass after the store class exists in Task 2.
|
|
|
|
## Task 2: Implement DuckDB Hot Store Schema and Writes
|
|
|
|
**Files:**
|
|
- Create: `modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/DuckDbHotEventFileStore.java`
|
|
- Create: `modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/DuckDbHotEventFileStoreTest.java`
|
|
|
|
- [ ] **Step 1: Write failing append/query/idempotency tests**
|
|
|
|
Create `DuckDbHotEventFileStoreTest`:
|
|
|
|
```java
|
|
class DuckDbHotEventFileStoreTest {
|
|
|
|
@TempDir
|
|
Path tempDir;
|
|
|
|
@Test
|
|
void appendsRecordsAndQueriesByVinTypeAndTime() throws Exception {
|
|
EventFileStore store = store();
|
|
store.append(rawRecord("raw-1", "VIN001", "2026-06-23T01:00:00Z"));
|
|
store.append(rawRecord("raw-2", "VIN002", "2026-06-23T01:00:01Z"));
|
|
store.append(rawRecord("raw-3", "VIN001", "2026-06-23T01:00:02Z"));
|
|
|
|
EventFileQuery query = new EventFileQuery(
|
|
ProtocolId.GB32960,
|
|
LocalDate.parse("2026-06-23"),
|
|
LocalDate.parse("2026-06-23"),
|
|
Instant.parse("2026-06-23T01:00:00Z"),
|
|
Instant.parse("2026-06-23T01:00:03Z"),
|
|
EventFileQuery.Order.DESC,
|
|
2,
|
|
"VIN001",
|
|
"RAW_ARCHIVE");
|
|
|
|
assertThat(store.query(query))
|
|
.extracting(EventFileRecord::eventId)
|
|
.containsExactly("raw-3", "raw-1");
|
|
}
|
|
|
|
@Test
|
|
void appendAllIsIdempotentByEventId() throws Exception {
|
|
EventFileStore store = store();
|
|
EventFileRecord original = rawRecord("same-id", "VIN001", "2026-06-23T01:00:00Z");
|
|
EventFileRecord replacement = new EventFileRecord(
|
|
"same-id",
|
|
ProtocolId.GB32960,
|
|
"RAW_ARCHIVE",
|
|
"VIN001",
|
|
Instant.parse("2026-06-23T01:00:05Z"),
|
|
Instant.parse("2026-06-23T01:00:06Z"),
|
|
"archive://replacement.bin",
|
|
Map.of("source", "replacement"),
|
|
"{\"replacement\":true}");
|
|
|
|
store.appendAll(List.of(original, replacement));
|
|
|
|
assertThat(store.query(new EventFileQuery(
|
|
ProtocolId.GB32960,
|
|
LocalDate.parse("2026-06-23"),
|
|
LocalDate.parse("2026-06-23"),
|
|
EventFileQuery.Order.ASC,
|
|
10,
|
|
"VIN001",
|
|
"RAW_ARCHIVE")))
|
|
.singleElement()
|
|
.satisfies(record -> {
|
|
assertThat(record.eventId()).isEqualTo("same-id");
|
|
assertThat(record.rawArchiveUri()).isEqualTo("archive://replacement.bin");
|
|
});
|
|
}
|
|
|
|
@Test
|
|
void findsRecordByRawArchiveUri() throws Exception {
|
|
EventFileStore store = store();
|
|
EventFileRecord record = rawRecord("raw-uri", "VIN001", "2026-06-23T01:00:00Z");
|
|
store.append(record);
|
|
|
|
EventFileRecord found = store.findByRawArchiveUri(record.rawArchiveUri());
|
|
|
|
assertThat(found).isNotNull();
|
|
assertThat(found.eventId()).isEqualTo("raw-uri");
|
|
}
|
|
|
|
private EventFileStore store() {
|
|
return new DuckDbHotEventFileStore(tempDir, ZoneId.of("Asia/Shanghai"), new ObjectMapper());
|
|
}
|
|
|
|
private static EventFileRecord rawRecord(String id, String vin, String eventTime) {
|
|
return new EventFileRecord(
|
|
id,
|
|
ProtocolId.GB32960,
|
|
"RAW_ARCHIVE",
|
|
vin,
|
|
Instant.parse(eventTime),
|
|
Instant.parse(eventTime).plusMillis(100),
|
|
"archive://" + id + ".bin",
|
|
Map.of("platformAccount", "Hyundai", "command", "REALTIME_REPORT"),
|
|
"{\"eventId\":\"" + id + "\"}");
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test and verify it fails**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl :event-file-store -Dtest=DuckDbHotEventFileStoreTest test
|
|
```
|
|
|
|
Expected: compilation failure because `DuckDbHotEventFileStore` does not exist.
|
|
|
|
- [ ] **Step 3: Create the hot store class**
|
|
|
|
Create `DuckDbHotEventFileStore` with this structure:
|
|
|
|
```java
|
|
public final class DuckDbHotEventFileStore implements EventFileStore {
|
|
|
|
private static final TypeReference<Map<String, String>> STRING_MAP = new TypeReference<>() {};
|
|
|
|
private final Path root;
|
|
private final Path dbPath;
|
|
private final ZoneId partitionZone;
|
|
private final ObjectMapper objectMapper;
|
|
private volatile boolean initialized;
|
|
|
|
public DuckDbHotEventFileStore(Path root, ZoneId partitionZone) {
|
|
this(root, partitionZone, new ObjectMapper());
|
|
}
|
|
|
|
public DuckDbHotEventFileStore(Path root, ZoneId partitionZone, ObjectMapper objectMapper) {
|
|
if (root == null) {
|
|
throw new IllegalArgumentException("root must not be null");
|
|
}
|
|
this.root = root.toAbsolutePath();
|
|
this.dbPath = this.root.resolve("events.duckdb");
|
|
this.partitionZone = partitionZone == null ? ZoneId.of("Asia/Shanghai") : partitionZone;
|
|
this.objectMapper = objectMapper == null ? new ObjectMapper() : objectMapper;
|
|
}
|
|
|
|
@Override
|
|
public synchronized void appendAll(List<EventFileRecord> records) throws IOException {
|
|
if (records == null || records.isEmpty()) {
|
|
return;
|
|
}
|
|
ensureInitialized();
|
|
try (Connection connection = DriverManager.getConnection(jdbcUrl())) {
|
|
connection.setAutoCommit(false);
|
|
try {
|
|
upsertRecords(connection, records);
|
|
connection.commit();
|
|
} catch (SQLException | IOException e) {
|
|
connection.rollback();
|
|
throw e;
|
|
} finally {
|
|
connection.setAutoCommit(true);
|
|
}
|
|
} catch (SQLException e) {
|
|
throw new IOException("write duckdb hot event store failed", e);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public List<EventFileRecord> query(EventFileQuery query) throws IOException {
|
|
ensureInitialized();
|
|
// Implement in Task 3.
|
|
return List.of();
|
|
}
|
|
|
|
@Override
|
|
public EventFileRecord findByRawArchiveUri(String rawArchiveUri) throws IOException {
|
|
ensureInitialized();
|
|
// Implement in Task 3.
|
|
return null;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Add schema initialization**
|
|
|
|
Add:
|
|
|
|
```java
|
|
private void ensureInitialized() throws IOException {
|
|
if (initialized) {
|
|
return;
|
|
}
|
|
synchronized (this) {
|
|
if (initialized) {
|
|
return;
|
|
}
|
|
Files.createDirectories(root);
|
|
try (Connection connection = DriverManager.getConnection(jdbcUrl());
|
|
Statement statement = connection.createStatement()) {
|
|
statement.execute("""
|
|
CREATE TABLE IF NOT EXISTS event_records (
|
|
event_id VARCHAR PRIMARY KEY,
|
|
protocol VARCHAR NOT NULL,
|
|
event_type VARCHAR NOT NULL,
|
|
vin VARCHAR NOT NULL,
|
|
event_time_ms BIGINT NOT NULL,
|
|
ingest_time_ms BIGINT NOT NULL,
|
|
partition_date DATE NOT NULL,
|
|
raw_archive_uri VARCHAR NOT NULL,
|
|
metadata_json VARCHAR NOT NULL,
|
|
payload_json VARCHAR NOT NULL
|
|
)
|
|
""");
|
|
statement.execute("""
|
|
CREATE INDEX IF NOT EXISTS event_records_protocol_date_time_idx
|
|
ON event_records(protocol, partition_date, event_time_ms)
|
|
""");
|
|
statement.execute("""
|
|
CREATE INDEX IF NOT EXISTS event_records_vin_date_time_idx
|
|
ON event_records(protocol, vin, partition_date, event_time_ms)
|
|
""");
|
|
statement.execute("""
|
|
CREATE INDEX IF NOT EXISTS event_records_vin_type_date_time_idx
|
|
ON event_records(protocol, vin, event_type, partition_date, event_time_ms)
|
|
""");
|
|
statement.execute("""
|
|
CREATE INDEX IF NOT EXISTS event_records_raw_archive_uri_idx
|
|
ON event_records(raw_archive_uri)
|
|
""");
|
|
initialized = true;
|
|
} catch (SQLException e) {
|
|
throw new IOException("initialize duckdb hot event store failed", e);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Add idempotent batch upsert**
|
|
|
|
Use DuckDB `INSERT OR REPLACE` inside one transaction:
|
|
|
|
```java
|
|
private void upsertRecords(Connection connection, List<EventFileRecord> records)
|
|
throws SQLException, IOException {
|
|
try (PreparedStatement ps = connection.prepareStatement("""
|
|
INSERT OR REPLACE INTO event_records VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""")) {
|
|
for (EventFileRecord record : records) {
|
|
ps.setString(1, record.eventId());
|
|
ps.setString(2, record.protocol().name());
|
|
ps.setString(3, record.eventType());
|
|
ps.setString(4, record.vin());
|
|
ps.setLong(5, record.eventTime().toEpochMilli());
|
|
ps.setLong(6, record.ingestTime().toEpochMilli());
|
|
ps.setString(7, LocalDate.ofInstant(record.eventTime(), partitionZone).toString());
|
|
ps.setString(8, record.rawArchiveUri());
|
|
ps.setString(9, objectMapper.writeValueAsString(record.metadata()));
|
|
ps.setString(10, record.payloadJson());
|
|
ps.addBatch();
|
|
}
|
|
ps.executeBatch();
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Run the hot store tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl :event-file-store -Dtest=DuckDbHotEventFileStoreTest test
|
|
```
|
|
|
|
Expected: query tests still fail until Task 3 implements reads; append initialization should compile.
|
|
|
|
## Task 3: Implement Hot Store Queries and Raw URI Lookup
|
|
|
|
**Files:**
|
|
- Modify: `modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/DuckDbHotEventFileStore.java`
|
|
- Test: `modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/DuckDbHotEventFileStoreTest.java`
|
|
|
|
- [ ] **Step 1: Implement `query(EventFileQuery)`**
|
|
|
|
Use prepared statements for every external value:
|
|
|
|
```java
|
|
@Override
|
|
public List<EventFileRecord> query(EventFileQuery query) throws IOException {
|
|
ensureInitialized();
|
|
String order = query.order() == EventFileQuery.Order.DESC ? "DESC" : "ASC";
|
|
StringBuilder where = new StringBuilder("""
|
|
WHERE protocol = ?
|
|
AND partition_date BETWEEN CAST(? AS DATE) AND CAST(? AS DATE)
|
|
""");
|
|
if (query.vin() != null) {
|
|
where.append(" AND vin = ?\n");
|
|
}
|
|
if (query.eventType() != null) {
|
|
where.append(" AND event_type = ?\n");
|
|
}
|
|
if (query.eventTimeFrom() != null) {
|
|
where.append(" AND event_time_ms >= ?\n");
|
|
}
|
|
if (query.eventTimeTo() != null) {
|
|
where.append(" AND event_time_ms <= ?\n");
|
|
}
|
|
String sql = """
|
|
SELECT event_id, protocol, event_type, vin, event_time_ms, ingest_time_ms,
|
|
raw_archive_uri, metadata_json, payload_json
|
|
FROM event_records
|
|
%s
|
|
ORDER BY event_time_ms %s, ingest_time_ms %s, event_id %s
|
|
LIMIT ?
|
|
""".formatted(where, order, order, order);
|
|
try (Connection connection = DriverManager.getConnection(jdbcUrl());
|
|
PreparedStatement ps = connection.prepareStatement(sql)) {
|
|
bindQuery(ps, query);
|
|
try (ResultSet rs = ps.executeQuery()) {
|
|
List<EventFileRecord> out = new ArrayList<>();
|
|
while (rs.next()) {
|
|
out.add(record(rs));
|
|
}
|
|
return out;
|
|
}
|
|
} catch (SQLException e) {
|
|
throw new IOException("query duckdb hot event store failed", e);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add query binding helper**
|
|
|
|
```java
|
|
private static void bindQuery(PreparedStatement ps, EventFileQuery query) throws SQLException {
|
|
int index = 1;
|
|
ps.setString(index++, query.protocol().name());
|
|
ps.setString(index++, query.dateFrom().toString());
|
|
ps.setString(index++, query.dateTo().toString());
|
|
if (query.vin() != null) {
|
|
ps.setString(index++, query.vin());
|
|
}
|
|
if (query.eventType() != null) {
|
|
ps.setString(index++, query.eventType());
|
|
}
|
|
if (query.eventTimeFrom() != null) {
|
|
ps.setLong(index++, query.eventTimeFrom().toEpochMilli());
|
|
}
|
|
if (query.eventTimeTo() != null) {
|
|
ps.setLong(index++, query.eventTimeTo().toEpochMilli());
|
|
}
|
|
ps.setInt(index, query.limit());
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Implement `findByRawArchiveUri`**
|
|
|
|
```java
|
|
@Override
|
|
public EventFileRecord findByRawArchiveUri(String rawArchiveUri) throws IOException {
|
|
if (rawArchiveUri == null || rawArchiveUri.isBlank()) {
|
|
return null;
|
|
}
|
|
ensureInitialized();
|
|
try (Connection connection = DriverManager.getConnection(jdbcUrl());
|
|
PreparedStatement ps = connection.prepareStatement("""
|
|
SELECT event_id, protocol, event_type, vin, event_time_ms, ingest_time_ms,
|
|
raw_archive_uri, metadata_json, payload_json
|
|
FROM event_records
|
|
WHERE raw_archive_uri = ?
|
|
ORDER BY event_type = 'RAW_ARCHIVE' DESC, ingest_time_ms DESC, event_id DESC
|
|
LIMIT 1
|
|
""")) {
|
|
ps.setString(1, rawArchiveUri);
|
|
try (ResultSet rs = ps.executeQuery()) {
|
|
return rs.next() ? record(rs) : null;
|
|
}
|
|
} catch (SQLException e) {
|
|
throw new IOException("query duckdb hot store by raw archive uri failed", e);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Add record mapper and helpers**
|
|
|
|
```java
|
|
private EventFileRecord record(ResultSet rs) throws SQLException, IOException {
|
|
return new EventFileRecord(
|
|
rs.getString("event_id"),
|
|
protocol(rs.getString("protocol")),
|
|
rs.getString("event_type"),
|
|
rs.getString("vin"),
|
|
Instant.ofEpochMilli(rs.getLong("event_time_ms")),
|
|
Instant.ofEpochMilli(rs.getLong("ingest_time_ms")),
|
|
rs.getString("raw_archive_uri"),
|
|
readMetadata(rs.getString("metadata_json")),
|
|
rs.getString("payload_json"));
|
|
}
|
|
|
|
private Map<String, String> readMetadata(String json) throws IOException {
|
|
if (json == null || json.isBlank()) {
|
|
return Map.of();
|
|
}
|
|
return objectMapper.readValue(json, STRING_MAP);
|
|
}
|
|
|
|
private static ProtocolId protocol(String value) {
|
|
if (value == null || value.isBlank()) {
|
|
return ProtocolId.UNKNOWN;
|
|
}
|
|
try {
|
|
return ProtocolId.valueOf(value);
|
|
} catch (IllegalArgumentException ex) {
|
|
return ProtocolId.UNKNOWN;
|
|
}
|
|
}
|
|
|
|
private String jdbcUrl() {
|
|
return "jdbc:duckdb:" + dbPath;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run hot store tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl :event-file-store -Dtest=DuckDbHotEventFileStoreTest test
|
|
```
|
|
|
|
Expected: all `DuckDbHotEventFileStoreTest` tests pass.
|
|
|
|
## Task 4: Preserve Legacy Store Tests and Update Defaults
|
|
|
|
**Files:**
|
|
- Modify: `modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/DuckDbParquetEventFileStoreTest.java`
|
|
- Modify: `modules/apps/vehicle-history-app/src/main/resources/application.yml`
|
|
- Modify: `modules/apps/vehicle-history-app/src/test/java/com/lingniu/ingest/historyapp/VehicleHistoryAppDefaultsTest.java`
|
|
|
|
- [ ] **Step 1: Keep Parquet tests explicitly legacy**
|
|
|
|
No behavior change is needed in `DuckDbParquetEventFileStoreTest`; leave it instantiating `DuckDbParquetEventFileStore` directly. Add a class comment:
|
|
|
|
```java
|
|
/**
|
|
* Compatibility coverage for the legacy Parquet sidecar backend.
|
|
* Production history uses DuckDbHotEventFileStore through auto-configuration.
|
|
*/
|
|
class DuckDbParquetEventFileStoreTest {
|
|
```
|
|
|
|
- [ ] **Step 2: Set vehicle-history-app storage default**
|
|
|
|
Add to `modules/apps/vehicle-history-app/src/main/resources/application.yml`:
|
|
|
|
```yaml
|
|
event-file-store:
|
|
enabled: ${EVENT_FILE_STORE_ENABLED:true}
|
|
storage: ${EVENT_FILE_STORE_STORAGE:duckdb-hot}
|
|
path: ${EVENT_FILE_STORE_PATH:./target/event-store/}
|
|
zone-id: ${EVENT_FILE_STORE_ZONE_ID:Asia/Shanghai}
|
|
batch-size: ${EVENT_FILE_STORE_BATCH_SIZE:1000}
|
|
flush-interval-millis: ${EVENT_FILE_STORE_FLUSH_INTERVAL_MILLIS:1000}
|
|
```
|
|
|
|
Keep existing indentation and only add `storage`; if `batch-size` already exists, update it to `1000`.
|
|
|
|
- [ ] **Step 3: Update app default test**
|
|
|
|
In `VehicleHistoryAppDefaultsTest`, assert:
|
|
|
|
```java
|
|
assertThat(context.getEnvironment()
|
|
.getProperty("lingniu.ingest.event-file-store.storage"))
|
|
.isEqualTo("duckdb-hot");
|
|
```
|
|
|
|
- [ ] **Step 4: Run app default tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl :vehicle-history-app -Dtest=VehicleHistoryAppDefaultsTest test
|
|
```
|
|
|
|
Expected: default configuration test passes.
|
|
|
|
## Task 5: Verify History Ingest Still Archives RAW Bytes
|
|
|
|
**Files:**
|
|
- Modify: `modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/EventHistoryEnvelopeIngestorTest.java`
|
|
- Test: `modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/Gb32960DecodedFrameServiceTest.java`
|
|
|
|
- [ ] **Step 1: Add an integration-style test using the hot store**
|
|
|
|
Add a test that writes a RAW envelope through `EventHistoryEnvelopeIngestor` into `DuckDbHotEventFileStore`, then finds it by URI.
|
|
|
|
```java
|
|
@Test
|
|
void rawArchiveEnvelopeCanBeFoundFromDuckDbHotStoreByUri(@TempDir Path tempDir) throws Exception {
|
|
EventFileStore store = new DuckDbHotEventFileStore(tempDir, ZoneId.of("Asia/Shanghai"), OBJECT_MAPPER);
|
|
CapturingArchiveStore archive = new CapturingArchiveStore();
|
|
EventHistoryEnvelopeIngestor ingestor =
|
|
new EventHistoryEnvelopeIngestor(store, new TelemetryEnvelopeRecordMapper(), archive);
|
|
String rawArchiveKey = "2026/06/23/GB32960/VINRAW001/raw-event-hot.bin";
|
|
String rawArchiveUri = "archive://" + rawArchiveKey;
|
|
byte[] rawBytes = new byte[]{0x23, 0x23, 0x02, 0x01};
|
|
|
|
VehicleEnvelope envelope = VehicleEnvelope.newBuilder()
|
|
.setSchemaVersion("1.0")
|
|
.setEventId("raw-event-hot")
|
|
.setVin("VINRAW001")
|
|
.setSource("GB32960")
|
|
.setProtocolVersion("V2016")
|
|
.setEventTimeMs(1_782_112_400_000L)
|
|
.setIngestTimeMs(1_782_112_401_000L)
|
|
.putMetadata(RawArchiveKeys.META_KEY, rawArchiveKey)
|
|
.putMetadata(RawArchiveKeys.META_URI, rawArchiveUri)
|
|
.setRawArchive(RawArchiveRef.newBuilder()
|
|
.setUri(rawArchiveUri)
|
|
.setSizeBytes(rawBytes.length)
|
|
.setData(ByteString.copyFrom(rawBytes))
|
|
.build())
|
|
.build();
|
|
|
|
EnvelopeIngestResult result = ingestor.tryIngest(envelope.toByteArray());
|
|
|
|
assertThat(result.status()).isEqualTo(EnvelopeIngestResult.Status.STORED);
|
|
assertThat(store.findByRawArchiveUri(rawArchiveUri))
|
|
.isNotNull()
|
|
.extracting(EventFileRecord::eventId)
|
|
.isEqualTo("raw-event-hot");
|
|
assertThat(archive.bytesByKey).containsEntry(rawArchiveKey, rawBytes);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the event history ingestor test**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl :event-history-service -Dtest=EventHistoryEnvelopeIngestorTest test
|
|
```
|
|
|
|
Expected: test passes.
|
|
|
|
- [ ] **Step 3: Run decoded frame service tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl :event-history-service -Dtest=Gb32960DecodedFrameServiceTest test
|
|
```
|
|
|
|
Expected: existing replay/snapshot tests pass. If they use a fake store, no change is needed.
|
|
|
|
## Task 6: Full Module Verification
|
|
|
|
**Files:**
|
|
- No source changes unless failures expose missing imports or config assertions.
|
|
|
|
- [ ] **Step 1: Run sink module tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl :event-file-store test
|
|
```
|
|
|
|
Expected: all event-file-store tests pass, including both hot and legacy stores.
|
|
|
|
- [ ] **Step 2: Run event-history-service tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl :event-history-service test
|
|
```
|
|
|
|
Expected: all event-history-service tests pass.
|
|
|
|
- [ ] **Step 3: Package vehicle-history-app**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
mvn -pl :vehicle-history-app -am package -DskipTests
|
|
```
|
|
|
|
Expected: package succeeds.
|
|
|
|
## Task 7: Local Runtime Verification
|
|
|
|
**Files:**
|
|
- No committed source changes.
|
|
|
|
- [ ] **Step 1: Stop any old history service on port 20200**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
lsof -tiTCP:20200 -sTCP:LISTEN | xargs -r kill
|
|
```
|
|
|
|
Expected: no command output, or the old process exits.
|
|
|
|
- [ ] **Step 2: Start vehicle-history-app with hot DuckDB store**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
EVENT_FILE_STORE_STORAGE=duckdb-hot \
|
|
EVENT_FILE_STORE_PATH=./target/live-history-event-store \
|
|
SINK_ARCHIVE_PATH=./target/live-history-archive \
|
|
KAFKA_BROKERS=114.55.58.251:9092 \
|
|
KAFKA_CONSUMER_ENABLED=true \
|
|
KAFKA_GROUP_HISTORY=vehicle-history-hot-$(date +%Y%m%d%H%M%S) \
|
|
java --sun-misc-unsafe-memory-access=allow \
|
|
-jar modules/apps/vehicle-history-app/target/vehicle-history-app.jar
|
|
```
|
|
|
|
Expected:
|
|
|
|
- app starts on `http://127.0.0.1:20200`;
|
|
- logs show Kafka consumer subscribed;
|
|
- `target/live-history-event-store/events.duckdb` is created.
|
|
|
|
- [ ] **Step 3: Verify health**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
curl -sS http://127.0.0.1:20200/actuator/health
|
|
```
|
|
|
|
Expected:
|
|
|
|
```json
|
|
{"status":"UP"}
|
|
```
|
|
|
|
- [ ] **Step 4: Verify a recent VIN query**
|
|
|
|
Run with a VIN observed in live archive:
|
|
|
|
```bash
|
|
curl -sS 'http://127.0.0.1:20200/api/event-history/gb32960/telemetry-snapshots?vin=LNXNEGRR9SR318194&platformAccount=Hyundai&dateFrom=2026-06-23&dateTo=2026-06-24&order=DESC&limit=3'
|
|
```
|
|
|
|
Expected:
|
|
|
|
- response is a JSON array;
|
|
- if live traffic for that VIN exists after service start, at least one snapshot appears;
|
|
- `rawArchiveUris` point to existing files under `target/live-history-archive`.
|
|
|
|
- [ ] **Step 5: Verify raw frame replay**
|
|
|
|
Use one `rawArchiveUri` from Step 4:
|
|
|
|
```bash
|
|
curl -sS 'http://127.0.0.1:20200/api/event-history/gb32960/frame?rawArchiveUri=archive://REPLACE_ME&platformAccount=Hyundai'
|
|
```
|
|
|
|
Expected:
|
|
|
|
- response contains `vin`, `command`, `eventTime`, and parsed `blocks`;
|
|
- no `raw archive is missing` warning for the selected URI.
|
|
|
|
## Self-Review Checklist
|
|
|
|
- [ ] The new hot store does not rewrite Parquet files on every append.
|
|
- [ ] `appendAll` writes one batch in one transaction.
|
|
- [ ] Duplicate `event_id` replay is idempotent.
|
|
- [ ] Existing history HTTP APIs still use `EventFileStore`, so controller contracts are unchanged.
|
|
- [ ] RAW bytes still land in `ArchiveStore`.
|
|
- [ ] `findByRawArchiveUri` is backed by DuckDB index.
|
|
- [ ] No MySQL credential, RDS host, username, or password appears in the plan or source files.
|
|
- [ ] This plan does not claim the full production goal is complete; it only lands the history foundation.
|