Refactor Kafka publishing without DB outbox
This commit is contained in:
+6
-14
@@ -722,14 +722,14 @@ IM Server 已接入 `im.message.persist`,用于把在线发送链路和最终
|
|||||||
|
|
||||||
当前处理方式:
|
当前处理方式:
|
||||||
|
|
||||||
1. WS/HTTP 发送入口先同步完成权限校验、业务幂等、`seq_id` 分配和 Topic 游标占用。
|
1. WS/HTTP 发送入口先完成权限校验,并由 topic 内存态分配 `seq_id`。
|
||||||
2. Kafka 开启时,发送入口先写租户库 `im_kafka_outbox`,由 Outbox worker 可靠生产 `im.message.persist`,客户端仍立即拿到已分配的 `seq_id`。
|
2. Kafka 开启时,发送入口直接生产 `im.message.persist`,不查库、不落库、不写 DB outbox;客户端在 Kafka 写入成功后拿到已分配的 `seq_id`。
|
||||||
3. Kafka 关闭时,继续走原同步落库路径,兼容本地和未接 Kafka 环境。
|
3. Kafka 关闭时,继续走原同步落库路径,兼容本地和未接 Kafka 环境。
|
||||||
4. 消费者按 `message_kind` 区分私聊与群聊:
|
4. 消费者按 `message_kind` 区分私聊与群聊:
|
||||||
- `p2p` 写入 `im_messages`。
|
- `p2p` 写入 `im_messages`。
|
||||||
- `group` 写入 `im_group_messages`。
|
- `group` 写入 `im_group_messages`。
|
||||||
5. 消费侧复用 `im_kafka_consume_log` 做 `event_id + consumer_group` 幂等。
|
5. 消费侧复用 `im_kafka_consume_log` 做 `event_id + consumer_group` 幂等。
|
||||||
6. 失败按统一退避重试,超过上限写入 `im_kafka_dead_letters` 并投递 `im.dead.letter`。
|
6. 失败按统一退避重试 3 次,超过上限先投递 `im.dead.letter`,再由 dead letter consumer 写入 `im_kafka_dead_letters`。
|
||||||
|
|
||||||
### 11.10 IM Server 已接入的缓存失效消费
|
### 11.10 IM Server 已接入的缓存失效消费
|
||||||
|
|
||||||
@@ -874,17 +874,9 @@ IM Server 已在 `im.admin.command` 中接入用户治理类后台动作。Admin
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 11.9 IM Server 已接入的 Kafka Outbox Worker
|
### 11.9 IM Server 已移除 Kafka DB Outbox
|
||||||
|
|
||||||
IM Server 已接入租户库 `im_kafka_outbox` 通用发送 worker,用于避免“业务库写成功但 Kafka 未发送”的事件丢失。
|
IM Server 不再维护租户库 `im_kafka_outbox`,Kafka 事件统一由发送入口直接生产。生产侧使用 Kafka writer 的 `max_retry_count` 控制发送重试;消费侧使用 `im_kafka_consume_log` 做处理幂等和失败重试,超过上限后进入 `im.dead.letter`。
|
||||||
|
|
||||||
当前能力:
|
|
||||||
|
|
||||||
1. 启动后按 active 租户扫描每个租户库的 `im_kafka_outbox`。
|
|
||||||
2. 扫描 `pending/failed` 且已到 `next_retry_at` 的事件。
|
|
||||||
3. 发送成功后标记 `sent`、写入 `sent_at`。
|
|
||||||
4. 发送失败后标记 `failed`,按 Kafka 配置的 `retry_backoff_ms` 写入下一次重试时间。
|
|
||||||
5. 首个接入方为 `im.message.persist`,后续文件生命周期、工作台事件、审计事件可复用同一 outbox。
|
|
||||||
|
|
||||||
本轮暂不实现 `im.message.push` 离线 Push Kafka 化,已记录到 [IM 第一至十一章后续增强项](./IM-未实现后续增强项.md)。
|
本轮暂不实现 `im.message.push` 离线 Push Kafka 化,已记录到 [IM 第一至十一章后续增强项](./IM-未实现后续增强项.md)。
|
||||||
|
|
||||||
@@ -930,7 +922,7 @@ IM Server 已接入 `im.workbench.event` 的首批生产与消费。
|
|||||||
|
|
||||||
### 11.12 IM Server 已接入的审计事件
|
### 11.12 IM Server 已接入的审计事件
|
||||||
|
|
||||||
IM Server 已接入 `im.audit.event` 的可靠生产,审计事件写入租户库 `im_kafka_outbox` 后由 Kafka Outbox worker 发送。
|
IM Server 已接入 `im.audit.event` 的直接生产,审计事件不写 DB outbox。
|
||||||
|
|
||||||
当前审计范围:
|
当前审计范围:
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ SOURCE docs/sql/IM-Kafka基础表SQL.sql;
|
|||||||
| 表 | 用途 |
|
| 表 | 用途 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `im_kafka_consume_log` | 消费幂等、重试状态。 |
|
| `im_kafka_consume_log` | 消费幂等、重试状态。 |
|
||||||
| `im_kafka_outbox` | IM 侧可靠生产 Kafka 事件。 |
|
|
||||||
| `im_kafka_dead_letters` | 死信记录与人工处理状态。 |
|
| `im_kafka_dead_letters` | 死信记录与人工处理状态。 |
|
||||||
| `im_messages` | 私聊消息存储。 |
|
| `im_messages` | 私聊消息存储。 |
|
||||||
| `im_group_messages` | 群聊消息存储。 |
|
| `im_group_messages` | 群聊消息存储。 |
|
||||||
@@ -886,15 +885,7 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id}
|
|||||||
|
|
||||||
### TC-KAFKA-030 Outbox 可靠投递
|
### TC-KAFKA-030 Outbox 可靠投递
|
||||||
|
|
||||||
目标:验证 IM 侧 Kafka Outbox 防止业务成功但 Kafka 丢事件。
|
目标:验证 IM 侧 Kafka 直接生产失败时不会产生 DB outbox,调用方收到发送失败。
|
||||||
|
|
||||||
步骤:
|
|
||||||
|
|
||||||
1. 临时停止 Kafka。
|
|
||||||
2. 执行会产生 outbox 的操作,如发送消息、提交工作台事件。
|
|
||||||
3. 查询 `im_kafka_outbox`。
|
|
||||||
4. 恢复 Kafka。
|
|
||||||
5. 等待 outbox worker 投递。
|
|
||||||
|
|
||||||
预期:
|
预期:
|
||||||
|
|
||||||
@@ -958,7 +949,7 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id}
|
|||||||
| 进入死信 | 查 `im_kafka_dead_letters.envelope/last_error`,修复后用重放命令。 |
|
| 进入死信 | 查 `im_kafka_dead_letters.envelope/last_error`,修复后用重放命令。 |
|
||||||
| 用户没有被踢下线 | 查目标 `user_id` 是否是 IM 用户 ID;是否连接在当前 IM 节点;跨节点踢人是否需要后续 Redis fanout 扩展。 |
|
| 用户没有被踢下线 | 查目标 `user_id` 是否是 IM 用户 ID;是否连接在当前 IM 节点;跨节点踢人是否需要后续 Redis fanout 扩展。 |
|
||||||
| 群治理未生效 | 查 `topic/version/actor_user_id` 是否正确,目标用户是否为群成员。 |
|
| 群治理未生效 | 查 `topic/version/actor_user_id` 是否正确,目标用户是否为群成员。 |
|
||||||
| 消息未落库 | 查 `im_kafka_outbox` 是否 sent,`im.message.persist` 是否消费成功。 |
|
| 消息未落库 | 查 `im.message.persist` 是否消费成功,以及 `im_kafka_consume_log.last_reason/last_error`。 |
|
||||||
| 附件未绑定 | 查消息是否带附件、`im.file.lifecycle` 是否消费成功。 |
|
| 附件未绑定 | 查消息是否带附件、`im.file.lifecycle` 是否消费成功。 |
|
||||||
| 内部 API 401 | 查 secret、签名文本、RequestURI、timestamp、nonce 是否一致。 |
|
| 内部 API 401 | 查 secret、签名文本、RequestURI、timestamp、nonce 是否一致。 |
|
||||||
| 配置没有刷新 | 查是否发送 `im.config.event` 或 `im.cache.invalidate`,IM 是否清理了对应 namespace。 |
|
| 配置没有刷新 | 查是否发送 `im.config.event` 或 `im.cache.invalidate`,IM 是否清理了对应 namespace。 |
|
||||||
@@ -1023,7 +1014,7 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id}
|
|||||||
| 文件生命周期 | 上传完成、附件绑定、群文件登记/删除、OSS 元数据和引用表。 |
|
| 文件生命周期 | 上传完成、附件绑定、群文件登记/删除、OSS 元数据和引用表。 |
|
||||||
| 工作台事件 | 会议、日报/月报、总经理信箱、红点/系统通知投递。 |
|
| 工作台事件 | 会议、日报/月报、总经理信箱、红点/系统通知投递。 |
|
||||||
| 死信闭环 | 死信查询、忽略、解决、重放,以及重放后二次成功/失败。 |
|
| 死信闭环 | 死信查询、忽略、解决、重放,以及重放后二次成功/失败。 |
|
||||||
| Outbox | Kafka 停止/恢复时 `im_kafka_outbox`、Admin outbox 的可靠投递。 |
|
| Kafka 生产失败 | Kafka 停止/恢复时,确认 IM 不写 `im_kafka_outbox`,接口/WS 返回发送失败,恢复后可重新发送。 |
|
||||||
| Admin 页面 | 操作单状态从 pending/running 到 succeeded/failed/dead 的页面联调。 |
|
| Admin 页面 | 操作单状态从 pending/running 到 succeeded/failed/dead 的页面联调。 |
|
||||||
|
|
||||||
### 10.2 2026-08-27 完整后端链路联调
|
### 10.2 2026-08-27 完整后端链路联调
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Remove the deprecated Kafka DB outbox table from a tenant database.
|
||||||
|
-- Run once per tenant business database after deploying the direct Kafka publish path.
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS im_kafka_outbox;
|
||||||
@@ -29,29 +29,6 @@ CREATE TABLE IF NOT EXISTS im_kafka_consume_log (
|
|||||||
INDEX idx_im_kafka_consume_log_topic(source_topic, id)
|
INDEX idx_im_kafka_consume_log_topic(source_topic, id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS im_kafka_outbox (
|
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
|
||||||
event_id VARCHAR(96) NOT NULL,
|
|
||||||
topic VARCHAR(128) NOT NULL,
|
|
||||||
event_type VARCHAR(96) NOT NULL,
|
|
||||||
biz_type VARCHAR(64) NOT NULL DEFAULT '',
|
|
||||||
biz_id VARCHAR(128) NOT NULL DEFAULT '',
|
|
||||||
key_value VARCHAR(256) NOT NULL DEFAULT '',
|
|
||||||
envelope JSON NOT NULL,
|
|
||||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
|
||||||
attempts INT NOT NULL DEFAULT 0,
|
|
||||||
next_retry_at DATETIME(3),
|
|
||||||
sent_at DATETIME(3),
|
|
||||||
last_reason VARCHAR(64) NOT NULL DEFAULT '',
|
|
||||||
last_error VARCHAR(1000) NOT NULL DEFAULT '',
|
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
|
||||||
PRIMARY KEY(id),
|
|
||||||
UNIQUE INDEX uk_im_kafka_outbox_event(event_id),
|
|
||||||
INDEX idx_im_kafka_outbox_pending(status, next_retry_at, id),
|
|
||||||
INDEX idx_im_kafka_outbox_biz(biz_type, biz_id)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS im_kafka_dead_letters (
|
CREATE TABLE IF NOT EXISTS im_kafka_dead_letters (
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
dead_id VARCHAR(96) NOT NULL,
|
dead_id VARCHAR(96) NOT NULL,
|
||||||
|
|||||||
@@ -263,10 +263,6 @@ type TenantHTTPInfrastructureAdapter interface {
|
|||||||
TenantKafkaDeadLetterGet(t.TenantID, string) (*t.KafkaDeadLetter, error)
|
TenantKafkaDeadLetterGet(t.TenantID, string) (*t.KafkaDeadLetter, error)
|
||||||
TenantKafkaDeadLetterMark(t.TenantID, string, string, string, string, time.Time) error
|
TenantKafkaDeadLetterMark(t.TenantID, string, string, string, string, time.Time) error
|
||||||
TenantKafkaDeadLetterMarkReplay(t.TenantID, string, string, string, time.Time) (*t.KafkaDeadLetter, error)
|
TenantKafkaDeadLetterMarkReplay(t.TenantID, string, string, string, time.Time) (*t.KafkaDeadLetter, error)
|
||||||
TenantKafkaOutboxCreate(t.TenantID, *t.KafkaOutboxRecord) error
|
|
||||||
TenantKafkaOutboxListPending(t.TenantID, int, time.Time) ([]t.KafkaOutboxRecord, error)
|
|
||||||
TenantKafkaOutboxMarkSent(t.TenantID, string, time.Time) error
|
|
||||||
TenantKafkaOutboxMarkFailed(t.TenantID, string, string, string, time.Time, time.Time) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TenantRegistrationInviteAdapter stores invitation definitions in the
|
// TenantRegistrationInviteAdapter stores invitation definitions in the
|
||||||
|
|||||||
@@ -44,20 +44,6 @@ func ensureIMHTTPInfrastructureTables(exec sqlExecutor) error {
|
|||||||
INDEX idx_im_kafka_consume_log_biz(biz_type, biz_id),
|
INDEX idx_im_kafka_consume_log_biz(biz_type, biz_id),
|
||||||
INDEX idx_im_kafka_consume_log_topic(source_topic, id)
|
INDEX idx_im_kafka_consume_log_topic(source_topic, id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||||
`CREATE TABLE IF NOT EXISTS im_kafka_outbox (
|
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT, event_id VARCHAR(96) NOT NULL,
|
|
||||||
topic VARCHAR(128) NOT NULL, event_type VARCHAR(96) NOT NULL,
|
|
||||||
biz_type VARCHAR(64) NOT NULL DEFAULT '', biz_id VARCHAR(128) NOT NULL DEFAULT '',
|
|
||||||
key_value VARCHAR(256) NOT NULL DEFAULT '', envelope JSON NOT NULL,
|
|
||||||
status VARCHAR(16) NOT NULL DEFAULT 'pending', attempts INT NOT NULL DEFAULT 0,
|
|
||||||
next_retry_at DATETIME(3), sent_at DATETIME(3), last_reason VARCHAR(64) NOT NULL DEFAULT '',
|
|
||||||
last_error VARCHAR(1000) NOT NULL DEFAULT '',
|
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
|
||||||
PRIMARY KEY(id), UNIQUE INDEX uk_im_kafka_outbox_event(event_id),
|
|
||||||
INDEX idx_im_kafka_outbox_pending(status, next_retry_at, id),
|
|
||||||
INDEX idx_im_kafka_outbox_biz(biz_type, biz_id)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
|
||||||
`CREATE TABLE IF NOT EXISTS im_kafka_dead_letters (
|
`CREATE TABLE IF NOT EXISTS im_kafka_dead_letters (
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT, dead_id VARCHAR(96) NOT NULL,
|
id BIGINT NOT NULL AUTO_INCREMENT, dead_id VARCHAR(96) NOT NULL,
|
||||||
event_id VARCHAR(96) NOT NULL, parent_event_id VARCHAR(96) NOT NULL DEFAULT '',
|
event_id VARCHAR(96) NOT NULL, parent_event_id VARCHAR(96) NOT NULL DEFAULT '',
|
||||||
|
|||||||
@@ -12,15 +12,14 @@ func TestEnsureIMHTTPInfrastructureTablesUsesSelectedTenantDatabase(t *testing.T
|
|||||||
if err := ensureIMHTTPInfrastructureTables(exec); err != nil {
|
if err := ensureIMHTTPInfrastructureTables(exec); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(exec.statements) != 8 {
|
if len(exec.statements) != 7 {
|
||||||
t.Fatalf("created %d infrastructure tables, want 8", len(exec.statements))
|
t.Fatalf("created %d infrastructure tables, want 7", len(exec.statements))
|
||||||
}
|
}
|
||||||
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
|
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
|
||||||
for _, table := range []string{
|
for _, table := range []string{
|
||||||
"im_http_idempotency",
|
"im_http_idempotency",
|
||||||
"im_business_events_outbox",
|
"im_business_events_outbox",
|
||||||
"im_kafka_consume_log",
|
"im_kafka_consume_log",
|
||||||
"im_kafka_outbox",
|
|
||||||
"im_kafka_dead_letters",
|
"im_kafka_dead_letters",
|
||||||
"im_realname_appeals",
|
"im_realname_appeals",
|
||||||
"im_conversation_settings",
|
"im_conversation_settings",
|
||||||
@@ -33,4 +32,7 @@ func TestEnsureIMHTTPInfrastructureTablesUsesSelectedTenantDatabase(t *testing.T
|
|||||||
if strings.Contains(joined, "tinode.") || strings.Contains(joined, "create database") || strings.Contains(joined, "tenant_id") {
|
if strings.Contains(joined, "tinode.") || strings.Contains(joined, "create database") || strings.Contains(joined, "tenant_id") {
|
||||||
t.Fatalf("infrastructure schema must stay inside the selected tenant database: %s", joined)
|
t.Fatalf("infrastructure schema must stay inside the selected tenant database: %s", joined)
|
||||||
}
|
}
|
||||||
|
if strings.Contains(joined, "im_kafka_outbox") {
|
||||||
|
t.Fatalf("kafka outbox table must not be created: %s", joined)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -512,140 +512,6 @@ func (a *adapter) TenantKafkaDeadLetterMarkReplay(id t.TenantID, deadID, resolve
|
|||||||
return a.TenantKafkaDeadLetterGet(id, deadID)
|
return a.TenantKafkaDeadLetterGet(id, deadID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *adapter) TenantKafkaOutboxCreate(id t.TenantID, record *t.KafkaOutboxRecord) error {
|
|
||||||
tenantAdp, err := a.infrastructureAdapter(id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if record == nil || record.EventID == "" || record.Topic == "" || record.EventType == "" || len(record.Envelope) == 0 {
|
|
||||||
return t.ErrMalformed
|
|
||||||
}
|
|
||||||
if tenantAdp != a {
|
|
||||||
return tenantAdp.TenantKafkaOutboxCreate(id, record)
|
|
||||||
}
|
|
||||||
now := t.TimeNow()
|
|
||||||
_, err = a.db.Exec(`INSERT INTO im_kafka_outbox
|
|
||||||
(event_id,topic,event_type,biz_type,biz_id,key_value,envelope,status,next_retry_at,created_at,updated_at)
|
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
|
||||||
ON DUPLICATE KEY UPDATE updated_at=updated_at`,
|
|
||||||
record.EventID, record.Topic, record.EventType, record.BizType, record.BizID, record.Key,
|
|
||||||
record.Envelope, t.KafkaOutboxPending, record.NextRetryAt, now, now)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *adapter) TenantKafkaOutboxListPending(id t.TenantID, limit int, now time.Time) ([]t.KafkaOutboxRecord, error) {
|
|
||||||
tenantAdp, err := a.infrastructureAdapter(id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
if tenantAdp != a {
|
|
||||||
return tenantAdp.TenantKafkaOutboxListPending(id, limit, now)
|
|
||||||
}
|
|
||||||
rows := []struct {
|
|
||||||
ID int64 `db:"id"`
|
|
||||||
EventID string `db:"event_id"`
|
|
||||||
Topic string `db:"topic"`
|
|
||||||
EventType string `db:"event_type"`
|
|
||||||
BizType string `db:"biz_type"`
|
|
||||||
BizID string `db:"biz_id"`
|
|
||||||
Key string `db:"key_value"`
|
|
||||||
Envelope []byte `db:"envelope"`
|
|
||||||
Status string `db:"status"`
|
|
||||||
Attempts int `db:"attempts"`
|
|
||||||
NextRetryAt sql.NullTime `db:"next_retry_at"`
|
|
||||||
SentAt sql.NullTime `db:"sent_at"`
|
|
||||||
LastReason string `db:"last_reason"`
|
|
||||||
LastError string `db:"last_error"`
|
|
||||||
CreatedAt time.Time `db:"created_at"`
|
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
|
||||||
}{}
|
|
||||||
err = a.db.Select(&rows, `SELECT id,event_id,topic,event_type,biz_type,biz_id,key_value,envelope,status,attempts,
|
|
||||||
next_retry_at,sent_at,last_reason,last_error,created_at,updated_at
|
|
||||||
FROM im_kafka_outbox
|
|
||||||
WHERE status IN (?,?) AND (next_retry_at IS NULL OR next_retry_at<=?)
|
|
||||||
ORDER BY id ASC LIMIT ?`, t.KafkaOutboxPending, t.KafkaOutboxFailed, now, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]t.KafkaOutboxRecord, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
rec := t.KafkaOutboxRecord{
|
|
||||||
ID: row.ID,
|
|
||||||
EventID: row.EventID,
|
|
||||||
Topic: row.Topic,
|
|
||||||
EventType: row.EventType,
|
|
||||||
BizType: row.BizType,
|
|
||||||
BizID: row.BizID,
|
|
||||||
Key: row.Key,
|
|
||||||
Envelope: row.Envelope,
|
|
||||||
Status: row.Status,
|
|
||||||
Attempts: row.Attempts,
|
|
||||||
LastReason: row.LastReason,
|
|
||||||
LastError: row.LastError,
|
|
||||||
CreatedAt: row.CreatedAt,
|
|
||||||
UpdatedAt: row.UpdatedAt,
|
|
||||||
}
|
|
||||||
if row.NextRetryAt.Valid {
|
|
||||||
rec.NextRetryAt = &row.NextRetryAt.Time
|
|
||||||
}
|
|
||||||
if row.SentAt.Valid {
|
|
||||||
rec.SentAt = &row.SentAt.Time
|
|
||||||
}
|
|
||||||
out = append(out, rec)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *adapter) TenantKafkaOutboxMarkSent(id t.TenantID, eventID string, now time.Time) error {
|
|
||||||
tenantAdp, err := a.infrastructureAdapter(id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if eventID == "" {
|
|
||||||
return t.ErrMalformed
|
|
||||||
}
|
|
||||||
if tenantAdp != a {
|
|
||||||
return tenantAdp.TenantKafkaOutboxMarkSent(id, eventID, now)
|
|
||||||
}
|
|
||||||
res, err := a.db.Exec(`UPDATE im_kafka_outbox SET status=?,sent_at=?,attempts=attempts+1,last_reason='',
|
|
||||||
last_error='',updated_at=? WHERE event_id=? AND status<>?`,
|
|
||||||
t.KafkaOutboxSent, now, now, eventID, t.KafkaOutboxSent)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
|
||||||
return t.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *adapter) TenantKafkaOutboxMarkFailed(id t.TenantID, eventID, reason, message string, nextRetryAt, now time.Time) error {
|
|
||||||
tenantAdp, err := a.infrastructureAdapter(id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if eventID == "" {
|
|
||||||
return t.ErrMalformed
|
|
||||||
}
|
|
||||||
if tenantAdp != a {
|
|
||||||
return tenantAdp.TenantKafkaOutboxMarkFailed(id, eventID, reason, message, nextRetryAt, now)
|
|
||||||
}
|
|
||||||
res, err := a.db.Exec(`UPDATE im_kafka_outbox SET status=?,attempts=attempts+1,next_retry_at=?,
|
|
||||||
last_reason=?,last_error=?,updated_at=? WHERE event_id=? AND status<>?`,
|
|
||||||
t.KafkaOutboxFailed, nextRetryAt, truncateKafkaString(reason, 64), truncateKafkaString(message, 1000),
|
|
||||||
now, eventID, t.KafkaOutboxSent)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
|
||||||
return t.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanKafkaDeadLetter(rows *sqlx.Rows) (*t.KafkaDeadLetter, error) {
|
func scanKafkaDeadLetter(rows *sqlx.Rows) (*t.KafkaDeadLetter, error) {
|
||||||
var row struct {
|
var row struct {
|
||||||
ID int64 `db:"id"`
|
ID int64 `db:"id"`
|
||||||
|
|||||||
+7
-21
@@ -38,9 +38,9 @@ type testVerificationStore struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type testHTTPInfrastructureStore struct {
|
type testHTTPInfrastructureStore struct {
|
||||||
records map[string]*types.HTTPIdempotencyRecord
|
records map[string]*types.HTTPIdempotencyRecord
|
||||||
events []types.BusinessEvent
|
events []types.BusinessEvent
|
||||||
outbox []types.KafkaOutboxRecord
|
deadLetters []types.KafkaDeadLetter
|
||||||
}
|
}
|
||||||
|
|
||||||
type testTenantEntryStore struct {
|
type testTenantEntryStore struct {
|
||||||
@@ -162,7 +162,10 @@ func (s *testHTTPInfrastructureStore) MarkKafkaConsumeFailed(_ types.TenantID, e
|
|||||||
return &types.KafkaConsumeRecord{EventID: eventID, ConsumerGroup: group, Status: types.KafkaConsumeFailed, RetryCount: 1, LastReason: reason, LastError: message}, nil
|
return &types.KafkaConsumeRecord{EventID: eventID, ConsumerGroup: group, Status: types.KafkaConsumeFailed, RetryCount: 1, LastReason: reason, LastError: message}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *testHTTPInfrastructureStore) MarkKafkaConsumeDead(_ types.TenantID, _ *types.KafkaDeadLetter, _ time.Time) error {
|
func (s *testHTTPInfrastructureStore) MarkKafkaConsumeDead(_ types.TenantID, dead *types.KafkaDeadLetter, _ time.Time) error {
|
||||||
|
if dead != nil {
|
||||||
|
s.deadLetters = append(s.deadLetters, *dead)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,23 +185,6 @@ func (s *testHTTPInfrastructureStore) MarkKafkaDeadLetterReplay(_ types.TenantID
|
|||||||
return &types.KafkaDeadLetter{DeadID: deadID, Status: types.KafkaDeadStatusPendingReplay}, nil
|
return &types.KafkaDeadLetter{DeadID: deadID, Status: types.KafkaDeadStatusPendingReplay}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *testHTTPInfrastructureStore) CreateKafkaOutbox(_ types.TenantID, record *types.KafkaOutboxRecord) error {
|
|
||||||
s.outbox = append(s.outbox, *record)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *testHTTPInfrastructureStore) ListPendingKafkaOutbox(_ types.TenantID, _ int, _ time.Time) ([]types.KafkaOutboxRecord, error) {
|
|
||||||
return append([]types.KafkaOutboxRecord(nil), s.outbox...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *testHTTPInfrastructureStore) MarkKafkaOutboxSent(_ types.TenantID, _ string, _ time.Time) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *testHTTPInfrastructureStore) MarkKafkaOutboxFailed(_ types.TenantID, _, _, _ string, _, _ time.Time) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type testRelationsStore struct {
|
type testRelationsStore struct {
|
||||||
context *types.RelationshipContext
|
context *types.RelationshipContext
|
||||||
created *types.FriendRequest
|
created *types.FriendRequest
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ const (
|
|||||||
adminCommandKafkaDeadResolve = "kafka.dead_letter.resolve"
|
adminCommandKafkaDeadResolve = "kafka.dead_letter.resolve"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var publishKafkaDeadLetter = publishKafkaEnvelope
|
||||||
|
|
||||||
type adminCommandPayload struct {
|
type adminCommandPayload struct {
|
||||||
CommandID string `json:"command_id"`
|
CommandID string `json:"command_id"`
|
||||||
ActorUserID string `json:"actor_user_id"`
|
ActorUserID string `json:"actor_user_id"`
|
||||||
@@ -482,6 +484,13 @@ func kafkaTenantID(tenantCode string) (types.TenantID, error) {
|
|||||||
return tenant.ID, nil
|
return tenant.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func kafkaTenantIDFromEnvelope(envelope kafkaEnvelope) (types.TenantID, error) {
|
||||||
|
if !envelope.TenantID.IsZero() {
|
||||||
|
return envelope.TenantID, nil
|
||||||
|
}
|
||||||
|
return kafkaTenantID(envelope.TenantCode)
|
||||||
|
}
|
||||||
|
|
||||||
func markKafkaDeadLetter(ctx context.Context, tenantID types.TenantID, envelope kafkaEnvelope, consume *types.KafkaConsumeRecord, reason, message string) error {
|
func markKafkaDeadLetter(ctx context.Context, tenantID types.TenantID, envelope kafkaEnvelope, consume *types.KafkaConsumeRecord, reason, message string) error {
|
||||||
encoded, err := json.Marshal(envelope)
|
encoded, err := json.Marshal(envelope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -512,26 +521,15 @@ func markKafkaDeadLetter(ctx context.Context, tenantID types.TenantID, envelope
|
|||||||
Status: types.KafkaDeadStatusDead,
|
Status: types.KafkaDeadStatusDead,
|
||||||
}
|
}
|
||||||
now := time.Now().UTC().Round(time.Millisecond)
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
if err = store.HTTPInfrastructure.MarkKafkaConsumeDead(tenantID, dead, now); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if auditErr := enqueueKafkaAuditEvent(ctx, tenantID, "system", types.ZeroUid, "kafka.dead_letter.created", "kafka_event", envelope.EventID, "dead", reason, map[string]any{
|
|
||||||
"dead_id": deadID,
|
|
||||||
"event_type": envelope.EventType,
|
|
||||||
"biz_type": envelope.BizType,
|
|
||||||
"biz_id": envelope.BizID,
|
|
||||||
"consumer_group": kafkaBus.config.ConsumerGroup,
|
|
||||||
}); auditErr != nil {
|
|
||||||
logs.Warn.Printf("kafka dead letter audit enqueue failed: event=%s dead=%s err=%v", envelope.EventID, deadID, auditErr)
|
|
||||||
}
|
|
||||||
deadPayload, err := json.Marshal(dead)
|
deadPayload, err := json.Marshal(dead)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return publishKafkaEnvelope(ctx, "dead_letter", envelope.EventID, kafkaEnvelope{
|
return publishKafkaDeadLetter(ctx, "dead_letter", envelope.EventID, kafkaEnvelope{
|
||||||
EventID: deadID,
|
EventID: deadID,
|
||||||
TraceID: envelope.TraceID,
|
TraceID: envelope.TraceID,
|
||||||
TenantCode: envelope.TenantCode,
|
TenantCode: envelope.TenantCode,
|
||||||
|
TenantID: tenantID,
|
||||||
SourceService: "im-server",
|
SourceService: "im-server",
|
||||||
TargetService: "hotgo-admin",
|
TargetService: "hotgo-admin",
|
||||||
EventType: "kafka.dead_letter.created",
|
EventType: "kafka.dead_letter.created",
|
||||||
|
|||||||
@@ -6,10 +6,57 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/segmentio/kafka-go"
|
||||||
|
"github.com/tinode/chat/server/logs"
|
||||||
"github.com/tinode/chat/server/store"
|
"github.com/tinode/chat/server/store"
|
||||||
"github.com/tinode/chat/server/store/types"
|
"github.com/tinode/chat/server/store/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func startKafkaDeadLetterConsumer() {
|
||||||
|
startKafkaConsumer("dead_letter", handleKafkaDeadLetter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleKafkaDeadLetter(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
|
||||||
|
var dead types.KafkaDeadLetter
|
||||||
|
if err := json.Unmarshal(envelope.Payload, &dead); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dead.DeadID == "" {
|
||||||
|
dead.DeadID = envelope.EventID
|
||||||
|
}
|
||||||
|
tenantID, err := tenantIDFromDeadLetter(envelope, &dead)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
if err = store.HTTPInfrastructure.MarkKafkaConsumeDead(tenantID, &dead, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if auditErr := enqueueKafkaAuditEvent(ctx, tenantID, "system", types.ZeroUid, "kafka.dead_letter.created", "kafka_event", dead.EventID, "dead", dead.LastReason, map[string]any{
|
||||||
|
"dead_id": dead.DeadID,
|
||||||
|
"event_type": dead.EventType,
|
||||||
|
"biz_type": dead.BizType,
|
||||||
|
"biz_id": dead.BizID,
|
||||||
|
"consumer_group": dead.ConsumerGroup,
|
||||||
|
}); auditErr != nil {
|
||||||
|
logs.Warn.Printf("kafka dead letter audit enqueue failed: event=%s dead=%s err=%v", dead.EventID, dead.DeadID, auditErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tenantIDFromDeadLetter(envelope kafkaEnvelope, dead *types.KafkaDeadLetter) (types.TenantID, error) {
|
||||||
|
if !envelope.TenantID.IsZero() {
|
||||||
|
return envelope.TenantID, nil
|
||||||
|
}
|
||||||
|
if dead != nil && len(dead.Envelope) > 0 {
|
||||||
|
var original kafkaEnvelope
|
||||||
|
if err := json.Unmarshal(dead.Envelope, &original); err == nil && !original.TenantID.IsZero() {
|
||||||
|
return original.TenantID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kafkaTenantID(envelope.TenantCode)
|
||||||
|
}
|
||||||
|
|
||||||
func replayKafkaDeadLetter(ctx context.Context, tenantID types.TenantID, deadID string, actor types.Uid, note string) (*types.KafkaDeadLetter, error) {
|
func replayKafkaDeadLetter(ctx context.Context, tenantID types.TenantID, deadID string, actor types.Uid, note string) (*types.KafkaDeadLetter, error) {
|
||||||
now := time.Now().UTC().Round(time.Millisecond)
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
dead, err := store.HTTPInfrastructure.MarkKafkaDeadLetterReplay(tenantID, deadID, actor.UserId(), note, now)
|
dead, err := store.HTTPInfrastructure.MarkKafkaDeadLetterReplay(tenantID, deadID, actor.UserId(), note, now)
|
||||||
|
|||||||
@@ -104,15 +104,7 @@ func enqueueKafkaGroupFileLifecycle(ctx context.Context, tenantID types.TenantID
|
|||||||
return enqueueKafkaFileLifecycle(ctx, tenantID, eventType, firstNonEmpty(file.GroupFileID, file.FileID), payload)
|
return enqueueKafkaFileLifecycle(ctx, tenantID, eventType, firstNonEmpty(file.GroupFileID, file.FileID), payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func enqueueKafkaFileLifecycle(_ context.Context, tenantID types.TenantID, eventType, bizID string, payload kafkaFileLifecyclePayload) error {
|
func enqueueKafkaFileLifecycle(ctx context.Context, tenantID types.TenantID, eventType, bizID string, payload kafkaFileLifecyclePayload) error {
|
||||||
tenantCode := ""
|
|
||||||
if store.Tenants != nil {
|
|
||||||
if tenant, err := store.Tenants.GetByID(tenantID); err == nil && tenant != nil {
|
|
||||||
tenantCode = tenant.Code
|
|
||||||
} else {
|
|
||||||
logs.Warn.Printf("kafka file lifecycle: failed to resolve tenant code for tenant %d: %v", tenantID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if payload.OccurredAt.IsZero() {
|
if payload.OccurredAt.IsZero() {
|
||||||
payload.OccurredAt = time.Now().UTC().Round(time.Millisecond)
|
payload.OccurredAt = time.Now().UTC().Round(time.Millisecond)
|
||||||
}
|
}
|
||||||
@@ -121,9 +113,9 @@ func enqueueKafkaFileLifecycle(_ context.Context, tenantID types.TenantID, event
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
eventID := stableIMEventID("file-lifecycle", tenantID, eventType, bizID, payload.SeqID, strings.Join(payload.AttachmentURLs, ","))
|
eventID := stableIMEventID("file-lifecycle", tenantID, eventType, bizID, payload.SeqID, strings.Join(payload.AttachmentURLs, ","))
|
||||||
err = enqueueKafkaOutbox(tenantID, "file_lifecycle", bizID, kafkaEnvelope{
|
err = publishKafkaEnvelope(ctx, "file_lifecycle", bizID, kafkaEnvelope{
|
||||||
EventID: eventID,
|
EventID: eventID,
|
||||||
TenantCode: tenantCode,
|
TenantID: tenantID,
|
||||||
SourceService: "im-server",
|
SourceService: "im-server",
|
||||||
TargetService: "im-server",
|
TargetService: "im-server",
|
||||||
EventType: eventType,
|
EventType: eventType,
|
||||||
@@ -149,7 +141,7 @@ func enqueueKafkaFileLifecycle(_ context.Context, tenantID types.TenantID, event
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleKafkaFileLifecycle(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
|
func handleKafkaFileLifecycle(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
|
||||||
tenantID, err := kafkaTenantID(envelope.TenantCode)
|
tenantID, err := kafkaTenantIDFromEnvelope(envelope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ type kafkaMessagePersistPayload struct {
|
|||||||
MessageKind string `json:"message_kind"`
|
MessageKind string `json:"message_kind"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var publishKafkaMessagePersist = publishKafkaEnvelope
|
||||||
|
|
||||||
func startKafkaMessagePersistConsumer() {
|
func startKafkaMessagePersistConsumer() {
|
||||||
startKafkaConsumer("message_persist", handleKafkaMessagePersist)
|
startKafkaConsumer("message_persist", handleKafkaMessagePersist)
|
||||||
}
|
}
|
||||||
@@ -28,28 +30,27 @@ func saveMessageMaybeKafka(ctx context.Context, tenantID types.TenantID, msg *ty
|
|||||||
if !kafkaEnabled() {
|
if !kafkaEnabled() {
|
||||||
return store.Messages.Save(tenantID, msg, attachmentURLs, readBySender)
|
return store.Messages.Save(tenantID, msg, attachmentURLs, readBySender)
|
||||||
}
|
}
|
||||||
saveResult, err := store.PrepareMessagePersist(tenantID, msg, attachmentURLs, readBySender)
|
if msg == nil || msg.TenantID != tenantID || msg.Topic == "" || msg.SeqId <= 0 {
|
||||||
if err != nil || saveResult == nil || saveResult.Duplicate {
|
return nil, types.ErrMalformed
|
||||||
return saveResult, err
|
|
||||||
}
|
}
|
||||||
if readBySender && !types.ParseUid(msg.From).IsZero() {
|
clientMsgID, err := store.NormalizeClientMsgID(msg.Head)
|
||||||
saveResult.MarkedReadBySender = true
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
msg.ClientMsgID = clientMsgID
|
||||||
|
msg.InitTimes()
|
||||||
if err = enqueueKafkaMessagePersist(ctx, tenantID, msg, attachmentURLs, readBySender); err != nil {
|
if err = enqueueKafkaMessagePersist(ctx, tenantID, msg, attachmentURLs, readBySender); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return saveResult, nil
|
if types.GetTopicCat(msg.Topic) == types.TopicCatGrp {
|
||||||
}
|
if cacheErr := store.CachePendingHotMessage(tenantID, msg); cacheErr != nil {
|
||||||
|
logs.Warn.Printf("kafka message persist: failed to cache pending hot message after publish topic=%s seq=%d: %v", msg.Topic, msg.SeqId, cacheErr)
|
||||||
func enqueueKafkaMessagePersist(_ context.Context, tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) error {
|
|
||||||
tenantCode := ""
|
|
||||||
if store.Tenants != nil {
|
|
||||||
if tenant, err := store.Tenants.GetByID(tenantID); err == nil && tenant != nil {
|
|
||||||
tenantCode = tenant.Code
|
|
||||||
} else {
|
|
||||||
logs.Warn.Printf("kafka message persist: failed to resolve tenant code for tenant %d: %v", tenantID, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return &types.MessageSaveResult{SeqId: msg.SeqId, MarkedReadBySender: readBySender && !types.ParseUid(msg.From).IsZero()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func enqueueKafkaMessagePersist(ctx context.Context, tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) error {
|
||||||
kind := "p2p"
|
kind := "p2p"
|
||||||
if types.GetTopicCat(msg.Topic) == types.TopicCatGrp {
|
if types.GetTopicCat(msg.Topic) == types.TopicCatGrp {
|
||||||
kind = "group"
|
kind = "group"
|
||||||
@@ -66,7 +67,7 @@ func enqueueKafkaMessagePersist(_ context.Context, tenantID types.TenantID, msg
|
|||||||
eventID := fmt.Sprintf("msg.persist.%d.%s.%d", tenantID, msg.Topic, msg.SeqId)
|
eventID := fmt.Sprintf("msg.persist.%d.%s.%d", tenantID, msg.Topic, msg.SeqId)
|
||||||
envelope := kafkaEnvelope{
|
envelope := kafkaEnvelope{
|
||||||
EventID: eventID,
|
EventID: eventID,
|
||||||
TenantCode: tenantCode,
|
TenantID: tenantID,
|
||||||
SourceService: "im-server",
|
SourceService: "im-server",
|
||||||
TargetService: "im-server",
|
TargetService: "im-server",
|
||||||
EventType: "message.persist." + kind,
|
EventType: "message.persist." + kind,
|
||||||
@@ -78,13 +79,17 @@ func enqueueKafkaMessagePersist(_ context.Context, tenantID types.TenantID, msg
|
|||||||
Actor: &kafkaActor{Type: "user", ID: types.ParseUid(msg.From).UserId()},
|
Actor: &kafkaActor{Type: "user", ID: types.ParseUid(msg.From).UserId()},
|
||||||
Payload: payload,
|
Payload: payload,
|
||||||
}
|
}
|
||||||
return enqueueKafkaOutbox(tenantID, "message_persist", msg.Topic, envelope)
|
return publishKafkaMessagePersist(ctx, "message_persist", msg.Topic, envelope)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleKafkaMessagePersist(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
|
func handleKafkaMessagePersist(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
|
||||||
tenantID, err := kafkaTenantID(envelope.TenantCode)
|
tenantID := envelope.TenantID
|
||||||
if err != nil {
|
var err error
|
||||||
return err
|
if tenantID.IsZero() {
|
||||||
|
tenantID, err = kafkaTenantID(envelope.TenantCode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, "message_persist")
|
consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, "message_persist")
|
||||||
if err != nil || !claimed {
|
if err != nil || !claimed {
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/segmentio/kafka-go"
|
|
||||||
"github.com/tinode/chat/server/logs"
|
|
||||||
"github.com/tinode/chat/server/store"
|
|
||||||
"github.com/tinode/chat/server/store/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
kafkaOutboxPollInterval = 2 * time.Second
|
|
||||||
kafkaOutboxTenantLimit = 1000
|
|
||||||
kafkaOutboxBatchLimit = 100
|
|
||||||
)
|
|
||||||
|
|
||||||
func startKafkaOutboxPoller() {
|
|
||||||
if !kafkaEnabled() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
kafkaBus.wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer kafkaBus.wg.Done()
|
|
||||||
ticker := time.NewTicker(kafkaOutboxPollInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-kafkaBus.ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
drainKafkaOutbox(kafkaBus.ctx)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-kafkaBus.ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func enqueueKafkaOutbox(tenantID types.TenantID, topicName, key string, envelope kafkaEnvelope) error {
|
|
||||||
if envelope.OccurredAt.IsZero() {
|
|
||||||
envelope.OccurredAt = time.Now().UTC()
|
|
||||||
}
|
|
||||||
raw, err := json.Marshal(envelope)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return store.HTTPInfrastructure.CreateKafkaOutbox(tenantID, &types.KafkaOutboxRecord{
|
|
||||||
EventID: envelope.EventID,
|
|
||||||
Topic: topicName,
|
|
||||||
EventType: envelope.EventType,
|
|
||||||
BizType: envelope.BizType,
|
|
||||||
BizID: envelope.BizID,
|
|
||||||
Key: key,
|
|
||||||
Envelope: raw,
|
|
||||||
Status: types.KafkaOutboxPending,
|
|
||||||
NextRetryAt: nil,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func drainKafkaOutbox(ctx context.Context) {
|
|
||||||
if store.Tenants == nil || store.HTTPInfrastructure == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tenants, err := store.Tenants.ListActive(kafkaOutboxTenantLimit)
|
|
||||||
if err != nil {
|
|
||||||
logs.Warn.Printf("kafka outbox: list active tenants: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now().UTC().Round(time.Millisecond)
|
|
||||||
for _, tenant := range tenants {
|
|
||||||
if tenant.ID.IsZero() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
records, err := store.HTTPInfrastructure.ListPendingKafkaOutbox(tenant.ID, kafkaOutboxBatchLimit, now)
|
|
||||||
if err != nil {
|
|
||||||
logs.Warn.Printf("kafka outbox: list tenant=%d: %v", tenant.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, record := range records {
|
|
||||||
if err = publishKafkaOutboxRecord(ctx, record); err != nil {
|
|
||||||
nextRetryAt := now.Add(kafkaOutboxRetryDelay(record.Attempts + 1))
|
|
||||||
if markErr := store.HTTPInfrastructure.MarkKafkaOutboxFailed(tenant.ID, record.EventID,
|
|
||||||
"KAFKA_PUBLISH_FAILED", err.Error(), nextRetryAt, now); markErr != nil {
|
|
||||||
logs.Warn.Printf("kafka outbox: mark failed event=%s tenant=%d: %v", record.EventID, tenant.ID, markErr)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err = store.HTTPInfrastructure.MarkKafkaOutboxSent(tenant.ID, record.EventID, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
|
||||||
logs.Warn.Printf("kafka outbox: mark sent event=%s tenant=%d: %v", record.EventID, tenant.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func publishKafkaOutboxRecord(ctx context.Context, record types.KafkaOutboxRecord) error {
|
|
||||||
topic := kafkaTopic(record.Topic)
|
|
||||||
if topic == "" {
|
|
||||||
topic = record.Topic
|
|
||||||
}
|
|
||||||
return kafkaBus.writer.WriteMessages(ctx, kafka.Message{
|
|
||||||
Topic: topic,
|
|
||||||
Key: []byte(record.Key),
|
|
||||||
Value: record.Envelope,
|
|
||||||
Time: time.Now().UTC(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func kafkaOutboxRetryDelay(attempt int) time.Duration {
|
|
||||||
if attempt <= 0 {
|
|
||||||
attempt = 1
|
|
||||||
}
|
|
||||||
if kafkaBus == nil || len(kafkaBus.config.RetryBackoffMS) == 0 {
|
|
||||||
return time.Duration(attempt) * 10 * time.Second
|
|
||||||
}
|
|
||||||
index := attempt - 1
|
|
||||||
if index >= len(kafkaBus.config.RetryBackoffMS) {
|
|
||||||
index = len(kafkaBus.config.RetryBackoffMS) - 1
|
|
||||||
}
|
|
||||||
return time.Duration(kafkaBus.config.RetryBackoffMS[index]) * time.Millisecond
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/segmentio/kafka-go/sasl"
|
"github.com/segmentio/kafka-go/sasl"
|
||||||
"github.com/segmentio/kafka-go/sasl/scram"
|
"github.com/segmentio/kafka-go/sasl/scram"
|
||||||
"github.com/tinode/chat/server/logs"
|
"github.com/tinode/chat/server/logs"
|
||||||
|
"github.com/tinode/chat/server/store/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -55,6 +56,7 @@ type kafkaEnvelope struct {
|
|||||||
EventID string `json:"event_id"`
|
EventID string `json:"event_id"`
|
||||||
TraceID string `json:"trace_id,omitempty"`
|
TraceID string `json:"trace_id,omitempty"`
|
||||||
TenantCode string `json:"tenant_code,omitempty"`
|
TenantCode string `json:"tenant_code,omitempty"`
|
||||||
|
TenantID types.TenantID `json:"tenant_id,omitempty"`
|
||||||
SourceService string `json:"source_service,omitempty"`
|
SourceService string `json:"source_service,omitempty"`
|
||||||
TargetService string `json:"target_service,omitempty"`
|
TargetService string `json:"target_service,omitempty"`
|
||||||
EventType string `json:"event_type"`
|
EventType string `json:"event_type"`
|
||||||
@@ -132,6 +134,7 @@ func initKafkaRuntime(raw json.RawMessage) error {
|
|||||||
AllowAutoTopicCreation: cfg.AllowAutoTopic,
|
AllowAutoTopicCreation: cfg.AllowAutoTopic,
|
||||||
WriteTimeout: time.Duration(cfg.WriteTimeoutMS) * time.Millisecond,
|
WriteTimeout: time.Duration(cfg.WriteTimeoutMS) * time.Millisecond,
|
||||||
ReadTimeout: time.Duration(cfg.ReadTimeoutMS) * time.Millisecond,
|
ReadTimeout: time.Duration(cfg.ReadTimeoutMS) * time.Millisecond,
|
||||||
|
MaxAttempts: cfg.MaxRetryCount,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := rt.ping(context.Background()); err != nil {
|
if err := rt.ping(context.Background()); err != nil {
|
||||||
@@ -164,7 +167,7 @@ func normalizeKafkaConfig(cfg *kafkaConfig) {
|
|||||||
cfg.RequiredAcks = int(kafka.RequireOne)
|
cfg.RequiredAcks = int(kafka.RequireOne)
|
||||||
}
|
}
|
||||||
if cfg.MaxRetryCount <= 0 {
|
if cfg.MaxRetryCount <= 0 {
|
||||||
cfg.MaxRetryCount = 5
|
cfg.MaxRetryCount = 3
|
||||||
}
|
}
|
||||||
if len(cfg.RetryBackoffMS) == 0 {
|
if len(cfg.RetryBackoffMS) == 0 {
|
||||||
cfg.RetryBackoffMS = []int{10000, 30000, 60000, 300000, 900000}
|
cfg.RetryBackoffMS = []int{10000, 30000, 60000, 300000, 900000}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/segmentio/kafka-go"
|
"github.com/segmentio/kafka-go"
|
||||||
"github.com/tinode/chat/server/logs"
|
|
||||||
"github.com/tinode/chat/server/store"
|
"github.com/tinode/chat/server/store"
|
||||||
"github.com/tinode/chat/server/store/types"
|
"github.com/tinode/chat/server/store/types"
|
||||||
)
|
)
|
||||||
@@ -87,15 +86,7 @@ func enqueueKafkaAuditEvent(ctx context.Context, tenantID types.TenantID, actorT
|
|||||||
return enqueueKafkaTypedEvent(ctx, tenantID, "audit_event", "audit."+action, "audit", resource+":"+resourceID, actor, payload)
|
return enqueueKafkaTypedEvent(ctx, tenantID, "audit_event", "audit."+action, "audit", resource+":"+resourceID, actor, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func enqueueKafkaTypedEvent(_ context.Context, tenantID types.TenantID, topicName, eventType, bizType, bizID string, actor types.Uid, payload any) error {
|
func enqueueKafkaTypedEvent(ctx context.Context, tenantID types.TenantID, topicName, eventType, bizType, bizID string, actor types.Uid, payload any) error {
|
||||||
tenantCode := ""
|
|
||||||
if store.Tenants != nil {
|
|
||||||
if tenant, err := store.Tenants.GetByID(tenantID); err == nil && tenant != nil {
|
|
||||||
tenantCode = tenant.Code
|
|
||||||
} else {
|
|
||||||
logs.Warn.Printf("kafka event: failed to resolve tenant code for tenant %d: %v", tenantID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
encoded, err := json.Marshal(payload)
|
encoded, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -103,7 +94,7 @@ func enqueueKafkaTypedEvent(_ context.Context, tenantID types.TenantID, topicNam
|
|||||||
eventID := stableIMEventID(topicName, tenantID, eventType, bizID, encoded)
|
eventID := stableIMEventID(topicName, tenantID, eventType, bizID, encoded)
|
||||||
envelope := kafkaEnvelope{
|
envelope := kafkaEnvelope{
|
||||||
EventID: eventID,
|
EventID: eventID,
|
||||||
TenantCode: tenantCode,
|
TenantID: tenantID,
|
||||||
SourceService: "im-server",
|
SourceService: "im-server",
|
||||||
TargetService: "im-server",
|
TargetService: "im-server",
|
||||||
EventType: eventType,
|
EventType: eventType,
|
||||||
@@ -115,11 +106,11 @@ func enqueueKafkaTypedEvent(_ context.Context, tenantID types.TenantID, topicNam
|
|||||||
if !actor.IsZero() {
|
if !actor.IsZero() {
|
||||||
envelope.Actor = &kafkaActor{Type: "user", ID: actor.UserId()}
|
envelope.Actor = &kafkaActor{Type: "user", ID: actor.UserId()}
|
||||||
}
|
}
|
||||||
return enqueueKafkaOutbox(tenantID, topicName, bizID, envelope)
|
return publishKafkaEnvelope(ctx, topicName, bizID, envelope)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleKafkaWorkbenchEvent(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
|
func handleKafkaWorkbenchEvent(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error {
|
||||||
tenantID, err := kafkaTenantID(envelope.TenantCode)
|
tenantID, err := kafkaTenantIDFromEnvelope(envelope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -698,7 +698,7 @@ func main() {
|
|||||||
startKafkaMessagePersistConsumer()
|
startKafkaMessagePersistConsumer()
|
||||||
startKafkaFileLifecycleConsumer()
|
startKafkaFileLifecycleConsumer()
|
||||||
startKafkaWorkbenchEventConsumer()
|
startKafkaWorkbenchEventConsumer()
|
||||||
startKafkaOutboxPoller()
|
startKafkaDeadLetterConsumer()
|
||||||
stopExternalBusinessEvents := startIMExternalBusinessEventPoller()
|
stopExternalBusinessEvents := startIMExternalBusinessEventPoller()
|
||||||
defer stopExternalBusinessEvents()
|
defer stopExternalBusinessEvents()
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
"dial_timeout_ms": 2000,
|
"dial_timeout_ms": 2000,
|
||||||
"read_timeout_ms": 2000,
|
"read_timeout_ms": 2000,
|
||||||
"write_timeout_ms": 2000,
|
"write_timeout_ms": 2000,
|
||||||
"pending_ttl_seconds": 120,
|
"pending_ttl_seconds": 300,
|
||||||
"done_ttl_seconds": 86400,
|
"done_ttl_seconds": 86400,
|
||||||
"failed_ttl_seconds": 600,
|
"failed_ttl_seconds": 600,
|
||||||
"hot_message_ttl_seconds": 86400,
|
"hot_message_ttl_seconds": 86400,
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
"sasl_mechanism": "SCRAM-SHA-512",
|
"sasl_mechanism": "SCRAM-SHA-512",
|
||||||
"sasl_username": "kafka-external",
|
"sasl_username": "kafka-external",
|
||||||
"sasl_password": "gr58tNnlZOVGW3AmMbL2",
|
"sasl_password": "gr58tNnlZOVGW3AmMbL2",
|
||||||
"max_retry_count": 5,
|
"max_retry_count": 3,
|
||||||
"retry_backoff_ms": [10000, 30000, 60000, 300000, 900000],
|
"retry_backoff_ms": [10000, 30000, 60000, 300000, 900000],
|
||||||
"dead_letter_retention_seconds": 7776000,
|
"dead_letter_retention_seconds": 7776000,
|
||||||
"topics": {
|
"topics": {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ type groupMessageRuntime interface {
|
|||||||
MarkDone(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error
|
MarkDone(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error
|
||||||
MarkFailed(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) error
|
MarkFailed(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) error
|
||||||
CacheHotMessage(tenantID types.TenantID, msg *types.Message) error
|
CacheHotMessage(tenantID types.TenantID, msg *types.Message) error
|
||||||
|
CachePendingHotMessage(tenantID types.TenantID, msg *types.Message) error
|
||||||
GetHotMessages(tenantID types.TenantID, topic string, opts *types.QueryOpt, maxLimit int) ([]types.Message, bool, error)
|
GetHotMessages(tenantID types.TenantID, topic string, opts *types.QueryOpt, maxLimit int) ([]types.Message, bool, error)
|
||||||
InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error
|
InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error
|
||||||
MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error
|
MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error
|
||||||
@@ -187,6 +188,27 @@ func DeleteCachedObject(tenantID types.TenantID, namespace, id string) error {
|
|||||||
return groupMsgRuntime.DeleteCachedObject(tenantID, namespace, id)
|
return groupMsgRuntime.DeleteCachedObject(tenantID, namespace, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CacheHotMessage(tenantID types.TenantID, msg *types.Message) error {
|
||||||
|
if groupMsgRuntime == nil || !tenantID.IsValid() || msg == nil || msg.Topic == "" || msg.SeqId <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.CacheHotMessage(tenantID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CachePendingHotMessage(tenantID types.TenantID, msg *types.Message) error {
|
||||||
|
if groupMsgRuntime == nil || !tenantID.IsValid() || msg == nil || msg.Topic == "" || msg.SeqId <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.CachePendingHotMessage(tenantID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetHotMessages(tenantID types.TenantID, topic string, opts *types.QueryOpt, maxLimit int) ([]types.Message, bool, error) {
|
||||||
|
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.GetHotMessages(tenantID, topic, opts, maxLimit)
|
||||||
|
}
|
||||||
|
|
||||||
func InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error {
|
func InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error {
|
||||||
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" {
|
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -239,7 +261,7 @@ func initGroupMessageRuntime(raw json.RawMessage) error {
|
|||||||
cfg.KeyPrefix = "im"
|
cfg.KeyPrefix = "im"
|
||||||
}
|
}
|
||||||
if cfg.PendingTTL <= 0 {
|
if cfg.PendingTTL <= 0 {
|
||||||
cfg.PendingTTL = 120
|
cfg.PendingTTL = 300
|
||||||
}
|
}
|
||||||
if cfg.DoneTTL <= 0 {
|
if cfg.DoneTTL <= 0 {
|
||||||
cfg.DoneTTL = 86400
|
cfg.DoneTTL = 86400
|
||||||
@@ -380,15 +402,25 @@ func (r *redisGroupMessageRuntime) MarkFailed(tenantID types.TenantID, topic str
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *redisGroupMessageRuntime) CacheHotMessage(tenantID types.TenantID, msg *types.Message) error {
|
func (r *redisGroupMessageRuntime) CacheHotMessage(tenantID types.TenantID, msg *types.Message) error {
|
||||||
|
return r.cacheHotMessage(tenantID, msg, time.Duration(r.cfg.HotMsgTTL)*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) CachePendingHotMessage(tenantID types.TenantID, msg *types.Message) error {
|
||||||
|
return r.cacheHotMessage(tenantID, msg, time.Duration(r.cfg.PendingTTL)*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) cacheHotMessage(tenantID types.TenantID, msg *types.Message, ttl time.Duration) error {
|
||||||
if msg == nil || msg.Topic == "" || msg.SeqId <= 0 {
|
if msg == nil || msg.Topic == "" || msg.SeqId <= 0 {
|
||||||
return types.ErrMalformed
|
return types.ErrMalformed
|
||||||
}
|
}
|
||||||
|
if ttl <= 0 {
|
||||||
|
return types.ErrMalformed
|
||||||
|
}
|
||||||
payload, err := json.Marshal(msg)
|
payload, err := json.Marshal(msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
ttl := time.Duration(r.cfg.HotMsgTTL) * time.Second
|
|
||||||
seq := strconv.FormatInt(msg.SeqId, 10)
|
seq := strconv.FormatInt(msg.SeqId, 10)
|
||||||
pipe := r.client.Pipeline()
|
pipe := r.client.Pipeline()
|
||||||
pipe.Set(ctx, r.hotMsgKey(tenantID, msg.Topic, msg.SeqId), payload, ttl)
|
pipe.Set(ctx, r.hotMsgKey(tenantID, msg.Topic, msg.SeqId), payload, ttl)
|
||||||
|
|||||||
@@ -22,10 +22,6 @@ type HTTPInfrastructurePersistenceInterface interface {
|
|||||||
GetKafkaDeadLetter(types.TenantID, string) (*types.KafkaDeadLetter, error)
|
GetKafkaDeadLetter(types.TenantID, string) (*types.KafkaDeadLetter, error)
|
||||||
MarkKafkaDeadLetter(types.TenantID, string, string, string, string, time.Time) error
|
MarkKafkaDeadLetter(types.TenantID, string, string, string, string, time.Time) error
|
||||||
MarkKafkaDeadLetterReplay(types.TenantID, string, string, string, time.Time) (*types.KafkaDeadLetter, error)
|
MarkKafkaDeadLetterReplay(types.TenantID, string, string, string, time.Time) (*types.KafkaDeadLetter, error)
|
||||||
CreateKafkaOutbox(types.TenantID, *types.KafkaOutboxRecord) error
|
|
||||||
ListPendingKafkaOutbox(types.TenantID, int, time.Time) ([]types.KafkaOutboxRecord, error)
|
|
||||||
MarkKafkaOutboxSent(types.TenantID, string, time.Time) error
|
|
||||||
MarkKafkaOutboxFailed(types.TenantID, string, string, string, time.Time, time.Time) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type httpInfrastructureMapper struct{}
|
type httpInfrastructureMapper struct{}
|
||||||
@@ -155,35 +151,3 @@ func (httpInfrastructureMapper) MarkKafkaDeadLetterReplay(tenantID types.TenantI
|
|||||||
}
|
}
|
||||||
return infrastructure.TenantKafkaDeadLetterMarkReplay(tenantID, deadID, resolvedBy, note, now)
|
return infrastructure.TenantKafkaDeadLetterMarkReplay(tenantID, deadID, resolvedBy, note, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (httpInfrastructureMapper) CreateKafkaOutbox(tenantID types.TenantID, record *types.KafkaOutboxRecord) error {
|
|
||||||
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return infrastructure.TenantKafkaOutboxCreate(tenantID, record)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (httpInfrastructureMapper) ListPendingKafkaOutbox(tenantID types.TenantID, limit int, now time.Time) ([]types.KafkaOutboxRecord, error) {
|
|
||||||
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return infrastructure.TenantKafkaOutboxListPending(tenantID, limit, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (httpInfrastructureMapper) MarkKafkaOutboxSent(tenantID types.TenantID, eventID string, now time.Time) error {
|
|
||||||
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return infrastructure.TenantKafkaOutboxMarkSent(tenantID, eventID, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (httpInfrastructureMapper) MarkKafkaOutboxFailed(tenantID types.TenantID, eventID, reason, message string, nextRetryAt, now time.Time) error {
|
|
||||||
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return infrastructure.TenantKafkaOutboxMarkFailed(tenantID, eventID, reason, message, nextRetryAt, now)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3443,6 +3443,10 @@ func PersistPreparedMessage(tenantID types.TenantID, msg *types.Message, attachm
|
|||||||
return Messages.PersistPrepared(tenantID, msg, attachmentURLs, readBySender)
|
return Messages.PersistPrepared(tenantID, msg, attachmentURLs, readBySender)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NormalizeClientMsgID(head types.KVMap) (string, error) {
|
||||||
|
return normalizeClientMsgID(head)
|
||||||
|
}
|
||||||
|
|
||||||
func retryTenantMessageSaveAfterSeqConflict(tenantAdp adapter.TenantBusinessAdapter, tenantID types.TenantID, msg *types.Message) error {
|
func retryTenantMessageSaveAfterSeqConflict(tenantAdp adapter.TenantBusinessAdapter, tenantID types.TenantID, msg *types.Message) error {
|
||||||
latest, err := tenantAdp.TenantMessageGetAll(tenantID, msg.Topic, types.ZeroUid, &types.QueryOpt{Before: 1 << 62, Limit: 1})
|
latest, err := tenantAdp.TenantMessageGetAll(tenantID, msg.Topic, types.ZeroUid, &types.QueryOpt{Before: 1 << 62, Limit: 1})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -23,10 +23,6 @@ const (
|
|||||||
KafkaDeadStatusReplayed = "replayed"
|
KafkaDeadStatusReplayed = "replayed"
|
||||||
KafkaDeadStatusIgnored = "ignored"
|
KafkaDeadStatusIgnored = "ignored"
|
||||||
KafkaDeadStatusResolved = "resolved"
|
KafkaDeadStatusResolved = "resolved"
|
||||||
|
|
||||||
KafkaOutboxPending = "pending"
|
|
||||||
KafkaOutboxSent = "sent"
|
|
||||||
KafkaOutboxFailed = "failed"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTPIdempotencyRecord stores the first response for one authenticated write.
|
// HTTPIdempotencyRecord stores the first response for one authenticated write.
|
||||||
@@ -115,26 +111,6 @@ type KafkaDeadLetterPage struct {
|
|||||||
NextCursor string
|
NextCursor string
|
||||||
}
|
}
|
||||||
|
|
||||||
// KafkaOutboxRecord stores a Kafka event waiting for reliable publication.
|
|
||||||
type KafkaOutboxRecord struct {
|
|
||||||
ID int64
|
|
||||||
EventID string
|
|
||||||
Topic string
|
|
||||||
EventType string
|
|
||||||
BizType string
|
|
||||||
BizID string
|
|
||||||
Key string
|
|
||||||
Envelope []byte
|
|
||||||
Status string
|
|
||||||
Attempts int
|
|
||||||
NextRetryAt *time.Time
|
|
||||||
SentAt *time.Time
|
|
||||||
LastReason string
|
|
||||||
LastError string
|
|
||||||
CreatedAt time.Time
|
|
||||||
UpdatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// StableEventID derives an idempotent event identifier from domain identity.
|
// StableEventID derives an idempotent event identifier from domain identity.
|
||||||
func StableEventID(parts ...any) string {
|
func StableEventID(parts ...any) string {
|
||||||
hash := sha256.New()
|
hash := sha256.New()
|
||||||
|
|||||||
+2
-2
@@ -95,7 +95,7 @@
|
|||||||
"dial_timeout_ms": 2000,
|
"dial_timeout_ms": 2000,
|
||||||
"read_timeout_ms": 2000,
|
"read_timeout_ms": 2000,
|
||||||
"write_timeout_ms": 2000,
|
"write_timeout_ms": 2000,
|
||||||
"pending_ttl_seconds": 120,
|
"pending_ttl_seconds": 300,
|
||||||
"done_ttl_seconds": 86400,
|
"done_ttl_seconds": 86400,
|
||||||
"failed_ttl_seconds": 600,
|
"failed_ttl_seconds": 600,
|
||||||
"hot_message_ttl_seconds": 86400,
|
"hot_message_ttl_seconds": 86400,
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
"sasl_mechanism": "SCRAM-SHA-512",
|
"sasl_mechanism": "SCRAM-SHA-512",
|
||||||
"sasl_username": "kafka-external",
|
"sasl_username": "kafka-external",
|
||||||
"sasl_password": "gr58tNnlZOVGW3AmMbL2",
|
"sasl_password": "gr58tNnlZOVGW3AmMbL2",
|
||||||
"max_retry_count": 5,
|
"max_retry_count": 3,
|
||||||
"retry_backoff_ms": [10000, 30000, 60000, 300000, 900000],
|
"retry_backoff_ms": [10000, 30000, 60000, 300000, 900000],
|
||||||
"dead_letter_retention_seconds": 7776000,
|
"dead_letter_retention_seconds": 7776000,
|
||||||
"topics": {
|
"topics": {
|
||||||
|
|||||||
+60
-1
@@ -2984,11 +2984,20 @@ func (t *Topic) replyGetData(sess *Session, asUid types.Uid, asChan bool, req *M
|
|||||||
count := 0
|
count := 0
|
||||||
if userData := t.perUser[asUid]; (userData.modeGiven & userData.modeWant).IsReader() {
|
if userData := t.perUser[asUid]; (userData.modeGiven & userData.modeWant).IsReader() {
|
||||||
// Read messages from DB
|
// Read messages from DB
|
||||||
messages, err := store.Messages.GetAll(t.tenantID, t.name, asUid, msgOpts2storeOpts(req))
|
opts := msgOpts2storeOpts(req)
|
||||||
|
messages, err := store.Messages.GetAll(t.tenantID, t.name, asUid, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sess.queueOut(ErrUnknownReply(msg, now))
|
sess.queueOut(ErrUnknownReply(msg, now))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if len(messages) == 0 && shouldTryHotMessageFallback(t.cat, t.lastID, opts) {
|
||||||
|
hotMessages, ok, cacheErr := store.GetHotMessages(t.tenantID, t.name, opts, hotMessageFallbackLimit(opts))
|
||||||
|
if cacheErr != nil {
|
||||||
|
logs.Warn.Printf("get.data hot message fallback failed topic=%s user=%s: %v", t.name, asUid.UserId(), cacheErr)
|
||||||
|
} else if ok {
|
||||||
|
messages = filterHotMessagesForUser(hotMessages, asUid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Push the list of messages to the client as {data}.
|
// Push the list of messages to the client as {data}.
|
||||||
if messages != nil {
|
if messages != nil {
|
||||||
@@ -3032,6 +3041,56 @@ func (t *Topic) replyGetData(sess *Session, asUid types.Uid, asChan bool, req *M
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldTryHotMessageFallback(cat types.TopicCat, lastID int64, opts *types.QueryOpt) bool {
|
||||||
|
if cat != types.TopicCatGrp || opts == nil || opts.IfModifiedSince != nil ||
|
||||||
|
!opts.User.IsZero() || opts.Topic != "" || opts.Since <= 0 || opts.Before > 0 || len(opts.IdRanges) > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
limit := hotMessageFallbackLimit(opts)
|
||||||
|
if limit <= 0 || limit > 10 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
floor := lastID - 10
|
||||||
|
if floor < 1 {
|
||||||
|
floor = 1
|
||||||
|
}
|
||||||
|
return lastID <= 0 || opts.Since >= floor
|
||||||
|
}
|
||||||
|
|
||||||
|
func hotMessageFallbackLimit(opts *types.QueryOpt) int {
|
||||||
|
if opts != nil && opts.Limit > 0 {
|
||||||
|
return opts.Limit
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterHotMessagesForUser(messages []types.Message, asUid types.Uid) []types.Message {
|
||||||
|
if len(messages) == 0 {
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
filtered := messages[:0]
|
||||||
|
for i := range messages {
|
||||||
|
if hotMessageVisibleToUser(&messages[i], asUid) {
|
||||||
|
filtered = append(filtered, messages[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func hotMessageVisibleToUser(msg *types.Message, asUid types.Uid) bool {
|
||||||
|
if msg == nil || msg.DeletedAt != nil || msg.DelId > 0 || asUid.IsZero() {
|
||||||
|
return msg != nil && msg.DeletedAt == nil && msg.DelId == 0
|
||||||
|
}
|
||||||
|
uid := asUid.String()
|
||||||
|
userID := asUid.UserId()
|
||||||
|
for _, deleted := range msg.DeletedFor {
|
||||||
|
if deleted.User == uid || deleted.User == userID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// replyGetTags returns topics' tags - tokens used for discovery.
|
// replyGetTags returns topics' tags - tokens used for discovery.
|
||||||
func (t *Topic) replyGetTags(sess *Session, asUid types.Uid, msg *ClientComMessage) error {
|
func (t *Topic) replyGetTags(sess *Session, asUid types.Uid, msg *ClientComMessage) error {
|
||||||
now := types.TimeNow()
|
now := types.TimeNow()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -9,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang/mock/gomock"
|
"github.com/golang/mock/gomock"
|
||||||
|
"github.com/segmentio/kafka-go"
|
||||||
"github.com/tinode/chat/server/auth"
|
"github.com/tinode/chat/server/auth"
|
||||||
"github.com/tinode/chat/server/logs"
|
"github.com/tinode/chat/server/logs"
|
||||||
"github.com/tinode/chat/server/store"
|
"github.com/tinode/chat/server/store"
|
||||||
@@ -901,6 +904,196 @@ func TestHandleBroadcastDataFiltersInvalidMentions(t *testing.T) {
|
|||||||
verifyResponseCodes(helper.results[0], []int{http.StatusAccepted}, t)
|
verifyResponseCodes(helper.results[0], []int{http.StatusAccepted}, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveMessageMaybeKafkaPublishesWithoutDBPersist(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
defer ctrl.Finish()
|
||||||
|
|
||||||
|
oldKafkaBus := kafkaBus
|
||||||
|
oldMessages := store.Messages
|
||||||
|
oldPublisher := publishKafkaMessagePersist
|
||||||
|
defer func() {
|
||||||
|
kafkaBus = oldKafkaBus
|
||||||
|
store.Messages = oldMessages
|
||||||
|
publishKafkaMessagePersist = oldPublisher
|
||||||
|
}()
|
||||||
|
|
||||||
|
kafkaBus = &kafkaRuntime{}
|
||||||
|
store.Messages = mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
||||||
|
var publishedTopic string
|
||||||
|
var publishedKey string
|
||||||
|
var publishedEnvelope kafkaEnvelope
|
||||||
|
publishKafkaMessagePersist = func(_ context.Context, topicName, key string, envelope kafkaEnvelope) error {
|
||||||
|
publishedTopic = topicName
|
||||||
|
publishedKey = key
|
||||||
|
publishedEnvelope = envelope
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &types.Message{
|
||||||
|
TenantID: 1,
|
||||||
|
Topic: "grp-test",
|
||||||
|
SeqId: 1,
|
||||||
|
From: types.Uid(1).String(),
|
||||||
|
Head: types.KVMap{"x-im-client-mid": "client-1"},
|
||||||
|
Content: "hello",
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := saveMessageMaybeKafka(context.Background(), 1, msg, []string{"file1"}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("saveMessageMaybeKafka returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got == nil || got.SeqId != 1 || !got.MarkedReadBySender {
|
||||||
|
t.Fatalf("unexpected save result: %#v", got)
|
||||||
|
}
|
||||||
|
if msg.ClientMsgID != "client-1" {
|
||||||
|
t.Fatalf("client message id not normalized: %q", msg.ClientMsgID)
|
||||||
|
}
|
||||||
|
if publishedTopic != "message_persist" {
|
||||||
|
t.Fatalf("unexpected kafka topic: %q", publishedTopic)
|
||||||
|
}
|
||||||
|
if publishedKey != "grp-test" {
|
||||||
|
t.Fatalf("unexpected kafka key: %q", publishedKey)
|
||||||
|
}
|
||||||
|
if publishedEnvelope.EventType != "message.persist.group" {
|
||||||
|
t.Fatalf("unexpected event type: %q", publishedEnvelope.EventType)
|
||||||
|
}
|
||||||
|
if publishedEnvelope.TenantID != 1 || publishedEnvelope.TenantCode != "" {
|
||||||
|
t.Fatalf("unexpected tenant routing fields: tenant_id=%d tenant_code=%q", publishedEnvelope.TenantID, publishedEnvelope.TenantCode)
|
||||||
|
}
|
||||||
|
if publishedEnvelope.IdempotencyKey != "client-1" {
|
||||||
|
t.Fatalf("unexpected idempotency key: %q", publishedEnvelope.IdempotencyKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldTryHotMessageFallback(t *testing.T) {
|
||||||
|
if !shouldTryHotMessageFallback(types.TopicCatGrp, 159, &types.QueryOpt{Since: 159, Limit: 1}) {
|
||||||
|
t.Fatal("expected fallback for latest group seq query")
|
||||||
|
}
|
||||||
|
if shouldTryHotMessageFallback(types.TopicCatP2P, 159, &types.QueryOpt{Since: 159, Limit: 1}) {
|
||||||
|
t.Fatal("did not expect fallback for p2p topic")
|
||||||
|
}
|
||||||
|
if shouldTryHotMessageFallback(types.TopicCatGrp, 159, &types.QueryOpt{Since: 1, Limit: 1}) {
|
||||||
|
t.Fatal("did not expect fallback for old history query")
|
||||||
|
}
|
||||||
|
if shouldTryHotMessageFallback(types.TopicCatGrp, 159, &types.QueryOpt{Since: 159, Limit: 50}) {
|
||||||
|
t.Fatal("did not expect fallback for broad query")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterHotMessagesForUserSkipsDeleted(t *testing.T) {
|
||||||
|
uid := types.Uid(1)
|
||||||
|
now := time.Now()
|
||||||
|
messages := []types.Message{
|
||||||
|
{SeqId: 1},
|
||||||
|
{SeqId: 2, DeletedFor: []types.SoftDelete{{User: uid.String()}}},
|
||||||
|
{SeqId: 3, DeletedFor: []types.SoftDelete{{User: uid.UserId()}}},
|
||||||
|
{SeqId: 4, DeletedAt: &now},
|
||||||
|
{SeqId: 5, DelId: 10},
|
||||||
|
}
|
||||||
|
filtered := filterHotMessagesForUser(messages, uid)
|
||||||
|
if len(filtered) != 1 || filtered[0].SeqId != 1 {
|
||||||
|
t.Fatalf("unexpected filtered messages: %#v", filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkKafkaDeadLetterPublishesBeforeDBWrite(t *testing.T) {
|
||||||
|
oldKafkaBus := kafkaBus
|
||||||
|
oldPublisher := publishKafkaDeadLetter
|
||||||
|
oldInfrastructure := store.HTTPInfrastructure
|
||||||
|
defer func() {
|
||||||
|
kafkaBus = oldKafkaBus
|
||||||
|
publishKafkaDeadLetter = oldPublisher
|
||||||
|
store.HTTPInfrastructure = oldInfrastructure
|
||||||
|
}()
|
||||||
|
|
||||||
|
kafkaBus = &kafkaRuntime{config: kafkaConfig{ConsumerGroup: "im-server"}}
|
||||||
|
infrastructure := &testHTTPInfrastructureStore{}
|
||||||
|
store.HTTPInfrastructure = infrastructure
|
||||||
|
var publishedEnvelope kafkaEnvelope
|
||||||
|
publishKafkaDeadLetter = func(_ context.Context, topicName, key string, envelope kafkaEnvelope) error {
|
||||||
|
if topicName != "dead_letter" || key != "event-1" {
|
||||||
|
t.Fatalf("unexpected dead letter publish target topic=%q key=%q", topicName, key)
|
||||||
|
}
|
||||||
|
publishedEnvelope = envelope
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
original := kafkaEnvelope{
|
||||||
|
EventID: "event-1",
|
||||||
|
TenantID: 1,
|
||||||
|
EventType: "message.persist.group",
|
||||||
|
BizType: "message",
|
||||||
|
BizID: "grp-test:1",
|
||||||
|
}
|
||||||
|
consume := &types.KafkaConsumeRecord{
|
||||||
|
EventID: "event-1",
|
||||||
|
ConsumerGroup: "im-server",
|
||||||
|
SourceTopic: "im.message.persist",
|
||||||
|
RetryCount: 3,
|
||||||
|
}
|
||||||
|
if err := markKafkaDeadLetter(context.Background(), 1, original, consume, "DB_FAILED", "boom"); err != nil {
|
||||||
|
t.Fatalf("markKafkaDeadLetter returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(infrastructure.deadLetters) != 0 {
|
||||||
|
t.Fatalf("dead letter was written before dead_letter topic consumption: %#v", infrastructure.deadLetters)
|
||||||
|
}
|
||||||
|
if publishedEnvelope.EventID == "" || publishedEnvelope.TenantID != 1 {
|
||||||
|
t.Fatalf("dead letter event was not published correctly: %#v", publishedEnvelope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleKafkaDeadLetterWritesDB(t *testing.T) {
|
||||||
|
oldKafkaBus := kafkaBus
|
||||||
|
oldInfrastructure := store.HTTPInfrastructure
|
||||||
|
defer func() {
|
||||||
|
kafkaBus = oldKafkaBus
|
||||||
|
store.HTTPInfrastructure = oldInfrastructure
|
||||||
|
}()
|
||||||
|
|
||||||
|
kafkaBus = nil
|
||||||
|
infrastructure := &testHTTPInfrastructureStore{}
|
||||||
|
store.HTTPInfrastructure = infrastructure
|
||||||
|
original := kafkaEnvelope{
|
||||||
|
EventID: "event-1",
|
||||||
|
TenantID: 1,
|
||||||
|
EventType: "message.persist.group",
|
||||||
|
BizType: "message",
|
||||||
|
BizID: "grp-test:1",
|
||||||
|
}
|
||||||
|
encodedOriginal, _ := json.Marshal(original)
|
||||||
|
dead := types.KafkaDeadLetter{
|
||||||
|
DeadID: "dead-1",
|
||||||
|
EventID: "event-1",
|
||||||
|
SourceTopic: "im.message.persist",
|
||||||
|
ConsumerGroup: "im-server",
|
||||||
|
EventType: "message.persist.group",
|
||||||
|
BizType: "message",
|
||||||
|
BizID: "grp-test:1",
|
||||||
|
Envelope: encodedOriginal,
|
||||||
|
RetryCount: 3,
|
||||||
|
LastReason: "DB_FAILED",
|
||||||
|
LastError: "boom",
|
||||||
|
Status: types.KafkaDeadStatusDead,
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(dead)
|
||||||
|
envelope := kafkaEnvelope{
|
||||||
|
EventID: "dead-1",
|
||||||
|
TenantID: 1,
|
||||||
|
EventType: "kafka.dead_letter.created",
|
||||||
|
ParentEventID: "event-1",
|
||||||
|
Payload: payload,
|
||||||
|
}
|
||||||
|
if err := handleKafkaDeadLetter(context.Background(), envelope, kafka.Message{}); err != nil {
|
||||||
|
t.Fatalf("handleKafkaDeadLetter returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(infrastructure.deadLetters) != 1 {
|
||||||
|
t.Fatalf("expected one dead letter write, got %d", len(infrastructure.deadLetters))
|
||||||
|
}
|
||||||
|
if infrastructure.deadLetters[0].DeadID != "dead-1" || infrastructure.deadLetters[0].EventID != "event-1" {
|
||||||
|
t.Fatalf("unexpected dead letter write: %#v", infrastructure.deadLetters[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleBroadcastDataInactiveTopic(t *testing.T) {
|
func TestHandleBroadcastDataInactiveTopic(t *testing.T) {
|
||||||
numUsers := 2
|
numUsers := 2
|
||||||
helper := TopicTestHelper{}
|
helper := TopicTestHelper{}
|
||||||
|
|||||||
Reference in New Issue
Block a user