Skip to content

Commit 2ad649e

Browse files
committed
feat(inbox): serve CL-7238 mail id read-back from the mailbox unique key
1 parent 75bb780 commit 2ad649e

3 files changed

Lines changed: 157 additions & 2 deletions

File tree

‎packages/inbox/src/delivery.ts‎

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,20 @@
33
// group) and status so list filters and the three-column UI work without
44
// re-deriving on every read.
55

6+
import { and, eq, or } from "drizzle-orm";
67
import {
78
deliverInboxItems,
9+
mailboxKey,
10+
principalMail,
811
type DeliverInboxItemsOpts,
912
type MailboxDb,
1013
type MailboxEventBus,
1114
} from "@corbits/mailbox";
12-
import type { MailboxDelivery, NotifyInboxItem } from "@corbits/notify/mailbox";
15+
import type {
16+
MailboxDelivery,
17+
NotifyInboxItem,
18+
ResolveExistingMailIds,
19+
} from "@corbits/notify/mailbox";
1320

1421
import { classificationFromRefs } from "./group";
1522

@@ -74,3 +81,58 @@ export function createWorkbenchMailboxDelivery(
7481
return deliverInboxItems(db, stamped, writeOpts);
7582
};
7683
}
84+
85+
/**
86+
* CL-7238 read-back for `@corbits/notify`'s crash window: a redelivery
87+
* reports an already-committed row as pre-existing (`id: null`), so the
88+
* host resolves it to its row id here and the missing dispatch rows get
89+
* repaired instead of lost. Served from the mailbox unique mail key —
90+
* the same `mailboxKey.inbox(source, externalId)` the write dedupes on —
91+
* so the lookup can never disagree with the delivery about which row an
92+
* item belongs to. Positional in, positional out, `null` where no row
93+
* exists; the dispatch store dedupes by (mail row, sink), so a benign
94+
* redelivery's repair never double-queues.
95+
*/
96+
export function createResolveExistingMailIds(
97+
db: MailboxDb,
98+
): ResolveExistingMailIds {
99+
return async (items) => {
100+
if (items.length === 0) return [];
101+
const keyed = items.map((item) => ({
102+
tenantId: item.tenantId,
103+
principalId: item.principalId,
104+
messageKey: mailboxKey.inbox(item.source, item.externalId),
105+
}));
106+
const rows = await db
107+
.select({
108+
id: principalMail.id,
109+
tenantId: principalMail.tenantId,
110+
principalId: principalMail.principalId,
111+
messageKey: principalMail.messageKey,
112+
})
113+
.from(principalMail)
114+
.where(
115+
or(
116+
...keyed.map((key) =>
117+
and(
118+
eq(principalMail.tenantId, key.tenantId),
119+
eq(principalMail.principalId, key.principalId),
120+
eq(principalMail.messageKey, key.messageKey),
121+
),
122+
),
123+
),
124+
);
125+
const idsByKey = new Map(
126+
rows.map((row) => [
127+
`${row.tenantId}\n${row.principalId}\n${row.messageKey}`,
128+
row.id,
129+
]),
130+
);
131+
return keyed.map(
132+
(key) =>
133+
idsByKey.get(
134+
`${key.tenantId}\n${key.principalId}\n${key.messageKey}`,
135+
) ?? null,
136+
);
137+
};
138+
}

‎packages/inbox/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export {
2424
type WorkbenchInboxStatus,
2525
} from "./vocabulary";
2626
export {
27+
createResolveExistingMailIds,
2728
createWorkbenchMailboxDelivery,
2829
type CreateWorkbenchMailboxDeliveryOpts,
2930
} from "./delivery";

‎packages/inbox/test/delivery.test.ts‎

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,18 @@ import {
2020
deliverCredentialMail,
2121
deliverMentionMail,
2222
deliverRunFailureMail,
23+
type ApprovalNotification,
2324
type NotifyDeliveryDeps,
25+
type NotifyDispatchStore,
26+
type SinkDeliveryResult,
2427
} from "@corbits/notify";
2528

2629
import { setupDatabase } from "../../../scripts/db-setup";
2730
import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url";
28-
import { createWorkbenchMailboxDelivery } from "../src/delivery";
31+
import {
32+
createResolveExistingMailIds,
33+
createWorkbenchMailboxDelivery,
34+
} from "../src/delivery";
2935
import { inboxGroupOf } from "../src/group";
3036
import { WORKBENCH_INBOX_PRIORITIES } from "../src/vocabulary";
3137
import { dbGate } from "../../../scripts/e2e/db-gate";
@@ -213,5 +219,91 @@ describeIfDb(
213219
await mailboxDb.close();
214220
}
215221
}, 15000);
222+
223+
test("a crash between mail write and dispatch enqueue is repaired on redelivery without doubles (CL-7238)", async () => {
224+
const mailboxDb = createMailboxDb(scratchUrl);
225+
try {
226+
const innerDispatch = createInMemoryNotifyDispatchStore();
227+
let crashNext = true;
228+
const dispatch: NotifyDispatchStore = {
229+
...innerDispatch,
230+
enqueue: async (inputs) => {
231+
if (crashNext) {
232+
crashNext = false;
233+
throw new Error(
234+
"boom: crash between the mail write and the dispatch enqueue",
235+
);
236+
}
237+
await innerDispatch.enqueue(inputs);
238+
},
239+
};
240+
const delivered: SinkDeliveryResult = { status: "delivered" };
241+
const sinks = createSinkRegistry();
242+
sinks.register({
243+
name: "always",
244+
isEnabledFor: async () => true,
245+
deliver: async () => delivered,
246+
});
247+
const deps: NotifyDeliveryDeps = {
248+
mail: createWorkbenchMailboxDelivery({ db: mailboxDb.db }),
249+
addressing: {
250+
inbox: (recipient) => `${recipient.principalId}@inbox.test`,
251+
from: (kind) => `${kind}@notify.test`,
252+
},
253+
dispatch,
254+
resolveExistingMailIds: createResolveExistingMailIds(mailboxDb.db),
255+
sinks,
256+
};
257+
const event: ApprovalNotification = {
258+
kind: "approval",
259+
approvalId: generateId("approval"),
260+
tenantId,
261+
runId: generateId("workflowRun"),
262+
deploymentId: generateId("workflowRun"),
263+
toolName: "quarantine_token",
264+
toolArguments: { repo: "corbitsdev/workbench" },
265+
recipients: [{ tenantId, principalId }],
266+
createdAt: new Date().toISOString(),
267+
};
268+
269+
// The first attempt commits its mail row through the real adapter,
270+
// then dies before the dispatch enqueue — the crash window.
271+
await expect(deliverApprovalMail(deps, event)).rejects.toThrow("boom");
272+
273+
// The redelivery dedupes on the mail key (no new row), resolves the
274+
// committed row's id through the adapter read-back, and queues the
275+
// dispatch the crash swallowed.
276+
const redelivery = await deliverApprovalMail(deps, event);
277+
expect(redelivery.deliveredMailboxRowIds).toEqual([]);
278+
expect(redelivery.queuedDispatchCount).toBe(1);
279+
280+
const page = await listUserMailbox(mailboxDb.db, {
281+
tenantId,
282+
principalId,
283+
limit: 10,
284+
view: "all",
285+
priorities: WORKBENCH_INBOX_PRIORITIES,
286+
});
287+
// Exactly one mail row for the redelivered event — the crash and
288+
// both redeliveries deduped onto the first attempt's row.
289+
const matching = page.items.filter((item) =>
290+
(item.subject ?? "").includes("quarantine_token"),
291+
);
292+
expect(matching).toHaveLength(1);
293+
const mailId = matching[0]?.id;
294+
if (mailId === undefined)
295+
throw new Error("expected the redelivered mail row to list");
296+
expect(await dispatch.listFor(mailId)).toHaveLength(1);
297+
298+
// A further benign redelivery repairs the same row again, and the
299+
// dispatch store's (mail row, sink) dedupe holds it to one row.
300+
const third = await deliverApprovalMail(deps, event);
301+
expect(third.deliveredMailboxRowIds).toEqual([]);
302+
expect(third.queuedDispatchCount).toBe(1);
303+
expect(await dispatch.listFor(mailId)).toHaveLength(1);
304+
} finally {
305+
await mailboxDb.close();
306+
}
307+
}, 15000);
216308
},
217309
);

0 commit comments

Comments
 (0)