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
+31
View File
@@ -0,0 +1,31 @@
# Repository Guidelines
## Project Structure & Module Organization
Application code lives under `src/main/kotlin/com/gzzn/omms/msgexchange/`. Keep changes within the established modules: `ingress` receives mailbox records, `processing` owns FIFO decisions, `delivery` handles outbound events, `jobs` runs maintenance work, and `infra` contains persistence, Redis, health, and retry adapters. Runtime configuration, Flyway migrations, and Lua scripts are in `src/main/resources/`. Tests mirror production packages under `src/test/kotlin/`. Architecture and requirements live in `docs/`; treat `docs/legacy/` as reference material, not current design. Local middleware definitions are in `compose.yaml` and `deploy/dev/`.
## Build, Test, and Development Commands
- `./gradlew build` — compile, test, and package the application.
- `./gradlew test` — run the JUnit 5 test suite.
- `MICRONAUT_ENVIRONMENTS=dev ./gradlew run` — start the stub-backed development profile without external services.
- `cp .env.example .env && docker compose up -d` — start local PostgreSQL, MySQL, Valkey, and Kafka.
- `docker compose ps` — verify middleware health.
Use JDK 25. In restricted environments, point `GRADLE_USER_HOME` and `TMPDIR` to writable directories.
## Coding Style & Naming Conventions
Use Kotlin conventions with four-space indentation, trailing commas in multiline declarations, and immutable values by default. Types use `PascalCase`; functions and properties use `camelCase`; constants use `UPPER_SNAKE_CASE`. Name tests after behavior, for example `MessageProcessorTest` and `InboxPollerTest`. Keep handlers pure: return domain decisions rather than performing Redis, Kafka, or database writes directly. Preserve the single-writer and strict message FIFO invariants documented in `docs/architecture.md`.
## Testing Guidelines
Tests use JUnit 5, Micronaut Test, and Kotlin Test. Add focused tests beside the affected package. Changes to ordering, retry, identity, snapshot, or delivery behavior must include invariant-level regression tests. Prefer injected clocks and in-memory adapters over sleeps or live infrastructure. Run `./gradlew test` before submitting.
## Commit & Pull Request Guidelines
History follows Conventional Commit-style subjects such as `feat(ref): ...`, `fix(processing): ...`, and `docs: ...`; include the relevant Plane identifier when applicable. Keep commits scoped and avoid mixing unrelated refactors. Pull requests should explain behavior changes, affected invariants, configuration or migration impact, linked Plane work items, and verification performed. Include request/response examples for API changes; screenshots are only needed for visual documentation changes.
## Security & Configuration
Never commit credentials or production endpoints. Use environment variables documented in `.env.example`. Shared MySQL is an external mailbox boundary: do not add schema migrations or unapproved tables there.
+2 -1
View File
@@ -48,6 +48,7 @@ dependencies {
implementation(libs.logstash.logback.encoder) implementation(libs.logstash.logback.encoder)
runtimeOnly(libs.mysql.connector.j) runtimeOnly(libs.mysql.connector.j)
runtimeOnly(libs.postgresql)
// ACM2-11datasources.reference21 类静态主数据独立 PG 库)驱动 org.postgresql:postgresql // ACM2-11datasources.reference21 类静态主数据独立 PG 库)驱动 org.postgresql:postgresql
// 随阶段 6 实装加入(当前 reference enabled=false 不加载;版本由 micronaut-platform BOM 钉 ~42.7 // 随阶段 6 实装加入(当前 reference enabled=false 不加载;版本由 micronaut-platform BOM 钉 ~42.7
runtimeOnly(libs.snakeyaml) // U02:显式版本入 catalog(原无版本号依赖 BOM 覆盖,snakeyaml 不在 micronaut BOM 内) runtimeOnly(libs.snakeyaml) // U02:显式版本入 catalog(原无版本号依赖 BOM 覆盖,snakeyaml 不在 micronaut BOM 内)
@@ -61,7 +62,7 @@ dependencies {
application { application {
// Kotlin 顶层 mainApplication.kt)→ JVM 主类为 ApplicationKt // Kotlin 顶层 mainApplication.kt)→ JVM 主类为 ApplicationKt
// 不声明则 ./gradlew run 报 "No main class specified"、installDist 产出的启动脚本主类为空。 // 不声明则 ./gradlew run 报 "No main class specified"、installDist 产出的启动脚本主类为空。
mainClass.set("com.gzzn.omms.msgexchange.nextgen.ApplicationKt") mainClass.set("com.gzzn.omms.msgexchange.ApplicationKt")
} }
tasks.test { tasks.test {
@@ -1,4 +1,4 @@
package com.gzzn.omms.msgexchange.nextgen package com.gzzn.omms.msgexchange
import io.micronaut.runtime.Micronaut import io.micronaut.runtime.Micronaut
@@ -1,7 +1,8 @@
package com.gzzn.omms.msgexchange.nextgen package com.gzzn.omms.msgexchange
import com.gzzn.omms.msgexchange.nextgen.delivery.Dispatcher import com.gzzn.omms.msgexchange.delivery.Dispatcher
import com.gzzn.omms.msgexchange.nextgen.processing.Pump import com.gzzn.omms.msgexchange.ingress.InboxPoller
import com.gzzn.omms.msgexchange.processing.Pump
import io.micronaut.context.annotation.Requires import io.micronaut.context.annotation.Requires
import io.micronaut.runtime.event.annotation.EventListener import io.micronaut.runtime.event.annotation.EventListener
import io.micronaut.runtime.server.event.ServerStartupEvent import io.micronaut.runtime.server.event.ServerStartupEvent
@@ -9,14 +10,14 @@ import jakarta.annotation.PreDestroy
import jakarta.inject.Singleton import jakarta.inject.Singleton
/** /**
* U07T03+N29管道生命周期装配服务启动ServerStartupEvent在各自专用单线程 * U07T03+N29管道生命周期装配服务启动后拉起 inbox 轮询主泵投递三条专用单线程
* msgx-pump / msgx-dispatcher不占用 Netty event loop上拉起 Pump Dispatcher 循环
* 停机时 requestStop + interrupt + join仅当 `msgx.pipeline.autostart=true` 时装配 * 停机时 requestStop + interrupt + join仅当 `msgx.pipeline.autostart=true` 时装配
* 默认关需要真实仓储或 msgx.stubs=true 才安全开启 * 默认关需要真实仓储或 msgx.stubs=true 才安全开启
*/ */
@Requires(property = "msgx.pipeline.autostart", value = "true") @Requires(property = "msgx.pipeline.autostart", value = "true")
@Singleton @Singleton
class PipelineLifecycle( class PipelineLifecycle(
private val poller: InboxPoller,
private val pump: Pump, private val pump: Pump,
private val dispatcher: Dispatcher, private val dispatcher: Dispatcher,
) { ) {
@@ -34,9 +35,10 @@ class PipelineLifecycle(
private fun startIfNeeded() { private fun startIfNeeded() {
if (started) return if (started) return
started = true started = true
threads += spawn("msgx-inbox-poller", poller::loop)
threads += spawn("msgx-pump", pump::loop) threads += spawn("msgx-pump", pump::loop)
threads += spawn("msgx-dispatcher", dispatcher::loop) threads += spawn("msgx-dispatcher", dispatcher::loop)
log.info("pipeline loops started (pump, dispatcher)") log.info("pipeline loops started (inbox-poller, pump, dispatcher)")
} }
private fun spawn(name: String, body: () -> Unit): Thread = private fun spawn(name: String, body: () -> Unit): Thread =
@@ -44,6 +46,7 @@ class PipelineLifecycle(
@PreDestroy @PreDestroy
fun stop() { fun stop() {
poller.stop()
pump.stop() pump.stop()
dispatcher.stop() dispatcher.stop()
threads.forEach { it.interrupt() } // 解除 Thread.sleep 阻塞,加速退出 threads.forEach { it.interrupt() } // 解除 Thread.sleep 阻塞,加速退出
@@ -1,7 +1,7 @@
package com.gzzn.omms.msgexchange.nextgen.codec package com.gzzn.omms.msgexchange.codec
import com.gzzn.omms.msgexchange.nextgen.domain.DecodedMessage import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.ErrorClass
/** /**
* ACMA-8 流程 2解码失败分类矩阵/状态机MALFORMED 不重试CODEC_ERROR 可一键重放 * ACMA-8 流程 2解码失败分类矩阵/状态机MALFORMED 不重试CODEC_ERROR 可一键重放
@@ -0,0 +1,17 @@
package com.gzzn.omms.msgexchange.config
import io.micronaut.context.annotation.ConfigurationProperties
@ConfigurationProperties("mailbox")
class MailboxProps {
var sharedMysql: SharedMysql = SharedMysql()
@ConfigurationProperties("shared-mysql")
class SharedMysql {
var enabled: Boolean = false
var url: String = ""
var username: String = ""
var password: String = ""
var driverClassName: String = "com.mysql.cj.jdbc.Driver"
}
}
@@ -1,4 +1,4 @@
package com.gzzn.omms.msgexchange.nextgen.config package com.gzzn.omms.msgexchange.config
import io.micronaut.context.annotation.ConfigurationProperties import io.micronaut.context.annotation.ConfigurationProperties
import java.time.Duration import java.time.Duration
@@ -1,13 +1,13 @@
package com.gzzn.omms.msgexchange.nextgen.delivery package com.gzzn.omms.msgexchange.delivery
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.nextgen.config.PipelineProps import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.domain.EventStatus import com.gzzn.omms.msgexchange.domain.EventStatus
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.Targets import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.MsgEventRepository import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.nextgen.infra.retry.FailureScheduler import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import jakarta.inject.Singleton import jakarta.inject.Singleton
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
@@ -1,7 +1,7 @@
package com.gzzn.omms.msgexchange.nextgen.delivery package com.gzzn.omms.msgexchange.delivery
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.SchdPush import com.gzzn.omms.msgexchange.domain.SchdPush
/** /**
* ACMA-8 流程 3 flushSchd 聚合纯函数可单测 * ACMA-8 流程 3 flushSchd 聚合纯函数可单测
@@ -1,4 +1,4 @@
package com.gzzn.omms.msgexchange.nextgen.domain package com.gzzn.omms.msgexchange.domain
/** /**
* ACMA-8 流程 2Handler 决策纯函数产物状态与报文进变更与事件出 * ACMA-8 流程 2Handler 决策纯函数产物状态与报文进变更与事件出
@@ -1,4 +1,4 @@
package com.gzzn.omms.msgexchange.nextgen.domain package com.gzzn.omms.msgexchange.domain
/** /**
* 解码后的入站报文统一内部模型META 字段实名 SNDR/SEQN/DTTMlegacy META.javaI3 幂等键来源 * 解码后的入站报文统一内部模型META 字段实名 SNDR/SEQN/DTTMlegacy META.javaI3 幂等键来源
@@ -1,4 +1,4 @@
package com.gzzn.omms.msgexchange.nextgen.domain package com.gzzn.omms.msgexchange.domain
/** /**
* ACMA-8 MSG_EVENT统一投递事件 / outbox * ACMA-8 MSG_EVENT统一投递事件 / outbox
@@ -1,4 +1,4 @@
package com.gzzn.omms.msgexchange.nextgen.domain package com.gzzn.omms.msgexchange.domain
/** /**
* ACMA-8 数据模型 / PROC_STATE 状态机六迁移表 * ACMA-8 数据模型 / PROC_STATE 状态机六迁移表
@@ -1,7 +1,7 @@
package com.gzzn.omms.msgexchange.nextgen.infra.health package com.gzzn.omms.msgexchange.infra.health
import com.gzzn.omms.msgexchange.nextgen.delivery.DeliveryPort import com.gzzn.omms.msgexchange.delivery.DeliveryPort
import com.gzzn.omms.msgexchange.nextgen.infra.redis.FlightRedisClient import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
import io.micronaut.context.BeanProvider import io.micronaut.context.BeanProvider
import io.micronaut.core.async.publisher.Publishers import io.micronaut.core.async.publisher.Publishers
import io.micronaut.health.HealthStatus import io.micronaut.health.HealthStatus
@@ -1,4 +1,4 @@
package com.gzzn.omms.msgexchange.nextgen.infra.log package com.gzzn.omms.msgexchange.infra.log
import org.slf4j.MDC import org.slf4j.MDC
@@ -1,11 +1,11 @@
package com.gzzn.omms.msgexchange.nextgen.infra.persistence package com.gzzn.omms.msgexchange.infra.persistence
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.domain.EventStatus import com.gzzn.omms.msgexchange.domain.EventStatus
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.ProcState import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.nextgen.domain.ProcStatus import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.nextgen.domain.RefUpsert import com.gzzn.omms.msgexchange.domain.RefUpsert
import java.time.Instant import java.time.Instant
/** /**
@@ -19,6 +19,9 @@ import java.time.Instant
interface ProcStateRepository { interface ProcStateRepository {
fun insert(cminmsgsId: Long, state: ProcStatus = ProcStatus.PENDING) fun insert(cminmsgsId: Long, state: ProcStatus = ProcStatus.PENDING)
/** 主路径轮询/compat 入队前判重(PG 已有行则跳过)。 */
fun exists(cminmsgsId: Long): Boolean
/** I1:严格 FIFO 队头(最小未完成 CMINMSGS_ID)。 */ /** I1:严格 FIFO 队头(最小未完成 CMINMSGS_ID)。 */
fun headUnfinished(): ProcState? fun headUnfinished(): ProcState?
@@ -165,5 +168,11 @@ interface CminmsgInboxRepository {
/** 主路径/处理:按 CMINMSGS_ID 读取上游已落信的原文 XML。 */ /** 主路径/处理:按 CMINMSGS_ID 读取上游已落信的原文 XML。 */
fun rawOf(cminmsgsId: Long): String? 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) 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)
}
}
@@ -1,4 +1,4 @@
package com.gzzn.omms.msgexchange.nextgen.infra.redis package com.gzzn.omms.msgexchange.infra.redis
/** /**
* ACMA-8 流程 4 / I4 / I5Redis Lua 脚本装载与执行入口 * ACMA-8 流程 4 / I4 / I5Redis Lua 脚本装载与执行入口
@@ -1,6 +1,6 @@
package com.gzzn.omms.msgexchange.nextgen.infra.retry package com.gzzn.omms.msgexchange.infra.retry
import com.gzzn.omms.msgexchange.nextgen.config.PipelineProps import com.gzzn.omms.msgexchange.config.PipelineProps
import io.micronaut.context.annotation.Factory import io.micronaut.context.annotation.Factory
import jakarta.inject.Singleton import jakarta.inject.Singleton
import java.time.Clock import java.time.Clock
@@ -1,9 +1,9 @@
package com.gzzn.omms.msgexchange.nextgen.infra.retry package com.gzzn.omms.msgexchange.infra.retry
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.domain.ProcState import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.nextgen.domain.ProcStatus import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ProcStateRepository import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import jakarta.inject.Singleton import jakarta.inject.Singleton
/** /**
@@ -1,7 +1,7 @@
package com.gzzn.omms.msgexchange.nextgen.infra.retry package com.gzzn.omms.msgexchange.infra.retry
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ProcStateRepository import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import jakarta.inject.Singleton import jakarta.inject.Singleton
/** /**
@@ -1,15 +1,15 @@
package com.gzzn.omms.msgexchange.nextgen.infra.stub package com.gzzn.omms.msgexchange.infra.stub
import com.gzzn.omms.msgexchange.nextgen.codec.DecodeFailure import com.gzzn.omms.msgexchange.codec.DecodeFailure
import com.gzzn.omms.msgexchange.nextgen.codec.DecodeResult import com.gzzn.omms.msgexchange.codec.DecodeResult
import com.gzzn.omms.msgexchange.nextgen.codec.XmlCodec import com.gzzn.omms.msgexchange.codec.XmlCodec
import com.gzzn.omms.msgexchange.nextgen.delivery.DeliveryPort import com.gzzn.omms.msgexchange.delivery.DeliveryPort
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.infra.redis.FlightRedisClient import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.nextgen.infra.redis.RedisScript import com.gzzn.omms.msgexchange.infra.redis.RedisScript
import com.gzzn.omms.msgexchange.nextgen.processing.CodecHolder import com.gzzn.omms.msgexchange.processing.CodecHolder
import com.gzzn.omms.msgexchange.nextgen.processing.HandlerHolder import com.gzzn.omms.msgexchange.processing.HandlerHolder
import com.gzzn.omms.msgexchange.nextgen.processing.HandlerRegistry import com.gzzn.omms.msgexchange.processing.HandlerRegistry
import io.micronaut.context.annotation.Bean import io.micronaut.context.annotation.Bean
import io.micronaut.context.annotation.Factory import io.micronaut.context.annotation.Factory
import io.micronaut.context.annotation.Requires import io.micronaut.context.annotation.Requires
@@ -1,19 +1,19 @@
package com.gzzn.omms.msgexchange.nextgen.infra.stub package com.gzzn.omms.msgexchange.infra.stub
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.domain.EventStatus import com.gzzn.omms.msgexchange.domain.EventStatus
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.ProcState import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.nextgen.domain.ProcStatus import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.nextgen.domain.RefUpsert import com.gzzn.omms.msgexchange.domain.RefUpsert
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.CminmsgInboxRepository import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.FlightStateRepository import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.MsgEventRepository import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ProcStateRepository import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.PumpJobRepository import com.gzzn.omms.msgexchange.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.RefDataRepository import com.gzzn.omms.msgexchange.infra.persistence.RefDataRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ReqTrackRepository import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.StaticRefRepository import com.gzzn.omms.msgexchange.infra.persistence.StaticRefRepository
import io.micronaut.context.annotation.Requires import io.micronaut.context.annotation.Requires
import jakarta.inject.Singleton import jakarta.inject.Singleton
import java.time.Instant import java.time.Instant
@@ -36,6 +36,8 @@ class StubProcState : ProcStateRepository {
rows[cminmsgsId] = ProcState(cminmsgsId, state) rows[cminmsgsId] = ProcState(cminmsgsId, state)
} }
override fun exists(cminmsgsId: Long): Boolean = rows.containsKey(cminmsgsId)
override fun headUnfinished(): ProcState? = override fun headUnfinished(): ProcState? =
rows.filterValues { it.state == ProcStatus.PENDING || it.state == ProcStatus.FAILED } rows.filterValues { it.state == ProcStatus.PENDING || it.state == ProcStatus.FAILED }
.minByOrNull { it.key }?.value .minByOrNull { it.key }?.value
@@ -85,10 +87,21 @@ class StubProcState : ProcStateRepository {
@Singleton @Singleton
class StubInbox : CminmsgInboxRepository { class StubInbox : CminmsgInboxRepository {
private val raws = linkedMapOf<Long, String>() private val raws = linkedMapOf<Long, String>()
private val processed = mutableSetOf<Long>()
fun clear() { raws.clear() } fun clear() {
raws.clear()
processed.clear()
}
private val ids = AtomicLong(0) 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 { override fun insertRaw(rawXml: String): Long {
val id = ids.incrementAndGet() val id = ids.incrementAndGet()
raws[id] = rawXml raws[id] = rawXml
@@ -97,8 +110,14 @@ class StubInbox : CminmsgInboxRepository {
override fun rawOf(cminmsgsId: Long): String? = raws[cminmsgsId] 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) { override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
// 内存 stub:无列可回填,记录即可(后续由真实实装/审计消费) processed += cminmsgsId
} }
} }
@@ -1,4 +1,4 @@
package com.gzzn.omms.msgexchange.nextgen.ingress package com.gzzn.omms.msgexchange.ingress
import io.micronaut.http.HttpResponse import io.micronaut.http.HttpResponse
import io.micronaut.http.MediaType import io.micronaut.http.MediaType
@@ -0,0 +1,16 @@
package com.gzzn.omms.msgexchange.ingress
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import jakarta.inject.Singleton
/** 流程 1 入队:共享库 CMINMSGS_ID → 自有 PG PROC_STATE(PENDING),幂等跳过已存在行。 */
@Singleton
class InboxEnqueue(private val procState: ProcStateRepository) {
/** @return true 若新建 PENDING 行;false 若已存在(轮询重扫/compensate 幂等)。 */
fun enqueue(cminmsgsId: Long): Boolean {
if (procState.exists(cminmsgsId)) return false
procState.insert(cminmsgsId)
return true
}
}
@@ -0,0 +1,65 @@
package com.gzzn.omms.msgexchange.ingress
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import jakarta.inject.Singleton
/**
* ACMA-8 流程 1 · 主路径:JDBC 轮询共享 MySQL CMINMSGS`DATE_PROCESSED IS NULL`),
* 发现新信后入队自有 PG。与 legacy `MsgExchangeRunner.getNewMsgsAfterId(0L)` 同语义。
*/
@Singleton
class InboxPoller(
private val inbox: CminmsgInboxRepository,
private val enqueue: InboxEnqueue,
private val props: PipelineProps,
) {
private val log = org.slf4j.LoggerFactory.getLogger(InboxPoller::class.java)
@Volatile
private var running = false
/** legacy 现役:afterId=0,每轮扫全部未处理行;PROC_STATE 判重防重复入队。 */
fun pollOnce(): Int {
val batch = props.pipeline.claimBatch.coerceAtLeast(1)
val ids = inbox.pollUnprocessed(afterId = 0L, limit = batch)
var enqueued = 0
for (id in ids) {
if (enqueue.enqueue(id)) {
enqueued++
log.info("polled cminmsgsId={}", id)
}
}
return enqueued
}
fun loop() {
running = true
log.info("inbox poller loop started")
while (running) {
try {
pollOnce()
sleepQuietly(props.pipeline.pollInterval)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
break
} catch (e: Exception) {
log.error("inbox poller tick failed", e)
sleepQuietly(props.pipeline.pollInterval)
}
}
log.info("inbox poller loop stopped")
}
fun stop() {
running = false
}
private fun sleepQuietly(d: java.time.Duration) {
try {
Thread.sleep(d.toMillis().coerceAtLeast(1))
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
}
}
@@ -0,0 +1,23 @@
package com.gzzn.omms.msgexchange.ingress
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import jakarta.inject.Singleton
import java.time.Instant
/** ACMA-8 流程 1 · compat 写路径:HTTP 落信 + PG 入队(非生产主拓扑;主路径=InboxPoller JDBC 轮询)。 */
@Singleton
class InboxService(
private val inbox: CminmsgInboxRepository,
private val enqueue: InboxEnqueue,
) {
private val log = org.slf4j.LoggerFactory.getLogger(InboxService::class.java)
data class Receipt(val cminmsgsId: Long, val receivedAt: Instant)
fun accept(rawXml: String): Receipt {
val id = inbox.insertRaw(rawXml)
enqueue.enqueue(id)
log.info("compat-accepted cminmsgsId={}", id)
return Receipt(id, Instant.now())
}
}
@@ -1,12 +1,12 @@
package com.gzzn.omms.msgexchange.nextgen.jobs package com.gzzn.omms.msgexchange.jobs
import com.gzzn.omms.msgexchange.nextgen.infra.redis.FlightRedisClient import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.nextgen.infra.redis.RedisScript import com.gzzn.omms.msgexchange.infra.redis.RedisScript
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.FlightStateRepository import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.MsgEventRepository import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.PumpJobRepository import com.gzzn.omms.msgexchange.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.Targets import com.gzzn.omms.msgexchange.domain.Targets
import jakarta.inject.Singleton import jakarta.inject.Singleton
/** /**
@@ -1,29 +0,0 @@
package com.gzzn.omms.msgexchange.nextgen.ingress
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ProcStateRepository
import jakarta.inject.Singleton
import java.time.Instant
/** ACMA-8 流程 1 · compat 写路径:HTTP 落信 + PG 入队(非生产主拓扑;主路径=InboxPoller JDBC 轮询,U05)。 */
@Singleton
class InboxService(
private val inbox: CminmsgInboxRepository,
private val procState: ProcStateRepository,
// TODO(U05): 主路径 InboxPoller — JDBC 轮询 DATE_PROCESSED IS NULL → procState.insert
// compat 路径 insertRaw + PG 入队跨库;PG 失败以共享库重扫补建;wakePump 仅加速。
) {
private val log = org.slf4j.LoggerFactory.getLogger(InboxService::class.java)
data class Receipt(val cminmsgsId: Long, val receivedAt: Instant)
fun accept(rawXml: String): Receipt {
val id = inbox.insertRaw(rawXml) // compat:共享信箱外部写(ACM2-12,与 PG 跨库)
procState.insert(id) // 自有 PG 入队;接收层无唯一约束(I3)
wakePump()
log.info("accepted cminmsgsId={}", id)
return Receipt(id, Instant.now())
}
private fun wakePump() = Unit // TODO: 主泵唤醒(仅加速)
}
@@ -1,8 +1,8 @@
package com.gzzn.omms.msgexchange.nextgen.processing package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.nextgen.domain.DecodedMessage import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.nextgen.domain.Decision import com.gzzn.omms.msgexchange.domain.Decision
import com.gzzn.omms.msgexchange.nextgen.domain.MsgKind import com.gzzn.omms.msgexchange.domain.MsgKind
/** /**
* ACMA-8 流程 2Handler = 纯函数状态与报文进Decision 不碰 Redis/Kafka * ACMA-8 流程 2Handler = 纯函数状态与报文进Decision 不碰 Redis/Kafka
@@ -1,7 +1,7 @@
package com.gzzn.omms.msgexchange.nextgen.processing package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.nextgen.config.PipelineProps import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.nextgen.domain.DecodedMessage import com.gzzn.omms.msgexchange.domain.DecodedMessage
import java.time.LocalDate import java.time.LocalDate
/** /**
@@ -1,19 +1,19 @@
package com.gzzn.omms.msgexchange.nextgen.processing package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.nextgen.config.PipelineProps import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.nextgen.domain.DecodedMessage import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.ProcState import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.nextgen.domain.ProcStatus import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.nextgen.domain.Targets import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.CminmsgInboxRepository import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.MsgEventRepository import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ProcStateRepository import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.PumpJobRepository import com.gzzn.omms.msgexchange.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.nextgen.infra.redis.FlightRedisClient import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.nextgen.infra.retry.ProcFailure import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.nextgen.jobs.JobExecutor import com.gzzn.omms.msgexchange.jobs.JobExecutor
import jakarta.inject.Singleton import jakarta.inject.Singleton
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
@@ -122,7 +122,7 @@ class MessageProcessor(
private val log = org.slf4j.LoggerFactory.getLogger(MessageProcessor::class.java) private val log = org.slf4j.LoggerFactory.getLogger(MessageProcessor::class.java)
fun processOne(head: ProcState) { fun processOne(head: ProcState) {
com.gzzn.omms.msgexchange.nextgen.infra.log.TraceLog.withTrace(head.cminmsgsId) { com.gzzn.omms.msgexchange.infra.log.TraceLog.withTrace(head.cminmsgsId) {
try { try {
processInternal(head) processInternal(head)
} catch (e: InterruptedException) { } catch (e: InterruptedException) {
@@ -151,8 +151,8 @@ class MessageProcessor(
return return
} }
val decoded = when (val r = codecHolder.codec.decode(raw)) { val decoded = when (val r = codecHolder.codec.decode(raw)) {
is com.gzzn.omms.msgexchange.nextgen.codec.DecodeResult.Ok -> r.message is com.gzzn.omms.msgexchange.codec.DecodeResult.Ok -> r.message
is com.gzzn.omms.msgexchange.nextgen.codec.DecodeResult.Err -> { is com.gzzn.omms.msgexchange.codec.DecodeResult.Err -> {
// T06U11):MALFORMED(报文非法)→ DEAD 不重试;CODEC_ERROR(可随 codec 修复重放)→ FAILED 退避 // T06U11):MALFORMED(报文非法)→ DEAD 不重试;CODEC_ERROR(可随 codec 修复重放)→ FAILED 退避
if (r.failure.errorClass == ErrorClass.MALFORMED) { if (r.failure.errorClass == ErrorClass.MALFORMED) {
log.error("decode MALFORMED -> DEAD id={} detail={}", head.cminmsgsId, r.failure.detail) log.error("decode MALFORMED -> DEAD id={} detail={}", head.cminmsgsId, r.failure.detail)
@@ -185,9 +185,9 @@ class MessageProcessor(
procFailure.fail(head, ErrorClass.UNSUPPORTED, "no-handler:${decoded.typeTag}") procFailure.fail(head, ErrorClass.UNSUPPORTED, "no-handler:${decoded.typeTag}")
return return
} }
val schdKind = decoded.kind as? com.gzzn.omms.msgexchange.nextgen.domain.MsgKind.Schd val schdKind = decoded.kind as? com.gzzn.omms.msgexchange.domain.MsgKind.Schd
if (schdKind != null && if (schdKind != null &&
schdKind.subtype == com.gzzn.omms.msgexchange.nextgen.domain.MsgKind.SchdSubtype.DNLD schdKind.subtype == com.gzzn.omms.msgexchange.domain.MsgKind.SchdSubtype.DNLD
) { ) {
snapshotFlow.publishSnapshot(head, decoded) snapshotFlow.publishSnapshot(head, decoded)
return return
@@ -214,5 +214,5 @@ class MessageProcessor(
} }
/** 延迟装配占位(阶段 1 后续以 Micronaut Bean 替换直连构造)。 */ /** 延迟装配占位(阶段 1 后续以 Micronaut Bean 替换直连构造)。 */
class CodecHolder(val codec: com.gzzn.omms.msgexchange.nextgen.codec.XmlCodec) class CodecHolder(val codec: com.gzzn.omms.msgexchange.codec.XmlCodec)
class HandlerHolder(val registry: HandlerRegistry) class HandlerHolder(val registry: HandlerRegistry)
@@ -1,14 +1,14 @@
package com.gzzn.omms.msgexchange.nextgen.processing package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.nextgen.domain.DecodedMessage import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.domain.ProcState import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.nextgen.domain.ProcStatus import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ProcStateRepository import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.RefDataRepository import com.gzzn.omms.msgexchange.infra.persistence.RefDataRepository
import com.gzzn.omms.msgexchange.nextgen.infra.redis.FlightRedisClient import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.nextgen.infra.redis.RedisScript import com.gzzn.omms.msgexchange.infra.redis.RedisScript
import com.gzzn.omms.msgexchange.nextgen.infra.retry.ProcFailure import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import jakarta.inject.Singleton import jakarta.inject.Singleton
/** /**
@@ -1,6 +1,6 @@
package com.gzzn.omms.msgexchange.nextgen.reference package com.gzzn.omms.msgexchange.reference
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.StaticRefRepository import com.gzzn.omms.msgexchange.infra.persistence.StaticRefRepository
import jakarta.inject.Singleton import jakarta.inject.Singleton
/** /**
@@ -1,8 +1,8 @@
package com.gzzn.omms.msgexchange.nextgen.reference package com.gzzn.omms.msgexchange.reference
import com.gzzn.omms.msgexchange.nextgen.domain.RefUpsert import com.gzzn.omms.msgexchange.domain.RefUpsert
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ReqTrackRepository import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.StaticRefRepository import com.gzzn.omms.msgexchange.infra.persistence.StaticRefRepository
import jakarta.inject.Singleton import jakarta.inject.Singleton
import java.time.Instant import java.time.Instant
+1 -1
View File
@@ -32,5 +32,5 @@
<appender-ref ref="ASYNC_LOGSTASH"/> <appender-ref ref="ASYNC_LOGSTASH"/>
</root> </root>
<logger name="com.gzzn.omms.msgexchange.nextgen" level="DEBUG"/> <logger name="com.gzzn.omms.msgexchange" level="DEBUG"/>
</configuration> </configuration>
@@ -1,15 +1,15 @@
package com.gzzn.omms.msgexchange.nextgen package com.gzzn.omms.msgexchange
import com.gzzn.omms.msgexchange.nextgen.delivery.Dispatcher import com.gzzn.omms.msgexchange.delivery.Dispatcher
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.ProcStatus import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.nextgen.domain.Targets import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.nextgen.infra.stub.StubDeliveryPort import com.gzzn.omms.msgexchange.infra.stub.StubDeliveryPort
import com.gzzn.omms.msgexchange.nextgen.infra.stub.StubMsgEvents import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.nextgen.infra.stub.StubProcState import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.nextgen.ingress.InboxController import com.gzzn.omms.msgexchange.ingress.InboxController
import com.gzzn.omms.msgexchange.nextgen.processing.Pump import com.gzzn.omms.msgexchange.processing.Pump
import io.micronaut.context.ApplicationContext import io.micronaut.context.ApplicationContext
import io.micronaut.test.extensions.junit5.annotation.MicronautTest import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import jakarta.inject.Inject import jakarta.inject.Inject
@@ -22,7 +22,7 @@ import org.junit.jupiter.api.Test
* U07 端到端stub 装配等价 /beans 核验 MySQL/Redis/Kafka * U07 端到端stub 装配等价 /beans 核验 MySQL/Redis/Kafka
* 核心 bean 装配齐全compat HTTP PG 入队主泵领取解码未实装FAILED(CODEC_ERROR)+退避 * 核心 bean 装配齐全compat HTTP PG 入队主泵领取解码未实装FAILED(CODEC_ERROR)+退避
* U11 重放把 FAILED 拉回 PENDINGDispatcher flushSchd 聚合发出 schd * U11 重放把 FAILED 拉回 PENDINGDispatcher flushSchd 聚合发出 schd
* 生产主路径 JDBC 轮询 InboxPoller U05本测未覆盖 * 生产主路径 JDBC 轮询 InboxPollercompat HTTP 写路径见 InboxService
* 后台循环关闭autostart=false按需手动 tick避免测试泄漏线程 * 后台循环关闭autostart=false按需手动 tick避免测试泄漏线程
*/ */
@MicronautTest @MicronautTest
@@ -44,14 +44,14 @@ class PipelineSmokeTest {
@org.junit.jupiter.api.BeforeEach @org.junit.jupiter.api.BeforeEach
fun cleanStubs() { fun cleanStubs() {
ctx.getBean(StubProcState::class.java).clear() ctx.getBean(StubProcState::class.java).clear()
ctx.getBean(com.gzzn.omms.msgexchange.nextgen.infra.stub.StubInbox::class.java).clear() ctx.getBean(com.gzzn.omms.msgexchange.infra.stub.StubInbox::class.java).clear()
ctx.getBean(StubMsgEvents::class.java).clear() ctx.getBean(StubMsgEvents::class.java).clear()
ctx.getBean(StubDeliveryPort::class.java).clear() ctx.getBean(StubDeliveryPort::class.java).clear()
} }
@Test @Test
fun `core pipeline beans are wired under stubs`() { fun `core pipeline beans are wired under stubs`() {
assertNotNull(ctx.getBean(com.gzzn.omms.msgexchange.nextgen.config.PipelineProps::class.java)) assertNotNull(ctx.getBean(com.gzzn.omms.msgexchange.config.PipelineProps::class.java))
assertTrue(ctx.getBeansOfType(Pump::class.java).isNotEmpty()) assertTrue(ctx.getBeansOfType(Pump::class.java).isNotEmpty())
assertTrue(ctx.getBeansOfType(Dispatcher::class.java).isNotEmpty()) assertTrue(ctx.getBeansOfType(Dispatcher::class.java).isNotEmpty())
assertNotNull(controller) assertNotNull(controller)
@@ -82,7 +82,7 @@ class PipelineSmokeTest {
val stub = ctx.getBean(StubProcState::class.java) val stub = ctx.getBean(StubProcState::class.java)
assertEquals(ProcStatus.FAILED, stub.snapshotOf(id)!!.state) assertEquals(ProcStatus.FAILED, stub.snapshotOf(id)!!.state)
val n = ctx.getBean(com.gzzn.omms.msgexchange.nextgen.infra.retry.ReplayService::class.java) val n = ctx.getBean(com.gzzn.omms.msgexchange.infra.retry.ReplayService::class.java)
.replay(listOf(ErrorClass.CODEC_ERROR)) .replay(listOf(ErrorClass.CODEC_ERROR))
assertEquals(1, n) assertEquals(1, n)
@@ -1,4 +1,4 @@
package com.gzzn.omms.msgexchange.nextgen package com.gzzn.omms.msgexchange
import java.time.Clock import java.time.Clock
import java.time.Instant import java.time.Instant
@@ -1,4 +1,4 @@
package com.gzzn.omms.msgexchange.nextgen.config package com.gzzn.omms.msgexchange.config
import io.micronaut.test.extensions.junit5.annotation.MicronautTest import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import jakarta.inject.Inject import jakarta.inject.Inject
@@ -1,4 +1,4 @@
package com.gzzn.omms.msgexchange.nextgen.config package com.gzzn.omms.msgexchange.config
import io.micronaut.test.extensions.junit5.annotation.MicronautTest import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import io.micronaut.test.support.TestPropertyProvider import io.micronaut.test.support.TestPropertyProvider
@@ -1,4 +1,4 @@
package com.gzzn.omms.msgexchange.nextgen.config package com.gzzn.omms.msgexchange.config
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -1,13 +1,13 @@
package com.gzzn.omms.msgexchange.nextgen.delivery package com.gzzn.omms.msgexchange.delivery
import com.gzzn.omms.msgexchange.nextgen.MutableClock import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.nextgen.config.PipelineProps import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.domain.EventStatus import com.gzzn.omms.msgexchange.domain.EventStatus
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.Targets import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.MsgEventRepository import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.nextgen.infra.retry.FailureScheduler import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertNull
@@ -1,8 +1,8 @@
package com.gzzn.omms.msgexchange.nextgen.delivery package com.gzzn.omms.msgexchange.delivery
import com.gzzn.omms.msgexchange.nextgen.domain.EventStatus import com.gzzn.omms.msgexchange.domain.EventStatus
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.Targets import com.gzzn.omms.msgexchange.domain.Targets
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -1,8 +1,8 @@
package com.gzzn.omms.msgexchange.nextgen.infra.health package com.gzzn.omms.msgexchange.infra.health
import com.gzzn.omms.msgexchange.nextgen.delivery.DeliveryPort import com.gzzn.omms.msgexchange.delivery.DeliveryPort
import com.gzzn.omms.msgexchange.nextgen.infra.redis.FlightRedisClient import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.nextgen.infra.redis.RedisScript import com.gzzn.omms.msgexchange.infra.redis.RedisScript
import io.micronaut.health.HealthStatus import io.micronaut.health.HealthStatus
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -1,9 +1,9 @@
package com.gzzn.omms.msgexchange.nextgen.infra.retry package com.gzzn.omms.msgexchange.infra.retry
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.domain.ProcState import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.nextgen.domain.ProcStatus import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ProcStateRepository import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -24,6 +24,7 @@ class ReplayServiceTest {
} }
override fun insert(cminmsgsId: Long, state: ProcStatus) = Unit override fun insert(cminmsgsId: Long, state: ProcStatus) = Unit
override fun exists(cminmsgsId: Long): Boolean = rows.containsKey(cminmsgsId)
override fun headUnfinished(): ProcState? = null override fun headUnfinished(): ProcState? = null
override fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean = true override fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean = true
override fun ownerOfIdentity(identityKey: String): Long? = null override fun ownerOfIdentity(identityKey: String): Long? = null
@@ -0,0 +1,45 @@
package com.gzzn.omms.msgexchange.ingress
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.infra.stub.StubInbox
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.processing.Pump
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import jakarta.inject.Inject
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/** 主路径:JDBC 轮询语义(stub 下 InboxPoller + 外部写信箱模拟)。 */
@MicronautTest
class InboxPollerTest {
@Inject lateinit var poller: InboxPoller
@Inject lateinit var pump: Pump
@Inject lateinit var stubInbox: StubInbox
@Inject lateinit var stubProc: StubProcState
@BeforeEach
fun clean() {
stubInbox.clear()
stubProc.clear()
}
@Test
fun `poller enqueues externally written mailbox rows`() {
val id = stubInbox.simulateExternalWrite("<MSG/>")
assertEquals(1, poller.pollOnce())
assertNotNull(stubProc.snapshotOf(id))
assertEquals(ProcStatus.PENDING, stubProc.snapshotOf(id)!!.state)
}
@Test
fun `poller is idempotent for already enqueued rows`() {
val id = stubInbox.simulateExternalWrite("<MSG/>")
poller.pollOnce()
assertEquals(0, poller.pollOnce())
pump.tick()
assertEquals(ProcStatus.FAILED, stubProc.snapshotOf(id)!!.state)
}
}
@@ -1,9 +1,9 @@
package com.gzzn.omms.msgexchange.nextgen.processing package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.nextgen.config.PipelineProps import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.nextgen.domain.DecodedMessage import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.nextgen.domain.MetaFields import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.nextgen.domain.MsgKind import com.gzzn.omms.msgexchange.domain.MsgKind
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.time.LocalDate import java.time.LocalDate
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -1,33 +1,33 @@
package com.gzzn.omms.msgexchange.nextgen.processing package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.nextgen.MutableClock import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.nextgen.codec.DecodeFailure import com.gzzn.omms.msgexchange.codec.DecodeFailure
import com.gzzn.omms.msgexchange.nextgen.codec.DecodeResult import com.gzzn.omms.msgexchange.codec.DecodeResult
import com.gzzn.omms.msgexchange.nextgen.codec.XmlCodec import com.gzzn.omms.msgexchange.codec.XmlCodec
import com.gzzn.omms.msgexchange.nextgen.config.PipelineProps import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.nextgen.domain.DecodedMessage import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.nextgen.domain.Decision import com.gzzn.omms.msgexchange.domain.Decision
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.domain.EventStatus import com.gzzn.omms.msgexchange.domain.EventStatus
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.MsgKind import com.gzzn.omms.msgexchange.domain.MsgKind
import com.gzzn.omms.msgexchange.nextgen.domain.MetaFields import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.nextgen.domain.NotifyPayload import com.gzzn.omms.msgexchange.domain.NotifyPayload
import com.gzzn.omms.msgexchange.nextgen.domain.ProcState import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.nextgen.domain.ProcStatus import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.nextgen.domain.SchdPush import com.gzzn.omms.msgexchange.domain.SchdPush
import com.gzzn.omms.msgexchange.nextgen.domain.Targets import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.CminmsgInboxRepository import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.MsgEventRepository import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ProcStateRepository import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.PumpJobRepository import com.gzzn.omms.msgexchange.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.RefDataRepository import com.gzzn.omms.msgexchange.infra.persistence.RefDataRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ReqTrackRepository import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.FlightStateRepository import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.nextgen.infra.redis.FlightRedisClient import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.nextgen.infra.redis.RedisScript import com.gzzn.omms.msgexchange.infra.redis.RedisScript
import com.gzzn.omms.msgexchange.nextgen.infra.retry.FailureScheduler import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.nextgen.infra.retry.ProcFailure import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertNull
@@ -50,6 +50,8 @@ class MessageProcessorTest {
override fun insert(cminmsgsId: Long, state: ProcStatus) { record[cminmsgsId] = ProcState(cminmsgsId, state) } override fun insert(cminmsgsId: Long, state: ProcStatus) { record[cminmsgsId] = ProcState(cminmsgsId, state) }
override fun exists(cminmsgsId: Long): Boolean = record.containsKey(cminmsgsId)
override fun headUnfinished(): ProcState? = null override fun headUnfinished(): ProcState? = null
override fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean { override fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean {
@@ -99,6 +101,8 @@ class MessageProcessorTest {
override fun insertRaw(rawXml: String): Long = 0 override fun insertRaw(rawXml: String): Long = 0
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
override fun rawOf(cminmsgsId: Long): String? { override fun rawOf(cminmsgsId: Long): String? {
throwOnRawOf?.let { throw it } throwOnRawOf?.let { throw it }
return raws[cminmsgsId] return raws[cminmsgsId]