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)
runtimeOnly(libs.mysql.connector.j)
runtimeOnly(libs.postgresql)
// ACM2-11datasources.reference21 类静态主数据独立 PG 库)驱动 org.postgresql:postgresql
// 随阶段 6 实装加入(当前 reference enabled=false 不加载;版本由 micronaut-platform BOM 钉 ~42.7
runtimeOnly(libs.snakeyaml) // U02:显式版本入 catalog(原无版本号依赖 BOM 覆盖,snakeyaml 不在 micronaut BOM 内)
@@ -61,7 +62,7 @@ dependencies {
application {
// Kotlin 顶层 mainApplication.kt)→ JVM 主类为 ApplicationKt
// 不声明则 ./gradlew run 报 "No main class specified"、installDist 产出的启动脚本主类为空。
mainClass.set("com.gzzn.omms.msgexchange.nextgen.ApplicationKt")
mainClass.set("com.gzzn.omms.msgexchange.ApplicationKt")
}
tasks.test {
@@ -1,4 +1,4 @@
package com.gzzn.omms.msgexchange.nextgen
package com.gzzn.omms.msgexchange
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.nextgen.processing.Pump
import com.gzzn.omms.msgexchange.delivery.Dispatcher
import com.gzzn.omms.msgexchange.ingress.InboxPoller
import com.gzzn.omms.msgexchange.processing.Pump
import io.micronaut.context.annotation.Requires
import io.micronaut.runtime.event.annotation.EventListener
import io.micronaut.runtime.server.event.ServerStartupEvent
@@ -9,14 +10,14 @@ import jakarta.annotation.PreDestroy
import jakarta.inject.Singleton
/**
* U07T03+N29管道生命周期装配服务启动ServerStartupEvent在各自专用单线程
* msgx-pump / msgx-dispatcher不占用 Netty event loop上拉起 Pump Dispatcher 循环
* U07T03+N29管道生命周期装配服务启动后拉起 inbox 轮询主泵投递三条专用单线程
* 停机时 requestStop + interrupt + join仅当 `msgx.pipeline.autostart=true` 时装配
* 默认关需要真实仓储或 msgx.stubs=true 才安全开启
*/
@Requires(property = "msgx.pipeline.autostart", value = "true")
@Singleton
class PipelineLifecycle(
private val poller: InboxPoller,
private val pump: Pump,
private val dispatcher: Dispatcher,
) {
@@ -34,9 +35,10 @@ class PipelineLifecycle(
private fun startIfNeeded() {
if (started) return
started = true
threads += spawn("msgx-inbox-poller", poller::loop)
threads += spawn("msgx-pump", pump::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 =
@@ -44,6 +46,7 @@ class PipelineLifecycle(
@PreDestroy
fun stop() {
poller.stop()
pump.stop()
dispatcher.stop()
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.nextgen.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.ErrorClass
/**
* 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 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.gzzn.omms.msgexchange.nextgen.config.PipelineProps
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.domain.EventStatus
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.Targets
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.nextgen.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.config.PipelineProps
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.Targets
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import jakarta.inject.Singleton
import java.time.Duration
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.nextgen.domain.SchdPush
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.SchdPush
/**
* ACMA-8 流程 3 flushSchd 聚合纯函数可单测
@@ -1,4 +1,4 @@
package com.gzzn.omms.msgexchange.nextgen.domain
package com.gzzn.omms.msgexchange.domain
/**
* 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 幂等键来源
@@ -1,4 +1,4 @@
package com.gzzn.omms.msgexchange.nextgen.domain
package com.gzzn.omms.msgexchange.domain
/**
* 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 状态机六迁移表
@@ -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.nextgen.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.delivery.DeliveryPort
import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
import io.micronaut.context.BeanProvider
import io.micronaut.core.async.publisher.Publishers
import io.micronaut.health.HealthStatus
@@ -1,4 +1,4 @@
package com.gzzn.omms.msgexchange.nextgen.infra.log
package com.gzzn.omms.msgexchange.infra.log
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.nextgen.domain.EventStatus
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.ProcState
import com.gzzn.omms.msgexchange.nextgen.domain.ProcStatus
import com.gzzn.omms.msgexchange.nextgen.domain.RefUpsert
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.EventStatus
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.RefUpsert
import java.time.Instant
/**
@@ -19,6 +19,9 @@ import java.time.Instant
interface ProcStateRepository {
fun insert(cminmsgsId: Long, state: ProcStatus = ProcStatus.PENDING)
/** 主路径轮询/compat 入队前判重(PG 已有行则跳过)。 */
fun exists(cminmsgsId: Long): Boolean
/** I1:严格 FIFO 队头(最小未完成 CMINMSGS_ID)。 */
fun headUnfinished(): ProcState?
@@ -165,5 +168,11 @@ interface CminmsgInboxRepository {
/** 主路径/处理:按 CMINMSGS_ID 读取上游已落信的原文 XML。 */
fun rawOf(cminmsgsId: Long): String?
/**
* 主路径 JDBC 轮询共享库未处理报文 ID 列表legacy `getNewMsgsAfterId` 同语义
* @param afterId 下界legacy 现役传 0仅返回 `DATE_PROCESSED IS NULL`
*/
fun pollUnprocessed(afterId: Long, limit: Int): List<Long>
fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long)
}
@@ -0,0 +1,63 @@
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import io.micronaut.context.annotation.Requires
import jakarta.inject.Named
import jakarta.inject.Singleton
import javax.sql.DataSource
/**
* 共享 MySQL CMINMSGS 信箱适配(ACM2-12:仅 DML,不建表)。
* 列名与 legacy `entity/Cminmsg.java` 一致。
*/
@Singleton
@Requires(property = "msgx.stubs", notEquals = "true")
@Requires(property = "mailbox.shared-mysql.enabled", value = "true")
class JdbcCminmsgInboxRepository(
@Named("mailbox") private val ds: DataSource,
) : CminmsgInboxRepository {
override fun insertRaw(rawXml: String): Long =
ds.updateReturningLong(
"""
INSERT INTO cminmsgs (CMINMSGS_CLOB_MSG, CMINMSGS_DATE_RECEIVED, CMINMSGS_DATE_PROCESSED)
VALUES (?, CURRENT_TIMESTAMP, NULL)
""".trimIndent(),
) { ps ->
ps.setString(1, rawXml)
}
override fun rawOf(cminmsgsId: Long): String? =
ds.queryOne(
"SELECT CMINMSGS_CLOB_MSG FROM cminmsgs WHERE CMINMSGS_ID = ?",
{ ps -> ps.setLong(1, cminmsgsId) },
) { rs -> rs.getString("CMINMSGS_CLOB_MSG") }
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> =
ds.query(
"""
SELECT CMINMSGS_ID FROM cminmsgs
WHERE CMINMSGS_ID > ? AND CMINMSGS_DATE_PROCESSED IS NULL
ORDER BY CMINMSGS_ID ASC
LIMIT ?
""".trimIndent(),
{ ps ->
ps.setLong(1, afterId)
ps.setInt(2, limit)
},
) { rs -> rs.getLong("CMINMSGS_ID") }
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
ds.update(
"""
UPDATE cminmsgs
SET CMINMSGS_DATE_PROCESSED = CURRENT_TIMESTAMP,
CMINMSGS_STATUS = ?
WHERE CMINMSGS_ID = ?
""".trimIndent(),
) { ps ->
ps.setString(1, "PROCESSED")
ps.setLong(2, cminmsgsId)
}
}
}
@@ -0,0 +1,46 @@
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
import java.sql.ResultSet
import java.sql.Timestamp
import java.time.Instant
import javax.sql.DataSource
internal fun Instant.toSqlTimestamp(): Timestamp = Timestamp.from(this)
internal fun ResultSet.getInstant(column: String): Instant? =
getTimestamp(column)?.toInstant()
internal fun <T> DataSource.query(sql: String, bind: (java.sql.PreparedStatement) -> Unit, map: (ResultSet) -> T): List<T> =
connection.use { conn ->
conn.prepareStatement(sql).use { ps ->
bind(ps)
ps.executeQuery().use { rs ->
buildList {
while (rs.next()) add(map(rs))
}
}
}
}
internal fun <T> DataSource.queryOne(sql: String, bind: (java.sql.PreparedStatement) -> Unit, map: (ResultSet) -> T): T? =
query(sql, bind, map).firstOrNull()
internal fun DataSource.update(sql: String, bind: (java.sql.PreparedStatement) -> Unit): Int =
connection.use { conn ->
conn.prepareStatement(sql).use { ps ->
bind(ps)
ps.executeUpdate()
}
}
internal fun DataSource.updateReturningLong(sql: String, bind: (java.sql.PreparedStatement) -> Unit): Long =
connection.use { conn ->
conn.prepareStatement(sql, java.sql.Statement.RETURN_GENERATED_KEYS).use { ps ->
bind(ps)
ps.executeUpdate()
ps.generatedKeys.use { keys ->
check(keys.next()) { "no generated key" }
keys.getLong(1)
}
}
}
@@ -0,0 +1,426 @@
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.EventStatus
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.RefUpsert
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.infra.persistence.RefDataRepository
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.infra.persistence.StaticRefRepository
import io.micronaut.context.annotation.Requires
import jakarta.inject.Singleton
import java.time.Instant
import javax.sql.DataSource
@Singleton
@Requires(property = "msgx.stubs", notEquals = "true")
@Requires(property = "datasources.default.enabled", value = "true")
class JdbcProcStateRepository(
private val ds: DataSource,
) : ProcStateRepository {
override fun insert(cminmsgsId: Long, state: ProcStatus) {
val now = Instant.now()
ds.update(
"""
INSERT INTO proc_state (cminmsgs_id, state, attempts, updated_at)
VALUES (?, ?, 0, ?)
ON CONFLICT (cminmsgs_id) DO NOTHING
""".trimIndent(),
) { ps ->
ps.setLong(1, cminmsgsId)
ps.setString(2, state.name)
ps.setTimestamp(3, now.toSqlTimestamp())
}
}
override fun exists(cminmsgsId: Long): Boolean =
ds.queryOne(
"SELECT 1 FROM proc_state WHERE cminmsgs_id = ?",
{ ps -> ps.setLong(1, cminmsgsId) },
) { 1 } != null
override fun headUnfinished(): ProcState? =
ds.queryOne(
"""
SELECT cminmsgs_id, state, identity_key, attempts, next_attempt_at, error_class, last_error, updated_at
FROM proc_state
WHERE state IN ('PENDING', 'FAILED')
ORDER BY cminmsgs_id ASC
LIMIT 1
""".trimIndent(),
{},
::mapProcState,
)
override fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean {
ownerOfIdentity(identityKey)?.let { owner ->
if (owner != cminmsgsId) return false
return true
}
val updated = ds.update(
"""
UPDATE proc_state SET identity_key = ?, updated_at = ?
WHERE cminmsgs_id = ? AND identity_key IS NULL
""".trimIndent(),
) { ps ->
ps.setString(1, identityKey)
ps.setTimestamp(2, Instant.now().toSqlTimestamp())
ps.setLong(3, cminmsgsId)
}
return updated == 1
}
override fun ownerOfIdentity(identityKey: String): Long? =
ds.queryOne(
"SELECT cminmsgs_id FROM proc_state WHERE identity_key = ?",
{ ps -> ps.setString(1, identityKey) },
) { rs -> rs.getLong("cminmsgs_id") }
override fun update(
cminmsgsId: Long,
state: ProcStatus,
nextAttemptAt: Instant?,
attempts: Int?,
errorClass: ErrorClass?,
lastError: String?,
) {
ds.update(
"""
UPDATE proc_state SET
state = ?,
next_attempt_at = ?,
attempts = COALESCE(?, attempts),
error_class = COALESCE(?, error_class),
last_error = COALESCE(?, last_error),
updated_at = ?
WHERE cminmsgs_id = ?
""".trimIndent(),
) { ps ->
ps.setString(1, state.name)
ps.setTimestamp(2, nextAttemptAt?.toSqlTimestamp())
if (attempts != null) ps.setInt(3, attempts) else ps.setNull(3, java.sql.Types.INTEGER)
ps.setString(4, errorClass?.name)
ps.setString(5, lastError)
ps.setTimestamp(6, Instant.now().toSqlTimestamp())
ps.setLong(7, cminmsgsId)
}
}
override fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int {
if (errorClasses.isEmpty()) return 0
val placeholders = errorClasses.joinToString(",") { "?" }
return ds.update(
"""
UPDATE proc_state SET state = 'PENDING', attempts = 0, next_attempt_at = NULL, updated_at = ?
WHERE state IN ('FAILED', 'DEAD') AND error_class IN ($placeholders)
""".trimIndent(),
) { ps ->
ps.setTimestamp(1, Instant.now().toSqlTimestamp())
errorClasses.forEachIndexed { i, ec -> ps.setString(i + 2, ec.name) }
}
}
private fun mapProcState(rs: java.sql.ResultSet) = ProcState(
cminmsgsId = rs.getLong("cminmsgs_id"),
state = ProcStatus.valueOf(rs.getString("state")),
identityKey = rs.getString("identity_key"),
attempts = rs.getInt("attempts"),
nextAttemptAt = rs.getInstant("next_attempt_at"),
errorClass = rs.getString("error_class")?.let(ErrorClass::valueOf),
lastError = rs.getString("last_error"),
updatedAt = rs.getInstant("updated_at") ?: Instant.now(),
)
}
@Singleton
@Requires(property = "msgx.stubs", notEquals = "true")
@Requires(property = "datasources.default.enabled", value = "true")
class JdbcMsgEventRepository(
private val ds: DataSource,
) : MsgEventRepository {
override fun insertAll(events: List<MsgEvent>): List<Long> =
events.map { e ->
ds.updateReturningLong(
"""
INSERT INTO msg_event (target, partition_key, payload_json, state, attempts, next_attempt_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
) { ps ->
ps.setString(1, e.target)
ps.setString(2, e.partitionKey)
ps.setString(3, e.payloadJson)
ps.setString(4, e.state.name)
ps.setInt(5, e.attempts)
ps.setTimestamp(6, e.nextAttemptAt?.toSqlTimestamp())
ps.setTimestamp(7, e.createdAt.toSqlTimestamp())
}
}
override fun headUnsent(target: String): MsgEvent? =
ds.queryOne(
"""
SELECT event_id, target, partition_key, payload_json, state, attempts, next_attempt_at, error_class, last_error, created_at
FROM msg_event
WHERE target = ? AND state = 'PENDING'
ORDER BY event_id ASC
LIMIT 1
""".trimIndent(),
{ ps -> ps.setString(1, target) },
::mapEvent,
)
override fun claimBatch(target: String, limit: Int): List<MsgEvent> =
ds.query(
"""
SELECT event_id, target, partition_key, payload_json, state, attempts, next_attempt_at, error_class, last_error, created_at
FROM msg_event
WHERE target = ? AND state = 'PENDING'
ORDER BY event_id ASC
LIMIT ?
""".trimIndent(),
{ ps ->
ps.setString(1, target)
ps.setInt(2, limit)
},
::mapEvent,
)
override fun markSent(eventId: Long) {
ds.update("UPDATE msg_event SET state = 'SENT' WHERE event_id = ?") { ps -> ps.setLong(1, eventId) }
}
override fun markAllSent(eventIds: List<Long>) {
eventIds.forEach(::markSent)
}
override fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int) {
ds.update(
"UPDATE msg_event SET state = 'PENDING', next_attempt_at = ?, attempts = ? WHERE event_id = ?",
) { ps ->
ps.setTimestamp(1, nextAttemptAt.toSqlTimestamp())
ps.setInt(2, attempts)
ps.setLong(3, eventId)
}
}
override fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int?) {
ds.update(
"""
UPDATE msg_event SET state = 'DEAD', error_class = ?, last_error = ?,
attempts = COALESCE(?, attempts)
WHERE event_id = ?
""".trimIndent(),
) { ps ->
ps.setString(1, errorClass.name)
ps.setString(2, lastError)
if (attempts != null) ps.setInt(3, attempts) else ps.setNull(3, java.sql.Types.INTEGER)
ps.setLong(4, eventId)
}
}
override fun insertSync(events: List<MsgEvent>) {
insertAll(events)
}
private fun mapEvent(rs: java.sql.ResultSet) = MsgEvent(
eventId = rs.getLong("event_id"),
target = rs.getString("target"),
partitionKey = rs.getString("partition_key"),
payloadJson = rs.getString("payload_json"),
state = EventStatus.valueOf(rs.getString("state")),
attempts = rs.getInt("attempts"),
nextAttemptAt = rs.getInstant("next_attempt_at"),
errorClass = rs.getString("error_class")?.let(ErrorClass::valueOf),
lastError = rs.getString("last_error"),
createdAt = rs.getInstant("created_at") ?: Instant.now(),
)
}
@Singleton
@Requires(property = "msgx.stubs", notEquals = "true")
@Requires(property = "datasources.default.enabled", value = "true")
class JdbcPumpJobRepository(
private val ds: DataSource,
) : PumpJobRepository {
override fun enqueue(kind: String) {
val now = Instant.now()
ds.update(
"INSERT INTO pump_job (kind, state, created_at, updated_at) VALUES (?, 'QUEUED', ?, ?)",
) { ps ->
ps.setString(1, kind)
ps.setTimestamp(2, now.toSqlTimestamp())
ps.setTimestamp(3, now.toSqlTimestamp())
}
}
override fun headQueued(): PumpJobRepository.Job? =
ds.queryOne(
"SELECT job_id, kind FROM pump_job WHERE state = 'QUEUED' ORDER BY job_id ASC LIMIT 1",
{},
) { rs -> PumpJobRepository.Job(rs.getLong("job_id"), rs.getString("kind")) }
override fun markRunning(jobId: Long) = markState(jobId, "RUNNING", null)
override fun markDone(jobId: Long) = markState(jobId, "DONE", null)
override fun markFailed(jobId: Long, lastError: String) = markState(jobId, "FAILED", lastError)
private fun markState(jobId: Long, state: String, lastError: String?) {
ds.update(
"UPDATE pump_job SET state = ?, updated_at = ?, last_error = ? WHERE job_id = ?",
) { ps ->
ps.setString(1, state)
ps.setTimestamp(2, Instant.now().toSqlTimestamp())
ps.setString(3, lastError)
ps.setLong(4, jobId)
}
}
}
/** gen→Redis 过渡占位:U09 前进程内 CAS(与 StubRefData 同语义)。 */
@Singleton
@Requires(property = "msgx.stubs", notEquals = "true")
@Requires(property = "datasources.default.enabled", value = "true")
class JdbcRefDataRepository : RefDataRepository {
private val gens = mutableMapOf<String, RefDataRepository.GenMeta>()
override fun getGen(day: String): RefDataRepository.GenMeta? = gens[day]
override fun putGenIfVersion(day: String, expected: Long, new: RefDataRepository.GenMeta): Boolean {
val cur = gens[day]?.version ?: 0L
if (cur != expected) return false
gens[day] = new
return true
}
}
@Singleton
@Requires(property = "msgx.stubs", notEquals = "true")
@Requires(property = "datasources.default.enabled", value = "true")
class JdbcStaticRefRepository(
private val ds: DataSource,
) : StaticRefRepository {
override fun upsertAll(refs: List<RefUpsert>) {
val now = Instant.now()
refs.forEach { r ->
ds.update(
"""
INSERT INTO ref_master (rtype, rkey, payload_json, source, refreshed_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (rtype, rkey) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
source = EXCLUDED.source,
refreshed_at = EXCLUDED.refreshed_at
""".trimIndent(),
) { ps ->
ps.setString(1, r.rtype)
ps.setString(2, r.rkey)
ps.setString(3, r.payloadJson)
ps.setString(4, r.source)
ps.setTimestamp(5, now.toSqlTimestamp())
}
}
}
override fun findByType(type: String): List<RefUpsert> =
ds.query(
"SELECT rtype, rkey, payload_json, source FROM ref_master WHERE rtype = ?",
{ ps -> ps.setString(1, type) },
) { rs ->
RefUpsert(
rtype = rs.getString("rtype"),
rkey = rs.getString("rkey"),
payloadJson = rs.getString("payload_json"),
source = rs.getString("source"),
)
}
}
@Singleton
@Requires(property = "msgx.stubs", notEquals = "true")
@Requires(property = "datasources.default.enabled", value = "true")
class JdbcReqTrackRepository(
private val ds: DataSource,
) : ReqTrackRepository {
override fun findOpenByKind(kind: String): ReqTrackRepository.Req? =
ds.queryOne(
"""
SELECT req_id, req_type, state, sent_at FROM req_track
WHERE req_type = ? AND state IN ('REGISTERED', 'SENT', 'WAITING')
ORDER BY req_id ASC LIMIT 1
""".trimIndent(),
{ ps -> ps.setString(1, kind) },
) { rs ->
ReqTrackRepository.Req(
reqId = rs.getLong("req_id"),
reqType = rs.getString("req_type"),
state = rs.getString("state"),
sentAt = rs.getInstant("sent_at"),
)
}
override fun forceExpireOpenOf(kind: String) {
ds.update(
"""
UPDATE req_track SET state = 'EXPIRED'
WHERE req_type = ? AND state IN ('REGISTERED', 'SENT', 'WAITING')
""".trimIndent(),
) { ps -> ps.setString(1, kind) }
}
override fun insert(kind: String, paramsJson: String): Long =
ds.updateReturningLong(
"INSERT INTO req_track (req_type, params_json, state) VALUES (?, ?, 'REGISTERED')",
) { ps ->
ps.setString(1, kind)
ps.setString(2, paramsJson)
}
override fun linkCoutmsgs(reqId: Long, coutmsgsId: Long) {
ds.update("UPDATE req_track SET coutmsgs_id = ? WHERE req_id = ?") { ps ->
ps.setLong(1, coutmsgsId)
ps.setLong(2, reqId)
}
}
override fun markSent(reqId: Long, sentAt: Instant) {
ds.update("UPDATE req_track SET state = 'SENT', sent_at = ? WHERE req_id = ?") { ps ->
ps.setTimestamp(1, sentAt.toSqlTimestamp())
ps.setLong(2, reqId)
}
}
override fun expireIfWaiting(reqId: Long) {
ds.update("UPDATE req_track SET state = 'EXPIRED' WHERE req_id = ? AND state = 'WAITING'") { ps ->
ps.setLong(1, reqId)
}
}
override fun markDone(reqId: Long) {
ds.update(
"UPDATE req_track SET state = 'DONE', completed_at = ? WHERE req_id = ?",
) { ps ->
ps.setTimestamp(1, Instant.now().toSqlTimestamp())
ps.setLong(2, reqId)
}
}
}
@Singleton
@Requires(property = "msgx.stubs", notEquals = "true")
@Requires(property = "datasources.default.enabled", value = "true")
class JdbcFlightStateRepository : FlightStateRepository {
override fun replaceDay(day: String, flights: List<Pair<String, String>>) = Unit
override fun findByDay(day: String): List<Pair<String, String>> = emptyList()
}
@@ -0,0 +1,30 @@
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.micronaut.context.annotation.Factory
import io.micronaut.context.annotation.Requires
import jakarta.inject.Named
import jakarta.inject.Singleton
import javax.sql.DataSource
@Factory
class MailboxDataSourceFactory {
@Singleton
@Named("mailbox")
@Requires(property = "msgx.stubs", notEquals = "true")
@Requires(property = "mailbox.shared-mysql.enabled", value = "true")
fun mailboxDataSource(props: com.gzzn.omms.msgexchange.config.MailboxProps): DataSource {
val cfg = props.sharedMysql
val hikari = HikariConfig().apply {
jdbcUrl = cfg.url
username = cfg.username
password = cfg.password
driverClassName = cfg.driverClassName
maximumPoolSize = 5
poolName = "mailbox"
}
return HikariDataSource(hikari)
}
}
@@ -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 脚本装载与执行入口
@@ -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 jakarta.inject.Singleton
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.nextgen.domain.ProcState
import com.gzzn.omms.msgexchange.nextgen.domain.ProcStatus
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import jakarta.inject.Singleton
/**
@@ -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.nextgen.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
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.nextgen.codec.DecodeResult
import com.gzzn.omms.msgexchange.nextgen.codec.XmlCodec
import com.gzzn.omms.msgexchange.nextgen.delivery.DeliveryPort
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.nextgen.infra.redis.RedisScript
import com.gzzn.omms.msgexchange.nextgen.processing.CodecHolder
import com.gzzn.omms.msgexchange.nextgen.processing.HandlerHolder
import com.gzzn.omms.msgexchange.nextgen.processing.HandlerRegistry
import com.gzzn.omms.msgexchange.codec.DecodeFailure
import com.gzzn.omms.msgexchange.codec.DecodeResult
import com.gzzn.omms.msgexchange.codec.XmlCodec
import com.gzzn.omms.msgexchange.delivery.DeliveryPort
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.infra.redis.RedisScript
import com.gzzn.omms.msgexchange.processing.CodecHolder
import com.gzzn.omms.msgexchange.processing.HandlerHolder
import com.gzzn.omms.msgexchange.processing.HandlerRegistry
import io.micronaut.context.annotation.Bean
import io.micronaut.context.annotation.Factory
import io.micronaut.context.annotation.Requires
@@ -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.nextgen.domain.EventStatus
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.ProcState
import com.gzzn.omms.msgexchange.nextgen.domain.ProcStatus
import com.gzzn.omms.msgexchange.nextgen.domain.RefUpsert
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.RefDataRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.StaticRefRepository
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.EventStatus
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.RefUpsert
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.infra.persistence.RefDataRepository
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.infra.persistence.StaticRefRepository
import io.micronaut.context.annotation.Requires
import jakarta.inject.Singleton
import java.time.Instant
@@ -36,6 +36,8 @@ class StubProcState : ProcStateRepository {
rows[cminmsgsId] = ProcState(cminmsgsId, state)
}
override fun exists(cminmsgsId: Long): Boolean = rows.containsKey(cminmsgsId)
override fun headUnfinished(): ProcState? =
rows.filterValues { it.state == ProcStatus.PENDING || it.state == ProcStatus.FAILED }
.minByOrNull { it.key }?.value
@@ -85,10 +87,21 @@ class StubProcState : ProcStateRepository {
@Singleton
class StubInbox : CminmsgInboxRepository {
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)
/** 模拟上游外部写信箱(不经 compat HTTP、不自动入队 PG)。 */
fun simulateExternalWrite(rawXml: String): Long {
val id = ids.incrementAndGet()
raws[id] = rawXml
return id
}
override fun insertRaw(rawXml: String): Long {
val id = ids.incrementAndGet()
raws[id] = rawXml
@@ -97,8 +110,14 @@ class StubInbox : CminmsgInboxRepository {
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) {
// 内存 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.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.nextgen.infra.redis.RedisScript
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.Targets
import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.infra.redis.RedisScript
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.Targets
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.nextgen.domain.Decision
import com.gzzn.omms.msgexchange.nextgen.domain.MsgKind
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.Decision
import com.gzzn.omms.msgexchange.domain.MsgKind
/**
* 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.nextgen.domain.DecodedMessage
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage
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.nextgen.domain.DecodedMessage
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.ProcState
import com.gzzn.omms.msgexchange.nextgen.domain.ProcStatus
import com.gzzn.omms.msgexchange.nextgen.domain.Targets
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.nextgen.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.nextgen.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.nextgen.jobs.JobExecutor
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.ErrorClass
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.Targets
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
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.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.jobs.JobExecutor
import jakarta.inject.Singleton
import java.time.Duration
import java.time.Instant
@@ -122,7 +122,7 @@ class MessageProcessor(
private val log = org.slf4j.LoggerFactory.getLogger(MessageProcessor::class.java)
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 {
processInternal(head)
} catch (e: InterruptedException) {
@@ -151,8 +151,8 @@ class MessageProcessor(
return
}
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.nextgen.codec.DecodeResult.Err -> {
is com.gzzn.omms.msgexchange.codec.DecodeResult.Ok -> r.message
is com.gzzn.omms.msgexchange.codec.DecodeResult.Err -> {
// T06U11):MALFORMED(报文非法)→ DEAD 不重试;CODEC_ERROR(可随 codec 修复重放)→ FAILED 退避
if (r.failure.errorClass == ErrorClass.MALFORMED) {
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}")
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 &&
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)
return
@@ -214,5 +214,5 @@ class MessageProcessor(
}
/** 延迟装配占位(阶段 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)
@@ -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.nextgen.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.domain.ProcState
import com.gzzn.omms.msgexchange.nextgen.domain.ProcStatus
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.RefDataRepository
import com.gzzn.omms.msgexchange.nextgen.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.nextgen.infra.redis.RedisScript
import com.gzzn.omms.msgexchange.nextgen.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.RefDataRepository
import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.infra.redis.RedisScript
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
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
/**
@@ -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.nextgen.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.StaticRefRepository
import com.gzzn.omms.msgexchange.domain.RefUpsert
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.infra.persistence.StaticRefRepository
import jakarta.inject.Singleton
import java.time.Instant
+1 -1
View File
@@ -32,5 +32,5 @@
<appender-ref ref="ASYNC_LOGSTASH"/>
</root>
<logger name="com.gzzn.omms.msgexchange.nextgen" level="DEBUG"/>
<logger name="com.gzzn.omms.msgexchange" level="DEBUG"/>
</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.nextgen.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.ProcStatus
import com.gzzn.omms.msgexchange.nextgen.domain.Targets
import com.gzzn.omms.msgexchange.nextgen.infra.stub.StubDeliveryPort
import com.gzzn.omms.msgexchange.nextgen.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.nextgen.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.nextgen.ingress.InboxController
import com.gzzn.omms.msgexchange.nextgen.processing.Pump
import com.gzzn.omms.msgexchange.delivery.Dispatcher
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.infra.stub.StubDeliveryPort
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.ingress.InboxController
import com.gzzn.omms.msgexchange.processing.Pump
import io.micronaut.context.ApplicationContext
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import jakarta.inject.Inject
@@ -22,7 +22,7 @@ import org.junit.jupiter.api.Test
* U07 端到端stub 装配等价 /beans 核验 MySQL/Redis/Kafka
* 核心 bean 装配齐全compat HTTP PG 入队主泵领取解码未实装FAILED(CODEC_ERROR)+退避
* U11 重放把 FAILED 拉回 PENDINGDispatcher flushSchd 聚合发出 schd
* 生产主路径 JDBC 轮询 InboxPoller U05本测未覆盖
* 生产主路径 JDBC 轮询 InboxPollercompat HTTP 写路径见 InboxService
* 后台循环关闭autostart=false按需手动 tick避免测试泄漏线程
*/
@MicronautTest
@@ -44,14 +44,14 @@ class PipelineSmokeTest {
@org.junit.jupiter.api.BeforeEach
fun cleanStubs() {
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(StubDeliveryPort::class.java).clear()
}
@Test
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(Dispatcher::class.java).isNotEmpty())
assertNotNull(controller)
@@ -82,7 +82,7 @@ class PipelineSmokeTest {
val stub = ctx.getBean(StubProcState::class.java)
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))
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.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 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.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.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.nextgen.config.PipelineProps
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.domain.EventStatus
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.Targets
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.nextgen.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.config.PipelineProps
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.Targets
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
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.nextgen.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.Targets
import com.gzzn.omms.msgexchange.domain.EventStatus
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.Targets
import org.junit.jupiter.api.Test
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.nextgen.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.nextgen.infra.redis.RedisScript
import com.gzzn.omms.msgexchange.delivery.DeliveryPort
import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.infra.redis.RedisScript
import io.micronaut.health.HealthStatus
import org.junit.jupiter.api.Assertions.assertEquals
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.nextgen.domain.ProcState
import com.gzzn.omms.msgexchange.nextgen.domain.ProcStatus
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
@@ -24,6 +24,7 @@ class ReplayServiceTest {
}
override fun insert(cminmsgsId: Long, state: ProcStatus) = Unit
override fun exists(cminmsgsId: Long): Boolean = rows.containsKey(cminmsgsId)
override fun headUnfinished(): ProcState? = null
override fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean = true
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.nextgen.domain.DecodedMessage
import com.gzzn.omms.msgexchange.nextgen.domain.MetaFields
import com.gzzn.omms.msgexchange.nextgen.domain.MsgKind
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 org.junit.jupiter.api.Test
import java.time.LocalDate
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.nextgen.codec.DecodeFailure
import com.gzzn.omms.msgexchange.nextgen.codec.DecodeResult
import com.gzzn.omms.msgexchange.nextgen.codec.XmlCodec
import com.gzzn.omms.msgexchange.nextgen.config.PipelineProps
import com.gzzn.omms.msgexchange.nextgen.domain.DecodedMessage
import com.gzzn.omms.msgexchange.nextgen.domain.Decision
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.domain.EventStatus
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.MsgKind
import com.gzzn.omms.msgexchange.nextgen.domain.MetaFields
import com.gzzn.omms.msgexchange.nextgen.domain.NotifyPayload
import com.gzzn.omms.msgexchange.nextgen.domain.ProcState
import com.gzzn.omms.msgexchange.nextgen.domain.ProcStatus
import com.gzzn.omms.msgexchange.nextgen.domain.SchdPush
import com.gzzn.omms.msgexchange.nextgen.domain.Targets
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.RefDataRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.nextgen.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.nextgen.infra.redis.RedisScript
import com.gzzn.omms.msgexchange.nextgen.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.nextgen.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.codec.DecodeFailure
import com.gzzn.omms.msgexchange.codec.DecodeResult
import com.gzzn.omms.msgexchange.codec.XmlCodec
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.Decision
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.MsgKind
import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.domain.NotifyPayload
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.SchdPush
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
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.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.infra.redis.RedisScript
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
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 exists(cminmsgsId: Long): Boolean = record.containsKey(cminmsgsId)
override fun headUnfinished(): ProcState? = null
override fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean {
@@ -99,6 +101,8 @@ class MessageProcessorTest {
override fun insertRaw(rawXml: String): Long = 0
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
override fun rawOf(cminmsgsId: Long): String? {
throwOnRawOf?.let { throw it }
return raws[cminmsgsId]