From 27fbfb250bb95ad171b18b552d16fee8d73405ce Mon Sep 17 00:00:00 2001 From: avatasia Date: Wed, 14 Oct 2026 15:24:15 +0800 Subject: [PATCH 01/36] Give the session list a validator, and stop one clock driving two fetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the remaining poll-cost items, in the order they compound. countRolesInPrefix counted the list's eight counters with five matchAll passes over the bounded prefix — a match object per hit, thousands of them per session per rebuild. It now runs four test-driven scans that read each value out of the text by position. The scans carry /g and test mutates lastIndex, so each resets it first: the loops break when a value has no closing quote, which is the normal case for a truncated prefix and leaves the cursor mid-string on module state shared across calls. verify-count-roles.mjs holds the capturing implementation it replaced as a reference and requires the two to agree over hand-written edges and over every session prefix in the store it is pointed at, so the rewrite is held to being the same function and not merely a faster one. /api/sessions had no validator while /api/trajectory got one first. sessionsValidator is built from the same values the payload is built from, in the same request: the visible slice's dir|sizeBytes|lastActiveAt — exactly what describeSession keys its descriptor cache on — plus the titles, which no file stat can see, plus the limit, because ?limit=10 and ?limit=20 are different payloads and must not satisfy one another. The 304 is decided before any descriptor is built, and an unchanged list answers from the bytes the previous request already built. The soundness bound is the descriptor cache's and not a new one: a manifest.json appearing after the walk listed a session would change startedAt without moving the tag, and that is already invisible for up to SESSION_CACHE_TTL_MS to the cache this reuses. verify-list-cache.mjs pins the TTL relationship that says so. One POLL_INTERVAL drove both fetches and it was wrong for both. The list is a cached walk and a cached title read and now usually a 304; the trajectory reparses a lineage, rebuilds a payload that measured 18,924 rows, and reopens the runtime database on every request. They are two ticks now, with the expensive one slower, and the list wakes the trajectory when it has evidence: a 200 means something in the visible slice moved, a 304 means nothing on screen can be stale. So the slow tick is a floor on latency rather than a delay applied to every change. The list keeps the fast tick for a reader-visible reason too: the picker renders 「刚刚」 against a render-time clock, so a slower tick makes that label sit still and then jump. That ordering is asserted, not just argued in a comment. The ledger build guard moves with the trajectory, where it belongs — it exists because a table mid-build must not be replaced mid-build, and the list never renders the ledger. Read points that moved with it: verify-list-cache.mjs measures the cache against the list's own cadence rather than the page's, client-check's poll assertion no longer passes just because SESSION_POLL_INTERVAL contains POLL_INTERVAL as a substring, and the list body cache joins the others in dispose. 63/63 guards, 33 mutations across the three new controls. --- .../mmc-trajectory/miniapp/client/index.html | 115 ++++++++-- .../mmc-trajectory/miniapp/node/server.mjs | 164 ++++++++++--- .../mmc-trajectory/tests/client-check.mjs | 7 +- .../tests/mutate-count-roles.py | 186 +++++++++++++++ .../tests/mutate-list-cache-ttl.py | 26 ++- .../tests/mutate-poll-decoupling.py | 187 +++++++++++++++ .../tests/mutate-sessions-etag.py | 199 ++++++++++++++++ .../tests/verify-count-roles.mjs | 213 +++++++++++++++++ .../tests/verify-list-cache.mjs | 33 ++- .../tests/verify-poll-decoupling.mjs | 169 ++++++++++++++ .../tests/verify-sessions-etag.mjs | 216 ++++++++++++++++++ 11 files changed, 1463 insertions(+), 52 deletions(-) create mode 100644 plugins/avatasia/mmc-trajectory/tests/mutate-count-roles.py create mode 100644 plugins/avatasia/mmc-trajectory/tests/mutate-poll-decoupling.py create mode 100644 plugins/avatasia/mmc-trajectory/tests/mutate-sessions-etag.py create mode 100644 plugins/avatasia/mmc-trajectory/tests/verify-count-roles.mjs create mode 100644 plugins/avatasia/mmc-trajectory/tests/verify-poll-decoupling.mjs create mode 100644 plugins/avatasia/mmc-trajectory/tests/verify-sessions-etag.mjs diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html index f13a610..e85aa58 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -3658,7 +3658,15 @@

检视

/* ---------------- state ---------------- */ - var POLL_INTERVAL = 2500; + // Two cadences, one per fetch, because the two are not the same work. See startPolling(). + // + // 2500 for the list, which is a cached walk and a cached title read and now usually a 304 — + // and which the picker renders 「刚刚」 against. 8000 for the trajectory, which reparses a + // lineage and reopens the runtime database on every request. The list wakes the trajectory + // early when it has evidence, so the slow tick is a floor on latency, not a replacement for + // noticing a change. + var SESSION_POLL_INTERVAL = 2500; + var TRAJECTORY_POLL_INTERVAL = 8000; var PAGE_STEP = 200; var SEARCH_DEBOUNCE = 200; // Which window the page opens on, and which every reset returns to. A constant rather @@ -3782,6 +3790,11 @@

检视

// unchanged session costs a 304 with no body instead of a multi-megabyte reply. etag: '', etagFor: '', + // The same pair for the session list, and separate for the same reason: a validator + // means nothing against a different request, and the list's request is a limit. Written + // in exactly one place — fetchSessions — the way `etag` is written only in fetchTrajectory. + sessionsEtag: '', + sessionsEtagFor: '', // Cursor for `?since=`: the highest record index already on screen, so a poll that // finds new records transfers the tail instead of the session. Bound to a cache key // for the same reason the validator is — a cursor means nothing against a different @@ -3797,7 +3810,8 @@

检视

var dom = {}; var inflight = null; var sessionsInflight = null; - var pollTimer = null; + var sessionPollTimer = null; + var trajectoryPollTimer = null; var searchTimer = null; var rangeTimer = null; var refreshSeq = 0; @@ -3902,27 +3916,69 @@

检视

return state.followLatest || !state.sessionId ? 'latest' : encodeURIComponent(state.sessionId); } - function fetchSessions() { + /** + * The session list, on its own validator. + * + * `manual` is the refresh button asking for the truth, and it drops the validator the way + * `fetchTrajectory(manual)` does — a refresh that revalidated the list would be a refresh + * of half the page. Every other caller passes nothing. + * + * Resolves to whether the list actually changed, and that is the part other code reads: a + * 200 means something in the visible slice moved, which is the cheap evidence the slow + * trajectory tick is waiting for; a 304 means nothing on screen can be stale. + * + * @param {boolean} [manual] + * @returns {Promise} + */ + function fetchSessions(manual) { if (sessionsInflight) sessionsInflight.abort(); var controller = new AbortController(); sessionsInflight = controller; - var query = '?limit=20'; - return fetch('/api/sessions' + query, { signal: controller.signal, headers: { Accept: 'application/json' } }) + var key = 'limit=20'; + var query = '?' + key; + var headers = { Accept: 'application/json' }; + if (manual) { + state.sessionsEtag = ''; + state.sessionsEtagFor = ''; + } else if (state.sessionsEtag && state.sessionsEtagFor === key) { + headers['If-None-Match'] = state.sessionsEtag; + } + return fetch('/api/sessions' + query, { signal: controller.signal, headers: headers }) .then(function (response) { + // Nothing moved since the copy already on screen. Keep every piece of state and + // re-render nothing: the picker, its 「刚刚」 labels and its counts are all still true. + if (response.status === 304) { + var fresh = response.headers.get('etag'); + if (fresh) state.sessionsEtag = fresh; + state.sessionsEtagFor = key; + return { changed: false, body: null, etag: state.sessionsEtag }; + } if (!response.ok) throw new Error('http'); - return response.json(); + return response + .json() + .catch(function () { return null; }) + .then(function (body) { + if (!body || typeof body !== 'object') throw new Error('api'); + return { changed: true, body: body, etag: response.headers.get('etag') || '' }; + }); }) - .then(function (data) { + .then(function (result) { sessionsInflight = null; - state.sessions = data && Array.isArray(data.sessions) ? data.sessions : []; + // One writer for the list's validator, on the one path that carried a body. + state.sessionsEtag = result.etag; + state.sessionsEtagFor = key; + if (!result.changed) return false; + state.sessions = Array.isArray(result.body.sessions) ? result.body.sessions : []; state.sessionsLoaded = true; state.sessionsError = false; renderSessionSelect(); + return true; }) .catch(function (error) { sessionsInflight = null; - if (error && error.name === 'AbortError') return; + if (error && error.name === 'AbortError') return false; state.sessionsError = true; + return false; }); } @@ -8658,26 +8714,53 @@

检视

function startPolling() { stopPolling(); - pollTimer = window.setInterval(function () { + // Two ticks, because the two fetches are not the same cost class and were never the same + // work. The list is a cached walk, a cached title read and a descriptor cache; once the + // page can revalidate it, an unchanged list costs a 304. The trajectory is the one that + // reparses a lineage and rebuilds a payload that measured 18,924 rows, and it also opens + // the runtime database on every request to work out which conversation is live. + // + // The list therefore keeps the fast tick, and it is not only for freshness: the picker + // renders 「刚刚」 against a render-time clock, so at a slow cadence that label would sit + // still and then jump. The trajectory gets the slower tick, and is woken early by the + // list instead of by the clock — see the fetchSessions call below. + sessionPollTimer = window.setInterval(function () { + if (document.hidden) return; + fetchSessions().then(function (changed) { + // A 200 is cheap evidence that something on screen may be stale, and a misfire is + // nearly free: the trajectory carries its own validator, so a tick that finds nothing + // changed ends in a 304 without touching a file. A 304 means nothing moved, so there + // is nothing to wake for. + if (changed) fetchTrajectory(false); + }); + }, SESSION_POLL_INTERVAL); + trajectoryPollTimer = window.setInterval(function () { if (document.hidden) return; // A table that is still being built is not a table this tick may replace. An 全部 window // over 18,924 rows takes about 4 s to lay out — longer than this interval — so letting // every tick through would restart the build before it reached the end, and the reader // would watch the count reset from 3,000 back to 0 forever. The tick is remembered // instead, and flushDeferredPoll() runs it once the build finishes. + // + // This belongs to the trajectory alone. The list never renders the ledger; it rebuilds + // an