fix: fence background delivery claims (Closes #479)
This commit is contained in:
1 parent
8206856d94
commit
31b0e79aa1
3 files changed
+244
-43
No files matched your search
@@ -55,7 +55,11 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
||||
return transact;
|
||||
}
|
||||
|
||||
function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) {
|
||||
function createIssueSyncStore({
|
||||
transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000,
|
||||
createToken = () => globalThis.crypto?.randomUUID?.() ||
|
||||
(Date.now().toString(36) + '-' + Math.random().toString(36).slice(2)),
|
||||
} = {}) {
|
||||
const transact = transaction || createIndexedDbTransaction(indexedDB);
|
||||
|
||||
async function reconcile(items, outboxLane = 'issue') {
|
||||
@@ -96,13 +100,15 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
(candidate.status === 'queued' || candidate.status === 'sending') &&
|
||||
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
|
||||
if (!item) return null;
|
||||
const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs };
|
||||
const claimed = {
|
||||
...item, status: 'sending', claimUntil: timestamp + claimMs, claimToken: createToken(),
|
||||
};
|
||||
await records.put(claimed);
|
||||
return claimed;
|
||||
});
|
||||
}
|
||||
|
||||
async function claimBatch(ownerLogin, limit = 70) {
|
||||
async function planBatch(ownerLogin, limit = 70) {
|
||||
return transact(async records => {
|
||||
const timestamp = Number(now());
|
||||
const eligible = (await records.getAll()).filter(candidate =>
|
||||
@@ -117,14 +123,10 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
const selected = [];
|
||||
const maximum = Math.max(0, Number(limit) || 0);
|
||||
while (selected.length < maximum && (lanes.issue.length || lanes.authored.length)) {
|
||||
if (lanes.issue.length) selected.push(lanes.issue.shift());
|
||||
if (selected.length < maximum && lanes.authored.length) selected.push(lanes.authored.shift());
|
||||
if (lanes.issue.length) selected.push(lanes.issue.shift().id);
|
||||
if (selected.length < maximum && lanes.authored.length) selected.push(lanes.authored.shift().id);
|
||||
}
|
||||
const claimed = selected.map(item => ({
|
||||
...item, status: 'sending', claimUntil: timestamp + claimMs,
|
||||
}));
|
||||
for (const item of claimed) await records.put(item);
|
||||
return claimed;
|
||||
return selected;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,7 +137,9 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
if (!item || item.ownerLogin !== ownerLogin ||
|
||||
!['queued', 'sending'].includes(item.status) ||
|
||||
(item.status === 'sending' && Number(item.claimUntil) > timestamp)) return null;
|
||||
const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs };
|
||||
const claimed = {
|
||||
...item, status: 'sending', claimUntil: timestamp + claimMs, claimToken: createToken(),
|
||||
};
|
||||
await records.put(claimed);
|
||||
return claimed;
|
||||
});
|
||||
@@ -166,6 +170,32 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
});
|
||||
}
|
||||
|
||||
async function updateClaim(id, claimToken, transform) {
|
||||
return transact(async records => {
|
||||
const item = records.get ? await records.get(id) :
|
||||
(await records.getAll()).find(candidate => candidate.id === id);
|
||||
if (!item || item.status !== 'sending' || !claimToken || item.claimToken !== claimToken) {
|
||||
return false;
|
||||
}
|
||||
await records.put(transform(item));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function clearClaim(item, changes) {
|
||||
const { claimToken: _claimToken, ...unclaimed } = item;
|
||||
return { ...unclaimed, ...changes, claimUntil: 0 };
|
||||
}
|
||||
|
||||
async function renew(id, claimToken) {
|
||||
let renewed = null;
|
||||
await updateClaim(id, claimToken, item => {
|
||||
renewed = { ...item, claimUntil: Number(now()) + claimMs };
|
||||
return renewed;
|
||||
});
|
||||
return renewed;
|
||||
}
|
||||
|
||||
function preferenceId(ownerLogin) {
|
||||
return 'receipt-preference:' + String(ownerLogin || '').trim();
|
||||
}
|
||||
@@ -194,14 +224,18 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
update,
|
||||
claim,
|
||||
claimNext,
|
||||
claimBatch,
|
||||
complete: (id, deliveredIssue) => update(id, item => ({
|
||||
...item, status: 'sent', claimUntil: 0,
|
||||
planBatch,
|
||||
supportsClaimTokens: true,
|
||||
renew,
|
||||
checkpoint: updateClaim,
|
||||
complete: (id, claimToken, deliveredIssue) => updateClaim(id, claimToken, item => clearClaim(item, {
|
||||
status: 'sent',
|
||||
...(item.completionIntent === 'create-and-start' && deliveredIssue ? { deliveredIssue } : {}),
|
||||
})),
|
||||
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
|
||||
fail: (id, error, deliveryState) => update(id, item => ({
|
||||
...item, status: 'attention', claimUntil: 0, error,
|
||||
release: (id, claimToken) => updateClaim(id, claimToken,
|
||||
item => clearClaim(item, { status: 'queued' })),
|
||||
fail: (id, claimToken, error, deliveryState) => updateClaim(id, claimToken, item => clearClaim(item, {
|
||||
status: 'attention', error,
|
||||
...(deliveryState ? { deliveryState } : {}),
|
||||
})),
|
||||
snapshot: () => transact(async records =>
|
||||
@@ -225,6 +259,24 @@ function createBackgroundIssueSync({
|
||||
const activeRequests = new Set();
|
||||
const timeoutMs = Math.max(1, Number(requestTimeoutMs) || 15000);
|
||||
|
||||
const completeClaim = (item, delivered) => store.supportsClaimTokens
|
||||
? store.complete(item.id, item.claimToken, delivered) : store.complete(item.id, delivered);
|
||||
const releaseClaim = item => store.supportsClaimTokens
|
||||
? store.release(item.id, item.claimToken) : store.release(item.id);
|
||||
const failClaim = (item, error, deliveryState) => store.supportsClaimTokens
|
||||
? store.fail(item.id, item.claimToken, error, deliveryState)
|
||||
: store.fail(item.id, error, deliveryState);
|
||||
const checkpointClaim = (item, transform) => store.supportsClaimTokens
|
||||
? store.checkpoint(item.id, item.claimToken, transform) : store.update?.(item.id, transform);
|
||||
|
||||
async function requestStage(item, url, options) {
|
||||
if (store.renew) {
|
||||
const renewed = await store.renew(item.id, item.claimToken);
|
||||
if (!renewed) throw new Error('Background delivery claim was lost.');
|
||||
}
|
||||
return requestJson(url, options);
|
||||
}
|
||||
|
||||
async function requestJson(url, options = {}) {
|
||||
const controller = new AbortController();
|
||||
activeRequests.add(controller);
|
||||
@@ -368,12 +420,13 @@ function createBackgroundIssueSync({
|
||||
let deliveredIssue = item.deliveredIssue;
|
||||
if (!deliveredIssue) {
|
||||
const request = deliveryRequest(item);
|
||||
deliveredIssue = await requestJson(request.url, request.options);
|
||||
await store.update?.(item.id, current => ({ ...current, deliveredIssue }));
|
||||
deliveredIssue = await requestStage(item, request.url, request.options);
|
||||
await checkpointClaim(item, current => ({ ...current, deliveredIssue }));
|
||||
}
|
||||
let attachmentMarkdown = item.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestJson(
|
||||
const uploaded = await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -394,9 +447,10 @@ function createBackgroundIssueSync({
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await store.update?.(item.id, current => ({ ...current, deliveredIssue, attachmentMarkdown }));
|
||||
await checkpointClaim(item, current => ({ ...current, deliveredIssue, attachmentMarkdown }));
|
||||
}
|
||||
await requestJson(
|
||||
await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -414,7 +468,8 @@ function createBackgroundIssueSync({
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
let attachmentMarkdown = item.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestJson(
|
||||
const uploaded = await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -435,10 +490,11 @@ function createBackgroundIssueSync({
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await store.update?.(item.id, current => ({ ...current, attachmentMarkdown }));
|
||||
await checkpointClaim(item, current => ({ ...current, attachmentMarkdown }));
|
||||
}
|
||||
const text = String(item.body || '').trim();
|
||||
return requestJson(
|
||||
return requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/comments',
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -456,30 +512,30 @@ function createBackgroundIssueSync({
|
||||
try {
|
||||
const delivered = item.attachment && item.kind === 'issue-comment' ?
|
||||
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
|
||||
await deliverIssueCapture(item) : await requestJson(request.url, request.options);
|
||||
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
|
||||
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
|
||||
const error = new Error('Issue closure was not confirmed.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await store.complete(item.id, delivered);
|
||||
await completeClaim(item, delivered);
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status === 401) {
|
||||
await store.release(item.id);
|
||||
await releaseClaim(item);
|
||||
throw error;
|
||||
}
|
||||
if (status >= 400 && status < 500) {
|
||||
await store.fail(
|
||||
item.id,
|
||||
await failClaim(
|
||||
item,
|
||||
String(error?.message || 'Issue needs attention').slice(0, 240),
|
||||
error?.code === 'delivery_uncertain' ? 'uncertain' : undefined,
|
||||
);
|
||||
return { attention: true, error, receipt: receiptFor(item, 'attention') };
|
||||
}
|
||||
await store.release(item.id);
|
||||
await releaseClaim(item);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -508,7 +564,30 @@ function createBackgroundIssueSync({
|
||||
if (result.attention) attention += 1;
|
||||
if (result.receipt) receipts.push(result.receipt);
|
||||
};
|
||||
if (store.claimBatch) {
|
||||
if (store.planBatch) {
|
||||
const planned = await store.planBatch(login, batchSize);
|
||||
let next = 0;
|
||||
let authenticationError = null;
|
||||
let transientError = null;
|
||||
const worker = async () => {
|
||||
while (!purgeRequested && !authenticationError && next < planned.length) {
|
||||
const id = planned[next++];
|
||||
const item = await store.claim(id, login);
|
||||
if (!item) continue;
|
||||
try {
|
||||
collect(await deliver(item));
|
||||
} catch (error) {
|
||||
if (Number(error?.status || 0) === 401) authenticationError = error;
|
||||
else if (!transientError) transientError = error;
|
||||
}
|
||||
}
|
||||
};
|
||||
const concurrency = Math.max(1, Math.min(Number(maxConcurrency) || 1, planned.length));
|
||||
await Promise.all(Array.from({ length: concurrency }, worker));
|
||||
if (purgeRequested) throw new Error('Background delivery canceled.');
|
||||
if (authenticationError) throw authenticationError;
|
||||
if (transientError) throw transientError;
|
||||
} else if (store.claimBatch) {
|
||||
const claimed = await store.claimBatch(login, batchSize);
|
||||
let next = 0;
|
||||
let authenticationError = null;
|
||||
|
||||
Reference in new issue
Block a user