feat(acm2): 闭合 G-KAFKA-D3 / G-IGNORE / G-EVENT-RETENTION 三个缺口
- G-KAFKA-D3:max-in-flight 默认收敛到 1,KafkaD3Check 启动自检钉住三项联合满足 D3 - G-IGNORE:IgnoreRules 在身份绑定后精确匹配 TYPE 字段,命中写 SKIPPED + 回填意图 - G-EVENT-RETENTION:V10 迁移加 SENT_AT 列,投递原子写,EventCleanupJob 有界清理过期 SENT 行
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
package com.gzzn.omms.msgexchange.config
|
||||
|
||||
import io.micronaut.context.annotation.Context
|
||||
import io.micronaut.context.annotation.Value
|
||||
import jakarta.inject.Singleton
|
||||
|
||||
/**
|
||||
* D3 启动自检:Kafka 生产者的 acks / enable-idempotence / max-in-flight 三项
|
||||
* 必须联合满足幂等生产前提。不满足时拒绝启动——静默放行会让幂等保证在运行时失效。
|
||||
*
|
||||
* 独立于 `PipelineLifecycle`(受 autostart 开关控制),无条件执行。
|
||||
*/
|
||||
@Singleton
|
||||
@Context
|
||||
class KafkaD3Check(
|
||||
@Value("\${kafka.producers.default.acks}") acks: String,
|
||||
@Value("\${kafka.producers.default.enable-idempotence}") idempotence: String,
|
||||
@Value("\${kafka.producers.default.max-in-flight-requests-per-connection}") maxInFlight: String,
|
||||
) {
|
||||
private val acks: String = acks
|
||||
private val idempotence: Boolean = idempotence.toBoolean()
|
||||
private val maxInFlight: Int = maxInFlight.toInt()
|
||||
|
||||
fun validate() {
|
||||
require(acks == "all" || acks == "-1") {
|
||||
"D3 violation: kafka.producers.default.acks must be 'all', got '$acks'"
|
||||
}
|
||||
require(idempotence) {
|
||||
"D3 violation: kafka.producers.default.enable-idempotence must be true, got $idempotence"
|
||||
}
|
||||
require(maxInFlight == 1) {
|
||||
"D3 violation: kafka.producers.default.max-in-flight-requests-per-connection must be 1, got $maxInFlight"
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
validate()
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,13 @@ class PipelineProps {
|
||||
*/
|
||||
var autostart: Boolean = false
|
||||
|
||||
/**
|
||||
* MSG_EVENT 已确认投递行(`STATE='SENT'`)的保留期。
|
||||
* 清理作业删除 `SENT_AT < now - eventRetention` 的行;
|
||||
* 取值应大于最长可能的投递重试周期,避免误删刚发出的事件。
|
||||
*/
|
||||
var eventRetention: Duration = Duration.ofDays(7)
|
||||
|
||||
/** N28:attempt ≤ 0(如 FAILED 未递增 attempts 的行)不得抛异常,取下界=首档退避。 */
|
||||
fun backoffFor(attempt: Int): Long {
|
||||
val index = (attempt - 1).coerceAtLeast(0)
|
||||
|
||||
@@ -130,7 +130,7 @@ class Dispatcher(
|
||||
// 未知目标不能在"未发送"的情况下被标记为已发;记账后停止本轮。
|
||||
else -> error("unknown delivery target: $target")
|
||||
}
|
||||
msgEvents.markSent(eventId)
|
||||
msgEvents.markSent(eventId, scheduler.now())
|
||||
true
|
||||
} catch (ex: Exception) {
|
||||
retryOrDead(e, ex.message ?: ex.javaClass.simpleName)
|
||||
@@ -158,7 +158,7 @@ class Dispatcher(
|
||||
EventType.UPSERT -> port.sendKafkaSchd("schd", e.partitionKey, e.payloadJson)
|
||||
}
|
||||
// 条件确认:读取时刻的代次(EVENT_ID + STATE_VERSION)被新写入覆盖时不标记,留待下一轮重发。
|
||||
e.eventId?.let { msgEvents.markSentIfVersion(it, e.stateVersion) }
|
||||
e.eventId?.let { msgEvents.markSentIfVersion(it, e.stateVersion, scheduler.now()) }
|
||||
} catch (ex: Exception) {
|
||||
failures.add(e)
|
||||
}
|
||||
|
||||
@@ -29,4 +29,6 @@ data class MsgEvent(
|
||||
val errorClass: ErrorClass? = null,
|
||||
val lastError: String? = null,
|
||||
val createdAt: java.time.Instant,
|
||||
/** 投递确认时刻(`STATE='SENT'` 时写入);清理作业按此字段判断保留期。 */
|
||||
val sentAt: java.time.Instant? = null,
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
class PipelineCounters {
|
||||
private val srvtSeen = AtomicLong(0)
|
||||
private val vipfSeen = AtomicLong(0)
|
||||
private val ignored = AtomicLong(0)
|
||||
|
||||
/**
|
||||
* 入站记录里出现 `SRVT`/`VIPF` 段的条数(`[G-SRVT-VIPF]`)。
|
||||
@@ -30,4 +31,9 @@ class PipelineCounters {
|
||||
fun srvtSeenCount(): Long = srvtSeen.get()
|
||||
|
||||
fun vipfSeenCount(): Long = vipfSeen.get()
|
||||
|
||||
/** US-04 忽略清单命中计数。 */
|
||||
fun ignoredAdd(count: Int = 1) { ignored.addAndGet(count.toLong()) }
|
||||
|
||||
fun ignoredCount(): Long = ignored.get()
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import java.time.Duration
|
||||
* - `msgx.pipeline.job.last_sweep_selected`:上一轮回填扫描选中的待办条数(扫描积压)
|
||||
* - `msgx.pipeline.codec.srvt_seen.total` / `msgx.pipeline.codec.vipf_seen.total`:入站记录里出现
|
||||
* `SRVT`/`VIPF` 段的条数(尚未落明细表,`[G-SRVT-VIPF]`;> 0 表示真实流量确有该段)
|
||||
* - `msgx.pipeline.processing.ignored.total`:命中 US-04 忽略清单的报文条数
|
||||
*
|
||||
* 取数统一走 [BacklogSnapshotProvider](30 秒 TTL),因此指标抓取不会打穿数据库。
|
||||
* 无法取数时以 `NaN` 上报(Micrometer 的惯例表示"本次无值"),而不是伪造 0。
|
||||
@@ -101,6 +102,10 @@ class PipelineMetrics(
|
||||
Gauge.builder("msgx.pipeline.codec.vipf_seen.total", counters) { it.vipfSeenCount().toDouble() }
|
||||
.strongReference(true)
|
||||
.register(registry)
|
||||
|
||||
Gauge.builder("msgx.pipeline.processing.ignored.total", counters) { it.ignoredCount().toDouble() }
|
||||
.strongReference(true)
|
||||
.register(registry)
|
||||
}
|
||||
|
||||
private fun backlogGauge(name: String, value: (com.gzzn.omms.msgexchange.infra.persistence.Backlog) -> Double) {
|
||||
|
||||
@@ -170,7 +170,7 @@ interface MsgEventRepository {
|
||||
/** 领取待发的整态事件:写入侧已保证每个 `FLID` 只有一行,读端不再去重合并。 */
|
||||
fun mergePendingSchd(now: Instant, limit: Int): List<MsgEvent>
|
||||
|
||||
fun markSent(eventId: Long)
|
||||
fun markSent(eventId: Long, now: Instant)
|
||||
|
||||
/**
|
||||
* 条件确认:只在该行仍是本批读取到的代次(`EVENT_ID` + `STATE_VERSION`)且尚未发出时标记 `SENT`。
|
||||
@@ -178,13 +178,20 @@ interface MsgEventRepository {
|
||||
*
|
||||
* @return 受影响行数(0 或 1)
|
||||
*/
|
||||
fun markSentIfVersion(eventId: Long, stateVersion: Long): Int
|
||||
fun markSentIfVersion(eventId: Long, stateVersion: Long, now: Instant): Int
|
||||
|
||||
fun markAllSent(eventIds: List<Long>)
|
||||
fun markAllSent(eventIds: List<Long>, now: Instant)
|
||||
|
||||
fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int)
|
||||
|
||||
fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int? = null)
|
||||
|
||||
/**
|
||||
* 删除已确认投递且超过保留期的事件行:`STATE='SENT' AND SENT_AT < cutoff`。
|
||||
* 带 `STATE='SENT'` 条件,避免与 upsertSchd 的并发写入冲突。
|
||||
* @return 实际删除的行数
|
||||
*/
|
||||
fun deleteExpiredSent(cutoff: Instant, limit: Int): Int
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+29
-8
@@ -473,6 +473,7 @@ class JdbcMsgEventRepository(
|
||||
next_attempt_at = NULL,
|
||||
error_class = NULL,
|
||||
last_error = NULL,
|
||||
sent_at = NULL,
|
||||
created_at = EXCLUDED.created_at
|
||||
WHERE EXCLUDED.state_version > msg_event.state_version
|
||||
OR (EXCLUDED.state_version = msg_event.state_version AND EXCLUDED.event_type = 'TOMBSTONE')
|
||||
@@ -519,22 +520,26 @@ class JdbcMsgEventRepository(
|
||||
::mapEvent,
|
||||
)
|
||||
|
||||
override fun markSent(eventId: Long) {
|
||||
ds.update("UPDATE msg_event SET state = 'SENT' WHERE event_id = ?", { ps -> ps.setLong(1, eventId) })
|
||||
override fun markSent(eventId: Long, now: Instant) {
|
||||
ds.update(
|
||||
"UPDATE msg_event SET state = 'SENT', sent_at = ? WHERE event_id = ?",
|
||||
{ ps -> ps.setTimestamp(1, now.toSqlTimestamp()); ps.setLong(2, eventId) },
|
||||
)
|
||||
}
|
||||
|
||||
/** 条件确认:仅当行仍是本批读到的代次且尚未发出时才标记,被新代次覆盖则影响 0 行。 */
|
||||
override fun markSentIfVersion(eventId: Long, stateVersion: Long): Int =
|
||||
override fun markSentIfVersion(eventId: Long, stateVersion: Long, now: Instant): Int =
|
||||
ds.update(
|
||||
"UPDATE msg_event SET state = 'SENT' WHERE event_id = ? AND state_version = ? AND state = 'PENDING'",
|
||||
{ ps -> ps.setLong(1, eventId); ps.setLong(2, stateVersion) },
|
||||
"UPDATE msg_event SET state = 'SENT', sent_at = ? WHERE event_id = ? AND state_version = ? AND state = 'PENDING'",
|
||||
{ ps -> ps.setTimestamp(1, now.toSqlTimestamp()); ps.setLong(2, eventId); ps.setLong(3, stateVersion) },
|
||||
)
|
||||
|
||||
override fun markAllSent(eventIds: List<Long>) {
|
||||
override fun markAllSent(eventIds: List<Long>, now: Instant) {
|
||||
if (eventIds.isEmpty()) return
|
||||
val placeholders = eventIds.joinToString(",") { "?" }
|
||||
ds.update("UPDATE msg_event SET state = 'SENT' WHERE event_id IN ($placeholders)") { ps ->
|
||||
eventIds.forEachIndexed { i, id -> ps.setLong(i + 1, id) }
|
||||
ds.update("UPDATE msg_event SET state = 'SENT', sent_at = ? WHERE event_id IN ($placeholders)") { ps ->
|
||||
ps.setTimestamp(1, now.toSqlTimestamp())
|
||||
eventIds.forEachIndexed { i, id -> ps.setLong(i + 2, id) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,6 +562,21 @@ class JdbcMsgEventRepository(
|
||||
)
|
||||
}
|
||||
|
||||
override fun deleteExpiredSent(cutoff: Instant, limit: Int): Int =
|
||||
ds.update(
|
||||
"""
|
||||
DELETE FROM msg_event WHERE event_id IN (
|
||||
SELECT event_id FROM msg_event
|
||||
WHERE state = 'SENT' AND sent_at IS NOT NULL AND sent_at < ?
|
||||
ORDER BY event_id LIMIT ?
|
||||
)
|
||||
""".trimIndent(),
|
||||
{ ps ->
|
||||
ps.setTimestamp(1, cutoff.toSqlTimestamp())
|
||||
ps.setInt(2, limit)
|
||||
},
|
||||
)
|
||||
|
||||
private fun mapEvent(rs: ResultSet) = MsgEvent(
|
||||
eventId = rs.getLong("event_id"),
|
||||
target = rs.getString("target"),
|
||||
@@ -570,6 +590,7 @@ class JdbcMsgEventRepository(
|
||||
errorClass = rs.getString("error_class")?.let(ErrorClass::valueOf),
|
||||
lastError = rs.getString("last_error"),
|
||||
createdAt = rs.getInstant("created_at") ?: clock.instant(),
|
||||
sentAt = rs.getInstant("sent_at"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -262,7 +262,7 @@ class StubMsgEvents : MsgEventRepository {
|
||||
val id = ids.incrementAndGet()
|
||||
rows[id] = e.copy(
|
||||
eventId = id, state = EventStatus.PENDING, attempts = 0, nextAttemptAt = null,
|
||||
errorClass = null, lastError = null,
|
||||
errorClass = null, lastError = null, sentAt = null,
|
||||
)
|
||||
return id
|
||||
}
|
||||
@@ -278,19 +278,19 @@ class StubMsgEvents : MsgEventRepository {
|
||||
.sortedBy { it.eventId!! }
|
||||
.take(limit)
|
||||
|
||||
override fun markSent(eventId: Long) {
|
||||
rows[eventId] = (rows[eventId] ?: return).copy(state = EventStatus.SENT)
|
||||
override fun markSent(eventId: Long, now: Instant) {
|
||||
rows[eventId] = (rows[eventId] ?: return).copy(state = EventStatus.SENT, sentAt = now)
|
||||
}
|
||||
|
||||
override fun markSentIfVersion(eventId: Long, stateVersion: Long): Int {
|
||||
override fun markSentIfVersion(eventId: Long, stateVersion: Long, now: Instant): Int {
|
||||
val row = rows[eventId] ?: return 0
|
||||
if (row.state != EventStatus.PENDING || row.stateVersion != stateVersion) return 0
|
||||
rows[eventId] = row.copy(state = EventStatus.SENT)
|
||||
rows[eventId] = row.copy(state = EventStatus.SENT, sentAt = now)
|
||||
return 1
|
||||
}
|
||||
|
||||
override fun markAllSent(eventIds: List<Long>) {
|
||||
eventIds.forEach { markSent(it) }
|
||||
override fun markAllSent(eventIds: List<Long>, now: Instant) {
|
||||
eventIds.forEach { markSent(it, now) }
|
||||
}
|
||||
|
||||
override fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int) {
|
||||
@@ -303,6 +303,16 @@ class StubMsgEvents : MsgEventRepository {
|
||||
attempts = attempts ?: rows[eventId]?.attempts ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
override fun deleteExpiredSent(cutoff: Instant, limit: Int): Int {
|
||||
val ids = rows.values
|
||||
.filter { it.state == EventStatus.SENT && it.sentAt != null && it.sentAt < cutoff }
|
||||
.sortedBy { it.eventId!! }
|
||||
.take(limit)
|
||||
.map { it.eventId!! }
|
||||
ids.forEach { rows.remove(it) }
|
||||
return ids.size
|
||||
}
|
||||
}
|
||||
|
||||
@Singleton
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.gzzn.omms.msgexchange.jobs
|
||||
|
||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
||||
import jakarta.inject.Singleton
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* MSG_EVENT 有界清理:删除已确认投递(`STATE='SENT'`)且 `SENT_AT` 超过保留期的行。
|
||||
*
|
||||
* 每轮 JobRunner tick 跑一次,删除量有上限(`CLEANUP_BATCH`),避免单次长事务。
|
||||
* 只删 `STATE='SENT'` 的行——upsertSchd 写入的待发行状态是 `PENDING`,不会被误删。
|
||||
*/
|
||||
@Singleton
|
||||
class EventCleanupJob(
|
||||
private val msgEvents: MsgEventRepository,
|
||||
private val props: PipelineProps,
|
||||
) {
|
||||
data class CleanupOutcome(val deleted: Int)
|
||||
|
||||
fun run(now: Instant): CleanupOutcome {
|
||||
val cutoff = now.minus(props.pipeline.eventRetention)
|
||||
val deleted = msgEvents.deleteExpiredSent(cutoff, CLEANUP_BATCH)
|
||||
return CleanupOutcome(deleted)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CLEANUP_BATCH = 500
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.gzzn.omms.msgexchange.jobs
|
||||
|
||||
import com.gzzn.omms.msgexchange.config.OperationDayProps
|
||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||
import com.gzzn.omms.msgexchange.infra.metrics.JobActivity
|
||||
import com.gzzn.omms.msgexchange.processing.BackfillService
|
||||
import jakarta.inject.Singleton
|
||||
@@ -23,8 +24,10 @@ import java.time.ZoneId
|
||||
class JobRunner(
|
||||
private val backfill: BackfillService,
|
||||
private val historySweep: HistorySweepJob,
|
||||
private val eventCleanup: EventCleanupJob,
|
||||
private val clock: Clock,
|
||||
private val activity: JobActivity,
|
||||
private val props: PipelineProps,
|
||||
operationDayProps: OperationDayProps,
|
||||
) {
|
||||
private val log = org.slf4j.LoggerFactory.getLogger(JobRunner::class.java)
|
||||
@@ -43,7 +46,7 @@ class JobRunner(
|
||||
running = true
|
||||
activity.started()
|
||||
thread = Thread.ofPlatform().name("msgx-jobs").daemon(true).start { loop() }
|
||||
log.info("job runner started (backfill {}s, history daily 03:30 {})", TICK_PERIOD.seconds, zone)
|
||||
log.info("job runner started (backfill {}s, history daily 03:30 {}, event cleanup {})", TICK_PERIOD.seconds, zone, props.pipeline.eventRetention)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
@@ -75,6 +78,10 @@ class JobRunner(
|
||||
try {
|
||||
val selected = backfill.sweep(startedAt)
|
||||
maybeHistorySweep()
|
||||
val cleanupOutcome = eventCleanup.run(startedAt)
|
||||
if (cleanupOutcome.deleted > 0) {
|
||||
log.info("event cleanup: deleted {} expired SENT rows", cleanupOutcome.deleted)
|
||||
}
|
||||
activity.tickFinished(startedAt, (System.nanoTime() - startedNanos) / 1_000_000, selected)
|
||||
} catch (e: InterruptedException) {
|
||||
throw e
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.gzzn.omms.msgexchange.processing
|
||||
|
||||
/**
|
||||
* US-04 忽略清单:基线规则为 `LDM-*`、`REGN-*`、`RSTA-*`、`EROR-*`。
|
||||
*
|
||||
* 匹配按 `TYPE` 前缀做(`TYPE-*` 表示该 TYPE 下所有 STYP 均忽略);
|
||||
* `MetaFields.type` 已由 codec 统一大写,这里直接用大写常量比较。
|
||||
* 返回命中的规则标签(如 `LDM-*`),未命中返回 null。
|
||||
*/
|
||||
object IgnoreRules {
|
||||
private val rules: List<Pair<String, String>> = listOf(
|
||||
"LDM" to "LDM-*",
|
||||
"REGN" to "REGN-*",
|
||||
"RSTA" to "RSTA-*",
|
||||
"EROR" to "EROR-*",
|
||||
)
|
||||
|
||||
fun match(type: String): String? {
|
||||
for ((prefix, label) in rules) {
|
||||
if (type == prefix) return label
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,15 @@ class MessageProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
// US-04:忽略清单命中 → SKIPPED + 回填意图,不取 PIPELINE_LOCK、不写航班表、不创建 MSG_EVENT
|
||||
val ignoreRule = IgnoreRules.match(decoded.meta.type)
|
||||
if (ignoreRule != null) {
|
||||
log.info("ignored:{} -> SKIPPED msgId={}", ignoreRule, head.msgId)
|
||||
counters.ignoredAdd()
|
||||
procState.markTerminal(head.msgId, ProcStatus.SKIPPED, lastError = "ignored:$ignoreRule", now = clock.instant())
|
||||
return
|
||||
}
|
||||
|
||||
// 按报文类型分派:日计划走 SCHD,其余走 FLOP / FDEL / ADFT;报文缺载荷直接判为非法报文的死信
|
||||
val result: ApplyResult = when (val kind = decoded.kind) {
|
||||
is MsgKind.Schd -> {
|
||||
|
||||
@@ -96,11 +96,12 @@ kafka:
|
||||
servers: ${MSGX_KAFKA_SERVERS}
|
||||
producers:
|
||||
default: # U03/R09:Micronaut Kafka 按具名 producer 解析,须有 default 层
|
||||
# 默认 acks=all + 幂等(现代 Kafka 3.x+);若对接旧版 Broker(如现网 0.10.x/1.x,无 INIT_PRODUCER_ID 协议),
|
||||
# 经 MSGX_KAFKA_ACKS=1 与 MSGX_KAFKA_IDEMPOTENCE=false 降级兼容。
|
||||
# D3:acks=all + 幂等 + max-in-flight=1 联合保证幂等生产;
|
||||
# 若对接旧版 Broker(如现网 0.10.x/1.x,无 INIT_PRODUCER_ID 协议),
|
||||
# 三项须同时降级为 MSGX_KAFKA_ACKS=1 / MSGX_KAFKA_IDEMPOTENCE=false / MSGX_KAFKA_MAX_IN_FLIGHT=1。
|
||||
acks: ${MSGX_KAFKA_ACKS:all}
|
||||
enable-idempotence: ${MSGX_KAFKA_IDEMPOTENCE:true}
|
||||
max-in-flight-requests-per-connection: ${MSGX_KAFKA_MAX_IN_FLIGHT:5}
|
||||
max-in-flight-requests-per-connection: ${MSGX_KAFKA_MAX_IN_FLIGHT:1}
|
||||
eureka:
|
||||
client:
|
||||
registration:
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- =====================================================================
|
||||
-- V10:MSG_EVENT 增加 SENT_AT,为有界清理提供时间基准(G-EVENT-RETENTION)
|
||||
--
|
||||
-- 所有投递确认操作在设置 STATE='SENT' 的同一条 UPDATE 中写入 SENT_AT;
|
||||
-- 清理作业只删除 STATE='SENT' AND SENT_AT IS NOT NULL AND SENT_AT < now - retention。
|
||||
--
|
||||
-- 存量 SENT 行的 SENT_AT 设为迁移时刻 CURRENT_TIMESTAMP,
|
||||
-- 避免迁移完成后它们立即过期被清理掉。
|
||||
-- =====================================================================
|
||||
|
||||
ALTER TABLE msg_event ADD COLUMN sent_at TIMESTAMP(6) WITH TIME ZONE;
|
||||
|
||||
UPDATE msg_event SET sent_at = CURRENT_TIMESTAMP WHERE state = 'SENT';
|
||||
Reference in New Issue
Block a user