feat(ingress): 实现 JDBC 信箱轮询、入站持久化与对拍测试 (U05)
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
package com.gzzn.omms.msgexchange.infra.health
|
||||
|
||||
import com.gzzn.omms.msgexchange.delivery.DeliveryPort
|
||||
import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
|
||||
import io.micronaut.context.BeanProvider
|
||||
import io.micronaut.core.async.publisher.Publishers
|
||||
import io.micronaut.health.HealthStatus
|
||||
import io.micronaut.management.health.indicator.HealthIndicator
|
||||
import io.micronaut.management.health.indicator.HealthResult
|
||||
import jakarta.inject.Singleton
|
||||
import org.reactivestreams.Publisher
|
||||
|
||||
/**
|
||||
* U12(R05):阶段 A 关键依赖的自定义健康指示器——
|
||||
* Redis(flightInfo 权威存储)与 Kafka(投递端口)。经 BeanProvider 可选解析:
|
||||
* 缺 bean(如未用 stub 也未实装)时指示 DOWN 而非启动失败;
|
||||
* UP 判据为真实 ping(false/异常 → DOWN),而非仅 bean 存在(复审 P1 修正)。
|
||||
*/
|
||||
@Singleton
|
||||
class FlightRedisHealthIndicator(
|
||||
private val redis: BeanProvider<FlightRedisClient>,
|
||||
) : HealthIndicator {
|
||||
|
||||
override fun getResult(): Publisher<HealthResult> =
|
||||
Publishers.just(redisHealth(if (redis.isPresent) redis.get() else null))
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class KafkaDeliveryHealthIndicator(
|
||||
private val port: BeanProvider<DeliveryPort>,
|
||||
) : HealthIndicator {
|
||||
|
||||
override fun getResult(): Publisher<HealthResult> =
|
||||
Publishers.just(kafkaHealth(if (port.isPresent) port.get() else null))
|
||||
}
|
||||
|
||||
/** ping 判定独立成纯函数便于单测:client 为 null = bean 缺失;ping false/异常 = DOWN。 */
|
||||
internal fun redisHealth(client: FlightRedisClient?): HealthResult =
|
||||
healthOf("redis-flight-store", "flight store", client?.let {
|
||||
try { it.ping() } catch (e: Exception) { false }
|
||||
})
|
||||
|
||||
internal fun kafkaHealth(port: DeliveryPort?): HealthResult =
|
||||
healthOf("kafka-delivery", "delivery port", port?.let {
|
||||
try { it.ping() } catch (e: Exception) { false }
|
||||
})
|
||||
|
||||
private fun healthOf(name: String, what: String, pingOk: Boolean?): HealthResult {
|
||||
val (status, message) = when (pingOk) {
|
||||
null -> HealthStatus.DOWN to "$what bean missing (stub off, impl pending)"
|
||||
false -> HealthStatus.DOWN to "$what ping failed"
|
||||
true -> HealthStatus.UP to "$what ping ok"
|
||||
}
|
||||
return HealthResult.builder(name).status(status).details(mapOf("message" to message)).build()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.gzzn.omms.msgexchange.infra.log
|
||||
|
||||
import org.slf4j.MDC
|
||||
|
||||
/**
|
||||
* U12(R05):处理路径入口写入 MDC traceId(=cminmsgsId/eventId),
|
||||
* 使一条消息全链路日志可串(logback %X{traceId} + logstash includeMdcKeyName)。
|
||||
*/
|
||||
object TraceLog {
|
||||
fun <T> withTrace(id: Any, body: () -> T): T {
|
||||
MDC.put("traceId", id.toString())
|
||||
return try {
|
||||
body()
|
||||
} finally {
|
||||
MDC.remove("traceId")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.gzzn.omms.msgexchange.infra.persistence
|
||||
|
||||
import com.gzzn.omms.msgexchange.domain.ErrorClass
|
||||
import com.gzzn.omms.msgexchange.domain.EventStatus
|
||||
import com.gzzn.omms.msgexchange.domain.MsgEvent
|
||||
import com.gzzn.omms.msgexchange.domain.ProcState
|
||||
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||
import com.gzzn.omms.msgexchange.domain.RefUpsert
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* ACM2-12 仓储接口(接口驱动,主泵/调度循环可单测;Micronaut Data JDBC 实装属 U05 批次)。
|
||||
* 存储边界:自有 PostgreSQL(datasources.default)= 本文件除 CminmsgInboxRepository 外
|
||||
* 的全部接口(消息管道 PROC_STATE/MSG_EVENT、PUMP_JOB、REQ_TRACK、21 类 REF_MASTER);
|
||||
* 共享 MySQL 信箱(CMINMSGS / COUTMSGS)经信箱封装访问,仅 DML、不建表;
|
||||
* 主路径=上游外部写 CMINMSGS → 本系统 JDBC 轮询读;compat=insertRaw HTTP 写;
|
||||
* Redis = 航班动态 + 快照 gen(RefDataRepository 目标实现);FLIGHT_STATE 缓做。
|
||||
*/
|
||||
interface ProcStateRepository {
|
||||
fun insert(cminmsgsId: Long, state: ProcStatus = ProcStatus.PENDING)
|
||||
|
||||
/** 主路径轮询/compat 入队前判重(PG 已有行则跳过)。 */
|
||||
fun exists(cminmsgsId: Long): Boolean
|
||||
|
||||
/** I1:严格 FIFO 队头(最小未完成 CMINMSGS_ID)。 */
|
||||
fun headUnfinished(): ProcState?
|
||||
|
||||
/** I3:identity 首次绑定;返回 false = 另一条消息已持有该键。 */
|
||||
fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean
|
||||
|
||||
fun ownerOfIdentity(identityKey: String): Long?
|
||||
|
||||
fun update(
|
||||
cminmsgsId: Long,
|
||||
state: ProcStatus,
|
||||
nextAttemptAt: Instant? = null,
|
||||
attempts: Int? = null,
|
||||
errorClass: ErrorClass? = null,
|
||||
lastError: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* U11 显式重放入口(ReplayService):仅把给定 errorClass 集合中的行从 FAILED/DEAD 置回 PENDING,
|
||||
* 以便主泵重新领取。实现约定:ATTEMPTS=0、NEXT_ATTEMPT_AT=NULL(立即重试),
|
||||
* ERROR_CLASS/LAST_ERROR 保留作审计。返回受影响行数。
|
||||
*/
|
||||
fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int
|
||||
}
|
||||
|
||||
interface MsgEventRepository {
|
||||
fun insertAll(events: List<MsgEvent>): List<Long>
|
||||
|
||||
/** I1 双层同策略:每 target 严格 FIFO 队头。 */
|
||||
fun headUnsent(target: String): MsgEvent?
|
||||
|
||||
fun claimBatch(target: String, limit: Int): List<MsgEvent> // ORDER BY EVENT_ID ASC
|
||||
|
||||
fun markSent(eventId: Long)
|
||||
|
||||
fun markAllSent(eventIds: List<Long>)
|
||||
|
||||
fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int)
|
||||
|
||||
fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int? = null)
|
||||
|
||||
/** 阶段 B(定案 2):Delivery 同线程在 ES 投递成功后同步 enqueue 删除事件。 */
|
||||
fun insertSync(events: List<MsgEvent>)
|
||||
}
|
||||
|
||||
/**
|
||||
* 快照 generation(SCHD_GEN)协议——只留 gen。
|
||||
* ACM2-12:gen 迁 Redis(与 flightInfo 同源,Lua 内原子「覆盖+按代差删+版本推进」,
|
||||
* DB 仅写 SUCCEEDED;重放幂等由 Lua 承接,协议重设计属 U09)。本接口为过渡占位,
|
||||
* 目标实现为 Redis gen store(script 化),非关系表。
|
||||
*/
|
||||
interface RefDataRepository {
|
||||
data class GenMeta(val flids: List<String>, val version: Long)
|
||||
|
||||
fun getGen(day: String): GenMeta?
|
||||
|
||||
/** 流程 4:版本 CAS(expected 未变才写,重放 no-op,不二次自增)。 */
|
||||
fun putGenIfVersion(day: String, expected: Long, new: GenMeta): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 21 类静态主数据(航空公司/航线/机位/登机桥等)——自有 PostgreSQL `REF_MASTER` 表
|
||||
* (ACM2-12:与消息管道同自有库;SOURCE=ADMINAPI/AODB/PIPELINE,N19 对齐)。
|
||||
* 与主链弱事务耦合:写入者为 ReferenceService(21 类同步)与请求应答路径;
|
||||
* Redis 只作只读热点投影(legacy orms_stand 语义延续,阶段 6)。
|
||||
*/
|
||||
interface StaticRefRepository {
|
||||
fun upsertAll(refs: List<RefUpsert>)
|
||||
|
||||
fun findByType(type: String): List<RefUpsert>
|
||||
}
|
||||
|
||||
/**
|
||||
* 15 类请求状态机——自有 PG `REQ_TRACK`(ACM2-12;Reference & Query 域)。
|
||||
* 与共享库 COUTMSGS(出站信箱)跨库:先 COUTMSGS 落库成功 → 再 markSent,补偿重扫,最终一致。
|
||||
*/
|
||||
interface ReqTrackRepository {
|
||||
data class Req(
|
||||
val reqId: Long,
|
||||
val reqType: String,
|
||||
val state: String, // REGISTERED/SENT/WAITING/DONE/EXPIRED
|
||||
val sentAt: Instant? = null,
|
||||
)
|
||||
|
||||
fun findOpenByKind(kind: String): Req?
|
||||
|
||||
fun forceExpireOpenOf(kind: String)
|
||||
|
||||
fun insert(kind: String, paramsJson: String): Long
|
||||
|
||||
fun linkCoutmsgs(reqId: Long, coutmsgsId: Long)
|
||||
|
||||
fun markSent(reqId: Long, sentAt: Instant)
|
||||
|
||||
fun expireIfWaiting(reqId: Long)
|
||||
|
||||
fun markDone(reqId: Long)
|
||||
}
|
||||
|
||||
/**
|
||||
* 泵作业调度记录——自有 PG `PUMP_JOB`(ACM2-12)。
|
||||
* 决策 1 修订:作业不插队,仅在消息队头空闲/退避窗口由主泵执行(跨库/异队列无全序);
|
||||
* 作业动作本身(归档写共享库 CMINMSGS_HST、清场删 Redis/ES 等)仍在各自目标存储。
|
||||
*/
|
||||
interface PumpJobRepository {
|
||||
data class Job(val jobId: Long, val kind: String) // ARCHIVE/HISTORY_SWEEP/PROJECTION_REBUILD
|
||||
|
||||
fun enqueue(kind: String)
|
||||
|
||||
fun headQueued(): Job?
|
||||
|
||||
fun markRunning(jobId: Long)
|
||||
|
||||
fun markDone(jobId: Long)
|
||||
|
||||
fun markFailed(jobId: Long, lastError: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* 阶段 B 权威(ACM2-12:缓做,不落表——航班动态权威保持 Redis;阶段 B 重新
|
||||
* 评估后再定是否引入事务化权威)。本接口仅供占位与测试,勿据此建表。
|
||||
*/
|
||||
interface FlightStateRepository {
|
||||
/** 阶段 B 权威;replaceDay = 单事务删差集+写新代+版本提升。 */
|
||||
fun replaceDay(day: String, flights: List<Pair<String, String>>)
|
||||
|
||||
fun findByDay(day: String): List<Pair<String, String>>
|
||||
}
|
||||
|
||||
/**
|
||||
* 共享信箱 CMINMSGS 访问(ACM2-12:库属他人系统,本系统不建表)。
|
||||
*
|
||||
* **主路径(生产)**:`pollNew` / `rawOf` — JDBC 轮询/读取上游外部写入的报文(U05 InboxPoller)。
|
||||
* **compat 路径**:`insertRaw` — HTTP `POST /cminmsgs/send` 辅助写(手工/对拍,非主拓扑)。
|
||||
*
|
||||
* 入队模型:发现或 compat 写得到 CMINMSGS_ID → 自有 PG 建 PROC_STATE(PENDING);
|
||||
* PG 建行失败以共享库 DATE_PROCESSED IS NULL 重扫补建。
|
||||
* `backfillOnSuccess` = 处理成功后的外部回填(DATE_PROCESSED/STATUS,最终一致)。
|
||||
*/
|
||||
interface CminmsgInboxRepository {
|
||||
/** compat HTTP 写路径:向共享信箱插入原文(U16 契约对拍)。 */
|
||||
fun insertRaw(rawXml: String): Long
|
||||
|
||||
/** 主路径/处理:按 CMINMSGS_ID 读取上游已落信的原文 XML。 */
|
||||
fun rawOf(cminmsgsId: Long): String?
|
||||
|
||||
/**
|
||||
* 主路径 JDBC 轮询:共享库未处理报文 ID 列表(legacy `getNewMsgsAfterId` 同语义)。
|
||||
* @param afterId 下界(legacy 现役传 0);仅返回 `DATE_PROCESSED IS NULL` 行。
|
||||
*/
|
||||
fun pollUnprocessed(afterId: Long, limit: Int): List<Long>
|
||||
|
||||
fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long)
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
|
||||
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
||||
import io.micronaut.context.annotation.Requires
|
||||
import jakarta.inject.Named
|
||||
import jakarta.inject.Singleton
|
||||
import javax.sql.DataSource
|
||||
|
||||
/**
|
||||
* 共享 MySQL CMINMSGS 信箱适配(ACM2-12:仅 DML,不建表)。
|
||||
* 列名与 legacy `entity/Cminmsg.java` 一致。
|
||||
*/
|
||||
@Singleton
|
||||
@Requires(property = "msgx.stubs", notEquals = "true")
|
||||
@Requires(property = "mailbox.shared-mysql.enabled", value = "true")
|
||||
class JdbcCminmsgInboxRepository(
|
||||
@Named("mailbox") private val ds: DataSource,
|
||||
) : CminmsgInboxRepository {
|
||||
|
||||
override fun insertRaw(rawXml: String): Long =
|
||||
ds.updateReturningLong(
|
||||
"""
|
||||
INSERT INTO cminmsgs (CMINMSGS_CLOB_MSG, CMINMSGS_DATE_RECEIVED, CMINMSGS_DATE_PROCESSED)
|
||||
VALUES (?, CURRENT_TIMESTAMP, NULL)
|
||||
""".trimIndent(),
|
||||
) { ps ->
|
||||
ps.setString(1, rawXml)
|
||||
}
|
||||
|
||||
override fun rawOf(cminmsgsId: Long): String? =
|
||||
ds.queryOne(
|
||||
"SELECT CMINMSGS_CLOB_MSG FROM cminmsgs WHERE CMINMSGS_ID = ?",
|
||||
{ ps -> ps.setLong(1, cminmsgsId) },
|
||||
) { rs -> rs.getString("CMINMSGS_CLOB_MSG") }
|
||||
|
||||
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> =
|
||||
ds.query(
|
||||
"""
|
||||
SELECT CMINMSGS_ID FROM cminmsgs
|
||||
WHERE CMINMSGS_ID > ? AND CMINMSGS_DATE_PROCESSED IS NULL
|
||||
ORDER BY CMINMSGS_ID ASC
|
||||
LIMIT ?
|
||||
""".trimIndent(),
|
||||
{ ps ->
|
||||
ps.setLong(1, afterId)
|
||||
ps.setInt(2, limit)
|
||||
},
|
||||
) { rs -> rs.getLong("CMINMSGS_ID") }
|
||||
|
||||
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
|
||||
ds.update(
|
||||
"""
|
||||
UPDATE cminmsgs
|
||||
SET CMINMSGS_DATE_PROCESSED = CURRENT_TIMESTAMP,
|
||||
CMINMSGS_STATUS = ?
|
||||
WHERE CMINMSGS_ID = ?
|
||||
""".trimIndent(),
|
||||
) { ps ->
|
||||
ps.setString(1, "PROCESSED")
|
||||
ps.setLong(2, cminmsgsId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
|
||||
|
||||
import java.sql.ResultSet
|
||||
import java.sql.Timestamp
|
||||
import java.time.Instant
|
||||
import javax.sql.DataSource
|
||||
|
||||
internal fun Instant.toSqlTimestamp(): Timestamp = Timestamp.from(this)
|
||||
|
||||
internal fun ResultSet.getInstant(column: String): Instant? =
|
||||
getTimestamp(column)?.toInstant()
|
||||
|
||||
internal fun <T> DataSource.query(sql: String, bind: (java.sql.PreparedStatement) -> Unit, map: (ResultSet) -> T): List<T> =
|
||||
connection.use { conn ->
|
||||
conn.prepareStatement(sql).use { ps ->
|
||||
bind(ps)
|
||||
ps.executeQuery().use { rs ->
|
||||
buildList {
|
||||
while (rs.next()) add(map(rs))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun <T> DataSource.queryOne(sql: String, bind: (java.sql.PreparedStatement) -> Unit, map: (ResultSet) -> T): T? =
|
||||
query(sql, bind, map).firstOrNull()
|
||||
|
||||
internal fun DataSource.update(sql: String, bind: (java.sql.PreparedStatement) -> Unit): Int =
|
||||
connection.use { conn ->
|
||||
conn.prepareStatement(sql).use { ps ->
|
||||
bind(ps)
|
||||
ps.executeUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun DataSource.updateReturningLong(sql: String, bind: (java.sql.PreparedStatement) -> Unit): Long =
|
||||
connection.use { conn ->
|
||||
conn.prepareStatement(sql, java.sql.Statement.RETURN_GENERATED_KEYS).use { ps ->
|
||||
bind(ps)
|
||||
ps.executeUpdate()
|
||||
ps.generatedKeys.use { keys ->
|
||||
check(keys.next()) { "no generated key" }
|
||||
keys.getLong(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
|
||||
|
||||
import com.gzzn.omms.msgexchange.domain.ErrorClass
|
||||
import com.gzzn.omms.msgexchange.domain.EventStatus
|
||||
import com.gzzn.omms.msgexchange.domain.MsgEvent
|
||||
import com.gzzn.omms.msgexchange.domain.ProcState
|
||||
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||
import com.gzzn.omms.msgexchange.domain.RefUpsert
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PumpJobRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.RefDataRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.StaticRefRepository
|
||||
import io.micronaut.context.annotation.Requires
|
||||
import jakarta.inject.Singleton
|
||||
import java.time.Instant
|
||||
import javax.sql.DataSource
|
||||
|
||||
@Singleton
|
||||
@Requires(property = "msgx.stubs", notEquals = "true")
|
||||
@Requires(property = "datasources.default.enabled", value = "true")
|
||||
class JdbcProcStateRepository(
|
||||
private val ds: DataSource,
|
||||
) : ProcStateRepository {
|
||||
|
||||
override fun insert(cminmsgsId: Long, state: ProcStatus) {
|
||||
val now = Instant.now()
|
||||
ds.update(
|
||||
"""
|
||||
INSERT INTO proc_state (cminmsgs_id, state, attempts, updated_at)
|
||||
VALUES (?, ?, 0, ?)
|
||||
ON CONFLICT (cminmsgs_id) DO NOTHING
|
||||
""".trimIndent(),
|
||||
) { ps ->
|
||||
ps.setLong(1, cminmsgsId)
|
||||
ps.setString(2, state.name)
|
||||
ps.setTimestamp(3, now.toSqlTimestamp())
|
||||
}
|
||||
}
|
||||
|
||||
override fun exists(cminmsgsId: Long): Boolean =
|
||||
ds.queryOne(
|
||||
"SELECT 1 FROM proc_state WHERE cminmsgs_id = ?",
|
||||
{ ps -> ps.setLong(1, cminmsgsId) },
|
||||
) { 1 } != null
|
||||
|
||||
override fun headUnfinished(): ProcState? =
|
||||
ds.queryOne(
|
||||
"""
|
||||
SELECT cminmsgs_id, state, identity_key, attempts, next_attempt_at, error_class, last_error, updated_at
|
||||
FROM proc_state
|
||||
WHERE state IN ('PENDING', 'FAILED')
|
||||
ORDER BY cminmsgs_id ASC
|
||||
LIMIT 1
|
||||
""".trimIndent(),
|
||||
{},
|
||||
::mapProcState,
|
||||
)
|
||||
|
||||
override fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean {
|
||||
ownerOfIdentity(identityKey)?.let { owner ->
|
||||
if (owner != cminmsgsId) return false
|
||||
return true
|
||||
}
|
||||
val updated = ds.update(
|
||||
"""
|
||||
UPDATE proc_state SET identity_key = ?, updated_at = ?
|
||||
WHERE cminmsgs_id = ? AND identity_key IS NULL
|
||||
""".trimIndent(),
|
||||
) { ps ->
|
||||
ps.setString(1, identityKey)
|
||||
ps.setTimestamp(2, Instant.now().toSqlTimestamp())
|
||||
ps.setLong(3, cminmsgsId)
|
||||
}
|
||||
return updated == 1
|
||||
}
|
||||
|
||||
override fun ownerOfIdentity(identityKey: String): Long? =
|
||||
ds.queryOne(
|
||||
"SELECT cminmsgs_id FROM proc_state WHERE identity_key = ?",
|
||||
{ ps -> ps.setString(1, identityKey) },
|
||||
) { rs -> rs.getLong("cminmsgs_id") }
|
||||
|
||||
override fun update(
|
||||
cminmsgsId: Long,
|
||||
state: ProcStatus,
|
||||
nextAttemptAt: Instant?,
|
||||
attempts: Int?,
|
||||
errorClass: ErrorClass?,
|
||||
lastError: String?,
|
||||
) {
|
||||
ds.update(
|
||||
"""
|
||||
UPDATE proc_state SET
|
||||
state = ?,
|
||||
next_attempt_at = ?,
|
||||
attempts = COALESCE(?, attempts),
|
||||
error_class = COALESCE(?, error_class),
|
||||
last_error = COALESCE(?, last_error),
|
||||
updated_at = ?
|
||||
WHERE cminmsgs_id = ?
|
||||
""".trimIndent(),
|
||||
) { ps ->
|
||||
ps.setString(1, state.name)
|
||||
ps.setTimestamp(2, nextAttemptAt?.toSqlTimestamp())
|
||||
if (attempts != null) ps.setInt(3, attempts) else ps.setNull(3, java.sql.Types.INTEGER)
|
||||
ps.setString(4, errorClass?.name)
|
||||
ps.setString(5, lastError)
|
||||
ps.setTimestamp(6, Instant.now().toSqlTimestamp())
|
||||
ps.setLong(7, cminmsgsId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int {
|
||||
if (errorClasses.isEmpty()) return 0
|
||||
val placeholders = errorClasses.joinToString(",") { "?" }
|
||||
return ds.update(
|
||||
"""
|
||||
UPDATE proc_state SET state = 'PENDING', attempts = 0, next_attempt_at = NULL, updated_at = ?
|
||||
WHERE state IN ('FAILED', 'DEAD') AND error_class IN ($placeholders)
|
||||
""".trimIndent(),
|
||||
) { ps ->
|
||||
ps.setTimestamp(1, Instant.now().toSqlTimestamp())
|
||||
errorClasses.forEachIndexed { i, ec -> ps.setString(i + 2, ec.name) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapProcState(rs: java.sql.ResultSet) = ProcState(
|
||||
cminmsgsId = rs.getLong("cminmsgs_id"),
|
||||
state = ProcStatus.valueOf(rs.getString("state")),
|
||||
identityKey = rs.getString("identity_key"),
|
||||
attempts = rs.getInt("attempts"),
|
||||
nextAttemptAt = rs.getInstant("next_attempt_at"),
|
||||
errorClass = rs.getString("error_class")?.let(ErrorClass::valueOf),
|
||||
lastError = rs.getString("last_error"),
|
||||
updatedAt = rs.getInstant("updated_at") ?: Instant.now(),
|
||||
)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@Requires(property = "msgx.stubs", notEquals = "true")
|
||||
@Requires(property = "datasources.default.enabled", value = "true")
|
||||
class JdbcMsgEventRepository(
|
||||
private val ds: DataSource,
|
||||
) : MsgEventRepository {
|
||||
|
||||
override fun insertAll(events: List<MsgEvent>): List<Long> =
|
||||
events.map { e ->
|
||||
ds.updateReturningLong(
|
||||
"""
|
||||
INSERT INTO msg_event (target, partition_key, payload_json, state, attempts, next_attempt_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
) { ps ->
|
||||
ps.setString(1, e.target)
|
||||
ps.setString(2, e.partitionKey)
|
||||
ps.setString(3, e.payloadJson)
|
||||
ps.setString(4, e.state.name)
|
||||
ps.setInt(5, e.attempts)
|
||||
ps.setTimestamp(6, e.nextAttemptAt?.toSqlTimestamp())
|
||||
ps.setTimestamp(7, e.createdAt.toSqlTimestamp())
|
||||
}
|
||||
}
|
||||
|
||||
override fun headUnsent(target: String): MsgEvent? =
|
||||
ds.queryOne(
|
||||
"""
|
||||
SELECT event_id, target, partition_key, payload_json, state, attempts, next_attempt_at, error_class, last_error, created_at
|
||||
FROM msg_event
|
||||
WHERE target = ? AND state = 'PENDING'
|
||||
ORDER BY event_id ASC
|
||||
LIMIT 1
|
||||
""".trimIndent(),
|
||||
{ ps -> ps.setString(1, target) },
|
||||
::mapEvent,
|
||||
)
|
||||
|
||||
override fun claimBatch(target: String, limit: Int): List<MsgEvent> =
|
||||
ds.query(
|
||||
"""
|
||||
SELECT event_id, target, partition_key, payload_json, state, attempts, next_attempt_at, error_class, last_error, created_at
|
||||
FROM msg_event
|
||||
WHERE target = ? AND state = 'PENDING'
|
||||
ORDER BY event_id ASC
|
||||
LIMIT ?
|
||||
""".trimIndent(),
|
||||
{ ps ->
|
||||
ps.setString(1, target)
|
||||
ps.setInt(2, limit)
|
||||
},
|
||||
::mapEvent,
|
||||
)
|
||||
|
||||
override fun markSent(eventId: Long) {
|
||||
ds.update("UPDATE msg_event SET state = 'SENT' WHERE event_id = ?") { ps -> ps.setLong(1, eventId) }
|
||||
}
|
||||
|
||||
override fun markAllSent(eventIds: List<Long>) {
|
||||
eventIds.forEach(::markSent)
|
||||
}
|
||||
|
||||
override fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int) {
|
||||
ds.update(
|
||||
"UPDATE msg_event SET state = 'PENDING', next_attempt_at = ?, attempts = ? WHERE event_id = ?",
|
||||
) { ps ->
|
||||
ps.setTimestamp(1, nextAttemptAt.toSqlTimestamp())
|
||||
ps.setInt(2, attempts)
|
||||
ps.setLong(3, eventId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int?) {
|
||||
ds.update(
|
||||
"""
|
||||
UPDATE msg_event SET state = 'DEAD', error_class = ?, last_error = ?,
|
||||
attempts = COALESCE(?, attempts)
|
||||
WHERE event_id = ?
|
||||
""".trimIndent(),
|
||||
) { ps ->
|
||||
ps.setString(1, errorClass.name)
|
||||
ps.setString(2, lastError)
|
||||
if (attempts != null) ps.setInt(3, attempts) else ps.setNull(3, java.sql.Types.INTEGER)
|
||||
ps.setLong(4, eventId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun insertSync(events: List<MsgEvent>) {
|
||||
insertAll(events)
|
||||
}
|
||||
|
||||
private fun mapEvent(rs: java.sql.ResultSet) = MsgEvent(
|
||||
eventId = rs.getLong("event_id"),
|
||||
target = rs.getString("target"),
|
||||
partitionKey = rs.getString("partition_key"),
|
||||
payloadJson = rs.getString("payload_json"),
|
||||
state = EventStatus.valueOf(rs.getString("state")),
|
||||
attempts = rs.getInt("attempts"),
|
||||
nextAttemptAt = rs.getInstant("next_attempt_at"),
|
||||
errorClass = rs.getString("error_class")?.let(ErrorClass::valueOf),
|
||||
lastError = rs.getString("last_error"),
|
||||
createdAt = rs.getInstant("created_at") ?: Instant.now(),
|
||||
)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@Requires(property = "msgx.stubs", notEquals = "true")
|
||||
@Requires(property = "datasources.default.enabled", value = "true")
|
||||
class JdbcPumpJobRepository(
|
||||
private val ds: DataSource,
|
||||
) : PumpJobRepository {
|
||||
|
||||
override fun enqueue(kind: String) {
|
||||
val now = Instant.now()
|
||||
ds.update(
|
||||
"INSERT INTO pump_job (kind, state, created_at, updated_at) VALUES (?, 'QUEUED', ?, ?)",
|
||||
) { ps ->
|
||||
ps.setString(1, kind)
|
||||
ps.setTimestamp(2, now.toSqlTimestamp())
|
||||
ps.setTimestamp(3, now.toSqlTimestamp())
|
||||
}
|
||||
}
|
||||
|
||||
override fun headQueued(): PumpJobRepository.Job? =
|
||||
ds.queryOne(
|
||||
"SELECT job_id, kind FROM pump_job WHERE state = 'QUEUED' ORDER BY job_id ASC LIMIT 1",
|
||||
{},
|
||||
) { rs -> PumpJobRepository.Job(rs.getLong("job_id"), rs.getString("kind")) }
|
||||
|
||||
override fun markRunning(jobId: Long) = markState(jobId, "RUNNING", null)
|
||||
|
||||
override fun markDone(jobId: Long) = markState(jobId, "DONE", null)
|
||||
|
||||
override fun markFailed(jobId: Long, lastError: String) = markState(jobId, "FAILED", lastError)
|
||||
|
||||
private fun markState(jobId: Long, state: String, lastError: String?) {
|
||||
ds.update(
|
||||
"UPDATE pump_job SET state = ?, updated_at = ?, last_error = ? WHERE job_id = ?",
|
||||
) { ps ->
|
||||
ps.setString(1, state)
|
||||
ps.setTimestamp(2, Instant.now().toSqlTimestamp())
|
||||
ps.setString(3, lastError)
|
||||
ps.setLong(4, jobId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** gen→Redis 过渡占位:U09 前进程内 CAS(与 StubRefData 同语义)。 */
|
||||
@Singleton
|
||||
@Requires(property = "msgx.stubs", notEquals = "true")
|
||||
@Requires(property = "datasources.default.enabled", value = "true")
|
||||
class JdbcRefDataRepository : RefDataRepository {
|
||||
private val gens = mutableMapOf<String, RefDataRepository.GenMeta>()
|
||||
|
||||
override fun getGen(day: String): RefDataRepository.GenMeta? = gens[day]
|
||||
|
||||
override fun putGenIfVersion(day: String, expected: Long, new: RefDataRepository.GenMeta): Boolean {
|
||||
val cur = gens[day]?.version ?: 0L
|
||||
if (cur != expected) return false
|
||||
gens[day] = new
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@Requires(property = "msgx.stubs", notEquals = "true")
|
||||
@Requires(property = "datasources.default.enabled", value = "true")
|
||||
class JdbcStaticRefRepository(
|
||||
private val ds: DataSource,
|
||||
) : StaticRefRepository {
|
||||
|
||||
override fun upsertAll(refs: List<RefUpsert>) {
|
||||
val now = Instant.now()
|
||||
refs.forEach { r ->
|
||||
ds.update(
|
||||
"""
|
||||
INSERT INTO ref_master (rtype, rkey, payload_json, source, refreshed_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (rtype, rkey) DO UPDATE SET
|
||||
payload_json = EXCLUDED.payload_json,
|
||||
source = EXCLUDED.source,
|
||||
refreshed_at = EXCLUDED.refreshed_at
|
||||
""".trimIndent(),
|
||||
) { ps ->
|
||||
ps.setString(1, r.rtype)
|
||||
ps.setString(2, r.rkey)
|
||||
ps.setString(3, r.payloadJson)
|
||||
ps.setString(4, r.source)
|
||||
ps.setTimestamp(5, now.toSqlTimestamp())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun findByType(type: String): List<RefUpsert> =
|
||||
ds.query(
|
||||
"SELECT rtype, rkey, payload_json, source FROM ref_master WHERE rtype = ?",
|
||||
{ ps -> ps.setString(1, type) },
|
||||
) { rs ->
|
||||
RefUpsert(
|
||||
rtype = rs.getString("rtype"),
|
||||
rkey = rs.getString("rkey"),
|
||||
payloadJson = rs.getString("payload_json"),
|
||||
source = rs.getString("source"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@Requires(property = "msgx.stubs", notEquals = "true")
|
||||
@Requires(property = "datasources.default.enabled", value = "true")
|
||||
class JdbcReqTrackRepository(
|
||||
private val ds: DataSource,
|
||||
) : ReqTrackRepository {
|
||||
|
||||
override fun findOpenByKind(kind: String): ReqTrackRepository.Req? =
|
||||
ds.queryOne(
|
||||
"""
|
||||
SELECT req_id, req_type, state, sent_at FROM req_track
|
||||
WHERE req_type = ? AND state IN ('REGISTERED', 'SENT', 'WAITING')
|
||||
ORDER BY req_id ASC LIMIT 1
|
||||
""".trimIndent(),
|
||||
{ ps -> ps.setString(1, kind) },
|
||||
) { rs ->
|
||||
ReqTrackRepository.Req(
|
||||
reqId = rs.getLong("req_id"),
|
||||
reqType = rs.getString("req_type"),
|
||||
state = rs.getString("state"),
|
||||
sentAt = rs.getInstant("sent_at"),
|
||||
)
|
||||
}
|
||||
|
||||
override fun forceExpireOpenOf(kind: String) {
|
||||
ds.update(
|
||||
"""
|
||||
UPDATE req_track SET state = 'EXPIRED'
|
||||
WHERE req_type = ? AND state IN ('REGISTERED', 'SENT', 'WAITING')
|
||||
""".trimIndent(),
|
||||
) { ps -> ps.setString(1, kind) }
|
||||
}
|
||||
|
||||
override fun insert(kind: String, paramsJson: String): Long =
|
||||
ds.updateReturningLong(
|
||||
"INSERT INTO req_track (req_type, params_json, state) VALUES (?, ?, 'REGISTERED')",
|
||||
) { ps ->
|
||||
ps.setString(1, kind)
|
||||
ps.setString(2, paramsJson)
|
||||
}
|
||||
|
||||
override fun linkCoutmsgs(reqId: Long, coutmsgsId: Long) {
|
||||
ds.update("UPDATE req_track SET coutmsgs_id = ? WHERE req_id = ?") { ps ->
|
||||
ps.setLong(1, coutmsgsId)
|
||||
ps.setLong(2, reqId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun markSent(reqId: Long, sentAt: Instant) {
|
||||
ds.update("UPDATE req_track SET state = 'SENT', sent_at = ? WHERE req_id = ?") { ps ->
|
||||
ps.setTimestamp(1, sentAt.toSqlTimestamp())
|
||||
ps.setLong(2, reqId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun expireIfWaiting(reqId: Long) {
|
||||
ds.update("UPDATE req_track SET state = 'EXPIRED' WHERE req_id = ? AND state = 'WAITING'") { ps ->
|
||||
ps.setLong(1, reqId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun markDone(reqId: Long) {
|
||||
ds.update(
|
||||
"UPDATE req_track SET state = 'DONE', completed_at = ? WHERE req_id = ?",
|
||||
) { ps ->
|
||||
ps.setTimestamp(1, Instant.now().toSqlTimestamp())
|
||||
ps.setLong(2, reqId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@Requires(property = "msgx.stubs", notEquals = "true")
|
||||
@Requires(property = "datasources.default.enabled", value = "true")
|
||||
class JdbcFlightStateRepository : FlightStateRepository {
|
||||
override fun replaceDay(day: String, flights: List<Pair<String, String>>) = Unit
|
||||
override fun findByDay(day: String): List<Pair<String, String>> = emptyList()
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig
|
||||
import com.zaxxer.hikari.HikariDataSource
|
||||
import io.micronaut.context.annotation.Factory
|
||||
import io.micronaut.context.annotation.Requires
|
||||
import jakarta.inject.Named
|
||||
import jakarta.inject.Singleton
|
||||
import javax.sql.DataSource
|
||||
|
||||
@Factory
|
||||
class MailboxDataSourceFactory {
|
||||
|
||||
@Singleton
|
||||
@Named("mailbox")
|
||||
@Requires(property = "msgx.stubs", notEquals = "true")
|
||||
@Requires(property = "mailbox.shared-mysql.enabled", value = "true")
|
||||
fun mailboxDataSource(props: com.gzzn.omms.msgexchange.config.MailboxProps): DataSource {
|
||||
val cfg = props.sharedMysql
|
||||
val hikari = HikariConfig().apply {
|
||||
jdbcUrl = cfg.url
|
||||
username = cfg.username
|
||||
password = cfg.password
|
||||
driverClassName = cfg.driverClassName
|
||||
maximumPoolSize = 5
|
||||
poolName = "mailbox"
|
||||
}
|
||||
return HikariDataSource(hikari)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.gzzn.omms.msgexchange.infra.redis
|
||||
|
||||
/**
|
||||
* ACMA-8 流程 4 / I4 / I5:Redis Lua 脚本装载与执行入口。
|
||||
* 阶段 A 全部 flightInfo 写均经此处,且仅由主泵线程调用(I5)。
|
||||
*/
|
||||
enum class RedisScript(val classpathLocation: String) {
|
||||
/** 同一 hash 原子“覆盖新代 + 按代差删”(setArg 为 N 对 field/value,delArg 为差集)。 */
|
||||
SNAPSHOT_REPLACE("lua/snapshot_replace.lua"),
|
||||
|
||||
/** 3:30 清场批量删除(仅 ES 归档成功集)。 */
|
||||
BATCH_DELETE("lua/batch_delete.lua"),
|
||||
}
|
||||
|
||||
interface FlightRedisClient {
|
||||
/**
|
||||
* 执行脚本。SNAPSHOT_REPLACE:setPairs 为新代全量 field/value,delFields 为按代差集;
|
||||
* BATCH_DELETE:delFields 为待删 FLID 集。
|
||||
*/
|
||||
fun eval(script: RedisScript, setPairs: List<Pair<String, String>> = emptyList(), delFields: List<String> = emptyList())
|
||||
|
||||
/** 阶段 A 权威读(处理决策 loadState、3:30 清场 findAll)。 */
|
||||
fun hgetAllFlightInfo(): Map<String, String>
|
||||
|
||||
/** 连通性探测(健康检查用;实现必须为快速调用,失败返回 false 而非抛出穿出)。 */
|
||||
fun ping(): Boolean
|
||||
}
|
||||
|
||||
// TODO(阶段1后续): 基于 micronaut-redis-lettuce 的实装(脚本自 classpath 装载并缓存 SHA)。
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.gzzn.omms.msgexchange.infra.retry
|
||||
|
||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||
import io.micronaut.context.annotation.Factory
|
||||
import jakarta.inject.Singleton
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* 统一重试策略(ACM2-10 U08):供 MessageProcessor / SnapshotFlow(ProcState 侧)与
|
||||
* Dispatcher(MsgEvent 侧)共用——attempts 递增后按 backoff 表给 nextAttemptAt;
|
||||
* exhausted 判定与两侧同源(maxAttempts)。时间一律经可注入 Clock(测试用固定钟,避免脆弱睡眠)。
|
||||
*/
|
||||
@Singleton
|
||||
class FailureScheduler(
|
||||
private val props: PipelineProps,
|
||||
private val clock: Clock,
|
||||
) {
|
||||
/** 便捷构造:默认系统时钟(生产路径)。 */
|
||||
constructor(props: PipelineProps) : this(props, Clock.systemUTC())
|
||||
|
||||
fun now(): Instant = clock.instant()
|
||||
|
||||
fun exhausted(attempts: Int): Boolean = attempts >= props.pipeline.maxAttempts
|
||||
|
||||
/** attempts 指递增后的值;N28:attempt ≤ 0 由 backoffFor 兜底为首档。 */
|
||||
fun nextAttemptAt(attemptsAfterIncrement: Int): Instant =
|
||||
now().plusMillis(props.pipeline.backoffFor(attemptsAfterIncrement))
|
||||
}
|
||||
|
||||
/** 提供可注入 Clock(java.time.Clock);测试可用 Clock.fixed(...) 或自定义可变钟覆盖。 */
|
||||
@Factory
|
||||
class TimeFactory {
|
||||
@Singleton
|
||||
fun systemClock(): Clock = Clock.systemUTC()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.gzzn.omms.msgexchange.infra.retry
|
||||
|
||||
import com.gzzn.omms.msgexchange.domain.ErrorClass
|
||||
import com.gzzn.omms.msgexchange.domain.ProcState
|
||||
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
||||
import jakarta.inject.Singleton
|
||||
|
||||
/**
|
||||
* ProcState 侧统一失败迁移(U08/U10):处理/快照路径共用——
|
||||
* attempts+1 后若 exhausted → DEAD(EXHAUSTED)(终态,errorClass 规范化,原因保留在 lastError);
|
||||
* 否则 FAILED + attempts + nextAttemptAt(退避)(可重放)。任何“失败”都不得在无退避下直接终态化。
|
||||
*/
|
||||
@Singleton
|
||||
class ProcFailure(
|
||||
private val procState: ProcStateRepository,
|
||||
val scheduler: FailureScheduler,
|
||||
) {
|
||||
fun fail(head: ProcState, ec: ErrorClass, reason: String) {
|
||||
val attempts = head.attempts + 1
|
||||
if (scheduler.exhausted(attempts)) {
|
||||
procState.update(
|
||||
head.cminmsgsId, ProcStatus.DEAD,
|
||||
attempts = attempts,
|
||||
errorClass = ErrorClass.EXHAUSTED,
|
||||
lastError = "$reason; attempts=$attempts",
|
||||
)
|
||||
} else {
|
||||
procState.update(
|
||||
head.cminmsgsId, ProcStatus.FAILED,
|
||||
attempts = attempts,
|
||||
nextAttemptAt = scheduler.nextAttemptAt(attempts),
|
||||
errorClass = ec,
|
||||
lastError = reason,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.gzzn.omms.msgexchange.infra.retry
|
||||
|
||||
import com.gzzn.omms.msgexchange.domain.ErrorClass
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
||||
import jakarta.inject.Singleton
|
||||
|
||||
/**
|
||||
* U11 显式重放入口:只允许“可恢复”的错误类从 FAILED/DEAD 回到 PENDING(主泵重领)。
|
||||
* 不可恢复类(MALFORMED——报文非法,重放必再失败)与未知类一律不在白名单内。
|
||||
*/
|
||||
@Singleton
|
||||
class ReplayService(
|
||||
private val procState: ProcStateRepository,
|
||||
) {
|
||||
private val log = org.slf4j.LoggerFactory.getLogger(ReplayService::class.java)
|
||||
/** 可恢复错误类:codec 修复可重放 / 未实装补齐可重放 / 基础设施抖动可重放 / 重试耗尽后人工复核可重放。 */
|
||||
val replayableErrorClasses: Set<ErrorClass> =
|
||||
setOf(ErrorClass.CODEC_ERROR, ErrorClass.UNSUPPORTED, ErrorClass.INFRA, ErrorClass.EXHAUSTED)
|
||||
|
||||
/** 只重放白名单内的类;请求含 MALFORMED 等非法类时静默忽略该类。 */
|
||||
fun replay(requested: Collection<ErrorClass>): Int {
|
||||
val allowed = requested.filter { it in replayableErrorClasses }
|
||||
if (allowed.isEmpty()) {
|
||||
log.warn("replay requested only non-replayable classes: {}", requested)
|
||||
return 0
|
||||
}
|
||||
val n = procState.requeueByErrorClasses(allowed)
|
||||
log.info("replayed rows={} classes={}", n, allowed)
|
||||
return n
|
||||
}
|
||||
|
||||
/** 默认入口:重放全部可恢复类。 */
|
||||
fun replayAll(): Int = replay(replayableErrorClasses)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.gzzn.omms.msgexchange.infra.stub
|
||||
|
||||
import com.gzzn.omms.msgexchange.codec.DecodeFailure
|
||||
import com.gzzn.omms.msgexchange.codec.DecodeResult
|
||||
import com.gzzn.omms.msgexchange.codec.XmlCodec
|
||||
import com.gzzn.omms.msgexchange.delivery.DeliveryPort
|
||||
import com.gzzn.omms.msgexchange.domain.ErrorClass
|
||||
import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
|
||||
import com.gzzn.omms.msgexchange.infra.redis.RedisScript
|
||||
import com.gzzn.omms.msgexchange.processing.CodecHolder
|
||||
import com.gzzn.omms.msgexchange.processing.HandlerHolder
|
||||
import com.gzzn.omms.msgexchange.processing.HandlerRegistry
|
||||
import io.micronaut.context.annotation.Bean
|
||||
import io.micronaut.context.annotation.Factory
|
||||
import io.micronaut.context.annotation.Requires
|
||||
import jakarta.inject.Singleton
|
||||
|
||||
/**
|
||||
* U07:stub 适配层——XmlCodec / Redis 客户端 / DeliveryPort / Handler 装配,仅在 msgx.stubs=true 时生效。
|
||||
* stub codec 未实装 → 报文 decode 返回 CODEC_ERROR(走 FAILED 可重放路径,链路上可观测),
|
||||
* 语义见 ACM2-10 U11:不把“未实装”写成报文非法/终态。
|
||||
*/
|
||||
@Requires(property = "msgx.stubs", value = "true")
|
||||
@Singleton
|
||||
class StubXmlCodec : XmlCodec {
|
||||
override fun decode(rawXml: String): DecodeResult =
|
||||
DecodeResult.Err(DecodeFailure(ErrorClass.CODEC_ERROR, "stub:codec-not-implemented"))
|
||||
|
||||
override fun encodeRqrd(kind: String, rangeJson: String): String = ""
|
||||
}
|
||||
|
||||
@Requires(property = "msgx.stubs", value = "true")
|
||||
@Singleton
|
||||
class StubRedis : FlightRedisClient {
|
||||
val hash = mutableMapOf<String, String>()
|
||||
val evalCalls = mutableListOf<RedisScript>()
|
||||
|
||||
fun clear() { hash.clear(); evalCalls.clear() }
|
||||
|
||||
override fun eval(script: RedisScript, setPairs: List<Pair<String, String>>, delFields: List<String>) {
|
||||
evalCalls += script
|
||||
when (script) {
|
||||
RedisScript.SNAPSHOT_REPLACE -> {
|
||||
hash.putAll(setPairs)
|
||||
delFields.forEach { hash.remove(it) }
|
||||
}
|
||||
RedisScript.BATCH_DELETE -> delFields.forEach { hash.remove(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun hgetAllFlightInfo(): Map<String, String> = hash.toMap()
|
||||
|
||||
override fun ping(): Boolean = true
|
||||
}
|
||||
|
||||
@Requires(property = "msgx.stubs", value = "true")
|
||||
@Singleton
|
||||
class StubDeliveryPort : DeliveryPort {
|
||||
val sent = mutableListOf<Pair<String, String>>()
|
||||
|
||||
fun clear() { sent.clear() }
|
||||
|
||||
override fun sendKafka(topic: String, payloadJson: String) { sent += topic to payloadJson }
|
||||
override fun indexFlightHts(payloadJson: String) = Unit
|
||||
override fun projectRedis(payloadJson: String) = Unit
|
||||
}
|
||||
|
||||
/** Holder 工厂:CodecHolder/HandlerHolder 由 Micronaut Bean 提供(取代直连构造占位)。 */
|
||||
@Factory
|
||||
@Requires(property = "msgx.stubs", value = "true")
|
||||
class StubHolderFactory {
|
||||
|
||||
@Bean
|
||||
fun codecHolder(codec: StubXmlCodec): CodecHolder = CodecHolder(codec)
|
||||
|
||||
@Bean
|
||||
fun handlerHolder(): HandlerHolder = HandlerHolder(HandlerRegistry(emptyList())) // 阶段 2 前无 Handler 注册
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.gzzn.omms.msgexchange.infra.stub
|
||||
|
||||
import com.gzzn.omms.msgexchange.domain.ErrorClass
|
||||
import com.gzzn.omms.msgexchange.domain.EventStatus
|
||||
import com.gzzn.omms.msgexchange.domain.MsgEvent
|
||||
import com.gzzn.omms.msgexchange.domain.ProcState
|
||||
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||
import com.gzzn.omms.msgexchange.domain.RefUpsert
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PumpJobRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.RefDataRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.StaticRefRepository
|
||||
import io.micronaut.context.annotation.Requires
|
||||
import jakarta.inject.Singleton
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* U07(U01 遗留 stub):内存仓储装配——仅当 `msgx.stubs=true`(dev/影子冒烟)时生效
|
||||
* (@Requires),让「启动 + compat HTTP 写 + 主泵/投递循环 + /beans 端到端」在无 MySQL/Redis/Kafka 时可跑。
|
||||
* 与真实 Micronaut Data 实装按模块替换;切换点条件显式,不误入生产。
|
||||
*/
|
||||
@Requires(property = "msgx.stubs", value = "true")
|
||||
@Singleton
|
||||
class StubProcState : ProcStateRepository {
|
||||
private val rows = linkedMapOf<Long, ProcState>()
|
||||
private val bound = mutableMapOf<String, Long>()
|
||||
|
||||
fun clear() { rows.clear(); bound.clear() }
|
||||
|
||||
override fun insert(cminmsgsId: Long, state: ProcStatus) {
|
||||
rows[cminmsgsId] = ProcState(cminmsgsId, state)
|
||||
}
|
||||
|
||||
override fun exists(cminmsgsId: Long): Boolean = rows.containsKey(cminmsgsId)
|
||||
|
||||
override fun headUnfinished(): ProcState? =
|
||||
rows.filterValues { it.state == ProcStatus.PENDING || it.state == ProcStatus.FAILED }
|
||||
.minByOrNull { it.key }?.value
|
||||
|
||||
override fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean {
|
||||
val owner = bound[identityKey]
|
||||
if (owner != null && owner != cminmsgsId) return false
|
||||
bound[identityKey] = cminmsgsId
|
||||
rows[cminmsgsId] = (rows[cminmsgsId] ?: ProcState(cminmsgsId, ProcStatus.PENDING)).copy(identityKey = identityKey)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun ownerOfIdentity(identityKey: String): Long? = bound[identityKey]
|
||||
|
||||
override fun update(
|
||||
cminmsgsId: Long, state: ProcStatus, nextAttemptAt: Instant?, attempts: Int?,
|
||||
errorClass: ErrorClass?, lastError: String?,
|
||||
) {
|
||||
val old = rows[cminmsgsId] ?: ProcState(cminmsgsId, state)
|
||||
rows[cminmsgsId] = old.copy(
|
||||
state = state,
|
||||
nextAttemptAt = nextAttemptAt ?: old.nextAttemptAt,
|
||||
attempts = attempts ?: old.attempts,
|
||||
errorClass = errorClass ?: old.errorClass,
|
||||
lastError = lastError ?: old.lastError,
|
||||
)
|
||||
}
|
||||
|
||||
override fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int {
|
||||
var n = 0
|
||||
rows.keys.toList().forEach { id ->
|
||||
val s = rows[id]!!
|
||||
if (s.errorClass != null && s.errorClass in errorClasses &&
|
||||
(s.state == ProcStatus.FAILED || s.state == ProcStatus.DEAD)) {
|
||||
rows[id] = s.copy(state = ProcStatus.PENDING, attempts = 0, nextAttemptAt = null)
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/** 测试/运维观测用:读取当前状态行。 */
|
||||
fun snapshotOf(cminmsgsId: Long): ProcState? = rows[cminmsgsId]
|
||||
}
|
||||
|
||||
@Requires(property = "msgx.stubs", value = "true")
|
||||
@Singleton
|
||||
class StubInbox : CminmsgInboxRepository {
|
||||
private val raws = linkedMapOf<Long, String>()
|
||||
private val processed = mutableSetOf<Long>()
|
||||
|
||||
fun clear() {
|
||||
raws.clear()
|
||||
processed.clear()
|
||||
}
|
||||
private val ids = AtomicLong(0)
|
||||
|
||||
/** 模拟上游外部写信箱(不经 compat HTTP、不自动入队 PG)。 */
|
||||
fun simulateExternalWrite(rawXml: String): Long {
|
||||
val id = ids.incrementAndGet()
|
||||
raws[id] = rawXml
|
||||
return id
|
||||
}
|
||||
|
||||
override fun insertRaw(rawXml: String): Long {
|
||||
val id = ids.incrementAndGet()
|
||||
raws[id] = rawXml
|
||||
return id
|
||||
}
|
||||
|
||||
override fun rawOf(cminmsgsId: Long): String? = raws[cminmsgsId]
|
||||
|
||||
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> =
|
||||
raws.keys
|
||||
.filter { it > afterId && it !in processed }
|
||||
.sorted()
|
||||
.take(limit)
|
||||
|
||||
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
|
||||
processed += cminmsgsId
|
||||
}
|
||||
}
|
||||
|
||||
@Requires(property = "msgx.stubs", value = "true")
|
||||
@Singleton
|
||||
class StubMsgEvents : MsgEventRepository {
|
||||
private val rows = linkedMapOf<Long, MsgEvent>()
|
||||
|
||||
fun clear() { rows.clear() }
|
||||
private val ids = AtomicLong(0)
|
||||
|
||||
override fun insertAll(events: List<MsgEvent>): List<Long> = events.map { e ->
|
||||
val id = ids.incrementAndGet()
|
||||
rows[id] = e.copy(eventId = id)
|
||||
id
|
||||
}
|
||||
|
||||
override fun headUnsent(target: String): MsgEvent? =
|
||||
rows.values.filter { it.target == target && it.state == EventStatus.PENDING }
|
||||
.minByOrNull { it.eventId ?: Long.MAX_VALUE }
|
||||
|
||||
override fun claimBatch(target: String, limit: Int): List<MsgEvent> =
|
||||
rows.values.filter { it.target == target && it.state == EventStatus.PENDING }
|
||||
.sortedBy { it.eventId ?: Long.MAX_VALUE }
|
||||
.take(limit)
|
||||
|
||||
override fun markSent(eventId: Long) = mutate(eventId) { it.copy(state = EventStatus.SENT) }
|
||||
|
||||
override fun markAllSent(eventIds: List<Long>) = eventIds.forEach(::markSent)
|
||||
|
||||
override fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int) =
|
||||
mutate(eventId) { it.copy(state = EventStatus.PENDING, attempts = attempts, nextAttemptAt = nextAttemptAt) }
|
||||
|
||||
override fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int?) =
|
||||
mutate(eventId) { it.copy(state = EventStatus.DEAD, errorClass = errorClass, lastError = lastError, attempts = attempts ?: it.attempts) }
|
||||
|
||||
override fun insertSync(events: List<MsgEvent>) {
|
||||
insertAll(events)
|
||||
}
|
||||
|
||||
private fun mutate(eventId: Long, f: (MsgEvent) -> MsgEvent) {
|
||||
val cur = rows[eventId] ?: return
|
||||
rows[eventId] = f(cur)
|
||||
}
|
||||
}
|
||||
|
||||
@Requires(property = "msgx.stubs", value = "true")
|
||||
@Singleton
|
||||
class StubPumpJobs : PumpJobRepository {
|
||||
|
||||
fun clear() { rows.clear() }
|
||||
data class JobRow(val job: PumpJobRepository.Job, var state: String, var lastError: String? = null)
|
||||
|
||||
private val rows = linkedMapOf<Long, JobRow>()
|
||||
private val ids = AtomicLong(0)
|
||||
|
||||
override fun enqueue(kind: String) {
|
||||
val id = ids.incrementAndGet()
|
||||
rows[id] = JobRow(PumpJobRepository.Job(id, kind), "QUEUED")
|
||||
}
|
||||
|
||||
override fun headQueued(): PumpJobRepository.Job? =
|
||||
rows.values.firstOrNull { it.state == "QUEUED" }?.job
|
||||
|
||||
override fun markRunning(jobId: Long) { rows[jobId]?.state = "RUNNING" }
|
||||
override fun markDone(jobId: Long) { rows[jobId]?.state = "DONE" }
|
||||
override fun markFailed(jobId: Long, lastError: String) { rows[jobId]?.state = "FAILED"; rows[jobId]?.lastError = lastError }
|
||||
}
|
||||
|
||||
/** 快照 gen 协议 stub(业务库 REF_DATA 的 SCHD_GEN 行;ACM2-11 拆分后 21 类不在本实现)。 */
|
||||
@Requires(property = "msgx.stubs", value = "true")
|
||||
@Singleton
|
||||
class StubRefData : RefDataRepository {
|
||||
private val gens = mutableMapOf<String, RefDataRepository.GenMeta>()
|
||||
|
||||
fun clear() { gens.clear() }
|
||||
|
||||
override fun getGen(day: String): RefDataRepository.GenMeta? = gens[day]
|
||||
|
||||
override fun putGenIfVersion(day: String, expected: Long, new: RefDataRepository.GenMeta): Boolean {
|
||||
val cur = gens[day]?.version ?: 0L
|
||||
if (cur != expected) return false
|
||||
gens[day] = new
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** 21 类静态主数据 stub(独立 PG reference 库;内存实现,source 保留供审计断言)。 */
|
||||
@Requires(property = "msgx.stubs", value = "true")
|
||||
@Singleton
|
||||
class StubStaticRef : StaticRefRepository {
|
||||
private val rows = mutableMapOf<Pair<String, String>, RefUpsert>()
|
||||
|
||||
fun clear() { rows.clear() }
|
||||
|
||||
override fun upsertAll(refs: List<RefUpsert>) {
|
||||
refs.forEach { rows[it.rtype to it.rkey] = it }
|
||||
}
|
||||
|
||||
override fun findByType(type: String): List<RefUpsert> =
|
||||
rows.values.filter { it.rtype == type }
|
||||
}
|
||||
|
||||
@Requires(property = "msgx.stubs", value = "true")
|
||||
@Singleton
|
||||
class StubReqTrack : ReqTrackRepository {
|
||||
private val rows = mutableMapOf<Long, ReqTrackRepository.Req>()
|
||||
|
||||
fun clear() { rows.clear() }
|
||||
private val ids = AtomicLong(0)
|
||||
|
||||
override fun findOpenByKind(kind: String): ReqTrackRepository.Req? =
|
||||
rows.values.firstOrNull { it.reqType == kind && it.state in setOf("REGISTERED", "SENT", "WAITING") }
|
||||
|
||||
override fun forceExpireOpenOf(kind: String) {
|
||||
rows.keys.toList().forEach { id ->
|
||||
val r = rows[id]!!
|
||||
if (r.reqType == kind && r.state in setOf("REGISTERED", "SENT", "WAITING"))
|
||||
rows[id] = r.copy(state = "EXPIRED")
|
||||
}
|
||||
}
|
||||
|
||||
override fun insert(kind: String, paramsJson: String): Long {
|
||||
val id = ids.incrementAndGet()
|
||||
rows[id] = ReqTrackRepository.Req(id, kind, "REGISTERED")
|
||||
return id
|
||||
}
|
||||
|
||||
override fun linkCoutmsgs(reqId: Long, coutmsgsId: Long) = Unit
|
||||
override fun markSent(reqId: Long, sentAt: Instant) { rows[reqId]?.let { rows[reqId] = it.copy(state = "SENT", sentAt = sentAt) } }
|
||||
override fun expireIfWaiting(reqId: Long) { rows[reqId]?.let { rows[reqId] = it.copy(state = "EXPIRED") } }
|
||||
override fun markDone(reqId: Long) { rows[reqId]?.let { rows[reqId] = it.copy(state = "DONE") } }
|
||||
}
|
||||
|
||||
@Requires(property = "msgx.stubs", value = "true")
|
||||
@Singleton
|
||||
class StubFlightState : FlightStateRepository {
|
||||
private val byDay = mutableMapOf<String, MutableList<Pair<String, String>>>()
|
||||
|
||||
fun clear() { byDay.clear() }
|
||||
|
||||
override fun replaceDay(day: String, flights: List<Pair<String, String>>) {
|
||||
byDay[day] = flights.toMutableList()
|
||||
}
|
||||
|
||||
override fun findByDay(day: String): List<Pair<String, String>> = byDay[day] ?: emptyList()
|
||||
}
|
||||
Reference in New Issue
Block a user