From ca5710d09afb256c47fda1b2b47816bf9a7b5f3f Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:44:04 +0800 Subject: [PATCH 1/2] fix(console): deduplicate current_strategies_cache KV writes to stay under the free-tier daily quota The 5s SWR freshness window re-put the GitHub-derived strategies cache to KV on every refresh (unconditional, ~500-600 writes/day, pushing the account past Cloudflare's 1,000 writes/day free KV limit). Writes now happen only when the payload actually changed, plus at most one ts keep-alive bump per 10 minutes so cold isolates keep serving from the KV stale window. Fresh memo window raised 5s -> 60s to cut KV read and GitHub load proportionally. scheduled() now uses the same dedup helper. --- ...current_strategies_kv_dedup_validation.mjs | 66 +++++++++++++++++++ web/strategy-switch-console/worker.js | 37 +++++++---- 2 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 tests/console_current_strategies_kv_dedup_validation.mjs diff --git a/tests/console_current_strategies_kv_dedup_validation.mjs b/tests/console_current_strategies_kv_dedup_validation.mjs new file mode 100644 index 00000000..da702826 --- /dev/null +++ b/tests/console_current_strategies_kv_dedup_validation.mjs @@ -0,0 +1,66 @@ +// Validates that current_strategies_cache KV writes are quota-deduplicated: +// one write on cold miss, none while the payload is unchanged (until the +// 10-minute ts keep-alive bump), and a bump once the stored entry ages out. +import assert from "node:assert/strict"; +import worker, { __test } from "./helpers/directory_worker.mjs"; + +const realNow = Date.now.bind(Date); +let nowOffset = 0; +Date.now = () => realNow() + nowOffset; + +const realFetch = globalThis.fetch; +globalThis.fetch = async () => { throw new Error("synthetic_network_off"); }; + +const store = new Map(); +let puts = 0; +const env = { + SESSION_SECRET: "synthetic-dedup-only", + ALLOWED_GITHUB_LOGINS: "fixture-reader", + STRATEGY_SWITCH_CONFIG: { + async get(key) { return store.has(key) ? store.get(key) : null; }, + async put(key, value) { puts++; store.set(key, value); }, + async list() { return { keys: [] }; }, + async delete(key) { store.delete(key); }, + }, +}; + +const session = await __test.makeSession("fixture-reader", [], env); +let pending = []; +const ctx = { waitUntil(promise) { pending.push(Promise.resolve(promise).catch(() => {})); } }; + +const requestConfig = async () => { + const response = await worker.fetch(new Request("https://synthetic.invalid/api/config", { + headers: { Cookie: `qsl_switch_session=${session}` }, + }), env, ctx); + assert.equal(response.status, 200); + await Promise.allSettled(pending); + pending = []; +}; + +// 1) Cold miss: exactly one cache write. +await requestConfig(); +assert.equal(puts, 1, "cold miss must write the cache once"); + +// 2) Follow-up request inside the fresh window: memo serves, zero writes. +await requestConfig(); +assert.equal(puts, 1, "fresh memo must not write"); + +// 3) Past the 60s fresh TTL but payload unchanged and stored ts younger +// than the 10-minute bump interval: background refresh writes nothing. +nowOffset += 61_000; +await requestConfig(); +assert.equal(puts, 1, "unchanged payload within the bump interval must skip the write"); + +// 4) Stored ts aged past the bump interval: exactly one keep-alive rewrite. +nowOffset += 600_000; +await requestConfig(); +assert.equal(puts, 2, "aged cache entry must take one keep-alive bump"); + +// 5) The bump path must not degenerate into per-request writes again. +nowOffset += 61_000; +await requestConfig(); +assert.equal(puts, 2, "post-bump requests must stay write-free"); + +Date.now = realNow; +globalThis.fetch = realFetch; +console.log("console_current_strategies_kv_dedup_validation: PASS"); diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index bea70133..56f7fa17 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -183,8 +183,9 @@ const AUDIT_LOG_KEY = "audit_log"; const AUDIT_LOG_LIMIT = 50; const CURRENT_STRATEGIES_TIMEOUT_MS = 25000; const CURRENT_STRATEGIES_CACHE_KEY = "current_strategies_cache"; -const CURRENT_STRATEGIES_CACHE_TTL_MS = 5_000; // 5 sec — rapid refresh during active development +const CURRENT_STRATEGIES_CACHE_TTL_MS = 60_000; // 60 sec — was 5 sec "active development" default; 5 sec drove ~600 KV writes/day at quota-critical traffic const CURRENT_STRATEGIES_STALE_TTL_MS = 600_000; // 10 min — return stale + background refresh +const CURRENT_STRATEGIES_KV_BUMP_MS = 600_000; // 10 min — min interval for quota-limited ts-only cache refreshes const GITHUB_API_TIMEOUT_MS = 8000; const GITHUB_LOGIN_REQUEST_TIMEOUT_MS = 20000; const UX1_CALCULATOR_DEADLINE_MS = 30000; @@ -1085,10 +1086,7 @@ export default { try { const accountConfig = await loadAccountOptionsConfig(env); const strategies = await loadCurrentStrategiesSafely(accountConfig.options, env); - await writeConfigJson(env, CURRENT_STRATEGIES_CACHE_KEY, { - ts: Date.now(), - data: strategies, - }); + await writeCurrentStrategiesCache(env, strategies, Date.now()); } catch { // Silently skip — next user request will populate cache via SWR } @@ -6323,6 +6321,25 @@ let _memCurrentStrategies = null; let _memCurrentStrategiesTs = 0; let _memRefreshing = false; // prevent concurrent background refreshes +// KV free tier caps writes at 1,000/day account-wide, and every /api/config +// request can drive a refresh, so the cache write is paid for only when the +// payload changed or the stored entry needs its ts bumped to stay inside the +// stale window that cold isolates serve from. +let _lastKvCachePayloadJson = null; + +async function writeCurrentStrategiesCache(env, payload, ts) { + const payloadJson = JSON.stringify(payload); + if (payloadJson !== _lastKvCachePayloadJson) { + await writeConfigJson(env, CURRENT_STRATEGIES_CACHE_KEY, { ts, data: payload }); + _lastKvCachePayloadJson = payloadJson; + return; + } + const cached = await readConfigJson(env, CURRENT_STRATEGIES_CACHE_KEY); + if (cached?.ts && ts - cached.ts < CURRENT_STRATEGIES_KV_BUMP_MS) return; + await writeConfigJson(env, CURRENT_STRATEGIES_CACHE_KEY, { ts, data: payload }); + _lastKvCachePayloadJson = payloadJson; +} + async function configPayload(request, env, ctx) { const session = await readSession(request, env); const meta = await loadPlatformMeta(env); @@ -6386,10 +6403,7 @@ async function readCurrentStrategies(accountOptions, env, ctx) { const fresh = await loadCurrentStrategiesSafely(accountOptions, env); _memCurrentStrategies = fresh; _memCurrentStrategiesTs = Date.now(); - await writeConfigJson(env, CURRENT_STRATEGIES_CACHE_KEY, { - ts: _memCurrentStrategiesTs, - data: fresh, - }); + await writeCurrentStrategiesCache(env, fresh, _memCurrentStrategiesTs); } catch { /* keep stale data */ } finally { _memRefreshing = false; } })()); @@ -6401,10 +6415,7 @@ async function readCurrentStrategies(accountOptions, env, ctx) { _memCurrentStrategies = currentStrategies; _memCurrentStrategiesTs = Date.now(); if (hasConfigStore(env) && ctx) { - ctx.waitUntil(writeConfigJson(env, CURRENT_STRATEGIES_CACHE_KEY, { - ts: _memCurrentStrategiesTs, - data: currentStrategies, - })); + ctx.waitUntil(writeCurrentStrategiesCache(env, currentStrategies, _memCurrentStrategiesTs)); } } From 70cd7659cae96227b0bddc6eb304d00f1803d4e3 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:58:35 +0800 Subject: [PATCH 2/2] test(console): keep the previous-day fixture inside the 36h observation window The cross-day rotation case pinned yesterday at 10:00 UTC, which leaves the Worker's 36h window at 22:00 UTC, so the sync was rejected as account_facts_observation_window (400) instead of reaching the binding mismatch (409) between 22:00 and 24:00 UTC every day. --- tests/account_facts_validation.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/account_facts_validation.mjs b/tests/account_facts_validation.mjs index ff33cce9..e3048141 100644 --- a/tests/account_facts_validation.mjs +++ b/tests/account_facts_validation.mjs @@ -1517,7 +1517,9 @@ assert.equal(afterLbRefresh.accounts.find((item) => item.platform === "longbridg // Cross-day binding rotation: old daily rows stay stored but are excluded from // the current account history for both platforms. const previousDayStart = new Date(Date.now() - 86400000); -previousDayStart.setUTCHours(10, 0, 0, 0); +// 23:00 UTC keeps the previous-day row inside the Worker's 36h observation window at +// any run hour; 10:00 made this case fail every day between 22:00 and 24:00 UTC. +previousDayStart.setUTCHours(23, 0, 0, 0); const previousDayStartedAt = previousDayStart.toISOString().replace(/\.\d{3}Z$/, "Z"); const previousDayFinishedAt = new Date(previousDayStart.getTime() + 60000).toISOString().replace(/\.\d{3}Z$/, "Z"); const rotationLb = {