feat(syslog): 优化大日志通道并增加异常连续消费兜底
- 128 KiB 及以上日志默认进入大日志 Topic - InfluxDB 原文及达梦大字段超过 256 KiB 时截断保存 - 截断内容追加 HB_XDR_TRUNCATED 标记 - InfluxDB 写入失败时继续执行规则解析和达梦写入 - 未知或系统性异常优先写入普通/大日志 DLT - DLT 写入失败时记录特殊告警并提交 offset,避免阻塞后续消息 - DLT 失败等待时间默认限制为 1 秒 - 增加连续性放行、DLT 成功及失败监控指标 - 更新 Docker Compose、v3 升级文档及相关测试
This commit is contained in:
+21
@@ -42,6 +42,11 @@ class SyslogFailureRouterReliabilityTest {
|
||||
ReflectionTestUtils.setField(router, "retry10mTopic", "raw.RETRY.10M");
|
||||
ReflectionTestUtils.setField(router, "retry1hTopic", "raw.RETRY.1H");
|
||||
ReflectionTestUtils.setField(router, "dltTopic", "raw.DLT");
|
||||
ReflectionTestUtils.setField(router, "largeOriginalTopic", "raw.large");
|
||||
ReflectionTestUtils.setField(router, "largeRetry1mTopic", "raw.large.RETRY.1M");
|
||||
ReflectionTestUtils.setField(router, "largeRetry10mTopic", "raw.large.RETRY.10M");
|
||||
ReflectionTestUtils.setField(router, "largeRetry1hTopic", "raw.large.RETRY.1H");
|
||||
ReflectionTestUtils.setField(router, "largeDltTopic", "raw.large.DLT");
|
||||
ReflectionTestUtils.setField(router, "publishTimeoutMs", 1000L);
|
||||
ReflectionTestUtils.setField(router, "consumerVersion", "test");
|
||||
record = new ConsumerRecord<>("raw", 2, 42L, "device-1", "ciphertext");
|
||||
@@ -86,6 +91,22 @@ class SyslogFailureRouterReliabilityTest {
|
||||
assertFalse(body.getValue().contains("schema_version"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void largeMessageFailureNeverFallsBackIntoNormalRetryTopic() {
|
||||
ConsumerRecord<String, String> largeRecord = new ConsumerRecord<>(
|
||||
"raw.large", 1, 7L, "event", "large-ciphertext");
|
||||
SettableListenableFuture<SendResult<String, String>> acknowledged = new SettableListenableFuture<>();
|
||||
acknowledged.set(null);
|
||||
when(template.send(eq("raw.large.RETRY.1M"), anyString(), anyString()))
|
||||
.thenReturn(acknowledged);
|
||||
|
||||
SyslogFailureRouter.TerminalState state = router.route(
|
||||
largeRecord, router.resolve(largeRecord), new SocketException("reset"));
|
||||
|
||||
assertEquals(SyslogFailureRouter.TerminalState.RETRY_PUBLISHED, state);
|
||||
verify(template).send(eq("raw.large.RETRY.1M"), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishFailureNeverBecomesTerminal() {
|
||||
SettableListenableFuture<SendResult<String, String>> failed = new SettableListenableFuture<>();
|
||||
|
||||
+71
@@ -4,12 +4,17 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.kafka.annotation.KafkaListener;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.kafka.clients.consumer.ConsumerRecord;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import com.common.service.StatsMaterializationConsumer;
|
||||
import com.common.service.StatsProjectionDeltaConsumer;
|
||||
|
||||
/** 防止多节点 consumer-rule 的监听器 ID 或显式消费组在后续合并中回退。 */
|
||||
class SyslogKafkaTopologyTest {
|
||||
@@ -50,6 +55,56 @@ class SyslogKafkaTopologyTest {
|
||||
}, listener.topics());
|
||||
}
|
||||
|
||||
@Test
|
||||
void largeRawListenerUsesDedicatedTopicGroupAndFactory() throws Exception {
|
||||
Method method = SysLogProcessor.class.getMethod(
|
||||
"consumeLargeMessages", List.class,
|
||||
org.springframework.kafka.support.Acknowledgment.class);
|
||||
KafkaListener listener = method.getAnnotation(KafkaListener.class);
|
||||
|
||||
assertNotNull(listener);
|
||||
assertEquals("syslog-consumer-rule-raw-large", listener.id());
|
||||
assertEquals("${syslog.large-listener.group-id:${spring.kafka.consumer.group-id}-large}",
|
||||
listener.groupId());
|
||||
assertEquals("largeRawKafkaListenerContainerFactory", listener.containerFactory());
|
||||
assertArrayEquals(new String[]{"${spring.kafka.consumer.large-topic:agent-syslog-topic.large}"},
|
||||
listener.topics());
|
||||
}
|
||||
|
||||
@Test
|
||||
void largeRetryReplayListenerIsAlsoIsolated() throws Exception {
|
||||
Method method = SysLogProcessor.class.getMethod(
|
||||
"consumeLargeRetryAndReplay", List.class,
|
||||
org.springframework.kafka.support.Acknowledgment.class);
|
||||
KafkaListener listener = method.getAnnotation(KafkaListener.class);
|
||||
|
||||
assertNotNull(listener);
|
||||
assertEquals("syslog-consumer-rule-large-retry-replay", listener.id());
|
||||
assertEquals("largeRawKafkaListenerContainerFactory", listener.containerFactory());
|
||||
assertArrayEquals(new String[]{
|
||||
"${syslog.large-reliability.retry-1m-topic:${spring.kafka.consumer.large-topic}.RETRY.1M}",
|
||||
"${syslog.large-reliability.retry-10m-topic:${spring.kafka.consumer.large-topic}.RETRY.10M}",
|
||||
"${syslog.large-reliability.retry-1h-topic:${spring.kafka.consumer.large-topic}.RETRY.1H}",
|
||||
"${syslog.large-reliability.replay-topic:${spring.kafka.consumer.large-topic}.REPLAY}"
|
||||
}, listener.topics());
|
||||
}
|
||||
|
||||
@Test
|
||||
void largePollIsPartitionedByUtf8BytesInsteadOfOnlyRecordCount() {
|
||||
List<ConsumerRecord<String, String>> records = Arrays.asList(
|
||||
new ConsumerRecord<>("large", 0, 0L, "k1", "中文"),
|
||||
new ConsumerRecord<>("large", 0, 1L, "k2", "12"),
|
||||
new ConsumerRecord<>("large", 0, 2L, "k3", "🙂"));
|
||||
|
||||
List<List<ConsumerRecord<String, String>>> chunks =
|
||||
SysLogProcessor.partitionRecordsByBytes(records, 7L);
|
||||
|
||||
assertEquals(2, chunks.size());
|
||||
assertEquals(1, chunks.get(0).size());
|
||||
assertEquals(2, chunks.get(1).size());
|
||||
assertEquals(6, chunks.get(0).get(0).value().getBytes(StandardCharsets.UTF_8).length);
|
||||
}
|
||||
|
||||
@Test
|
||||
void statsMaterializerUsesDedicatedTopicAndGroup() throws Exception {
|
||||
Method method = StatsMaterializationConsumer.class.getMethod(
|
||||
@@ -65,4 +120,20 @@ class SyslogKafkaTopologyTest {
|
||||
"${stats.materialization.topic:${spring.kafka.consumer.topic}.STATS.MATERIALIZE}"
|
||||
}, listener.topics());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statsProjectionUsesSeparateSingleLaneTopicAndGroup() throws Exception {
|
||||
Method method = StatsProjectionDeltaConsumer.class.getMethod(
|
||||
"consume", List.class, org.springframework.kafka.support.Acknowledgment.class);
|
||||
KafkaListener listener = method.getAnnotation(KafkaListener.class);
|
||||
|
||||
assertNotNull(listener);
|
||||
assertEquals("syslog-stats-projection", listener.id());
|
||||
assertEquals("${stats.projection-async.group-id:${spring.kafka.consumer.group-id}-stats-projection}",
|
||||
listener.groupId());
|
||||
assertEquals("statsProjectionKafkaListenerContainerFactory", listener.containerFactory());
|
||||
assertArrayEquals(new String[]{
|
||||
"${stats.projection-async.topic:${spring.kafka.consumer.topic}.STATS.PROJECTION}"
|
||||
}, listener.topics());
|
||||
}
|
||||
}
|
||||
|
||||
+10
-9
@@ -119,12 +119,14 @@ class DirectStatsProjectionWriterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void kafkaMaterializationRemovesBusinessTableUpdatesFromFactTransaction() {
|
||||
void durableOutboxRemovesDailyAndBusinessProjectionUpdatesFromFactTransaction() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
StatsMaterializationPublisher publisher = mock(StatsMaterializationPublisher.class);
|
||||
when(publisher.isEnabled()).thenReturn(true);
|
||||
StatsProjectionOutboxWriter outbox = mock(StatsProjectionOutboxWriter.class);
|
||||
when(outbox.isEnabled()).thenReturn(true);
|
||||
DirectStatsProjectionWriter writer = new DirectStatsProjectionWriter(
|
||||
jdbc, new StatsWriteModeProvider("DIRECT"), new DirectStatsWriteMetrics(), publisher);
|
||||
jdbc, new StatsWriteModeProvider("DIRECT"), new DirectStatsWriteMetrics(), publisher, outbox);
|
||||
configure(writer, true);
|
||||
TransactionSynchronizationManager.setActualTransactionActive(true);
|
||||
|
||||
@@ -135,20 +137,19 @@ class DirectStatsProjectionWriterTest {
|
||||
row.put("normalize_rule_id", 11L);
|
||||
writer.writeStandardizedRow(row);
|
||||
|
||||
ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc, times(2)).batchUpdate(sql.capture(), anyList());
|
||||
assertTrue(sql.getAllValues().get(0).contains("MERGE INTO device_daily_stats"));
|
||||
assertTrue(sql.getAllValues().get(1).contains("MERGE INTO normalize_rule_daily_stats"));
|
||||
verify(publisher).enqueueAfterCommit(anyCollection());
|
||||
assertTrue("KAFKA_DIRTY_KEY".equals(writer.status().get("business_materialization_mode")));
|
||||
verify(outbox).append(org.mockito.ArgumentMatchers.any(StatsProjectionOutboxEvent.class));
|
||||
verifyNoInteractions(jdbc);
|
||||
assertTrue("DURABLE_OUTBOX".equals(writer.status().get("daily_projection_write_mode")));
|
||||
}
|
||||
|
||||
private DirectStatsProjectionWriter writer(JdbcTemplate jdbc, String mode, boolean readEnabled) {
|
||||
StatsWriteModeProvider modeProvider = new StatsWriteModeProvider(mode);
|
||||
StatsMaterializationPublisher publisher = mock(StatsMaterializationPublisher.class);
|
||||
when(publisher.isEnabled()).thenReturn(false);
|
||||
StatsProjectionOutboxWriter outbox = mock(StatsProjectionOutboxWriter.class);
|
||||
when(outbox.isEnabled()).thenReturn(false);
|
||||
DirectStatsProjectionWriter writer = new DirectStatsProjectionWriter(
|
||||
jdbc, modeProvider, new DirectStatsWriteMetrics(), publisher);
|
||||
jdbc, modeProvider, new DirectStatsWriteMetrics(), publisher, outbox);
|
||||
configure(writer, readEnabled);
|
||||
return writer;
|
||||
}
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.common.service;
|
||||
|
||||
import com.common.entity.SyslogNonNormalMessage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class DmLargeTextLimiterTest {
|
||||
|
||||
@Test
|
||||
void normalConfiguredFieldsAreTruncatedButOtherFieldsRemainUntouched() {
|
||||
DmLargeTextLimiter limiter = limiter(96L, "http_req_body,description", 0L);
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("http_req_body", repeat("中文🙂", 100));
|
||||
row.put("other", repeat("x", 200));
|
||||
|
||||
limiter.truncateNormalRow(row);
|
||||
|
||||
String body = (String) row.get("http_req_body");
|
||||
assertTrue(body.contains("HB_XDR_TRUNCATED"));
|
||||
assertTrue(body.getBytes(StandardCharsets.UTF_8).length <= 96);
|
||||
assertEquals(repeat("x", 200), row.get("other"));
|
||||
assertEquals(1L, limiter.getNormalFieldsTruncated());
|
||||
}
|
||||
|
||||
@Test
|
||||
void allNonNormalLargeColumnsUseTheSameVisibleMarker() {
|
||||
DmLargeTextLimiter limiter = limiter(0L, "", 96L);
|
||||
SyslogNonNormalMessage message = new SyslogNonNormalMessage();
|
||||
message.setSyslogMessage(repeat("原文", 100));
|
||||
message.setHeaderMessage(repeat("头", 100));
|
||||
message.setReasonDetail(repeat("原因", 100));
|
||||
|
||||
limiter.truncateNonNormal(message);
|
||||
|
||||
assertTrue(message.getSyslogMessage().contains("HB_XDR_TRUNCATED"));
|
||||
assertTrue(message.getHeaderMessage().contains("HB_XDR_TRUNCATED"));
|
||||
assertTrue(message.getReasonDetail().contains("HB_XDR_TRUNCATED"));
|
||||
assertEquals(3L, limiter.getNonNormalFieldsTruncated());
|
||||
}
|
||||
|
||||
private DmLargeTextLimiter limiter(long normal, String fields, long nonNormal) {
|
||||
DmLargeTextLimiter limiter = new DmLargeTextLimiter();
|
||||
ReflectionTestUtils.setField(limiter, "normalMaxBytes", normal);
|
||||
ReflectionTestUtils.setField(limiter, "normalFieldsValue", fields);
|
||||
ReflectionTestUtils.setField(limiter, "nonNormalMaxBytes", nonNormal);
|
||||
limiter.validate();
|
||||
return limiter;
|
||||
}
|
||||
|
||||
private static String repeat(String value, int count) {
|
||||
StringBuilder result = new StringBuilder(value.length() * count);
|
||||
for (int i = 0; i < count; i++) result.append(value);
|
||||
return result.toString();
|
||||
}
|
||||
}
|
||||
+34
@@ -2,6 +2,11 @@ package com.common.service;
|
||||
|
||||
import com.common.entity.IngressStatsEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.SimpleTransactionStatus;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
@@ -9,6 +14,11 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class IngressStatsProjectionWriterTest {
|
||||
@Test
|
||||
@@ -41,4 +51,28 @@ class IngressStatsProjectionWriterTest {
|
||||
assertEquals(Arrays.asList(8L, 2L, 9L), devices);
|
||||
assertEquals(Arrays.asList(3L, 4L, 7L), new ArrayList<>(result.collects.keySet()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void directAsyncModeWritesOnlyOneOutboxEvent() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
PlatformTransactionManager manager = mock(PlatformTransactionManager.class);
|
||||
when(manager.getTransaction(any(TransactionDefinition.class)))
|
||||
.thenReturn(new SimpleTransactionStatus());
|
||||
StatsProjectionOutboxWriter outbox = mock(StatsProjectionOutboxWriter.class);
|
||||
when(outbox.isEnabled()).thenReturn(true);
|
||||
IngressStatsProjectionWriter writer = new IngressStatsProjectionWriter(
|
||||
jdbc, manager, new StatsWriteModeProvider("DIRECT"),
|
||||
mock(StatsMaterializationPublisher.class), outbox);
|
||||
ReflectionTestUtils.setField(writer, "enabled", true);
|
||||
ReflectionTestUtils.setField(writer, "transactionTimeoutSeconds", 45);
|
||||
|
||||
int groups = writer.writeBatch(Arrays.asList(
|
||||
new IngressStatsEvent("a", 7L, 3L, LocalDateTime.of(2026, 9, 12, 8, 0)),
|
||||
new IngressStatsEvent("b", 7L, 3L, LocalDateTime.of(2026, 9, 12, 8, 1))));
|
||||
|
||||
assertEquals(2, groups);
|
||||
verify(outbox).append(org.mockito.ArgumentMatchers.any(StatsProjectionOutboxEvent.class));
|
||||
verifyNoInteractions(jdbc);
|
||||
verify(manager).commit(any());
|
||||
}
|
||||
}
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package com.common.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.SimpleTransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class StatsProjectionAsyncTest {
|
||||
@AfterEach
|
||||
void clearTransactionState() {
|
||||
TransactionSynchronizationManager.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboxEventAggregatesOneFactBatchByDimension() {
|
||||
LocalDateTime time = LocalDateTime.of(2026, 9, 12, 8, 0);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (long ruleId : Arrays.asList(11L, 12L, 11L)) {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("created_at", time);
|
||||
row.put("log_time", time.plusMinutes(ruleId));
|
||||
row.put("device_id", 7L);
|
||||
row.put("normalize_rule_id", ruleId);
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
StatsProjectionOutboxEvent event = StatsProjectionOutboxEvent.fromAccumulator(
|
||||
new DirectStatsAccumulator().addStandardizedRows(rows));
|
||||
|
||||
event.validate();
|
||||
assertEquals(1, event.getDevices().size());
|
||||
assertEquals(3L, event.getDevices().get(0).getParseDelta());
|
||||
assertEquals(2, event.getRules().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboxWriterRequiresAndUsesFactTransaction() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
StatsProjectionOutboxWriter writer = new StatsProjectionOutboxWriter(jdbc, new ObjectMapper());
|
||||
ReflectionTestUtils.setField(writer, "enabled", true);
|
||||
ReflectionTestUtils.setField(writer, "outboxTable", "stats_projection_outbox");
|
||||
StatsProjectionOutboxEvent event = oneDeviceEvent("event-1");
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> writer.append(event));
|
||||
|
||||
TransactionSynchronizationManager.setActualTransactionActive(true);
|
||||
writer.append(event);
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.contains("INSERT INTO stats_projection_outbox"),
|
||||
org.mockito.ArgumentMatchers.eq("event-1"),
|
||||
org.mockito.ArgumentMatchers.eq(StatsProjectionOutboxEvent.SCHEMA),
|
||||
org.mockito.ArgumentMatchers.anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectionConsumerAppliesFreshEventAndRecordsIdempotencyMarker() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForList(org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.eq(String.class), org.mockito.ArgumentMatchers.<Object>any()))
|
||||
.thenReturn(Collections.emptyList());
|
||||
PlatformTransactionManager manager = mock(PlatformTransactionManager.class);
|
||||
when(manager.getTransaction(any(TransactionDefinition.class))).thenReturn(new SimpleTransactionStatus());
|
||||
StatsMaterializationPublisher publisher = mock(StatsMaterializationPublisher.class);
|
||||
StatsProjectionDeltaConsumer consumer = new StatsProjectionDeltaConsumer(
|
||||
jdbc, manager, new ObjectMapper(), publisher);
|
||||
configure(consumer);
|
||||
|
||||
StatsProjectionDeltaConsumer.ApplyResult result = consumer.applyWithRetry(
|
||||
Collections.singletonList(oneDeviceEvent("event-1")));
|
||||
|
||||
assertEquals(1, result.appliedEvents);
|
||||
assertEquals(1, result.deviceGroups);
|
||||
ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc, times(3)).batchUpdate(sql.capture(), anyList());
|
||||
assertTrue(sql.getAllValues().get(0).contains("MERGE INTO device_daily_stats"));
|
||||
assertTrue(sql.getAllValues().get(1).contains("stats_projection_applied_event"));
|
||||
assertTrue(sql.getAllValues().get(2).contains("SET status='APPLIED'"));
|
||||
verify(publisher).enqueueAfterCommit(org.mockito.ArgumentMatchers.anyCollection());
|
||||
verify(manager).commit(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectionConsumerDoesNotApplyDuplicateDeltaTwice() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForList(org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.eq(String.class), org.mockito.ArgumentMatchers.<Object>any()))
|
||||
.thenReturn(Collections.singletonList("event-1"));
|
||||
PlatformTransactionManager manager = mock(PlatformTransactionManager.class);
|
||||
when(manager.getTransaction(any(TransactionDefinition.class))).thenReturn(new SimpleTransactionStatus());
|
||||
StatsMaterializationPublisher publisher = mock(StatsMaterializationPublisher.class);
|
||||
StatsProjectionDeltaConsumer consumer = new StatsProjectionDeltaConsumer(
|
||||
jdbc, manager, new ObjectMapper(), publisher);
|
||||
configure(consumer);
|
||||
|
||||
StatsProjectionDeltaConsumer.ApplyResult result = consumer.applyWithRetry(
|
||||
Collections.singletonList(oneDeviceEvent("event-1")));
|
||||
|
||||
assertEquals(0, result.appliedEvents);
|
||||
assertEquals(1, result.duplicates);
|
||||
ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc).batchUpdate(sql.capture(), anyList());
|
||||
assertTrue(sql.getValue().contains("SET status='APPLIED'"));
|
||||
}
|
||||
|
||||
private StatsProjectionOutboxEvent oneDeviceEvent(String eventId) {
|
||||
DirectStatsAccumulator accumulator = new DirectStatsAccumulator();
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("created_at", LocalDate.of(2026, 9, 12));
|
||||
row.put("log_time", LocalDateTime.of(2026, 9, 12, 8, 0));
|
||||
row.put("device_id", 7L);
|
||||
StatsProjectionOutboxEvent event = StatsProjectionOutboxEvent.fromAccumulator(
|
||||
accumulator.addStandardizedRow(row));
|
||||
event.setEventId(eventId);
|
||||
return event;
|
||||
}
|
||||
|
||||
private void configure(StatsProjectionDeltaConsumer consumer) {
|
||||
ReflectionTestUtils.setField(consumer, "enabled", true);
|
||||
ReflectionTestUtils.setField(consumer, "appliedTable", "stats_projection_applied_event");
|
||||
ReflectionTestUtils.setField(consumer, "outboxTable", "stats_projection_outbox");
|
||||
ReflectionTestUtils.setField(consumer, "deviceTable", "device_daily_stats");
|
||||
ReflectionTestUtils.setField(consumer, "ruleTable", "normalize_rule_daily_stats");
|
||||
ReflectionTestUtils.setField(consumer, "transactionTimeoutSeconds", 45);
|
||||
ReflectionTestUtils.setField(consumer, "maxConcurrencyRetries", 3);
|
||||
ReflectionTestUtils.setField(consumer, "retryBackoffMs", 0L);
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.common.util;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class Utf8TextTruncatorTest {
|
||||
@Test
|
||||
void truncatesWithinBudgetWithoutSplittingChineseOrEmoji() {
|
||||
String source = repeat("中文🙂", 100);
|
||||
|
||||
Utf8TextTruncator.Result result = Utf8TextTruncator.truncate(source, 96L);
|
||||
|
||||
assertTrue(result.isTruncated());
|
||||
assertTrue(result.getValue().endsWith("]"));
|
||||
assertTrue(result.getValue().contains("HB_XDR_TRUNCATED"));
|
||||
assertTrue(result.getValue().getBytes(StandardCharsets.UTF_8).length <= 96);
|
||||
assertEquals(source.getBytes(StandardCharsets.UTF_8).length, result.getOriginalUtf8Bytes());
|
||||
assertNotNull(result.getOriginalSha256());
|
||||
assertFalse(result.getValue().contains("�"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroMeansUnlimitedAndSmallEnabledLimitFailsClearly() {
|
||||
Utf8TextTruncator.Result result = Utf8TextTruncator.truncate("中文🙂", 0L);
|
||||
assertFalse(result.isTruncated());
|
||||
assertEquals("中文🙂", result.getValue());
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> Utf8TextTruncator.truncate(repeat("x", 100), 8L));
|
||||
}
|
||||
|
||||
private static String repeat(String value, int count) {
|
||||
StringBuilder result = new StringBuilder(value.length() * count);
|
||||
for (int i = 0; i < count; i++) result.append(value);
|
||||
return result.toString();
|
||||
}
|
||||
}
|
||||
+25
@@ -67,6 +67,25 @@ class InfluxRawV2ContractTest {
|
||||
String line=raw.getPoint().toLineProtocol(); assertFalse(line.substring(0,line.indexOf(' ')).contains("uuid=")); assertTrue(line.contains("event_bucket=2085")); assertTrue(line.contains("uuid=\"20260814-a3d874a0-6f50-4f0c-9e7a-9c20303360f5\"")); assertEquals(WritePrecision.NS,raw.getPoint().getPrecision()); assertEquals("yelangbucket_v2",raw.getBucket());
|
||||
}
|
||||
|
||||
@Test void oversizedRawMessageIsMarkedAndCarriesStructuredTruncationMetadata() {
|
||||
RawStorageProperties properties=mock(RawStorageProperties.class);
|
||||
when(properties.getWriteSchema()).thenReturn(RawStorageProperties.WriteSchema.V2);
|
||||
when(properties.getV2Bucket()).thenReturn("yelangbucket_v2");
|
||||
when(properties.getMessageMaxUtf8Bytes()).thenReturn(128L);
|
||||
RawInfluxMetrics metrics=new RawInfluxMetrics();
|
||||
InfluxRawPointFactory factory=new InfluxRawPointFactory(properties,metrics);
|
||||
|
||||
String line=factory.create("20260814-a3d874a0-6f50-4f0c-9e7a-9c20303360f5",
|
||||
repeat("中文🙂",100),"20260814000000000","126","1","raw.large",
|
||||
ReceiveTimeParser.parse("20260814000000000"),1_786_636_800_000_000_123L)
|
||||
.getPoint().toLineProtocol();
|
||||
|
||||
assertTrue(line.contains("HB_XDR_TRUNCATED"));
|
||||
assertTrue(line.contains("message_truncated=true"));
|
||||
assertTrue(line.contains("message_original_sha256="));
|
||||
assertEquals(1L,metrics.snapshot().get("raw_message_truncated_total"));
|
||||
}
|
||||
|
||||
@Test void onlyMissingPointTimeFallsBack() {
|
||||
RawStorageProperties properties=mock(RawStorageProperties.class); when(properties.isLegacyFallbackEnabled()).thenReturn(true); RawInfluxMetrics metrics=new RawInfluxMetrics(); PointTimeResolver resolver=new PointTimeResolver(properties,metrics); long millis=ReceiveTimeParser.parse("20260814000000000").getEpochMillis();
|
||||
assertNotNull(resolver.resolve(false,null,millis,true)); assertThrows(IllegalArgumentException.class,()->resolver.resolve(true,"-1",millis,true)); assertThrows(IllegalArgumentException.class,()->resolver.resolve(true,"not-number",millis,true)); assertThrows(IllegalArgumentException.class,()->resolver.resolve(true,Long.toString(millis*1_000_000L+1_000_000L),millis,true)); assertEquals(1L,metrics.snapshot().get("legacy_missing_point_time_total"));
|
||||
@@ -108,4 +127,10 @@ class InfluxRawV2ContractTest {
|
||||
assertTrue(query.indexOf("|> group()") < query.indexOf("|> sort("));
|
||||
assertTrue(query.indexOf("|> sort(") < query.indexOf("|> limit(n: 2)"));
|
||||
}
|
||||
|
||||
private static String repeat(String value, int count) {
|
||||
StringBuilder result = new StringBuilder(value.length() * count);
|
||||
for (int i=0;i<count;i++) result.append(value);
|
||||
return result.toString();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user