Skip to content

Commit 60e430b

Browse files
os-zhuangclaude
andauthored
fix(plugin-email): send() releases an insert-assigned row id from managedRowIds (#5169) (#5524)
`EmailPersistence` is a public interface whose `insert` may answer with an id of its own — a database-assigned primary key, an external delivery system's receipt id. `sendInternal()` reserved that id as service-managed (so the `sys_email` afterInsert outbox drain skips a row `send()` is already delivering) but its `finally` released only the id it had minted itself, so every such message left one entry behind for the life of the process. The memory is the smaller half. `isServiceManaged(persistedId)` stayed true forever, and that is a standing "this row belongs to a live send()" assertion which both the drain hook and the boot outbox sweep (#5161) trust and nothing ever re-checks — a row stranded at `queued` under such an id would be skipped by every future sweep. The id now travels in a variable declared outside the try (`extraManagedId`) and the `finally` releases exactly what was reserved, so the release condition can never drift from the reservation condition. The window itself is unchanged: the release still happens after inline delivery has finalized the row and after queue mode has published its job. No in-repo path reached the branch — the plugin's own persistence returns the id it was given, because ObjectQL's insert echoes `row.id` — so this was reachable only through a custom `EmailPersistence`. Two tests pin it, one per delivery mode, and the inline one also asserts the managed window still covers the row's `sent` finalize so a future "release earlier" cannot pass silently. Claude-Session: https://claude.ai/code/session_01BWS4heBoAitLmzCLhcYdbK Co-authored-by: Claude <noreply@anthropic.com>
1 parent f2a1c0b commit 60e430b

4 files changed

Lines changed: 107 additions & 1 deletion

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/plugin-email": patch
3+
---
4+
5+
fix(plugin-email): `send()` releases an insert-assigned row id from `managedRowIds` instead of leaking it (#5169)
6+
7+
`EmailPersistence.insert` is a **public** interface and may answer with an id of
8+
its own — a database-assigned primary key, an external delivery system's receipt
9+
id. `send()` reserves that id as service-managed too (so the `sys_email`
10+
`afterInsert` outbox drain skips a row `send()` is already delivering), but its
11+
`finally` only released the id `send()` had minted. The insert-assigned one was
12+
reserved and never released.
13+
14+
Two consequences, both now fixed:
15+
16+
- **memory** — one leaked string per message in a `Set` that lives as long as the
17+
process;
18+
- **semantics**`isServiceManaged(persistedId)` stayed true forever. Ids are
19+
unique, so no other row was mistaken for a managed one, but that entry is a
20+
standing "this row belongs to a live `send()`" assertion which the drain hook
21+
and the boot outbox sweep (#5161) both trust and nothing ever re-checks: a row
22+
stranded at `queued` under such an id would be skipped by every future sweep.
23+
24+
The reservation window is unchanged — the release still happens in the same
25+
`finally`, after inline delivery has finalized the row and after queue mode has
26+
published the job, so nothing that relied on the row reading managed *during*
27+
`send()` is affected. The in-repo persistence returns the id it was given
28+
(ObjectQL echoes `row.id`), so no in-repo path ever reached the leaking branch;
29+
this was reachable only by a custom `EmailPersistence` implementation.

packages/plugins/plugin-email/src/email-service.queue-delivery.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,30 @@ describe('EmailService — queue delivery on', () => {
189189
expect(svc.isServiceManaged(res.id)).toBe(false);
190190
});
191191

192+
it('releases an insert-assigned id once the job is published (#5169)', async () => {
193+
// Queue mode returns EARLY (right after the publish), so the release of an
194+
// insert-assigned id happens on that path too — and it must, because the
195+
// row is now the worker's: the boot outbox sweep decides whether to requeue
196+
// it by asking `isServiceManaged`, and a permanently-true answer would make
197+
// a stranded row unsweepable forever.
198+
const queue = makeQueue();
199+
const transport = { send: vi.fn(async () => ({ messageId: '<x>' })) };
200+
const persistence: EmailPersistence = {
201+
async insert() { return { id: 'db-pk-9' }; },
202+
async update() { /* noop */ },
203+
};
204+
const svc = new EmailService({
205+
transport, defaultFrom: 'no@reply.com', persistence, queueDelivery: wiring(queue),
206+
});
207+
208+
const res = await svc.send(MSG);
209+
210+
// The job references the PERSISTED id, and that id is no longer managed.
211+
expect(res).toMatchObject({ id: 'db-pk-9', status: 'queued' });
212+
expect(queue.published[0].data).toEqual({ rowId: 'db-pk-9' });
213+
expect(svc.isServiceManaged('db-pk-9')).toBe(false);
214+
});
215+
192216
it('sendInline() bypasses the queue — the mail/test path', async () => {
193217
const transport = { send: vi.fn(async () => ({ messageId: '<live@x>' })) };
194218
const queue = makeQueue();

packages/plugins/plugin-email/src/email-service.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,35 @@ describe('EmailService', () => {
165165
expect(svc.isServiceManaged(insertedId!)).toBe(false); // cleared after send
166166
});
167167

168+
it('releases an insert-ASSIGNED row id from the managed set too (#5169)', async () => {
169+
// `EmailPersistence` is public and its `insert` may answer with an id of
170+
// its own — a database-assigned primary key, an external delivery system's
171+
// receipt id. `send()` reserves that id as managed as well; the bug was
172+
// that it never released it, so `isServiceManaged(persistedId)` stayed true
173+
// forever: one leaked entry per message, and a "belongs to a live send()"
174+
// assertion the drain hook and the boot sweep trust but nobody re-checks.
175+
let managedDuringDelivery: boolean | undefined;
176+
const transport = { send: vi.fn(async () => ({ messageId: '<m@x>' })) };
177+
let svc!: EmailService;
178+
const persistence: EmailPersistence = {
179+
// Ignores the minted id and hands back the row's real (DB) key.
180+
async insert() { return { id: 'db-pk-7' }; },
181+
async update(id) {
182+
// The `sent` finalize runs INSIDE send(), i.e. while this row is still
183+
// send()'s to deliver — the window the managed flag exists to protect
184+
// must NOT shrink to make the release possible.
185+
managedDuringDelivery = svc.isServiceManaged(String(id));
186+
},
187+
};
188+
svc = new EmailService({ transport, defaultFrom: 'no@reply.com', persistence });
189+
190+
const res = await svc.send({ to: 'a@b.com', subject: 'Hi', text: 'x' });
191+
192+
expect(res).toMatchObject({ id: 'db-pk-7', status: 'sent' });
193+
expect(managedDuringDelivery).toBe(true); // window kept
194+
expect(svc.isServiceManaged('db-pk-7')).toBe(false); // released, not leaked
195+
});
196+
168197
it('deliverPersistedRow delivers an existing row WITHOUT inserting a new one', async () => {
169198
const transport = { send: vi.fn(async () => ({ messageId: '<drained@x>' })) };
170199
const { p, rows } = makePersistence();

packages/plugins/plugin-email/src/email-service.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -583,14 +583,29 @@ export class EmailService implements IEmailService {
583583
// Reserve the row id BEFORE persistence.insert so the drain hook
584584
// (which fires synchronously inside that insert) sees it as managed
585585
// and skips it — `send()` owns this row's delivery.
586+
//
587+
// `EmailPersistence` is a PUBLIC interface and its `insert` may answer with
588+
// an id of its own (a database-assigned primary key, an external delivery
589+
// system's receipt id). That id is reserved too — and it has to be released
590+
// by the same `finally`, which is why it is held in a variable declared
591+
// OUT here rather than recomputed from `persistedId` inside the try (#5169).
592+
// Reserving without releasing would leave `isServiceManaged(persistedId)`
593+
// permanently true: one leaked string per message for the life of the
594+
// process, and — worse than the memory — a standing "this row belongs to a
595+
// live send()" assertion that the drain hook and the boot outbox sweep both
596+
// trust and nothing ever re-checks.
597+
let extraManagedId: string | undefined;
586598
this.managedRowIds.add(id);
587599
try {
588600
let persistedId: string | undefined;
589601
if (this.options.persistence) {
590602
try {
591603
const res = await this.options.persistence.insert(baseRow);
592604
persistedId = typeof res === 'string' ? res : res?.id ?? id;
593-
if (persistedId !== id) this.managedRowIds.add(persistedId);
605+
if (persistedId !== id) {
606+
this.managedRowIds.add(persistedId);
607+
extraManagedId = persistedId;
608+
}
594609
} catch (err: any) {
595610
this.options.logger?.warn('EmailService: sys_email persist failed (non-fatal)', { error: err?.message });
596611
}
@@ -625,7 +640,16 @@ export class EmailService implements IEmailService {
625640
// reclaimed afterwards, which is why the keys travel with the delivery.
626641
return await this.deliverNormalized(rowId, normalized, undefined, storageKeys);
627642
} finally {
643+
// Release EXACTLY what was reserved above — both ids, and only here.
644+
// Here and not earlier: the reservation has to outlive the whole body,
645+
// because in inline mode the delivery (and the `sent`/`failed` update of
646+
// this very row) happens inside the try, and a sweep that ran mid-flight
647+
// must still see the row as `send()`'s. Once this returns, ownership is
648+
// over in both modes: inline delivery is finished, and a queued row is
649+
// the worker's — with the row committed at `queued`, re-checkable, which
650+
// is what makes the boot sweep a backstop rather than a double-send.
628651
this.managedRowIds.delete(id);
652+
if (extraManagedId !== undefined) this.managedRowIds.delete(extraManagedId);
629653
}
630654
}
631655

0 commit comments

Comments
 (0)