fix(delivery): upsert latest schedule event
KAFKA:schd 收敛为每 FLID 单行:V9 先按 (STATE_VERSION DESC, EVENT_ID DESC) 收敛存量再建部分唯一索引 uq_schd_event;写入走带谓词的单行 upsert(只进不退、同版本 tombstone 优先、接受新代次即换 EVENT_ID 并重置 PENDING/清旧错误);读取不再 DISTINCT ON;成功确认改按 (EVENT_ID, STATE_VERSION, PENDING) 条件更新,删除 Dispatcher superseded 清理循环。msg 路径不变。 验证:全量 ./gradlew test 129 tests / 0 fail(新增 3 个用例:单行只进不退、同版本 tombstone 不可复活、发送期间新代次保持 PENDING)。V9 的存量收敛/索引与 upsert SQL 需真实 PG,本机无 Docker 时 FlywayMigrationTest 跳过。
This commit is contained in:
@@ -31,8 +31,8 @@ interface DeliveryPort {
|
||||
/**
|
||||
* 投递调度:把 outbox(待发事件表)里的事件发给下游。
|
||||
*
|
||||
* KAFKA_MSG 一条一条按登记顺序发,不插队。KAFKA_SCHD 走 flushSchd 批量发:同一个 FLID 攒了
|
||||
* 多条未发事件时只发版本号最新的那条,旧的自然作废;删除通知发 value 为空的 tombstone。
|
||||
* KAFKA_MSG 一条一条按登记顺序发,不插队。KAFKA_SCHD 走 flushSchd:outbox 每个 FLID 只保留
|
||||
* 一行(写入侧单行 upsert),发出后按读取时刻的代次做条件确认;删除通知发 value 为空的 tombstone。
|
||||
* 两个主题之间不保证先后顺序。
|
||||
*
|
||||
* 失败处理:队首的重试时间没到就不取;一批里有发送失败,整批重试次数加一并推后退避,
|
||||
@@ -138,7 +138,7 @@ class Dispatcher(
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量发 KAFKA_SCHD:每个 FLID 只发版本号最新的那条未发事件,删除通知发空 value。 */
|
||||
/** 发 KAFKA_SCHD:每个 FLID 只有一行待发事件,删除通知发空 value,成功后按代次条件确认。 */
|
||||
internal fun flushSchd() {
|
||||
val batch = try {
|
||||
msgEvents.mergePendingSchd(scheduler.now(), props.schd.flushLimit)
|
||||
@@ -157,34 +157,12 @@ class Dispatcher(
|
||||
EventType.TOMBSTONE -> port.sendKafkaNull("schd", e.partitionKey)
|
||||
EventType.UPSERT -> port.sendKafkaSchd("schd", e.partitionKey, e.payloadJson)
|
||||
}
|
||||
// 条件确认:读取时刻的代次(EVENT_ID + STATE_VERSION)被新写入覆盖时不标记,留待下一轮重发。
|
||||
e.eventId?.let { msgEvents.markSentIfVersion(it, e.stateVersion) }
|
||||
} catch (ex: Exception) {
|
||||
failures.add(e)
|
||||
}
|
||||
}
|
||||
val sentIds = batch.mapNotNull { it.eventId }.toSet() - failures.mapNotNull { it.eventId }.toSet()
|
||||
if (sentIds.isNotEmpty()) msgEvents.markAllSent(sentIds.toList())
|
||||
val sentVersions = batch.associate { it.partitionKey to it.stateVersion }
|
||||
// 被更新版本压掉的旧事件也要标成已发,否则它们会一直留在队里:同一个 FLID 只按最新版本输出一次。
|
||||
// 这里必须**有界**:若 markAllSent 因任何原因没有生效(例如行缺 eventId),
|
||||
// 旧的无界 while(true) 会原地空转。
|
||||
runCatching {
|
||||
var rounds = 0
|
||||
while (rounds < SUPERSEDED_CLEANUP_MAX_ROUNDS) {
|
||||
rounds++
|
||||
val superseded = msgEvents.mergePendingSchd(scheduler.now(), props.schd.flushLimit)
|
||||
.filter { sentVersions[it.partitionKey]?.let { v -> it.stateVersion < v } == true }
|
||||
if (superseded.isEmpty()) break
|
||||
val ids = superseded.mapNotNull { it.eventId }
|
||||
if (ids.isEmpty()) {
|
||||
log.warn("superseded cleanup: {} rows without event_id, stop to avoid a spin", superseded.size)
|
||||
break
|
||||
}
|
||||
msgEvents.markAllSent(ids)
|
||||
if (rounds == SUPERSEDED_CLEANUP_MAX_ROUNDS) {
|
||||
log.warn("superseded cleanup hit round cap ({}); remaining rows will be handled next flush", rounds)
|
||||
}
|
||||
}
|
||||
}.onFailure { log.warn("superseded cleanup failed: {}", it.message) }
|
||||
failures.forEach { retryOrDead(it, it.lastError ?: "send-failed") }
|
||||
lastFlush = scheduler.now()
|
||||
}
|
||||
@@ -203,9 +181,4 @@ class Dispatcher(
|
||||
private fun sleepQuietly(d: Duration) {
|
||||
if (!d.isNegative && !d.isZero) Thread.sleep(d.toMillis().coerceAtLeast(1))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** superseded 清理的轮数上限:只用于防止"标不掉又不报错"时的原地空转。 */
|
||||
const val SUPERSEDED_CLEANUP_MAX_ROUNDS = 100
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user