From ec99e053557fc76519101a30c2cf18b80a311b29 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:31:19 +0800 Subject: [PATCH 1/3] fix(console): compare stored KV content, not bytes, for cache dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1013 left worker-STRATEGY_SWITCH_CONFIG at one KV write per 5-minute cron tick (39 writes in the 29 minutes after 02:30 UTC on 2026-11-01, against 644 for the pre-fix day). current_strategies_cache stamps execution_evidence.observed_at with new Date() on every build, so the payload bytes differ on every refresh and no whole-payload comparison can report "unchanged"; the module-level memo holding the previous payload was also null in a cold isolate, which sent the cron straight to the unconditional write. writeCurrentStrategiesCache now compares a signature that drops those stamps against the payload already stored in KV, so it writes only on real content change or once the stored entry passes the keep-alive ceiling. The single 60s/10min pair is split into memo 60s, KV fresh 20min, stale ceiling 60min and bump ceiling 20min — the bump ceiling equals the fresh window, so a cold isolate never finds the entry older than 20 minutes and never blocks on GitHub. A strategy switch still reaches KV within one cron tick because a real change bypasses the bump gate. --- ...current_strategies_kv_dedup_validation.mjs | 71 ++++++++++++++++--- web/strategy-switch-console/worker.js | 53 +++++++++----- 2 files changed, 98 insertions(+), 26 deletions(-) diff --git a/tests/console_current_strategies_kv_dedup_validation.mjs b/tests/console_current_strategies_kv_dedup_validation.mjs index da702826..7c8d2c64 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: +// one write on cold miss, none while the rendered content is unchanged (until the +// 20-minute ts keep-alive bump), and — the case that defeats a whole-payload +// comparison — none when only execution_evidence.observed_at moved. import assert from "node:assert/strict"; import worker, { __test } from "./helpers/directory_worker.mjs"; @@ -41,18 +42,17 @@ const requestConfig = async () => { 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. +// 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 with unchanged content and a recently bumped entry. nowOffset += 61_000; await requestConfig(); -assert.equal(puts, 1, "unchanged payload within the bump interval must skip the write"); +assert.equal(puts, 1, "unchanged content within the bump interval must skip the write"); -// 4) Stored ts aged past the bump interval: exactly one keep-alive rewrite. -nowOffset += 600_000; +// 4) Stored entry aged past the 20-minute bump interval: one keep-alive rewrite. +nowOffset += 1_200_000; await requestConfig(); assert.equal(puts, 2, "aged cache entry must take one keep-alive bump"); @@ -61,6 +61,59 @@ nowOffset += 61_000; await requestConfig(); assert.equal(puts, 2, "post-bump requests must stay write-free"); +// Direct helper cases: the production payload re-stamps +// execution_evidence.observed_at with new Date() on every build, so equality has to +// ignore those stamps while still catching a real change. +const { writeCurrentStrategiesCache, currentStrategiesSignature } = __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" }], +}); + +const isolatedStore = new Map(); +let isolatedPuts = 0; +const isolatedEnv = { + ...env, + STRATEGY_SWITCH_CONFIG: { + async get(key) { return isolatedStore.has(key) ? isolatedStore.get(key) : null; }, + async put(key, value) { isolatedPuts++; isolatedStore.set(key, value); }, + async list() { return { keys: [] }; }, + async delete(key) { isolatedStore.delete(key); }, + }, +}; + +const write = async (payload, ts) => writeCurrentStrategiesCache(isolatedEnv, payload, ts); +const baseTs = 1_793_500_000_000; + +await write(evidencePayload("2026-11-01T04:00:00.000Z", "100"), baseTs); +assert.equal(isolatedPuts, 1, "first cache population must write"); + +await write(evidencePayload("2026-11-01T04:05:00.000Z", "100"), baseTs + 300_000); +assert.equal(isolatedPuts, 1, "observed_at-only change must not write"); + +await write(evidencePayload("2026-11-01T04:10:00.000Z", "100"), baseTs + 600_000); +assert.equal(isolatedPuts, 1, "repeated cron ticks on unchanged content must not write"); + +await write(evidencePayload("2026-11-01T04:25:00.000Z", "100"), baseTs + 1_500_000); +assert.equal(isolatedPuts, 2, "content that only carries new stamps still needs one keep-alive bump"); + +const stored = JSON.parse(isolatedStore.get("current_strategies_cache")); +assert.equal(stored.data.longbridge.hk.execution_evidence.observed_at, "2026-11-01T04:25:00.000Z", + "the stored payload must keep its own stamps for readers"); +assert.equal(stored.ts, baseTs + 1_500_000); + +await write(evidencePayload("2026-11-01T04:26:00.000Z", "250"), baseTs + 1_560_000); +assert.equal(isolatedPuts, 3, "a real content change must write immediately, inside the bump interval"); + +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", +); + 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 c41c6f01..c886b8d7 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -183,9 +183,10 @@ 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 +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_200_000; // 20 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 = 1_200_000; // 20 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 +6322,39 @@ 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); +} 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); + const cached = await readConfigJson(env, CURRENT_STRATEGIES_CACHE_KEY); + 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; } async function configPayload(request, env, ctx) { @@ -6367,7 +6384,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) { @@ -6381,7 +6398,7 @@ async function readCurrentStrategies(accountOptions, env, ctx) { const cached = await readConfigJson(env, CURRENT_STRATEGIES_CACHE_KEY); if (cached?.ts && cached.data) { const age = Date.now() - cached.ts; - if (age < CURRENT_STRATEGIES_CACHE_TTL_MS) { + if (age < CURRENT_STRATEGIES_KV_FRESH_MS) { currentStrategies = cached.data; cacheFresh = true; } else if (age < CURRENT_STRATEGIES_STALE_TTL_MS) { @@ -17648,6 +17665,8 @@ function escapeHtml(value) { export const __test = { effectiveAccountSettings, + writeCurrentStrategiesCache, + currentStrategiesSignature, dispatchAccountDiagnosis, humanDecisionReceipt, researchPromotionMaterialKey, From 75fe7dd1fd49ec382e9b811b27d1f81465a6d7ae Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:07:42 +0800 Subject: [PATCH 2/3] fix(console): stop the widened KV window from masking failures Three holes in the dedup change, each found by re-reading the paths the new comparison depends on: - loadCurrentStrategiesSafely returns an evidence-only read_failed payload when GitHub times out, and that payload was written and then served as *fresh* for the whole KV window. A transient failure used to cost 60 seconds of stale serving; with a widened window it would cost minutes. An entry whose every account is exactly the fallback shape now stays inside the window but never counts as fresh, so the next request revalidates. - readConfigJson throws on invalid JSON and the stored value is now read before writing, so a damaged entry would block every future write and could never be overwritten. The read is guarded and a damaged value is treated as absent. - The keep-alive ceiling equalled the fresh window, leaving no slack: one late cron tick pushed the entry out of freshness and every cold isolate fanned out to GitHub. Bump is now 15 min against a 30 min window. The dedup test only proved what it looked like it proved: the fixture carried no accounts, so the stored payload was an empty object and steps 1-5 passed against the previous byte-comparison code too. It now configures an account, which makes the payload carry the per-build observed_at stamp, and fails at the observed_at-only case when the comparison is switched back to bytes. Also pins the exact bump boundary, the damaged-value self-heal, the degraded predicate on both a real rendered payload and a read_failed shape, and the bump-plus-tick invariant, and the file is finally listed in validate.yml so CI runs it. --- .github/workflows/validate.yml | 1 + ...current_strategies_kv_dedup_validation.mjs | 131 ++++++++++++------ web/strategy-switch-console/worker.js | 35 ++++- 3 files changed, 123 insertions(+), 44 deletions(-) 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 7c8d2c64..ed0af3e5 100644 --- a/tests/console_current_strategies_kv_dedup_validation.mjs +++ b/tests/console_current_strategies_kv_dedup_validation.mjs @@ -1,7 +1,7 @@ -// Validates that current_strategies_cache KV writes stay quota-safe: -// one write on cold miss, none while the rendered content is unchanged (until the -// 20-minute ts keep-alive bump), and — the case that defeats a whole-payload -// comparison — none when only execution_evidence.observed_at moved. +// 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"; @@ -10,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); }, @@ -25,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(() => {})); } }; @@ -38,21 +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 memo window: zero writes. await requestConfig(); assert.equal(puts, 1, "fresh memo must not write"); -// 3) Past the memo window with unchanged content and a recently bumped entry. +// 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 content 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 entry aged past the 20-minute bump interval: one keep-alive rewrite. -nowOffset += 1_200_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,10 +78,9 @@ nowOffset += 61_000; await requestConfig(); assert.equal(puts, 2, "post-bump requests must stay write-free"); -// Direct helper cases: the production payload re-stamps -// execution_evidence.observed_at with new Date() on every build, so equality has to -// ignore those stamps while still catching a real change. -const { writeCurrentStrategiesCache, currentStrategiesSignature } = __test; +// Direct helper cases pin the boundaries and the failure modes the HTTP timeline +// cannot reach from a single isolate. +const { writeCurrentStrategiesCache, currentStrategiesSignature, isDegradedCurrentStrategies } = __test; const evidencePayload = (observedAt, netAssets) => ({ longbridge: { hk: { execution_evidence: { status: "reported", observed_at: observedAt }, net_assets: netAssets }, @@ -73,40 +89,55 @@ const evidencePayload = (observedAt, netAssets) => ({ accounts: [{ execution_evidence: { observed_at: observedAt, rows: [{ at: "2026-10-30T12:00:00.000Z" }] }, status: "ok" }], }); -const isolatedStore = new Map(); -let isolatedPuts = 0; -const isolatedEnv = { - ...env, - STRATEGY_SWITCH_CONFIG: { - async get(key) { return isolatedStore.has(key) ? isolatedStore.get(key) : null; }, - async put(key, value) { isolatedPuts++; isolatedStore.set(key, value); }, - async list() { return { keys: [] }; }, - async delete(key) { isolatedStore.delete(key); }, - }, -}; +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 write = async (payload, ts) => writeCurrentStrategiesCache(isolatedEnv, payload, ts); 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(isolatedPuts, 1, "first cache population must write"); +assert.equal(fixture.state.puts, 1, "first cache population must write"); await write(evidencePayload("2026-11-01T04:05:00.000Z", "100"), baseTs + 300_000); -assert.equal(isolatedPuts, 1, "observed_at-only change must not write"); - await write(evidencePayload("2026-11-01T04:10:00.000Z", "100"), baseTs + 600_000); -assert.equal(isolatedPuts, 1, "repeated cron ticks on unchanged content must not write"); +assert.equal(fixture.state.puts, 1, "repeated cron ticks on unchanged content must not write"); -await write(evidencePayload("2026-11-01T04:25:00.000Z", "100"), baseTs + 1_500_000); -assert.equal(isolatedPuts, 2, "content that only carries new stamps still needs one keep-alive bump"); +// 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"); -const stored = JSON.parse(isolatedStore.get("current_strategies_cache")); -assert.equal(stored.data.longbridge.hk.execution_evidence.observed_at, "2026-11-01T04:25:00.000Z", +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 + 1_500_000); +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"); -await write(evidencePayload("2026-11-01T04:26:00.000Z", "250"), baseTs + 1_560_000); -assert.equal(isolatedPuts, 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")), @@ -114,6 +145,26 @@ assert.notEqual( "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"); + 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 c886b8d7..fdafa246 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -183,10 +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"; +// 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_200_000; // 20 min — a cold isolate serves the KV entry without any GitHub fan-out +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 = 1_200_000; // 20 min — ceiling for ts-only rewrites when the content did not change +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; @@ -6349,7 +6352,14 @@ function currentStrategiesSignature(payload) { async function writeCurrentStrategiesCache(env, payload, ts) { const signature = currentStrategiesSignature(payload); - const cached = await readConfigJson(env, CURRENT_STRATEGIES_CACHE_KEY); + // A damaged stored value must not wedge the cache: readConfigJson throws on + // invalid JSON, and this helper is the only path that can overwrite it. + let cached = null; + try { + cached = await readConfigJson(env, CURRENT_STRATEGIES_CACHE_KEY); + } catch { + cached = null; + } if (cached?.data && currentStrategiesSignature(cached.data) === signature && cached.ts && ts - cached.ts < CURRENT_STRATEGIES_KV_BUMP_MS) { return; @@ -6357,6 +6367,17 @@ async function writeCurrentStrategiesCache(env, payload, ts) { await writeConfigJson(env, CURRENT_STRATEGIES_CACHE_KEY, { ts, data: payload }); } +// 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) { const session = await readSession(request, env); const meta = await loadPlatformMeta(env); @@ -6398,7 +6419,10 @@ async function readCurrentStrategies(accountOptions, env, ctx) { const cached = await readConfigJson(env, CURRENT_STRATEGIES_CACHE_KEY); if (cached?.ts && cached.data) { const age = Date.now() - cached.ts; - if (age < CURRENT_STRATEGIES_KV_FRESH_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) { @@ -17667,6 +17691,9 @@ export const __test = { effectiveAccountSettings, writeCurrentStrategiesCache, currentStrategiesSignature, + isDegradedCurrentStrategies, + currentStrategiesKvBumpMs: CURRENT_STRATEGIES_KV_BUMP_MS, + currentStrategiesKvFreshMs: CURRENT_STRATEGIES_KV_FRESH_MS, dispatchAccountDiagnosis, humanDecisionReceipt, researchPromotionMaterialKey, From 11eca1656f5f9a66997d75fd8a95eb6f17343532 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:34:22 +0800 Subject: [PATCH 3/3] fix(console): treat a damaged cache entry as a miss on read CodeRabbit's incremental audit of 75fe7dd flagged a Major stability issue: readCurrentStrategies read current_strategies_cache through readConfigJson, which throws on invalid JSON, so a damaged entry failed the cold /api/config request instead of letting the next load repair it. Route both the read and the write comparison through readCurrentStrategiesCacheEntry, which returns null only for the parse-failure message readConfigJson raises. A KV read that fails for any other reason is an outage and still propagates, so cold isolates keep their GitHub payloads rather than fanning out on every request while the store is unhealthy. The validation now ages the isolate memo past its stale ceiling so the KV branch is what sees the damaged entry: the request must answer 200 and repair with exactly one write, and a transport failure must not classify as damage. Reverting the read-site guard reproduces the 500, so the case is not vacuous. --- ...current_strategies_kv_dedup_validation.mjs | 40 ++++++++++++++++++- web/strategy-switch-console/worker.js | 33 ++++++++++----- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/tests/console_current_strategies_kv_dedup_validation.mjs b/tests/console_current_strategies_kv_dedup_validation.mjs index ed0af3e5..8eb9eb34 100644 --- a/tests/console_current_strategies_kv_dedup_validation.mjs +++ b/tests/console_current_strategies_kv_dedup_validation.mjs @@ -80,7 +80,10 @@ 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 } = __test; +const { + writeCurrentStrategiesCache, currentStrategiesSignature, isDegradedCurrentStrategies, + readCurrentStrategiesCacheEntry, isConfigJsonParseError, +} = __test; const evidencePayload = (observedAt, netAssets) => ({ longbridge: { hk: { execution_evidence: { status: "reported", observed_at: observedAt }, net_assets: netAssets }, @@ -165,6 +168,41 @@ assert.equal(isDegradedCurrentStrategies({}), false, 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 (fetch attempts ${fetchAttempts})`); diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index fdafa246..55cf26cc 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -6350,16 +6350,29 @@ function currentStrategiesSignature(payload) { return JSON.stringify(clone); } -async function writeCurrentStrategiesCache(env, payload, ts) { - const signature = currentStrategiesSignature(payload); - // A damaged stored value must not wedge the cache: readConfigJson throws on - // invalid JSON, and this helper is the only path that can overwrite it. - let cached = null; +// 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 { - cached = await readConfigJson(env, CURRENT_STRATEGIES_CACHE_KEY); - } catch { - cached = null; + return await readConfigJson(env, CURRENT_STRATEGIES_CACHE_KEY); + } catch (error) { + if (!isConfigJsonParseError(error)) throw error; + return null; } +} + +async function writeCurrentStrategiesCache(env, payload, ts) { + 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; @@ -6416,7 +6429,7 @@ 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; // A degraded entry stays inside the freshness window but is never *fresh*: @@ -17690,6 +17703,8 @@ function escapeHtml(value) { export const __test = { effectiveAccountSettings, writeCurrentStrategiesCache, + readCurrentStrategiesCacheEntry, + isConfigJsonParseError, currentStrategiesSignature, isDegradedCurrentStrategies, currentStrategiesKvBumpMs: CURRENT_STRATEGIES_KV_BUMP_MS,