Skip to content

Commit 3ab8881

Browse files
Pigbibicodex
andcommitted
fix: make exact M0 source retries idempotent
Co-Authored-By: Codex <noreply@openai.com>
1 parent cf05e85 commit 3ab8881

5 files changed

Lines changed: 47 additions & 3 deletions

File tree

docs/m0_research_publisher_envelope_contract.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,8 @@ Environment 保护的 fallback。控制台部署会在部署前验证该 secret
159159
URL、发布 token 和 QAR 读取 token 不会写进封套、`GITHUB_STEP_SUMMARY` 或 workflow 输出。该
160160
workflow 不读取运行时、平台、selector、策略或券商配置;其唯一网络写入是构建器在
161161
`--publish` 明确指定时,对上述研究接收地址发送经过校验的 no-order 封套。
162+
163+
若已存 current 与来件的 `source_artifact.sha256` 完全相同,接收端返回 `200`
164+
`replayed: true`,且不再写 KV。这只确认同一不可变来源已经收到,供网络重试或重复人工触发
165+
恢复;不同 source artifact 的重复 source run、不同来源的重复 ledger 或 ledger 时间回退仍返回
166+
`409`

tests/strategy_switch_worker_validation.mjs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2818,7 +2818,9 @@ const m0ResearchSync = await worker.fetch(
28182818
m0ResearchEnv,
28192819
);
28202820
assert.equal(m0ResearchSync.status, 200);
2821-
assert.equal((await m0ResearchSync.json()).no_order, true);
2821+
const m0ResearchSyncPayload = await m0ResearchSync.json();
2822+
assert.equal(m0ResearchSyncPayload.replayed, false);
2823+
assert.equal(m0ResearchSyncPayload.no_order, true);
28222824
assert.ok(m0LedgerStore.has("m0_research_ledger_current"));
28232825
assert.ok(m0LedgerStore.has(`m0_research_ledger_archive:${m0ResearchLedgerSha}`));
28242826
const storedM0ResearchCurrent = JSON.parse(m0LedgerStore.get("m0_research_ledger_current"));
@@ -2831,6 +2833,21 @@ const m0ResearchLedgerWrites = m0LedgerPutOptions.filter((entry) => (
28312833
));
28322834
assert.equal(m0ResearchLedgerWrites.length, 2);
28332835
assert.ok(m0ResearchLedgerWrites.every((entry) => entry.options?.expirationTtl === 14 * 24 * 60 * 60));
2836+
const m0ExactSourceReplay = await worker.fetch(
2837+
new Request("https://switch.example/api/internal/sync-m0-research-ledger", {
2838+
method: "POST",
2839+
headers: { Authorization: `Bearer ${m0ResearchSyncValue}`, "Content-Type": "application/json" },
2840+
body: JSON.stringify(m0ResearchEnvelope),
2841+
}),
2842+
m0ResearchEnv,
2843+
);
2844+
assert.equal(m0ExactSourceReplay.status, 200);
2845+
const m0ExactSourceReplayPayload = await m0ExactSourceReplay.json();
2846+
assert.equal(m0ExactSourceReplayPayload.replayed, true);
2847+
assert.equal(m0ExactSourceReplayPayload.no_order, true);
2848+
assert.equal(m0LedgerPutOptions.filter((entry) => (
2849+
entry.key === "m0_research_ledger_current" || entry.key.startsWith("m0_research_ledger_archive:")
2850+
)).length, 2);
28342851
const m0ResearchRead = await worker.fetch(
28352852
new Request("https://switch.example/api/m0-research", { headers: m0ResearchCookieHeaders }),
28362853
m0ResearchEnv,

web/strategy-switch-console/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,8 @@ Add the returned namespace id to `wrangler.toml`.
213213

214214
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.
215215

216+
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`.
217+
216218
Deploy:
217219

218220
```bash

web/strategy-switch-console/README.zh-CN.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,10 @@ JSON 计算并验证 `ledger_sha256`;`source_artifact.sha256` 是已认证的
124124
不超过 262,144 bytes;Worker 保留同一请求体上限作为接收端边界,不另行转换或放宽该契约。
125125

126126
Worker 固定使用 `m0_research_ledger_current` 和由已校验 digest 派生的
127-
`m0_research_ledger_archive:<ledger_sha256>`;调用方不能传入 KV key。current 记录检测到
128-
重复 artifact/ledger、相同 source run ID 或 ledger 时间回退会返回 `409`。这是基于 KV
127+
`m0_research_ledger_archive:<ledger_sha256>`;调用方不能传入 KV key。若来件的
128+
`source_artifact.sha256` 与当前记录完全相同,Worker 返回 `200``replayed: true`,且不写入
129+
KV,用于网络重试或重复人工触发。不同 source artifact 的相同 source run ID、不同来源的重复
130+
ledger 或时间回退仍会返回 `409`。这是基于 KV
129131
当前记录的 **best-effort** 重放/回退保护:Cloudflare KV 不是线性一致的比较并交换存储,
130132
并发写入仍不能被表述为强原子顺序保证。本接口不为此新增 Durable Object 或其他绑定;它的
131133
职责仍限于 no-order 研究资料接收。current 与 archive 均使用 Cloudflare KV 的 14 天物理

web/strategy-switch-console/worker.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2259,6 +2259,23 @@ async function syncM0ResearchLedgerResponse(request, env) {
22592259

22602260
const current = await readCurrentM0ResearchLedgerRecord(env);
22612261
if (current) {
2262+
// A retry for the exact immutable source is not a new decision or a
2263+
// second write. Acknowledge it so a lost response cannot make a valid
2264+
// no-order publication look failed, while retaining the guards below.
2265+
if (envelope.source_artifact.sha256 === current.envelope.source_artifact.sha256) {
2266+
return json({
2267+
ok: true,
2268+
schema_version: current.envelope.schema_version,
2269+
source_repository: current.envelope.source_artifact.repository,
2270+
source_revision: current.envelope.source_artifact.revision,
2271+
source_run_id: current.envelope.source_artifact.run_id,
2272+
artifact_id: current.envelope.source_artifact.artifact_id,
2273+
ledger_sha256: current.envelope.ledger_sha256,
2274+
replayed: true,
2275+
no_order: true,
2276+
expires_at: current.expires_at,
2277+
});
2278+
}
22622279
const replayError = m0ResearchLedgerReplayError(current.envelope, envelope);
22632280
if (replayError) return json({ ok: false, error: replayError }, 409);
22642281
}
@@ -2308,6 +2325,7 @@ async function syncM0ResearchLedgerResponse(request, env) {
23082325
source_run_id: envelope.source_artifact.run_id,
23092326
artifact_id: envelope.source_artifact.artifact_id,
23102327
ledger_sha256: envelope.ledger_sha256,
2328+
replayed: false,
23112329
no_order: true,
23122330
expires_at: expiresAt,
23132331
});

0 commit comments

Comments
 (0)