Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion tests/account_facts_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
66 changes: 66 additions & 0 deletions tests/console_current_strategies_kv_dedup_validation.mjs
Original file line number Diff line number Diff line change
@@ -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");
37 changes: 24 additions & 13 deletions web/strategy-switch-console/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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; }
})());
Expand All @@ -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));
}
}

Expand Down
Loading