Skip to content

Commit 4ee23d9

Browse files
Pigbibicodex
andauthored
fix: add durable atomic execution claims (#340)
* feat: define runtime selectable allowlist contract Co-Authored-By: Codex <noreply@openai.com> * ci: pin QPK sync action dependencies Co-Authored-By: Codex <noreply@openai.com> * fix: add atomic durable execution claims Co-Authored-By: Codex <noreply@openai.com> * test: prove durable claims fail closed and single winner Co-Authored-By: Codex <noreply@openai.com> * fix: implement atomic create for cloud object stores Co-Authored-By: Codex <noreply@openai.com> * test: keep risk snapshot fixture unexpired in CI Co-Authored-By: Codex <noreply@openai.com> --------- Co-authored-by: Codex <noreply@openai.com>
1 parent 3fac34b commit 4ee23d9

13 files changed

Lines changed: 196 additions & 6 deletions

.github/workflows/open-downstream-qpk-pin-prs.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ jobs:
2727
runs-on: ubuntu-latest
2828
timeout-minutes: 30
2929
steps:
30-
- uses: actions/checkout@v6
30+
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
3131

32-
- uses: actions/setup-python@v6
32+
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.1.0
3333
with:
3434
python-version: "3.11"
3535

.github/workflows/update-qpk-pin.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ jobs:
2626
timeout-minutes: 15
2727
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
2828
steps:
29-
- uses: actions/checkout@v6
29+
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
3030

31-
- uses: actions/setup-python@v6
31+
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.1.0
3232
with:
3333
python-version: "3.11"
3434

@@ -79,7 +79,7 @@ jobs:
7979
id: create_pin_pr
8080
if: steps.update.outputs.changed == 'true' && steps.verify.outcome == 'success'
8181
continue-on-error: true
82-
uses: peter-evans/create-pull-request@v7
82+
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.8
8383
with:
8484
token: ${{ secrets.QSL_REPO_SYNC_TOKEN || github.token }}
8585
commit-message: "chore: advance staged QPK pin"

docs/qpk_repo_sync_auth.zh-CN.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
`open-downstream-qpk-pin-prs` workflow 需要 `QSL_REPO_SYNC_TOKEN` 才能跨仓 push 分支并开 PR。
44

5+
## 当前发布链说明
6+
7+
QPK 发布采用 release-set 两阶段流程:先生成候选 QPK 版本并验证依赖闭包,再由下游仓库分别通过 CI 后更新正式 release-set。旧的 `auto/qpk-pin-sync-*` 和孤立的 `auto/qpk-pin-update` 分支不再作为发布输入;如果发现没有对应开放 PR 的孤立自动分支,应删除后再重新触发发布流程。
8+
9+
跨仓同步 token 只用于创建下游 PR,不用于部署、运行时交易或修改任何 broker 权限。优先使用仅限目标仓库的 fine-grained token;不要把个人长期 token 写入仓库文件、workflow 日志或 issue。
10+
511
## 为什么用仓库级 Secret(不是 Org Secret)
612

713
`QuantPlatformKit`**public** 仓库。在 GitHub Free org 上,org-level secret 对 public 仓的注入不可靠(workflow 里会静默变成空字符串)。**仓库级 secret** 是唯一已验证稳定的方案。
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Runtime-selectable allowlist v1
2+
3+
`runtime-selectable allowlist` 描述某个平台在当前部署中可以选择的策略 profile;它不等于策略生命周期状态,也不授予 live 权限。
4+
5+
## 边界
6+
7+
- catalog 负责描述策略生命周期和证据状态;
8+
- allowlist 负责描述 runtime 可选择的 profile;
9+
- authority policy、Risk Gate 和 broker permission 负责决定是否可以执行;
10+
- 缺少 allowlist、证据或 authority 时,profile 必须保持 `PARKED`/`live_candidate`,不能自动执行。
11+
12+
```json
13+
{
14+
"schema": "qsl.runtime_selectable_allowlist.v1",
15+
"platform": "example",
16+
"domain": "us_equity",
17+
"profiles": ["example_profile"],
18+
"source_digest": "<sha256>",
19+
"generated_at": "<utc>",
20+
"permission_effect": "none"
21+
}
22+
```
23+
24+
迁移期间,旧 `get_runtime_enabled_profiles()` 只能作为一次性读取适配器;新代码不得继续写入旧 status 字段。迁移完成后,各 broker/runtime 应只消费本 allowlist,并独立校验 authority、Risk Gate、回滚引用和 broker 权限。

src/quant_platform_kit/cloud/aws_provider.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,24 @@ def write_text(self, uri: str, data: str, content_type: str = "text/plain") -> s
115115
self.client.put_object(Bucket=bucket, Key=key, Body=data.encode("utf-8"), ContentType=content_type)
116116
return uri
117117

118+
def create_text(self, uri: str, data: str, content_type: str = "text/plain") -> bool:
119+
"""Create an object only if it does not already exist."""
120+
bucket, key = self._parse_uri(uri)
121+
try:
122+
self.client.put_object(
123+
Bucket=bucket,
124+
Key=key,
125+
Body=data.encode("utf-8"),
126+
ContentType=content_type,
127+
IfNoneMatch="*",
128+
)
129+
return True
130+
except self.client.exceptions.ClientError as exc:
131+
code = exc.response.get("Error", {}).get("Code")
132+
if code in {"PreconditionFailed", "412", "ConditionalRequestConflict"}:
133+
return False
134+
raise
135+
118136
def write_bytes(self, uri: str, data: bytes, content_type: str = "application/octet-stream") -> str:
119137
bucket, key = self._parse_uri(uri)
120138
self.client.put_object(Bucket=bucket, Key=key, Body=data, ContentType=content_type)

src/quant_platform_kit/cloud/azure_provider.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,23 @@ def write_text(self, uri: str, data: str, content_type: str = "text/plain") -> s
161161
)
162162
return uri
163163

164+
def create_text(self, uri: str, data: str, content_type: str = "text/plain") -> bool:
165+
"""Create a blob only if it does not already exist."""
166+
_, container, blob = self._parse_uri(uri)
167+
try:
168+
self._get_blob_client(container, blob).upload_blob(
169+
data.encode("utf-8"),
170+
overwrite=False,
171+
content_settings={"content_type": content_type},
172+
)
173+
return True
174+
except Exception as exc:
175+
from azure.core.exceptions import ResourceExistsError
176+
177+
if isinstance(exc, ResourceExistsError):
178+
return False
179+
raise
180+
164181
def write_bytes(self, uri: str, data: bytes, content_type: str = "application/octet-stream") -> str:
165182
account, container, blob = self._parse_uri(uri)
166183
self._get_blob_client(container, blob).upload_blob(

src/quant_platform_kit/cloud/gcp_provider.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,17 @@ def write_text(self, uri: str, data: str, content_type: str = "text/plain") -> s
128128
self.client.bucket(bucket).blob(blob).upload_from_string(data, content_type=content_type)
129129
return uri
130130

131+
def create_text(self, uri: str, data: str, content_type: str = "text/plain") -> bool:
132+
from google.api_core.exceptions import Conflict, PreconditionFailed
133+
bucket, blob = self._parse_uri(uri)
134+
try:
135+
self.client.bucket(bucket).blob(blob).upload_from_string(
136+
data, content_type=content_type, if_generation_match=0
137+
)
138+
return True
139+
except (Conflict, PreconditionFailed):
140+
return False
141+
131142
def write_bytes(self, uri: str, data: bytes, content_type: str = "application/octet-stream") -> str:
132143
bucket, blob = self._parse_uri(uri)
133144
self.client.bucket(bucket).blob(blob).upload_from_string(data, content_type=content_type)

src/quant_platform_kit/cloud/local_provider.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,19 @@ def write_text(self, uri: str, data: str, content_type: str = "text/plain") -> s
108108
path.write_text(data)
109109
return uri
110110

111+
def create_text(self, uri: str, data: str, content_type: str = "text/plain") -> bool:
112+
path = self._to_local_path(uri)
113+
path.parent.mkdir(parents=True, exist_ok=True)
114+
try:
115+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
116+
except FileExistsError:
117+
return False
118+
with os.fdopen(fd, "w") as handle:
119+
handle.write(data)
120+
handle.flush()
121+
os.fsync(handle.fileno())
122+
return True
123+
111124
def write_bytes(self, uri: str, data: bytes, content_type: str = "application/octet-stream") -> str:
112125
path = self._to_local_path(uri)
113126
path.parent.mkdir(parents=True, exist_ok=True)

src/quant_platform_kit/cloud/ports.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ def write_text(self, uri: str, data: str, content_type: str = "text/plain") -> s
8383
"""写入文本对象,返回 URI。"""
8484
...
8585

86+
def create_text(self, uri: str, data: str, content_type: str = "text/plain") -> bool:
87+
"""仅创建文本对象;对象已存在时返回 False。"""
88+
...
89+
8690
def write_bytes(self, uri: str, data: bytes, content_type: str = "application/octet-stream") -> str:
8791
"""写入二进制对象,返回 URI。"""
8892
...

src/quant_platform_kit/common/execution_state.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import json
6+
import os
67
import re
78
import tempfile
89
from collections.abc import Callable, Mapping
@@ -161,6 +162,36 @@ def record_marker(
161162
path.parent.mkdir(parents=True, exist_ok=True)
162163
path.write_text(encoded, encoding="utf-8")
163164

165+
def claim_marker(self, marker_key: str, *, metadata: Mapping[str, Any] | None = None) -> bool:
166+
"""Atomically reserve an execution identity before broker submission."""
167+
if not str(marker_key or "").strip():
168+
raise ValueError("execution claim requires a marker key")
169+
payload = json.dumps({
170+
"schema_version": "execution_claim.v1",
171+
"marker_key": str(marker_key),
172+
"claimed_at": datetime.now(timezone.utc).isoformat(),
173+
"state": "claimed",
174+
"metadata": dict(metadata or {}),
175+
}, ensure_ascii=False, indent=2, sort_keys=True)
176+
if self.cloud_prefix_uri:
177+
create = getattr(self._object_store(), "create_text", None)
178+
if not callable(create):
179+
raise RuntimeError("cloud execution store lacks atomic create-only support")
180+
return bool(create(self._cloud_uri(marker_key), payload, "application/json"))
181+
if self.local_dir:
182+
path = self._local_path(marker_key)
183+
path.parent.mkdir(parents=True, exist_ok=True)
184+
try:
185+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
186+
except FileExistsError:
187+
return False
188+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
189+
handle.write(payload)
190+
handle.flush()
191+
os.fsync(handle.fileno())
192+
return True
193+
raise RuntimeError("execution state store has no durable claim backend")
194+
164195
def has_prior_execution_report(
165196
self,
166197
*,

0 commit comments

Comments
 (0)