Merge pull request 'Make mobile queue priority self-healing across devices' (#1467) from timmy/1466-self-healing-queue-priority into main
This commit is contained in:
commit
2a44cf20fc
10
README.md
10
README.md
|
|
@ -122,12 +122,14 @@ sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to ov
|
||||||
`.stackchain-state/saved-searches.sqlite3` path.
|
`.stackchain-state/saved-searches.sqlite3` path.
|
||||||
|
|
||||||
The mobile **Customize routine order** control is also portable across authenticated devices. Reordering
|
The mobile **Customize routine order** control is also portable across authenticated devices. Reordering
|
||||||
remains immediate when offline and is marked **Sync pending** until connectivity returns. The complete
|
remains immediate when offline and is marked **Sync pending** until connectivity returns. Rapid taps are
|
||||||
|
coalesced into one latest-order write; transient network or server failures retry automatically with bounded
|
||||||
|
backoff, and a clean phone refreshes the account order when it returns to the foreground. The complete
|
||||||
routine order is stored as an encrypted, revisioned collection scoped to the confirmed Gitea login; a
|
routine order is stored as an encrypted, revisioned collection scoped to the confirmed Gitea login; a
|
||||||
concurrent edit shows explicit **Keep this device** and **Use other device** actions instead of silently
|
concurrent edit shows explicit **Keep this device** and **Use other device** actions instead of silently
|
||||||
losing either order. Resetting publishes the canonical default order. Delivery, Human Gates, and active
|
losing either order. Account changes discard stale responses and retry work. Resetting publishes the
|
||||||
Prepare Today precedence are not customizable. Set `STACKCHAIN_QUEUE_PRIORITY_DB` to override the
|
canonical default order. Delivery, Human Gates, and active Prepare Today precedence are not customizable.
|
||||||
default `.stackchain-state/queue-priority.sqlite3` path.
|
Set `STACKCHAIN_QUEUE_PRIORITY_DB` to override the default `.stackchain-state/queue-priority.sqlite3` path.
|
||||||
|
|
||||||
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
|
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
|
||||||
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
|
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
|
||||||
|
|
|
||||||
|
|
@ -447,11 +447,10 @@
|
||||||
},
|
},
|
||||||
onChange: () => {
|
onChange: () => {
|
||||||
renderMobileQueuePresentation();
|
renderMobileQueuePresentation();
|
||||||
void mobileQueuePriority.sync();
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
mobileQueuePriority.start();
|
mobileQueuePriority.start();
|
||||||
window.addEventListener('online', () => { void mobileQueuePriority.sync(); });
|
mobileQueuePriority.startLifecycle({window, document});
|
||||||
let rR = null;
|
let rR = null;
|
||||||
function rRC() {
|
function rRC() {
|
||||||
if (rR) return rR;
|
if (rR) return rR;
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,17 @@
|
||||||
const prefix = 'stackchain-mobile-queue-priority-v1:';
|
const prefix = 'stackchain-mobile-queue-priority-v1:';
|
||||||
const labels = options.labels || {};
|
const labels = options.labels || {};
|
||||||
const documentRef = options.document || (typeof document !== 'undefined' ? document : null);
|
const documentRef = options.document || (typeof document !== 'undefined' ? document : null);
|
||||||
|
const setTimer = options.setTimeout || setTimeout;
|
||||||
|
const clearTimer = options.clearTimeout || clearTimeout;
|
||||||
|
const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150;
|
||||||
|
const retryBaseMs = Number.isFinite(options.retryBaseMs) ? Math.max(1, options.retryBaseMs) : 1000;
|
||||||
|
const retryMaxMs = Number.isFinite(options.retryMaxMs) ? Math.max(retryBaseMs, options.retryMaxMs) : 30000;
|
||||||
let memory = null;
|
let memory = null;
|
||||||
const syncFlights = new Map();
|
const syncFlights = new Map();
|
||||||
|
let debounceTimer = null;
|
||||||
|
let retryTimer = null;
|
||||||
|
let retryAccount = '';
|
||||||
|
let retryAttempts = 0;
|
||||||
|
|
||||||
function key() {
|
function key() {
|
||||||
const login = String(getLogin() || '').trim().toLowerCase();
|
const login = String(getLogin() || '').trim().toLowerCase();
|
||||||
|
|
@ -90,6 +99,45 @@
|
||||||
if (!persist(value)) return false;
|
if (!persist(value)) return false;
|
||||||
options.onChange?.(order.slice());
|
options.onChange?.(order.slice());
|
||||||
announce(value);
|
announce(value);
|
||||||
|
scheduleSync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSync() {
|
||||||
|
if (!fetchJson || !key()) return false;
|
||||||
|
if (debounceTimer) clearTimer(debounceTimer);
|
||||||
|
debounceTimer = setTimer(() => {
|
||||||
|
debounceTimer = null;
|
||||||
|
void sync();
|
||||||
|
}, debounceMs);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRetry() {
|
||||||
|
if (retryTimer) clearTimer(retryTimer);
|
||||||
|
retryTimer = null;
|
||||||
|
retryAccount = '';
|
||||||
|
retryAttempts = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transient(error) {
|
||||||
|
const status = Number(error?.status) || 0;
|
||||||
|
return status === 0 || status === 408 || status === 425 || status === 429 || status >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRetry(accountKey) {
|
||||||
|
if (retryTimer && retryAccount !== accountKey) clearRetry();
|
||||||
|
if (retryTimer || key() !== accountKey) return false;
|
||||||
|
const delay = Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempts));
|
||||||
|
retryAttempts += 1;
|
||||||
|
retryAccount = accountKey;
|
||||||
|
retryTimer = setTimer(() => {
|
||||||
|
retryTimer = null;
|
||||||
|
retryAccount = '';
|
||||||
|
const current = read();
|
||||||
|
if (key() !== accountKey || !current.pending || current.status === 'conflict') return;
|
||||||
|
void sync();
|
||||||
|
}, delay);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,6 +171,7 @@
|
||||||
throw new Error('Queue priority response is invalid.');
|
throw new Error('Queue priority response is invalid.');
|
||||||
}
|
}
|
||||||
const value = {revision:snapshotValue.revision, order:snapshotValue.order.slice(), pending:false, status:'ready', remote:null};
|
const value = {revision:snapshotValue.revision, order:snapshotValue.order.slice(), pending:false, status:'ready', remote:null};
|
||||||
|
clearRetry();
|
||||||
persist(value);
|
persist(value);
|
||||||
options.onChange?.(value.order.slice());
|
options.onChange?.(value.order.slice());
|
||||||
announce(value);
|
announce(value);
|
||||||
|
|
@ -180,10 +229,12 @@
|
||||||
const latest = read();
|
const latest = read();
|
||||||
const remote = error?.status === 409 && error?.payload?.detail?.snapshot;
|
const remote = error?.status === 409 && error?.payload?.detail?.snapshot;
|
||||||
if (remote && valid(remote.order) && Number.isInteger(remote.revision)) {
|
if (remote && valid(remote.order) && Number.isInteger(remote.revision)) {
|
||||||
|
clearRetry();
|
||||||
latest.status = 'conflict'; latest.pending = true;
|
latest.status = 'conflict'; latest.pending = true;
|
||||||
latest.remote = {revision:remote.revision, order:remote.order.slice()};
|
latest.remote = {revision:remote.revision, order:remote.order.slice()};
|
||||||
} else {
|
} else {
|
||||||
latest.status = 'pending'; latest.pending = true;
|
latest.status = 'pending'; latest.pending = true;
|
||||||
|
if (transient(error)) scheduleRetry(accountKey);
|
||||||
}
|
}
|
||||||
persist(latest); announce(latest); render();
|
persist(latest); announce(latest); render();
|
||||||
return snapshot();
|
return snapshot();
|
||||||
|
|
@ -193,6 +244,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function sync() {
|
function sync() {
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimer(debounceTimer);
|
||||||
|
debounceTimer = null;
|
||||||
|
}
|
||||||
const accountKey = key();
|
const accountKey = key();
|
||||||
const current = read();
|
const current = read();
|
||||||
if (!fetchJson || !accountKey || !current.pending) return Promise.resolve(snapshot());
|
if (!fetchJson || !accountKey || !current.pending) return Promise.resolve(snapshot());
|
||||||
|
|
@ -266,6 +321,20 @@
|
||||||
return render();
|
return render();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {getOrder, move, reset, render, start, load, sync, useLocal, useRemote,
|
function startLifecycle(lifecycle = {}) {
|
||||||
|
const windowObject = lifecycle.window;
|
||||||
|
const lifecycleDocument = lifecycle.document;
|
||||||
|
const reconcile = () => {
|
||||||
|
if (!key()) return Promise.resolve(snapshot());
|
||||||
|
return read().pending ? sync() : load();
|
||||||
|
};
|
||||||
|
windowObject?.addEventListener?.('online', () => { void reconcile(); });
|
||||||
|
lifecycleDocument?.addEventListener?.('visibilitychange', () => {
|
||||||
|
if (!lifecycleDocument.hidden) void reconcile();
|
||||||
|
});
|
||||||
|
return reconcile;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {getOrder, move, reset, render, start, startLifecycle, load, sync, scheduleSync, useLocal, useRemote,
|
||||||
state:snapshot, defaultOrder:() => DEFAULT_ORDER.slice()};
|
state:snapshot, defaultOrder:() => DEFAULT_ORDER.slice()};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -149,42 +149,109 @@ const fetchJson = async (url, init={{}}) => {{
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_queue_priority_serializes_rapid_edits_and_publishes_latest_order():
|
def test_mobile_queue_priority_coalesces_rapid_edits_and_publishes_latest_order():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||||
const values = new Map(); const pending=[]; const payloads=[];
|
const values = new Map(); const payloads=[]; const timers=[];
|
||||||
const fetchJson = async (_url, init) => new Promise(resolve => {{
|
const setTimer = (callback, delay) => {{ const timer={{callback,delay,cancelled:false}}; timers.push(timer); return timer; }};
|
||||||
const payload=JSON.parse(init.body); payloads.push(payload); pending.push(() => resolve({{revision:payload.revision+1,order:payload.order}}));
|
const clearTimer = timer => {{ if (timer) timer.cancelled=true; }};
|
||||||
}});
|
const fetchJson = async (_url, init) => {{
|
||||||
|
const payload=JSON.parse(init.body); payloads.push(payload);
|
||||||
|
return {{revision:payload.revision+1,order:payload.order}};
|
||||||
|
}};
|
||||||
(async () => {{
|
(async () => {{
|
||||||
let priority;
|
const priority=createPriority({{
|
||||||
priority=createPriority({{
|
|
||||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||||
getLogin:()=>'alice', fetchJson, onChange:()=>{{ void priority.sync(); }},
|
getLogin:()=>'alice', fetchJson, setTimeout:setTimer, clearTimeout:clearTimer, debounceMs:150,
|
||||||
}});
|
}});
|
||||||
priority.move('following', -1);
|
for (let index=0; index<4; index += 1) priority.move('following', -1);
|
||||||
priority.move('following', -1);
|
const before=payloads.length;
|
||||||
|
const active=timers.filter(timer=>!timer.cancelled);
|
||||||
|
active[0].callback();
|
||||||
await new Promise(resolve => setImmediate(resolve));
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
const firstPending=pending.length;
|
process.stdout.write(JSON.stringify({{before,active:active.map(timer=>timer.delay),payloads,state:priority.state()}}));
|
||||||
pending.shift()();
|
|
||||||
await new Promise(resolve => setImmediate(resolve));
|
|
||||||
const secondPending=pending.length;
|
|
||||||
pending.shift()();
|
|
||||||
await new Promise(resolve => setImmediate(resolve));
|
|
||||||
process.stdout.write(JSON.stringify({{firstPending,secondPending,payloads,state:priority.state()}}));
|
|
||||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
assert result.returncode == 0, result.stderr
|
assert result.returncode == 0, result.stderr
|
||||||
payload = json.loads(result.stdout)
|
payload = json.loads(result.stdout)
|
||||||
assert payload["firstPending"] == 1
|
assert payload["before"] == 0
|
||||||
assert payload["secondPending"] == 1
|
assert payload["active"] == [150]
|
||||||
assert len(payload["payloads"]) == 2
|
assert len(payload["payloads"]) == 1
|
||||||
assert payload["payloads"][0]["revision"] == 0
|
assert payload["payloads"][0]["revision"] == 0
|
||||||
assert payload["payloads"][1]["revision"] == 1
|
assert payload["payloads"][0]["order"] == payload["state"]["order"]
|
||||||
assert payload["payloads"][1]["order"] == payload["state"]["order"]
|
assert payload["state"]["revision"] == 1
|
||||||
assert payload["state"]["revision"] == 2
|
assert payload["state"]["status"] == "ready"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_queue_priority_retries_transient_failure_without_another_edit():
|
||||||
|
script = f"""
|
||||||
|
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||||
|
const values = new Map(); const timers=[]; let calls=0;
|
||||||
|
const setTimer = (callback, delay) => {{ const timer={{callback,delay,cancelled:false}}; timers.push(timer); return timer; }};
|
||||||
|
const clearTimer = timer => {{ if (timer) timer.cancelled=true; }};
|
||||||
|
const fetchJson = async (_url, init) => {{
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) {{ const error=new Error('temporary'); error.status=503; throw error; }}
|
||||||
|
const payload=JSON.parse(init.body);
|
||||||
|
return {{revision:payload.revision+1,order:payload.order}};
|
||||||
|
}};
|
||||||
|
(async () => {{
|
||||||
|
const priority=createPriority({{
|
||||||
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||||
|
getLogin:()=>'alice', fetchJson, setTimeout:setTimer, clearTimeout:clearTimer,
|
||||||
|
debounceMs:150, retryBaseMs:1000, retryMaxMs:30000,
|
||||||
|
}});
|
||||||
|
priority.move('following', -1);
|
||||||
|
timers.find(timer=>!timer.cancelled).callback();
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
const afterFailure=priority.state();
|
||||||
|
const retry=timers.filter(timer=>!timer.cancelled).at(-1);
|
||||||
|
retry.callback();
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
process.stdout.write(JSON.stringify({{calls,afterFailure,retryDelay:retry.delay,settled:priority.state()}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
assert payload["afterFailure"]["pending"] is True
|
||||||
|
assert payload["afterFailure"]["status"] == "pending"
|
||||||
|
assert payload["retryDelay"] == 1000
|
||||||
|
assert payload["calls"] == 2
|
||||||
|
assert payload["settled"]["pending"] is False
|
||||||
|
assert payload["settled"]["status"] == "ready"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_queue_priority_refreshes_clean_device_when_foregrounded():
|
||||||
|
script = f"""
|
||||||
|
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||||
|
const values = new Map(); const windowListeners={{}}; const documentListeners={{}};
|
||||||
|
const remoteOrder=['attention','following','today','update','agenda','authored','filed','later','draft'];
|
||||||
|
let calls=0;
|
||||||
|
const documentRef={{hidden:false,addEventListener:(name, callback)=>documentListeners[name]=callback}};
|
||||||
|
const windowRef={{addEventListener:(name, callback)=>windowListeners[name]=callback}};
|
||||||
|
const priority=createPriority({{
|
||||||
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||||
|
getLogin:()=>'alice', fetchJson:async () => {{ calls += 1; return {{revision:4,order:remoteOrder.slice()}}; }},
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
priority.startLifecycle({{window:windowRef,document:documentRef}});
|
||||||
|
documentListeners.visibilitychange();
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
process.stdout.write(JSON.stringify({{calls,state:priority.state(),listeners:[...Object.keys(windowListeners),...Object.keys(documentListeners)]}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
assert set(payload["listeners"]) == {"online", "visibilitychange"}
|
||||||
|
assert payload["calls"] == 1
|
||||||
|
assert payload["state"]["revision"] == 4
|
||||||
|
assert payload["state"]["order"][1] == "following"
|
||||||
assert payload["state"]["status"] == "ready"
|
assert payload["state"]["status"] == "ready"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -535,7 +602,7 @@ async def test_mobile_queue_priority_wires_cross_device_hydration_and_explicit_c
|
||||||
assert 'id="use-remote-mobile-queue-priority"' in html
|
assert 'id="use-remote-mobile-queue-priority"' in html
|
||||||
assert "fetchJson: fetchReviewJson" in html
|
assert "fetchJson: fetchReviewJson" in html
|
||||||
assert "void mobileQueuePriority.load();" in html
|
assert "void mobileQueuePriority.load();" in html
|
||||||
assert "void mobileQueuePriority.sync();" in html
|
assert "mobileQueuePriority.startLifecycle({window, document});" in html
|
||||||
assert "keepLocalButton: qs('#keep-local-mobile-queue-priority')" in html
|
assert "keepLocalButton: qs('#keep-local-mobile-queue-priority')" in html
|
||||||
assert "useRemoteButton: qs('#use-remote-mobile-queue-priority')" in html
|
assert "useRemoteButton: qs('#use-remote-mobile-queue-priority')" in html
|
||||||
assert "error.payload = payload;" in html
|
assert "error.payload = payload;" in html
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user