diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 5407642a..6d3f8945 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -162,6 +162,7 @@ jobs: node tests/console_acceptance_budget_validation.mjs node tests/kv_directory_validation.mjs node tests/console_source_quota_validation.mjs + node tests/console_current_strategies_kv_dedup_validation.mjs node tests/console_inbox_requests_validation.mjs node tests/console_v2_i18n_validation.mjs node tests/console_account_state_validation.mjs diff --git a/tests/console_current_strategies_kv_dedup_validation.mjs b/tests/console_current_strategies_kv_dedup_validation.mjs index da702826..8eb9eb34 100644 --- a/tests/console_current_strategies_kv_dedup_validation.mjs +++ b/tests/console_current_strategies_kv_dedup_validation.mjs @@ -1,6 +1,7 @@ -// 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. +// Validates that current_strategies_cache KV writes stay quota-safe. +// The case that matters is the production one: every build re-stamps +// execution_evidence.observed_at with new Date(), so the payload bytes always +// differ while the rendered content does not. import assert from "node:assert/strict"; import worker, { __test } from "./helpers/directory_worker.mjs"; @@ -9,13 +10,20 @@ let nowOffset = 0; Date.now = () => realNow() + nowOffset; const realFetch = globalThis.fetch; -globalThis.fetch = async () => { throw new Error("synthetic_network_off"); }; +let fetchAttempts = 0; +globalThis.fetch = async () => { fetchAttempts++; throw new Error("synthetic_network_off"); }; -const store = new Map(); -let puts = 0; +const account = { + key: "default", label: "Binance", target_name: "crypto_live_pool_rotation", + runtime_status_target_id: "binance.crypto_live_pool_rotation", supported_domains: ["crypto"], + service_name: "binance-platform", default_execution_mode: "live", +}; +// With no network the Worker answers with the read_failed evidence fallback, whose +// only per-build difference is observed_at — the exact shape the live cron produces. const env = { SESSION_SECRET: "synthetic-dedup-only", ALLOWED_GITHUB_LOGINS: "fixture-reader", + STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON: JSON.stringify({ binance: [account] }), STRATEGY_SWITCH_CONFIG: { async get(key) { return store.has(key) ? store.get(key) : null; }, async put(key, value) { puts++; store.set(key, value); }, @@ -24,6 +32,9 @@ const env = { }, }; +const store = new Map(); +let puts = 0; + const session = await __test.makeSession("fixture-reader", [], env); let pending = []; const ctx = { waitUntil(promise) { pending.push(Promise.resolve(promise).catch(() => {})); } }; @@ -37,22 +48,28 @@ const requestConfig = async () => { pending = []; }; -// 1) Cold miss: exactly one cache write. +const storedCache = () => JSON.parse(store.get("current_strategies_cache")); + +// 1) Cold miss: exactly one cache write, and the payload really carries evidence. await requestConfig(); assert.equal(puts, 1, "cold miss must write the cache once"); +assert.ok(storedCache().data.binance.default.execution_evidence.observed_at, + "the fixture payload must include the per-build stamp the production payload has"); -// 2) Follow-up request inside the fresh window: memo serves, zero writes. +// 2) Follow-up request inside the memo window: 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. +// 3) Past the memo window: the refresh rebuilds the payload with a new observed_at, +// which byte-comparison calls "changed". Content comparison must skip the write. nowOffset += 61_000; await requestConfig(); -assert.equal(puts, 1, "unchanged payload within the bump interval must skip the write"); +assert.equal(puts, 1, "observed_at-only change must not write"); +assert.ok(storedCache().data.binance.default.execution_evidence.observed_at, + "the stored entry keeps the stamp it was written with"); -// 4) Stored ts aged past the bump interval: exactly one keep-alive rewrite. -nowOffset += 600_000; +// 4) Stored entry aged past the 15-minute keep-alive ceiling: one rewrite. +nowOffset += 900_000; await requestConfig(); assert.equal(puts, 2, "aged cache entry must take one keep-alive bump"); @@ -61,6 +78,131 @@ nowOffset += 61_000; await requestConfig(); assert.equal(puts, 2, "post-bump requests must stay write-free"); +// Direct helper cases pin the boundaries and the failure modes the HTTP timeline +// cannot reach from a single isolate. +const { + writeCurrentStrategiesCache, currentStrategiesSignature, isDegradedCurrentStrategies, + readCurrentStrategiesCacheEntry, isConfigJsonParseError, +} = __test; +const evidencePayload = (observedAt, netAssets) => ({ + longbridge: { + hk: { execution_evidence: { status: "reported", observed_at: observedAt }, net_assets: netAssets }, + }, + // Evidence rows carry data-derived timestamps; only observed_at is stamped per build. + accounts: [{ execution_evidence: { observed_at: observedAt, rows: [{ at: "2026-10-30T12:00:00.000Z" }] }, status: "ok" }], +}); + +function makeIsolatedEnv(initial) { + const isolatedStore = new Map(initial ?? []); + const state = { puts: 0 }; + return { + state, + env: { + ...env, + STRATEGY_SWITCH_CONFIG: { + async get(key) { return isolatedStore.has(key) ? isolatedStore.get(key) : null; }, + async put(key, value) { state.puts++; isolatedStore.set(key, value); }, + async list() { return { keys: [] }; }, + async delete(key) { isolatedStore.delete(key); }, + }, + }, + raw: { store: isolatedStore }, + }; +} + +const baseTs = 1_793_500_000_000; +const bump = __test.currentStrategiesKvBumpMs; +const fixture = makeIsolatedEnv(); +const write = (payload, ts) => writeCurrentStrategiesCache(fixture.env, payload, ts); + +await write(evidencePayload("2026-11-01T04:00:00.000Z", "100"), baseTs); +assert.equal(fixture.state.puts, 1, "first cache population must write"); + +await write(evidencePayload("2026-11-01T04:05:00.000Z", "100"), baseTs + 300_000); +await write(evidencePayload("2026-11-01T04:10:00.000Z", "100"), baseTs + 600_000); +assert.equal(fixture.state.puts, 1, "repeated cron ticks on unchanged content must not write"); + +// Exactly at the ceiling the entry is no longer inside it, so the keep-alive fires. +await write(evidencePayload("2026-11-01T04:15:00.000Z", "100"), baseTs + bump); +assert.equal(fixture.state.puts, 2, "a stored entry at the bump ceiling must be rewritten"); + +await write(evidencePayload("2026-11-01T04:20:00.000Z", "100"), baseTs + bump + 300_000); +assert.equal(fixture.state.puts, 2, "the fresh bump buys another full interval of no writes"); + +const stored = JSON.parse(fixture.raw.store.get("current_strategies_cache")); +assert.equal(stored.data.longbridge.hk.execution_evidence.observed_at, "2026-11-01T04:15:00.000Z", + "the stored payload must keep its own stamps for readers"); +assert.equal(stored.ts, baseTs + bump); + +await write(evidencePayload("2026-11-01T04:26:00.000Z", "250"), baseTs + bump + 360_000); +assert.equal(fixture.state.puts, 3, "a real content change must write immediately, inside the bump interval"); + +// A damaged stored value must not wedge the cache forever. +const broken = makeIsolatedEnv([["current_strategies_cache", "{not json"]]); +await writeCurrentStrategiesCache(broken.env, evidencePayload("2026-11-01T04:00:00.000Z", "100"), baseTs); +assert.equal(broken.state.puts, 1, "an unparseable stored value must be overwritten"); + +assert.notEqual( + currentStrategiesSignature(evidencePayload("2026-11-01T04:00:00.000Z", "100")), + currentStrategiesSignature(evidencePayload("2026-11-01T04:00:00.000Z", "101")), + "signature must distinguish rendered values", +); + +// The read_failed fallback carries no strategy information, so it must never be +// served as a fresh entry for the length of the KV window — and a payload that +// really did render (the captured entry above carries not_connected evidence) must +// keep the window, otherwise the guard silently disables the whole optimization. +assert.equal(isDegradedCurrentStrategies({ + binance: { default: { execution_evidence: { status: "read_failed", observed_at: "2026-11-01T04:00:00.000Z" } } }, +}), true, "the evidence-only read_failed shape must count as degraded"); +assert.equal(isDegradedCurrentStrategies(storedCache().data), false, + "a rendered payload must not count as degraded"); +assert.equal(isDegradedCurrentStrategies({ + binance: { default: { execution_evidence: { status: "read_failed" }, current_strategy: "x" } }, +}), false, "an account carrying strategy data is not degraded"); +assert.equal(isDegradedCurrentStrategies({}), false, + "an unconfigured account set is not a read failure"); + +// The freshness windows must leave slack for a missed cron tick: an entry that is +// only kept alive at the bump ceiling still has to land inside the KV fresh window. +assert.ok(bump + 300_000 <= __test.currentStrategiesKvFreshMs, + "bump ceiling plus one 5-minute tick must stay inside the KV fresh window"); + +// The cached payload is echoed back through /api/config, so re-stamping a stored +// timestamp on every read would make each read a distinct value — and a reader that +// wrote back what it read would defeat the dedup in production. +const tsBeforeEcho = storedCache().ts; +await requestConfig(); +assert.equal(storedCache().ts, tsBeforeEcho, "reading /api/config must not re-stamp the cached ts"); +assert.equal(puts, 2, "reading /api/config must not add a write"); + +// A damaged entry has to be a miss on the *read* path too, not a 500: let the +// isolate memo age past its stale ceiling so the KV branch is the one that sees it. +store.set("current_strategies_cache", "{not json"); +nowOffset += 3_700_000; +const putsBeforeRepair = puts; +await requestConfig(); +assert.equal(puts, putsBeforeRepair + 1, "a damaged cache entry must be repaired by exactly one write"); +assert.ok(storedCache().data, "the repaired entry must carry a payload again"); + +// A KV outage is not a damaged entry. Swallowing it would fan every cold request out +// to GitHub while the store is unhealthy. +assert.equal(isConfigJsonParseError(new Error("STRATEGY_SWITCH_CONFIG.current_strategies_cache must be valid JSON")), true, + "the parse failure readConfigJson reports must classify as damage"); +assert.equal(isConfigJsonParseError(new Error("kv unavailable")), false, + "a transport failure must not classify as damage"); +await assert.rejects( + () => readCurrentStrategiesCacheEntry({ + ...env, + STRATEGY_SWITCH_CONFIG: { + async get() { throw new Error("kv unavailable"); }, + async put() {}, async list() { return { keys: [] }; }, async delete() {}, + }, + }), + /kv unavailable/, + "an unavailable KV read must still reach the caller", +); + Date.now = realNow; globalThis.fetch = realFetch; -console.log("console_current_strategies_kv_dedup_validation: PASS"); +console.log(`console_current_strategies_kv_dedup_validation: PASS (fetch attempts ${fetchAttempts})`); diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index c41c6f01..55cf26cc 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -183,9 +183,13 @@ 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 = 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 +// The keep-alive bump stays below the KV fresh window so one missed cron tick +// (the schedule is */5) still leaves the entry inside it; a cold isolate then +// never has to fan out to GitHub just because the stamp aged out. +const CURRENT_STRATEGIES_MEMO_TTL_MS = 60_000; // 60 sec — a warm isolate re-checks GitHub after this +const CURRENT_STRATEGIES_KV_FRESH_MS = 1_800_000; // 30 min — a cold isolate serves the KV entry without any GitHub fan-out +const CURRENT_STRATEGIES_STALE_TTL_MS = 3_600_000; // 60 min — return stale plus background refresh before blocking on GitHub +const CURRENT_STRATEGIES_KV_BUMP_MS = 900_000; // 15 min — ceiling for ts-only rewrites when the content did not change const GITHUB_API_TIMEOUT_MS = 8000; const GITHUB_LOGIN_REQUEST_TIMEOUT_MS = 20000; const UX1_CALCULATOR_DEADLINE_MS = 30000; @@ -6321,23 +6325,70 @@ 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; +// KV free tier caps writes at 1,000/day account-wide and the */5 cron plus every +// background refresh reaches this helper, so a write is paid for only when the +// rendered content changed or the stored entry needs its ts bumped to stay inside +// the fresh window that cold isolates serve from. +// current_strategies_cache carries per-account execution_evidence.observed_at +// stamped with new Date() (see accountExecutionEvidence callers), so whole-payload +// byte equality can never hold; the comparison drops those stamps and keeps the +// stored value untouched for readers. +function currentStrategiesSignature(payload) { + const clone = JSON.parse(JSON.stringify(payload ?? null)); + const visit = (node) => { + if (Array.isArray(node)) { + node.forEach(visit); + return; + } + if (!node || typeof node !== "object") return; + for (const [key, value] of Object.entries(node)) { + if (key === "execution_evidence" && value && typeof value === "object") delete value.observed_at; + else visit(value); + } + }; + visit(clone); + return JSON.stringify(clone); +} + +// readConfigJson reports a damaged value as `STRATEGY_SWITCH_CONFIG. must be +// valid JSON`. Only that shape is a cache miss; a KV read that failed for any other +// reason is an outage and has to reach the caller instead of being mistaken for an +// empty cache — the difference is what keeps cold isolates from fanning out to +// GitHub on every request while the store is unhealthy. +function isConfigJsonParseError(error) { + return /^STRATEGY_SWITCH_CONFIG\.[^\s]+ must be valid JSON$/.test(String(error?.message || "")); +} + +async function readCurrentStrategiesCacheEntry(env) { + try { + return await readConfigJson(env, CURRENT_STRATEGIES_CACHE_KEY); + } catch (error) { + if (!isConfigJsonParseError(error)) throw error; + return 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; + const signature = currentStrategiesSignature(payload); + // A damaged stored value must not wedge the cache; this is the only path that can + // overwrite it, so the comparison reads through the same repair-aware helper. + const cached = await readCurrentStrategiesCacheEntry(env); + if (cached?.data && currentStrategiesSignature(cached.data) === signature + && cached.ts && ts - cached.ts < CURRENT_STRATEGIES_KV_BUMP_MS) { 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; +} + +// loadCurrentStrategiesSafely answers with evidence-only account entries whenever +// GitHub timed out or threw, so a payload whose every account is exactly that +// shape carries no strategy information and must not be served as fresh. +function isDegradedCurrentStrategies(payload) { + const accounts = Object.values(payload || {}).flatMap(options => Object.values(options || {})); + if (!accounts.length) return false; + return accounts.every(account => account && typeof account === "object" + && Object.keys(account).length === 1 + && account.execution_evidence?.status === "read_failed"); } async function configPayload(request, env, ctx) { @@ -6367,7 +6418,7 @@ async function readCurrentStrategies(accountOptions, env, ctx) { // 1) In-memory cache if (_memCurrentStrategies) { const age = Date.now() - _memCurrentStrategiesTs; - if (age < CURRENT_STRATEGIES_CACHE_TTL_MS) { + if (age < CURRENT_STRATEGIES_MEMO_TTL_MS) { currentStrategies = _memCurrentStrategies; cacheFresh = true; } else if (age < CURRENT_STRATEGIES_STALE_TTL_MS) { @@ -6378,10 +6429,13 @@ async function readCurrentStrategies(accountOptions, env, ctx) { // 2) KV cache if (!currentStrategies && hasConfigStore(env)) { - const cached = await readConfigJson(env, CURRENT_STRATEGIES_CACHE_KEY); + const cached = await readCurrentStrategiesCacheEntry(env); if (cached?.ts && cached.data) { const age = Date.now() - cached.ts; - if (age < CURRENT_STRATEGIES_CACHE_TTL_MS) { + // A degraded entry stays inside the freshness window but is never *fresh*: + // the next request must revalidate instead of serving a read_failed payload + // for the whole 30 minutes. + if (age < CURRENT_STRATEGIES_KV_FRESH_MS && !isDegradedCurrentStrategies(cached.data)) { currentStrategies = cached.data; cacheFresh = true; } else if (age < CURRENT_STRATEGIES_STALE_TTL_MS) { @@ -17648,6 +17702,13 @@ function escapeHtml(value) { export const __test = { effectiveAccountSettings, + writeCurrentStrategiesCache, + readCurrentStrategiesCacheEntry, + isConfigJsonParseError, + currentStrategiesSignature, + isDegradedCurrentStrategies, + currentStrategiesKvBumpMs: CURRENT_STRATEGIES_KV_BUMP_MS, + currentStrategiesKvFreshMs: CURRENT_STRATEGIES_KV_FRESH_MS, dispatchAccountDiagnosis, humanDecisionReceipt, researchPromotionMaterialKey,