diff --git a/docs/m0_research_publisher_envelope_contract.md b/docs/m0_research_publisher_envelope_contract.md index b9fa84e..4d39bc7 100644 --- a/docs/m0_research_publisher_envelope_contract.md +++ b/docs/m0_research_publisher_envelope_contract.md @@ -159,3 +159,8 @@ Environment 保护的 fallback。控制台部署会在部署前验证该 secret URL、发布 token 和 QAR 读取 token 不会写进封套、`GITHUB_STEP_SUMMARY` 或 workflow 输出。该 workflow 不读取运行时、平台、selector、策略或券商配置;其唯一网络写入是构建器在 `--publish` 明确指定时,对上述研究接收地址发送经过校验的 no-order 封套。 + +若已存 current 与来件的 `source_artifact.sha256` 完全相同,接收端返回 `200` 和 +`replayed: true`,且不再写 KV。这只确认同一不可变来源已经收到,供网络重试或重复人工触发 +恢复;不同 source artifact 的重复 source run、不同来源的重复 ledger 或 ledger 时间回退仍返回 +`409`。 diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index 75fc2c7..e25db87 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -2818,7 +2818,9 @@ const m0ResearchSync = await worker.fetch( m0ResearchEnv, ); assert.equal(m0ResearchSync.status, 200); -assert.equal((await m0ResearchSync.json()).no_order, true); +const m0ResearchSyncPayload = await m0ResearchSync.json(); +assert.equal(m0ResearchSyncPayload.replayed, false); +assert.equal(m0ResearchSyncPayload.no_order, true); assert.ok(m0LedgerStore.has("m0_research_ledger_current")); assert.ok(m0LedgerStore.has(`m0_research_ledger_archive:${m0ResearchLedgerSha}`)); const storedM0ResearchCurrent = JSON.parse(m0LedgerStore.get("m0_research_ledger_current")); @@ -2831,6 +2833,21 @@ const m0ResearchLedgerWrites = m0LedgerPutOptions.filter((entry) => ( )); assert.equal(m0ResearchLedgerWrites.length, 2); assert.ok(m0ResearchLedgerWrites.every((entry) => entry.options?.expirationTtl === 14 * 24 * 60 * 60)); +const m0ExactSourceReplay = await worker.fetch( + new Request("https://switch.example/api/internal/sync-m0-research-ledger", { + method: "POST", + headers: { Authorization: `Bearer ${m0ResearchSyncValue}`, "Content-Type": "application/json" }, + body: JSON.stringify(m0ResearchEnvelope), + }), + m0ResearchEnv, +); +assert.equal(m0ExactSourceReplay.status, 200); +const m0ExactSourceReplayPayload = await m0ExactSourceReplay.json(); +assert.equal(m0ExactSourceReplayPayload.replayed, true); +assert.equal(m0ExactSourceReplayPayload.no_order, true); +assert.equal(m0LedgerPutOptions.filter((entry) => ( + entry.key === "m0_research_ledger_current" || entry.key.startsWith("m0_research_ledger_archive:") +)).length, 2); const m0ResearchRead = await worker.fetch( new Request("https://switch.example/api/m0-research", { headers: m0ResearchCookieHeaders }), m0ResearchEnv, diff --git a/web/strategy-switch-console/README.md b/web/strategy-switch-console/README.md index aaef93b..e16799e 100644 --- a/web/strategy-switch-console/README.md +++ b/web/strategy-switch-console/README.md @@ -213,6 +213,8 @@ Add the returned namespace id to `wrangler.toml`. For GitHub Actions auto-deploy, configure `STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID`, `STRATEGY_SWITCH_CONSOLE_URL`, `STRATEGY_SWITCH_SYNC_TOKEN`, `M0_RESEARCH_SYNC_TOKEN`, and either `CLOUDFLARE_API_TOKEN` or `CLOUDFLARE_WRANGLER_CONFIG_TOML` in the `runtime-strategy-switch` environment (or reuse `RUNTIME_SETTINGS_GH_TOKEN` only if it matches the Worker sync secret). `CLOUDFLARE_ACCOUNT_ID` is optional when Wrangler can infer it from the token. `M0_RESEARCH_SYNC_TOKEN` must match the separately protected `m0-research-publisher` environment secret; it is only copied to the Worker binding. A missing M0 token fails the deployment before it can retain a stale Worker secret. The workflow deploys the Worker and then syncs the bundled strategy profile catalog into KV so the website is not left with stale profile/plugin metadata. +An authenticated retry carrying the exact same immutable M0 source-artifact SHA is acknowledged with `200` and `replayed: true`, without another KV write. A different source/run replay or a ledger-time rollback remains rejected with `409`. + Deploy: ```bash diff --git a/web/strategy-switch-console/README.zh-CN.md b/web/strategy-switch-console/README.zh-CN.md index 6d347e1..7099bdc 100644 --- a/web/strategy-switch-console/README.zh-CN.md +++ b/web/strategy-switch-console/README.zh-CN.md @@ -124,8 +124,10 @@ JSON 计算并验证 `ledger_sha256`;`source_artifact.sha256` 是已认证的 不超过 262,144 bytes;Worker 保留同一请求体上限作为接收端边界,不另行转换或放宽该契约。 Worker 固定使用 `m0_research_ledger_current` 和由已校验 digest 派生的 -`m0_research_ledger_archive:`;调用方不能传入 KV key。current 记录检测到 -重复 artifact/ledger、相同 source run ID 或 ledger 时间回退会返回 `409`。这是基于 KV +`m0_research_ledger_archive:`;调用方不能传入 KV key。若来件的 +`source_artifact.sha256` 与当前记录完全相同,Worker 返回 `200` 与 `replayed: true`,且不写入 +KV,用于网络重试或重复人工触发。不同 source artifact 的相同 source run ID、不同来源的重复 +ledger 或时间回退仍会返回 `409`。这是基于 KV 当前记录的 **best-effort** 重放/回退保护:Cloudflare KV 不是线性一致的比较并交换存储, 并发写入仍不能被表述为强原子顺序保证。本接口不为此新增 Durable Object 或其他绑定;它的 职责仍限于 no-order 研究资料接收。current 与 archive 均使用 Cloudflare KV 的 14 天物理 diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index e9c2a86..601f53a 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -2259,6 +2259,23 @@ async function syncM0ResearchLedgerResponse(request, env) { const current = await readCurrentM0ResearchLedgerRecord(env); if (current) { + // A retry for the exact immutable source is not a new decision or a + // second write. Acknowledge it so a lost response cannot make a valid + // no-order publication look failed, while retaining the guards below. + if (envelope.source_artifact.sha256 === current.envelope.source_artifact.sha256) { + return json({ + ok: true, + schema_version: current.envelope.schema_version, + source_repository: current.envelope.source_artifact.repository, + source_revision: current.envelope.source_artifact.revision, + source_run_id: current.envelope.source_artifact.run_id, + artifact_id: current.envelope.source_artifact.artifact_id, + ledger_sha256: current.envelope.ledger_sha256, + replayed: true, + no_order: true, + expires_at: current.expires_at, + }); + } const replayError = m0ResearchLedgerReplayError(current.envelope, envelope); if (replayError) return json({ ok: false, error: replayError }, 409); } @@ -2308,6 +2325,7 @@ async function syncM0ResearchLedgerResponse(request, env) { source_run_id: envelope.source_artifact.run_id, artifact_id: envelope.source_artifact.artifact_id, ledger_sha256: envelope.ledger_sha256, + replayed: false, no_order: true, expires_at: expiresAt, });