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:
@@ -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