Offline retry queue for userscript writes #5
@@ -416,6 +416,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parks a write without counting it as a failure. Used when the key is already
|
||||||
|
// on the wire: the write is owed, but nothing went wrong, so it must not spend
|
||||||
|
// one of the ten attempts.
|
||||||
|
function queueDefer(key, op, sendStatus) {
|
||||||
|
const e = queueGet(key) || queuePush({ key: key, op: op, sendStatus: false, attempts: 0 });
|
||||||
|
e.op = op; // a delete replaces a put, and a put replaces a delete
|
||||||
|
e.sendStatus = e.sendStatus || sendStatus; // sticky: an archive intent is never dropped
|
||||||
|
saveQueue(queue);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
// Records a failed write and classifies why it failed. A 400 is a payload the
|
// Records a failed write and classifies why it failed. A 400 is a payload the
|
||||||
// server will never accept, so it is dropped now instead of being retried ten
|
// server will never accept, so it is dropped now instead of being retried ten
|
||||||
// times; a 401 is the wrong token, so the entry is kept untouched and the
|
// times; a 401 is the wrong token, so the entry is kept untouched and the
|
||||||
@@ -427,9 +438,7 @@
|
|||||||
toast("Couldn't sync " + titleFor(key) + " — change lost", true);
|
toast("Couldn't sync " + titleFor(key) + " — change lost", true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const e = queueGet(key) || queuePush({ key: key, op: op, sendStatus: false, attempts: 0 });
|
const e = queueDefer(key, op, sendStatus);
|
||||||
e.op = op; // a delete replaces a put, and a put replaces a delete
|
|
||||||
e.sendStatus = e.sendStatus || sendStatus; // sticky: an archive intent is never dropped
|
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
authFailed = true;
|
authFailed = true;
|
||||||
} else if (++e.attempts >= QUEUE_MAX_ATTEMPTS) {
|
} else if (++e.attempts >= QUEUE_MAX_ATTEMPTS) {
|
||||||
@@ -440,29 +449,52 @@
|
|||||||
saveQueue(queue);
|
saveQueue(queue);
|
||||||
}
|
}
|
||||||
|
|
||||||
let draining = false;
|
// Keys with a request on the wire right now, mapped to "another write arrived
|
||||||
|
// while this one was flying". A second write to the same key is never sent
|
||||||
|
// concurrently — the two responses would race to own the row — it is parked in
|
||||||
|
// the queue instead and a later drain replays it. The flight already in the
|
||||||
|
// air must then leave that entry alone and not adopt its own now-stale
|
||||||
|
// response, or it would undo the write the user just made.
|
||||||
|
const inFlight = new Map();
|
||||||
|
|
||||||
|
let draining = null; // the pass in progress, so a second caller awaits it
|
||||||
|
|
||||||
// Replays everything owed. Cheap in the normal case — it returns on the first
|
// Replays everything owed. Cheap in the normal case — it returns on the first
|
||||||
// line when the queue is empty, which is why it can hang off navigation. A
|
// line when the queue is empty, which is why it can hang off navigation. A
|
||||||
// 401 stops the whole pass: the token is wrong, so the next entry would fail
|
// 401 stops the whole pass: the token is wrong, so the next entry would fail
|
||||||
// the same way, and the queue is left intact so fixing the token fixes it.
|
// the same way, and the queue is left intact so fixing the token fixes it.
|
||||||
async function drain() {
|
//
|
||||||
if (draining || queue.length === 0) return;
|
// Returns the in-flight pass when one is already running, so refresh()'s
|
||||||
draining = true;
|
// `await drain()` really does wait for what we owe instead of racing a drain
|
||||||
authFailed = false;
|
// that onNavigate or the online listener started unawaited.
|
||||||
try {
|
function drain() {
|
||||||
for (const e of queue.slice()) {
|
if (queue.length === 0) return Promise.resolve();
|
||||||
if (e.op === "delete") await pushDelete(e.key);
|
if (draining) return draining;
|
||||||
else await pushBookmark(e.key, e.sendStatus);
|
const pass = (async () => {
|
||||||
if (authFailed) {
|
authFailed = false;
|
||||||
toast("Sync auth failed — check the token", true);
|
try {
|
||||||
break;
|
for (const e of queue.slice()) {
|
||||||
|
if (e.op === "delete") await pushDelete(e.key);
|
||||||
|
else await pushBookmark(e.key, e.sendStatus);
|
||||||
|
if (authFailed) {
|
||||||
|
toast("Sync auth failed — check the token", true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
render();
|
||||||
}
|
}
|
||||||
} finally {
|
})();
|
||||||
draining = false;
|
// Swallowed, not surfaced: drain is called unawaited from onNavigate and the
|
||||||
}
|
// online listener, and an uncaught rejection on a page we do not control is
|
||||||
render();
|
// a console error nobody can act on. Every real sync failure is already
|
||||||
|
// toasted and queued by pushBookmark/pushDelete.
|
||||||
|
draining = pass
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
draining = null;
|
||||||
|
});
|
||||||
|
return draining;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keys the server has not heard about yet must survive a fetched list, or the
|
// Keys the server has not heard about yet must survive a fetched list, or the
|
||||||
@@ -503,29 +535,51 @@
|
|||||||
queueDrop(key); // removed locally in the meantime — nothing left to send
|
queueDrop(key); // removed locally in the meantime — nothing left to send
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (inFlight.has(key)) {
|
||||||
|
queueDefer(key, "put", withStatus); // see inFlight — parked, not sent
|
||||||
|
inFlight.set(key, true); // supersedes the flight already in the air
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
inFlight.set(key, false);
|
||||||
|
let ok = false;
|
||||||
try {
|
try {
|
||||||
// ponytail: last-write-wins, so a replay can overwrite a newer server
|
// ponytail: last-write-wins, so a replay can overwrite a newer server
|
||||||
// value (a poller-written latest_chapter, or progress from another
|
// value (a poller-written latest_chapter, or progress from another
|
||||||
// device). Single user, self-healing on the next poll — revisit only if
|
// device). Single user, self-healing on the next poll — revisit only if
|
||||||
// this ever runs multi-user.
|
// this ever runs multi-user.
|
||||||
upsertLocal(await apiPut(key, bm, { sendStatus: withStatus }));
|
const saved = await apiPut(key, bm, { sendStatus: withStatus });
|
||||||
queueDrop(key);
|
if (!inFlight.get(key)) {
|
||||||
render();
|
upsertLocal(saved);
|
||||||
return true;
|
queueDrop(key);
|
||||||
|
}
|
||||||
|
ok = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
queueEnqueue(key, "put", withStatus, e);
|
queueEnqueue(key, "put", withStatus, e);
|
||||||
return false;
|
} finally {
|
||||||
|
inFlight.delete(key);
|
||||||
}
|
}
|
||||||
|
// Outside the try: a throw in render() is a UI bug, not a write failure, and
|
||||||
|
// must not re-queue a write that already landed.
|
||||||
|
if (ok) render();
|
||||||
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pushDelete(key) {
|
async function pushDelete(key) {
|
||||||
|
if (inFlight.has(key)) {
|
||||||
|
queueDefer(key, "delete", false); // see inFlight — parked, not sent
|
||||||
|
inFlight.set(key, true); // supersedes the flight already in the air
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
inFlight.set(key, false);
|
||||||
try {
|
try {
|
||||||
await apiDelete(key);
|
await apiDelete(key);
|
||||||
queueDrop(key);
|
if (!inFlight.get(key)) queueDrop(key);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
queueEnqueue(key, "delete", false, e);
|
queueEnqueue(key, "delete", false, e);
|
||||||
return false;
|
return false;
|
||||||
|
} finally {
|
||||||
|
inFlight.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,6 +689,9 @@
|
|||||||
});
|
});
|
||||||
upsertLocal(bm);
|
upsertLocal(bm);
|
||||||
render();
|
render();
|
||||||
|
// A queued write owns this row; the drain sends latest_chapter
|
||||||
|
// with it, carrying the correct bucket.
|
||||||
|
if (queueGet(bm.key)) return;
|
||||||
try {
|
try {
|
||||||
const saved = await apiPut(bm.key, bm);
|
const saved = await apiPut(bm.key, bm);
|
||||||
upsertLocal(saved);
|
upsertLocal(saved);
|
||||||
|
|||||||
Reference in New Issue
Block a user