fix(sync): bound request lifetime
req: operations/003
This commit is contained in:
@@ -7,6 +7,7 @@ const LEGACY_COMMAND_SCHEMA = 1;
|
||||
const MIGRATION_KEY = "commandSchemaMigration";
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const BACKOFF_MS = [25, 50];
|
||||
const REQUEST_TIMEOUT_MS = 1_000;
|
||||
const root = document.querySelector("[data-kanban-sync]");
|
||||
const TAB_ID = sessionStorage.getItem("hemx-kanban-sync-tab-id") || crypto.randomUUID();
|
||||
const LEASE_MS = 5000;
|
||||
@@ -16,6 +17,8 @@ let accountPartition;
|
||||
let uploadLimit;
|
||||
let retryTimer;
|
||||
let leaseTimer;
|
||||
let acknowledgementSource;
|
||||
const activeRequests = new Set();
|
||||
let synchronizing = false;
|
||||
let uploadsThisRun = 0;
|
||||
let uploadedTotal = 0;
|
||||
@@ -39,6 +42,30 @@ class UploadError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// req: operations/003
|
||||
export async function fetchWithTimeout(
|
||||
input,
|
||||
init = {},
|
||||
fetchImplementation = fetch,
|
||||
timeoutMs = REQUEST_TIMEOUT_MS,
|
||||
) {
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
|
||||
throw new TypeError("sync request timeout must be a positive integer");
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(new DOMException(`sync request timed out after ${timeoutMs} ms`, "TimeoutError")),
|
||||
timeoutMs,
|
||||
);
|
||||
activeRequests.add(controller);
|
||||
try {
|
||||
return await fetchImplementation(input, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
activeRequests.delete(controller);
|
||||
}
|
||||
}
|
||||
|
||||
function requestResult(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.addEventListener("success", () => resolve(request.result), { once: true });
|
||||
@@ -322,7 +349,7 @@ async function upload(command) {
|
||||
setPhase(attempt === 1 ? "uploading" : "retrying", `Uploading ${command.id} (attempt ${attempt} of ${MAX_ATTEMPTS}).`);
|
||||
try {
|
||||
const query = new URLSearchParams({ command_id: command.id, card_id: command.cardId, column: command.targetColumn });
|
||||
const response = await fetch(`/sync/commands?${query}`, { method: "POST" });
|
||||
const response = await fetchWithTimeout(`/sync/commands?${query}`, { method: "POST" });
|
||||
if (response.status === 503 && attempt < MAX_ATTEMPTS) {
|
||||
const base = BACKOFF_MS[attempt - 1];
|
||||
const delay = base + Math.floor(Math.random() * base);
|
||||
@@ -413,6 +440,7 @@ async function synchronize(command) {
|
||||
|
||||
const reconnect = command.session || command.actor || "kanban";
|
||||
const source = new EventSource(`/sync/acknowledgements?after=0&reconnect=${encodeURIComponent(reconnect)}`);
|
||||
acknowledgementSource = source;
|
||||
let opens = 0;
|
||||
source.addEventListener("open", () => {
|
||||
opens += 1;
|
||||
@@ -422,6 +450,7 @@ async function synchronize(command) {
|
||||
const canonical = JSON.parse(event.data);
|
||||
if (canonical.commandId !== command.id) return;
|
||||
source.close();
|
||||
if (acknowledgementSource === source) acknowledgementSource = undefined;
|
||||
root.setAttribute("data-sync-pending-before-ack", String((await pendingCommands(database)).length));
|
||||
const queueCommandId = command.queueCommandId || command.id;
|
||||
await removePendingCommand(database, queueCommandId);
|
||||
@@ -454,7 +483,7 @@ async function synchronize(command) {
|
||||
});
|
||||
source.addEventListener("snapshot-required", async (event) => {
|
||||
const missing = JSON.parse(event.data);
|
||||
const response = await fetch(missing.snapshotUrl);
|
||||
const response = await fetchWithTimeout(missing.snapshotUrl);
|
||||
if (!response.ok) throw new Error(`snapshot failed with ${response.status}`);
|
||||
const snapshot = await response.json();
|
||||
const queued = await pendingCommands(database);
|
||||
@@ -497,6 +526,7 @@ async function synchronize(command) {
|
||||
}
|
||||
synchronizing = false;
|
||||
source.close();
|
||||
if (acknowledgementSource === source) acknowledgementSource = undefined;
|
||||
clearTimeout(leaseTimer);
|
||||
await releaseUploaderLease(database);
|
||||
root.setAttribute("data-sync-leader", "false");
|
||||
@@ -564,7 +594,8 @@ async function runLeaseLoop(command) {
|
||||
|
||||
async function start() {
|
||||
if (!root) return;
|
||||
const contextResponse = await fetch("/sync/context", { credentials: "same-origin", cache: "no-store" });
|
||||
root.setAttribute("data-sync-request-timeout-ms", String(REQUEST_TIMEOUT_MS));
|
||||
const contextResponse = await fetchWithTimeout("/sync/context", { credentials: "same-origin", cache: "no-store" });
|
||||
if (!contextResponse.ok) throw new Error(`account context failed with ${contextResponse.status}`);
|
||||
const context = await contextResponse.json();
|
||||
if (!context || typeof context.accountPartition !== "string" || !context.accountPartition) {
|
||||
@@ -633,6 +664,12 @@ async function start() {
|
||||
window.addEventListener("pagehide", () => {
|
||||
stopped = true;
|
||||
clearTimeout(leaseTimer);
|
||||
clearTimeout(retryTimer);
|
||||
acknowledgementSource?.close();
|
||||
acknowledgementSource = undefined;
|
||||
for (const controller of activeRequests) {
|
||||
controller.abort(new DOMException("sync cancelled because page is hidden", "AbortError"));
|
||||
}
|
||||
if (database) releaseUploaderLease(database).catch(() => {});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user