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
1 change: 1 addition & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
170 changes: 156 additions & 14 deletions tests/console_current_strategies_kv_dedup_validation.mjs
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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); },
Expand All @@ -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(() => {})); } };
Expand All @@ -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");

Expand All @@ -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})`);
97 changes: 79 additions & 18 deletions web/strategy-switch-console/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.<key> 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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
Loading