feat(ingress): 实现 JDBC 信箱轮询、入站持久化与对拍测试 (U05)

This commit is contained in:
windyboy
2026-09-07 15:11:33 +08:00
parent dc68f1e1f8
commit c7b4b527ef
51 changed files with 1005 additions and 235 deletions
@@ -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 关键依赖的自定义健康指示器——
* RedisflightInfo 权威存储)与 Kafka(投递端口)。经 BeanProvider 可选解析:
* 缺 bean(如未用 stub 也未实装)时指示 DOWN 而非启动失败;
* UP 判据为真实 pingfalse/异常 → 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 批次)。
* 存储边界:自有 PostgreSQLdatasources.default= 本文件除 CminmsgInboxRepository 外
* 的全部接口(消息管道 PROC_STATE/MSG_EVENT、PUMP_JOB、REQ_TRACK、21 类 REF_MASTER);
* 共享 MySQL 信箱(CMINMSGS / COUTMSGS)经信箱封装访问,仅 DML、不建表;
* 主路径=上游外部写 CMINMSGS → 本系统 JDBC 轮询读;compat=insertRaw HTTP 写;
* Redis = 航班动态 + 快照 genRefDataRepository 目标实现);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?
/** I3identity 首次绑定;返回 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>)
}
/**
* 快照 generationSCHD_GEN)协议——只留 gen。
* ACM2-12gen 迁 Redis(与 flightInfo 同源,Lua 内原子「覆盖+按代差删+版本推进」,
* DB 仅写 SUCCEEDED;重放幂等由 Lua 承接,协议重设计属 U09)。本接口为过渡占位,
* 目标实现为 Redis gen storescript 化),非关系表。
*/
interface RefDataRepository {
data class GenMeta(val flids: List<String>, val version: Long)
fun getGen(day: String): GenMeta?
/** 流程 4:版本 CASexpected 未变才写,重放 no-op,不二次自增)。 */
fun putGenIfVersion(day: String, expected: Long, new: GenMeta): Boolean
}
/**
* 21 类静态主数据(航空公司/航线/机位/登机桥等)——自有 PostgreSQL `REF_MASTER` 表
* (ACM2-12:与消息管道同自有库;SOURCE=ADMINAPI/AODB/PIPELINEN19 对齐)。
* 与主链弱事务耦合:写入者为 ReferenceService21 类同步)与请求应答路径;
* Redis 只作只读热点投影(legacy orms_stand 语义延续,阶段 6)。
*/
interface StaticRefRepository {
fun upsertAll(refs: List<RefUpsert>)
fun findByType(type: String): List<RefUpsert>
}
/**
* 15 类请求状态机——自有 PG `REQ_TRACK`ACM2-12Reference & 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)
}
@@ -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)
}
}
}
@@ -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()
}
@@ -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 / I5Redis Lua 脚本装载与执行入口。
* 阶段 A 全部 flightInfo 写均经此处,且仅由主泵线程调用(I5)。
*/
enum class RedisScript(val classpathLocation: String) {
/** 同一 hash 原子“覆盖新代 + 按代差删”(setArg 为 N 对 field/valuedelArg 为差集)。 */
SNAPSHOT_REPLACE("lua/snapshot_replace.lua"),
/** 3:30 清场批量删除(仅 ES 归档成功集)。 */
BATCH_DELETE("lua/batch_delete.lua"),
}
interface FlightRedisClient {
/**
* 执行脚本。SNAPSHOT_REPLACEsetPairs 为新代全量 field/valuedelFields 为按代差集;
* BATCH_DELETEdelFields 为待删 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 / SnapshotFlowProcState 侧)与
* DispatcherMsgEvent 侧)共用——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 指递增后的值;N28attempt ≤ 0 由 backoffFor 兜底为首档。 */
fun nextAttemptAt(attemptsAfterIncrement: Int): Instant =
now().plusMillis(props.pipeline.backoffFor(attemptsAfterIncrement))
}
/** 提供可注入 Clockjava.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
/**
* U07stub 适配层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
/**
* U07U01 遗留 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()
}