feat(kanban): coordinate uploader tabs

req: sync/018
This commit is contained in:
slhx agent
2026-07-13 17:40:16 +02:00
parent e4688bb6df
commit 0d475e81c8
3 changed files with 263 additions and 6 deletions
+86 -1
View File
@@ -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.");
}