Skip to content

Commit 230f720

Browse files
committed
fix(storage): preserve reset retry receipts
Generated-by: Maka
1 parent 28a62bb commit 230f720

2 files changed

Lines changed: 57 additions & 42 deletions

File tree

packages/storage/src/__tests__/external-conversation-authority.test.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,18 +75,27 @@ describe('interactive external-conversation authority', () => {
7575
});
7676
});
7777

78-
test('bounds release receipts and purges bindings by Session', async () => {
78+
test('keeps in-horizon receipts and refuses capacity before deleting a newer binding', async () => {
7979
await withInteractiveRoot(async ({ root, capability }) => {
8080
const owner = await tryAcquireInteractiveRootOwner(capability);
8181
assert.ok(owner);
8282
if (!owner) return;
8383
const writer = await openInteractiveExternalConversationAuthorityForWrite(owner.lease);
8484
try {
85-
for (let index = 0; index <= EXTERNAL_CONVERSATION_RELEASE_RECEIPT_LIMIT; index += 1) {
85+
for (let index = 0; index < EXTERNAL_CONVERSATION_RELEASE_RECEIPT_LIMIT; index += 1) {
8686
await writer.resolve('feishu:chat-1', `session-${index}`);
8787
await writer.release('feishu:chat-1', `release-${index}`);
8888
}
8989
await writer.resolve('feishu:chat-1', 'session-final');
90+
assert.deepEqual(await writer.release('feishu:chat-1', 'release-0'), {
91+
hadBinding: true,
92+
});
93+
assert.equal((await writer.lookup('feishu:chat-1'))?.sessionId, 'session-final');
94+
await assert.rejects(
95+
() => writer.release('feishu:chat-1', 'release-over-capacity'),
96+
/receipt capacity is full/,
97+
);
98+
assert.equal((await writer.lookup('feishu:chat-1'))?.sessionId, 'session-final');
9099
assert.equal(await writer.purgeSession('session-final'), 1);
91100
const database = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true });
92101
try {
@@ -146,10 +155,10 @@ describe('interactive external-conversation authority', () => {
146155
'sha256:' || printf('%064x', value),
147156
'seed',
148157
0,
149-
value
158+
?
150159
FROM sequence
151160
`)
152-
.run(EXTERNAL_CONVERSATION_RELEASE_RECEIPT_TOTAL_LIMIT - 1);
161+
.run(EXTERNAL_CONVERSATION_RELEASE_RECEIPT_TOTAL_LIMIT - 2, Date.now());
153162
} finally {
154163
database.close();
155164
}

packages/storage/src/external-conversation-authority.ts

Lines changed: 44 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const EXTERNAL_CONVERSATION_BINDING_LIMIT = 500;
1414
export const EXTERNAL_CONVERSATION_RELEASE_RECEIPT_LIMIT = 64;
1515
export const EXTERNAL_CONVERSATION_RELEASE_RECEIPT_TOTAL_LIMIT =
1616
EXTERNAL_CONVERSATION_BINDING_LIMIT * EXTERNAL_CONVERSATION_RELEASE_RECEIPT_LIMIT;
17+
export const EXTERNAL_CONVERSATION_RELEASE_RETRY_HORIZON_MS = 7 * 24 * 60 * 60 * 1_000;
1718

1819
const SAFE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
1920
const writerBrand: unique symbol = Symbol('InteractiveExternalConversationAuthorityWriter');
@@ -208,6 +209,16 @@ class SqliteExternalConversationAuthority implements ExternalConversationAuthori
208209
const conversationDigest = digestConversationId(conversationId);
209210
assertSafeId(operationId, 'External-conversation release operation id');
210211
return this.#lease.transaction('write', () => {
212+
const now = Date.now();
213+
// Exact reset deduplication is guaranteed for this explicit platform
214+
// retry horizon. Prune only expired receipts; reaching either bound
215+
// rejects a new reset before it can delete a binding.
216+
this.#lease.database
217+
.prepare(`
218+
DELETE FROM external_conversation_release_receipts
219+
WHERE committed_at < ?
220+
`)
221+
.run(Math.max(0, now - EXTERNAL_CONVERSATION_RELEASE_RETRY_HORIZON_MS));
211222
const receipt = this.#lease.database
212223
.prepare(`
213224
SELECT had_binding AS hadBinding
@@ -217,6 +228,31 @@ class SqliteExternalConversationAuthority implements ExternalConversationAuthori
217228
.get(conversationDigest, operationId) as { hadBinding?: unknown } | undefined;
218229
if (receipt) return Object.freeze({ hadBinding: decodeBoolean(receipt.hadBinding) });
219230

231+
const conversationReceiptCount = this.#lease.database
232+
.prepare(`
233+
SELECT COUNT(*) AS count
234+
FROM external_conversation_release_receipts
235+
WHERE conversation_digest = ?
236+
`)
237+
.get(conversationDigest) as { count?: unknown };
238+
const totalReceiptCount = this.#lease.database
239+
.prepare('SELECT COUNT(*) AS count FROM external_conversation_release_receipts')
240+
.get() as { count?: unknown };
241+
const perConversation = decodeCount(
242+
conversationReceiptCount.count,
243+
'external-conversation release receipt count',
244+
);
245+
const total = decodeCount(
246+
totalReceiptCount.count,
247+
'external-conversation release receipt total count',
248+
);
249+
if (
250+
perConversation >= EXTERNAL_CONVERSATION_RELEASE_RECEIPT_LIMIT ||
251+
total >= EXTERNAL_CONVERSATION_RELEASE_RECEIPT_TOTAL_LIMIT
252+
) {
253+
throw new Error('External-conversation release receipt capacity is full');
254+
}
255+
220256
const removed = this.#lease.database
221257
.prepare('DELETE FROM external_conversation_bindings WHERE conversation_digest = ?')
222258
.run(conversationDigest).changes;
@@ -230,44 +266,7 @@ class SqliteExternalConversationAuthority implements ExternalConversationAuthori
230266
conversation_digest, operation_id, had_binding, committed_at
231267
) VALUES (?, ?, ?, ?)
232268
`)
233-
.run(conversationDigest, operationId, hadBinding ? 1 : 0, Date.now());
234-
this.#lease.database
235-
.prepare(`
236-
DELETE FROM external_conversation_release_receipts
237-
WHERE conversation_digest = ?
238-
AND operation_id NOT IN (
239-
SELECT operation_id
240-
FROM external_conversation_release_receipts
241-
WHERE conversation_digest = ?
242-
ORDER BY committed_at DESC, operation_id DESC
243-
LIMIT ?
244-
)
245-
`)
246-
.run(conversationDigest, conversationDigest, EXTERNAL_CONVERSATION_RELEASE_RECEIPT_LIMIT);
247-
const receiptCount = this.#lease.database
248-
.prepare('SELECT COUNT(*) AS count FROM external_conversation_release_receipts')
249-
.get() as { count?: unknown };
250-
if (
251-
typeof receiptCount.count !== 'number' ||
252-
!Number.isSafeInteger(receiptCount.count) ||
253-
receiptCount.count < 0
254-
) {
255-
throw new Error('Invalid external-conversation release receipt count');
256-
}
257-
const excess = receiptCount.count - EXTERNAL_CONVERSATION_RELEASE_RECEIPT_TOTAL_LIMIT;
258-
if (excess > 0) {
259-
this.#lease.database
260-
.prepare(`
261-
DELETE FROM external_conversation_release_receipts
262-
WHERE rowid IN (
263-
SELECT rowid
264-
FROM external_conversation_release_receipts
265-
ORDER BY committed_at ASC, conversation_digest ASC, operation_id ASC
266-
LIMIT ?
267-
)
268-
`)
269-
.run(excess);
270-
}
269+
.run(conversationDigest, operationId, hadBinding ? 1 : 0, now);
271270
return Object.freeze({ hadBinding });
272271
});
273272
}
@@ -342,6 +341,13 @@ class SqliteExternalConversationAuthority implements ExternalConversationAuthori
342341
}
343342
}
344343

344+
function decodeCount(value: unknown, label: string): number {
345+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
346+
throw new Error(`Invalid ${label}`);
347+
}
348+
return value;
349+
}
350+
345351
function digestConversationId(conversationId: string): string {
346352
if (
347353
typeof conversationId !== 'string' ||

0 commit comments

Comments
 (0)