feat(kanban): coordinate uploader tabs
req: sync/018
This commit is contained in:
@@ -3,8 +3,15 @@ const COMMANDS = "commands";
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const BACKOFF_MS = [25, 50];
|
||||
const root = document.querySelector("[data-kanban-sync]");
|
||||
const TAB_ID = sessionStorage.getItem("hemx-kanban-sync-tab-id") || crypto.randomUUID();
|
||||
const LEASE_MS = 5000;
|
||||
const LEASE_POLL_MS = 100;
|
||||
const LEASE_KEY = "uploaderLease";
|
||||
let database;
|
||||
let retryTimer;
|
||||
let leaseTimer;
|
||||
let synchronizing = false;
|
||||
let stopped = false;
|
||||
|
||||
class UploadError extends Error {
|
||||
constructor(status, retryable) {
|
||||
@@ -63,6 +70,38 @@ function decideRebase(snapshot, command) {
|
||||
return { kind: "conflicted", reason: "canonical-state-diverged", canonicalColumn: canonical.column };
|
||||
}
|
||||
|
||||
async function claimUploaderLease(database) {
|
||||
const transaction = database.transaction("meta", "readwrite");
|
||||
const done = transactionDone(transaction);
|
||||
const meta = transaction.objectStore("meta");
|
||||
const now = Date.now();
|
||||
const current = await requestResult(meta.get(LEASE_KEY));
|
||||
if (current && current.owner !== TAB_ID && current.expiresAt > now) {
|
||||
await done;
|
||||
return { leader: false, owner: current.owner, expiresAt: current.expiresAt };
|
||||
}
|
||||
const lease = { owner: TAB_ID, expiresAt: now + LEASE_MS };
|
||||
meta.put(lease, LEASE_KEY);
|
||||
await done;
|
||||
return { leader: true, ...lease };
|
||||
}
|
||||
|
||||
async function releaseUploaderLease(database) {
|
||||
const transaction = database.transaction("meta", "readwrite");
|
||||
const done = transactionDone(transaction);
|
||||
const meta = transaction.objectStore("meta");
|
||||
const current = await requestResult(meta.get(LEASE_KEY));
|
||||
if (current?.owner === TAB_ID) meta.delete(LEASE_KEY);
|
||||
await done;
|
||||
}
|
||||
|
||||
function publishLease(lease) {
|
||||
root.setAttribute("data-sync-tab-id", TAB_ID);
|
||||
root.setAttribute("data-sync-leader", String(lease.leader));
|
||||
root.setAttribute("data-sync-lease-owner", lease.owner || TAB_ID);
|
||||
root.setAttribute("data-sync-lease-expires", String(lease.expiresAt));
|
||||
}
|
||||
|
||||
async function commitConvergedRebase(database, snapshot, command) {
|
||||
const transaction = database.transaction([COMMANDS, "meta"], "readwrite");
|
||||
const done = transactionDone(transaction);
|
||||
@@ -130,6 +169,21 @@ async function upload(command) {
|
||||
}
|
||||
|
||||
async function synchronize(command) {
|
||||
if (synchronizing) return;
|
||||
synchronizing = true;
|
||||
const lease = await claimUploaderLease(database);
|
||||
publishLease(lease);
|
||||
if (!lease.leader) {
|
||||
synchronizing = false;
|
||||
setPhase("standby", "Another tab owns sync; waiting for lease takeover.");
|
||||
return;
|
||||
}
|
||||
clearTimeout(leaseTimer);
|
||||
leaseTimer = setTimeout(() => {
|
||||
if (!stopped && root.getAttribute("data-sync-phase") !== "acknowledged") {
|
||||
synchronize(command).catch(failPermanently);
|
||||
}
|
||||
}, LEASE_MS / 2);
|
||||
root.removeAttribute("data-sync-error");
|
||||
root.removeAttribute("data-sync-manual-retry");
|
||||
setOnline(navigator.onLine);
|
||||
@@ -156,6 +210,10 @@ async function synchronize(command) {
|
||||
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 }));
|
||||
synchronizing = false;
|
||||
clearTimeout(leaseTimer);
|
||||
await releaseUploaderLease(database);
|
||||
root.setAttribute("data-sync-leader", "false");
|
||||
source.close();
|
||||
});
|
||||
source.addEventListener("snapshot-required", async (event) => {
|
||||
@@ -182,15 +240,35 @@ async function synchronize(command) {
|
||||
setPhase("conflicted", `Canonical snapshot ${snapshot.serverSequence} conflicts with ${command.id} (${decision.reason}); the pending command remains queued.`);
|
||||
root.dispatchEvent(new CustomEvent("kanban:sync-conflicted", { detail: { snapshot, command, decision } }));
|
||||
}
|
||||
synchronizing = false;
|
||||
source.close();
|
||||
});
|
||||
} catch (error) {
|
||||
synchronizing = false;
|
||||
setOnline(false);
|
||||
if (error instanceof UploadError && !error.retryable) throw error;
|
||||
scheduleManualRetry(command, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function runLeaseLoop(command) {
|
||||
if (stopped) return;
|
||||
const phase = root.getAttribute("data-sync-phase");
|
||||
if (phase === "acknowledged" || phase === "rebased" || phase === "conflicted" || phase === "failed") return;
|
||||
if (root.getAttribute("data-sync-leader") === "true") {
|
||||
await synchronize(command);
|
||||
return;
|
||||
}
|
||||
const lease = await claimUploaderLease(database);
|
||||
publishLease(lease);
|
||||
if (lease.leader) {
|
||||
await synchronize(command);
|
||||
return;
|
||||
}
|
||||
setPhase("standby", "Another tab owns sync; waiting for lease takeover.");
|
||||
leaseTimer = setTimeout(() => runLeaseLoop(command).catch(failPermanently), LEASE_POLL_MS);
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (!root) return;
|
||||
database = await openLog();
|
||||
@@ -208,10 +286,17 @@ async function start() {
|
||||
window.addEventListener("online", () => {
|
||||
if (root.getAttribute("data-sync-phase") === "offline") synchronize(command).catch(failPermanently);
|
||||
});
|
||||
await synchronize(command);
|
||||
await runLeaseLoop(command);
|
||||
}
|
||||
|
||||
window.addEventListener("pagehide", () => {
|
||||
stopped = true;
|
||||
clearTimeout(leaseTimer);
|
||||
if (database) releaseUploaderLease(database).catch(() => {});
|
||||
});
|
||||
|
||||
function failPermanently(error) {
|
||||
synchronizing = false;
|
||||
root.setAttribute("data-sync-error", error instanceof Error ? error.message : String(error));
|
||||
setPhase("failed", "Sync failed; the durable command remains queued.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user