fix: preserve channel message delivery

This commit is contained in:
lofyer
2026-08-13 02:09:09 +08:00
parent 67cb69f07d
commit 980f3a0c8f
15 changed files with 586 additions and 130 deletions
+137 -63
View File
@@ -89,8 +89,8 @@ export class ChannelService {
private readonly outbox: Outbox
private readonly onDeliveryFailure?: (error: unknown) => void
private readonly onDeliverySuccess?: () => void
private readonly tasks = new Set<Promise<void>>()
private readonly active = new Map<string, AbortController>()
private readonly conversationTails = new Map<string, Promise<void>>()
private state: ServiceState = 'idle'
private stopPromise?: Promise<void>
@@ -149,18 +149,17 @@ export class ChannelService {
this.state = 'running'
try {
await this.driver.start(async (rawMessage, acknowledge) => {
await acknowledge()
if (this.state !== 'running') {
await acknowledge()
return
}
const task = this.process(rawMessage).catch(() => {
// Processing failures are converted to bounded channel results.
})
this.tasks.add(task)
void task.finally(() => {
this.tasks.delete(task)
})
try {
this.enqueue(rawMessage)
await acknowledge()
} catch (error) {
this.onDeliveryFailure?.(error)
}
})
await this.retryUndelivered()
} catch (error) {
@@ -170,9 +169,12 @@ export class ChannelService {
}
cancel(eventId: string): boolean {
const controller = this.active.get(
this.activeKey(this.driver.channel, eventId)
)
const suffix = `\u0000${eventId}`
const controller = [...this.active.entries()].find(
([key]) =>
key.startsWith(`${this.driver.channel}\u0000`) &&
key.endsWith(suffix)
)?.[1]
if (!controller) {
return false
}
@@ -201,7 +203,7 @@ export class ChannelService {
const driverStop = Promise.resolve().then(() => this.driver.stop())
const results = await Promise.allSettled([
driverStop,
...this.tasks
...this.conversationTails.values()
])
const driverResult = results[0]
if (driverResult?.status === 'rejected') {
@@ -259,73 +261,96 @@ export class ChannelService {
const claimed = await this.dedupStore.claim(
message.channel,
message.accountId,
message.eventId
)
if (!claimed) {
return
}
if (message.text.length > this.maximumInputLength) {
await this.deliver(
this.result(message, {
status: 'rejected',
error: `消息过长,最多允许 ${this.maximumInputLength} 个字符`
}),
new AbortController().signal
)
return
}
if (this.active.size >= this.maximumConcurrency) {
await this.deliver(
this.result(message, {
status: 'busy',
error: '当前请求较多,请稍后重试'
}),
new AbortController().signal
)
return
}
const key = this.activeKey(message.channel, message.eventId)
const controller = new AbortController()
this.active.set(key, controller)
let durableResult = false
try {
const rawResult = await this.execute(message, controller.signal)
if (controller.signal.aborted) {
await this.deliver(
if (message.text.length > this.maximumInputLength) {
durableResult = await this.tryDeliver(
this.result(message, {
status: 'cancelled',
error: '请求已取消'
status: 'rejected',
error: `消息过长,最多允许 ${this.maximumInputLength} 个字符`
}),
new AbortController().signal
)
return
}
const result = channelExecutorResultSchema.safeParse(rawResult)
if (!result.success) {
await this.deliver(
if (this.active.size >= this.maximumConcurrency) {
durableResult = await this.tryDeliver(
this.result(message, {
status: 'failed',
error: '请求返回了无效结果'
status: 'busy',
error: '当前请求较多,请稍后重试'
}),
controller.signal
new AbortController().signal
)
return
}
await this.deliver(this.result(message, result.data), controller.signal)
} catch {
const cancelled = controller.signal.aborted
await this.deliver(
this.result(message, {
status: cancelled ? 'cancelled' : 'failed',
error: cancelled ? '请求已取消' : '请求处理失败'
}),
new AbortController().signal
const key = this.activeKey(
message.channel,
message.accountId,
message.eventId
)
const controller = new AbortController()
this.active.set(key, controller)
try {
let rawResult: Awaited<ReturnType<ChannelExecutor>>
try {
rawResult = await this.execute(message, controller.signal)
} catch {
const cancelled = controller.signal.aborted
durableResult = await this.tryDeliver(
this.result(message, {
status: cancelled ? 'cancelled' : 'failed',
error: cancelled ? '请求已取消' : '请求处理失败'
}),
new AbortController().signal
)
return
}
if (controller.signal.aborted) {
durableResult = await this.tryDeliver(
this.result(message, {
status: 'cancelled',
error: '请求已取消'
}),
new AbortController().signal
)
return
}
const result = channelExecutorResultSchema.safeParse(rawResult)
if (!result.success) {
durableResult = await this.tryDeliver(
this.result(message, {
status: 'failed',
error: '请求返回了无效结果'
}),
controller.signal
)
return
}
durableResult = await this.tryDeliver(
this.result(message, result.data),
controller.signal
)
} finally {
this.active.delete(key)
}
} finally {
this.active.delete(key)
if (!durableResult) {
await this.dedupStore.release(
message.channel,
message.accountId,
message.eventId
)
}
}
}
@@ -420,7 +445,7 @@ export class ChannelService {
private async deliver(
message: ChannelResultMessage,
signal: AbortSignal
): Promise<void> {
): Promise<boolean> {
const entry = await this.outbox.enqueue(message)
try {
await this.driver.send(message, signal)
@@ -429,11 +454,60 @@ export class ChannelService {
} catch (error) {
await this.outbox.markFailed(entry.id)
this.onDeliveryFailure?.(error)
throw error
}
return true
}
private async tryDeliver(
message: ChannelResultMessage,
signal: AbortSignal
): Promise<boolean> {
try {
return await this.deliver(message, signal)
} catch (error) {
this.onDeliveryFailure?.(error)
return false
}
}
private activeKey(channel: string, eventId: string): string {
return `${channel}\u0000${eventId}`
private activeKey(
channel: string,
accountId: string,
eventId: string
): string {
return `${channel}\u0000${accountId}\u0000${eventId}`
}
private enqueue(rawMessage: unknown): void {
const parsed = channelInboundTextSchema.safeParse(rawMessage)
if (!parsed.success) {
throw new Error('通道消息格式无效')
}
if (parsed.data.channel !== this.driver.channel) {
throw new Error('通道消息来源不匹配')
}
const key =
`${parsed.data.channel}\u0000${parsed.data.accountId}` +
`\u0000${parsed.data.conversationId}`
const previous = this.conversationTails.get(key) ?? Promise.resolve()
const task =
this.conversationTails.has(key)
? previous
.catch(() => undefined)
.then(() => this.process(parsed.data))
: this.process(parsed.data)
const tail = task.then(
() => undefined,
() => undefined
)
this.conversationTails.set(key, tail)
void tail.finally(() => {
if (this.conversationTails.get(key) === tail) {
this.conversationTails.delete(key)
}
})
void task.catch(() => {
// The event claim is released when no durable result could be recorded.
})
}
}