feat(kanban): recover after retry exhaustion
req: sync/004\nreq: sync/010\nreq: sync/011\nreq: sync/014\nreq: sync/016
This commit is contained in:
@@ -3,6 +3,8 @@ const COMMANDS = "commands";
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const BACKOFF_MS = [25, 50];
|
||||
const root = document.querySelector("[data-kanban-sync]");
|
||||
let database;
|
||||
let retryTimer;
|
||||
|
||||
class UploadError extends Error {
|
||||
constructor(status, retryable) {
|
||||
@@ -64,6 +66,18 @@ function validatePending(command) {
|
||||
return command;
|
||||
}
|
||||
|
||||
function setOnline(online) {
|
||||
root.setAttribute("data-sync-connection", online ? "online" : "offline");
|
||||
}
|
||||
|
||||
function scheduleManualRetry(command, error) {
|
||||
clearTimeout(retryTimer);
|
||||
root.setAttribute("data-sync-error", error instanceof Error ? error.message : String(error));
|
||||
root.setAttribute("data-sync-manual-retry", "available");
|
||||
setPhase("offline", "Sync is offline after bounded retries; the durable command remains queued. Retry now when ready.");
|
||||
root.dispatchEvent(new CustomEvent("kanban:sync-exhausted", { detail: { commandId: command.id, attempts: MAX_ATTEMPTS } }));
|
||||
}
|
||||
|
||||
async function upload(command) {
|
||||
root.setAttribute("data-sync-max-attempts", String(MAX_ATTEMPTS));
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
||||
@@ -97,9 +111,45 @@ async function upload(command) {
|
||||
throw new Error("sync retry limit exhausted");
|
||||
}
|
||||
|
||||
async function synchronize(command) {
|
||||
root.removeAttribute("data-sync-error");
|
||||
root.removeAttribute("data-sync-manual-retry");
|
||||
setOnline(navigator.onLine);
|
||||
try {
|
||||
const acknowledgement = await upload(command);
|
||||
setOnline(true);
|
||||
root.setAttribute("data-sync-upload-sequence", String(acknowledgement.serverSequence));
|
||||
setPhase("awaiting-ack", `Command ${command.id} uploaded; awaiting canonical acknowledgement.`);
|
||||
|
||||
const reconnect = command.session || command.actor || "kanban";
|
||||
const source = new EventSource(`/sync/acknowledgements?after=0&reconnect=${encodeURIComponent(reconnect)}`);
|
||||
let opens = 0;
|
||||
source.addEventListener("open", () => {
|
||||
opens += 1;
|
||||
root.setAttribute("data-sync-transport-opens", String(opens));
|
||||
});
|
||||
source.addEventListener("acknowledgement", async (event) => {
|
||||
const canonical = JSON.parse(event.data);
|
||||
if (canonical.commandId !== command.id) return;
|
||||
root.setAttribute("data-sync-pending-before-ack", String((await pendingCommands(database)).length));
|
||||
await removeAcknowledged(database, command.id);
|
||||
root.setAttribute("data-sync-pending-count", String((await pendingCommands(database)).length));
|
||||
root.setAttribute("data-sync-ack-sequence", String(canonical.serverSequence));
|
||||
root.setAttribute("data-sync-canonical-column", canonical.canonicalColumn);
|
||||
setPhase("acknowledged", `Command ${command.id} acknowledged in ${canonical.canonicalColumn}.`);
|
||||
root.dispatchEvent(new CustomEvent("kanban:sync-acknowledged", { detail: canonical }));
|
||||
source.close();
|
||||
});
|
||||
} catch (error) {
|
||||
setOnline(false);
|
||||
if (error instanceof UploadError && !error.retryable) throw error;
|
||||
scheduleManualRetry(command, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (!root) return;
|
||||
const database = await openLog();
|
||||
database = await openLog();
|
||||
const commands = await pendingCommands(database);
|
||||
root.setAttribute("data-sync-pending-count", String(commands.length));
|
||||
if (commands.length === 0) {
|
||||
@@ -107,33 +157,22 @@ async function start() {
|
||||
return;
|
||||
}
|
||||
const command = validatePending(commands[0]);
|
||||
const acknowledgement = await upload(command);
|
||||
root.setAttribute("data-sync-upload-sequence", String(acknowledgement.serverSequence));
|
||||
setPhase("awaiting-ack", `Command ${command.id} uploaded; awaiting canonical acknowledgement.`);
|
||||
root.addEventListener("click", (event) => {
|
||||
if (!event.target.closest("[data-sync-retry]")) return;
|
||||
synchronize(command).catch(failPermanently);
|
||||
});
|
||||
window.addEventListener("online", () => {
|
||||
if (root.getAttribute("data-sync-phase") === "offline") synchronize(command).catch(failPermanently);
|
||||
});
|
||||
await synchronize(command);
|
||||
}
|
||||
|
||||
const reconnect = command.session || command.actor || "kanban";
|
||||
const source = new EventSource(`/sync/acknowledgements?after=0&reconnect=${encodeURIComponent(reconnect)}`);
|
||||
let opens = 0;
|
||||
source.addEventListener("open", () => {
|
||||
opens += 1;
|
||||
root.setAttribute("data-sync-transport-opens", String(opens));
|
||||
});
|
||||
source.addEventListener("acknowledgement", async (event) => {
|
||||
const canonical = JSON.parse(event.data);
|
||||
if (canonical.commandId !== command.id) return;
|
||||
root.setAttribute("data-sync-pending-before-ack", String((await pendingCommands(database)).length));
|
||||
await removeAcknowledged(database, command.id);
|
||||
root.setAttribute("data-sync-pending-count", String((await pendingCommands(database)).length));
|
||||
root.setAttribute("data-sync-ack-sequence", String(canonical.serverSequence));
|
||||
root.setAttribute("data-sync-canonical-column", canonical.canonicalColumn);
|
||||
setPhase("acknowledged", `Command ${command.id} acknowledged in ${canonical.canonicalColumn}.`);
|
||||
root.dispatchEvent(new CustomEvent("kanban:sync-acknowledged", { detail: canonical }));
|
||||
source.close();
|
||||
});
|
||||
function failPermanently(error) {
|
||||
root.setAttribute("data-sync-error", error instanceof Error ? error.message : String(error));
|
||||
setPhase("failed", "Sync failed; the durable command remains queued.");
|
||||
}
|
||||
|
||||
start().catch((error) => {
|
||||
if (!root) return;
|
||||
root.setAttribute("data-sync-error", error instanceof Error ? error.message : String(error));
|
||||
setPhase("failed", "Sync failed; the durable command remains queued.");
|
||||
failPermanently(error);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user