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 = { 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)); } }