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:
王绍宏
2026-09-12 19:24:58 +08:00
parent 0e9e6656db
commit c868e3befd
16 changed files with 342 additions and 43 deletions
@@ -67,6 +67,9 @@ public class SysLogProcessor {
@Value("${syslog.large-listener.processing-batch-max-bytes:8388608}")
private long largeProcessingBatchMaxBytes;
@Value("${syslog.failure-continuity.enabled:true}")
private boolean failureContinuityEnabled;
@Autowired
private AppProperties appProperties;
@@ -178,7 +181,7 @@ public class SysLogProcessor {
}
for (List<ConsumerRecord<String, String>> chunk : partitionRecordsByBytes(
records, Math.max(1L, largeProcessingBatchMaxBytes))) {
processBatch(chunk, enforceRetryDue, lane, largeTaskExecutor);
processBatchWithContinuity(chunk, enforceRetryDue, lane, largeTaskExecutor);
}
ack.acknowledge();
}
@@ -201,8 +204,8 @@ public class SysLogProcessor {
/**
* 统一批处理边界:原文批量写入 InfluxDB 后,有界并行执行标准化。
* 正常消息仍需到达可靠终态才提交 offset;非标消息按现场临时策略异步落库,
* 其写库结果不参与 Kafka ACK。
* 失败时优先进入 Retry/DLT;最终兜底按连续性策略记录并推进 offset。非标消息按现场临时策略
* 异步落库,其写库结果不参与 Kafka ACK。
*/
private void consumeBatch(List<ConsumerRecord<String, String>> records, Acknowledgment ack,
boolean enforceRetryDue, String lane, Executor normalizationExecutor) {
@@ -210,12 +213,29 @@ public class SysLogProcessor {
ack.acknowledge();
return;
}
processBatch(records, enforceRetryDue, lane, normalizationExecutor);
processBatchWithContinuity(records, enforceRetryDue, lane, normalizationExecutor);
ack.acknowledge();
}
private void processBatch(List<ConsumerRecord<String, String>> records,
boolean enforceRetryDue, String lane, Executor normalizationExecutor) {
private void processBatchWithContinuity(List<ConsumerRecord<String, String>> records,
boolean enforceRetryDue, String lane,
Executor normalizationExecutor) {
try {
processBatch(records, enforceRetryDue, lane, normalizationExecutor);
} catch (SyslogFailureRouter.RetryNotDueException notDue) {
// Retry Topic 的计划时间尚未到达,不属于处理异常,仍按原延时语义保留 offset。
throw notDue;
} catch (RuntimeException failure) {
if (!failureContinuityEnabled) throw failure;
routeContinuityBatch(records, failure, "BATCH_" + lane);
log.error("[SYSLOG_CONTINUITY] 批次特殊异常已记录,继续消费后续消息: lane={}, "
+ "records={}, errorType={}",
lane, records.size(), failure.getClass().getName(), failure);
}
}
void processBatch(List<ConsumerRecord<String, String>> records,
boolean enforceRetryDue, String lane, Executor normalizationExecutor) {
long batchStartedAt = System.currentTimeMillis();
long prepareStartedAt = batchStartedAt;
BatchMetrics metrics = new BatchMetrics();
@@ -239,7 +259,21 @@ public class SysLogProcessor {
int ingressGroups = ingressStatsProjectionWriter.writeBatch(ingressEvents(preparedMessages));
long influxStartedAt = System.currentTimeMillis();
List<PreparedMessage> persistedMessages = persistRawBatch(preparedMessages, metrics);
List<PreparedMessage> persistedMessages;
try {
persistedMessages = persistRawBatch(preparedMessages, metrics);
} catch (RuntimeException rawStorageFailure) {
if (!failureContinuityEnabled) throw rawStorageFailure;
routeContinuityPrepared(preparedMessages, rawStorageFailure, "RAW_STORAGE");
rawInfluxMetrics.rawWriteBypassed(preparedMessages.size());
metrics.rawStorageBypassed.addAndGet(preparedMessages.size());
// 无论 DLT 是否可用,都继续使用内存中的完整内容执行规则和达梦写入。
persistedMessages = preparedMessages;
log.error("[SYSLOG_CONTINUITY] InfluxDB 原文写入失败,已尝试记录 DLT 并继续标准化: "
+ "lane={}, records={}, errorType={}",
lane, preparedMessages.size(), rawStorageFailure.getClass().getName(),
rawStorageFailure);
}
long influxMs = System.currentTimeMillis() - influxStartedAt;
long normalizeStartedAt = System.currentTimeMillis();
@@ -249,7 +283,7 @@ public class SysLogProcessor {
totalProcessed.addAndGet(records.size());
log.info("Syslog批次完成: lane={}, topicPartitions={}, records={}, prepared={}, persisted={}, "
+ "success={}, filtered={}, retry={}, dlt={}, normalizedRows={}, dmSuccessfulRows={}, "
+ "dmFailedMessages={}, ingressGroups={}, prepareMs={}, influxMs={}, normalizeMs={}, "
+ "dmFailedMessages={}, ingressGroups={}, rawStorageBypassed={}, prepareMs={}, influxMs={}, normalizeMs={}, "
+ "ruleComputeMs={}, ruleSnapshotMs={}, messageParseMs={}, fieldMappingMs={}, "
+ "filterCompleteMs={}, enrichmentMs={}, ruleAssembleMs={}, compiledRuleHit={}, "
+ "compiledRuleMiss={}, messageParseHit={}, messageParseMiss={}, deviceUnique={}, "
@@ -259,6 +293,7 @@ public class SysLogProcessor {
lane, topicPartitions(records), records.size(), preparedMessages.size(), persistedMessages.size(),
metrics.success.get(), metrics.filtered.get(), metrics.retry.get(), metrics.dlt.get(),
metrics.normalizedRows.get(), metrics.dmSuccessfulRows.get(), metrics.dmFailedMessages.get(), ingressGroups,
metrics.rawStorageBypassed.get(),
prepareMs, influxMs, normalizeMs, metrics.ruleComputeMs(), metrics.ruleSnapshotMs(),
metrics.messageParseMs(), metrics.fieldMappingMs(), metrics.filterCompleteMs(),
metrics.enrichmentMs(), metrics.ruleAssembleMs(), metrics.compiledRuleHits(),
@@ -271,6 +306,73 @@ public class SysLogProcessor {
System.currentTimeMillis() - batchStartedAt);
}
private void routeContinuityBatch(List<ConsumerRecord<String, String>> records,
RuntimeException failure,
String stage) {
int published = 0;
for (ConsumerRecord<String, String> record : records) {
RuntimeException dltFailure = tryRouteContinuityRecord(record, null, failure, stage);
if (dltFailure != null) {
recordContinuityLoss(records.size(), published, record, failure, dltFailure, stage);
return;
}
published++;
}
rawInfluxMetrics.continuityDltPublished(published);
}
private void routeContinuityPrepared(List<PreparedMessage> messages,
RuntimeException failure,
String stage) {
int published = 0;
for (PreparedMessage message : messages) {
RuntimeException dltFailure = tryRouteContinuityRecord(
message.record, message.context, failure, stage);
if (dltFailure != null) {
recordContinuityLoss(messages.size(), published, message.record,
failure, dltFailure, stage);
return;
}
published++;
}
rawInfluxMetrics.continuityDltPublished(published);
}
/**
* 连续性优先的最后边界。优先把原消息写入 DLT;即使 DLT 不可用,也只记录特殊告警和计数,
* 不再把异常抛回 Kafka listener,避免一个坏批次永久阻塞整个分区。
*/
private RuntimeException tryRouteContinuityRecord(ConsumerRecord<String, String> record,
SyslogFailureRouter.MessageContext context,
RuntimeException sourceFailure,
String stage) {
try {
SyslogFailureRouter.MessageContext resolved = context == null
? failureRouter.resolve(record) : context;
failureRouter.routeContinuityDlt(record, resolved, sourceFailure, stage);
return null;
} catch (RuntimeException dltFailure) {
return dltFailure;
}
}
private void recordContinuityLoss(int total, int published,
ConsumerRecord<String, String> firstFailedRecord,
RuntimeException sourceFailure,
RuntimeException dltFailure,
String stage) {
if (published > 0) rawInfluxMetrics.continuityDltPublished(published);
int releasedWithoutDlt = total - published;
rawInfluxMetrics.continuityDltPublishFailed(releasedWithoutDlt);
// 首次失败后不再逐条等待或打印,剩余消息用批次计数记录,避免 Kafka 故障时放大阻塞和日志量。
log.error("[SYSLOG_CONTINUITY_LOSS] 特殊异常写入 DLT 失败,但按连续性策略仍将提交原 offset: "
+ "topic={}, partition={}, firstFailedOffset={}, stage={}, batchRecords={}, "
+ "dltPublished={}, releasedWithoutDlt={}, sourceErrorType={}, dltErrorType={}",
firstFailedRecord.topic(), firstFailedRecord.partition(), firstFailedRecord.offset(), stage,
total, published, releasedWithoutDlt, sourceFailure.getClass().getName(),
dltFailure.getClass().getName(), dltFailure);
}
static List<List<ConsumerRecord<String, String>>> partitionRecordsByBytes(
List<ConsumerRecord<String, String>> records, long maxBytes) {
if (maxBytes <= 0L) throw new IllegalArgumentException("maxBytes 必须大于 0");
@@ -495,7 +597,7 @@ public class SysLogProcessor {
/**
* 一次 HTTP 请求持久化当前 Kafka 批次的原文。
* 批量请求如果因单条数据格式被 InfluxDB 拒绝,降级为逐条写入以定位坏消息;
* 连接、超时或服务端故障则直接保留原 offset,不制造 DLT 风暴。
* 连接、超时或服务端故障会上抛到连续性边界,由其尝试 DLT 后继续标准化。
*/
private List<PreparedMessage> persistRawBatch(List<PreparedMessage> messages, BatchMetrics metrics) {
if (messages.isEmpty()) {
@@ -536,7 +638,7 @@ public class SysLogProcessor {
return persisted;
} catch (Exception dependencyFailure) {
if (messages.get(0).v2) rawInfluxMetrics.v2FailedBatch();
// 整批原文的持久化结果不可确认,必须让 Kafka 从原 offset 重投。
// 整批原文的持久化结果不可确认,交给外层连续性策略统一处理。
throw new IllegalStateException("InfluxDB 批量原文写入未取得成功确认,禁止提交 offset", dependencyFailure);
}
}
@@ -595,7 +697,7 @@ public class SysLogProcessor {
}
/**
* 批内有界并行标准化。任务异常必须汇总回 listener;超时或系统性失败时整批不 ACK。
* 批内有界并行标准化。任务异常汇总回 listener 外层的连续性边界。
*/
void normalizePreparedMessages(List<PreparedMessage> messages, BatchMetrics metrics) {
normalizePreparedMessages(messages, metrics, taskExecutor);
@@ -783,7 +885,7 @@ public class SysLogProcessor {
}
}
/** Retry/DLT 取得 Broker ACK 才返回;系统性故障抛出以保留原 offset。 */
/** 常规 Retry/DLT 路由;未到终态的异常交给外层连续性边界记录并放行。 */
private SyslogFailureRouter.TerminalState routeFailure(ConsumerRecord<String, String> record,
SyslogFailureRouter.MessageContext context,
Exception failure) {
@@ -905,6 +1007,7 @@ public class SysLogProcessor {
private final AtomicInteger normalizedRows = new AtomicInteger();
private final AtomicInteger dmSuccessfulRows = new AtomicInteger();
private final AtomicInteger dmFailedMessages = new AtomicInteger();
private final AtomicInteger rawStorageBypassed = new AtomicInteger();
private final AtomicLong ruleComputeMs = new AtomicLong();
private final AtomicLong ruleSnapshotMs = new AtomicLong();
private final AtomicLong dmWriteMs = new AtomicLong();
@@ -30,9 +30,8 @@ import java.util.concurrent.TimeoutException;
/**
* Kafka 异常消息可靠路由器。
*
* <p>该组件把“业务处理失败”与“原 offset 是否允许推进”放在同一个同步边界内:
* Retry/DLT 的 send Future 只有取得 Broker ACK 后才返回终态;发布失败或结果未知一律抛出,
* 让原消息保持未提交。这样不会用一次异步方法调用冒充消息已经被补偿链路接管。</p>
* <p>常规 Retry/DLT 的 send Future 只有取得 Broker ACK 后才返回终态;发布失败或结果未知会抛给
* 调用方。活动 listener 外层另有连续性优先边界,可按现场策略记录后放行。</p>
*/
@Slf4j
@Component
@@ -65,6 +64,8 @@ public class SyslogFailureRouter {
private String largeDltTopic;
@Value("${syslog.reliability.publish-timeout-ms:30000}")
private long publishTimeoutMs;
@Value("${syslog.failure-continuity.dlt-publish-timeout-ms:1000}")
private long continuityDltPublishTimeoutMs;
@Value("${syslog.reliability.consumer-version:unknown}")
private String consumerVersion;
@@ -132,7 +133,7 @@ public class SyslogFailureRouter {
public TerminalState route(ConsumerRecord<String, String> record, MessageContext context, Throwable failure) {
FailureCategory category = classify(failure);
if (category == FailureCategory.SYSTEMIC) {
// [SYSLOG-ALARM][KACK][已修复] 系统性依赖故障不倾倒 DLT,也不提交原 offset,由容器背压并等待恢复。
// 常规路由把系统性故障上抛;活动 listener 的连续性边界再决定是否转 DLT 并放行。
return TerminalState.PAUSED;
}
@@ -179,11 +180,59 @@ public class SyslogFailureRouter {
return state;
}
/**
* 连续性优先兜底:把无法安全完成当前处理阶段的原消息直接写入 DLT。
* 本方法仍显式报告发布失败;是否继续提交原 offset 由调用方的连续性策略决定。
*/
public TerminalState routeContinuityDlt(ConsumerRecord<String, String> record,
MessageContext context,
Throwable failure,
String failedStage) {
int nextAttempt = Math.max(0, context.getAttemptCount()) + 1;
boolean largeLane = largeOriginalTopic != null
&& largeOriginalTopic.equals(context.getOriginalTopic());
String targetTopic = largeLane ? largeDltTopic : dltTopic;
FailureEnvelope source = readEnvelope(record.value());
FailureEnvelope envelope = new FailureEnvelope();
envelope.setEnvelopeType(ENVELOPE_TYPE);
envelope.setEventId(context.getEventId());
envelope.setDeviceId(context.getDeviceId() != null ? context.getDeviceId()
: (source == null ? null : source.getDeviceId()));
envelope.setOriginalTopic(source == null ? record.topic() : source.getOriginalTopic());
envelope.setOriginalPartition(source == null ? record.partition() : source.getOriginalPartition());
envelope.setOriginalOffset(source == null ? record.offset() : source.getOriginalOffset());
envelope.setCurrentTopic(record.topic());
envelope.setFirstReceivedAt(context.getFirstReceivedAt());
envelope.setFailedStage(failedStage == null ? "UNKNOWN" : failedStage);
envelope.setErrorCode("SYSLOG_CONTINUITY_BYPASS");
Throwable root = rootCause(failure);
envelope.setErrorType(root.getClass().getName());
envelope.setErrorMessage(sanitize(root.getMessage()));
envelope.setAttemptCount(nextAttempt);
envelope.setNextRetryAt(null);
envelope.setRuleId(source == null ? null : source.getRuleId());
envelope.setRuleVersion(source == null ? null : source.getRuleVersion());
envelope.setConsumerVersion(consumerVersion);
envelope.setOriginalPayload(context.getPayload());
envelope.setPointTimeNs(context.getPointTimeNs());
publishAndAwait(targetTopic, context.getEventId(), envelope,
Math.max(1L, continuityDltPublishTimeoutMs));
log.debug("[SYSLOG_CONTINUITY] 特殊异常已可靠写入 DLT: eventId={}, "
+ "stage={}, target={}, errorType={}",
context.getEventId(), envelope.getFailedStage(), targetTopic, envelope.getErrorType());
return TerminalState.DLT_PUBLISHED;
}
private void publishAndAwait(String topic, String key, FailureEnvelope envelope) {
publishAndAwait(topic, key, envelope, publishTimeoutMs);
}
private void publishAndAwait(String topic, String key, FailureEnvelope envelope, long timeoutMs) {
try {
String body = objectMapper.writeValueAsString(envelope);
// [流程节点4.3] 必须等待 Broker ACK;仅调用 send() 不构成 RETRY_PUBLISHED/DLT_PUBLISHED。
kafkaTemplate.send(topic, key, body).get(publishTimeoutMs, TimeUnit.MILLISECONDS);
kafkaTemplate.send(topic, key, body).get(timeoutMs, TimeUnit.MILLISECONDS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new FailurePublishException("发布异常消息时线程被中断,原 offset 不得提交", interrupted);
@@ -18,13 +18,13 @@ import java.util.concurrent.atomic.LongAdder;
public class DmLargeTextLimiter {
private static final long MIN_ENABLED_LIMIT = 64L;
@Value("${dm.normal.large-field-max-utf8-bytes:0}")
@Value("${dm.normal.large-field-max-utf8-bytes:262144}")
private long normalMaxBytes;
@Value("${dm.normal.large-fields:http_req_body,http_resp_body,http_req_header,http_resp_header,detail,description,cmdline,shell_cmdline,parent_cmdline,mail_message}")
private String normalFieldsValue;
@Value("${dm.non-normal.large-field-max-utf8-bytes:0}")
@Value("${dm.non-normal.large-field-max-utf8-bytes:262144}")
private long nonNormalMaxBytes;
private volatile Set<String> normalFields = Collections.emptySet();
@@ -19,6 +19,10 @@ public class RawInfluxMetrics {
private final LongAdder messagesTruncated = new LongAdder();
private final LongAdder originalBytesTruncated = new LongAdder();
private final LongAdder storedBytesAfterTruncation = new LongAdder();
private final LongAdder rawWriteBypassedBatches = new LongAdder();
private final LongAdder rawWriteBypassedMessages = new LongAdder();
private final LongAdder continuityDltPublished = new LongAdder();
private final LongAdder continuityDltPublishFailed = new LongAdder();
public void legacyMissing() { legacyMissing.increment(); }
public void legacyAssigned() { legacyAssigned.increment(); }
@@ -32,6 +36,12 @@ public class RawInfluxMetrics {
originalBytesTruncated.add(originalBytes);
storedBytesAfterTruncation.add(storedBytes);
}
public void rawWriteBypassed(long messages) {
rawWriteBypassedBatches.increment();
rawWriteBypassedMessages.add(messages);
}
public void continuityDltPublished(long messages) { continuityDltPublished.add(messages); }
public void continuityDltPublishFailed(long messages) { continuityDltPublishFailed.add(messages); }
public Map<String, Object> snapshot() {
Map<String, Object> values = new LinkedHashMap<>();
@@ -47,6 +57,10 @@ public class RawInfluxMetrics {
values.put("raw_message_truncated_total", messagesTruncated.sum());
values.put("raw_message_truncated_original_bytes_total", originalBytesTruncated.sum());
values.put("raw_message_truncated_stored_bytes_total", storedBytesAfterTruncation.sum());
values.put("raw_write_bypassed_batches_total", rawWriteBypassedBatches.sum());
values.put("raw_write_bypassed_messages_total", rawWriteBypassedMessages.sum());
values.put("continuity_dlt_published_total", continuityDltPublished.sum());
values.put("continuity_dlt_publish_failed_total", continuityDltPublishFailed.sum());
return values;
}
}
@@ -20,7 +20,7 @@ public class RawStorageProperties {
@Value("${influxdb.raw.query-max-rows:10000}") private int queryMaxRows;
@Value("${influxdb.raw.query-max-response-bytes:10485760}") private long queryMaxResponseBytes;
/** 原始 message 单字段的 UTF-8 存储上限;0 表示不限制。 */
@Value("${influxdb.raw.message-max-utf8-bytes:0}") private long messageMaxUtf8Bytes;
@Value("${influxdb.raw.message-max-utf8-bytes:262144}") private long messageMaxUtf8Bytes;
/** 一次同步写请求的估算 Line Protocol 上限;按字节切批,不能只按条数。 */
@Value("${influxdb.raw.write-batch-max-bytes:8388608}") private long writeBatchMaxBytes;
@@ -35,7 +35,7 @@ influxdb.raw.legacy-point-time-fallback-enabled=${INFLUXDB_RAW_LEGACY_POINT_TIME
influxdb.raw.query-max-rows=${INFLUXDB_RAW_QUERY_MAX_ROWS:10000}
influxdb.raw.query-max-response-bytes=${INFLUXDB_RAW_QUERY_MAX_RESPONSE_BYTES:10485760}
# 0 表示不限制;启用后最终 message(含截断标记)不超过该 UTF-8 字节数。
influxdb.raw.message-max-utf8-bytes=${INFLUXDB_RAW_MESSAGE_MAX_UTF8_BYTES:1048576}
influxdb.raw.message-max-utf8-bytes=${INFLUXDB_RAW_MESSAGE_MAX_UTF8_BYTES:262144}
# Influx 同步写按 Line Protocol 估算字节切批,避免少量大日志形成超大 HTTP 请求。
influxdb.raw.write-batch-max-bytes=${INFLUXDB_RAW_WRITE_BATCH_MAX_BYTES:8388608}
# InfluxDB 2.7 连接超时配置
@@ -79,6 +79,10 @@ spring.kafka.consumer.large-topic=${KAFKA_CONSUMER_LARGE_TOPIC:${spring.kafka.co
syslog.raw-consumer.enabled=true
# Raw Topic 当前有 3 个分区,使用独立工厂显式创建 3 个 consumer;可通过环境变量灰度调整。
syslog.raw-listener.concurrency=${SYSLOG_RAW_LISTENER_CONCURRENCY:3}
# 连续性优先:特殊/系统性异常优先写 DLT;即使 DLT 失败也记录告警并推进原 offset,避免阻塞分区。
syslog.failure-continuity.enabled=${SYSLOG_FAILURE_CONTINUITY_ENABLED:true}
# DLT 故障时最多快速等待 1 秒;首次失败后整批直接记录并放行。
syslog.failure-continuity.dlt-publish-timeout-ms=${SYSLOG_FAILURE_CONTINUITY_DLT_PUBLISH_TIMEOUT_MS:1000}
# 大日志使用独立 Topic、消费组和 ConsumerFactory;建议生产环境独立 JVM 部署。
syslog.large-listener.enabled=${SYSLOG_LARGE_LISTENER_ENABLED:true}
syslog.large-listener.group-id=${SYSLOG_LARGE_LISTENER_GROUP_ID:${spring.kafka.consumer.group-id}-large}
@@ -180,7 +184,7 @@ dm.standardized-write.batch-size=${DM_STANDARDIZED_WRITE_BATCH_SIZE:500}
dm.standardized-write.writer-concurrency=2
dm.standardized-write.writer-queue-capacity=100
# 标准化大字段和非标原文均在达梦持久化边界截断;0 表示不限制。
dm.normal.large-field-max-utf8-bytes=${DM_NORMAL_LARGE_FIELD_MAX_UTF8_BYTES:65536}
dm.normal.large-field-max-utf8-bytes=${DM_NORMAL_LARGE_FIELD_MAX_UTF8_BYTES:262144}
dm.normal.large-fields=${DM_NORMAL_LARGE_FIELDS:http_req_body,http_resp_body,http_req_header,http_resp_header,detail,description,cmdline,shell_cmdline,parent_cmdline,mail_message}
dm.non-normal.large-field-max-utf8-bytes=${DM_NON_NORMAL_LARGE_FIELD_MAX_UTF8_BYTES:262144}
dm.standardized-write.transaction-timeout-seconds=45
@@ -8,12 +8,14 @@ import com.common.service.DeviceCollectTaskService;
import com.common.service.DeviceDeviceService;
import com.common.service.DmStandardizedBatchWriter;
import com.common.service.SyslogNonNormalMessageService;
import com.influx.raw.RawInfluxMetrics;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.kafka.support.Acknowledgment;
import java.time.Instant;
import java.time.LocalDateTime;
@@ -21,6 +23,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
@@ -31,13 +34,17 @@ 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.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
/** 验证恢复并行后仍保留“全部可靠终态才允许返回”的处理边界。 */
/** 验证批内并行、常规可靠路由及连续性优先的最终放行边界。 */
class SysLogProcessorParallelTest {
private ExecutorService executor;
@@ -47,6 +54,7 @@ class SysLogProcessorParallelTest {
private SyslogFailureRouter failureRouter;
private DmStandardizedBatchWriter batchWriter;
private SyslogNonNormalMessageService nonNormalMessageService;
private RawInfluxMetrics rawInfluxMetrics;
@BeforeEach
void setUp() {
@@ -57,6 +65,7 @@ class SysLogProcessorParallelTest {
failureRouter = mock(SyslogFailureRouter.class);
batchWriter = mock(DmStandardizedBatchWriter.class);
nonNormalMessageService = mock(SyslogNonNormalMessageService.class);
rawInfluxMetrics = mock(RawInfluxMetrics.class);
ReflectionTestUtils.setField(processor, "taskExecutor", executor);
ReflectionTestUtils.setField(processor, "processTimeoutMs", 5000L);
ReflectionTestUtils.setField(processor, "logNormalProcessor", normalProcessor);
@@ -66,6 +75,8 @@ class SysLogProcessorParallelTest {
ReflectionTestUtils.setField(processor, "nonNormalWriterExecutor", nonNormalExecutor);
ReflectionTestUtils.setField(processor, "nonNormalMaxConcurrencyRetries", 3);
ReflectionTestUtils.setField(processor, "nonNormalConcurrencyRetryBackoffMs", 0L);
ReflectionTestUtils.setField(processor, "failureContinuityEnabled", true);
ReflectionTestUtils.setField(processor, "rawInfluxMetrics", rawInfluxMetrics);
when(batchWriter.write(any())).thenReturn(new DmStandardizedBatchWriter.WriteReport<>());
when(normalProcessor.captureRuleSnapshot(any())).thenReturn(
new BatchRuleSnapshot(Collections.emptyMap(), System.currentTimeMillis()));
@@ -232,6 +243,61 @@ class SysLogProcessorParallelTest {
assertEquals(3, metrics.filteredCount());
}
@Test
void unexpectedBatchFailureIsRecordedAndOffsetIsAcknowledged() {
SysLogProcessor spyProcessor = spy(processor);
RuntimeException sourceFailure = new IllegalStateException("unknown processing failure");
doThrow(sourceFailure).when(spyProcessor)
.processBatch(anyList(), eq(false), eq("RAW"), any(Executor.class));
when(failureRouter.resolve(any())).thenAnswer(invocation -> {
ConsumerRecord<String, String> record = invocation.getArgument(0);
return new SyslogFailureRouter.MessageContext(record.value(), "event-" + record.offset(),
0, null, Instant.now(), record.topic(), null);
});
when(failureRouter.routeContinuityDlt(any(), any(), eq(sourceFailure), eq("BATCH_RAW")))
.thenReturn(SyslogFailureRouter.TerminalState.DLT_PUBLISHED);
Acknowledgment ack = mock(Acknowledgment.class);
spyProcessor.consumeAndParallelProcess(records(2), ack);
verify(failureRouter, times(2))
.routeContinuityDlt(any(), any(), eq(sourceFailure), eq("BATCH_RAW"));
verify(rawInfluxMetrics).continuityDltPublished(2L);
verify(ack).acknowledge();
}
@Test
void dltPublishFailureIsLoggedAndOffsetIsStillAcknowledged() {
SysLogProcessor spyProcessor = spy(processor);
RuntimeException sourceFailure = new IllegalStateException("unknown processing failure");
doThrow(sourceFailure).when(spyProcessor)
.processBatch(anyList(), eq(false), eq("RAW"), any(Executor.class));
when(failureRouter.resolve(any())).thenAnswer(invocation -> {
ConsumerRecord<String, String> record = invocation.getArgument(0);
return new SyslogFailureRouter.MessageContext(record.value(), "event-" + record.offset(),
0, null, Instant.now(), record.topic(), null);
});
when(failureRouter.routeContinuityDlt(any(), any(), eq(sourceFailure), eq("BATCH_RAW")))
.thenThrow(new SyslogFailureRouter.FailurePublishException(
"broker unavailable", new IllegalStateException("send failed")));
Acknowledgment ack = mock(Acknowledgment.class);
spyProcessor.consumeAndParallelProcess(records(2), ack);
verify(failureRouter)
.routeContinuityDlt(any(), any(), eq(sourceFailure), eq("BATCH_RAW"));
verify(rawInfluxMetrics).continuityDltPublishFailed(2L);
verify(ack).acknowledge();
}
private List<ConsumerRecord<String, String>> records(int count) {
List<ConsumerRecord<String, String>> records = new ArrayList<>();
for (int i = 0; i < count; i++) {
records.add(new ConsumerRecord<>("raw", 0, i, "key-" + i, "ciphertext-" + i));
}
return records;
}
private List<SysLogProcessor.PreparedMessage> messages(int count) {
List<SysLogProcessor.PreparedMessage> messages = new ArrayList<>();
for (int i = 0; i < count; i++) {
@@ -117,6 +117,41 @@ class SyslogFailureRouterReliabilityTest {
() -> router.route(record, router.resolve(record), new SocketException("reset")));
}
@Test
void continuityFailureIsWrittenDirectlyToDltWithOriginalPayload() throws Exception {
SettableListenableFuture<SendResult<String, String>> acknowledged = new SettableListenableFuture<>();
acknowledged.set(null);
when(template.send(eq("raw.DLT"), anyString(), anyString())).thenReturn(acknowledged);
ArgumentCaptor<String> body = ArgumentCaptor.forClass(String.class);
SyslogFailureRouter.TerminalState state = router.routeContinuityDlt(
record, router.resolve(record), new InfluxException("influx unavailable"), "RAW_STORAGE");
assertEquals(SyslogFailureRouter.TerminalState.DLT_PUBLISHED, state);
verify(template).send(eq("raw.DLT"), anyString(), body.capture());
SyslogFailureRouter.FailureEnvelope envelope = new ObjectMapper().findAndRegisterModules()
.readValue(body.getValue(), SyslogFailureRouter.FailureEnvelope.class);
assertEquals("SYSLOG_CONTINUITY_BYPASS", envelope.getErrorCode());
assertEquals("RAW_STORAGE", envelope.getFailedStage());
assertEquals("ciphertext", envelope.getOriginalPayload());
assertEquals("raw", envelope.getOriginalTopic());
assertEquals(42L, envelope.getOriginalOffset());
}
@Test
void continuityFailureFromLargeLaneUsesLargeDlt() {
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.DLT"), anyString(), anyString())).thenReturn(acknowledged);
router.routeContinuityDlt(largeRecord, router.resolve(largeRecord),
new IllegalStateException("unknown"), "BATCH_RAW_LARGE");
verify(template).send(eq("raw.large.DLT"), anyString(), anyString());
}
@Test
void influxDependencyFailurePausesWithoutDltStorm() {
SyslogFailureRouter.TerminalState state = router.route(