feat(tasks,server,web): bridge outbox events to the SSE bus (U3)

OutboxRelay gains a `mirror` option: listed types are claimed even without a
task route and, after the claim transaction commits, published in order to
the realtime bus (publish failures are logged, delivery state unaffected).
The worker mirrors feed.*, decision.*, download.*, organization.* and
subscription.* (feed.due excluded) with their business-id payloads, so the
home page, inbox, task stream and feed tables finally receive them. Feed
tables refresh on feed.fetched / feed.health_changed; the task stream also
listens to feed and decision events.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:43:49 +08:00
1 parent 04401309fb
commit 615917bb45
6 files changed
+136 -6

No files matched your search

+20 -2
View File
@@ -34,6 +34,12 @@ export interface OutboxRelayOptions {
/** 失败达到该次数后置 status='dead'(默认 10);其间按 2^attempts 秒退避,上限 5 分钟 */
maxAttempts?: number;
logSink?: LogSink;
/**
* 镜像到实时总线(SSE):列出的 type 在投递成功、认领事务提交后逐条交给 publish(按 at, id 顺序)。
* 只在 mirror 里、没有路由的 type 同样会被认领并直接标记已投递。publish 失败只记日志,不影响投递状态
* (实时推送是尽力而为,前端断线或错过时靠重新拉快照)。
*/
mirror?: { types: readonly string[]; publish: (event: OutboxEvent) => unknown };
}
/**
@@ -60,10 +66,12 @@ export class OutboxRelay {
/** 处理一批;返回本批已投递与失败数 */
async pollOnce(): Promise<{ published: number; failed: number }> {
const types = Object.keys(this.opts.routes);
const mirrorTypes = new Set(this.opts.mirror?.types ?? []);
const types = [...new Set([...Object.keys(this.opts.routes), ...mirrorTypes])];
if (types.length === 0) return { published: 0, failed: 0 };
let published = 0;
let failed = 0;
const toMirror: OutboxEvent[] = [];
await this.opts.db.transaction().execute(async (trx) => {
const rows = await sql<OutboxEvent & { idempotency_key: string | null; attempts: number }>`
SELECT id, type, payload, at, idempotency_key, attempts FROM outbox_event
@@ -73,7 +81,8 @@ export class OutboxRelay {
FOR UPDATE SKIP LOCKED`.execute(trx);
for (const ev of rows.rows) {
try {
const route = this.opts.routes[ev.type]!(ev);
const routeFn = this.opts.routes[ev.type];
const route = routeFn ? routeFn(ev) : null;
if (route) {
const payloadTrace = (ev.payload as { traceId?: unknown } | null)?.traceId;
await this.opts.engine.enqueue(route.task, route.input, {
@@ -85,6 +94,7 @@ export class OutboxRelay {
await sql`UPDATE outbox_event SET status = 'dispatched', published_at = now(), attempts = attempts + 1,
last_error = NULL WHERE id = ${ev.id}`.execute(trx);
published++;
if (mirrorTypes.has(ev.type)) toMirror.push({ id: ev.id, type: ev.type, payload: ev.payload, at: ev.at });
} catch (err) {
failed++;
// 退避后重试;超过上限置 dead(需人工处理)
@@ -99,6 +109,14 @@ export class OutboxRelay {
}
}
});
// 提交后才推送:回滚的事件不会先被浏览器看到
for (const ev of toMirror) {
try {
await this.opts.mirror!.publish(ev);
} catch (err) {
this.log.error("outbox mirror publish failed", { eventId: ev.id, type: ev.type, err });
}
}
return { published, failed };
}
+36
View File
@@ -127,4 +127,40 @@ describe.skipIf(!hasPg)("OutboxRelay", () => {
}>`SELECT status FROM outbox_event WHERE type = 'echo.requested' AND published_at IS NULL`.execute(db);
expect(dead.rows).toEqual([{ status: "dead" }]);
});
it("mirror:投递成功后按顺序镜像到实时总线;只镜像不路由的 type 也认领,失败的不镜像", async () => {
await sql`DELETE FROM outbox_event`.execute(db);
await sql`INSERT INTO outbox_event (type, payload, at) VALUES
('mirror.only', '{"recordId":"r1"}'::jsonb, now() - interval '3 second'),
('echo.requested', '{"n":1001}'::jsonb, now() - interval '2 second'),
('echo.requested', '{"n":"bad"}'::jsonb, now() - interval '1 second'),
('not.mirrored', '{}'::jsonb, now())`.execute(db);
const seen: { type: string; payload: unknown }[] = [];
const relay = new OutboxRelay({
db,
engine,
routes: { "echo.requested": (ev) => ({ task: "echo", input: ev.payload }) },
logSink: () => {},
mirror: {
types: ["mirror.only", "echo.requested"],
publish: (ev) => {
seen.push({ type: ev.type, payload: ev.payload });
if (ev.type === "mirror.only") throw new Error("bus down"); // 推送失败不影响投递
},
},
});
expect(await relay.pollOnce()).toEqual({ published: 2, failed: 1 });
expect(seen).toEqual([
{ type: "mirror.only", payload: { recordId: "r1" } },
{ type: "echo.requested", payload: { n: 1001 } },
]);
const status = await sql<{ type: string; status: string }>`
SELECT type, status FROM outbox_event ORDER BY at`.execute(db);
expect(status.rows.map((r) => `${r.type}:${r.status}`)).toEqual([
"mirror.only:dispatched",
"echo.requested:dispatched",
"echo.requested:pending",
"not.mirrored:pending",
]);
});
});