fix: recover stalled background delivery (#305)
CI / lint (pull_request) Successful in 30s
CI / build-frontend (pull_request) Successful in 4s

This commit is contained in:
timmy committed 2026-08-08 13:46:46 +00:00
1 parent 6138b1f24e
commit add45365ab
5 files changed
+172 -17

No files matched your search

+35 -5
View File
@@ -196,10 +196,35 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
function createBackgroundIssueSync({
store, fetchJson, base = '', maxConcurrency = 3, batchSize = 70,
batch = work => work(),
batch = work => work(), requestTimeoutMs = 15000,
}) {
let purgeRequested = false;
let activeFlush = null;
const activeRequests = new Set();
const timeoutMs = Math.max(1, Number(requestTimeoutMs) || 15000);
async function requestJson(url, options = {}) {
const controller = new AbortController();
activeRequests.add(controller);
let timer;
const interrupted = new Promise((resolve, reject) => {
controller.signal.addEventListener('abort', () => {
reject(new Error(purgeRequested
? 'Background request canceled.'
: 'Background request timed out.'));
}, { once: true });
timer = setTimeout(() => controller.abort(), timeoutMs);
});
try {
return await Promise.race([
Promise.resolve().then(() => fetchJson(url, { ...options, signal: controller.signal })),
interrupted,
]);
} finally {
clearTimeout(timer);
activeRequests.delete(controller);
}
}
function receiptFor(item, status, delivered = {}) {
if (status === 'attention') {
return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' };
@@ -265,7 +290,7 @@ function createBackgroundIssueSync({
async function deliver(item) {
const request = deliveryRequest(item);
try {
const delivered = await fetchJson(request.url, request.options);
const delivered = await requestJson(request.url, request.options);
await store.complete(item.id);
const receipt = receiptFor(item, 'confirmed', delivered);
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
@@ -294,7 +319,7 @@ function createBackgroundIssueSync({
}
async function runFlush() {
const identity = await fetchJson(base + 'api/v1/background-identity', {
const identity = await requestJson(base + 'api/v1/background-identity', {
headers: { Accept: 'application/json' }, cache: 'no-store',
});
const login = String(identity?.login || '').trim();
@@ -314,7 +339,7 @@ function createBackgroundIssueSync({
let authenticationError = null;
let transientError = null;
const worker = async () => {
while (!authenticationError && next < claimed.length) {
while (!purgeRequested && !authenticationError && next < claimed.length) {
const item = claimed[next++];
try {
collect(await deliver(item));
@@ -326,13 +351,17 @@ function createBackgroundIssueSync({
};
const concurrency = Math.max(1, Math.min(Number(maxConcurrency) || 1, claimed.length));
await Promise.all(Array.from({ length: concurrency }, worker));
if (purgeRequested) {
await Promise.all(claimed.slice(next).map(item => store.release(item.id)));
throw new Error('Background delivery canceled.');
}
if (authenticationError) {
await Promise.all(claimed.slice(next).map(item => store.release(item.id)));
throw authenticationError;
}
if (transientError) throw transientError;
} else {
while (true) {
while (!purgeRequested) {
const item = await store.claimNext(login);
if (!item) break;
collect(await deliver(item));
@@ -351,6 +380,7 @@ function createBackgroundIssueSync({
async function purge() {
purgeRequested = true;
activeRequests.forEach(controller => controller.abort());
if (activeFlush) await activeFlush.catch(() => {});
await store.close?.();
}
+20 -4
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v29';
const CACHE = 'stackchain-dashboard-shell-v30';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [
BASE,
@@ -38,9 +38,10 @@ const SHELL = [
BASE + 'static/background-issue-sync.js',
];
async function sessionCsrf() {
async function sessionCsrf(signal) {
const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), {
headers: { Accept: 'application/json' },
signal,
});
if (!response.ok) return '';
const payload = await response.json().catch(() => ({}));
@@ -48,12 +49,16 @@ async function sessionCsrf() {
}
let batchedCsrf = null;
let batchedCsrfController = null;
async function withSessionCsrf(work) {
batchedCsrf = sessionCsrf();
batchedCsrfController = new AbortController();
batchedCsrf = sessionCsrf(batchedCsrfController.signal);
try {
return await work();
} finally {
batchedCsrfController.abort();
batchedCsrf = null;
batchedCsrfController = null;
}
}
@@ -62,7 +67,18 @@ async function fetchJson(url, options = {}) {
const method = String(options.method || 'GET').toUpperCase();
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
const headers = new Headers(options.headers || {});
const csrf = await (batchedCsrf || sessionCsrf());
const cancelBatchedCsrf = () => batchedCsrfController?.abort();
if (batchedCsrf && options.signal) {
if (options.signal.aborted) cancelBatchedCsrf();
else options.signal.addEventListener('abort', cancelBatchedCsrf, { once: true });
}
let csrf;
try {
csrf = await (batchedCsrf || sessionCsrf(options.signal));
} finally {
options.signal?.removeEventListener('abort', cancelBatchedCsrf);
}
if (options.signal?.aborted) throw new Error('Background request aborted.');
if (csrf) headers.set('X-CSRF-Token', csrf);
requestOptions.headers = headers;
}