af6cfdaa83
- 新增 daemon/channel/assistant/function/scheduler/logging worker 架构和 JetStream 消息协议 - 修复邮件通知误入 assistant、迁移 checksum、日志 subject 和工具调用无限循环风险 - 新增 assistant 工具轮次熔断、工具结果截断和提示词软收敛规则 - 补充邮件推送关键字、可信发件人配置、升级日志和验证测试
257 lines
9.3 KiB
Rust
257 lines
9.3 KiB
Rust
use ias_common::mq::{
|
||
ConsumerSpec, DeadLetterPayload, InboundPayload, JetStreamBus, JetStreamConfig,
|
||
MessageEnvelope, StreamKind, StreamNames, inbound_subject,
|
||
};
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires a running NATS JetStream server from docker-compose.nats.yml"]
|
||
async fn jetstream_publish_and_ack_roundtrip() -> anyhow::Result<()> {
|
||
let config = JetStreamConfig::from_env();
|
||
let bus = JetStreamBus::connect(config.clone()).await?;
|
||
bus.ensure_streams().await?;
|
||
let subject = inbound_subject("wechat", "integration");
|
||
let payload = InboundPayload {
|
||
channel: "wechat".to_string(),
|
||
account_id: "integration".to_string(),
|
||
from_user_id: "tester".to_string(),
|
||
content: "ping".to_string(),
|
||
context: None,
|
||
external_message_id: Some("integration-msg-1".to_string()),
|
||
session_id: None,
|
||
group_id: None,
|
||
create_time_ms: None,
|
||
};
|
||
let envelope = MessageEnvelope::new(
|
||
subject.as_str(),
|
||
payload.idempotency_key(),
|
||
"trace-integration",
|
||
payload,
|
||
);
|
||
bus.publish_envelope(subject.as_str(), &envelope).await?;
|
||
|
||
let names = StreamNames::new(config.stream_prefix.clone());
|
||
let spec = ConsumerSpec {
|
||
stream: names.name(StreamKind::Inbound),
|
||
durable_name: "integration-ack-test".to_string(),
|
||
filter_subject: subject.to_string(),
|
||
ack_wait: config.ack_wait(),
|
||
max_deliver: config.max_deliver,
|
||
};
|
||
let (tx, mut rx) = tokio::sync::mpsc::channel(1);
|
||
let consumer_bus = bus.clone();
|
||
let handle = tokio::spawn(async move {
|
||
consumer_bus
|
||
.consume_envelopes::<InboundPayload, _, _>(spec, move |message| {
|
||
let tx = tx.clone();
|
||
async move {
|
||
tx.send(message.message_id).await?;
|
||
Ok(())
|
||
}
|
||
})
|
||
.await
|
||
});
|
||
|
||
let message_id = tokio::time::timeout(std::time::Duration::from_secs(10), rx.recv())
|
||
.await?
|
||
.ok_or_else(|| anyhow::anyhow!("consumer closed before receiving test message"))?;
|
||
handle.abort();
|
||
|
||
assert_eq!(message_id, "wechat/integration/integration-msg-1");
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires a running NATS JetStream server from docker-compose.nats.yml"]
|
||
async fn jetstream_redelivery_reaches_dlq_after_max_deliver() -> anyhow::Result<()> {
|
||
let mut config = JetStreamConfig::from_env();
|
||
config.ack_wait_secs = 1;
|
||
config.max_deliver = 2;
|
||
let bus = JetStreamBus::connect(config.clone()).await?;
|
||
bus.ensure_streams().await?;
|
||
let names = StreamNames::new(config.stream_prefix.clone());
|
||
let unique = uuid::Uuid::new_v4().to_string();
|
||
let account_id = format!("dlq-{unique}");
|
||
let subject = inbound_subject("wechat", &account_id);
|
||
let payload = InboundPayload {
|
||
channel: "wechat".to_string(),
|
||
account_id: account_id.clone(),
|
||
from_user_id: "tester".to_string(),
|
||
content: "force retry".to_string(),
|
||
context: None,
|
||
external_message_id: Some(unique.clone()),
|
||
session_id: None,
|
||
group_id: None,
|
||
create_time_ms: None,
|
||
};
|
||
let message_id = format!("wechat/{account_id}/{unique}");
|
||
let envelope = MessageEnvelope::new(
|
||
subject.as_str(),
|
||
message_id.clone(),
|
||
format!("trace-{unique}"),
|
||
payload,
|
||
);
|
||
|
||
let (dlq_tx, mut dlq_rx) = tokio::sync::mpsc::channel(1);
|
||
let dlq_bus = bus.clone();
|
||
let dlq_stream = names.name(StreamKind::Dlq);
|
||
let dlq_ack_wait = config.ack_wait();
|
||
let dlq_max_deliver = config.max_deliver;
|
||
let dlq_handle = tokio::spawn(async move {
|
||
dlq_bus
|
||
.consume_envelopes::<DeadLetterPayload, _, _>(
|
||
ConsumerSpec {
|
||
stream: dlq_stream,
|
||
durable_name: format!("integration-dlq-{unique}"),
|
||
filter_subject: "dlq.>".to_string(),
|
||
ack_wait: dlq_ack_wait,
|
||
max_deliver: dlq_max_deliver,
|
||
},
|
||
move |message| {
|
||
let dlq_tx = dlq_tx.clone();
|
||
let expected = message_id.clone();
|
||
async move {
|
||
if message.payload.original_message_id.as_deref() == Some(&expected) {
|
||
dlq_tx.send(message.payload.original_message_id).await?;
|
||
}
|
||
Ok(())
|
||
}
|
||
},
|
||
)
|
||
.await
|
||
});
|
||
|
||
let retry_bus = bus.clone();
|
||
let retry_spec = ConsumerSpec {
|
||
stream: StreamNames::new(config.stream_prefix.clone()).name(StreamKind::Inbound),
|
||
durable_name: format!("integration-redelivery-{account_id}"),
|
||
filter_subject: subject.to_string(),
|
||
ack_wait: config.ack_wait(),
|
||
max_deliver: config.max_deliver,
|
||
};
|
||
let retry_handle = tokio::spawn(async move {
|
||
retry_bus
|
||
.consume_envelopes::<InboundPayload, _, _>(retry_spec, |_message| async move {
|
||
anyhow::bail!("force redelivery")
|
||
})
|
||
.await
|
||
});
|
||
|
||
bus.publish_envelope(subject.as_str(), &envelope).await?;
|
||
let observed = tokio::time::timeout(std::time::Duration::from_secs(20), dlq_rx.recv())
|
||
.await?
|
||
.ok_or_else(|| anyhow::anyhow!("dlq consumer closed before receiving test message"))?;
|
||
retry_handle.abort();
|
||
dlq_handle.abort();
|
||
|
||
assert_eq!(observed.as_deref(), Some(envelope.message_id.as_str()));
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires a running NATS JetStream server from docker-compose.nats.yml"]
|
||
async fn jetstream_concurrent_dlq_does_not_lose_messages() -> anyhow::Result<()> {
|
||
let mut config = JetStreamConfig::from_env();
|
||
config.ack_wait_secs = 1;
|
||
config.max_deliver = 2;
|
||
config.consumer_concurrency = 4;
|
||
let bus = JetStreamBus::connect(config.clone()).await?;
|
||
bus.ensure_streams().await?;
|
||
let names = StreamNames::new(config.stream_prefix.clone());
|
||
let batch = uuid::Uuid::new_v4().to_string();
|
||
let count = 8usize;
|
||
let account_id = format!("cdlq-{batch}");
|
||
|
||
// 发布 count 条不同 message_id 的消息
|
||
let mut message_ids = Vec::new();
|
||
for index in 0..count {
|
||
let external = format!("{batch}-{index}");
|
||
let subject = inbound_subject("wechat", &account_id);
|
||
let payload = InboundPayload {
|
||
channel: "wechat".to_string(),
|
||
account_id: account_id.clone(),
|
||
from_user_id: "tester".to_string(),
|
||
content: "force retry".to_string(),
|
||
context: None,
|
||
external_message_id: Some(external.clone()),
|
||
session_id: None,
|
||
group_id: None,
|
||
create_time_ms: None,
|
||
};
|
||
let message_id = format!("wechat/{account_id}/{external}");
|
||
message_ids.push(message_id.clone());
|
||
let envelope = MessageEnvelope::new(
|
||
subject.as_str(),
|
||
message_id,
|
||
format!("trace-{batch}-{index}"),
|
||
payload,
|
||
);
|
||
bus.publish_envelope(subject.as_str(), &envelope).await?;
|
||
}
|
||
|
||
// DLQ consumer 收集 original_message_id
|
||
let (dlq_tx, mut dlq_rx) = tokio::sync::mpsc::channel(count);
|
||
let dlq_bus = bus.clone();
|
||
let dlq_stream = names.name(StreamKind::Dlq);
|
||
let dlq_ack_wait = config.ack_wait();
|
||
let dlq_max_deliver = config.max_deliver;
|
||
let dlq_durable = format!("integration-cdlq-{batch}");
|
||
let dlq_handle = tokio::spawn(async move {
|
||
dlq_bus
|
||
.consume_envelopes::<DeadLetterPayload, _, _>(
|
||
ConsumerSpec {
|
||
stream: dlq_stream,
|
||
durable_name: dlq_durable,
|
||
filter_subject: "dlq.>".to_string(),
|
||
ack_wait: dlq_ack_wait,
|
||
max_deliver: dlq_max_deliver,
|
||
},
|
||
move |message| {
|
||
let dlq_tx = dlq_tx.clone();
|
||
async move {
|
||
let _ = dlq_tx.send(message.payload.original_message_id).await;
|
||
Ok(())
|
||
}
|
||
},
|
||
)
|
||
.await
|
||
});
|
||
|
||
// 失败 consumer(总是失败,触发 redelivery -> DLQ)
|
||
let retry_bus = bus.clone();
|
||
let retry_spec = ConsumerSpec {
|
||
stream: names.name(StreamKind::Inbound),
|
||
durable_name: format!("integration-cdlq-retry-{batch}"),
|
||
filter_subject: format!("inbound.wechat.{account_id}"),
|
||
ack_wait: config.ack_wait(),
|
||
max_deliver: config.max_deliver,
|
||
};
|
||
let retry_handle = tokio::spawn(async move {
|
||
retry_bus
|
||
.consume_envelopes::<InboundPayload, _, _>(retry_spec, |_| async move {
|
||
anyhow::bail!("force redelivery")
|
||
})
|
||
.await
|
||
});
|
||
|
||
// 收集 count 条 DLQ 消息,验证无碰撞丢失
|
||
let mut received = Vec::new();
|
||
for _ in 0..count {
|
||
let id = tokio::time::timeout(std::time::Duration::from_secs(30), dlq_rx.recv())
|
||
.await?
|
||
.ok_or_else(|| anyhow::anyhow!("dlq consumer closed early"))?;
|
||
if let Some(id) = id {
|
||
received.push(id);
|
||
}
|
||
}
|
||
retry_handle.abort();
|
||
dlq_handle.abort();
|
||
|
||
received.sort();
|
||
message_ids.sort();
|
||
assert_eq!(received.len(), count, "DLQ 丢失了消息");
|
||
for id in &message_ids {
|
||
assert!(received.contains(id), "DLQ 缺少消息 {id}");
|
||
}
|
||
Ok(())
|
||
}
|