feat(outbound): REQ_TRACK/COUTMSGS/schd-sync 与 RESP 守卫(ACM2-92/86)
出站请求状态机与编码;SCHD-RESP 须匹配未过期开放请求;EROR 结案匹配 SENT。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
package com.gzzn.omms.msgexchange.codec
|
||||
|
||||
/** AODB EROR 回报体(SIS 3.44):指向触发错误的源出站 META。 */
|
||||
data class ErorPayload(
|
||||
val seqs: Long,
|
||||
val typs: String,
|
||||
val stys: String,
|
||||
val etex: String,
|
||||
)
|
||||
@@ -9,6 +9,7 @@ import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
||||
import com.gzzn.omms.msgexchange.domain.ErrorClass
|
||||
import com.gzzn.omms.msgexchange.domain.MetaFields
|
||||
import com.gzzn.omms.msgexchange.domain.MsgKind
|
||||
import com.gzzn.omms.msgexchange.domain.OutboundRequestKeys
|
||||
import jakarta.inject.Singleton
|
||||
import javax.xml.stream.XMLInputFactory
|
||||
|
||||
@@ -68,7 +69,14 @@ class JacksonXmlCodec : XmlCodec {
|
||||
MsgKind.Fdel -> msg.flop?.let(SisWireMapper::flopPayload)
|
||||
// 参考数据类别报文的正文结构随 13 类各异(ACM2-93 实装 ReferenceDataProcessor 时绑定)
|
||||
is MsgKind.RefData -> null
|
||||
MsgKind.Eror -> null
|
||||
MsgKind.Eror -> msg.eror?.let {
|
||||
val seqs = it.seqs ?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-eror-seqs"))
|
||||
val typs = it.typs?.trim()?.takeIf { t -> t.isNotEmpty() }
|
||||
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-eror-typs"))
|
||||
val stys = it.stys?.trim()?.takeIf { t -> t.isNotEmpty() }
|
||||
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-eror-stys"))
|
||||
ErorPayload(seqs, typs.uppercase(), stys.uppercase(), it.etex?.trim().orEmpty())
|
||||
}
|
||||
is MsgKind.Unsupported -> null
|
||||
}
|
||||
|
||||
@@ -82,8 +90,36 @@ class JacksonXmlCodec : XmlCodec {
|
||||
)
|
||||
}
|
||||
|
||||
override fun encodeRqrd(kind: String, rangeJson: String): String =
|
||||
"""<?xml version="1.0" encoding="UTF-8"?><MSG><META><TYPE>RQFD</TYPE></META></MSG>"""
|
||||
override fun encodeOutboundRqfd(seqn: Long, dttm: Long): String =
|
||||
writeOutbound(
|
||||
SisOutboundMessageXml(
|
||||
meta = outboundMeta("RQFD", "NONE", seqn, dttm),
|
||||
rqfd = RqfdXml(),
|
||||
),
|
||||
)
|
||||
|
||||
override fun encodeOutboundRqrd(styp: String, seqn: Long, dttm: Long, rtyp: String?): String {
|
||||
val body = if (rtyp != null) RqrdXml(rtyp = rtyp) else RqrdXml()
|
||||
return writeOutbound(
|
||||
SisOutboundMessageXml(
|
||||
meta = outboundMeta("RQRD", styp.uppercase(), seqn, dttm),
|
||||
rqrd = body,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun outboundMeta(type: String, styp: String, seqn: Long, dttm: Long) = SisMetaXml(
|
||||
sndr = OutboundRequestKeys.SENDER,
|
||||
seqn = seqn,
|
||||
dttm = dttm,
|
||||
type = type,
|
||||
styp = styp,
|
||||
)
|
||||
|
||||
private fun writeOutbound(msg: SisOutboundMessageXml): String =
|
||||
mapper.writeValueAsString(msg).let { xml ->
|
||||
if (xml.startsWith("<?xml")) xml else """<?xml version="1.0" encoding="UTF-8"?>$xml"""
|
||||
}
|
||||
|
||||
/**
|
||||
* 子类型分派必须是**白名单**:未知的 SCHD 子类型不能静默当成全量日计划(DNLD)——
|
||||
|
||||
@@ -56,6 +56,34 @@ data class SisMessageXml(
|
||||
@param:JacksonXmlProperty(localName = "META") val meta: SisMetaXml? = null,
|
||||
@param:JacksonXmlProperty(localName = "SCHD") val schd: SchdXml? = null,
|
||||
@param:JacksonXmlProperty(localName = "FLOP") val flop: FlightRecordXml? = null,
|
||||
@param:JacksonXmlProperty(localName = "EROR") val eror: ErorXml? = null,
|
||||
@param:JacksonXmlProperty(localName = "RQFD") val rqfd: RqfdXml? = null,
|
||||
@param:JacksonXmlProperty(localName = "RQRD") val rqrd: RqrdXml? = null,
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class ErorXml(
|
||||
@param:JacksonXmlProperty(localName = "SEQS") val seqs: Long? = null,
|
||||
@param:JacksonXmlProperty(localName = "TYPS") val typs: String? = null,
|
||||
@param:JacksonXmlProperty(localName = "STYS") val stys: String? = null,
|
||||
@param:JacksonXmlProperty(localName = "ETEX") val etex: String? = null,
|
||||
)
|
||||
|
||||
/** 出站 RQFD 空体占位(C-4 全量不带 STDB/STDE)。 */
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class RqfdXml
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class RqrdXml(
|
||||
@param:JacksonXmlProperty(localName = "RTYP") val rtyp: String? = null,
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JacksonXmlRootElement(localName = "MSG")
|
||||
data class SisOutboundMessageXml(
|
||||
@param:JacksonXmlProperty(localName = "META") val meta: SisMetaXml,
|
||||
@param:JacksonXmlProperty(localName = "RQFD") val rqfd: RqfdXml? = null,
|
||||
@param:JacksonXmlProperty(localName = "RQRD") val rqrd: RqrdXml? = null,
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
|
||||
@@ -21,5 +21,10 @@ sealed interface DecodeResult {
|
||||
*/
|
||||
interface XmlCodec {
|
||||
fun decode(rawXml: String): DecodeResult
|
||||
fun encodeRqrd(kind: String, rangeJson: String): String
|
||||
|
||||
/** C-4:RQFD STYP=NONE,空 RQFD 体。 */
|
||||
fun encodeOutboundRqfd(seqn: Long, dttm: Long): String
|
||||
|
||||
/** C-4:RQRD,STYP 为参考数据子类型;RSTA 时可带 RTYP。 */
|
||||
fun encodeOutboundRqrd(styp: String, seqn: Long, dttm: Long, rtyp: String? = null): String
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ class PipelineProps {
|
||||
var registerEureka: Boolean = true
|
||||
var pipeline: Pipeline = Pipeline()
|
||||
var schd: Schd = Schd()
|
||||
var outbound: Outbound = Outbound()
|
||||
var identity: Identity = Identity()
|
||||
|
||||
@ConfigurationProperties("pipeline")
|
||||
@@ -115,6 +116,15 @@ class PipelineProps {
|
||||
var snapshotBatch: Int = 200
|
||||
}
|
||||
|
||||
@ConfigurationProperties("outbound")
|
||||
class Outbound {
|
||||
/** 出站请求落信后等待应答的最长时限(`US-09`;具体取值待 Q)。 */
|
||||
var responseTimeout: Duration = Duration.ofMinutes(30)
|
||||
|
||||
var routingRqfd: String = "OMMSRQFD"
|
||||
var routingRqrd: String = "OMMSRQRD"
|
||||
}
|
||||
|
||||
@ConfigurationProperties("identity")
|
||||
class Identity {
|
||||
/** CONFIRM(矩阵 #11):SEQN 重置作用域确认前保持 false,计算集中此处(I3)。 */
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.gzzn.omms.msgexchange.domain
|
||||
|
||||
/** 出站请求在 REQ_TRACK 与 C-4 编码中的稳定键。 */
|
||||
object OutboundRequestKeys {
|
||||
const val SENDER = "OMMS"
|
||||
const val RQFD_REQ_TYPE = "RQFD-NONE"
|
||||
const val RQRD_REQ_TYPE = "RQRD"
|
||||
|
||||
/** EROR 体 TYPS/STYS → REQ_TRACK.req_type;无法配对时 null。 */
|
||||
fun reqTypeFromEror(typs: String, stys: String): String? = when (typs.uppercase()) {
|
||||
"RQFD" -> if (stys.uppercase() == "NONE") RQFD_REQ_TYPE else null
|
||||
"RQRD" -> RQRD_REQ_TYPE
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -306,7 +306,7 @@ interface SnapshotLogRepository {
|
||||
* 同一类请求同时只保留一条有效,发新请求时把旧的置为已过期。
|
||||
*/
|
||||
interface ReqTrackRepository {
|
||||
enum class ReqState { PENDING, SENT, DONE, EXPIRED }
|
||||
enum class ReqState { PENDING, SENT, DONE, EXPIRED, FAILED }
|
||||
|
||||
data class Req(
|
||||
val reqId: Long,
|
||||
@@ -315,7 +315,10 @@ interface ReqTrackRepository {
|
||||
val sender: String,
|
||||
val state: ReqState,
|
||||
val coutmsgsId: Long? = null,
|
||||
val outboundSeqn: Long? = null,
|
||||
val writeUncertain: Boolean = false,
|
||||
val sentAt: Instant? = null,
|
||||
val createdAt: Instant? = null,
|
||||
)
|
||||
|
||||
/** 登记新请求:同类(类型+运营日+发送方)旧有效请求先置 EXPIRED。 */
|
||||
@@ -329,11 +332,35 @@ interface ReqTrackRepository {
|
||||
*/
|
||||
fun completeLatest(reqType: String, operationDay: LocalDate, sender: String): Boolean
|
||||
|
||||
fun linkCoutmsgs(reqId: Long, coutmsgsId: Long)
|
||||
fun linkCoutmsgs(reqId: Long, coutmsgsId: Long, outboundSeqn: Long)
|
||||
|
||||
fun markSent(reqId: Long, sentAt: Instant)
|
||||
|
||||
fun expire(reqId: Long)
|
||||
|
||||
fun markFailed(reqId: Long)
|
||||
|
||||
fun markWriteUncertain(reqId: Long)
|
||||
|
||||
/** 待落信:PENDING 且未标记 write_uncertain。 */
|
||||
fun listPendingDispatch(limit: Int): List<Req>
|
||||
|
||||
/** 已落信等待应答且超过时限:标记 EXPIRED 并返回条数。 */
|
||||
fun expireSentOlderThan(cutoff: Instant): Int
|
||||
|
||||
/** EROR:按出站 SEQN + 请求族匹配开放 SENT。 */
|
||||
fun findOpenSentBySeqn(reqType: String, operationDay: LocalDate, sender: String, outboundSeqn: Long): Req?
|
||||
}
|
||||
|
||||
/** 共享 MySQL COUTMSGS 出站信箱(只写、不改表)。 */
|
||||
interface CoutmsgOutboxRepository {
|
||||
enum class InsertOutcome { CONFIRMED, AMBIGUOUS }
|
||||
|
||||
data class InsertResult(val outcome: InsertOutcome, val coutmsgsId: Long?)
|
||||
|
||||
fun nextOutboundSeqn(): Long
|
||||
|
||||
fun insertMessage(rawXml: String, routingId: String): InsertResult
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
|
||||
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.CoutmsgOutboxRepository
|
||||
import io.micronaut.context.annotation.Requires
|
||||
import jakarta.inject.Named
|
||||
import jakarta.inject.Singleton
|
||||
import javax.sql.DataSource
|
||||
|
||||
@Singleton
|
||||
@Requires(property = "msgx.stubs", notEquals = "true")
|
||||
@Requires(property = "mailbox.shared-mysql.enabled", value = "true")
|
||||
class JdbcCoutmsgOutboxRepository(
|
||||
@Named("mailbox") private val ds: DataSource,
|
||||
) : CoutmsgOutboxRepository {
|
||||
|
||||
override fun nextOutboundSeqn(): Long =
|
||||
ds.queryOne("SELECT COALESCE(MAX(COUTMSGS_ID), 0) + 1 FROM coutmsgs", { _ -> }) { rs ->
|
||||
rs.getLong(1)
|
||||
} ?: 1L
|
||||
|
||||
override fun insertMessage(rawXml: String, routingId: String): CoutmsgOutboxRepository.InsertResult =
|
||||
try {
|
||||
val id = ds.updateReturningLong(
|
||||
"""
|
||||
INSERT INTO coutmsgs (COUTMSGS_CLOB_MSG, COUTMSGS_DATE_INSERTED, ROUTINGID)
|
||||
VALUES (?, CURRENT_TIMESTAMP, ?)
|
||||
""".trimIndent(),
|
||||
) { ps ->
|
||||
ps.setString(1, rawXml)
|
||||
ps.setString(2, routingId)
|
||||
}
|
||||
CoutmsgOutboxRepository.InsertResult(CoutmsgOutboxRepository.InsertOutcome.CONFIRMED, id)
|
||||
} catch (e: IllegalStateException) {
|
||||
CoutmsgOutboxRepository.InsertResult(CoutmsgOutboxRepository.InsertOutcome.AMBIGUOUS, null)
|
||||
} catch (e: Exception) {
|
||||
if (e.cause is IllegalStateException) {
|
||||
CoutmsgOutboxRepository.InsertResult(CoutmsgOutboxRepository.InsertOutcome.AMBIGUOUS, null)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
-4
@@ -961,8 +961,18 @@ class JdbcReqTrackRepository(
|
||||
private val ds: DataSource,
|
||||
private val clock: Clock,
|
||||
) : ReqTrackRepository {
|
||||
override fun insert(reqType: String, operationDay: LocalDate, sender: String): Long =
|
||||
ds.updateReturningLong(
|
||||
override fun insert(reqType: String, operationDay: LocalDate, sender: String): Long {
|
||||
ds.update(
|
||||
"""
|
||||
UPDATE req_track SET state = 'EXPIRED'
|
||||
WHERE req_type = ? AND operation_day = ? AND sender = ? AND state IN ('PENDING', 'SENT')
|
||||
""".trimIndent(),
|
||||
) { ps ->
|
||||
ps.setString(1, reqType)
|
||||
ps.setDate(2, java.sql.Date.valueOf(operationDay))
|
||||
ps.setString(3, sender)
|
||||
}
|
||||
return ds.updateReturningLong(
|
||||
"INSERT INTO req_track (req_type, operation_day, sender, state, created_at) VALUES (?, ?, ?, 'PENDING', ?) RETURNING req_id",
|
||||
{ ps ->
|
||||
ps.setString(1, reqType)
|
||||
@@ -971,6 +981,7 @@ class JdbcReqTrackRepository(
|
||||
ps.setTimestamp(4, clock.instant().toSqlTimestamp())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun findLatest(
|
||||
reqType: String,
|
||||
@@ -1015,8 +1026,15 @@ class JdbcReqTrackRepository(
|
||||
},
|
||||
) == 1
|
||||
|
||||
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 linkCoutmsgs(reqId: Long, coutmsgsId: Long, outboundSeqn: Long) {
|
||||
ds.update(
|
||||
"UPDATE req_track SET coutmsgs_id = ?, outbound_seqn = ? WHERE req_id = ?",
|
||||
{ ps ->
|
||||
ps.setLong(1, coutmsgsId)
|
||||
ps.setLong(2, outboundSeqn)
|
||||
ps.setLong(3, reqId)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun markSent(reqId: Long, sentAt: Instant) {
|
||||
@@ -1030,6 +1048,62 @@ class JdbcReqTrackRepository(
|
||||
ds.update("UPDATE req_track SET state = 'EXPIRED' WHERE req_id = ?", { ps -> ps.setLong(1, reqId) })
|
||||
}
|
||||
|
||||
override fun markFailed(reqId: Long) {
|
||||
ds.update(
|
||||
"UPDATE req_track SET state = 'FAILED', completed_at = ? WHERE req_id = ?",
|
||||
{ ps ->
|
||||
ps.setTimestamp(1, clock.instant().toSqlTimestamp())
|
||||
ps.setLong(2, reqId)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun markWriteUncertain(reqId: Long) {
|
||||
ds.update("UPDATE req_track SET write_uncertain = TRUE WHERE req_id = ?", { ps -> ps.setLong(1, reqId) })
|
||||
}
|
||||
|
||||
override fun listPendingDispatch(limit: Int): List<ReqTrackRepository.Req> =
|
||||
ds.query(
|
||||
"""
|
||||
SELECT * FROM req_track
|
||||
WHERE state = 'PENDING' AND write_uncertain = FALSE
|
||||
ORDER BY created_at ASC, req_id ASC
|
||||
LIMIT ?
|
||||
""".trimIndent(),
|
||||
{ ps -> ps.setInt(1, limit) },
|
||||
::mapReq,
|
||||
)
|
||||
|
||||
override fun expireSentOlderThan(cutoff: Instant): Int =
|
||||
ds.update(
|
||||
"""
|
||||
UPDATE req_track SET state = 'EXPIRED'
|
||||
WHERE state = 'SENT' AND sent_at IS NOT NULL AND sent_at < ?
|
||||
""".trimIndent(),
|
||||
) { ps -> ps.setTimestamp(1, cutoff.toSqlTimestamp()) }
|
||||
|
||||
override fun findOpenSentBySeqn(
|
||||
reqType: String,
|
||||
operationDay: LocalDate,
|
||||
sender: String,
|
||||
outboundSeqn: Long,
|
||||
): ReqTrackRepository.Req? =
|
||||
ds.queryOne(
|
||||
"""
|
||||
SELECT * FROM req_track
|
||||
WHERE req_type = ? AND operation_day = ? AND sender = ?
|
||||
AND state = 'SENT' AND outbound_seqn = ?
|
||||
ORDER BY created_at DESC, req_id DESC LIMIT 1
|
||||
""".trimIndent(),
|
||||
{ ps ->
|
||||
ps.setString(1, reqType)
|
||||
ps.setDate(2, java.sql.Date.valueOf(operationDay))
|
||||
ps.setString(3, sender)
|
||||
ps.setLong(4, outboundSeqn)
|
||||
},
|
||||
::mapReq,
|
||||
)
|
||||
|
||||
private fun mapReq(rs: ResultSet) = ReqTrackRepository.Req(
|
||||
reqId = rs.getLong("req_id"),
|
||||
reqType = rs.getString("req_type"),
|
||||
@@ -1037,6 +1111,9 @@ class JdbcReqTrackRepository(
|
||||
sender = rs.getString("sender"),
|
||||
state = ReqTrackRepository.ReqState.valueOf(rs.getString("state")),
|
||||
coutmsgsId = rs.getLong("coutmsgs_id").takeIf { !rs.wasNull() },
|
||||
outboundSeqn = rs.getLong("outbound_seqn").takeIf { !rs.wasNull() },
|
||||
writeUncertain = rs.getBoolean("write_uncertain"),
|
||||
sentAt = rs.getInstant("sent_at"),
|
||||
createdAt = rs.getInstant("created_at"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.CoutmsgOutboxRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
|
||||
import com.gzzn.omms.msgexchange.domain.SnapshotLogEntry
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.SnapshotLogRepository
|
||||
@@ -464,7 +465,7 @@ class StubSnapshotLog : SnapshotLogRepository, SnapshotLogPurge {
|
||||
|
||||
@Singleton
|
||||
@Requires(property = "msgx.stubs", value = "true")
|
||||
class StubReqTrack : ReqTrackRepository {
|
||||
class StubReqTrack(private val clock: Clock = Clock.systemUTC()) : ReqTrackRepository {
|
||||
val rows = linkedMapOf<Long, ReqTrackRepository.Req>()
|
||||
private val ids = AtomicLong(0)
|
||||
|
||||
@@ -477,7 +478,10 @@ class StubReqTrack : ReqTrackRepository {
|
||||
(it.state == ReqTrackRepository.ReqState.PENDING || it.state == ReqTrackRepository.ReqState.SENT)
|
||||
}.forEach { rows[it.reqId] = it.copy(state = ReqTrackRepository.ReqState.EXPIRED) }
|
||||
val id = ids.incrementAndGet()
|
||||
rows[id] = ReqTrackRepository.Req(id, reqType, operationDay, sender, ReqTrackRepository.ReqState.PENDING)
|
||||
rows[id] = ReqTrackRepository.Req(
|
||||
id, reqType, operationDay, sender, ReqTrackRepository.ReqState.PENDING,
|
||||
createdAt = clock.instant(),
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -498,8 +502,8 @@ class StubReqTrack : ReqTrackRepository {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun linkCoutmsgs(reqId: Long, coutmsgsId: Long) {
|
||||
rows[reqId]?.let { rows[reqId] = it.copy(coutmsgsId = coutmsgsId) }
|
||||
override fun linkCoutmsgs(reqId: Long, coutmsgsId: Long, outboundSeqn: Long) {
|
||||
rows[reqId]?.let { rows[reqId] = it.copy(coutmsgsId = coutmsgsId, outboundSeqn = outboundSeqn) }
|
||||
}
|
||||
|
||||
override fun markSent(reqId: Long, sentAt: Instant) {
|
||||
@@ -509,6 +513,65 @@ class StubReqTrack : ReqTrackRepository {
|
||||
override fun expire(reqId: Long) {
|
||||
rows[reqId]?.let { rows[reqId] = it.copy(state = ReqTrackRepository.ReqState.EXPIRED) }
|
||||
}
|
||||
|
||||
override fun markFailed(reqId: Long) {
|
||||
rows[reqId]?.let { rows[reqId] = it.copy(state = ReqTrackRepository.ReqState.FAILED) }
|
||||
}
|
||||
|
||||
override fun markWriteUncertain(reqId: Long) {
|
||||
rows[reqId]?.let { rows[reqId] = it.copy(writeUncertain = true) }
|
||||
}
|
||||
|
||||
override fun listPendingDispatch(limit: Int): List<ReqTrackRepository.Req> =
|
||||
rows.values.filter { it.state == ReqTrackRepository.ReqState.PENDING && !it.writeUncertain }
|
||||
.sortedWith(compareBy({ it.createdAt }, { it.reqId }))
|
||||
.take(limit)
|
||||
|
||||
override fun expireSentOlderThan(cutoff: Instant): Int {
|
||||
var n = 0
|
||||
rows.values.filter { it.state == ReqTrackRepository.ReqState.SENT && it.sentAt != null && it.sentAt!! < cutoff }
|
||||
.forEach {
|
||||
rows[it.reqId] = it.copy(state = ReqTrackRepository.ReqState.EXPIRED)
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
override fun findOpenSentBySeqn(
|
||||
reqType: String,
|
||||
operationDay: LocalDate,
|
||||
sender: String,
|
||||
outboundSeqn: Long,
|
||||
): ReqTrackRepository.Req? =
|
||||
rows.values.filter {
|
||||
it.reqType == reqType && it.operationDay == operationDay && it.sender == sender &&
|
||||
it.state == ReqTrackRepository.ReqState.SENT && it.outboundSeqn == outboundSeqn
|
||||
}.maxByOrNull { it.reqId }
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@Requires(property = "msgx.stubs", value = "true")
|
||||
class StubCoutmsgOutbox : CoutmsgOutboxRepository {
|
||||
val messages = linkedMapOf<Long, String>()
|
||||
private val ids = AtomicLong(0)
|
||||
|
||||
fun clear() {
|
||||
messages.clear()
|
||||
ids.set(0)
|
||||
}
|
||||
|
||||
var nextInsertOutcome: CoutmsgOutboxRepository.InsertOutcome = CoutmsgOutboxRepository.InsertOutcome.CONFIRMED
|
||||
|
||||
override fun nextOutboundSeqn(): Long = (messages.keys.maxOrNull() ?: 0L) + 1L
|
||||
|
||||
override fun insertMessage(rawXml: String, routingId: String): CoutmsgOutboxRepository.InsertResult {
|
||||
if (nextInsertOutcome == CoutmsgOutboxRepository.InsertOutcome.AMBIGUOUS) {
|
||||
return CoutmsgOutboxRepository.InsertResult(CoutmsgOutboxRepository.InsertOutcome.AMBIGUOUS, null)
|
||||
}
|
||||
val id = ids.incrementAndGet()
|
||||
messages[id] = rawXml
|
||||
return CoutmsgOutboxRepository.InsertResult(CoutmsgOutboxRepository.InsertOutcome.CONFIRMED, id)
|
||||
}
|
||||
}
|
||||
|
||||
/** 内存版共享信箱:可以模拟上游写入、库方清除,并记录哪些行被打上了处理标记。 */
|
||||
|
||||
@@ -13,7 +13,10 @@ import io.micronaut.http.annotation.Produces
|
||||
* 生产上收报走的是轮询共享信箱表 CMINMSGS(InboxPoller),不是这个 Controller。
|
||||
*/
|
||||
@Controller
|
||||
class InboxController(private val inbox: InboxService) {
|
||||
class InboxController(
|
||||
private val inbox: InboxService,
|
||||
private val schdSync: SchdSyncService,
|
||||
) {
|
||||
|
||||
@Post("/cminmsgs/send")
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
@@ -22,5 +25,14 @@ class InboxController(private val inbox: InboxService) {
|
||||
return HttpResponse.ok(receipt.msgId.toString()) // TODO: 先返回消息 ID 文本,等跟现役响应体逐字对拍过再定稿
|
||||
}
|
||||
|
||||
// TODO(阶段2): 按契约清单补齐 /schd/sync、/kafka/topics/{name}/msgs 这两个接口。
|
||||
/** C-8:登记 RQFD 出站请求;开放槽占用时 409,不重复登记。 */
|
||||
@Post("/schd/sync")
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
fun schdSync(): HttpResponse<String> =
|
||||
when (val outcome = schdSync.trigger()) {
|
||||
is SchdSyncService.Outcome.Registered ->
|
||||
HttpResponse.ok(outcome.reqId.toString())
|
||||
SchdSyncService.Outcome.OpenExists ->
|
||||
HttpResponse.status<String>(io.micronaut.http.HttpStatus.CONFLICT).body("open-request-exists")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.gzzn.omms.msgexchange.ingress
|
||||
|
||||
import com.gzzn.omms.msgexchange.processing.OutboundRequestService
|
||||
import jakarta.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SchdSyncService(private val outbound: OutboundRequestService) {
|
||||
sealed interface Outcome {
|
||||
data class Registered(val reqId: Long) : Outcome
|
||||
data object OpenExists : Outcome
|
||||
}
|
||||
|
||||
fun trigger(): Outcome =
|
||||
when (val r = outbound.registerRqfdSync()) {
|
||||
is OutboundRequestService.RegisterOutcome.Registered -> Outcome.Registered(r.reqId)
|
||||
OutboundRequestService.RegisterOutcome.OpenExists -> Outcome.OpenExists
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ class JobRunner(
|
||||
private val historySweep: HistorySweepJob,
|
||||
private val eventCleanup: EventCleanupJob,
|
||||
private val procStateCleanup: ProcStateCleanupJob,
|
||||
private val reqTrackMaintenance: ReqTrackMaintenanceJob,
|
||||
private val clock: Clock,
|
||||
private val activity: JobActivity,
|
||||
private val props: PipelineProps,
|
||||
@@ -87,6 +88,10 @@ class JobRunner(
|
||||
if (procCleanup.deleted > 0) {
|
||||
log.info("proc_state cleanup: deleted {} expired terminal rows", procCleanup.deleted)
|
||||
}
|
||||
val reqTrackOutcome = reqTrackMaintenance.run()
|
||||
if (reqTrackOutcome.dispatched > 0 || reqTrackOutcome.expired > 0) {
|
||||
log.info("req_track maintenance: dispatched={} expired={}", reqTrackOutcome.dispatched, reqTrackOutcome.expired)
|
||||
}
|
||||
activity.tickFinished(startedAt, (System.nanoTime() - startedNanos) / 1_000_000, selected)
|
||||
} catch (e: InterruptedException) {
|
||||
throw e
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.gzzn.omms.msgexchange.jobs
|
||||
|
||||
import com.gzzn.omms.msgexchange.processing.OutboundRequestService
|
||||
import jakarta.inject.Singleton
|
||||
|
||||
/** 出站请求:补偿落信与应答超时(`G-REQ-TRACK`)。 */
|
||||
@Singleton
|
||||
class ReqTrackMaintenanceJob(private val outbound: OutboundRequestService) {
|
||||
data class Outcome(val dispatched: Int, val expired: Int)
|
||||
|
||||
fun run(): Outcome = Outcome(
|
||||
dispatched = outbound.dispatchPending(limit = 20),
|
||||
expired = outbound.expireTimedOut(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.gzzn.omms.msgexchange.processing
|
||||
|
||||
import com.gzzn.omms.msgexchange.codec.XmlCodec
|
||||
import com.gzzn.omms.msgexchange.config.OperationDayProps
|
||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||
import com.gzzn.omms.msgexchange.domain.OutboundRequestKeys
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.CoutmsgOutboxRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
|
||||
import jakarta.inject.Singleton
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.time.Clock
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* 出站 REQ_TRACK 协调:登记、COUTMSGS 落信、超时与 EROR 失败(`G-REQ-TRACK`、`C-4`)。
|
||||
*/
|
||||
@Singleton
|
||||
class OutboundRequestService(
|
||||
private val reqTrack: ReqTrackRepository,
|
||||
private val outbox: CoutmsgOutboxRepository,
|
||||
private val codec: XmlCodec,
|
||||
private val props: PipelineProps,
|
||||
operationDayProps: OperationDayProps,
|
||||
private val clock: Clock,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(OutboundRequestService::class.java)
|
||||
private val zone: ZoneId = operationDayProps.zoneId()
|
||||
private val cutoffHour = operationDayProps.cutoffHour.coerceIn(0, 23)
|
||||
private val dttmFmt = DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
|
||||
|
||||
sealed interface RegisterOutcome {
|
||||
data class Registered(val reqId: Long) : RegisterOutcome
|
||||
data object OpenExists : RegisterOutcome
|
||||
}
|
||||
|
||||
fun currentOperationDay(): LocalDate {
|
||||
val airport = clock.withZone(zone)
|
||||
val now = java.time.LocalDateTime.now(airport)
|
||||
val day = now.toLocalDate()
|
||||
return if (now.hour < cutoffHour) day.minusDays(1) else day
|
||||
}
|
||||
|
||||
fun hasOpenRequest(reqType: String, operationDay: LocalDate = currentOperationDay()): Boolean =
|
||||
reqTrack.findLatest(
|
||||
reqType,
|
||||
operationDay,
|
||||
OutboundRequestKeys.SENDER,
|
||||
listOf(ReqTrackRepository.ReqState.PENDING, ReqTrackRepository.ReqState.SENT),
|
||||
) != null
|
||||
|
||||
fun registerRqfdSync(): RegisterOutcome {
|
||||
val day = currentOperationDay()
|
||||
if (hasOpenRequest(OutboundRequestKeys.RQFD_REQ_TYPE, day)) return RegisterOutcome.OpenExists
|
||||
val reqId = reqTrack.insert(OutboundRequestKeys.RQFD_REQ_TYPE, day, OutboundRequestKeys.SENDER)
|
||||
dispatchPending(limit = 1)
|
||||
return RegisterOutcome.Registered(reqId)
|
||||
}
|
||||
|
||||
fun hasOpenSentRqfd(operationDay: LocalDate = currentOperationDay()): Boolean =
|
||||
reqTrack.findLatest(
|
||||
OutboundRequestKeys.RQFD_REQ_TYPE,
|
||||
operationDay,
|
||||
OutboundRequestKeys.SENDER,
|
||||
listOf(ReqTrackRepository.ReqState.SENT),
|
||||
) != null
|
||||
|
||||
fun completeRqfdResponse(operationDay: LocalDate = currentOperationDay()): Boolean =
|
||||
reqTrack.completeLatest(OutboundRequestKeys.RQFD_REQ_TYPE, operationDay, OutboundRequestKeys.SENDER)
|
||||
|
||||
fun dispatchPending(limit: Int = 10): Int {
|
||||
var sent = 0
|
||||
reqTrack.listPendingDispatch(limit).forEach { req ->
|
||||
if (dispatchOne(req)) sent++
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
fun expireTimedOut(): Int {
|
||||
val cutoff = clock.instant().minus(props.outbound.responseTimeout)
|
||||
return reqTrack.expireSentOlderThan(cutoff)
|
||||
}
|
||||
|
||||
fun failFromEror(seqs: Long, typs: String, stys: String): Boolean {
|
||||
val reqType = OutboundRequestKeys.reqTypeFromEror(typs, stys) ?: return false
|
||||
val day = currentOperationDay()
|
||||
val open = reqTrack.findOpenSentBySeqn(reqType, day, OutboundRequestKeys.SENDER, seqs) ?: return false
|
||||
reqTrack.markFailed(open.reqId)
|
||||
log.warn(
|
||||
"outbound request FAILED from EROR reqId={} type={} seqn={} typs={} stys={}",
|
||||
open.reqId, reqType, seqs, typs, stys,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun dispatchOne(req: ReqTrackRepository.Req): Boolean {
|
||||
if (req.state != ReqTrackRepository.ReqState.PENDING || req.writeUncertain) return false
|
||||
val seqn = outbox.nextOutboundSeqn()
|
||||
val dttm = beijingMetaDttm()
|
||||
val xml = when (req.reqType) {
|
||||
OutboundRequestKeys.RQFD_REQ_TYPE -> codec.encodeOutboundRqfd(seqn, dttm)
|
||||
OutboundRequestKeys.RQRD_REQ_TYPE -> codec.encodeOutboundRqrd("AIRL", seqn, dttm)
|
||||
else -> {
|
||||
log.warn("unknown req_type for dispatch reqId={} type={}", req.reqId, req.reqType)
|
||||
return false
|
||||
}
|
||||
}
|
||||
val routing = when (req.reqType) {
|
||||
OutboundRequestKeys.RQFD_REQ_TYPE -> props.outbound.routingRqfd
|
||||
else -> props.outbound.routingRqrd
|
||||
}
|
||||
val inserted = outbox.insertMessage(xml, routing)
|
||||
return when (inserted.outcome) {
|
||||
CoutmsgOutboxRepository.InsertOutcome.CONFIRMED -> {
|
||||
val id = inserted.coutmsgsId ?: return false
|
||||
reqTrack.linkCoutmsgs(req.reqId, id, seqn)
|
||||
reqTrack.markSent(req.reqId, clock.instant())
|
||||
log.info("outbound COUTMSGS SENT reqId={} coutmsgsId={} seqn={}", req.reqId, id, seqn)
|
||||
true
|
||||
}
|
||||
CoutmsgOutboxRepository.InsertOutcome.AMBIGUOUS -> {
|
||||
reqTrack.markWriteUncertain(req.reqId)
|
||||
log.error(
|
||||
"outbound COUTMSGS write AMBIGUOUS reqId={} seqn={} — recorded, no blind resend",
|
||||
req.reqId, seqn,
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun beijingMetaDttm(): Long {
|
||||
val ldt = java.time.LocalDateTime.ofInstant(clock.instant(), zone)
|
||||
return dttmFmt.format(ldt).toLong()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.gzzn.omms.msgexchange.processing
|
||||
|
||||
import com.gzzn.omms.msgexchange.codec.ErorPayload
|
||||
import com.gzzn.omms.msgexchange.codec.FlopPayload
|
||||
import com.gzzn.omms.msgexchange.codec.ScheduleBody
|
||||
import com.gzzn.omms.msgexchange.codec.XmlCodec
|
||||
@@ -123,6 +124,7 @@ class MessageProcessor(
|
||||
private val flopProcessor: FlopProcessor,
|
||||
private val fdelProcessor: FdelProcessor,
|
||||
private val adftProcessor: AdftProcessor,
|
||||
private val outbound: OutboundRequestService,
|
||||
private val procFailure: ProcFailure,
|
||||
private val props: PipelineProps,
|
||||
private val clock: Clock,
|
||||
@@ -201,8 +203,24 @@ class MessageProcessor(
|
||||
val body = decoded.body as? ScheduleBody
|
||||
if (body == null) return deadMalformed(head, "missing-schd-body")
|
||||
when (kind.subtype) {
|
||||
MsgKind.SchdSubtype.DNLD, MsgKind.SchdSubtype.RESP ->
|
||||
MsgKind.SchdSubtype.DNLD ->
|
||||
scheduleProcessor.applyScheduleRecords(head, decoded)
|
||||
MsgKind.SchdSubtype.RESP -> {
|
||||
if (!outbound.hasOpenSentRqfd()) {
|
||||
log.info("SCHD-RESP without open RQFD -> SKIPPED msgId={} [G-RESP-GUARD]", head.msgId)
|
||||
procState.markTerminal(
|
||||
head.msgId, ProcStatus.SKIPPED,
|
||||
lastError = "resp-guard:no-open-req",
|
||||
now = clock.instant(),
|
||||
)
|
||||
return
|
||||
}
|
||||
val respResult = scheduleProcessor.applyScheduleRecords(head, decoded)
|
||||
if (respResult is ApplyResult.Succeeded || respResult is ApplyResult.ReplaySkipped) {
|
||||
outbound.completeRqfdResponse()
|
||||
}
|
||||
respResult
|
||||
}
|
||||
MsgKind.SchdSubtype.ADFT -> {
|
||||
val record = body.records.singleOrNull()
|
||||
if (record == null) return deadMalformed(head, "adft-needs-single-fltr")
|
||||
@@ -227,9 +245,20 @@ class MessageProcessor(
|
||||
return procFailure.fail(head, ErrorClass.UNSUPPORTED, "refdata-pending:${kind.type}")
|
||||
}
|
||||
MsgKind.Eror -> {
|
||||
// US-09 AC3:须匹配出站请求;REQ_TRACK 协调器落地前(ACM2-92)先按可重试失败留队。
|
||||
log.warn("eror without handler -> FAILED(UNSUPPORTED) msgId={}", head.msgId)
|
||||
return procFailure.fail(head, ErrorClass.UNSUPPORTED, "eror-pending")
|
||||
val payload = decoded.body as? ErorPayload
|
||||
?: return deadMalformed(head, "missing-eror-body")
|
||||
val matched = outbound.failFromEror(payload.seqs, payload.typs, payload.stys)
|
||||
if (!matched) {
|
||||
log.info("EROR without matching open outbound -> SKIPPED msgId={}", head.msgId)
|
||||
procState.markTerminal(
|
||||
head.msgId, ProcStatus.SKIPPED,
|
||||
lastError = "eror:no-matching-req",
|
||||
now = clock.instant(),
|
||||
)
|
||||
} else {
|
||||
procState.markTerminal(head.msgId, ProcStatus.SUCCEEDED, now = clock.instant())
|
||||
}
|
||||
return
|
||||
}
|
||||
is MsgKind.Unsupported -> {
|
||||
// 合法但不支持的类型:跳过留档记 SKIPPED(US-03 AC2;markTerminal 同一条 UPDATE
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- REQ_TRACK:出站 SEQN(EROR 配对)与落信结果不明标记(不盲重发)。
|
||||
ALTER TABLE req_track ADD COLUMN IF NOT EXISTS outbound_seqn BIGINT;
|
||||
ALTER TABLE req_track ADD COLUMN IF NOT EXISTS write_uncertain BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -16,6 +16,22 @@ class JacksonXmlCodecTest {
|
||||
|
||||
private val codec = JacksonXmlCodec()
|
||||
|
||||
@Test
|
||||
fun `encode outbound RQFD uses OMMS meta and empty body per C-4`() {
|
||||
val xml = codec.encodeOutboundRqfd(1243L, 20021010090311L)
|
||||
assertTrue(xml.contains("<SNDR>OMMS</SNDR>"))
|
||||
assertTrue(xml.contains("<TYPE>RQFD</TYPE>"))
|
||||
assertTrue(xml.contains("<STYP>NONE</STYP>"))
|
||||
assertTrue(xml.contains("<RQFD"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encode outbound RQRD carries STYP`() {
|
||||
val xml = codec.encodeOutboundRqrd("AIRL", 99L, 20021010090311L)
|
||||
assertTrue(xml.contains("<TYPE>RQRD</TYPE>"))
|
||||
assertTrue(xml.contains("<STYP>AIRL</STYP>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode AODBGTDT sample extracts META and three gates`() {
|
||||
val raw = """
|
||||
|
||||
@@ -14,8 +14,12 @@ import com.gzzn.omms.msgexchange.infra.stub.StubInbox
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineLock
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTx
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubCoutmsgOutbox
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
|
||||
import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec
|
||||
import com.gzzn.omms.msgexchange.processing.BackfillService
|
||||
import com.gzzn.omms.msgexchange.processing.OutboundRequestService
|
||||
import com.gzzn.omms.msgexchange.processing.MessageLifecycleGate
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
@@ -36,10 +40,17 @@ class JobRunnerTest {
|
||||
OperationDayProps(), StubPipelineTx(), StubPipelineLock(),
|
||||
)
|
||||
|
||||
private fun reqTrackJob() = ReqTrackMaintenanceJob(
|
||||
OutboundRequestService(
|
||||
StubReqTrack(clock), StubCoutmsgOutbox(), JacksonXmlCodec(), props, OperationDayProps(), clock,
|
||||
),
|
||||
)
|
||||
|
||||
private fun runner(proc: ProcStateRepository, activity: JobActivity) = JobRunner(
|
||||
BackfillService(proc, StubInbox(), MailboxProps(), props, clock, MessageLifecycleGate()),
|
||||
historySweep(), EventCleanupJob(StubMsgEvents(), props),
|
||||
ProcStateCleanupJob(proc, MessageLifecycleGate(), props),
|
||||
reqTrackJob(),
|
||||
clock, activity, props, OperationDayProps(),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.gzzn.omms.msgexchange.MutableClock
|
||||
import com.gzzn.omms.msgexchange.codec.DecodeResult
|
||||
import com.gzzn.omms.msgexchange.codec.FlopPayload
|
||||
import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec
|
||||
import com.gzzn.omms.msgexchange.codec.XmlCodec
|
||||
import com.gzzn.omms.msgexchange.config.OperationDayProps
|
||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||
@@ -205,6 +206,14 @@ class FlightCommitTest {
|
||||
flopProcessor = FlopProcessor(commit, f.flights, f.events, mapper, clock),
|
||||
fdelProcessor = FdelProcessor(commit, f.flights, f.events, mapper, clock),
|
||||
adftProcessor = AdftProcessor(commit, f.flights, f.events, opDay, mapper, clock),
|
||||
outbound = OutboundRequestService(
|
||||
com.gzzn.omms.msgexchange.infra.stub.StubReqTrack(clock),
|
||||
com.gzzn.omms.msgexchange.infra.stub.StubCoutmsgOutbox(),
|
||||
JacksonXmlCodec(),
|
||||
props,
|
||||
opDay,
|
||||
clock,
|
||||
),
|
||||
procFailure = ProcFailure(f.procState, FailureScheduler(props, clock)),
|
||||
props = props,
|
||||
clock = clock,
|
||||
@@ -228,7 +237,8 @@ class FlightCommitTest {
|
||||
|
||||
private fun codecReturning(decoded: DecodedMessage) = object : XmlCodec {
|
||||
override fun decode(rawXml: String): DecodeResult = DecodeResult.Ok(decoded)
|
||||
override fun encodeRqrd(kind: String, rangeJson: String): String = ""
|
||||
override fun encodeOutboundRqfd(seqn: Long, dttm: Long): String = ""
|
||||
override fun encodeOutboundRqrd(styp: String, seqn: Long, dttm: Long, rtyp: String?): String = ""
|
||||
}
|
||||
|
||||
private class TracingTx(private val trace: MutableList<String>) : PipelineTransactionManager {
|
||||
|
||||
@@ -16,7 +16,12 @@ import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
|
||||
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
|
||||
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
|
||||
import com.gzzn.omms.msgexchange.codec.ErorPayload
|
||||
import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubCoutmsgOutbox
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubInbox
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
|
||||
import com.gzzn.omms.msgexchange.processing.OutboundRequestService
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubSnapshotLog
|
||||
@@ -47,7 +52,8 @@ class IgnoreBranchTest {
|
||||
|
||||
private fun codecReturning(msg: DecodedMessage) = object : XmlCodec {
|
||||
override fun decode(rawXml: String): DecodeResult = DecodeResult.Ok(msg)
|
||||
override fun encodeRqrd(kind: String, rangeJson: String): String = ""
|
||||
override fun encodeOutboundRqfd(seqn: Long, dttm: Long): String = ""
|
||||
override fun encodeOutboundRqrd(styp: String, seqn: Long, dttm: Long, rtyp: String?): String = ""
|
||||
}
|
||||
|
||||
private fun processor(
|
||||
@@ -72,6 +78,9 @@ class IgnoreBranchTest {
|
||||
flopProcessor = FlopProcessor(commit, flights, events, ObjectMapper(), clock),
|
||||
fdelProcessor = FdelProcessor(commit, flights, events, ObjectMapper(), clock),
|
||||
adftProcessor = AdftProcessor(commit, flights, events, opDay, ObjectMapper(), clock),
|
||||
outbound = OutboundRequestService(
|
||||
StubReqTrack(clock), StubCoutmsgOutbox(), JacksonXmlCodec(), props, opDay, clock,
|
||||
),
|
||||
procFailure = procFailure,
|
||||
props = props,
|
||||
clock = clock,
|
||||
@@ -103,15 +112,17 @@ class IgnoreBranchTest {
|
||||
proc.insertIfAbsent(msgId, null)
|
||||
val inbox = StubInbox()
|
||||
inbox.raws[msgId] = "<RAW/>"
|
||||
val msg = decoded("EROR", "GEN").copy(kind = MsgKind.Eror)
|
||||
val msg = decoded("EROR", "NONE").copy(
|
||||
kind = MsgKind.Eror,
|
||||
body = ErorPayload(99L, "RQFD", "NONE", "test"),
|
||||
)
|
||||
val p = processor(proc = proc, inbox = inbox, codec = codecReturning(msg))
|
||||
|
||||
p.processOne(head())
|
||||
|
||||
val row = proc.find(msgId)!!
|
||||
assertEquals(ProcStatus.FAILED, row.state)
|
||||
assertEquals(ErrorClass.UNSUPPORTED, row.errorClass)
|
||||
assertEquals("eror-pending", row.lastError)
|
||||
assertEquals(ProcStatus.SKIPPED, row.state)
|
||||
assertEquals("eror:no-matching-req", row.lastError)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.gzzn.omms.msgexchange.processing
|
||||
|
||||
import com.gzzn.omms.msgexchange.MutableClock
|
||||
import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec
|
||||
import com.gzzn.omms.msgexchange.config.OperationDayProps
|
||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||
import com.gzzn.omms.msgexchange.domain.OutboundRequestKeys
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.CoutmsgOutboxRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubCoutmsgOutbox
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
|
||||
class OutboundRequestTest {
|
||||
|
||||
private val clock = MutableClock(MutableClock.BASE)
|
||||
private val opDay = OperationDayProps().apply { zone = "Asia/Shanghai"; cutoffHour = 0 }
|
||||
private val props = PipelineProps().apply { outbound.responseTimeout = Duration.ofMinutes(5) }
|
||||
|
||||
private fun service(req: StubReqTrack, outbox: StubCoutmsgOutbox) =
|
||||
OutboundRequestService(req, outbox, JacksonXmlCodec(), props, opDay, clock)
|
||||
|
||||
@Test
|
||||
fun `schd sync registers PENDING then confirms COUTMSGS as SENT`() {
|
||||
val req = StubReqTrack(clock)
|
||||
val outbox = StubCoutmsgOutbox()
|
||||
val svc = service(req, outbox)
|
||||
|
||||
val outcome = svc.registerRqfdSync()
|
||||
assertTrue(outcome is OutboundRequestService.RegisterOutcome.Registered)
|
||||
val open = req.findLatest(
|
||||
OutboundRequestKeys.RQFD_REQ_TYPE,
|
||||
LocalDate.of(2026, 9, 6),
|
||||
OutboundRequestKeys.SENDER,
|
||||
listOf(ReqTrackRepository.ReqState.SENT),
|
||||
)!!
|
||||
assertEquals(1L, open.coutmsgsId)
|
||||
assertTrue(outbox.messages.containsValue(outbox.messages.values.first()))
|
||||
assertTrue(outbox.messages.values.first().contains("<SNDR>OMMS</SNDR>"))
|
||||
assertTrue(outbox.messages.values.first().contains("<TYPE>RQFD</TYPE>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open slot rejects duplicate schd sync registration`() {
|
||||
val req = StubReqTrack(clock)
|
||||
val svc = service(req, StubCoutmsgOutbox())
|
||||
svc.registerRqfdSync()
|
||||
assertEquals(OutboundRequestService.RegisterOutcome.OpenExists, svc.registerRqfdSync())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restart retries PENDING without coutmsgs after job dispatch`() {
|
||||
val req = StubReqTrack(clock)
|
||||
val outbox = StubCoutmsgOutbox()
|
||||
val day = LocalDate.of(2026, 9, 6)
|
||||
val reqId = req.insert(OutboundRequestKeys.RQFD_REQ_TYPE, day, OutboundRequestKeys.SENDER)
|
||||
assertEquals(ReqTrackRepository.ReqState.PENDING, req.rows[reqId]!!.state)
|
||||
|
||||
val svc = service(req, outbox)
|
||||
assertEquals(1, svc.dispatchPending())
|
||||
|
||||
assertEquals(ReqTrackRepository.ReqState.SENT, req.rows[reqId]!!.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ambiguous write marks uncertain and does not blind resend`() {
|
||||
val req = StubReqTrack(clock)
|
||||
val outbox = StubCoutmsgOutbox().apply { nextInsertOutcome = CoutmsgOutboxRepository.InsertOutcome.AMBIGUOUS }
|
||||
val day = LocalDate.of(2026, 9, 6)
|
||||
val reqId = req.insert(OutboundRequestKeys.RQFD_REQ_TYPE, day, OutboundRequestKeys.SENDER)
|
||||
val svc = service(req, outbox)
|
||||
|
||||
assertFalse(svc.dispatchPending() > 0)
|
||||
assertTrue(req.rows[reqId]!!.writeUncertain)
|
||||
assertEquals(ReqTrackRepository.ReqState.PENDING, req.rows[reqId]!!.state)
|
||||
outbox.nextInsertOutcome = CoutmsgOutboxRepository.InsertOutcome.CONFIRMED
|
||||
assertEquals(0, svc.dispatchPending())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timeout expires SENT request`() {
|
||||
val req = StubReqTrack(clock)
|
||||
val day = LocalDate.of(2026, 9, 6)
|
||||
val reqId = req.insert(OutboundRequestKeys.RQFD_REQ_TYPE, day, OutboundRequestKeys.SENDER)
|
||||
req.linkCoutmsgs(reqId, 10L, 99L)
|
||||
req.markSent(reqId, clock.instant())
|
||||
clock.advance(Duration.ofMinutes(10).toMillis())
|
||||
|
||||
val svc = service(req, StubCoutmsgOutbox())
|
||||
assertEquals(1, svc.expireTimedOut())
|
||||
assertEquals(ReqTrackRepository.ReqState.EXPIRED, req.rows[reqId]!!.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EROR fails matching open outbound by seqn`() {
|
||||
val req = StubReqTrack(clock)
|
||||
val day = LocalDate.of(2026, 9, 6)
|
||||
val reqId = req.insert(OutboundRequestKeys.RQFD_REQ_TYPE, day, OutboundRequestKeys.SENDER)
|
||||
req.linkCoutmsgs(reqId, 1L, 42L)
|
||||
req.markSent(reqId, clock.instant())
|
||||
|
||||
val svc = service(req, StubCoutmsgOutbox())
|
||||
assertTrue(svc.failFromEror(42L, "RQFD", "NONE"))
|
||||
assertEquals(ReqTrackRepository.ReqState.FAILED, req.rows[reqId]!!.state)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.gzzn.omms.msgexchange.processing
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec
|
||||
import com.gzzn.omms.msgexchange.codec.ScheduleBody
|
||||
import com.gzzn.omms.msgexchange.config.OperationDayProps
|
||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||
import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
||||
import com.gzzn.omms.msgexchange.domain.MetaFields
|
||||
import com.gzzn.omms.msgexchange.domain.MsgKind
|
||||
import com.gzzn.omms.msgexchange.domain.OutboundRequestKeys
|
||||
import com.gzzn.omms.msgexchange.domain.ProcState
|
||||
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||
import com.gzzn.omms.msgexchange.infra.metrics.PipelineCounters
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
|
||||
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
|
||||
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubCoutmsgOutbox
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubFlightProjectionPort
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubInbox
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubSnapshotLog
|
||||
import com.gzzn.omms.msgexchange.MutableClock
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
|
||||
class RespGuardTest {
|
||||
|
||||
private val clock = MutableClock(MutableClock.BASE)
|
||||
private val msgId = 900L
|
||||
private val day = LocalDate.of(2026, 9, 6)
|
||||
|
||||
private fun schdMsg(styp: MsgKind.SchdSubtype, body: ScheduleBody) = DecodedMessage(
|
||||
meta = MetaFields("AODB", "SCHD", styp.name, 1L, 1L),
|
||||
kind = MsgKind.Schd(styp),
|
||||
rawXml = "<MSG/>",
|
||||
body = body,
|
||||
)
|
||||
|
||||
private fun emptyBody() = ScheduleBody(0, emptyList())
|
||||
|
||||
private fun processor(
|
||||
proc: StubProcState,
|
||||
inbox: StubInbox,
|
||||
req: StubReqTrack,
|
||||
decoded: DecodedMessage,
|
||||
): MessageProcessor {
|
||||
val props = PipelineProps()
|
||||
val opDay = OperationDayProps().apply { zone = "Asia/Shanghai"; cutoffHour = 0 }
|
||||
val flights = StubFlightState()
|
||||
val events = StubMsgEvents()
|
||||
val commit = testCommit(procState = proc, msgEvents = events, projection = StubFlightProjectionPort(), clock = clock)
|
||||
return MessageProcessor(
|
||||
inbox = inbox,
|
||||
procState = proc,
|
||||
codec = object : com.gzzn.omms.msgexchange.codec.XmlCodec {
|
||||
override fun decode(rawXml: String) =
|
||||
com.gzzn.omms.msgexchange.codec.DecodeResult.Ok(decoded)
|
||||
override fun encodeOutboundRqfd(seqn: Long, dttm: Long) = ""
|
||||
override fun encodeOutboundRqrd(styp: String, seqn: Long, dttm: Long, rtyp: String?) = ""
|
||||
},
|
||||
scheduleProcessor = ScheduleProcessor(
|
||||
commit, proc, flights, events, StubSnapshotLog(), opDay, props, ObjectMapper(), clock,
|
||||
),
|
||||
flopProcessor = FlopProcessor(commit, flights, events, ObjectMapper(), clock),
|
||||
fdelProcessor = FdelProcessor(commit, flights, events, ObjectMapper(), clock),
|
||||
adftProcessor = AdftProcessor(commit, flights, events, opDay, ObjectMapper(), clock),
|
||||
outbound = OutboundRequestService(req, StubCoutmsgOutbox(), JacksonXmlCodec(), props, opDay, clock),
|
||||
procFailure = ProcFailure(proc, FailureScheduler(props, clock)),
|
||||
props = props,
|
||||
clock = clock,
|
||||
operationDayProps = opDay,
|
||||
counters = PipelineCounters(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SCHD-RESP without open RQFD is SKIPPED`() {
|
||||
val proc = StubProcState()
|
||||
proc.insertIfAbsent(msgId, null)
|
||||
val inbox = StubInbox()
|
||||
inbox.raws[msgId] = "<x/>"
|
||||
val p = processor(proc, inbox, StubReqTrack(clock), schdMsg(MsgKind.SchdSubtype.RESP, emptyBody()))
|
||||
|
||||
p.processOne(ProcState(msgId, ProcStatus.PENDING, updatedAt = Instant.EPOCH))
|
||||
|
||||
assertEquals(ProcStatus.SKIPPED, proc.find(msgId)!!.state)
|
||||
assertEquals("resp-guard:no-open-req", proc.find(msgId)!!.lastError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SCHD-DNLD does not require open request`() {
|
||||
val proc = StubProcState()
|
||||
proc.insertIfAbsent(msgId, null)
|
||||
val inbox = StubInbox()
|
||||
inbox.raws[msgId] = "<x/>"
|
||||
val p = processor(proc, inbox, StubReqTrack(clock), schdMsg(MsgKind.SchdSubtype.DNLD, emptyBody()))
|
||||
|
||||
p.processOne(ProcState(msgId, ProcStatus.PENDING, updatedAt = Instant.EPOCH))
|
||||
|
||||
assertEquals(ProcStatus.SUCCEEDED, proc.find(msgId)!!.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate SCHD-RESP after completion is SKIPPED`() {
|
||||
val req = StubReqTrack(clock)
|
||||
val reqId = req.insert(OutboundRequestKeys.RQFD_REQ_TYPE, day, OutboundRequestKeys.SENDER)
|
||||
req.linkCoutmsgs(reqId, 1L, 1L)
|
||||
req.markSent(reqId, clock.instant())
|
||||
req.completeLatest(OutboundRequestKeys.RQFD_REQ_TYPE, day, OutboundRequestKeys.SENDER)
|
||||
assertEquals(ReqTrackRepository.ReqState.DONE, req.rows[reqId]!!.state)
|
||||
|
||||
val proc = StubProcState()
|
||||
proc.insertIfAbsent(msgId, null)
|
||||
val inbox = StubInbox()
|
||||
inbox.raws[msgId] = "<x/>"
|
||||
val p = processor(proc, inbox, req, schdMsg(MsgKind.SchdSubtype.RESP, emptyBody()))
|
||||
p.processOne(ProcState(msgId, ProcStatus.PENDING, updatedAt = Instant.EPOCH))
|
||||
|
||||
assertEquals(ProcStatus.SKIPPED, proc.find(msgId)!!.state)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user